Skip to main content

Class map, layer by layer

What each class owns, what it exposes, and who calls it — grouped by layer, DOM-free layers first. For when these run, read Lifecycle; for the file each one lives in, read File inventory.

layout/ & view/​

Derived from src/layout/**, src/view/**.

layout/ — pure​

Viewport — class​

layout/viewport/viewport.ts

One Gantt's whole view state: which slice of content is on screen, at what zoom, with what buffer. The single object view/ holds.

  • scale / scroll — The two models, public and readonly — supplied, or private defaults.
  • bind(dataset, onChange) — Binds both models with one shared #notify. Throws FreeGanttError('viewport-already-bound') on a second call.
  • get visible: Rect — The culling window, clamped against this Gantt's extents — not the shared scroll.state.max.
  • get timeScale / preset — Resolved and ready for LayoutInput; the shell never reaches through to scale.scale.
  • get/set overscan — Live-reconfigurable, notifies iff actually changed. Not on public GanttOptions.
  • batch(run) — Nests all three notifiers.

TimeScaleModel — class​

layout/viewport/time-scale-model.ts

Shareable x-axis. Takes intent; derives zone, span and pixel density from whoever binds to it. Two Gantts, one instance ⇒ x-synced with no event plumbing.

  • #resolve(bindings) — One pass accumulating three things: zone (first binding wins), the narrowest measured pane, and — for range: 'fitDataset' — min start / max end across every bound dataset.
  • get scale: TimeScale — Memoized on the identity of the resolved options object.
  • bind() → ScaleBindingHandle — unbind(), setPaneWidth(w). Copy-at-bind.

ScrollAxis — class​

layout/viewport/scroll-axis.ts

Shareable one-direction scroll position. Owns one shared position for one axis; each bound Gantt clamps it locally to its own content. A Gantt holds two, { x, y } — sharing an instance as one Gantt's x and another's x syncs that direction only.

  • panTo(position) — Clamps to [0, max] at write time and nowhere else.
  • get state: ScrollAxisState — { position, max, bindingCount } together, frozen.
  • bindScrollAxis(axis, binding, onChange) → ScrollAxisBindingHandle — free function, not a class method: unbind(), setContentSize(), setPaneSize().

BoundValue<B, V> — class​

layout/viewport/bound-value.ts

The binding side both models share. Scoped to layout/viewport/ on purpose — it must not grow into a general reactivity primitive.

  • #bindings: Map<B, () => void> — One collection for both jobs: iterate keys to resolve, values to notify.
  • #notified: { value: V } | undefined — Boxed so "nothing has been notified yet" is distinguishable from every possible V.

FrameLayout — class​

layout/frame-layout.ts

The stateful wrapper that keeps the row-height index and per-row Bar memo alive across renders. The shell states what to draw; the memory never crosses into view/.

  • #memory: FrameMemory — Holds one layout pass's cross-render memory — the RowHeightIndex plus the per-row item memo Map.

FrameMemory — class​

layout/frame-memory.ts

The cross-render memory of one layout pass. Kept behind FrameLayout so a later frame reuses row-height sums and produced items where the inputs did not change.

  • RowHeightIndex — Lazy prefix sums with binary search for indexAtY.
  • Map of per-row item memo — Per-row produced items, reused when the row's content is unchanged.

PrefixSumHeightIndex — class​

layout/row-height-index.ts

Lazy prefix sums, cached, with binary search for indexAtY. #validUpTo tracks how far the sums are computed, so a partial invalidation recomputes only the suffix.

  • topAt(i) / totalHeight — Fill the cache up to what was asked and no further.
  • indexAtY(y) — Binary search. This is what bounds computeFrame's row scan.
  • heightAt / invalidateFrom — Interface complete, zero production callers today.

computeFrame() — function​

layout/frame.ts

The full layout pass. Pure, stateless. Resolves rows → produces items → culls to the window → emits header ticks and date-line decorations.

  • heights = new PrefixSumHeightIndex(…) — A default parameter, so a caller with nothing to remember gets an index built and discarded within the call. FrameLayout is the only production caller that passes one.
  • overflowCount — Counts rows already emitted past the window bottom and stops after verticalRows of them.

produceBarsForRow() — function​

layout/bars/produce-bars.ts

Per-row bar production. 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. Header rows produce none.

  • resolveBars(entry, registry) — One resolution, one producer call, so no losing candidate's Bars are ever built and thrown away. A variant with no producer of its own draws one Bar over the Entry's whole span, so this never answers "nothing" for a variant the registry knows.
  • VariantRegistry.resolveFor(entry) — Walks newest-first: the consumer's rules, then a plugin's, then core's two. It stops at the first when that answers yes. Core's leaf carries no when, so every row resolves. Structure comes off the Entry itself (entry.hasChildren) — never an if (kind === …) chain.

resolveRows() — function​

layout/rows/resolve-rows.ts

Dispatches to the correct row source based on source.source: entries (flat or tree), group (groupBy buckets), or custom (consumer resolve() callback). Each resolved row gets a sequential index.

  • EntriesRowSource — Flat maps each entry one-to-one; tree does a depth-first walk using parentId (entries-source.ts).
  • GroupRowSource — Buckets entries by a consumer groupBy, emitting header rows then members (group-source.ts).
  • CustomRowSource — Adapts a consumer CustomRow[] (custom-source.ts).

draftForMove() / draftForResize() — function​

layout/gesture-draft.ts

Pure gesture math for drag previews — draftForMove, draftForResize, previewOffsets, cursorLabelForX. All date computation stays here so interaction/ performs no arithmetic.

resolveDateLines() — function​

layout/date-line.ts

Resolves the today-line and authored date lines into positioned DateLine decorations, consumed by the render backend.

ResolvedColumn — types​

layout/column.ts

Pure data types for the grid-column paint shape (FrameColumn, ResolvedColumn, FieldCompare) and its locale-bound formatter. Carries presentation only — grid columns show fields; they do not declare them.

view/ — DOM​

GanttShell — class​

view/gantt-shell.ts

The composition root, and the only class in src/ that holds references to the backend, viewport, layout, gesture pipeline, plugin runtime, collapse state, and the event bus.

  • #phase: 'constructing' | 'live' — 'constructing' until pane-size wiring completes; drops the two synchronous onChanges from scale.bind and scroll.bind and short-circuits premature render() calls. Becomes 'live' when wiring is done.
  • #applyPaneMeasurement(size) — One measurement pushed to everything it feeds.
  • render() — Gather → computeFrame → backend.sync → setContentSize → writePosition. One pass per call.
  • live properties — Every config key a live-reconfigurable property: preset, range, gridColumns, rowSource, collapsed, selection, locale, theme, todayLine, interactions, plugins, commands, viewportGestures, …
  • installPlugin / uninstallPlugin — Forwards to PluginRuntime. One occupant per plugin id.
  • destroy() — Idempotent. Detaches the attachments, unbinds the viewport, destroys the backend, clears the container.

attachScroll() — function​

view/scroll-attachment.ts

The one file exempted from no-scroll-outside-scroll-attachment. Owns no binding — it only reads viewport.visible and reads/writes the element.

  • EPSILON = 1 — Tolerates fractional scrollTop and filters the echo so a model-driven write does not bounce back as another panTo.
  • writePosition() — Called by render() after backend.sync().

attachPaneSize() — function​

view/pane-size-attachment.ts

The only file that observes element size. Reports a box and stops — no pixel math, no scale awareness, preserving the separation between viewport and geometry concerns.

  • entries[entries.length - 1] — A resize burst can deliver several entries for one target; only the last reflects the settled box.

attachSplitter() — function​

view/splitter.ts

Pointer-drag handler on the splitter chrome that resizes the grid pane relative to the timeline pane. Dispatches through the shell's gridWidth live property.

ensureBaseStyles() — function​

view/styles.ts

Idempotently injects the library's base stylesheet once per document, scoped to the --fg-* token set plus the prefers-color-scheme dark arm. A consumer overrides tokens in their own CSS; they never edit this sheet.

FrameScheduler — class​

view/frame-scheduler.ts

Coalesces render requests into at most one requestAnimationFrame per tick. The throttle between "a change happened" and "a frame drew"; keeps bursty commits off the hot path.

subscribeToDatasetChanges() — function​

view/dataset-change-subscription.ts

Bridges the dataset's change event into the shell's render pipeline — FrameScheduler.request(). Editing after mount renders on the next frame with no extra call.

resolveCapabilities() — function​

view/capability.ts

Merges the consumer's Interactions overrides with the per-kind default table and returns a Capabilities object with can(capability, entry). One resolution gates both gestures and affordances.

projectAffordances() — function​

view/affordance-projection.ts

Pure projection of hovered/movable/resizable paint tokens from hover, selection, and capability resolution. Feeds the hot path's class toggles — never a frame rebuild.

GesturePipeline — class​

view/gesture-pipeline.ts

Owns the full gesture lifecycle for move/resize — entry resolution, draft math from layout/gesture-draft.ts, preview rAF coalescing, snap resolution, and a commit pipeline with sync/async veto. Implements EntryGestureContext for interaction/ to drive.

TreeCollapse — class​

view/tree-collapse.ts

Holds collapsed RowIds as per-Gantt view state, plus the tree-arrow and ancestor-expand policy. Propose/commit two-step, so a beforeCollapseChange veto can cancel the commit. The payload type CollapseChange lives beside it in view/collapse-state.ts.

attachRowTwisty() — function​

view/attach-row-twisty.ts

Grid-pane click on a row twisty toggles collapse. Lives here, not in interaction/: collapse is viewport state, not a data gesture. Delegates hit testing to render/dom/row-twisty.ts's rowIdFromTwistyClick.

attachKeyboardNavigation() / attachWheelNavigation() — functions​

view/keyboard-navigation.ts · view/wheel-navigation.ts

Keydown handler for viewport navigation when nothing is selected; ctrl/cmd+wheel zoom and shift+wheel pan via resolveViewportGestures()'s on/off flags.

EventBus / GanttEventMap — types​

view/event-bus.ts

Declares the ten-plus Gantt event names and payload types (entryMove, selectionChange, collapseChange, navigationChange, …) and re-exports the generic EventBus mechanism from data/event-bus.ts.

EntryGestureContext — interface​

view/entry-gesture-context.ts

The type-seam between view/ (the shell realises it) and interaction/ (which drives it). interaction/ names pointer events and session lifecycle only — no layout or arithmetic.

time/ & model/​

Derived from src/time/**, src/model/**.

The time pipeline​

Everything that turns a date into a pixel goes left to right through here. Nothing outside time/ is allowed to shortcut it.

The time subsystem Pipeline from temporal-polyfill through zone operations to scale and preset resolution. temporal-polyfill/fns Instant · PlainDate · ZonedDateTime One of exactly TWO runtime deps. Confined to zone.ts — no other file may import it. time/zone.ts UNITS: Record<TimeUnit, UnitOps> ms addMs identity m addMs·MINUTE startOfMinute h addMs·HOUR startOfHour d addDays startOfDay w addDays×7 startOfWeek M/y addMonths/Years startOfMonth/Year stepBy() ↑step startOf() ↑floor One table ⇒ the two can never disagree on which units are supported. time/scale.ts — createTimeScale xForInstant(i) → px instantForX(x) → Instant widthForDuration(d, at) ticks(step, span) → Tick[] contentWidth instantForX ROUNDS: pixels do not divide evenly into ms, and Temporal rejects a fractional epoch ms. ticks() emits the cell COVERING span.x even when its own x is left of it. ViewPreset ×9 ZOOM_PRESETS ladder fine hour → coarse year plus 5 single-band ids Deep-FROZEN: a shipped preset is a value, not a shared mutable singleton. pxPerMsForPreset(zone, preset, at) preferredTickWidthPx / tick's real ms The zoom a preset implies ON ITS OWN — what a scale resolves to when there is no measured viewport to fit into. An unmeasured container is therefore not a special case needing an invented minimum width. Calendar stepping is not 86 400 000 A day tick is 23 or 25 hours across a DST transition. Jan 31 + 1 month clamps to Feb 28. DST fold and gap resolve via Temporal's explicit 'compatible' rule — the earlier offset for an ambiguous time, shift-forward for a nonexistent one. Both were undefined before. diffDays goes through PlainDate temporal-polyfill@1.0.4's ZONED day-unit diff throws for EVERY zone — a packaging bug in that build, not an environment quirk. PlainDate.diffDays is unaffected, and is exact here because both operands are already calendar day-starts. Re-check on upgrade.
Diagram 4 — time/. Amber = a vendored workaround with an expiry condition.

Two additions round out the layer without touching the pipeline above: time/presets.ts (the deep-frozen preset ladder this diagram references top-right) and time/snap.ts (where a drag's committed value snaps, pure and driven by the live preset).

model/ — pure​

Eighteen files, all types, plus the id helpers and the FreeGanttError family. That is the entire runtime — the layer exists so layout/, data/, render/ and view/ share one vocabulary without depending on each other.

TypeShapeThe rule it encodes
Instantnumber & {__brand}Epoch ms. Branded so a naked number cannot be passed as a date by accident.
TimeSpan{ start, end }Half-open [start, end) in storage. Display is inclusive, via one formatting helper — never an inline end - 1.
EntryId / RowId / BarId / ChangeSetIdstring & {__brand}Four distinct brands over string, so a row id cannot be used where a bar id belongs.
Entry{ id, name?, start?, end?, read(), hasChildren, children(), parent(), descendants(), depth, toInput() }The authored record, and it answers questions about itself. No stored classification: an Entry derives when it has children. start/end are absent together when it does not span; a spanning Entry draws one Bar, on the row its parentId names. read(key) is the one by-key value door — a core key, a props key, or a compute Field.
Field{ key, type?, rollUp?, editable?, column?, … } or { key, compute, … }What a value is. A core field and a consumer field share one declaration shape, so one code path serves both. The union is exclusive: a stored Field may roll up and may be edited; a compute Field may do neither and has no home; it runs on every row, a parent included. 'compute' in field is the one test that separates them. Field.column is optional defaults for the bare-key shorthand.
ChangeSet{ added, removed, updated }The one write shape emitted on change, with updated carrying { entryId, field, from, to } per field. What undo and redo replay.
Dataset{ entries, timeZone }The structural contract. api/dataset.ts's class implements it, which is what lets layout/ bind against a dataset without an illegal import.
Rect / Size / Point / PixelSpanreadonly numbersOne geometry vocabulary for four layers.
InstantInputInstant | Date | number | stringThe input twin of Instant: what a consumer may write where the library stores an Instant. A string is either absolute (explicit Z or numeric offset) or a Plain time that names no instant until the Dataset's zone resolves it.
EntryInput / TimeSpanInputthe loose twinsSame fields, looser types: plain-string ids, InstantInput dates. Because EntryId is string & brand and Instant is number & brand, a stored Entry is itself a valid EntryInput — the pass-through case costs nothing.

render/ & api/​

Derived from src/render/**, src/api/**, harness/main.ts.

Reading what a consumer writes: EntryInput → Entry​

Derived from src/model/time.ts, src/time/input.ts, src/data/entry-reader.ts.

The library stores branded, absolute values; a consumer writes loose ones. Exactly one crossing turns the second into the first, and it happens once, in the Dataset constructor.

Consumer input to stored entries How EntryInput becomes a stored Entry via toEntries and time conversion what the consumer writes { id: 'a', name: 'Survey', start: '2026-09-01', end: '2026-09-08' } EntryInput — plain strings, no brands, no zone api/dataset.ts new Dataset({ entries, timeZone }) Resolves each Entry's dates in the dataset zone, then calls toEntries ONCE at construct, and again on every add/update. data/entry-reader.ts toEntries(inputs, context) Field mapping ONLY: applies the entryId brand, copies optionals only when present. Anything here resembling date math is a bug. time/input.ts toInstant(zone, input) toEndInstant(zone, input) Every date decision lives here — it is the only layer allowed the zone lookup and the day arithmetic. the four readings Z / ±hh:mm string → absolute, zone ignored Plain date-time → resolved through zone date-only → that day's start Date / number → epoch ms, as-is A date the calendar lacks ('2026-02-31') throws InvalidInstantError. what is stored { id: EntryId, start: Instant, end: Instant } Entry — branded, absolute. Nothing downstream ever sees a loose value again. the date-only end rule A bare date on `end` means the last day the consumer wants INCLUDED, but storage wants the boundary AFTER it. 'inclusive' (default) advances one calendar day; only date-only strings are touched. Read once, at construction. Every layer downstream — layout/, render/, view/ — sees only branded absolute values, which is what lets computeFrame stay pure arithmetic with no parsing in the hot path.
Diagram 5 — the consumer boundary. Amber = the one rule that is a policy choice rather than a mechanical conversion.

:::note Why the split is where it is data/entry-reader.ts may see both model/ (for the brand helpers) and time/ (for the reading). It does no date math itself: resolving a Plain time needs the zone and the DST fold/gap policy, and advancing a date-only end by "one day" is zone-aware arithmetic that is not always 86 400 000 ms. Both operations belong in time/. The rule to carry forward: data/ maps fields, time/ decides dates. :::

What the DOM backend builds​

container.fg-container role=group; not the scroller ├─ div.fg-grid-pane width = gridWidth │ ├─ div.fg-grid-spacer one empty .fg-band per header band │ └─ div.fg-rows-clip clip; not transformed │ └─ div.fg-rows RenderSurfaces.grid; translateY(−visible.y) │ └─ div.fg-row keyed by RowId; cells inside ├─ div.fg-splitter ├─ div.fg-timeline-pane the single native scroller │ ├─ div.fg-header width = contentWidth │ │ └─ div.fg-band keyed by index │ │ └─ div.fg-tick keyed by index within the band │ ├─ div.fg-bars │ │ └─ div.fg-bar keyed by BarId │ ├─ div.fg-content-sizer 1×1px, translated to content extent − 1px │ └─ div.fg-date-line today + authored lines, keyed by date-line id

:::note One detail that is load-bearing, not incidental The content sizer must land its far edge on the content extent, hence the − 1: it is 1×1px, so translating its origin to the extent would make the browser's native scroll range one pixel longer than what ScrollAxis computed. Header, bars and sizer sit at x=0 in the timeline pane — the grid pane owns the label width, so there is no gutter offset in this backend. :::

createDomBackend() — factory​

render/dom/index.ts

A closure, not a class — the ten caches and the layer references are private by construction. Implements RenderBackend<HTMLElement>.

  • mount(surfaces) — Takes { grid, timeline }. Builds header, bars, content sizer and date-line layer inside the timeline pane.
  • sync(frame) — Keyed header/rows/cells/items, date-line, grid translateY, sizer transform. No layout reads.
  • applyState(state) — Hot-path class toggles and transforms only — hover/selection/drag preview, zero allocation, never a frame rebuild.
  • hitTest(x, y) — document.elementFromPoint → closest('.fg-bar') → dataset.barId. No materialized hit-region array; the bars array is the hit index.

syncKeyed() — function​

render/dom/sync-keyed.ts

The entire reconciler, in 30 lines. Three phases per call: look-up-or-create by key → patch only if toGeom differs from the cached geometry → prune keys no longer present.

  • nodes / geoms — The caller's persistent per-layer caches, owned by one KeyedLayer instance.
  • create(item, key) — Called once per key. Attributes fixed for the node's lifetime belong here.
  • shallowEqual(a, b) — Compares both directions.

readPixelProperty() — function​

render/dom/pixel-property.ts

Level 1 of the customization ladder: read a --fg-* custom property off the container, parse to px, validate against what the library can actually draw with.

  • policy.accepts — 'positive' — zero is nonsense. 'zeroOrMore' — zero is a real choice.
  • re-read cadence — The caller's, and each states it — neither is per render.

syncDateLine() — function​

render/dom/date-line.ts

Renders the today-line and authored date lines as positioned decoration elements, keyed by date-line id. The decoration geometry comes from layout/date-line.ts's resolveDateLines.

rowIdFromTwistyClick() — function​

render/dom/row-twisty.ts

When a click lands on a .fg-row-twisty inside the grid pane, returns that row's id. Keeps the twisty markup and data-row-id out of view/ so a second backend owns its own control geometry. Wired by view/attach-row-twisty.ts.

createNullBackend() — factory​

render/null/index.ts

RenderBackend<void> that records the last frame and does nothing else. For tests, SSR-of-data, and the future export seam.

  • lastFrame() — The whole added surface over the seam.

Still unreachable from view/: GanttShellOptions.backend is typed to THost HTMLElement, so this THost={} backend, and PaneLayout's real-element mounting, keep the whole stack DOM-free testing out of reach.

Gantt — public class​

api/gantt.ts

The public Gantt class. Constructs one GanttShell and forwards, and delegates every live-reconfigurable property to it.

  • GanttOptions — container, dataset, optional shared scale or preset/range/fit, optional scroll, gridColumns, rowSource, interactions, plugins, viewportGestures, theme, locale, todayLine, …
  • live properties — preset, range, fit, zoomIn/zoomOut, zoomPresets, panToInstant/panToToday, gridColumns, rowSource, collapsed, collapse/expand, selection, selectedEntries, interactions, plugins, commands, viewportGestures, theme, locale, todayLine, gridWidth, …
  • destroy() — Idempotent, delegates. No module-level singletons anywhere in src/.

Dataset — public class​

api/dataset.ts

Everything is delegated into data/ — the public class is a thin façade over DatasetState.

  • entries CRUD — entries.all, entries.add/update/remove, and the row's own doors — entry.read(key), entry.children(), entry.hasChildren.
  • transactions — transaction(fn) batches several edits into one changeset, one render.
  • events — on('beforeChange', vetoable), on('change', { changeSet }), on('historyChange', { canUndo, canRedo }).
  • fields — field(key), fields.all, fieldTypes, aggregators.
  • undo / redo — undo(), redo(), canUndo, canRedo, replay(changeSet).
  • implements DatasetContract — States the relationship to model/dataset.ts instead of leaving it structural-by-coincidence.

The harness, reviewed​

harness/main.ts is the library's first consumer and gets reviewed on every commit, changed or not: code there that re-derives what the library already computes is an API gap even when no lint fires. It demos tree rows, grid columns, field rollups, live rowSource switching, selection, timeline controls, shipped plugins, and Dataset plugins; harness/e2e/plugins.html is the plugin playground; harness/e2e/data.html demos transactions and undo. It still reads clean —

harness/main.ts
import { Gantt, Dataset, tooltips, contextMenu, inlineEditing } from 'freegantt';
import { demoTreeEntryInputs, demoFieldOptions } from '../fixtures/demo-dataset.js';

const dataset = new Dataset({
entries: demoTreeEntryInputs,
timeZone: 'UTC',
...demoFieldOptions,
});

const gantt = new Gantt({
container: '#gantt',
dataset,
plugins: [tooltips(), contextMenu(), inlineEditing()],
});
gantt.rowSource = { source: 'entries', tree: true };

— no restated rowHeight, no hand-built TimeScaleModel standing in for range: 'fitDataset'. harness/e2e/scroll-sync.ts is the shared-viewport e2e fixture: one pair of Gantts sharing a TimeScaleModel and both ScrollAxis instances, beside a pair sharing only the x axis and a pair sharing only the y axis.

extensions/​

Derived from src/extensions/**.

This layer may import only api/ and model/. The shell constructs the runtime; a built-in never imports view/.

PluginRuntime — class​

extensions/plugin-runtime.ts

Installs and uninstalls Gantt plugins, builds each PluginContext, and runs setup(). One occupant per plugin id.

CommandRegistry — class​

extensions/commands.ts

Core commands and plugin commands share one registry. Generic over its Gantt type so view/ can construct it without importing api/.

Keymap — class​

extensions/keymap.ts

Newest-first chord resolver. The innermost popup wins Escape.

createPopup() — function​

extensions/popup.ts

Anchoring, flipping, clamping, dismissal. Tooltips and the context menu build on this. Each feature holds its own instance, so a tooltip and a menu may both show. Inline editing does not — it needs a live input node.

tooltips() / contextMenu() / inlineEditing() — factories​

extensions/features/

The three shipped built-ins. Values a consumer imports (plugins: [tooltips(), contextMenu(), inlineEditing()]), never names in a config table. Each is an ordinary ChromePlugin.