@ganttlane/core
v0.5.0
Published
Headless gantt-chart core: dates, work calendar, time scale, scheduling, layout and state. No DOM.
Maintainers
Readme
@ganttlane/core
The headless core of ganttlane: dates, work calendar, time scale, scheduling and propagation,
hierarchy, layout, interaction state machines, edit sessions, undo history and the facade that
composes them, createGanttCore. It has no DOM, no globals and no timers, so it runs in Node, in a
worker and in a browser, and it is what @ganttlane/dom and
@ganttlane/react are built on.
Use it directly when you:
- write a renderer (canvas, a native toolkit, another framework): the core hands you a memoized snapshot to draw and takes pointer, scroll and size input back;
- schedule on a server: validate tasks, work out dates through links, calendars and
hierarchies, and turn a
ChangeSetinto your own records, with no chart at all; - test business rules (working days, link types, lags) without a browser.
For a chart in a page, install @ganttlane/dom or
@ganttlane/react instead; both re-export the types you need.
Documentation: the documentation site has the guides, live examples next to their code, and the generated API reference of every export. This README is the reference of the package.
Status:
0.x: the API may still change between minor versions until1.0.
Coming from frappe-gantt, or from a modified fork of it? See the migration guide.
Contents
- Install
- Quick start
- Concepts
- Options
- Events
- Methods
- Writing a renderer
- Building blocks
- Validating data and moving over
- Development and production builds
- Accessibility
- Browser and runtime support
- Size
- Limits and roadmap
Install
# pick one
pnpm add @ganttlane/core
npm install @ganttlane/core
yarn add @ganttlane/core
bun add @ganttlane/core- ESM only, with TypeScript declarations. Node 20 or newer;
require()works only where Node canrequirean ES module (20.19+ and 22.12+). - TypeScript must resolve package
exports:moduleResolutionofbundler,node16ornodenext(notnode10). - It depends on
@internationalized/date(calendar arithmetic) andvalibot(checks the shape of your tasks and links), both installed with it. - With strict pnpm you can only import what your own
package.jsonlists. If you installed@ganttlane/domor@ganttlane/reactonly, you already have the types of the core (Task,TaskRecord,ChangeSet, …) from them; add@ganttlane/coreyourself for runtime imports such ascreateGanttCoreorpatchTasks(the React package re-exportspatchTasks). Keep the three packages on the same version.
Quick start
A chart with no page: create it from data, ask it for computed records, edit through the same path a drag would take, and read the problems it found.
import { createGanttCore } from '@ganttlane/core';
const core = createGanttCore({
tasks: [
{ id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' },
{ id: 'build', name: 'Build', start: '2026-03-09', duration: 10 },
],
links: [{ source: 'design', target: 'build' }], // finish-to-start, the default type
onTasksChange: (changeSet) => {
console.log(
changeSet.reason,
changeSet.changes.map((change) => change.id),
);
},
});
// A TaskRecord: the computed form of a task, with `Date`s, lengths, progress and its problems.
const build = core.getTask('build');
console.log(build?.start, build?.endExclusive, build?.duration?.workingDays);
// A programmatic edit takes the path of a drag: it moves 'build' along the link, runs the
// before-hooks and emits one `tasksChange` with both tasks in it.
const result = await core.updateTask('design', { start: '2026-03-03' });
console.log(result.status); // 'committed'
console.log(core.getIssues()); // { data: [], options: [] }
core.destroy();Nothing here needs a viewport. A renderer adds one with setViewport and draws
getSnapshot(); see Writing a renderer.
Concepts
Tasks and links
A task is the object you pass in; the library never mutates it. A link joins two tasks. The
canonical form is a separate links array; task.dependencies (a list of ids, or
{ id, type, lag } objects) is a shorthand that is merged into it.
| Task field | Meaning |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| id, name | Required. The id is a string or a number, compared with === (5 and '5' are different tasks) |
| start, end, duration | Dates and length, see Dates, durations and time. end wins over duration |
| precision | 'day' or 'time': overrides what the input implies |
| durationMode | 'calendar' or 'working': how duration and lags are counted (default scheduling.durationMode) |
| progress | 0 to 100 |
| progressAnchor | 'start' (default) or 'end': what stays constant when the task is resized, the done part or the remainder |
| type | 'task' (default), 'milestone' (a point at start) or 'placeholder' (a row without a bar) |
| parentId | The parent task: see Hierarchy |
| parentAnchor, followParent | How the task is pinned to its parent's edges |
| locked | Not editable and not moved by propagation (links can still be drawn) |
| fixedDuration | The length cannot change: only moving is allowed |
| linkable | false forbids links from and to the task in the UI |
| editable | boolean or { move, resize, progress, links }: overrides the chart's editing for this task, both ways |
| className, rowClassName | CSS classes of the bar and the row |
| color, progressColor | Bar colours (renderers write them as --gantt-bar-color and --gantt-bar-progress-color) |
| description | Free text |
| dependencies | The links-into-this-task shorthand |
| data | Your payload, typed by the TData type argument and never read by the library |
| Link field | Meaning |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| source | The predecessor |
| target | The successor |
| type | 'FS' finish-to-start (default, scheduling.defaultLinkType), 'SS', 'FF' or 'SF' |
| lag | The gap between the linked edges: a number of days, or a duration ('4h'); negative for a lead. Left out, the current gap is kept |
| id | Defaults to `${source}:${type}:${target}` |
| editable | false keeps the link out of reach of the UI |
Two links of different types between the same pair tie both edges of the successor. An SS and an
FF link make it stretch when the predecessor is resized. An FS and an FF link both hang from
the predecessor's end, so with no lags (each keeps the gap it has) the successor moves as a whole
and keeps its length; it takes another length only when explicit lags ask for it. A link that would
close a cycle is always refused; a cycle that arrives in the data is reported as a cycle issue,
and its links are flagged and take no part in propagation.
A task with no dates is a row without a bar (a warning, no-dates). A task with only a start, only an
end or only a duration is invalid (incomplete-dates): ends are never projected backwards. An
invalid task keeps its row and carries its issues; nothing throws.
Dates, durations and time
- Floating wall-clock time. Dates are wall-clock values with no time zone and no daylight
saving. A
Dateor an epoch-ms number is read through the runtime's local clock (getFullYear(),getHours(), …), so a task at 09:00 stays at 09:00 for every viewer. A string is'2026-03-02'or'2026-03-02T09:30'(Tor a space; seconds and milliseconds allowed). A string withZor a UTC offset is rejected withunsupported-offset: convert it first, or givenew Date(...)and let the local clock decide. - Precision. A date-only value makes a task
precision: 'day'(it sits on whole days); a value with a time of day makes it'time'. ADateat local midnight counts as date-only. Settask.precisionto override. - Inclusive end. A date-only
endis the last day, inclusive (dates.endInclusive, defaulttrue):start: '2026-03-02', end: '2026-03-06'is five days. A value with a time of day is the exact moment the task ends.TaskRecord.endExclusiveis always the half-open end. - Durations. A number is days (
3,0.5). Strings:'30min','6h','2d','1w','1mo','1q','1y', compound'1d 4h', or ISO 8601 ('P1DT4H').'m'alone is rejected, because it could mean minutes or months. Objects:{ days: 2, hours: 4 }. - Records come back as
Dates.TaskRecordand the convenience events carry localDates.ChangeSets andSessionChangescarry the core's floating numbers (epoch-ms-like), so they can be handed back to the core; turn one into aDatewithtoDate(), or apply it to your own tasks withpatchTasks.
Granularity and alignment
scheduling.granularity is the data step: the smallest unit of time your tasks use. It is '1d'
by default (day planning). Set it to '1h', '30min', '15min', … for planning inside the day;
it must divide 24 hours exactly (1min up to 12h), or be 1d.
Invariant: every date, duration and lag in a TaskRecord, a LinkRecord and a ChangeSet is a
multiple of the granularity, counted from midnight. Input that is not (10:30 at 1h) is reported
as a misaligned issue with value and suggested, and is then aligned by
scheduling.alignInput:
| alignInput | 10:30–11:30 at 1h becomes |
| -------------------- | --------------------------------------------------------------------- |
| 'expand' (default) | 10:00–12:00: the start goes down, the end and lengths go up |
| 'nearest' | The nearest boundary; a tie goes in the direction that grows the task |
| 'reject' | The task is invalid (a row without a bar) |
Date-only tasks are always aligned. A lag is aligned to the nearest multiple (it may be negative).
The progress percentage is kept as given and the progress date is rounded down. Drag snap (the
view mode's step, or scheduling.snap) is always a multiple of the granularity: a default that is
finer is raised silently (the hour view mode snaps to days at '1d'), an explicit one is raised with
an issue.
Work calendar
calendar decides which days are working days. The first rule that applies wins:
workingDays: exceptions that are working whatever else is true (a worked Saturday);holidays: non-working days, optionally named ({ date, name });isWorkingDay(day): your function; it must be pure.true,false, orundefinedto go on;weekends: weekdays that are off,0= Sunday …6= Saturday. Default[0, 6].
A working day is a whole 24 hours; working hours and shifts are not modelled. With
durationMode: 'working' (per task or in scheduling) a duration counts working time only: five
working days from a Friday end on the next Thursday, and a time of day is kept across the weekend.
scheduling.skipNonWorkingDays makes drags land on working days (move and progress default to
true, resize to false); a bar that still lands on a non-working day is reported as a conflict.
The calendar is also a standalone function, createWorkCalendar.
Hierarchy
parentId makes a tree; rows come out in pre-order whatever the order of the input. hierarchy.expandedKeys
is 'all' (default) or the ids of the expanded parents. An unknown parent makes the task a root
(unknown-parent); a loop of parents is broken at its first member (parent-cycle).
A child can be pinned to its parent's edges with parentAnchor (default hierarchy.childAnchor,
'SS'): 'SS' and 'FS' pin its start to the parent's start and end, 'FF' and 'SF' pin its
end to the parent's end and start. Pin both edges (['SS', 'FF']) and the child stretches with its
parent. parentAnchor is read only for a child with followParent: true. Any other child just
shifts with its parent's start (keeping its offset and its length), whatever its parentAnchor says.
hierarchy.moveChildren chooses which children move with a parent that moves: 'all' (default:
the pinned ones follow their anchor, the others shift), 'following' (only tasks with
followParent: true) or 'none'.
Scheduling and propagation
An edit of one task (a drag, updateTask) is turned into a ChangeSet by propagate. With
scheduling.propagation: 'preserve-lag' (default) every task linked to the edited one, and every
pinned child, is moved so that its links keep their lag; with 'none' no link moves anything, and
they show their conflicts instead. 'none' is about links only: children still follow their
parent under the default hierarchy.moveChildren: 'all', so a chart where only the edited task
should move also needs hierarchy: { moveChildren: 'none' }. 'push-forward' is planned and
reported as unsupported-option; it behaves as 'preserve-lag' meanwhile. A locked task, and a task without a
bar, stop propagation.
The result is deterministic: each task changes at most once, in topological order. After the
change the plan is checked for conflicts, attached to the ChangeSet and to the records:
| Conflict.type | Meaning |
| -------------------------------------- | ---------------------------------------------------------------------- |
| non-working-start, non-working-end | The first day or the last moment of a task is a non-working day |
| outside-parent | A child extends beyond its parent's bar (parentId, edges) |
| link-violated | A link's gap is below its minimum (linkId, requiredMs, actualMs) |
Conflicts are warnings: they never block a change by themselves. onBeforeTaskChange sees them
and decides.
Controlled and uncontrolled data
dataMode: 'uncontrolled' (default): the core owns the tasks. A committed change is applied to its
own copy, which getTask reflects at once; your arrays are never touched.
dataMode: 'controlled': you own the tasks. Every committed change is reported once to
onTasksChange(changeSet), shown as a draft over your tasks, and applied only when you hand
the new tasks back with setTasks. While a committed change waits, the chart is busy: no gesture
and no updateTask starts. setTasks is your answer: hand back the patched tasks to accept the
change, or the old ones (the same array will do) to refuse it. Either way the draft ends. (The
React props work differently: see @ganttlane/react.)
patchTasks(tasks, changes, model) writes a ChangeSet (or the SessionChanges of a rollback)
into your tasks, in the shape each task already has: a Date stays a Date, an ISO string
stays an ISO string of the same precision, a task with duration keeps a duration. Tasks it does
not change keep their identity, and with nothing to change it returns the array you gave it.
import { createGanttCore, patchTasks, type Task } from '@ganttlane/core';
let tasks: Task[] = [
{ id: 'a', name: 'A', start: '2026-03-02', end: '2026-03-06' },
{ id: 'b', name: 'B', start: '2026-03-09', duration: 5 },
];
const core = createGanttCore({
tasks,
links: [{ source: 'a', target: 'b' }],
dataMode: 'controlled',
onTasksChange: (changeSet) => {
// `model` is the one the change set was computed on: the current snapshot's.
tasks = patchTasks(tasks, changeSet, core.getSnapshot().state.derived.model);
core.setTasks(tasks); // the answer: the draft ends and the chart shows your tasks
},
});
await core.updateTask('a', { start: '2026-03-03' });
console.log(tasks[0]); // { id: 'a', name: 'A', start: '2026-03-03', end: '2026-03-07' }Changes, edit sessions and history
A ChangeSet is one user action or API call: reason ('move', 'resize-start',
'resize-end', 'progress', 'api', 'undo', 'redo', 'session', 'rollback'), the
changes ({ id, before, after, cause, via? } with a TaskPatch of start, endExclusive,
progress and, when the change alters it, precision; cause is 'direct', 'link' or
'parent'), and the conflicts. Exactly one tasksChange is emitted per action, with every task
it touched.
Before-hooks let your code veto or rewrite a change before it commits, synchronously or after a confirmation:
import { createGanttCore } from '@ganttlane/core';
const core = createGanttCore({
tasks,
// The context is `{ changeSet, reason, conflicts }`.
onBeforeTaskChange: async ({ conflicts }) => {
if (conflicts.some((conflict) => conflict.type === 'non-working-start')) {
return await askUser('This starts on a day off. Keep it?'); // false cancels
}
// nothing (or true) lets it pass; a ChangeSet replaces the one being committed
// (it is re-validated and re-aligned like any other)
},
onBeforeLinkCreate: ({ link, wouldCreateCycle }) => !wouldCreateCycle,
});A throwing or rejecting hook cancels the change and reports on error. A link that would close a
cycle is rejected whatever the hook answers.
Edit sessions are the "Edit, Save, Cancel" flow: edit.begin() marks the data as the base,
actions go through as usual (applied as they happen, so getTask() shows them, and still emitting
their events), edit.getChanges() is the net difference from the base (a task dragged out and back
is not listed), edit.commit() closes the session and returns that SessionChanges, and
edit.rollback() puts the base back without asking the hooks. In uncontrolled mode the base is
restored by the core; in controlled mode the rollback is one editRollback event whose
SessionChanges is the inverse for you to write into your state (patchTasks for the tasks;
links.removed are links to drop, links.added links to restore: link models with an id and, in
link, your own Link, which a link from task.dependencies does not have). Sessions do not nest.
edit.isDirty flips dirtyChange.
History. Undo and redo are off until you set history: true (or { limit }, default 100): then
one action is one entry, and undo() and redo() replay entries without asking the hooks, but with
the same re-validation, inside a session too (a rollback puts the history back as it was at
begin()). They answer 'stale' if your data changed behind the history's back. The hotkeys are the renderer's
(keyboard.undo of @ganttlane/dom).
Issues
The core never throws on bad options or data. A problem becomes an Issue:
{ code, severity: 'error' | 'warning', message, path?, taskId?, linkId?, value?, suggested? }, and
the thing is repaired or skipped: an unusable option takes its default, an invalid task becomes a
row without a bar, a bad link is dropped.
- Where to read them:
core.getIssues()returns{ data, options }; theissuesevent (onIssues) fires when the list changes, withsource: 'data' | 'options' | 'change-set';setOptionsreturns the issues of the options now in use; aTaskRecordcarries its ownissues. code,path,valueandsuggestedare stable and identical in every build (the one warning that only the development build gives isunknown-option, below).messageis for people: the development build spells it out; the production build says only'<path>: <code>'. Never parse it.
| Group | Codes |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dates | invalid-date, unsupported-offset, invalid-duration, invalid-granularity, misaligned |
| Tasks | invalid-shape, duplicate-id, no-dates, incomplete-dates, end-before-start, too-long, no-working-time, progress-out-of-range |
| Tree | unknown-parent, parent-cycle, invalid-parent-anchor |
| Links | unknown-link-task, self-link, duplicate-link, inactive-link, cycle |
| Calendar | invalid-weekend, invalid-holiday, invalid-working-day, invalid-rule, time-ignored, no-working-days |
| Scale | invalid-view-mode, unknown-view-mode, invalid-snap, snap-below-granularity, snap-not-multiple |
| Options | invalid-option, unsupported-option, unknown-option (development build only), invalid-layout-option, invalid-timeline-option, invalid-scroll-option |
| Changes | too-short, invalid-change, unknown-task, locked-task, duplicate-change, plus misaligned, end-before-start and progress-out-of-range (source: 'change-set') |
A top-level key the core does not know is ignored. The development build
also reports it, as an unknown-option warning (the production build does not look for it), and
names the new place when the key is an option of frappe-gantt:
bar_height: is an option of frappe-gantt; here it is layout.barHeight. A key inside a group
(layout: { barheight }) is not looked at.
Options
createGanttCore(options) takes these; core.setOptions(patch) changes any of them later without
recreating the chart. A key left out of a patch keeps its value. Groups (calendar, dates,
scheduling, hierarchy, layout, timeline, scroll, interaction) are merged field by field,
and a field set to undefined goes back to its default. The merge is one level deep. A field
that is itself an object or a list is replaced whole: scheduling.skipNonWorkingDays,
scheduling.snap, timeline.range, timeline.infinite, scroll.autoScroll,
interaction.handles and calendar.holidays are, so
setOptions({ interaction: { handles: { resize: 12 } } }) sets the other handle sizes back to their
defaults, and setOptions({ scheduling: { skipNonWorkingDays: { move: false } } }) does the same for
the other two flags: write the whole object. Every other option (viewModes, editing, history,
the lists) is replaced whole too. A key set to undefined that has no default is an
issue, not a reset: setOptions({ tasks: undefined }) is an invalid-option issue
(tasks is required) and the chart shows no tasks, so pass tasks: [] to empty it on purpose.
setOptions recomputes only what a key invalidates, and swapping an on<Event> handler is O(1).
Keys the core does not know are ignored, and the development build warns about each top-level one
(unknown-option), so a renderer takes its own options out before it calls the core, as
createGantt does. Every value is validated; an unusable one is an issue and its
default is used.
Data
| Option | Type | Default | Meaning |
| ---------- | ---------------------------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| tasks | readonly Task<TData>[] | required | The tasks, in display order |
| links | readonly Link[] | none | The links; task.dependencies are merged in |
| dataMode | 'uncontrolled' | 'controlled' | 'uncontrolled' | Who owns the tasks: see Controlled and uncontrolled data |
View
| Option | Type | Default | Meaning |
| ----------- | ---------------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| viewMode | 'hour' | 'quarter-day' | 'half-day' | 'day' | 'week' | 'month' | 'year' or your own id | 'day' | The scale preset in use. An unknown id falls back to 'day' (to the first mode when a custom viewModes list has no 'day') with an unknown-view-mode warning |
| viewModes | ViewMode[] or { [id]: Partial<ViewMode> } | presets | A complete list (replaces the presets), or overrides merged over them; a new id adds a mode |
| locale | BCP 47 tag | 'en' | Formats header labels. Not the runtime's locale, so a server and a browser agree |
| direction | 'ltr' | 'rtl' | 'ltr' | Stored only: mirroring is planned |
| now | () => DateInput | the system clock | The source of "today" (tests, a server clock) |
A view mode is { id, label?, unit, step?, pxPerDay, headers, majorLine?, minorLine?, snap?, padding? }:
one column is step of unit ('hour', 'day', 'week', 'month', 'quarter', 'year'),
pxPerDay is the zoom, and headers are the header rows (top first), each with a unit and a
format (Intl.DateTimeFormatOptions, or a function of the cell's range and the locale). The
presets:
| id | Column | pxPerDay | Default snap | Padding |
| ------------- | -------- | ---------- | ------------ | ------- |
| hour | 1 hour | 960 | 15min | 7d |
| quarter-day | 6 hours | 160 | 1h | 7d |
| half-day | 12 hours | 80 | 1h | 14d |
| day | 1 day | 40 | 1d | 14d |
| week | 1 week | 20 | 1d | 4w |
| month | 1 month | 4 | 1d | 4mo |
| year | 1 year | 1 | 1mo | 2y |
The three hourly presets have 40 px columns, which have room for about two digits, so the hour row
of their header shows the hour of the 24-hour clock in two digits in every locale (00, 06,
12, 17): 5 PM and 17 Uhr do not fit, and the label would be cut to 5 …, with noon and
midnight reading the same in half-day. To show the clock of the locale instead, replace the row
(headers is replaced whole) and give the columns the room it needs:
createGanttCore({
tasks,
viewMode: 'quarter-day',
viewModes: {
// 70 px per 6-hour column.
'quarter-day': {
pxPerDay: 280,
headers: [
{ unit: 'day', format: { day: 'numeric', month: 'long' } },
{ unit: 'hour', step: 6, format: { hour: 'numeric' } },
],
},
},
});import { createGanttCore } from '@ganttlane/core';
createGanttCore({
tasks,
viewMode: 'day',
// Override a preset's zoom and add a mode of your own.
viewModes: {
day: { pxPerDay: 56 },
sprint: {
unit: 'week',
pxPerDay: 12,
headers: [
{ unit: 'month', format: { month: 'long', year: 'numeric' } },
{ unit: 'week', format: { day: 'numeric' } },
],
},
},
});Rules
| Option | Type / values | Default | Meaning |
| ------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| dates.endInclusive | boolean | true | A date-only end is the last day, inclusive |
| calendar.weekends | number[] | [0, 6] | Weekdays that are off, 0 = Sunday |
| calendar.holidays | (DateInput \| { date, name? })[] | none | Non-working days, optionally named |
| calendar.workingDays | DateInput[] | none | Days that work whatever the other rules say |
| calendar.isWorkingDay | (day: CalendarDay) => boolean \| undefined | none | A pure custom rule; undefined defers to weekends |
| scheduling.durationMode | 'calendar' | 'working' | 'calendar' | Default for tasks and lags |
| scheduling.propagation | 'preserve-lag' | 'none' | 'preserve-lag' | How an edit spreads along links ('push-forward' is planned). Children are hierarchy.moveChildren's, not this option's |
| scheduling.defaultLinkType | 'FS' | 'SS' | 'FF' | 'SF' | 'FS' | Type of a link that names none |
| scheduling.granularity | duration that divides 24 h, or '1d' | '1d' | The data step, see above |
| scheduling.alignInput | 'expand' | 'nearest' | 'reject' | 'expand' | What to do with misaligned input |
| scheduling.snap | step, 'unit', false or { step, mode, move, resize, progress } | the view mode's | Drag snapping; overrides the view mode's. mode: 'grid' snaps to boundaries, 'relative' moves by whole steps |
| scheduling.keepDayPrecision | boolean | true | Day-precision tasks snap to whole days at least. false lets a drag turn them into timed tasks |
| scheduling.skipNonWorkingDays | { move, resize, progress } booleans | { move: true, resize: false, progress: true } | Whether the edited edge skips non-working days |
| hierarchy.expandedKeys | 'all' | TaskId[] | 'all' | The expanded parents |
| hierarchy.childAnchor | link type or list | 'SS' | Default task.parentAnchor |
| hierarchy.moveChildren | 'all' | 'following' | 'none' | 'all' | Which children move with a moved parent |
| selection.onClick | boolean: a click on a bar selects that task | false | |
| selection.multiple | boolean: with onClick, Ctrl or Cmd + click adds or takes out | false |
| selection.clearOnEmptyClick | boolean: a click on an empty part clears the selected tasks | false | |
| editing | boolean | { move, resize, progress, links } | true | What the UI may do (false: nothing; updateTask, addLink and removeLink from code still commit); task.editable overrides it per task |
| history | boolean | { enabled, limit } | off, limit 100 | Undo and redo: history: true turns them on |
Layout, timeline and scrolling
| Option | Type / values | Default | Meaning |
| --------------------------------- | ------------------------------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| layout.rowHeight | number (px) | 48 | Height of a row |
| layout.barHeight | number | 30 | Height of a bar, centred in its row |
| layout.barRadius | number | 3 | Corner radius; each bar clamps it to half its height and width |
| layout.headerRowHeight | number | number[] | [45, 30] | One height for every header row, or one per row, top first |
| timeline.range | 'auto' | { start, end } | 'auto' | 'auto' spans the tasks plus padding; an explicit range with a date-only end includes that day |
| timeline.padding | duration or [before, after] | the view mode's | Padding around the data |
| timeline.includeToday | boolean | false | The automatic range always contains today |
| timeline.infinite | boolean | { threshold, extendBy, bounds: { min, max }, maxSpan } | false | The range grows as the viewport nears an edge. threshold is px (default the viewport width), extendBy a duration (default two viewports), maxSpan trims the far side |
| scroll.initial | 'today' | 'start' | 'end' | 'first-task' | 'none' | DateInput | 'today' | Where the chart opens |
| scroll.behavior | 'smooth' | 'instant' | 'auto' | 'smooth' | How scrollTo* moves |
| scroll.align | 'start' | 'center' | 'end' or 0 … 1 | 'start' | Where the target lands in the viewport |
| scroll.autoScroll | false | { edge, maxSpeed } | { edge: 40, maxSpeed: 20 } | Scrolling while a drag nears an edge: the zone in px and the speed in px per 60 Hz frame |
| interaction.dragThreshold | number (px) | 3 | Travel before a press becomes a drag |
| interaction.touchDragThreshold | number | 8 | The same for touch |
| interaction.doubleClickTime | number (ms) | 500 | Longest gap between the two clicks of a double click |
| interaction.doubleClickDistance | number (px) | 6 | Farthest distance between them |
| interaction.handles | { resize, progress, link, linkOffset } | { 8, 6, 6, 10 } | Sizes of the handles, which the hit test and the renderer share |
scroll.initial is 'today' by default. A plan that lies in the past or the future then opens at
the nearest end of its range, not at its first task; use 'first-task' or 'start' for a fixed plan.
Handlers are options too: see Events.
Events
Every event has an on<Event> option and a listener through core.on(name, handler), which returns
a function that removes it. Both take the same arguments. A listener that throws never breaks the
others: the error goes to error (and onError). With no one listening to error, what happens
depends on what caused the event:
- An API call (
updateTask,addLink,select,setTasks,setOptions,setViewMode,scrollTo*,edit.*,undo, …) rethrows what its listeners threw, once it is done: one error as it is, several as anAggregateError. A plain method throws synchronously; an async one (updateTask,addLink,removeLink) rejects its promise. The change itself has happened. - Renderer input (
setViewport,scroll,send,refreshNow) never throws after it changed something, because the renderer needs its result: the error goes to the host's unhandled-rejection channel (anunhandledrejectionevent in a browser) instead.
A handler given as an option can run before createGanttCore returns. onIssues does when the
first options or data have issues, so a handler that reads the variable the core is assigned to must
not assume it is set yet (declare let core: GanttCore | undefined, or add the listener with
core.on('issues', …) afterwards). onRangeChange and onScroll first run when the renderer
reports its size with setViewport.
| Event | Arguments | When |
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tasksChange | (changeSet) | A change committed: one per action, with every task it touched. Also for undo and redo |
| taskDatesChange | (record, { start, end, endExclusive }) | A convenience after tasksChange, per task whose dates changed (Dates) |
| taskProgressChange | (record, progress) | The same for progress |
| linkCreate | (link, id) | A link was created; a controlled owner adds exactly this link (the id may be …#2) |
| linkDelete | (linkRecord) | A link was deleted |
| linkClick | (linkRecord, event) | A link was clicked; it is now the selected link |
| taskClick | (record, event) | A bar was clicked (never after a drag) |
| taskDoubleClick | (record, event) | A bar was double-clicked |
| taskHoverChange | (record \| null, event) | The task under the pointer changed |
| selectionChange | (selectedKeys, selectedLink) | The selected tasks or link changed |
| viewModeChange | (viewMode) | The view mode in use changed |
| rangeChange | ({ start, end, reason }) | The timeline range changed (Dates): for lazy loading by date. reason is 'initial', 'data', 'options', 'view-mode', 'zoom', 'resize', 'drag' or a viewport reason (extension, trim, scroll-to) |
| rowsChange | (rows) | What getRows() returns changed; not during a drag preview |
| rowHoverChange | (record \| null) | The lit row changed: the pointer's row, or setHoveredRow |
| focusChange | (record \| null) | The task whose row or bar has the keyboard focus changed (focusTask) |
| expandedChange | (expandedKeys, expandedIds) | expand, collapse, expandAll or collapseAll changed the expanded parents |
| scroll | ({ x, y, date }) | The scroll position changed: the renderer reported one, or a request of the core's own moved it (the initial scroll, an instant scrollTo*), at once, before the renderer has performed it. date is at the left edge of the viewport |
| editRollback | (sessionChanges) | A session was cancelled: the inverse for controlled data to apply |
| dirtyChange | ({ isDirty }) | edit.isDirty flipped |
| historyChange | ({ canUndo, canRedo }) | What undo and redo can do changed |
| editRefused | ({ gesture, id, target?, reason, at }) | A gesture was refused ('busy', 'permission', 'unknown-task', 'self', 'cycle', 'duplicate'), at is 'start' or 'commit' |
| issues | ({ source, issues }) | The issues of the data or the options changed, or a change set needed correcting |
| error | ({ error, source: 'listener' \| 'hook', name }) | A listener or a before-hook threw |
The handler option names are onTasksChange, onTaskDatesChange, onTaskProgressChange,
onLinkCreate, onLinkDelete, onLinkClick, onTaskClick, onTaskDoubleClick,
onTaskHoverChange, onSelectionChange, onViewModeChange, onRangeChange, onExpandedChange,
onScroll, onEditRollback, onDirtyChange, onHistoryChange, onEditRefused, onIssues and
onError. The two before-hooks, onBeforeTaskChange(context) and onBeforeLinkCreate(context),
are options only (a hook may answer a value, a promise of one, or nothing, so a function that returns
Promise<boolean | void> is a hook); see Changes, edit sessions and history.
The payloads are typed by GanttEventMap<TData>; pointer events (event) are whatever the renderer
passed to send.
Methods
All members of the object createGanttCore returns are plain functions: they need no this, so
const { getTask } = core works. After destroy() the commands do nothing (the ones that return a
promise resolve { status: 'cancelled', reason: 'disposed' }) and the queries keep answering from
the state the chart had.
| Group | Members |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Data | setTasks(tasks), setLinks(links), updateTask(id, { start?, end?, progress? }), nudge(id, gesture, direction), addLink(link), removeLink(id) |
| Options | setOptions(patch) returns the issues of the options now in use |
| View | setViewMode(id, { keepScroll }), zoomTo(pxPerDay, { anchor }) |
| Scrolling | scrollToDate(date, { align, behavior }), scrollToToday(options), scrollToTask(id, { align, alignY, behavior, expand }) (align and alignY also take 'nearest' and false), scrollTo({ left, top, behavior }) |
| Rows | expand(id), collapse(id), toggle(id), expandAll(), collapseAll(), setHoveredRow(id), focusTask(id) |
| Selection | select(ids), selectLink(id), clearSelection() |
| Edit session | edit.begin(), edit.commit(), edit.rollback(), edit.getChanges(), edit.isOpen, edit.isDirty |
| History | undo(), redo(), canUndo, canRedo (need history: true) |
| Queries | getTask(id), getLink(id), getVisibleRange(), getRowMetrics(id), getBarRect(id), getRows(), getRowWindow(), getLayoutMetrics(), getIssues() |
| Events | on(name, handler), subscribe(listener) |
| For renderers | setViewport(size), scroll(position), send(event), refreshNow(), getSnapshot() |
| Life cycle | destroy() |
updateTask(id, { start, end, progress })is a programmatic change withreason: 'api'.startalone moves the task (its length is kept),endalone moves the end edge, both set both edges,progresssets the progress. It propagates like the same drag and runs the before-hooks. Values are exact: no snapping to the view mode, no rolling onto working days; misaligned values followalignInput. It is async and resolves{ status: 'committed', changeSet },'unchanged'(a locked task, or nothing to change),'rejected'(reason: 'unknown-task'or'invalid-value'withissues; an end not after the start isend-before-start),{ status: 'cancelled', reason }('hook': a hook answeredfalse;'error': it threw, rejected or answered something invalid;'disposed': the core was destroyed) or{ status: 'busy' }.editingdoes not apply to it: that option is about the UI.addLink(link)andremoveLink(id)are async and resolve{ status: 'created' }/{ status: 'deleted' },{ status: 'rejected', reason }withreasonone of'invalid'(shape, type or lag; withissues),'unknown-task','duplicate','cycle'or'unknown-link',{ status: 'cancelled', reason: 'hook' | 'error' | 'disposed' }(abeforeLinkCreatehook answeredfalse, or threw, or the core was destroyed;removeLinkis cancelled only by the last) or{ status: 'busy' }(another change is waiting for a hook or, with controlled data, for yoursetTasks).editingdoes not apply to them either.edit.begin(),commit()androllback()are synchronous and return astatus:'begun'/'already-open','committed'/'rolled-back'(withchanges),'no-session','busy'or'cancelled'.undo()andredo()are synchronous and return astatus:'undone'/'redone'(with theentry),'empty'(nothing to step through),'stale'(your data changed behind the history's back, and the history was cleared),'busy'or'cancelled'.scrollTo*andsetViewMode/zoomToreturn aScrollRequest, orundefined. AscrollTo*made before the viewport has a width is remembered, the latest one wins, and it replacesscroll.initialwhen the chart mounts.subscribe(listener)is called after every change of the snapshot and returns a function that stops it; withgetSnapshotit is the contract of React'suseSyncExternalStore.
Writing a renderer
A renderer does four things: tells the core how big it is, draws the snapshot, performs the scrolls the core asks for, and sends input back.
import { barsInWindow, createGanttCore, type GanttSnapshot } from '@ganttlane/core';
const core = createGanttCore({ tasks, links, viewMode: 'week', scroll: { initial: 'first-task' } });
let appliedRequest = 0;
function draw(snapshot: GanttSnapshot): void {
// 1. Perform a scroll request once (by id), in the frame that draws its snapshot.
const request = snapshot.scrollRequest;
if (request !== undefined && request.id > appliedRequest) {
appliedRequest = request.id;
performScroll(request); // see "Scroll requests" below
}
// 2. Draw only the window: the visible rows and time, with overscan.
for (const bar of barsInWindow(snapshot.window, snapshot.layout)) {
drawBar(bar.x, snapshot.layout.rows.headerHeight + bar.y, bar.width, bar.height);
}
}
core.subscribe(() => draw(core.getSnapshot()));
// 2. Report the size. The first non-zero width mounts the chart and returns the initial request.
const initial = core.setViewport({ width: 1200, height: 600 });- The snapshot (
getSnapshot()) is a memoized view-model, new only when something in it changed, and its parts keep their identity while their inputs do. It holds the store state (state.tasks,state.links,state.derived.model,.rows,.conflicts,.issues, the draft and the edit session),viewMode,timeline(range, scale, scroll offset, viewport width),contentWidth,header(the input ofheaderRowsandgridLines),layout(rows and bar geometry with the dragged draft laid over the data),window(what to draw),selection,feedback(the cursor, the hovered target, the dragged values, the link preview),editingandhandles(the permissions and handle sizes to draw),now,autoScroll,busyandscrollRequest. - Input.
core.send(event)takes{ type: 'down' | 'move' | 'up', pointerId, x, y, time, button?, pointerType?, isPrimary?, modifiers?, target?, event? }in content coordinates (xon the time scale,ybelow the header),{ type: 'key', key, modifiers? },{ type: 'cancel', pointerId? }and{ type: 'timeline' }(the timeline moved under a still pointer). It runs the interaction state machines (move, resize, progress, link creation) and applies previews, commits, links, clicks and range growth itself; what it returns are theRendererEffects left to you:autoscroll,link-previewandfeedback, all also in the snapshot. Drags keep their date under the pointer through autoscroll, range extension and zoom, because the state machine anchors in time, not in pixels. - Scrolling out. Report the scroller's position with
core.scroll({ left, top })on every scroll event, once per frame. Withtimeline.infiniteit may return aViewportChange: apply it in the same frame (scrollLeft += change.shift, and size the content to the snapshot's newcontentWidth) so the date under the left edge does not move. - Clock. Call
core.refreshNow()from one timer aligned to the next minute so the today line followssnapshot.now; it publishes only when the minute changed. - Errors.
setViewport,scroll,sendandrefreshNowfinish their change before they return, and never throw because an application handler did; those errors go toerror, or to the unhandled-rejection channel.
Scroll requests
The core cannot touch a scroller, so every scroll it wants is a request:
{ id, reason, from?, left?, top?, behavior }, returned by setViewMode, zoomTo, scrollTo* and
setViewport, and kept in the snapshot as scrollRequest. Perform each id once: if from is set,
jump there instantly first, then scroll to left and top (content and body pixels; a missing one
keeps its axis) with behavior. Values are absolu
