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.
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.
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.
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.
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.
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.
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.
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.
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.
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.