Skip to content
Pivotick
Main Navigation HomeGetting StartedConfigurationGalleryTry it out!Generated API docs

Appearance

Sidebar Navigation

Getting Started

Configuration

Callbacks

Edge layers

Layout

Render

Simulation

Customizing UI

Sidebar

Tooltip

Context Menu

Filters

Legend

Data table

Styling UI

Plugins

Pivotick API

Pivots & enrichment

Saving pivot results

Undo & history

Security

API docs

Gallery

Basics

Node styling

Edge styling

Layouts

Events & callbacks

UI customization

Editing & authoring

Notes & annotations

Filtering, search & hierarchy

Programmatic control

Theming & performance

Pivots & enrichment

Showpieces

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:

LookClass
Table │ Summaryfull-height tabs, underlined when active, closed off by a rulepvt-dock-tabs / pvt-dock-tab
Nodes │ Edges, By owner │ By kinda small pill grouppvt-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.

js
// 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()],
}
js
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' } },
    ],
}

Last updated:

Pager
Next pageGetting Started