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

Pivot & enrich ​

paste 9f21 has 2,143 correlations behind it. Fetching them all would bury the graph, so a pivot negotiates instead: it advertises the count, refuses to fetch past a cap, and lets you narrow until the number is one you can actually look at.

Press P, click paste 9f21, and read the panel.

  • Nothing is asked until you enter Pivot mode. Selecting a node costs zero backend calls.
  • ~2,143 wears a tilde because it is the provider's claim. 210 fetched is the library's own count, so it does not.
  • The gate refuses while the count is over maxCandidates: 2000, and says how to lift it.
  • Tick URLs and 210 comes back under the cap, so Fetch turns on by itself.
  • What arrives is not the graph. The 210 candidates open the dock's Review tab. The canvas does not move until you ingest.
  • Ingest is not saving. Once they land, the foot of the panel reads 420 unsaved — the 210 nodes and the 210 edges that came with them — and offers Save. Writing them back out is a second decision. This provider refuses every fifth node, so the toast reads Saved 378 of 420, the count drops to 42, and Retry sends exactly those.

Or skip the panel: right-click paste 9f21 and Pivot ▸ lists the same providers with the same counts, where picking a row is the run. Results land directly while few enough of them are new to the canvas, and open the Review tab when there are more.

Select Event 5f2a for the other shape: Objects & attributes declares autoIngest and autoSave, so its twelve objects land and are written back with no pane and no gesture. Passive DNS is the third state — no save at all, so nothing it brings is ever counted unsaved.

js
// A **pivot** is a runnable enrichment: two functions and some metadata. `summarize`
// says cheaply what is out there, and its facets become the narrowing controls; `fetch`
// goes and gets it. What comes back are *candidates*, staged in the dock for triage and
// never on the canvas, until someone ingests them.
//
// The whole point is the node with 2,143 correlations. Fetching all of them would ruin
// the graph, so the pivot advertises the count, declares a cap it refuses to fetch past,
// and lets the analyst narrow until the number is one they can actually look at.

/** What this fake source holds, by type. They sum to 2,143; URLs alone is 210. */
const BY_TYPE = [
    { value: 'domain', label: 'Domains', count: 1800 },
    { value: 'url', label: 'URLs', count: 210 },
    { value: 'paste', label: 'Pastes', count: 95 },
    { value: 'ip', label: 'IPs', count: 38 },
]

/** The types a narrowing chose, or all of them when it chose none. */
const chosen = (narrowing) => {
    const picked = Array.isArray(narrowing.type) ? narrowing.type.map(String) : []
    return picked.length ? BY_TYPE.filter((t) => picked.includes(t.value)) : BY_TYPE
}

const correlations = {
    id: 'correlations',
    label: 'Correlations',
    // Nothing correlates with an event, so the entry is absent for one.
    appliesTo: (nodes) => nodes.every((node) => node.getData()?.type !== 'event'),

    // Runs when Pivot mode is entered, and again whenever the origin or the narrowing
    // moves. A real one would POST the ids and the narrowing to a count endpoint.
    summarize: (nodes, narrowing) => ({
        total: chosen(narrowing).reduce((sum, t) => sum + t.count, 0),
        facets: [{
            key: 'type',
            label: 'Type',
            type: 'multiselect',
            options: BY_TYPE.map((t) => ({ label: t.label, value: t.value, count: t.count })),
        }],
    }),

    // The real call, reached only once the advertised count is under the cap.
    fetch: (nodes, narrowing) => {
        const origin = nodes[0]?.id ?? 'paste-9f21'
        const out = { nodes: [], edges: [] }
        for (const type of chosen(narrowing)) {
            // Capped at 210 for the demo's sake; a real source would return `count`.
            for (let i = 0; i < Math.min(type.count, 210); i++) {
                const id = `${type.value}-${i}`
                out.nodes.push({ id, data: { label: `${type.value} ${i}`, type: type.value } })
                out.edges.push({ from: origin, to: id })
            }
        }
        return out
    },

    // Refuse to fetch while the source claims more than this. Narrowing lifts it.
    maxCandidates: 2000,

    // The write half. Ingest puts candidates on the canvas; this is what puts them in
    // the source system, and it is the only thing that clears the panel's unsaved
    // count. A real one would POST them and report what the server accepted.
    //
    // This one deliberately refuses every fifth node, because a partial write is the
    // normal case at any scale and the retry is the interesting part: anything not
    // named in `savedNodeIds` stays unsaved, and **Retry** sends exactly that.
    save: ({ nodes, edges }) => {
        const written = nodes.filter((_, i) => i % 5 !== 0)
        const refused = nodes.length - written.length
        return {
            savedNodeIds: written.map((node) => node.id),
            savedEdgeIds: edges.map((edge) => edge.id),
            message: refused ? `${refused} refused by the server` : undefined,
        }
    },
}

// The other shape a pivot takes: a small, trusted result that needs no triage. It comes
// back as one container carrying its own children and lands straight on the canvas.
//
// These objects are the source's own already, so both halves are automatic: `autoIngest`
// lands them with no triage, `autoSave` writes them back with no gesture.
const expandEvent = {
    id: 'event-objects',
    label: 'Objects & attributes',
    appliesTo: (nodes) => nodes.length === 1 && nodes[0].getData()?.type === 'event',
    autoIngest: true,
    autoSave: true,
    save: () => true,
    fetch: ([node]) => ({
        nodes: [{
            id: `${node.id}-objects`,
            data: { label: 'Objects', type: 'container' },
            children: Array.from({ length: 12 }, (_, i) => ({
                id: `${node.id}-object-${i}`,
                data: { label: `attribute ${i}`, type: 'attribute' },
            })),
        }],
        edges: [{ from: node.id, to: `${node.id}-objects` }],
    }),
}

// A pivot with no `save` at all. Passive DNS is somebody else's observation, not ours to
// write anywhere — so its results are *not savable*: never counted unsaved, and no Save
// offered for them. That third state is what keeps the count honest.
const resolves = {
    id: 'passive-dns',
    label: 'Passive DNS',
    appliesTo: (nodes) => nodes.every((node) => node.getData()?.type === 'domain'),
    fetch: ([node]) => ({
        nodes: Array.from({ length: 6 }, (_, i) => ({
            id: `198.51.100.${20 + i}`,
            data: { label: `198.51.100.${20 + i}`, type: 'ip' },
        })),
        edges: Array.from({ length: 6 }, (_, i) => ({ from: node.id, to: `198.51.100.${20 + i}` })),
    }),
}

const options = {
    // Registering here rather than later is what makes the rail button correct on the
    // very first paint: `UI.pivotMode` defaults to `'auto'`, and the constructor's
    // pivots land before the UI is built.
    pivots: [correlations, expandEvent, resolves],
    UI: {
        mode: 'full',
        sidebar: { collapsed: true },
        // Triage happens in the dock, which starts folded: a staged set unfolds it
        // by itself when it arrives. The height is worth setting explicitly — the
        // default share leaves the candidate table too short to read.
        minimap: false,
        table: { open: false },
        dock: { height: 380 },
    },
    render: {
        defaultNodeStyle: {
            size: 8,
            color: (node) => ({
                paste: '#b48ead', domain: '#ebcb8b', ip: '#88c0d0',
                url: '#a3be8c', event: '#bf616a', container: '#5e81ac',
            }[node.getData()?.type] ?? '#8f9aa8'),
        },
    },
}
js
// A small investigation graph: what the analyst already has. Everything else in this
// card arrives through a pivot.
const data = {
    nodes: [
        { id: 'paste-9f21', data: { label: 'paste 9f21', type: 'paste' } },
        { id: 'evil.example', data: { label: 'evil.example', type: 'domain' } },
        { id: '198.51.100.7', data: { label: '198.51.100.7', type: 'ip' } },
        { id: 'event-5f2a', data: { label: 'Event 5f2a', type: 'event' } },
    ],
    edges: [
        { from: 'paste-9f21', to: 'evil.example' },
        { from: 'evil.example', to: '198.51.100.7' },
        { from: 'evil.example', to: 'event-5f2a' },
    ],
}

Full reference: Pivots & enrichment, and Saving pivot results for the write half this card ends on.

Last updated:

Pager
Next pageGetting Started