Custom renderers
@ganttlane/core is the whole chart without a page: dates, the work calendar, the time scale,
scheduling and propagation, hierarchy, layout, the interaction state machines, edit sessions and undo
history. It has no DOM, no globals and no timers, so it runs in Node, in a worker and in a browser.
@ganttlane/dom and @ganttlane/react are built on it.
Use the core directly when you want to write a renderer (a canvas, a native toolkit, another
framework), schedule on a server (validate tasks, work out dates through links and calendars,
turn a ChangeSet into your own records), or test business rules without a browser. For a chart
in a page, install @ganttlane/dom or
@ganttlane/react instead; both re-export the types you need.
This guide is about the first case: what the core gives a renderer, what a renderer owes the core, a
complete renderer in one short file, the two builds of the core, and the pieces of @ganttlane/dom
that a binding for another framework builds on.
The core on its own
Section titled “The core on its own”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' }], viewMode: 'week', onTasksChange: (changeSet) => console.log(changeSet.reason),});
const result = await core.updateTask('design', { start: '2026-03-03' });console.log(result.status); // 'committed'core.destroy();createGanttCore(options) returns a
GanttCore. Its members are plain functions that
need no this, so const { getTask } = core works. The options are the core’s half of the
Options guide: the data, the rules, the view, layout, timeline, scroll,
editing, history, interaction, and the on<Event> handlers. Keys the core does not know are
ignored, and the development build warns about each top-level one as unknown-option, so a renderer
takes its own options out before it calls the core, as createGantt does.
Most members are the methods of the vanilla chart, with the same behaviour: data (setTasks,
updateTask, addLink, …), setOptions, the view and scrolling (setViewMode, zoomTo,
scrollTo*), rows, selection, the edit session, history, queries (getTask, getBarRect, …) and
on. See the Methods guide. After destroy() the commands do nothing and the queries
keep answering from the last state. The members that are for renderers are setViewport, scroll,
send, refreshNow, getSnapshot and subscribe, and the rest of this page is about them.
What a renderer does
Section titled “What a renderer does”A renderer has four jobs:
- Tell the core how big it is, with
setViewport. The first non-zero width mounts the chart. - Draw the snapshot, with
getSnapshoteach timesubscribesays it changed. - Perform the scrolls the core asks for, the scroll requests, and report the position back with
scroll. - Send input, pointer and key events, with
send.
Plus one timer: read the clock once a minute with refreshNow, so the today line follows
snapshot.now.
The core never touches a scroller or a pointer: everything it wants done is a value you read, and
everything it wants to know is a call you make. setViewport, scroll, send and refreshNow
finish their change before they return and never throw because a handler of the application threw;
those errors go to the error event, or to the host’s unhandled-rejection channel.
The snapshot
Section titled “The snapshot”getSnapshot() returns the view-model to
draw. It is memoized: a new object only when something in it changed, and its parts keep their
identity while their inputs do. That makes snapshot.layout === previous.layout a valid “nothing to
do” check, and it is the contract of React’s useSyncExternalStore(core.subscribe, core.getSnapshot).
| Field | What it is, and what a renderer does with it |
|---|---|
state |
The data: tasks, links, derived.model (normalized tasks), derived.rows, derived.graph, draft, session |
viewMode |
The view mode in use: the header rows and the grid come from it |
timeline |
range, scale (date to x), scrollLeft and viewportWidth |
contentWidth |
Width of the scrolled content in px: size your scroll content to it |
scrollTop |
The vertical scroll offset, in body px |
viewportHeight |
Height of the viewport, header included |
header |
The input of headerRows and gridLines: the scale, the calendar adapter and the locale |
layoutOptions |
Row, bar and header row sizes in use |
layout |
Row metrics and bar geometry, with the dragged draft already laid over the data |
window |
What to draw: rows firstRow to lastRow and the time start to end, overscan included |
selection |
tasks (selected ids) and link (the selected link id) |
feedback |
The cursor, the hovered target, the values of a dragged task (patch) and the link preview |
editing |
Which gestures the UI allows; with a task’s permissions it decides which handles to draw |
handles |
The handle sizes in use: the hit test and the drawing share them |
now |
“Now”, to the minute, as floating time: the today line is at scale.dateToX(now) |
autoScroll |
The drag autoscroll velocity in px per 60 Hz frame; 0 when none |
busy |
A commit waits for a before-hook, or controlled data waits for setTasks; no gesture starts meanwhile |
scrollRequest |
The latest scroll request: see Scroll requests |
All coordinates are pixels. Bars are in body pixels: y = 0 is the top of the first row, and
x is on the time scale, where x = 0 is the left edge of the scrolled content. For content pixels
of the scroller, add layout.rows.headerHeight to a y. Dates inside the core are floating
wall-clock numbers: use toDate to get a Date, or core.getTask(id), which returns a TaskRecord
with Dates.
Drawing a frame from a snapshot takes a handful of building blocks, all pure functions (see Building blocks):
- Header cells:
headerRowstakes the snapshot’sviewMode,windowandheader, and gives one lazy list of cells per header row, each withx,widthandlabel. - Grid lines:
gridLinestakes the same three. - Rows:
snapshot.layout.rowshasrows,rowHeight,headerRowHeightsandbodyHeight. - Bars:
barsInWindowtakes the snapshot’swindowandlayoutand gives aBarGeometryfor each bar in the window:x,y,width,height,radius,progressWidthandkind('bar'or'milestone'). - Links:
snapshot.state.derived.graph.linkslists the links;routeLinkturns a link type and the two bars into an SVG path and an arrowhead. - Handles:
barHandlesgives the boxes of a bar (withsnapshot.handles), andresolvePermissionssays which of them the task allows (withsnapshot.editing).
Subscribing, and the events
Section titled “Subscribing, and the events”subscribe(listener) calls listener after every change of the snapshot and returns a function that
stops it. That is the signal to draw. The listener takes no arguments: read getSnapshot() in it,
ideally to schedule one draw per animation frame, since several changes can come in a frame.
state.lastDataChange lists the task and link ids of the last new model; a renderer that drew
version - 1 can apply it, and any other redraws everything.
The events (tasksChange, taskClick, rangeChange, scroll, …) are for the application, with the
payloads of the Events guide. A renderer rarely needs them.
Reporting size and scroll
Section titled “Reporting size and scroll”const request = core.setViewport({ width: scroller.clientWidth, height: scroller.clientHeight });setViewport(size) takes the size of the
scroller in CSS px, header included, from a ResizeObserver. The first non-zero width mounts the
timeline and applies scroll.initial, which is returned as the first scroll request and is also in
the snapshot.
scroll({ left, top }) reports the scroller’s
position: left is its scrollLeft, and top the body y at the bottom of the sticky header, which
is its scrollTop when the header sticks inside it. Call it from every scroll event, once per
frame at most. A gesture in progress is told that the timeline moved,
so a dragged bar stays under the still pointer; you do not send that event yourself. With
timeline.infinite the call may return a
ViewportChange: the range grew or shrank at
its start. Apply it in the same frame, scroller.scrollLeft += change.shift, with the content sized
to the snapshot’s new contentWidth, so the date under the left edge does not move.
refreshNow() reads “now” again (the now option, else the system clock) and publishes a new
snapshot only when the minute changed. Call it from one timer aligned to the next minute:
let timer: ReturnType<typeof setTimeout>;const tick = (): void => { core.refreshNow(); timer = setTimeout(tick, 60_000 - (Date.now() % 60_000));};timer = setTimeout(tick, 60_000 - (Date.now() % 60_000));// On teardown: clearTimeout(timer).Scroll requests
Section titled “Scroll requests”The core cannot touch a scroller, so every scroll it wants is a request:
ScrollRequest.
| Field | Meaning |
|---|---|
id |
Grows by one with every request of the core |
reason |
'initial', 'scroll-to', 'view-mode', 'zoom', 'data', 'options', 'resize' or 'drag' |
from |
An offset to jump to instantly first, or undefined to start from where the scroller is |
left |
The horizontal offset to scroll to, in content px; undefined keeps the axis |
top |
The vertical offset to scroll to, in body px; undefined keeps the axis |
behavior |
'smooth', 'instant' or 'auto' |
Requests are returned by setViewMode, zoomTo, scrollToDate, scrollToToday, scrollToTask and
setViewport, and the latest is kept in the snapshot as scrollRequest, so a renderer also sees the
requests of calls it did not make (the application’s scrollToTask, a data change). The contract:
- Perform each
idonce, in the frame that draws its snapshot: comparerequest.idwith the last one you applied. - Size the content first. The values are in the pixels of the snapshot that carries the request,
so set the width of the scrolled content to
contentWidthbefore you scroll. - If
fromis set, jump there instantly, then scroll toleftandtopwithbehavior. A missingleftortopkeeps its axis. - Report the result with
scroll(), as you do for every scroll.
Values are absolute and already account for earlier requests, so skipping a superseded request loses
nothing. A scrollTo* made before the viewport has a width is remembered, the latest wins, and it
replaces scroll.initial when the chart mounts.
Sending input
Section titled “Sending input”core.send({ type: 'down', pointerId: 1, x: 640, y: 24, time: performance.now() });send(event) feeds the interaction state
machines (move, resize, progress and link creation) and applies the previews, the commits, the links
and the range growth itself. The events, in content coordinates:
| Event | Fields |
|---|---|
PointerInput: down, move, up |
pointerId, x, y, time; optional button, pointerType, isPrimary, modifiers, target, event |
KeyInput: key |
key (a KeyboardEvent.key), optional modifiers; 'Escape' cancels a gesture and any key re-reads the modifiers |
ClickInput: click |
target, optional modifiers, event: a click no pointer made (Enter on a focused bar); no hover, no gesture, no double click |
CancelInput: cancel |
Optional pointerId: the pointer was cancelled or lost, so its gesture is rolled back |
TimelineInput: timeline |
The timeline moved under a still pointer; core.scroll already does this for you |
- Coordinates.
xis on the time scale,x = clientX - scrollerLeft + scrollLeft, andyis in body pixels,y = clientY - scrollerTop + scrollTop - headerHeight. timeisevent.timeStamp; it decides double clicks.buttondefaults to0, and any other button is ignored. Touch uses its own drag threshold: passpointerType.targetis what you know is under the pointer: aHitTargetof kind'task'(with apart:'body','resize-start','resize-end','progress','link-start','link-end'),'link'or'empty'. Leave it out and the machine hit-tests the layout, with the same permissions and handle sizes as the snapshot. A link is never found that way, so a renderer that draws links passes{ kind: 'link', id }for a press on one.eventis your own event; it comes back intaskClick,linkClickand the like.
The machine runs one GestureKind at a time:
'move', 'resize-start', 'resize-end', 'progress' or 'link-create'. send returns the
RendererEffects left to you: autoscroll
(a velocity to add to scrollLeft every frame while a drag is near an edge), link-preview (the
line to draw while a link is dragged) and feedback (the cursor, hover and dragged values). All
three are also in the snapshot (autoScroll, feedback), so a renderer that draws from the snapshot
can ignore the return value. Clicks, hovers and commits are not effects: they are events.
Drags keep their date under the pointer through autoscroll, range growth and zoom, because the state machine anchors in time, not in pixels.
Building blocks
Section titled “Building blocks”Everything createGanttCore is made of is exported, so you can use a part on its own. Each is a plain
function over plain values, with no hidden state.
| Area | Exports |
|---|---|
| Time | parseDateInput, parseDuration, parseGranularity: strict parsers that never throw. toDate, toDayKey, fromDayKey. alignFloor, alignCeil, alignNearest, isAligned. createFloatingAdapter, the default DateAdapter |
| Calendar | createWorkCalendar: the work calendar as a function (explain, countDays, addWorkingDays, …) |
| Scale | createTimeScale (dateToX, xToDate), resolveViewModes, VIEW_MODE_PRESETS, headerRows, headerCells, gridLines, currentCell, resolveSnap, snapTime, clampResize |
| Model | normalizeModel, patchTask, patchTasks: tasks to models, and a change set back into your own tasks |
| Hierarchy | buildHierarchy, visibleRows, expandAll, collapseAll, setExpanded, toggleExpanded |
| Schedule | buildLinkGraph, topoSort, wouldCreateCycle, propagate, detectConflicts, applyChangeSet, revalidateChangeSet |
| Progress | progressToDate, dateToProgress, progressFromDate, rebaseProgress |
| Layout | layoutRows, createLayout, barGeometry, placeLabel, visibleWindow, barsInWindow, routeLink, linkBBox |
| Viewport | resolveRange, planExtension, planScrollTo, scrollToTask, keepAnchor, rebaseTimeline |
| Input | createInteraction, hitTest, barHandles, resolvePermissions, INTERACTION_DEFAULTS |
| State | createStore, createEmitter, createHooks, createChangeController, createEditSession, createChangeHistory |
The ones a renderer reaches for, with a line each:
createTimeScale({ origin, pxPerDay }): the linear mapping between a date and an x (dateToX,xToDate).visibleWindow(viewport, rows, scale): the rows and the time to draw for a scroller. The snapshot’swindowis this.layoutRows,createLayoutandbarGeometry: the row metrics, the index from row to bar, and the box of one bar.placeLabel(bar, labelWidth, mode): where a bar’s label goes, inside or beside the bar.hitTest: what lies under a point: a row, a part of a bar, a handle.currentCell: the header cell under a position, for a label that stays in view.planScrollTo,planExtension,keepAnchorandrebaseTimeline: the viewport maths behind scroll requests and the infinite timeline.createInteraction: the state machine behindsend, for a renderer that wants no facade.propagate,createWorkCalendarandpatchTasks: scheduling with no chart at all.propagatemoves a task and returns theChangeSetof everything that follows, andpatchTaskswrites it into your own tasks.
A calendar, a time scale and a propagation used together are in the core README.
A minimal renderer
Section titled “A minimal renderer”This renderer draws the header and the bars of a chart into plain DOM elements, performs scroll requests, and sends pointer input back, so a bar can be dragged. The data and the core are created by the caller:
import { createGanttCore } from '@ganttlane/core';
import { mountBars } from './renderer';
const scroller = document.querySelector<HTMLElement>('#chart');if (!scroller) throw new Error('#chart not found');
const core = createGanttCore({ tasks: [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 60 }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' }, ], links: [{ source: 'design', target: 'build' }], viewMode: 'week', scroll: { initial: 'first-task' }, // Only the drag of a whole bar is drawn here, so only that is on. editing: { move: true, resize: false, progress: false, links: false }, onTasksChange: (changeSet) => { console.log(changeSet.reason, changeSet.changes.length); },});
const stop = mountBars(scroller, core);// Later: stop(); core.destroy();<div id="chart" style="height: 360px"></div>import { barsInWindow, type GanttCore, type GanttSnapshot, headerRows, type PointerInput, type ScrollRequest,} from '@ganttlane/core';
export function mountBars(scroller: HTMLElement, core: GanttCore): () => void { // The scroller holds a sticky header and the body, both as wide as the content. const content = document.createElement('div'); const header = document.createElement('div'); const body = document.createElement('div'); scroller.style.overflow = 'auto'; content.style.position = 'relative'; header.style.cssText = 'position: sticky; top: 0; z-index: 1; background: #f5f6f8'; body.style.position = 'relative'; content.append(header, body); scroller.append(content);
const controller = new AbortController(); const { signal } = controller; let appliedRequest = 0;
function perform(request: ScrollRequest): void { // `from` first, instantly; then the target, with the request's behaviour. if (request.from !== undefined) scroller.scrollLeft = request.from; const target: ScrollToOptions = { behavior: request.behavior }; if (request.left !== undefined) target.left = request.left; if (request.top !== undefined) target.top = request.top; scroller.scrollTo(target); }
function draw(snapshot: GanttSnapshot): void { const { rows } = snapshot.layout;
// Size the content first: a scroll request is in these pixels. content.style.width = `${snapshot.contentWidth}px`; header.style.height = `${rows.headerHeight}px`; body.style.height = `${rows.bodyHeight}px`;
// 1. Perform a scroll request once, in the frame that draws its snapshot. const request = snapshot.scrollRequest; if (request !== undefined && request.id > appliedRequest) { appliedRequest = request.id; perform(request); }
// 2. The header: the cells of the window, one row of cells per header row. const cells: HTMLElement[] = []; let top = 0; const headers = headerRows(snapshot.viewMode, snapshot.window, snapshot.header); for (const [index, row] of headers.entries()) { const height = rows.headerRowHeights[index] ?? 0; for (const cell of row) { const element = document.createElement('div'); element.textContent = cell.label; element.style.cssText = `position: absolute; left: ${cell.x}px; top: ${top}px; width: ${cell.width}px; height: ${height}px; box-sizing: border-box; border-left: 1px solid #d3d9e1; overflow: hidden`; cells.push(element); } top += height; } header.replaceChildren(...cells);
// 3. The bars: only the window, with the dragged draft already laid over the data. const bars = barsInWindow(snapshot.window, snapshot.layout).map((bar) => { const element = document.createElement('div'); element.dataset['taskId'] = String(bar.id); element.textContent = core.getTask(bar.id)?.name ?? ''; element.style.cssText = `position: absolute; left: ${bar.x}px; top: ${bar.y}px; width: ${bar.width}px; height: ${bar.height}px; border-radius: ${bar.radius}px; background: #3f6fd1; color: #fff; overflow: hidden`; const done = document.createElement('div'); done.style.cssText = `position: absolute; inset: 0 auto 0 0; width: ${bar.progressWidth}px; background: rgb(0 0 0 / 30%)`; element.prepend(done); return element; }); body.replaceChildren(...bars);
scroller.style.cursor = snapshot.feedback.cursor; }
// The core publishes a new snapshot when something changed: draw it. const unsubscribe = core.subscribe(() => { draw(core.getSnapshot()); });
// Tell the core how big the scroller is. The first non-zero width mounts the chart. const resize = (): void => { core.setViewport({ width: scroller.clientWidth, height: scroller.clientHeight }); }; const observer = new ResizeObserver(resize); observer.observe(scroller); resize();
// Report the scroll position (the browser fires `scroll` once per frame at most). scroller.addEventListener( 'scroll', () => { const change = core.scroll({ left: scroller.scrollLeft, top: scroller.scrollTop }); // Only with `timeline.infinite`: the content grew at its start, keep the date in place. if (change !== undefined) scroller.scrollLeft += change.shift; }, { signal }, );
// Pointer input, in content coordinates: x on the time scale, y below the header. const send = (type: PointerInput['type'], event: PointerEvent): void => { const box = scroller.getBoundingClientRect(); core.send({ type, pointerId: event.pointerId, x: event.clientX - box.left + scroller.scrollLeft, y: event.clientY - box.top + scroller.scrollTop - core.getSnapshot().layout.rows.headerHeight, time: event.timeStamp, button: event.button, pointerType: event.pointerType, isPrimary: event.isPrimary, event, }); }; scroller.addEventListener( 'pointerdown', (event) => { scroller.setPointerCapture(event.pointerId); send('down', event); }, { signal }, ); scroller.addEventListener('pointermove', (event) => void send('move', event), { signal }); scroller.addEventListener('pointerup', (event) => void send('up', event), { signal }); scroller.addEventListener( 'pointercancel', (event) => void core.send({ type: 'cancel', pointerId: event.pointerId }), { signal }, );
draw(core.getSnapshot());
// Leave nothing behind. The core is the caller's: it is not destroyed here. return () => { controller.abort(); observer.disconnect(); unsubscribe(); content.remove(); };}What it does:
- Size and scroll. A
ResizeObserverfeedssetViewport; the first width mounts the chart, and the initial scroll request (first-task) arrives through the snapshot. Thescrolllistener reports the position back, andperformfollows the contract above. - Drawing.
drawruns on every snapshot. It sizes the content, performs a pending request, and rebuilds the header cells and the bars of the window. Rebuilding is the simplest thing that works; the stock renderer reconciles by key and touches only what changed, and draws once per animation frame instead of once per snapshot. - Dragging. A press on a bar and moves of the pointer become
down,moveandup. The snapshot already has the dragged bar at its previewed place, so nothing here moves it; theupcommits the change, andonTasksChangehears oneChangeSetwith the dragged task and the tasks that followed it along the link.
What it leaves out is what makes the stock renderer long: link arrows (routeLink, with
{ kind: 'link', id } targets), the handles of a bar (barHandles), labels outside narrow bars, grid
lines, the today line (scale.dateToX(snapshot.now)), non-working days, the link preview, popovers,
drag autoscroll (snapshot.autoScroll with createAutoScroller from @ganttlane/dom/dom-utils),
touch handling (touch-action: none on bars that can move), and keyboard and accessibility semantics.
The playground runs the stock renderer on the same core, and is the better reference for everything the minimal renderer omits.
Development and production builds
Section titled “Development and production builds”@ganttlane/core ships two builds of the same code. They report the same issues (code, severity,
path, value, suggested and the ids) and behave identically. No extra check runs in the
development build: the difference is the message of an issue.
| Build | File | Export condition | Issue message |
|---|---|---|---|
| Production | dist/index.js |
default |
Short: '<path>: <code>' |
| Development | dist/index.development.js |
development |
A full English explanation, with the offending value and a hint |
The explanatory texts exist only in the development build: the bundler folds a build-time constant
(__GANTTLANE_DEV__), so the production build has none of them. The development build is about 4 kB
gzipped larger. Branch on code and path, never on message.
Vite and webpack choose the development condition in development mode on their own. This is why
@ganttlane/dom and @ganttlane/react give you readable issues while you build and short ones in
production, although neither has a development build of its own (the issues of the renderer’s own
options are always short). Elsewhere, opt in explicitly:
import { defaultClientConditions, defineConfig } from 'vite';
// Readable issue messages in a production build, for a staging site.export default defineConfig({ resolve: { conditions: ['development', ...defaultClientConditions] },});node --conditions=development server.jsesbuild app.ts --bundle --conditions=developmentOther tools: @rollup/plugin-node-resolve takes exportConditions: ['development'], and Jest takes
testEnvironmentOptions.customExportConditions.
@ganttlane/react has development warnings of its own (a controlled and uncontrolled switch, a
missing onTasksChange), switched by process.env.NODE_ENV as React’s are; see the
React guide.
For bindings of other frameworks
Section titled “For bindings of other frameworks”createChart
Section titled “createChart”@ganttlane/dom/facade is the part of createGantt that needs no DOM.
createChart(options) makes the same chart,
with the same routing of the options to the core and the renderer, the same validation and the one
list of issues, but draws nothing and touches no DOM. chart.gantt.core.getSnapshot() already
answers, also on a server. It is what @ganttlane/react is built on, and what a binding for Vue,
Svelte or a web component needs.
import { createChart } from '@ganttlane/dom/facade';
const chart = createChart({ tasks, viewMode: 'week' });chart.gantt.setOptions({ theme: 'dark' }); // kept until the chart is mountedchart.mount(element); // draws the renderer into an empty element with a heightchart.unmount(); // removes the renderer; the core and its state staychart.mount(other); // draws it again, with whatever setOptions changed meanwhilechart.gantt.destroy(); // ends the renderer and the coreA GanttChart has gantt (the object
createGantt returns), mount and unmount. createGantt(container, options) is
createChart(options, container).gantt. Application code uses createGantt.
mountRenderer and layers
Section titled “mountRenderer and layers”mountRenderer(container, core, options) is the
stock renderer alone, for a core you create and share yourself. It takes the renderer’s options
(theme, strings, height, display, idPrefix, layers, frames), returns an object with
destroy, setDisplay, setStrings, setHeight, setTheme and hideTooltip, and leaves the core
alone on destroy(). Several renderers can share a page, and even a core.
The renderer is made of six layers (header, grid, links, bars, overlay, popover), each
owning one element of the skeleton. The layers option of mountRenderer, and renderer.layers of
createGantt (at creation only), replace built-in layers by name with a
RendererLayer: mount(context),
update(snapshot, previous) and destroy().
import { barsInWindow } from '@ganttlane/core';import { createGantt, type RendererLayer } from '@ganttlane/dom';
// A bars layer of your own: it owns `g.gantt__bars` and nothing else.function createDotsLayer(): RendererLayer<SVGGElement> { let root: SVGGElement | undefined; return { mount: (context) => { root = context.root; // a layer adds nodes below its root and nowhere else }, update: (snapshot) => { // Draw from the snapshot, and read no layout here. const dots = barsInWindow(snapshot.window, snapshot.layout).map((bar) => { const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); dot.setAttribute('class', 'gantt__bar'); dot.setAttribute('data-task-id', String(bar.id)); dot.setAttribute('cx', String(bar.x)); dot.setAttribute('cy', String(bar.y + bar.height / 2)); dot.setAttribute('r', '6'); return dot; }); root?.replaceChildren(...dots); }, destroy: () => { root?.replaceChildren(); }, };}
createGantt(container, { tasks, renderer: { layers: { bars: createDotsLayer } } });The LayerContext has, among other things, the
root element, the scroller, the display and strings in use, an AbortSignal that ends with the
chart (give every listener this signal), send to the core, on, getTask and getLink.
A layer replaces what it draws, and whatever relies on that. The renderer’s pointer input finds the
bar under the pointer through the class gantt__bar and the data-task-id of the
unstyled contract, and falls back to hit-testing the layout when
the DOM cannot tell, so a bars layer of your own should keep both.
dom-utils
Section titled “dom-utils”@ganttlane/dom/dom-utils holds the helpers the renderer and other renderers share. None knows the
layers or the core.
| Helpers | What they do |
|---|---|
createDisposables, listen |
A bag of cleanups released newest first; a listener owned by an AbortSignal (the signal is required) |
observeSize, measureSize |
A ResizeObserver that reports the content size (equal sizes once); a one-off measure |
allocateIdPrefix, prefixedId, DEFAULT_ID_BASE |
Unique element ids per document and instance |
measureFrame, pointerInput, keyInput, hitTargetOf, pointerOnScrollbar, pointerOutsideBody |
The Pointer Events adapter: DOM events to the core’s PointerInput and KeyInput, targets read from the unstyled contract |
createAutoScroller |
The drag autoscroll loop: moves scrollLeft by snapshot.autoScroll each frame |
placePopover, pointRect, viewportRect, intersectRects, CURSOR_SIZE |
Popover placement above or below an anchor, flipped and shifted into bounds; pure |
scrollByInstant, scrollHostOf, measureHost, observeHostSize, forwardScroll |
Scrolling helpers, including the page host of a chart with automatic height |
showsNothing |
The rule for markup that shows nothing (an empty tooltip) |
ROOT_CLASS |
The class of a chart’s root element, 'gantt' |
For a renderer in the DOM, measureFrame once when a press starts, then pointerInput for each
event, replaces the coordinates arithmetic of the minimal renderer above and also handles a chart
scaled by a CSS transform:
import { hitTargetOf, measureFrame, pointerInput } from '@ganttlane/dom/dom-utils';
scroller.addEventListener('pointerdown', (event) => { const frame = measureFrame(scroller, header); const { timeline, scrollTop, layout } = core.getSnapshot(); const scroll = { scrollLeft: timeline.scrollLeft, scrollTop, headerHeight: layout.rows.headerHeight, }; core.send(pointerInput('down', event, frame, scroll));});Related
Section titled “Related”- Guides: Options, Events, Methods, Change sets and hooks, Edit sessions and history, React and Theming, whose unstyled contract is what the stock renderer’s input reads.
- No example covers custom renderers. The
playground runs the stock renderer on its
own core, and its
/bench/page draws large plans. - API reference:
createGanttCore,GanttCore,GanttSnapshotand the@ganttlane/coremodule; the@ganttlane/dommodule and its facade and dom-utils.