Guides / Writing a plugin

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

interface Plugin { name: string install(ctx: GanttContext): (() => void) | void }
  • name is unique per engine installing a duplicate throws.
  • install runs immediately on engine.use(), then the engine recomputes.
  • The returned function is the disposer. It runs on removePlugin(name) or destroy(). 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.

MemberWhat it is
ctx.storeObservable state get(), set(patch), subscribe(fn).
ctx.eventsEvent bus on, once, off, emit.
ctx.commandsCommand registry register, execute, has, list.
ctx.servicesService registry provide(key, obj), consume(key), has(key).
ctx.uiUI slot registry register(contribution) for toolbar/overlay DOM.
ctx.hooksThe transform pipelines hooks.rows and hooks.scene.
ctx.engineThe 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:

HookSignatureRuns during
hooks.rows(rows) => Row[]The heavy pass only. Transform data filter, sort, add hierarchy.
hooks.scene(scene, { scale, layouts, options }) => SceneEvery 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:

typeKey fields
rectx, y, width, height, rx?
linex1, y1, x2, y2
pathd, markerEnd?
polygonpoints, transform?
textx, 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 | undefined

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

ctx.ui.register({ slot: 'toolbar' | 'overlay', id: string, order?: number, mount({ element, viewport, engine, events }): (() => void) | void })

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