Writing a plugin
A plugin is any object with a name and an install(ctx) method. Inside install you tap hooks, register commands, provide services or contribute UI and return a teardown function. Every official feature is built this exact way.
The contract
nameis unique per engine installing a duplicate throws.installruns immediately onengine.use(), then the engine recomputes.- The returned function is the disposer. It runs on
removePlugin(name)ordestroy(). Undo everything you did every registry method already hands you a disposer to make that easy.
const helloPlugin = {
name: 'hello',
install(ctx) {
const off = ctx.events.on('task:click', ({ task }) => console.log('clicked', task.name))
return off // teardown removes the listener
},
}
engine.use(helloPlugin)The context
install receives one object the plugin's entire API to the engine.
| Member | What it is |
|---|---|
ctx.store | Observable state get(), set(patch), subscribe(fn). |
ctx.events | Event bus on, once, off, emit. |
ctx.commands | Command registry register, execute, has, list. |
ctx.services | Service registry provide(key, obj), consume(key), has(key). |
ctx.ui | UI slot registry register(contribution) for toolbar/overlay DOM. |
ctx.hooks | The transform pipelines hooks.rows and hooks.scene. |
ctx.engine | The engine itself for refresh(), getRows(), getTimeScale(), etc. |
Hooks
Hooks are ordered transform pipelines. You tap a function that takes a value and returns the next value; taps run in ascending priority (default 0). tap returns a disposer. There are two:
| Hook | Signature | Runs during |
|---|---|---|
hooks.rows | (rows) => Row[] | The heavy pass only. Transform data filter, sort, add hierarchy. |
hooks.scene | (scene, { scale, layouts, options }) => Scene | Every pass. Add or modify vector layers. |
Treat inputs as immutable. Return new arrays/objects rather than mutating rows or scene in place other taps and the renderer share those references. The { …spread } style in the examples below is the norm.
Row hooks run in the heavy pass
Because hooks.rows only runs on data/view/plugin changes, a row transform must not depend on the viewport. When your plugin changes its own state (a new sort key, a toggled filter), call ctx.engine.refresh() to re-run the pipeline.
Scene primitives
A scene is { width, height, layers }; each layer is { name, primitives }. The base layers, in paint order, are: backgrounds, grid, dependencies, bars, handles, milestones, labels. Insert your layer relative to those. Primitives are backend-neutral:
| type | Key fields |
|---|---|
rect | x, y, width, height, rx? |
line | x1, y1, x2, y2 |
path | d, markerEnd? |
polygon | points, transform? |
text | x, y, text, anchor?, baseline? |
All primitives share type, a unique key, and optional className, taskId, title and data (rendered as data-* attributes for hit-testing).
Worked example ① a scene plugin
Let's flag overdue tasks: any task whose end is before today and whose progress is under 1 gets a red outline. This taps hooks.scene, reads the precomputed layouts, and inserts a layer right after bars.
// Insert a layer immediately after a named one.
function insertAfter(scene, afterName, layer) {
const layers = scene.layers.slice()
const idx = layers.findIndex((l) => l.name === afterName)
layers.splice(idx === -1 ? layers.length : idx + 1, 0, layer)
return { ...scene, layers }
}
export function overduePlugin(options = {}) {
const className = options.className ?? 'gantt-overdue'
return {
name: 'overdue',
install(ctx) {
// Tap returns its own disposer hand it straight back as teardown.
return ctx.hooks.scene.tap((scene, { layouts, options }) => {
const today = options.dateAdapter.startOfDay(new Date())
const primitives = []
for (const l of layouts) {
if (l.isMilestone) continue
const end = options.dateAdapter.parse(l.task.end)
const done = (l.task.progress ?? 0) >= 1
if (end >= today || done) continue
primitives.push({
type: 'rect',
key: `overdue-${l.task.id}`,
className,
x: l.x - 1, y: l.y - 1, width: l.width + 2, height: l.height + 2, rx: 4,
data: { 'task-id': l.task.id },
})
}
if (primitives.length === 0) return scene
return insertAfter(scene, 'bars', { name: 'overdue', primitives })
})
},
}
}Style it with the same data-theme tokens as everything else:
.gantt .gantt-overdue { fill: none; stroke: #ff6b5d; stroke-width: 2; }Worked example ② a controller with a command
Now a data plugin that sorts rows by a field. It taps hooks.rows, exposes an imperative setSortKey, and registers a command so the toolbar (or anything else) can drive it. This is the controller + .plugin pattern used by createTree, createFilter and friends.
export function createSort(options = {}) {
let key = options.key ?? null
let refresh = null
function apply(rows) {
if (!key) return rows
// copy before sorting never mutate the input array
return [...rows].sort((a, b) => String(a[key]).localeCompare(String(b[key])))
}
const controller = {
plugin: {
name: 'sort',
install(ctx) {
refresh = () => ctx.engine.refresh()
const offHook = ctx.hooks.rows.tap(apply)
const offCmd = ctx.commands.register('sort.setKey', (k) => controller.setSortKey(k))
return () => { offHook(); offCmd(); refresh = null }
},
},
setSortKey(next) {
key = next
refresh?.() // re-run the heavy pass so the new order takes effect
},
}
return controller
}
// usage
const sort = createSort({ key: 'name' })
engine.use(sort.plugin)
sort.setSortKey('id')
// or, from anywhere with the engine:
engine.commands.execute('sort.setKey', 'id')Commands
Commands are named functions in a shared registry the decoupled way for one plugin to invoke another. The toolbar's expand-all button, for instance, only calls tree.expandAll if it exists:
ctx.commands.register('sort.setKey', (k) => controller.setSortKey(k)) // returns a disposer
if (ctx.commands.has('sort.setKey')) ctx.commands.execute('sort.setKey', 'name')Services
A service is a capability published under a string key that other plugins or the renderer look up. This is how the sidebar works: plugin-columns provides a model under gantt:sidebar, and the renderer draws a sidebar only when that key is present. Keys are unique; provide returns a disposer.
install(ctx) {
const model = { getRowIndent: (row) => (row.level ?? 0) * 16 /* … */ }
const off = ctx.services.provide('myteam:sidebar-extras', model)
return off
}
// elsewhere
const extras = ctx.engine.consume('myteam:sidebar-extras') // model | undefinedUI slots plugin DOM
For HTML UI (toolbars, tooltips, menus) plugins render plain DOM into a host the renderer provides, so one plugin works across svg/html/canvas. Register a contribution for a slot 'toolbar' (a bar above the chart) or 'overlay' (a pointer-transparent layer over it).
Here's a live task-count badge in the toolbar:
export function countBadgePlugin() {
return {
name: 'count-badge',
install(ctx) {
return ctx.ui.register({
slot: 'toolbar',
id: 'count-badge',
order: 100,
mount({ element, engine, events }) {
const badge = document.createElement('span')
badge.className = 'gantt__tb-btn'
element.appendChild(badge)
const update = () => {
const n = engine.getRows().reduce((s, r) => s + r.tasks.length, 0)
badge.textContent = `${n} tasks`
}
update()
const off = events.on('scene:change', update)
return () => { off(); badge.remove() } // mount's teardown
},
})
},
}
}The mount callback's own return value is a teardown run when the contribution is removed or the renderer re-mounts slots. viewport is the scroll container attach mousedown there for drag interactions, and use the gantt:viewport service's clientToScene to convert coordinates before calling engine.hitTest.
Best practices
- Always return a disposer and undo everything listeners, taps, commands, services, DOM. Registry methods return disposers precisely so you can collect and call them.
- Never mutate inputs. Copy rows/scene before changing them; return the new value.
- Call
refresh()after internal state changes so a row hook re-runs. Scene-only plugins can rely on the next natural rebuild, but calling refresh is always safe. - Bail early. If your scene tap produces nothing, return the scene unchanged don't add empty layers.
- Use
prioritywhen order matters against another tap; lower runs first. - Prefer services + commands over reaching into other plugins, so features stay swappable.
- Namespace your keys (
myteam:thing) and command names (myteam.do) to avoid collisions.
Because the engine runs headless, you can unit-test a plugin with no browser: construct a GanttEngine, use() your plugin, then assert on engine.getRows() or engine.getScene(). That's how the official plugins are tested.