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. ThrowsFreeGanttError('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 toscale.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
RowHeightIndexplus the per-row item memoMap.
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.
FrameLayoutis the only production caller that passes one. - overflowCount — Counts rows already emitted past the window bottom and stops after
verticalRowsof 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
whenthat answers yes. Core'sleafcarries nowhen, so every row resolves. Structure comes off the Entry itself (entry.hasChildren) — never anif (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 synchronousonChanges fromscale.bindandscroll.bindand short-circuits prematurerender()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
scrollTopand filters the echo so a model-driven write does not bounce back as anotherpanTo. - writePosition() — Called by
render()afterbackend.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.
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.
| Type | Shape | The rule it encodes |
|---|---|---|
Instant | number & {__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 / ChangeSetId | string & {__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 / PixelSpan | readonly numbers | One geometry vocabulary for four layers. |
InstantInput | Instant | Date | number | string | The 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 / TimeSpanInput | the loose twins | Same 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.
:::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
:::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
KeyedLayerinstance. - 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 sharedscaleorpreset/range/fit, optionalscroll,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.tsinstead 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 —
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.