Skip to content
Pivotick
Main Navigation Home
Getting Started
Configuration
Generated API docs
GalleryProjectsTry it out!

English

简体中文
Français
Deutsch
Português

English

简体中文
Français
Deutsch
Português

Appearance

Sidebar Navigation

Getting Started

Projects using Pivotick

Configuration

Callbacks

Edge layers

Simplify

Layout

Render

Simulation

Customizing UI

Sidebar

Tooltip

Context Menu

Filters

Legend

Data table

Styling UI

Translating the 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

Detail that follows the zoom ​

One node, four drawings. tiers picks between them from how large the node currently renders, so an overview is dots and a close-up is cards, and neither has to be the compromise the other can live with.

DrawingDeclared asShows up
a bare dotthe base stylewhen even 32 pixels is too much
a coloured dottiers[0], 32×32once the node renders 32 pixels wide
a labelled chiptiers[1], 110×46once it renders 110, i.e. half zoom
a summary cardtiers[2], 220×110once it renders 220, which is zoom 1 here
a detail cardfocusTier, 280×150on hover or a lone selection, at any zoom
the summary card againan indicator's focusTieron hover, until tiers[2] already shows it

tiers is an array and nothing caps its length. The widest one sets the footprint, so it is the one that engages at zoom 1 and is drawn at exactly its design size.

Things worth provoking:

  • Scroll out, then back in. The whole canvas changes drawing at once, because every node shares a footprint and therefore a threshold. Nothing moves while it happens: the box each node reserves in the layout is the widest tier's, and the drawing changes inside it.
  • Scroll slowly through a threshold. The drawings cross-fade rather than cutting, so nothing blinks: the outgoing one is held on screen while the new one comes up underneath it. render.detailTransition sets how long that takes, and 0 puts the hard cut back.
  • Park the zoom right on a threshold and nudge it. A tier that has engaged holds until the rendered size falls to 85% of what engaged it, so the picture settles instead of flickering.
  • Watch the chip on the way in. An intermediate tier engages at width / footprint of zoom 1, so it appears smaller than the box it was designed for and grows into it. That is the trade in narrowing one: a smaller drawing asks for less room and so shows up sooner, but it shows up smaller too. Its type is sized in graph units for the zooms it actually lives at; give it an explicit minRenderedSize if you would rather it waited for more room without redrawing it.
  • Hover a dot while zoomed out. The detail card is counter-scaled against the zoom, so it is the same 280×150 pixels however small the node is. Reading one node never means zooming to it.
  • Hover the IP, the file or the email, zoomed out and then at zoom 1. Their focus drawing is the summary card, which focusTierYieldsAt: 2 retires once the summary tier draws it anyway, so hovering them up close adds nothing. The campaign still opens its detail card.
  • Select a node, then shift-select a second. One selection promotes; two do not. Twenty cards at once would be a wall.

The footprint is declared, not measured

width and height are numbers you give, because the layout has to know how much room to leave before the first tick — and a measured card only reports its size after it has been drawn.

js
/** Shared shell for the three card drawings, so they differ only in what they carry. */
function shell(node, width, height) {
    const d = node.getData()
    const box = document.createElement('div')
    box.setAttribute('style', [
        'box-sizing: border-box',
        `width: ${width}px`,
        `height: ${height}px`,
        'padding: 6px 10px',
        'border-radius: 8px',
        `border: 2px solid ${SEV[d.sev].tone}`,
        `background: ${SEV[d.sev].wash}`,
        'font-family: system-ui, sans-serif',
        'overflow: hidden',
    ].join(';'))
    return box
}

function row(label, value, tone, size = 11) {
    const line = document.createElement('div')
    line.setAttribute('style', `display: flex; justify-content: space-between; gap: 10px; font-size: ${size}px; line-height: 1.6`)
    const left = document.createElement('span')
    left.setAttribute('style', 'color: #64748b')
    left.textContent = label
    const right = document.createElement('span')
    right.setAttribute('style', `color: ${tone}; font-weight: 600`)
    right.textContent = value
    line.append(left, right)
    return line
}

// Every line states its own line-height. Left to `normal` the browser picks one from the
// font, and a box sized to fit on one machine clips its last line on another.
const LINE = 1.2

function title(text, size, weight = 600) {
    const el = document.createElement('div')
    el.setAttribute('style', `font: ${weight} ${size}px/${LINE} system-ui, sans-serif; color: #0f172a;`
        + 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis')
    el.textContent = text
    return el
}

function kindLine(node, size, gap = 0) {
    const el = document.createElement('div')
    el.setAttribute('style', `font-size: ${size}px; line-height: ${LINE};`
        + `color: ${SEV[node.getData().sev].tone}; margin-bottom: ${gap}px`)
    el.textContent = node.getData().kind
    return el
}

/**
 * The small card: a name and what kind of thing it is.
 *
 * 110 wide against the 220 footprint, so it takes over at half zoom — a narrower drawing
 * asks for less room, and its threshold is its width.
 *
 * Its type is set larger than the bigger cards' below, which looks backwards until you
 * notice these sizes are in graph units. This tier is only ever drawn between k = 0.5 and
 * k = 1, so 18 units lands at 9-18 CSS pixels; the card below is never drawn under k = 1,
 * where 14 units is already 14 pixels. Each drawing is sized for the zoom it lives at.
 */
function chip(node) {
    const d = node.getData()
    const box = shell(node, 110, 46)
    box.style.padding = '5px 8px'
    box.append(title(d.name, 18), kindLine(node, 11))
    return box
}

/** The medium card: room for the two facts you sort on, without opening anything. */
function summaryCard(node) {
    const d = node.getData()
    const box = shell(node, 220, 110)
    box.style.padding = '8px 12px'

    box.append(title(d.name, 14, 700), kindLine(node, 11, 4),
        row('Severity', d.sev, SEV[d.sev].tone, 12),
        row('Sightings', String(d.count), '#0f172a', 12))
    return box
}

/** The focus drawing: everything the node knows, at a size that is readable at any zoom. */
function detailCard(node) {
    const d = node.getData()
    const box = shell(node, 280, 150)
    box.style.padding = '10px 14px'

    box.append(title(d.name, 14, 700), kindLine(node, 11, 6),
        row('Severity', d.sev, SEV[d.sev].tone),
        row('First seen', d.first, '#0f172a'),
        row('Sightings', String(d.count), '#0f172a'),
        row('Source', 'internal feed', '#0f172a'))
    return box
}

const INDICATORS = new Set(['IP', 'File', 'Email'])

const options = {
    render: {
        defaultNodeStyle: {
            // The floor: what a node draws when no tier qualifies at all.
            shape: 'circle',
            size: 7,
            color: (node) => SEV[node.getData().sev].tone,

            // Ordered smallest first; the richest one that fits wins. There is no limit on
            // how many there are — three here, and the base style above is the floor when
            // none of them qualifies.
            //
            // Each tier's `width` is both its design box and, by default, the rendered size
            // at which it takes over. The widest one sets the footprint, so it engages at
            // exactly k = 1 and is drawn at its design size; the two narrower ones engage
            // proportionally earlier, which is why their type is sized for the zoom they
            // live at rather than for their box.
            tiers: [
                {
                    width: 32, height: 32,
                    style: { shape: 'circle', size: 16, color: (node) => SEV[node.getData().sev].tone },
                },
                {
                    width: 110, height: 46,
                    style: { shape: 'none', html: chip },
                },
                {
                    width: 220, height: 110,
                    style: { shape: 'none', html: summaryCard },
                },
            ],

            // Whatever the zoom, a hovered or singly-selected node draws this instead. It
            // merges over the style above rather than over the active tier, and holds a
            // constant size on screen.
            focusTier: { shape: 'none', html: detailCard },
        },
        // A plain indicator has nothing beyond its summary to show, so its focus drawing is
        // the summary card, and it steps aside once `tiers[2]` draws that card anyway.
        nodeTypeAccessor: (node) => INDICATORS.has(node.getData().kind) ? 'indicator' : undefined,
        nodeStyleMap: {
            indicator: {
                focusTier: { shape: 'none', html: summaryCard },
                focusTierYieldsAt: 2,
            },
        },
    },
    simulation: {
        // Tight enough that the graph opens on chips: the collision force already holds the
        // footprints apart, so the links only have to stop it spreading further.
        d3LinkDistance: 60,
    },
    UI: {
        // The focus tier already draws everything a hover would say, so the tooltip beside
        // it would only repeat itself. Both fire on hover; here only one should.
        tooltip: { enabled: false },
    },
}
js
const SEV = {
    high: { tone: '#dc2626', wash: '#fef2f2' },
    medium: { tone: '#d97706', wash: '#fffbeb' },
    low: { tone: '#0891b2', wash: '#ecfeff' },
}

const data = {
    nodes: [
        { id: 'camp', data: { kind: 'Campaign', name: 'SILENT-QUAY', sev: 'high', first: '2026-02-11', count: 41 } },
        { id: 'actor', data: { kind: 'Threat actor', name: 'UNC-4417', sev: 'high', first: '2025-11-02', count: 63 } },
        { id: 'mal1', data: { kind: 'Malware', name: 'Dropbear', sev: 'high', first: '2026-03-04', count: 22 } },
        { id: 'c2a', data: { kind: 'Host', name: 'cdn-a.example', sev: 'high', first: '2026-03-06', count: 18 } },
        { id: 'ip1', data: { kind: 'IP', name: '203.0.113.44', sev: 'medium', first: '2026-03-09', count: 6 } },
        { id: 'hash', data: { kind: 'File', name: '9f2c…a10e', sev: 'high', first: '2026-03-04', count: 14 } },
        { id: 'mail', data: { kind: 'Email', name: 'ops@parcel.example', sev: 'low', first: '2026-02-27', count: 2 } },
        { id: 'vict', data: { kind: 'Sector', name: 'Maritime logistics', sev: 'low', first: '2026-02-14', count: 5 } },
    ],
    edges: [
        { from: 'actor', to: 'camp' }, { from: 'camp', to: 'mal1' }, { from: 'camp', to: 'mail' },
        { from: 'camp', to: 'vict' }, { from: 'mal1', to: 'c2a' }, { from: 'mal1', to: 'hash' },
        { from: 'c2a', to: 'ip1' }, { from: 'actor', to: 'mal1' },
    ],
}

See Rendering → Detail that follows the zoom for the full contract: what picks a tier, how to move a threshold, and when the focus drawing fires.

Last updated:

Pager
Next pageGetting Started