Plugins
A plugin is a self-contained bundle of UI elements, keybindings and lifecycle hooks that installs itself into a graph without the core needing to know it exists. Register one declaratively or imperatively:
import { Pivotick, minimap } from 'pivotick'
// declaratively
const graph = new Pivotick(container, data, { plugins: [minimap()] })
// …or at any point later
graph.use(minimap({ position: 'bottom-left' }))A plugin installed after the graph is live is caught up to the current lifecycle phase, so there is no "too late" — and everything it registers is torn down with the UI.
The shape of a plugin
import type { PivotickPlugin } from 'pivotick'
const myPlugin: PivotickPlugin = {
name: 'my-plugin', // used for de-duplication and logging
install(ctx) { /* … */ }, // called once
}Installing the same name twice warns and skips the duplicate, so a plugin can be listed in plugins and re-applied through graph.use without doubling up.
What install is handed
ctx is a {@link PluginContext}:
| Member | What it's for |
|---|---|
graph | the live Graph — nodes, edges, the data event bus, queryEngine |
ui | the UIManager — options, notifications, the mode store |
layout | the DOM scaffold, read live (never a snapshot). layout.canvas is where canvas-docked chrome goes |
addElement(element, slot?) | put a UIComponent into the lifecycle, mounted into slot |
addPanel(panel) / removePanel(id) / refreshPanel(id?) | sidebar panels — the same door as UI.extraPanels |
addDockTab(tab) / removeDockTab(id) | a pane in the bottom dock — the same door the built-in table comes through |
addRailMode(mode) / removeRailMode(id) | a mode on the left rail, beside Select / Create / View / Physics |
addPivot(definition) | a runnable enrichment — see Pivots & enrichment |
onPhase(phase, cb) | hook afterMount / graphReady / destroy; returns an unsubscribe |
addKeybinding(binding) | a shortcut that is removed when the UI is torn down |
keyManager | the keybinding registry, for anything more involved |
A binding's key is modifiers then the key, joined by +: 'Shift+K', 'Ctrl+F', 'ArrowUp'. Write modifiers as Ctrl, Meta (Cmd on macOS), Shift, Alt, in that order, and use Mod to match either Ctrl or Meta — which is what a shortcut meant to work on both platforms wants. The key itself is KeyboardEvent.key verbatim, so it is case-sensitive and shift changes it: the two halves of an undo pair are 'Mod+z' and 'Mod+Shift+Z'. Bindings never fire while a text field has focus.
Which layout slots exist depends on the mode: canvas and notification always, graphnavigation in every mode but static, mainheader / modal / slidePanel / moderail / toolpanel / flyout / legend in full and light, and sidebar in full only. Read the slot at the moment you need it rather than caching it.
Contributing a UI element
Extend UIComponent and the lifecycle is driven for you — including teardown of anything registered through track / listen / trackInteraction:
import { UIComponent } from 'pivotick'
class Watermark extends UIComponent {
onMount(slot) {
this.element = document.createElement('div')
this.element.textContent = 'draft'
slot?.appendChild(this.element)
}
onGraphReady() {
// `listen` and `trackInteraction` unsubscribe themselves on destroy
this.trackInteraction('canvasZoom', () => this.reposition())
}
onDestroy() {
this.element?.remove()
}
}
const watermark: PivotickPlugin = {
name: 'watermark',
install: (ctx) => ctx.addElement(new Watermark(ctx.ui), ctx.layout?.canvas),
}The four phases are mount(slot) → afterMount() → graphReady() → destroy(). graphReady fires once the simulation has settled, so it can be seconds after mount on a big graph — anything that should be on screen immediately belongs in onMount / onAfterMount (deferred a frame if it needs graph.renderer, which is constructed after the UI).
See the Extend with a plugin gallery card for a live, complete example.
Contributing a dock pane
ctx.addDockTab puts a pane in the bottom dock, beside the data table. The dock owns the region — its height, its divider, its fold and the strip that names the panes — and your pane owns what is in it:
const auditLog: PivotickPlugin = {
name: 'auditLog',
install: (ctx) => ctx.addDockTab({
label: 'Audit',
render: () => buildPane(ctx.graph), // once, on first activation
toolbar: () => [clearButton], // on every activation
onActivate: () => resumePainting(),
onDeactivate: () => stopPainting(),
}),
}Four things are worth knowing:
- A tab is a pane, not a view of one. If your pane has several views of its own, it stays a single dock tab and draws its own switch in
toolbar, callinghandle.refresh()to change body — which re-invokesrender. That is exactly what the data table does forNodes/Edges, and why the dock's strip never flattens one pane's views out beside another pane. Switching your own DOM behind the dock's back does not work: it keeps the elementrendergave it, and would re-attach a stale node on the next activation. Draw an inner switch as a segmented control, not as tabs — the outer level already looks like tabs.pvt-dock-viewson the strip andpvt-dock-viewon each button (plusactive) are public, so it looks like the table's switch and follows the theme without you restating either. - The first pane builds the region. Plugins install after the UI is built, so a tab always arrives too late for the dock's own mode gate to have said yes on its behalf. Registering one brings the dock into being, which means your plugin works with
UI.table: falseand needs nothing turned on butfullmode. renderis called once, lazily, the first time the pane is opened; the element is kept and re-attached afterwards, so it holds its own scroll position.toolbaris rebuilt on every activation, so its controls can read your pane's current state.onActivate/onDeactivateare the only signal that you are off screen, and what to do with them depends on your pane. Content that is a function of the graph's current state can stop working while hidden and re-derive on return — that is what the table does. Content that would miss something (a pane watching a live bus — an event is gone once it has fired) has to keep working and merely stop painting, flushing its backlog when it comes back. Nothing about the hooks prefers either.
A tab is not a UIComponent, so nothing drives lifecycle phases into it. When your pane needs graphReady, hold a UIComponent, addElement it, and call addDockTab from its onMount — see Contributing a UI element above.
See the Add a dock pane gallery card for a live, complete example — a pane with two views of its own, beside the data table.
Contributing a rail mode
The left rail's four modes — Select, Create, View, Physics — are built in. Anything else is yours: addRailMode puts a mode of your own below a divider, after them, ordered among its peers by order.
A pointer mode owns the contextual tool panel. Declare its tools and the panel draws them the way Select's and Create's are drawn, with the arming, the enabled/disabled states and the collapse handled for you:
const explore = {
name: 'explore-mode',
install(ctx) {
const selected = () => ctx.graph.renderer.getGraphInteraction().getSelectedNode()?.node
ctx.addRailMode({
id: 'explore',
label: 'Explore',
icon: compassSvg,
shortcut: 'E',
tools: [
{ id: 'expand', label: 'Expand neighbours', icon: plusSvg, kind: 'action',
enabled: () => !!selected(),
run: () => expandFrom(ctx.graph, selected()) },
{ id: 'walk', label: 'Path walk', icon: pathSvg, kind: 'toggle',
run: (armed) => armPathWalk(ctx.graph, armed) },
],
onExit: () => armPathWalk(ctx.graph, false),
})
},
}
new Pivotick(el, data, { plugins: [explore] })A tool's kind decides how it behaves. An 'action' runs once and leaves the mode as it was. A 'toggle' arms a modal tool: the panel collapses, and the rail button morphs to that tool's own icon and label, exactly as Select's slot becomes Lasso. A 'default' is the tool the mode rests on; name it in defaultTool and it is what a toggle reverts to.
tools may also be a function, re-read every time the panel is drawn — use that when the rows depend on what is selected, rather than only greying out. For anything a row cannot express, render() returns an element appended below the rows:
ctx.addRailMode({
id: 'explore', label: 'Explore', icon: compassSvg,
tools: () => toolsFor(ctx.graph.renderer.getGraphInteraction().getSelectedNode()),
render: () => depthSlider(),
})A flyout mode opens a settings overlay instead, like View and Physics. Subclass Flyout and hand addRailMode a factory — core mounts the panel, opens it exactly while your mode is active, and takes it away with the mode:
import { Pivotick, Flyout } from 'pivotick'
class EnrichFlyout extends Flyout {
mode = 'enrich' // must match the definition's id
template() { return this.headerRow(sparklesSvg, 'Enrich') + this.toggleRow('auto', gearSvg, 'Auto-enrich', 'Enrich on load') }
wire() { this.wireToggle('auto', () => toggleAuto(), () => isAuto()) }
}
ctx.addRailMode({
id: 'enrich', label: 'Enrich', icon: sparklesSvg,
kind: 'flyout',
flyout: (ui) => new EnrichFlyout(ui),
})Modes are mutually exclusive, so yours excludes View and Physics for free — one store, one active mode.
Three things worth knowing:
iconis a raw SVG string, injected as HTML and never sanitised. It must come from a source you trust. CSS sizes it to 20px on the rail, 18px in the panel.shortcutfollows the normal keybinding rules: claiming a key something else already owns shadows it, with a warning, until your mode is removed.onExitis where you disarm. It runs when the mode is left, and when it is removed while active — in which case the rail falls back to Select. It does not run on UI teardown; the disposers your plugin already tracks cover that.
addRailMode returns a disposer, and works in every mode — the button is only drawn where there is a rail, which means full and light.
Driving the viewport
Anything that navigates the graph — a minimap, an overview, a "jump to" control — uses two renderer methods:
// The extent of everything drawn, in graph coordinates (null when there's nothing).
const bounds = graph.renderer.getContentBounds()
// Put a graph-space point in the middle of the canvas.
graph.renderer.setViewport({ x: 120, y: -40 })
graph.renderer.setViewport({ x: 0, y: 0, scale: 1.5, animate: true })setViewport leaves the scale alone unless you pass one, so it is the primitive for panning. To read where the view currently is, invert the canvas corners rather than reaching for a zoom transform — this is renderer-agnostic:
const rect = graph.UIManager.layout.canvas.getBoundingClientRect()
const topLeft = graph.renderer.screenToGraphCoordinates(rect.left, rect.top)
const bottomRight = graph.renderer.screenToGraphCoordinates(rect.right, rect.bottom)fitAndCenter() (fit everything), zoomIn() / zoomOut() and focusElement(nodeOrEdge) remain the shortcuts for the common cases.
The minimap
A first-party plugin: a cached overview of the whole graph docked in a canvas corner, with a rectangle showing what is on screen. Click it to recentre the view; drag the rectangle to pan. A very small toggle in the corner it faces folds it away to just that button, and brings it back.
full mode mounts one for you — it is part of that mode's chrome, like the header, the sidebar and the mode rail. Every other mode leaves it to you:
// full mode: already there, nothing to install
new Pivotick(container, data, { UI: { mode: 'full' } })
// any other mode: ask for it, with `UI.minimap` …
new Pivotick(container, data, { UI: { mode: 'light', minimap: true } })
// … or as the plugin it is
import { Pivotick, minimap } from 'pivotick'
new Pivotick(container, data, { UI: { mode: 'light' }, plugins: [minimap()] })UI.minimap takes the same {@link MinimapOptions} the plugin does, so { minimap: { position: 'top-left' } } configures the one full mode brings along. UI.minimap: false suppresses it. Passing your own minimap() in plugins also wins — full mode stands aside rather than mounting a second one — so an existing plugins: [minimap({ … })] keeps working exactly as it did.
<!-- browser build: it hangs off the global, like Node and Edge -->
<script>new Pivotick(el, data, { plugins: [Pivotick.minimap()] })</script>Options
| Option | Type | Default | What it does |
|---|---|---|---|
position | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-right' | Which corner it docks in. 'bottom-right' is the only corner the built-in chrome leaves free in full mode. |
width | number | 200 | Width in CSS pixels, border included. |
height | number | derived | Height in CSS pixels. Omitted, it follows the canvas's aspect ratio (clamped to 70–400px) so the rectangle keeps the shape of the real viewport. |
collapsed | boolean | 'auto' | false, or 'auto' for the one full mode mounts | Which state it opens in. The toggle is always there; this is only where it starts. See Getting out of the way for 'auto'. |
Nothing else is configurable, because nothing else needs to be: the level of detail and the redraw cadence adapt to the graph.
What it draws
Nodes are dots in the colour the renderer gave them, edges are hairlines under them, and notes — the only content with a size of its own — are drawn to scale as translucent blocks in their own colour, over the nodes as they are on the canvas. Hidden and filtered-out content is left out. Past a few thousand nodes the picture becomes a density map instead (one stamp per node, no edges), which reads better than tens of thousands of overlapping dots; notes are drawn either way. It is all cached and only re-rasterised when the picture really changed — a node dropped, a note moved, the layout settling, a filter applied — so panning and zooming stay free however big the graph is.
Getting out of the way
A minimap you asked for stays where you put it. The one full mode mounts on your behalf was not asked for, so it opens collapsed: 'auto' and takes the canvas into account: it stays open while the canvas is at least four minimaps wide and tall, and folds itself away to the toggle below that. It keeps following the canvas from then on — folding away when the sidebar opens over it or the window narrows, coming back when the room does.
The moment anyone folds it away or brings it back — by the toggle, or through setCollapsed() — that stops: an explicit choice sticks, and no later resize overrides it. Pass an explicit collapsed: true / false to opt out of 'auto' from the start.
The toggle's arrow points at the corner the minimap docks in — the direction it folds away — and flips once it is collapsed. Folded away it draws nothing at all, not even the rectangle, so it costs nothing while it is out of the way; opening it re-rasterises for whatever the canvas looks like by then. setCollapsed(boolean) and isCollapsed() on the Minimap instance drive the same thing from code.
What it draws, and what it costs
The minimap maps the content bounds together with the current viewport, so the rectangle is always visible and honestly sized — zoom far out and the graph shrinks inside the frame rather than the rectangle sliding off it.
It keeps the graph in an offscreen bitmap and only re-rasterises it when the picture actually changed: on a data change, when a filter hides or restores nodes, when a node is dropped after a drag, on every 10th simulation tick while a layout settles, and on resize. Panning and zooming redraw one image and one rectangle, so navigating costs the same whether the graph has 20 nodes or 50,000.
Detail degrades with size, on purpose:
| Graph | Drawn as |
|---|---|
| ≤ 1500 nodes | a dot per node in the colour the renderer resolved, plus hairline edges (dropped past 4000 edges) |
| > 1500 nodes | one stamp per node in a single ink, alpha accumulating — dense regions read as a density map, and no per-node style is resolved at all |
It works in full, light and viewer modes, and full is the one that mounts it without being asked. In static — which promises no interactions — it is not mounted, and installing it there warns.
See the Minimap gallery card for a live one.