Guides / The core engine

The core engine

GanttEngine is the whole system minus the pixels. It owns the reactive state, the time-scale, the layout, the plugin host and the compute pipeline that turns rows into a scene. This page is the map of its public surface.

Constructing the engine

new GanttEngine(options?: GanttOptions): GanttEngine

Every option has a default, so new GanttEngine() is valid (it renders an empty three-month window). Construction runs one full compute pass immediately.

import { GanttEngine } from '@ganttkit/core'

const engine = new GanttEngine({
  rows,
  viewMode: 'Month',
  rowHeight: 44,
  highlightToday: true,
})

Options

OptionTypeDefaultNotes
rowsRow[][]Initial data.
viewMode'Day' | 'Week' | 'Month''Week'Controls the effective day width.
startDatestring | Date | nullnullPin the timeline start. Otherwise derived from the earliest task.
endDatestring | Date | nullnullPin the timeline end. Otherwise derived from the latest task.
rowHeightnumber50Pixel height of each row.
dayWidthnumber60Base pixels per day (scaled by view mode: Day ×1.5, Week ×1, Month ×0.5).
barPaddingnumber6Vertical gap between the bar and the row edge.
highlightTodaybooleantrueEmit the "today" column background.
draggablebooleantrueGlobal drag/resize toggle (tasks can override).
virtualizebooleantrueWindow the scene to the viewport. Turn off only for tiny charts you export.
overscanRowsnumber4Rows rendered above/below the viewport.
overscanColsnumber6Day columns rendered left/right of the viewport.
dateAdapterDateAdapterdefaultDateAdapterParsing/formatting/locale strategy.

Plugin lifecycle

Installing or removing a plugin triggers a full recompute, so the chart is always consistent.

MethodReturnsDescription
use(plugin)thisInstall a plugin and recompute. Chainable. Throws if the plugin name is already installed.
hasPlugin(name)booleanWhether a plugin with that name is installed.
removePlugin(name)voidDispose a plugin (running its teardown) and recompute.
destroy()voidDispose every plugin in reverse install order and clear all event handlers.

Reading state

These accessors are read-only snapshots cheap to call in an event handler or render loop.

MethodReturnsDescription
getState()GanttState{ rows, viewMode, selectedTaskId, loading }.
getOptions()ResolvedOptionsOptions with defaults applied.
getRows()Row[]Rows after the rows hook what the renderer displays.
getScene()SceneThe current (possibly windowed) scene.
getTimeScale()TimeScaleThe active time-scale (see below).
getWindow()Window | nullThe resolved virtualization window, or null when not virtualizing.

The store

State lives in a tiny observable store. Subscribers fire only when a shallow patch actually changes a reference, and re-entrant set() from a listener throws so update loops surface loudly instead of silently recursing.

const off = engine.store.subscribe((state, prev) => {
  if (state.viewMode !== prev.viewMode) console.log('view mode →', state.viewMode)
})
// …
off() // unsubscribe

Mutations

Mutations update state and run the appropriate compute pass. The heavy ones also emit a domain event.

MethodEffect
setRows(rows)Replace all rows, recompute, emit rows:change.
updateTask(id, patch)Shallow-merge a patch into one task; recompute. Returns true if found.
updateTaskDates(id, start, end)Convenience wrapper around updateTask for committing a drag/resize.
setViewMode(mode)Change the view mode (no-op if unchanged); recompute; emit viewmode:change.
selectTask(id \| null)Set the selected task; emit selection:change.
setLoading(bool)Flip the loading flag (renderers may show a spinner).
setDateAdapter(adapter)Swap the date strategy (e.g. locale change) and recompute.
setViewport(vp)Report the visible region triggers a cheap windowed rebuild only.
setDragPreview(id, start, end)Show a transient drag preview without committing to state.
clearDragPreview()Drop the preview and repaint committed state.
refresh()Force a full recompute (plugins call this after mutating their own state).

Events

engine.events is an event bus with on, once, off and emit. Every on/once returns an unsubscribe function.

const off = engine.events.on('task:dragend', ({ task, start, end, changed }) => {
  if (changed) save(task.id, start, end)
})
EventPayloadFires when
scene:change{ scene, reason }Scene rebuilt. reason is 'data' | 'viewport' | 'preview'.
daterange:change{ start, end }Timeline bounds recomputed.
rows:change{ rows }Rows replaced via setRows.
viewmode:change{ viewMode }View mode changed.
selection:change{ taskId }Selected task changed.
task:click{ task, row, originalEvent }A bar/milestone is clicked (emitted by the renderer).
task:hover{ task, row, handle, clientX, clientY }Pointer enters a task. handle is 'left' | 'right' | null.
task:hoverend{}Pointer leaves the last hovered task.
task:dragstart{ task, row, mode }Drag begins. mode is 'move' | 'resize-left' | 'resize-right'.
task:dragmove{ task, row, mode, start, end, changed }Drag in progress (preview dates).
task:dragend{ task, row, mode, start, end, changed }Drag released; changed is false for a no-op.
row:toggle{ rowId }A sidebar chevron is clicked (handled by plugin-tree).

The bus is untyped at runtime, so plugins can define their own event names freely. Renderers emit the pointer events (task:click, task:hover, task:drag*); the engine emits the state events. This split is why one plugin works across every renderer.

The two-pass compute pipeline

Performance comes from splitting compute into a heavy pass and a cheap pass. The renderer picks which to run from the scene:change reason.

Heavy · recompute()

On data / view / plugin change

Runs the rows hook, derives the date range (one O(tasks) scan), rebuilds the time-scale, then does a windowed scene build. Reason: 'data'.

Cheap · rebuildScene()

On scroll / resize / drag

Lays out and builds the scene for the current window only O(visible rows + days). Never re-runs the rows hook. Reason: 'viewport' or 'preview'.

Concretely: setRows, setViewMode, use and refresh take the heavy path; setViewport (scroll) and setDragPreview take the cheap path. A 20 000-task chart rebuilds on scroll in under a millisecond because only the visible slice is ever laid out.

Virtualization & the viewport

The renderer measures its scroll container and calls setViewport, coalescing bursts to one call per animation frame. The engine resolves that into a window a row and day range plus overscan and emits only the primitives inside it.

engine.setViewport({
  scrollTop: body.scrollTop,
  scrollLeft: body.scrollLeft,
  width: body.clientWidth,
  height: body.clientHeight,
})

engine.getWindow() // { rowStart, rowEnd, dayStart, dayEnd } | null

If you build a custom renderer, wiring setViewport to your scroll/resize handlers is the one thing you must do to get virtualization.

The time-scale

getTimeScale() returns the object that maps between dates and pixels. It's the single source of truth for horizontal geometry, so plugins and renderers never re-derive it.

MemberTypeDescription
dateToX(date)numberLeft-edge pixel x of that day's column.
xToDayIndex(x)numberWhole-day index for a pixel x.
dayWidthnumberEffective pixels per day for the current view mode.
widthnumberTotal timeline width in pixels.
totalDaysnumberNumber of day columns.
daysDay[]Per-day cells (date, isWeekend, isToday, isoWeek, …).
weeksWeek[]Week bands (label, range, width).
monthsMonth[]Month bands (label, width).
// Scroll so "today" sits ~30% from the left edge
const x = engine.getTimeScale().dateToX(new Date())
body.scrollLeft = Math.max(0, x - body.clientWidth * 0.3)

Hit-testing

"What's under the pointer" lives in the engine so no renderer or plugin re-implements geometry. Coordinates are in scene space (use the gantt:viewport service's clientToScene to convert).

MethodReturnsDescription
hitTest(x, y){ taskId, handle } | nullTopmost task at a point; handle is a resize edge or null.
hitTestRegion(x1, y1, x2, y2)string[]Task ids whose bars intersect a rectangle (rubber-band selection).

Services

The core stays feature-agnostic through a service registry. A plugin publishes a capability under a string key; renderers and other plugins consume it. Keys are unique providing a taken key throws.

const off = engine.provide('gantt:sidebar', sidebarModel) // returns a disposer
const sidebar = engine.consume('gantt:sidebar')           // model | undefined

Built-in keys: gantt:sidebar (columns), gantt:i18n (i18n) and gantt:viewport (published by the renderer for client→scene coordinate mapping). See Writing a plugin for how to define your own.

Date adapters

All date logic goes through a DateAdapter. The default is Gregorian with a Monday week start. createIntlAdapter(locale, options?) builds a locale-aware one on top of Intl, and plugin-i18n swaps it in when the locale changes.

import { createIntlAdapter } from '@ganttkit/core'

engine.setDateAdapter(createIntlAdapter('fr-FR', {
  weekStartsOn: 1,   // 0=Sun … 6=Sat; inferred from the locale if omitted
  weekend: [0, 6],   // day indices treated as weekend
}))