Skip to main content

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.

Gantt construction sequence Sequence diagram of Gantt construction from harness through GanttShell to Viewport and scale/scroll models, showing 20 steps from container resolve to first frame flush. harness main.ts Gantt api/gantt.ts GanttShell view/gantt-shell.ts domBackend render/dom/index.ts Viewport layout/viewport/ Scale + Scroll the two shareable models 1 new Gantt({container, dataset, …options}) 2 new GanttShell(…) 3 resolveContainer — throws ContainerNotFoundError 4 ensureBaseStyles; new PaneLayout → grid / splitter / timeline 5 new Viewport({ scale?, scroll?, overscan? }) 6 private defaults if omitted 7 createDomBackend() 8 mount({ grid, timeline }) 9 attachScroll(timeline pane, viewport) 10 bind(dataset, onChange) 11 scale.bind, then scroll.bind 12 onChange() ×2 — one from each bind, not batched dropped by #phase: no frame request, no navigationChange 13 subscribeToDatasetChanges — later commits request a frame 14 #applyPaneMeasurement(measureTimelinePane()) re-reads the four --fg-* pixel properties, then sizes the rows' viewport 15 setPaneSize(pane box − header height) 16 attachPaneSize(timeline); attachSplitter(splitter) 17 resolveCapabilities; new GesturePipeline + attachEntryGestures 18 new TreeCollapse; attachKeyboardNavigation; attachWheelNavigation 19 datasetPlugins; plugins; zoomPresets; selection — all before frame 1 20 #phase = 'live'; #frames.flush() → §2. One layout pass (measured).
Diagram 1 — 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:

src/layout/viewport/bound-value.ts
// 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:

src/layout/viewport/viewport.ts
// 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:

src/layout/viewport/viewport.ts
// 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.

Why bind notifies three times and setPaneSize notifies once Two rows. The top row shows bind calling bindTimeScale and bindScrollAxis for x and for y, each firing its own notification, so the shell's onChange runs three times. The bottom row shows setPaneSize wrapping the same three calls in one batch, so onChange runs once. Viewport.bind(dataset, onChange) — no batch around the three bindTimeScale(scale, …, #notify) bindScrollAxis(scroll.x, …, #notify) bindScrollAxis(scroll.y, …, #notify) #notify() delivers #notify() delivers #notify() delivers GanttShell onChange() runs three times one caller-visible event, three deliveries handle.setPaneSize(size) — one batch around the same three #notifications.batch(() => { scaleHandle.setPaneWidth(w) scrollHandleX.setPaneSize(w) scrollHandleY.setPaneSize(h) #notify() delivers GanttShell onChange() runs once the batch flushes at its end, once, iff anything moved
Diagram 2 — the same three sub-models, reached two ways. Amber = the unbatched three at step 12.

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:

src/view/gantt-shell.ts
// 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​

StepThe constraint that forces this position
4Styles 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–8Mount 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.
9Before bind, so #scrollAttachment is never undefined during a render. The timeline pane is the single native scroller.
10–12bind() 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–15One 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–18Capabilities 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–20Plugins 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.

One render pass Data flow of one render pass from GanttShell through computeFrame to the DOM backend sync. GanttShell.render() view/ — where DOM meets pure LayoutInput ← #frameSettings   .toLayoutInput(perFrame) this frame contributes: entries · scale · preset visible · overscan · revision columns · collapsed variants · datasetRevision decorationProviders FrameSettings holds: rowHeight · tickBoxFloorPx minBarWidthPx · barHeightPx rows · todayLine · dateLines locale? · fieldCompares fieldContext? FrameLayout .computeFrame(input) FrameMemory RowHeightIndex + per-row Bar memo reused across renders when inputs did not change computeFrame(input, heights) 1) resolveRows — per rowSource 2) produceBars — per variant 3) cull v + h to the window 4) header ticks; date-lines rows → bars, in order GeometryFrame revision · visible: Rect header.bands[] tickLines[] ← windowed rows[] ← windowed rowCount · tree ← FULL columns[] ← resolved bars[] ← produced links[] · decorations[] underBars[] · overBars[] contentWidth ← FULL contentHeight ← FULL plain numbers only — no DOM, no consumer output, no hit-region index domBackend.sync(frame) no allocation beyond geoms sync() keyed layers header bands + ticks grid header columns rows + cells; row bands bars (by id); links tick lines; date lines decorations under + over cursor line; grid translateY contentSizer.transform gives the pane its scroll range #viewportHandle.setContentSize(…) → Viewport.#contentSize → ScrollAxis binding.content FrameScheduler.request() max changed → notify → shell requests a later frame not nested in this pass scrollAttachment.writePosition() element.scrollLeft/Top ← visible.x/y, ε-filtered the shell's own tail, in order gridPattern · setGridSize — both before sync() band count moved → re-size the rows' viewport then setContentSize · writePosition · rovingFocus GesturePipeline.preview() class toggles + transforms never builds a frame (I13) This pass is synchronous. A content-size notify schedules the next frame through FrameScheduler (rAF).
Diagram 3 — one 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:

  1. resolveRows turns the configured rowSource (entries, group, custom) into an ordered list of rows, each stamped with a sequential index (resolve-rows.ts).
  2. 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 first when that answers yes. Core's leaf carries no when, so every row resolves. A variant with no producer of its own draws one Bar over the Entry's whole span.
  3. Cull then trims to the visible window, and header/date-line emission closes the pass.

The coordinate rules computeFrame keeps​

  • Windowed vs. full. rows and bars hold only what survived culling; contentWidth/contentHeight are 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.height means "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. :::

Binding and notification topology Two Gantts sharing TimeScaleModel and both ScrollAxis instances, one per direction, showing BoundValue and BatchedNotifier layers. GanttShell A live: #frames.request() GanttShell B own container, own backend Viewport A #paneSize · #contentSize #overscan BatchedNotifier SINGLE-subscriber: a 2nd bind() throws Viewport B its own paneSize / contentSize / overscan Fan-in exists so view/ holds ONE reaction, not one per model onChange() onChange() TimeScaleModel — SHAREABLE intent: preset (dayPreset) · range ('fitDataset') BoundValue<ScaleBinding, TimeScaleOptions> resolve: zone · narrowest pane · min start/max end equals: zone + range.start + range.end + pxPerMs ScrollAxis ×2 (x, y) — SHAREABLE each owns ONE shared position, frozen on write BoundValue<ScrollAxisBinding, ScrollAxisState> resolve: max = loosest (content − pane), per axis equals: position + max BoundValue<B, V> — the shared mechanism #bindings: Map<B, onChange> ← ONE map, two jobs #resolved: V | undefined ← memo, identity-stable #notified: { value: V } | undefined ← boxed, so "never notified" ≠ any possible V BatchedNotifier: #depth, #pending, finally-flush both models delegate to bind · setPaneWidth ↩ #notify bind · setPaneSize setContentSize ↕ same two models a second binding each Sharing a TimeScaleModel syncs x. Sharing scroll.x or scroll.y syncs that axis only . Sharing a Viewport is an error — it holds ONE Gantt's own measured boxes. Copy-at-bind everywhere: each model keeps its OWN mutable copy of what a caller supplied, so a caller holding a reference cannot change inputs behind its back. The handle is the only mutator.
Diagram 4 — two Gantts sharing one 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​

ClassKnows aboutDeliberately does not know about
BatchedNotifiera depth counter, a pending flag, one deliver callbackbindings, 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 ruletime, scroll, pixels. It takes resolve and equals as a contract and has no opinion on either.
TimeScaleModel / ScrollAxiswhat to resolve from a binding set, and what counts as a changeeach other, and the DOM. They differ only in those two functions.
Viewportone Gantt's pane size, content size and overscan; fans all three models into one reactionhow 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.