Add a dock pane
The bottom dock is a shared region, not the data table's private property: one grid row, one height, one fold, however many panes are in it. addDockTab() is how you put something else in there — the same door the built-in table comes through, so a pane you register is its equal rather than its guest.
This card adds a Summary pane that rolls the graph up by owner or by kind. The dock's strip reads Table │ Summary; click across and the body and the header controls change with it, because Select all / CSV / Columns belong to the table and mean nothing over a roll-up.
One tab is one pane
The Summary pane shows the graph two ways — By owner and By kind — and those are views of one pane, not two panes. So it registers a single dock tab and draws its own switch, calling refresh() to change body.
That distinction is the whole design. Registering the two views as two dock tabs would list them in the dock's strip beside Table, claiming that a view of the summary and the entire data table are the same kind of thing. The built-in table does exactly what this pane does: Nodes and Edges are its tabs, drawn on its own bar.
The two levels are drawn differently so they can sit next to each other and still read as an outer and an inner:
| Look | Class | |
|---|---|---|
Table │ Summary | full-height tabs, underlined when active, closed off by a rule | pvt-dock-tabs / pvt-dock-tab |
Nodes │ Edges, By owner │ By kind | a small pill group | pvt-dock-views / pvt-dock-view |
The inner pair is public, which is why the switch below is three class names rather than a block of inline style: your pane gets the same control the built-in table has, and a consumer who retints --pvt-theme-primary or overrides either class retints both levels at once.
refresh() is not optional politeness
The dock keeps the element your render returned and re-attaches it when your pane comes back to the front. A pane that swapped its own DOM would leave the dock holding a stale node to hand back later. So changing body goes through refresh(), which calls render again.
Knowing when nobody is looking
onActivate / onDeactivate are the only signal a pane gets that it is off screen, and what to do with them is yours to choose:
- Content that is a function of the graph's current state can stop working while hidden and re-derive on return. That is what this pane does, and what the data table does.
- Content that would miss something has to keep working and merely stop painting — a pane watching a live bus, say, since an event is gone once it has fired.
Nothing about the hooks prefers either.
It brings the dock with it
Plugins install after the UI is built, so a dock tab always arrives after the region's own mode gate has run. Registering one therefore builds the dock — your plugin works with UI.table: false and needs nothing turned on but full mode. With the table off and one pane of your own, no strip is drawn at all: the dock is simply your pane.
UI.dock configures the region itself (open, collapsed, height) — reach for it rather than UI.table when the table is switched off, since that is then the only door.
See Plugins for the contract and Data table for the dock's own options.
// The bottom dock is a shared region: one row, one height, one fold, however many panes
// are in it. `addDockTab` is the door — the same one the built-in data table comes
// through, so a pane you register is its equal rather than its guest.
//
// **One tab is one pane, not one view of one.** This pane rolls the graph up two ways,
// by owner and by kind. Those are two views of *this* pane, so it registers a single
// dock tab and draws its own switch, calling `refresh()` to change body. Registering
// them as two dock tabs would put them in the dock's strip beside `Table`, claiming a
// view of the summary and the whole data table are the same kind of thing.
//
// That is also why the two levels look different: the dock draws panes as full-height
// underlined tabs, and a pane's own views a lighter pill group. That inner look is public
// — `pvt-dock-views` on the strip, `pvt-dock-view` on each button, `active` on the current
// one — so your pane matches the built-in table for free, and follows the theme when a
// consumer overrides `--pvt-theme-primary` or either class.
const VIEWS = [
{ key: 'owner', label: 'By owner' },
{ key: 'kind', label: 'By kind' },
]
/** Count the nodes under each value of `key`, biggest group first. */
function rollUp(graph, key) {
const counts = new Map()
for (const node of graph.getNodes()) {
const value = node.getData()?.[key] ?? '—'
counts.set(value, (counts.get(value) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}
/**
* The pane's body. `render` is called once per pane, lazily, the first time it is
* opened — and again on every `refresh()`, which is how the view switch works.
*/
function renderSummary(graph, key) {
const rows = rollUp(graph, key)
const biggest = Math.max(...rows.map(([, count]) => count), 1)
const body = document.createElement('div')
body.style.cssText = 'padding: 8px 12px; font: 12px/1.9 sans-serif'
for (const [value, count] of rows) {
const row = document.createElement('div')
row.style.cssText = 'display: grid; grid-template-columns: 130px 1fr 34px; gap: 10px; align-items: center'
const name = document.createElement('span')
name.textContent = value
// A bar rather than a number alone: the shape of the split is the point.
const track = document.createElement('span')
track.style.cssText = 'height: 7px; border-radius: 4px; background: var(--pvt-bg-color-4)'
const fill = document.createElement('span')
fill.style.cssText = `display: block; height: 100%; border-radius: 4px; background: var(--pvt-vibrant-blue); width: ${(count / biggest) * 100}%`
track.appendChild(fill)
const tally = document.createElement('span')
tally.textContent = String(count)
tally.style.cssText = 'text-align: right; color: var(--pvt-text-color-3)'
row.append(name, track, tally)
body.appendChild(row)
}
return body
}
/**
* The pane's own controls, in the header slot the dock hands it. `toolbar` is re-invoked
* on every activation, so these always read the pane's current state.
*/
function renderControls(current, pick) {
const strip = document.createElement('div')
strip.className = 'pvt-dock-views'
for (const view of VIEWS) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'pvt-dock-view'
button.textContent = view.label
button.classList.toggle('active', view.key === current)
button.setAttribute('aria-pressed', String(view.key === current))
button.addEventListener('click', () => pick(view.key))
strip.appendChild(button)
}
return strip
}
/**
* Ships as a plugin, so it installs itself and the core never needs to know it exists.
* A plugin's tab also *builds* the dock if there isn't one — plugins install after the
* UI, so it always arrives after the region's own gate has run.
*/
function summaryPane() {
return {
name: 'summaryPane',
install(ctx) {
let view = 'owner'
// Whether the pane is the one on show, and whether the graph moved while it
// wasn't. `onActivate` / `onDeactivate` are the only signal you get, and this
// is the shape most panes want: don't work while hidden, catch up on return.
let visible = false
let stale = false
ctx.addDockTab({
id: 'summary',
label: 'Summary',
render: () => renderSummary(ctx.graph, view),
toolbar: (pane) => renderControls(view, (key) => {
view = key
// The dock keeps the element `render` gave it, so ask it to rebuild
// rather than swapping the DOM behind its back.
pane.refresh()
}),
onActivate: (pane) => {
visible = true
if (stale) { stale = false; pane.refresh() }
},
onDeactivate: () => { visible = false },
})
// The graph outlives nothing here, but a real plugin should keep the
// unsubscribe — see the Extend with a plugin card.
ctx.graph.on('dataBatchChanged', () => {
if (visible) ctx.refreshDockTab('summary')
else stale = true
})
},
}
}
const options = {
UI: {
mode: 'full',
// The dock is the subject, so it starts expanded; the table is the pane our own
// one sits beside, and is what makes the strip appear at all.
dock: { open: true, height: 0.4 },
table: { columns: [
{ key: 'label', label: 'Service', type: 'text' },
{ key: 'kind', label: 'Kind', type: 'select' },
{ key: 'owner', label: 'Owner', type: 'select' },
] },
// Full mode's minimap isn't what this card is about.
minimap: false,
},
plugins: [summaryPane()],
}const data = {
nodes: [
{ id: 'api', data: { label: 'api-gateway', kind: 'service', owner: 'Platform' } },
{ id: 'auth', data: { label: 'auth-service', kind: 'service', owner: 'Identity' } },
{ id: 'billing', data: { label: 'billing-service', kind: 'service', owner: 'Payments' } },
{ id: 'search', data: { label: 'search-service', kind: 'service', owner: 'Discovery' } },
{ id: 'users-db', data: { label: 'users-db', kind: 'datastore', owner: 'Identity' } },
{ id: 'orders-db', data: { label: 'orders-db', kind: 'datastore', owner: 'Payments' } },
{ id: 'index', data: { label: 'search-index', kind: 'datastore', owner: 'Discovery' } },
{ id: 'cache', data: { label: 'edge-cache', kind: 'datastore', owner: 'Platform' } },
{ id: 'mailer', data: { label: 'mailer', kind: 'worker', owner: 'Payments' } },
{ id: 'reindexer', data: { label: 'reindexer', kind: 'worker', owner: 'Discovery' } },
],
edges: [
{ from: 'api', to: 'auth', data: { label: 'authenticates' } },
{ from: 'api', to: 'search', data: { label: 'queries' } },
{ from: 'api', to: 'billing', data: { label: 'charges' } },
{ from: 'api', to: 'cache', data: { label: 'reads' } },
{ from: 'auth', to: 'users-db', data: { label: 'reads' } },
{ from: 'billing', to: 'orders-db', data: { label: 'writes' } },
{ from: 'billing', to: 'mailer', data: { label: 'enqueues' } },
{ from: 'search', to: 'index', data: { label: 'reads' } },
{ from: 'reindexer', to: 'index', data: { label: 'writes' } },
{ from: 'reindexer', to: 'orders-db', data: { label: 'reads' } },
],
}