Construction, render, notification
Three passes, in the order they run: what new Gantt(…) builds, what one render() does, and how
an edit reaches the screen. Counts on this page are measured, not estimated —
Maintaining holds the recipe that re-measures them.
Construction: the call sequence
new Gantt({ container, dataset }) forwards into one long GanttShell constructor. Everything
below happens synchronously, in this order, before that constructor returns. Beyond the diagram's
steps, the shell also builds the grid pane width, the container resize watch, the overlay and row
mount layers, the frame settings, the column chrome, the plugin registrations and their stylesheet,
the capability resolver, the gesture pipeline, the plugin runtime, the command registry, the keymap,
the collapse state, the entry selection, the roving focus, the live region, and the splitter,
row-twisty, keyboard and wheel navigation attachments.
Derived from harness/main.ts, api/gantt.ts, view/gantt-shell.ts,
layout/viewport/viewport.ts, layout/viewport/bound-value.ts, render/dom/index.ts,
view/capability.ts, view/gesture-pipeline.ts, view/tree-collapse.ts,
view/frame-settings.ts.
new Gantt(...), synchronous, top to bottom. Amber = the two bind notifies
that #phase drops; the section under this diagram explains why there are two. Steps
17–18 build the capability resolver, gesture pipeline, collapse state and navigation. Step 19
installs every constructor-supplied plugin, keybinding, command and variant, so all of them reach
frame 1.
Step 12: why one bind() delivers three notifications
The amber note says onChange() ×2. That surprises every new reader, so here is the whole story,
from the start. No knowledge of the code is assumed.
A Gantt does not own its time scale or its scroll position. Three small models own them.
TimeScaleModel answers "which instant sits at which pixel". A ScrollAxis answers "how far is
the content scrolled, and how far can it go" — for one direction; a Gantt holds two, scroll.x
and scroll.y . All three are shareable: two Gantts may bind to the same instance, and
that is how harness/e2e/scroll-sync.ts makes two Gantts pan together, on one axis or both. Viewport
is the fan-in over the three, so view/ holds one reaction instead of three.
Every binding follows one rule: bind always notifies the newcomer. When something binds, the model calls it straight back. That first call is not an update. It is the newcomer's first render, so it never waits for an open batch:
// The mechanism both models share.
bind(binding: Binding, onChange: () => void): BoundValueHandle {
this.#bindings.set(binding, onChange);
this.#resolved = undefined;
const changed = this.#recordResolved();
onChange(); // ← the newcomer hears about its own bind, always
…
}
One Viewport.bind() binds three models, and nothing wraps the three. Each call reaches
#notify, and each #notify delivers straight through to the shell:
// Inside bind().
const scaleHandle = bindTimeScale(this.scale, scaleBinding, this.#notify); // → onChange() #1
const scrollHandleX = bindScrollAxis(this.scroll.x, { content, pane }, this.#notify); // → onChange() #2
const scrollHandleY = bindScrollAxis(this.scroll.y, { content, pane }, this.#notify); // → onChange() #3
Compare that with every later write through the same handle. Each one wraps its calls in a batch, so one caller-visible change costs exactly one notification:
// The handle bind() returns.
setPaneSize: (size) => {
this.#paneSize = size;
this.#notifications.batch(() => { // ← one delivery at the end of the batch
scaleHandle.setPaneWidth(size.width);
scrollHandleX.setPaneSize(size.width);
scrollHandleY.setPaneSize(size.height);
});
},
So the count is not a mystery: bind() is the one path on the handle with no batch around it.
Nobody outside the shell ever sees those three calls. bind() runs in the constructor and
nowhere else, and the callback the shell passes in returns early for the whole of construction:
// Step 10 of the diagram above.
this.#viewportHandle = this.#viewport.bind(
{ entries: options.dataset.entries.all, timeZone: options.dataset.timeZone },
() => {
if (this.#phase === 'constructing') return; // ← all three bind-time calls stop here
this.#frames.request();
this.#emitNavigationChange();
},
);
Measured on a three-entry dataset: constructing a Gantt delivers three bind-time notifications
and runs one computeFrame. A fourth notification follows that first frame, from the
setContentSize at the end of render(). That one arrives after #phase is 'live', so it does
what a notification normally does — it asks for the next frame.
Why the order is what it is
| Step | The constraint that forces this position |
|---|---|
| 4 | Styles and panes before paint. ensureBaseStyles must run before PaneLayout inserts classed elements, or the first frame is unstyled. mount needs the grid and timeline panes that PaneLayout builds. |
| 7–8 | Mount before bind. Each model's bind notifies the newcomer at once. Those calls are dropped while wiring, but the render target must already exist for the deliberate first flush() at step 19. |
| 9 | Before bind, so #scrollAttachment is never undefined during a render. The timeline pane is the single native scroller. |
| 10–12 | bind() always notifies the newcomer, once per sub-model, with no Viewport batch around the three — the section below walks through why that is three. Those three onChanges land before #viewportHandle is assigned. #phase drops them, so none asks for a frame and none emits navigationChange. The first real frame is step 20. |
| 14–15 | One synchronous measurement, because a real ResizeObserver's first callback is queued, not immediate. The viewport gets the rows' box, not the pane box: the header sticks to the pane's top and covers that band of rows for the whole scroll, so the measured header height comes off the height. Reporting the full box left the scroll model one header short, and the last row could then never scroll fully into view. All four --fg-* pixel properties are re-read here, not per render. |
| 17–18 | Capabilities and gestures after the viewport. The gesture pipeline needs the viewport's scale for draft math, and the capability resolver needs the consumer's interactions options which the shell has by then. Keyboard and wheel navigation sit at the end so the elements they attach to exist. |
| 19–20 | Plugins before the first frame . A constructor-supplied plugin's variant, keybinding, command or selection has to reach frame 1, so the shell applies all of them through its own live setters, then flushes. theme is the one setting applied after the flush. |
One render pass
GanttShell.render() builds one LayoutInput — what this frame contributes, merged with what
FrameSettings holds — runs one pure function, hands the frame to the backend, then pushes the
resulting extents back into the viewport.
Derived from view/gantt-shell.ts render(), view/frame-settings.ts, layout/frame.ts,
layout/frame-layout.ts, layout/rows/*, layout/bars/produce-bars.ts,
layout/bars/variants.ts, render/dom/index.ts sync(), view/scroll-attachment.ts.
render(). Green = pure, blue = DOM. A content-size notify requests a
later frame; it does not re-enter this pass. The hot gesture path (bottom-left) never touches the
frame pipeline.
The pipeline inside computeFrame
The layout pass is not a flat row-per-entry walk. Between input and culling it resolves rows from a row source, then asks one registry what each Entry draws:
- resolveRows turns the configured
rowSource(entries, group, custom) into an ordered list of rows, each stamped with a sequential index (resolve-rows.ts). - produceBarsForRow turns each row's entries into
Bars. An Entry carries no stored classification, so nothing dispatches on a type tag. The variant registry resolves one variant per Entry, and that variant's own producer builds the Bars (bars/produce-bars.ts,bars/variants.ts). The walk runs newest-first — the consumer's rules, then a plugin's, then core's two — and stops at the firstwhenthat answers yes. Core'sleafcarries nowhen, so every row resolves. A variant with no producer of its own draws one Bar over the Entry's whole span. - Cull then trims to the visible window, and header/date-line emission closes the pass.
The coordinate rules computeFrame keeps
- Windowed vs. full.
rowsandbarshold only what survived culling;contentWidth/contentHeightare always the full extent. That split is what lets the scroll model compute a real maximum from a frame that drew twelve rows out of two thousand. - Zero disables culling. A zero
visible.heightmeans "no culling at all", not "an empty window" — and likewise for width. Without that rule an unmeasured container (detached,display:none, pre-paint) would silently render nothing. - Overscan is in index space vertically, pixels horizontally. Vertical overscan counts rows and goes through the height index, so it stays correct as row heights vary. Horizontal has no rows to count, so it is a pixel buffer.
- Rows are culled vertically only. A row whose bar is off-screen horizontally is still emitted — the grid pane needs its label.
The notification machine
Four classes, layered, each one strictly smaller than the one above. This is the most intricate
part of the codebase and the part most worth understanding before changing anything in
layout/viewport/.
Derived from layout/viewport/batched-notifier.ts, bound-value.ts, time-scale-model.ts,
scroll-axis.ts, viewport.ts.
:::note The one contract everything here implements Bind always notifies the newcomer; every other notification fires if and only if the resolved value actually changed. A newcomer's notification is its first render, so it does not wait for an open batch. A departing binding is never notified of its own departure; the survivors are, if the value moved without it. :::
TimeScaleModel and both
ScrollAxis instances (exactly what harness/e2e/scroll-sync.ts's both-axis
pair builds). Dashed = notification, solid = a call in.
The four layers, smallest first
| Class | Knows about | Deliberately does not know about |
|---|---|---|
BatchedNotifier | a depth counter, a pending flag, one deliver callback | bindings, values, what "changed" means. It is the whole batching mechanism and nothing else. |
BoundValue<B,V> | the binding set, the memoized resolved value, the last-notified value, and the notify-iff-changed rule | time, scroll, pixels. It takes resolve and equals as a contract and has no opinion on either. |
TimeScaleModel / ScrollAxis | what to resolve from a binding set, and what counts as a change | each other, and the DOM. They differ only in those two functions. |
Viewport | one Gantt's pane size, content size and overscan; fans all three models into one reaction | how any model resolves anything. It uses BatchedNotifier alone — it has one subscriber and no value of its own to compare, so BoundValue's comparison half would be a capability it must not have. |
Where the batches nest
Viewport.batch(run)
└─ Viewport.#notifications.batch( // coalesce the consumer reaction
scale.batch( // coalesce within TimeScaleModel
scroll.x.batch( // coalesce within ScrollAxis x
scroll.y.batch(run)))) // coalesce within ScrollAxis y
Viewport.setPaneSize(size) // one measurement, three models
└─ #notifications.batch(() => {
scaleHandle.setPaneWidth(size.width) // may notify
scrollHandleX.setPaneSize(size.width) // may notify
scrollHandleY.setPaneSize(size.height)// may notify
}) // → at most ONE render()
Without Viewport's own batching layer, a single resize would deliver three notifications for one
caller-visible change: the three models dedupe within themselves, but none knows the others were
touched by the same call.