Skip to main content

FreeGantt module map

Architecture snapshot — updated 2026-09-06.

The dependency graph as it actually stands in src/ right now: solid boxes are built and wired together by real imports; the one hatched box is the remaining stub (scheduling/index.ts). The vertical split is the one boundary the linter enforces — model/, time/, data/, and layout/ stay DOM-free; only the right-hand column may touch document or window. scheduling/ is DOM-free too, but sits outside the core zone on purpose: it is the first-party default scheduling plugin's engine, not a mandatory core layer — a Gantt with no scheduling plugin installed never loads it. extensions/ is built: plugin runtime, commands, keymap, popup, and the three shipped built-ins. scheduling/ is the only stub left.

api/ Gantt, Dataset, plugins · 2,397 lines · the only public export surface, alongside model/ types DOM-FREE CORE — mandatory, also runs headless in Node FIRST-PARTY DEFAULT PLUGIN — DOM-free, not mandatory core BROWSER — document / window allowed DOM BOUNDARY model/ Entry, Field, ChangeSet, ids 2,507 lines · 19 files · types + brand helpers, 0 deps errors, geometry, document schema time/ instant, zone, scale, ZonedTime 1,388 lines · 10 files · only new Date() here layout/ frame.ts, bars/, rows/, viewport/ 4,996 lines · 33 files · computeFrame(), row/bar pipeline TimeScaleModel, ScrollAxis, gesture-draft, FrameLayout data/ DatasetState, EntryStore, transaction 5,026 lines · 28 files · extension hook → changeset History, fields, rollup · imports time + model scheduling/ 5 lines · stub render/ backend, syncKeyed, decorations, ElementDescription 2,452 lines · 13 files · syncKeyed reconciler, date-line view/ GanttShell, PluginRuntime ports, GesturePipeline 9,520 lines · 38 files · capability, commands, plugin seams TreeCollapse, grid-columns, keyboard/wheel nav, splitter interaction/ entry-gestures, column-gestures 762 lines · 5 files · drives EntryGestureContext extensions/ PluginRuntime, popup, built-ins 2,905 lines · 16 files · dogfoods api/ harness/main.ts first consumer, sits outside src/ — reviewed every commit regardless DatasetState, dataset view + data + model (type-only) Entry type view + data + layout + time + interaction + extensions extension hook — generic seam (D4) × never — meet only through data/
Boxes trace what src/ contains; arrows trace real import statements — except the muted data/ ⇄ scheduling/ bridge, which is the designed extension-hook seam: no static import in either direction. model/ has zero dependencies and feeds nearly everything; time/ and layout/ stay DOM-free so they run the same in Node as in a browser; render/ and view/ are the DOM-touching modules. data/, interaction/ and extensions/ are fully built — extensions/ may import only api/ and model/. scheduling/ remains a stub. api/ imports interaction/ for constructor injection of gesture attachments and extensions/ for the plugin runtime and shipped built-ins.
DOM-free module, built
DOM-touching module, built
stub — reserved for S7
live import (solid arrow)
designed bridge, not yet wired
forbidden coupling, enforced by lint

Detail — model/​

Model coupling​

model/ is the one module every other layer is allowed to reach into — 2,507 lines, zero dependencies, no runtime beyond id/brand helpers. At S5 it also exports PluginId, ErrorReport, ElementDescription and command primitives alongside Field/ChangeSet. Every arrow below is a real import in src/ today; the label on each box is exactly what that layer pulls across the boundary, type-only imports called out separately from the runtime calls (entryId(), rowId(), barId()) that actually execute outside model/. render/ never imports model/ at all — it's included to show how it still ends up typed in terms of BarId/RowId, purely through layout/'s re-export.

model/ Entry, EntryInput, StoredEntry EntryId / RowId / BarId brands + entryId(), rowId(), barId() Field, FieldKey, ChangeSet geometry (Point, Size, PixelSpan, Rect) Instant, TimeSpan, Duration, FreeGanttError hierarchy 2,507 lines · 19 files · 0 deps — types + brand helpers only time/ receives (type-only): Instant, TimeSpan, Duration scale.ts / zone.ts / instant.ts format.ts / presets.ts / snap.ts 1,388 lines · 10 files layout/ receives: Entry, EntryId/BarId/RowId + barId(), rowId() — runtime calls geometry, Field types 4,996 lines · 33 files api/ re-exports: Entry, EntryId, RowId, BarId Field, FieldKey, ChangeSet + entryId(), barId() functions 2,397 lines · the public surface allow-list view/ receives: Entry type only via GanttShell's structural DatasetLike — not an api/ import (view -> api is not allowed) 9,520 lines · 38 files render/ no model/ import BarId, RowId arrive only as types, re-exported through layout/index.ts 2,452 lines · 13 files data/ receives: Entry, EntryId/BarId/RowId + entryId(), rowId(), barId() — runtime Field, FieldKey, ChangeSet 5,026 lines · 28 files re-export only
layout/ is the only layer that both imports model/ and calls its runtime helpers — everywhere else the crossing is types only. data/ also calls entryId()/rowId()/barId() at runtime for brand helpers. The api/index.ts re-export list now includes Field, FieldKey, FieldType and ChangeSet alongside the original types, giving consumers the full vocabulary to declare fields and react to changesets. view/ never imports api/ — GanttShell takes a structurally-compatible DatasetLike instead, so view -> api stays a non-edge.

Detail — data/ transaction flow​

The transaction pipeline​

Every mutation in data/ flows through one path: the consumer calls dataset.transaction(), which runs the body through a staged entry store, calls the extension hook once, runs hierarchy promotion and rollup, folds the changeset, and emits beforeChange/change. The extension hook is the designed seam where a scheduling plugin can inject cascade writes; without a plugin installed it is the identity function.

dataset.transaction() api/dataset.ts → data/ runTransaction() data/transaction.ts body runs add/update/remove on staged store extension hook identityExtender (default) scheduling plugin occupies in S7 promote + rollup hierarchy.ts, rollup.ts foldChangeSet() data/change-set.ts — diffs staged vs committed ChangeSet { added, removed, updated } one write shape — model/change-set.ts apply to committed EntryStore.applyChangeSet() beforeChange cancelable veto change carries the ChangeSet History records for undo/redo
The transaction pipeline. The consumer calls dataset.transaction(), which delegates to runTransaction() in data/transaction.ts. The body runs against a staged EntryStore overlay so mid-flight reads see consistent state. The EditExtender hook is called once (identity function by default; a Dataset plugin can wrap it). Hierarchy promotion and rollup run next, then foldChangeSet() diffs the staged edits against committed state to produce one ChangeSet. The events fire in order: beforeChange (cancelable — the consumer can veto), then change (carries the changeset). The History subscribes to change for undo/redo; view/'s subscribeToDatasetChanges subscribes for re-render.

Detail — api/​

Public API surface​

What a consumer can actually import from freegantt today, and the construction path that runs the first time new Gantt(...) is called. The sealed exports map makes everything left of the dashed line below unreachable — view/, layout/ and render/ are implementation, not surface, even though this diagram draws them to show what actually executes.

PUBLIC — api/index.ts, sealed exports map Gantt preset · range · fit · gridColumns rowSource · collapsed · collapsedIds selection · selectedEntries interactions · plugins · commands theme · locale · todayLine · destroy() api/gantt.ts Dataset entries.add / .update / .remove / .all entry.read / .children / .hasChildren transaction · on/off · undo/redo field · fields · fieldTypes replay api/dataset.ts model/ re-exports entryId(), barId() Entry, EntryId, RowId, BarId Field, FieldKey, ChangeSet Instant, TimeSpan, Duration the route to build Entry[] and declare fields time/ + layout/ re-exports presets · instant() · now() · addMs · ZonedTime instant(), ViewPreset TimeScaleModel, ScrollAxis — shareable axis objects layout/ re-exports for viewport coordination INTERNAL — unreachable by import, drawn only to show what runs ↓ new Gantt(options) api/gantt.ts constructs a GanttShell GanttShell view/gantt-shell.ts mounts, binds viewport, wires gestures FrameScheduler.flush() view/frame-scheduler.ts first render after construction computeFrame() layout/frame.ts rows, bars, header ticks backend.sync(frame) render/dom/index.ts syncKeyed() per layer same call chain re-runs on every TimeScaleModel onChange — resize, zoom, or a second bound Gantt
Four export groups, one allow-list philosophy: api/index.ts re-exports exactly the types a consumer needs to call the classes above it and nothing else — TimeScaleOptions stays internal because it carries resolved geometry, not a caller's state. The Gantt class exposes plugins, commands, and the live-reconfigurable properties (preset, range, gridColumns, rowSource, collapsed, selection, …) and Dataset exposes entries CRUD, transactions, events, fields, Dataset plugins, and undo/redo. The call chain underneath never appears in the public surface; a consumer only ever sees it as bars moving on screen. The first render happens after construction completes via FrameScheduler.flush(), not via bind-notify.

Detail — implemented modules​

How the built modules talk​

The same graph as above, minus the remaining stub — scheduling/ does not appear. Every arrow label below is a real symbol in src/ today. api/ builds both Dataset (from data/) and Gantt (from view/). view/GanttShell binds to the dataset (receiving DatasetState), builds the GesturePipeline with capability resolution, and wires interaction/ through constructor injection — the shell never imports interaction/ directly. Two loops carry all the traffic: the bind-time notification loop (TimeScaleModel calls back into GanttShell.render() on every resolved-scale change), and the per-render pipeline (render() → computeFrame() → backend.sync()). Solid arrows are direct calls; dashed arrows are callbacks or value/type flow.

api/ Gantt · Dataset new Gantt({container, dataset, scale?}) gantt.destroy() one private GanttShell each 2,397 lines · 12 files view/ — GanttShell constructor: backend.mount(container) → scale.bind(...) — its onChange fires the first render render(): frame = computeFrame(...) backend.sync(frame) destroy(): handle.unbind(); backend.destroy(); container cleared caches one RowHeightIndex FrameScheduler coalesces rAF build Gestures + Capabilities TreeCollapse · keyboard/wheel nav 9,520 lines · 38 files data/ — DatasetState transaction() → staged edits extension hook → changeset → events EntryStore, FieldRegistry, History fields (field · fields.all) imports time/ + model/ only 5,026 lines · 28 files interaction/ attachEntryGestures() attachKeyboardEditing() drives EntryGestureContext seam TimeScaleModel layout/viewport/time-scale-model.ts bind(binding, onChange) → handle handle.setPaneWidth(w) · unbind() get scale — memoized createTimeScale() get preset one shared instance = x-synced Gantts time/ createTimeScale(), hourPreset … yearPreset only new Date()/Date.now() in src/ 1,388 lines · 10 files · temporal-polyfill façade render/ RenderBackend contract — backend.ts mount(container) · sync(frame) · destroy() rowLabelWidth · date-line · row-twisty sync() = syncKeyed() × {ticks, rows, bars} null/ twin: same contract, headless 2,452 lines · 13 files computeFrame() layout/frame.ts + bars/ + rows/ resolveRows → produceItems → cull header ticks, date-lines, columns heights: shell-cached RowHeightIndex model/ Entry · EntryId / RowId / BarId · Field · ChangeSet · geometry · errors entryId() · rowId() · barId() — zero deps: types + brand-id helpers only new GanttShell({container, dataset, scale?, attachEntryGestures}) shell.destroy() new Dataset({entries, timeZone, editExtender?}) scale.bind(binding, onChange) reads .scale · .preset onChange() → this.render() on first get scale → createTimeScale(o) — memoized createDomBackend() · mount(container) rowLabelWidth · sync(frame) · destroy() computeFrame(LayoutInput) → GeometryFrame barId() · rowId() Entry (type-only) via DatasetLike inject via constructor EntryGestureContext change → re-render
Reading order is construction (api → view + data), bind (view → TimeScaleModel), resolve (TimeScaleModel → time/), then the per-render pipeline (view → computeFrame → backend.sync). The dashed return edge from TimeScaleModel is the whole reactivity story — there is no event bus and no observer list beyond the bindings map: a second Gantt sharing one TimeScaleModel is x-synced because its onChange is simply also in the map. interaction/ drives the EntryGestureContext seam — injected by api/gantt.ts, never imported by view/ directly. data/'s change event bridges into view/ via subscribeToDatasetChanges for re-render. destroy() unwinds in reverse: handle.unbind() then backend.destroy(). Everything left of view/ is DOM-free — the same computeFrame/createTimeScale calls run identically in Node, which is what the null backend's tests rely on.