Edge Rules & LabelsMOL
Between clusters a link bundles every link below it - its count and, for circuit breakers, its health. The worker draws all of them into the canvas, styled by rules: plain data it evaluates per edge, because functions never cross into it. The links worth reading also get labels in the DOM - an icon far away, a word closer, details up close - chosen by each link's length on screen, budgeted and never overlapping. A click on a link selects it, and its details - built in or a plugin's - show what it carries.
On this page (12)
§Rules
An edge plugin's rules are a list of { when, style, priority }: every condition in when must hold - equals, in, exists, gte, lte on an edge field such as kind, type, traffic or breaker.openShare - and the highest priority that matches wins. The style picks a tone (muted, ink, accent, link, ok, warn, danger - the theme's colors), a width, an arrow, a dash, flow along a link with traffic, a pulse or a gap. The back-edges of small cycles curve around and take the accent. A plugin's rules belong to its layer - relations unless it names another, the breakers' to health - and apply while that layer is shown; a calm (muted) line takes the relations layer's colour.
§Reads in the accent color
Inside a system, links of the type reads now draw thick and dashed in the accent color - the rule is plain data, evaluated in the worker for every link it draws.
§Labels per zoom level
A label renderer offers three tiers - icon, label, full - each from a length on screen (levels), with a box for collision (sizes) and an optional cap of its own (budget). Every frame the explorer ranks the candidates - the selection's links first, then the renderer's priority - and gives each the richest tier its length, the budget (160 icons, 48 labels, 16 full) and the free space allow. depths limits a renderer to some levels of the hierarchy. Labels belong to their plugin's layer - labels unless it names another - and are drawn in its colour while it is shown; the examples here show it (data-layers="nodes relations labels").
§A label of your own
Links between domains say which way most calls go; zoom in and the label adds the counts both ways and marks a cycle's back-edge.
§Circuit breakers
Every link between two entities has a breaker: closed, half-open (probing), open (tripped) or cut. A link between clusters bundles many and reports health by share, so one open breaker among 80 does not paint the overview red: at least 30 % open is critical, at least 15 % not closed is degraded. The built-in breaker plugin draws them - an icon far away, the state closer, trips and minutes up close - and the Health layer sums them per cluster - the breakers are the Health layer's: their colours on the lines and their badges show while it is shown. The live simulation trips, probes and closes breakers on its own.
§Live mode
Press Live mode: every second or so breakers trip and recover, nodes deploy and traffic moves; the badges, bars and edge labels follow. Health is shown: each domain's share as a bar; hide it in the layer switch and the lines go calm.
§Selecting a link
A click on a link - on its label or on its line - selects it (data-selected-edge="c1>c5", the state's edge): the rest of the graph steps back and the details outlet shows the link - both ends, its links each way, the relations it carries, the breakers it bundles, who sends and who receives most - and the relations outlet its single links. E or a double click expands it to a page, Esc lets it go. From a keyboard the link button at the end of a relation in the details does the same.
§A link's details
Click a link between two domains (its line or its count). The details show its traffic both ways, its breakers, its top senders and receivers; Expand shows the link's page.
§A link's details of your own
An edge plugin's detail - { match(item), title(item), render(item, ctx) } - draws the details of a selected link: item.source and item.target are its two nodes, item.edge the link in the view (counts each way, relation, breaker, style), and ctx.fetchEdge(item.id) what the worker knows beyond it - the links by relation, the system pairs and their breakers, the top senders and receivers and single links. Like a node's details it may compose library components; a Chart goes in an island.
§Traffic between domains
Click a link between two domains: a plugin shows its calls per minute as a Chart, the share of each direction as bars and the breakers that are not closed. A link between entities keeps the built-in details.
§CSS view file
The stylesheet and the script are the Graph Explorer's.
/* Graph Explorer - a knowledge graph of up to millions of nodes: a worker-drawn far field under a bounded pool of DOM * nodes, edge labels per zoom level, outlets the page places (breadcrumb, search, outline, details, relations, layers). * Its own properties derive from the tweakcn token set only (AGENTS.md "Token boundary rule"); the status colours ok and * warn have no token and are literals (amber and green read as degraded and healthy in every theme; a chart colour * such as --chart-1 is light blue in the default theme and read as information). The icons are a Lucide subset (ISC, lucide-static 0.469.0 - Copyright (c) * Lucide Contributors 2022, portions Cole Bemis 2013-2022 as part of Feather, MIT) drawn as CSS masks: an icon is one * empty element, cheap to morph and painted in the current color. */@layer components { /* the explorer and everything bound to it share the graph's colour roles */ :where(.graph-explorer, [data-graph-explorer-for]) { --graph-paper: var(--background); --graph-node: var(--popover); --graph-node-ink: var(--popover-foreground); --graph-edge: color-mix(in oklch, var(--muted-foreground) 55%, transparent); --graph-accent: var(--chart-2); --graph-link: var(--chart-4); --graph-ok: oklch(0.6 0.14 155); --graph-warn: oklch(0.72 0.16 65); --graph-danger: var(--destructive); --graph-focus: var(--ring); --graph-radius: var(--radius-md); } /* a tone is a colour role - inside the explorer and its outlets, and on what renderers draw (an icon on its own too: the fallbacks are the roles' defaults) */ :where(.graph-explorer, [data-graph-explorer-for], [class*="graph-explorer-"]) { &[data-tone="muted"], & [data-tone="muted"] { --tone: var(--muted-foreground); } &[data-tone="ink"], & [data-tone="ink"] { --tone: var(--foreground); } &[data-tone="accent"], & [data-tone="accent"] { --tone: var(--graph-accent, var(--chart-2)); } &[data-tone="link"], & [data-tone="link"] { --tone: var(--graph-link, var(--chart-4)); } &[data-tone="ok"], & [data-tone="ok"] { --tone: var(--graph-ok, oklch(0.6 0.14 155)); } &[data-tone="warn"], & [data-tone="warn"] { --tone: var(--graph-warn, oklch(0.72 0.16 65)); } &[data-tone="danger"], & [data-tone="danger"] { --tone: var(--graph-danger, var(--destructive)); } } /* ---------------------------------------------------------- the viewport: canvas, edges, slots, labels */ .graph-explorer { position: relative; display: block; box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: var(--graph-explorer-min-height, 20rem); overflow: hidden; contain: strict; touch-action: none; user-select: none; cursor: grab; outline: none; background: var(--graph-paper); /* flat on purpose: a centered gradient reads as an empty cluster */ color: var(--foreground); font-family: var(--font-sans); font-size: 0.875rem; &:focus-visible { box-shadow: inset 0 0 0 2px var(--graph-focus); } &[data-panning] { cursor: grabbing; } /* framed when it stands alone (in a Panel the panel is the frame) */ &[data-frame] { border: 1px solid var(--border); border-radius: var(--radius-lg); } & .graph-explorer-canvas, & .graph-explorer-edges, & .graph-explorer-world, & .graph-explorer-edge-labels, & .graph-explorer-signs, & .graph-explorer-changes, & .graph-explorer-ring { position: absolute; inset: 0; } & .graph-explorer-canvas { inline-size: 100%; block-size: 100%; transform-origin: 50% 50%; pointer-events: none; } & .graph-explorer-edges { inline-size: 100%; block-size: 100%; pointer-events: none; overflow: visible; } & .graph-explorer-world, & .graph-explorer-signs, & .graph-explorer-changes, & .graph-explorer-edge-labels { pointer-events: none; } & .graph-explorer-changes { overflow: hidden; } /* a changed node: a glowing dot at its top right in its layer's colour (the Indicator's ping), for a while */ & .graph-explorer-change-dot { position: absolute; left: 0; top: 0; inline-size: 0.625rem; block-size: 0.625rem; margin: -0.3125rem 0 0 -0.3125rem; color: var(--graph-explorer-layer, var(--graph-accent)); box-shadow: 0 0 0 2px var(--card), 0 0 0.5rem 0.125rem color-mix(in oklch, currentColor 70%, transparent); animation: graph-explorer-change-fade linear forwards; } & .graph-explorer-probe { position: absolute; inline-size: 0; block-size: 0; overflow: hidden; } & .graph-explorer-live { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } & .graph-explorer-loading { position: absolute; inset: 0; display: grid; place-content: center; gap: 0.5rem; justify-items: center; color: var(--muted-foreground); background: var(--graph-paper); & progress { inline-size: 12rem; accent-color: var(--primary); } &[hidden] { display: none; } } /* the pooled node slots */ & .graph-explorer-node { position: absolute; left: 0; top: 0; pointer-events: auto; contain: layout style; transition: opacity 0.18s ease; @starting-style { opacity: 0; } &[hidden] { display: none; } } &[data-dim] .graph-explorer-node:not([data-hl]) { opacity: 0.32; } & .graph-explorer-node[data-selected] > * { outline: 2px solid var(--graph-focus); outline-offset: 1px; } & .graph-explorer-node[data-kbd] > * { box-shadow: 0 0 0 4px color-mix(in oklch, var(--graph-focus) 40%, transparent), var(--shadow-md); } & .graph-explorer-focus-ring { position: absolute; left: 0; top: 0; border-radius: 999px; border: 2px solid var(--graph-focus); box-shadow: 0 0 0 4px color-mix(in oklch, var(--graph-focus) 30%, transparent); pointer-events: none; &[hidden] { display: none; } } /* the promoted edges (SVG) of the selection, the hover and the keyboard focus */ & .graph-explorer-edge { fill: none; stroke: var(--graph-edge); stroke-linecap: round; &[data-tone] { stroke: var(--tone); } &[data-flow] { stroke-dasharray: 2 7; animation: graph-explorer-flow 0.9s linear infinite; } &[data-pulse] { animation: graph-explorer-pulse 1.6s ease-in-out infinite; } /* the selected link */ &[data-selected] { stroke: var(--graph-accent); stroke-width: 3.5px; filter: drop-shadow(0 0 3px color-mix(in oklch, var(--graph-accent) 55%, transparent)); } } /* a changed link: the line lights up in its layer's colour and fades */ & .graph-explorer-edge-flash { fill: none; stroke: var(--graph-explorer-layer, var(--graph-accent)); stroke-linecap: round; filter: drop-shadow(0 0 4px var(--graph-explorer-layer, var(--graph-accent))); animation: graph-explorer-change-fade ease-out forwards; } /* a click on a promoted link selects it: a wide transparent stroke under the line */ & .graph-explorer-edge-hit { fill: none; stroke: transparent; stroke-width: 14px; stroke-linecap: round; pointer-events: stroke; cursor: pointer; } /* a calm line is the relations layer's colour (set by the runtime from the theme's chart colours) */ & :is(.graph-explorer-edge, .graph-explorer-edge-head)[data-tone="muted"] { --tone: var(--graph-explorer-relations, var(--muted-foreground)); } & .graph-explorer-edge-head { fill: var(--graph-edge); stroke: none; &[data-selected] { fill: var(--graph-accent); } &[data-tone] { fill: var(--tone); } } /* the edge labels (DOM, budgeted); a click selects the link */ & .graph-explorer-edge-label { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%); white-space: nowrap; pointer-events: auto; cursor: pointer; border-radius: 999px; &[hidden] { display: none; } &[data-selected] { outline: 2px solid var(--graph-accent); outline-offset: 1px; z-index: 1; } } /* signposts to relations off screen, and the neighbour ring (Jump to a neighbour) */ & .graph-explorer-sign { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%); pointer-events: auto; max-inline-size: 14rem; padding: 0.125rem 0.5rem; border: 1px solid var(--tone, var(--border)); border-radius: 999px; background: color-mix(in oklch, var(--popover) 92%, transparent); color: var(--popover-foreground); font: inherit; font-size: 0.6875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; box-shadow: var(--shadow-sm); &:hover, &:focus-visible { background: var(--accent); outline: 2px solid var(--ring); } } & .graph-explorer-ring { pointer-events: auto; background: color-mix(in oklch, var(--graph-paper) 55%, transparent); &[hidden] { display: none; } } & .graph-explorer-ring-center { position: absolute; left: 0; top: 0; inline-size: 14px; block-size: 14px; margin: -7px 0 0 -7px; border-radius: 999px; background: var(--graph-focus); box-shadow: 0 0 0 6px color-mix(in oklch, var(--graph-focus) 30%, transparent); } & .graph-explorer-ring-item { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%); transition: translate 0.32s cubic-bezier(0.2, 0.8, 0.2, 1); transition-delay: calc(var(--k, 0) * 18ms); display: flex; gap: 0.375rem; align-items: center; max-inline-size: 11rem; padding: 0.25rem 0.625rem; border: 1px solid var(--tone, var(--border)); border-radius: 999px; background: var(--popover); color: var(--popover-foreground); font: inherit; font-size: 0.75rem; white-space: nowrap; overflow: hidden; cursor: pointer; box-shadow: var(--shadow-md); &:hover, &:focus-visible { outline: 2px solid var(--ring); } } & .graph-explorer-hud { position: absolute; inset-inline-end: 0.5rem; inset-block-end: 0.5rem; margin: 0; padding: 0.375rem 0.5rem; border-radius: var(--radius-sm); background: color-mix(in oklch, var(--popover) 88%, transparent); color: var(--popover-foreground); border: 1px solid var(--border); font: 0.6875rem/1.45 var(--font-mono); pointer-events: none; &[hidden] { display: none; } } /* the keyboard map (a popover in the top layer) */ & .graph-explorer-help { max-inline-size: min(34rem, calc(100vw - 2rem)); padding: 0; margin: auto; border: 1px solid var(--border); cursor: auto; user-select: text; &::backdrop { background: color-mix(in oklch, var(--background) 40%, transparent); } } & .graph-explorer-keys { border-collapse: collapse; font-size: 0.8125rem; & td { padding: 0.25rem 0.5rem; vertical-align: top; } & td:first-child { white-space: nowrap; } } } /* a Panel's body that holds the explorer gives it all its room */ :where(.panel-body):has(> .graph-explorer) { display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); padding: 0; overflow: hidden; & > .graph-explorer { min-block-size: 0; } } /* ---------------------------------------------------------- what renderers draw: chips, cards, labels */ .graph-explorer-chip { display: flex; align-items: center; gap: 0.375rem; inline-size: 100%; block-size: 100%; box-sizing: border-box; padding: 0 0.5rem; border: 1px solid var(--border); border-radius: 999px; background: var(--graph-node); color: var(--graph-node-ink); box-shadow: var(--shadow-xs); font-size: 0.75rem; line-height: 1; white-space: nowrap; cursor: pointer; &[data-kind="cluster"] { font-weight: 500; } } .graph-explorer-card { position: relative; display: grid; align-content: start; gap: 0.25rem; inline-size: 100%; block-size: 100%; box-sizing: border-box; padding: 0.5rem 0.625rem; border: 1px solid var(--border); border-radius: var(--graph-radius); background: var(--graph-node); color: var(--graph-node-ink); box-shadow: var(--shadow-md); font-size: 0.75rem; overflow: hidden; cursor: pointer; & .graph-explorer-health { inline-size: 100%; block-size: 8px; margin: 0.375rem 0 0.25rem; } } .graph-explorer-card-head { display: flex; align-items: center; gap: 0.375rem; min-inline-size: 0; font-size: 0.8125rem; } .graph-explorer-card-meta { margin: 0; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .graph-explorer-card-flags { margin: 0; display: flex; flex-wrap: wrap; gap: 0.25rem; & .badge { font-size: 0.6875rem; } } .graph-explorer-card-actions { display: flex; justify-content: flex-end; } .graph-explorer-card-expand.btn { margin-inline-start: auto; inline-size: 1.5rem; block-size: 1.5rem; min-inline-size: 0; padding: 0; color: var(--muted-foreground); } .graph-explorer-label { overflow: hidden; text-overflow: ellipsis; min-inline-size: 0; flex: 1 1 auto; } /* the nodes layer's colour on what it draws: a chip, a card */ .graph-explorer :is(.graph-explorer-chip, .graph-explorer-card) { border-color: color-mix(in oklch, var(--graph-explorer-nodes, var(--border)) 55%, var(--border)); background: color-mix(in oklch, var(--graph-explorer-nodes, var(--graph-node)) 9%, var(--graph-node)); } /* a layer's part of a node: bordered and tinted in the layer's colour - a band in a card, a pill in a chip */ .graph-explorer-part { --graph-explorer-layer: var(--muted-foreground); box-sizing: border-box; min-inline-size: 0; border: 1px solid color-mix(in oklch, var(--graph-explorer-layer) 70%, transparent); background: color-mix(in oklch, var(--graph-explorer-layer) 13%, transparent); color: var(--graph-node-ink); & .graph-explorer-card-meta, & .graph-explorer-meter-row { margin: 0; color: inherit; } & .graph-explorer-count { margin: 0; color: inherit; } & .graph-explorer-health { margin: 0.125rem 0; } } :is(.graph-explorer-card, .graph-explorer-parts-only[data-tier="card"]) > .graph-explorer-part { display: grid; gap: 0.125rem; padding: 0.125rem 0.375rem; border-radius: calc(var(--graph-radius) - 2px); } :is(.graph-explorer-chip, .graph-explorer-parts-only[data-tier="chip"]) > .graph-explorer-part { display: inline-flex; flex: none; align-items: center; gap: 0.25rem; padding: 0.0625rem 0.3125rem; border-radius: 999px; line-height: 1; &:first-of-type { margin-inline-start: auto; } } /* the parts of a node whose own layer is hidden */ .graph-explorer-parts-only { display: flex; align-items: center; gap: 0.25rem; inline-size: 100%; block-size: 100%; box-sizing: border-box; padding-inline: 0.25rem; font-size: 0.75rem; &[data-tier="card"] { display: grid; align-content: start; padding: 0.375rem; border-radius: var(--graph-radius); background: color-mix(in oklch, var(--graph-node) 80%, transparent); box-shadow: var(--shadow-xs); } } .graph-explorer-count { margin-inline-start: auto; font-size: 0.6875rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } .graph-explorer-cycle { color: var(--graph-accent); font-weight: 700; } .graph-explorer-muted { color: var(--muted-foreground); margin: 0; } .graph-explorer-pad { padding: 0.75rem; } /* a type's color: a dot (the outline) or an icon */ .graph-explorer-type { flex: none; inline-size: 0.5rem; block-size: 0.5rem; border-radius: 999px; background: var(--type-color, var(--muted-foreground)); } .graph-explorer-type-icon { color: var(--type-color, currentColor); } :is(.graph-explorer-type, .graph-explorer-type-icon, .graph-explorer-avatar) { &[data-type="0"] { --type-color: var(--chart-1); } &[data-type="1"] { --type-color: var(--chart-2); } &[data-type="2"] { --type-color: var(--chart-3); } &[data-type="3"] { --type-color: var(--chart-4); } &[data-type="4"] { --type-color: var(--chart-5); } &[data-type="5"] { --type-color: oklch(from var(--chart-1) l c calc(h + 150)); } &[data-type="6"] { --type-color: oklch(from var(--chart-1) l c calc(h + 210)); } &[data-type="7"] { --type-color: oklch(from var(--chart-1) l c calc(h + 280)); } } .badge.graph-explorer-warn, .badge.graph-explorer-state[data-state="half-open"] { background: color-mix(in oklch, var(--graph-warn) 18%, transparent); color: var(--foreground); border-color: color-mix(in oklch, var(--graph-warn) 60%, transparent); } .badge.graph-explorer-state[data-state="open"] { background: var(--destructive); color: var(--destructive-foreground, white); } .badge.graph-explorer-state[data-state="cut"] { background: transparent; border-color: var(--foreground); color: var(--foreground); } /* the Health and Owners layers */ .graph-explorer-health { display: flex; gap: 1px; flex: 0 0 auto; inline-size: 44px; block-size: 6px; border-radius: 999px; overflow: hidden; background: var(--muted); & > span { min-inline-size: 0; background: var(--tone); } & > span[style*="flex:0"] { display: none; } } .graph-explorer-pill { flex: none; padding: 0 6px; border-radius: 999px; font-size: 0.625rem; line-height: 16px; background: color-mix(in oklch, var(--tone) 16%, transparent); color: var(--foreground); } .graph-explorer-meter-row { display: grid; grid-template-columns: 4.5rem 1fr auto; align-items: center; gap: 0.5rem; margin: 0.375rem 0 0; font-size: 0.6875rem; color: var(--muted-foreground); } .graph-explorer-meter { block-size: 6px; border-radius: 999px; background: var(--muted); overflow: hidden; & > span { display: block; block-size: 100%; background: var(--tone); } } .graph-explorer-avatar { flex: none; display: inline-grid; place-items: center; inline-size: 20px; block-size: 20px; border-radius: 999px; font-size: 0.5625rem; font-weight: 700; letter-spacing: 0.02em; color: var(--background); background: var(--type-color, var(--chart-1)); } /* edge labels: an icon, a count, a pill or a badge */ .graph-explorer-edge-icon { display: inline-grid; place-items: center; inline-size: 20px; block-size: 20px; border-radius: 999px; background: var(--popover); color: var(--muted-foreground); border: 1px solid color-mix(in oklch, var(--tone, var(--muted-foreground)) 45%, transparent); box-shadow: var(--shadow-xs); &[data-tone] { color: var(--tone); } & .graph-explorer-icon { inline-size: 12px; block-size: 12px; } } .graph-explorer-edge-count { display: inline-block; min-inline-size: 20px; padding: 0 5px; border-radius: 999px; text-align: center; background: var(--popover); color: var(--muted-foreground); border: 1px solid var(--border); font-size: 0.625rem; line-height: 16px; font-variant-numeric: tabular-nums; box-shadow: var(--shadow-xs); } .graph-explorer-edge-pill, .graph-explorer-edge-badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px 1px 5px; border-radius: 999px; border: 1px solid var(--tone, var(--border)); background: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-xs); font-size: 0.6875rem; line-height: 18px; white-space: nowrap; & .graph-explorer-icon { inline-size: 12px; block-size: 12px; } & .graph-explorer-muted { font-size: 0.625rem; } } .graph-explorer-edge-badge[data-tone="danger"] { background: var(--destructive); color: var(--destructive-foreground, white); border-color: var(--destructive); & .graph-explorer-icon { color: inherit; } } .graph-explorer-edge-badge[data-tone="warn"] { border-color: var(--graph-warn); } .graph-explorer-edge-sub { opacity: 0.8; font-size: 0.625rem; } /* ---------------------------------------------------------- the outlets */ .graph-explorer-path { min-inline-size: 0; overflow: hidden; & .breadcrumb-list { flex-wrap: nowrap; white-space: nowrap; } } .graph-explorer-search { position: relative; min-inline-size: 12rem; } .graph-explorer-search-input.input { inline-size: 100%; padding-inline-start: 2rem; } .graph-explorer-search-icon { position: absolute; inset-inline-start: 0.625rem; top: 50%; translate: 0 -50%; color: var(--muted-foreground); pointer-events: none; } .graph-explorer-results { position: absolute; inset-inline: 0; top: calc(100% + 4px); z-index: 20; margin: 0; padding: 0.25rem; list-style: none; max-block-size: 22rem; overflow: auto; overscroll-behavior: contain; background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); & [role="option"] { display: grid; gap: 0.125rem; padding: 0.375rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; &[aria-selected="true"], &:hover { background: var(--accent); color: var(--accent-foreground); } } &[hidden] { display: none; } } .graph-explorer-result-label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .graph-explorer-result-path, .graph-explorer-result-more { font-size: 0.75rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .graph-explorer-result-more { padding: 0.375rem 0.5rem; } /* the layer switch: a Toggle Group, every layer with its colour as an Indicator dot and its tint when shown; Compact after a Separator - the toggles keep the Toggle's own sizes (a rule of the same weight here would win or lose by the order the stylesheets come in: graphs.css loads after all.css, an app bundle sorts by name) */ .graph-explorer-layers { display: inline-flex; align-items: center; gap: 0.375rem; min-inline-size: 0; & .toggle-group { gap: 0.25rem; } & .separator[data-orientation="vertical"] { block-size: 1.25rem; } & .graph-explorer-icon { inline-size: 0.875rem; block-size: 0.875rem; } & .toggle.graph-explorer-layer { --graph-explorer-layer: var(--muted-foreground); & .indicator-dot { color: var(--graph-explorer-layer); background-color: transparent; box-shadow: inset 0 0 0 1.5px currentColor; } &:hover { background-color: color-mix(in oklch, var(--graph-explorer-layer) 10%, transparent); } &[aria-pressed="true"] { background-color: color-mix(in oklch, var(--graph-explorer-layer) 18%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--graph-explorer-layer) 60%, transparent); color: var(--foreground); & .indicator-dot { background-color: currentColor; } &:hover { background-color: color-mix(in oklch, var(--graph-explorer-layer) 28%, transparent); } } } } /* an edge label in its layer's colour (a breaker's danger badge keeps its own) */ .graph-explorer-edge-label[data-layer] > :is( .graph-explorer-edge-pill, .graph-explorer-edge-badge, .graph-explorer-edge-count, .graph-explorer-edge-icon ):not([data-tone="danger"]) { border-color: color-mix(in oklch, var(--graph-explorer-layer) 75%, transparent); background: color-mix(in oklch, var(--graph-explorer-layer) 14%, var(--popover)); } [data-graph-explorer-action="live"][aria-pressed="true"] { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); } .graph-explorer-outline { display: grid; grid-template-rows: auto minmax(0, 1fr); block-size: 100%; min-block-size: 0; overflow: hidden; } .graph-explorer-outline-head { padding: 0.5rem; border-block-end: 1px solid var(--border); } .graph-explorer-outline-filter.input { inline-size: 100%; } .graph-explorer-outline-list { list-style: none; margin: 0; padding: 0.25rem; overflow: auto; overscroll-behavior: contain; & li[aria-selected="true"] > .graph-explorer-outline-item { background: var(--secondary); color: var(--secondary-foreground); font-weight: 600; } } .graph-explorer-outline-item { display: flex; align-items: center; gap: 0.375rem; inline-size: 100%; padding: 0.25rem 0.375rem; padding-inline-start: calc(0.375rem + var(--depth, 0) * 0.625rem); border: 0; border-radius: var(--radius-sm); background: none; color: inherit; font: inherit; font-size: 0.8125rem; text-align: start; cursor: pointer; &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } .graph-explorer-outline-path .graph-explorer-outline-item { color: var(--muted-foreground); font-weight: 500; } .graph-explorer-outline-path:last-of-type .graph-explorer-outline-item { color: var(--foreground); } .graph-explorer-outline-more { padding: 0.5rem; font-size: 0.75rem; color: var(--muted-foreground); } /* the details (a node's or a link's), and their whole page when expanded. Built from the library's Badge, Button, Card, Statistic and Table plus the parts below; plugins use the same classes */ .graph-explorer-details { display: grid; align-content: start; gap: 0.875rem; container-type: inline-size; font-size: 0.8125rem; } .graph-explorer-island { min-inline-size: 0; } .graph-explorer-empty { display: grid; justify-items: center; gap: 0.25rem; padding: 2rem 0.5rem; text-align: center; color: var(--muted-foreground); & p { margin: 0; } & > .graph-explorer-icon { opacity: 0.5; } } .graph-explorer-detail-head { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; } .graph-explorer-detail-tile { display: grid; place-items: center; inline-size: 2.5rem; block-size: 2.5rem; border-radius: var(--radius-md); background: var(--muted); color: var(--graph-accent); box-shadow: inset 0 0 0 1px var(--border); &[data-size="xl"] { inline-size: 3.5rem; block-size: 3.5rem; border-radius: var(--radius-lg); } } .graph-explorer-detail-titles, .graph-explorer-page-titles { display: grid; gap: 0.125rem; min-inline-size: 0; } .graph-explorer-detail-kicker { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; font-size: 0.8125rem; font-weight: 500; color: var(--foreground); } .graph-explorer-cycle-note { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--muted-foreground); font-weight: 400; } .graph-explorer-trail { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; font-size: 0.75rem; color: var(--muted-foreground); & .graph-explorer-link { color: inherit; } } .graph-explorer-trail-sep { opacity: 0.6; } .graph-explorer-detail-actions { display: flex; flex-wrap: wrap; gap: 0.375rem; } /* facts: a grid of label and value cells */ .graph-explorer-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; background: var(--border); } .graph-explorer-stat { display: grid; gap: 0.125rem; min-inline-size: 0; padding: 0.5rem 0.625rem; background: var(--card); & dt { font-size: 0.6875rem; color: var(--muted-foreground); } & dd { margin: 0; font-size: 0.9375rem; font-weight: 600; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } &[data-tone] dd { color: var(--tone); } & .badge { font-size: 0.6875rem; } } .graph-explorer-section { display: grid; gap: 0.25rem; padding-block-start: 0.75rem; border-block-start: 1px solid var(--border); } .graph-explorer-section-title { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0 0 0.125rem; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted-foreground); } .graph-explorer-section-count { padding: 0 0.375rem; border-radius: 999px; background: var(--muted); font-variant-numeric: tabular-nums; letter-spacing: 0; } /* a list of nodes: a click opens one, the link button inspects the link to it */ .graph-explorer-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; } .graph-explorer-row { display: flex; align-items: center; gap: 0.25rem; min-inline-size: 0; border-radius: var(--radius-sm); &:hover { background: var(--accent); color: var(--accent-foreground); } & > .graph-explorer-bar { flex: 0 0 3.5rem; margin-inline-end: 0.375rem; } & > .badge { flex: none; font-size: 0.625rem; } } .graph-explorer-row-main { flex: 1; min-inline-size: 0; display: flex; align-items: center; gap: 0.375rem; padding: 0.3125rem 0.375rem; border: 0; border-radius: var(--radius-sm); background: none; color: inherit; font: inherit; text-align: start; cursor: pointer; &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } & > .graph-explorer-icon { color: var(--muted-foreground); } } .graph-explorer-row-name { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .graph-explorer-row-meta { display: flex; gap: 0.375rem; font-size: 0.6875rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; white-space: nowrap; } .graph-explorer-row-link, .graph-explorer-edge-btn { flex: none; display: inline-grid; place-items: center; border: 0; background: none; color: var(--muted-foreground); font: inherit; cursor: pointer; &:hover { color: var(--foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } } .graph-explorer-row-link { inline-size: 1.625rem; block-size: 1.625rem; border-radius: var(--radius-sm); &:hover { background: var(--muted); } } .graph-explorer-edge-btn { padding: 0; border-radius: 999px; } .graph-explorer-more { margin: 0.125rem 0 0; padding-inline: 0.375rem; font-size: 0.6875rem; color: var(--muted-foreground); } /* a share as a bar, in the tone of its kind */ .graph-explorer-bar { display: block; block-size: 0.375rem; min-inline-size: 2rem; border-radius: 999px; background: color-mix(in oklch, var(--tone, var(--graph-accent)) 16%, transparent); overflow: hidden; & > span { display: block; block-size: 100%; border-radius: inherit; background: var(--tone, var(--graph-accent)); } } .graph-explorer-shares { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; & li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.25rem 0.75rem; align-items: center; } & .graph-explorer-bar { grid-column: 1 / -1; } } .graph-explorer-share-name { display: inline-flex; align-items: center; gap: 0.375rem; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .graph-explorer-share-value { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } .graph-explorer-legend { list-style: none; margin: 0.375rem 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25rem 0.875rem; font-size: 0.6875rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; & li { display: inline-flex; align-items: center; gap: 0.375rem; &::before { content: ""; inline-size: 0.5rem; block-size: 0.5rem; border-radius: 2px; background: var(--tone); } } } /* the two ends of a link */ .graph-explorer-ends { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 0.5rem; } .graph-explorer-ends-arrow { display: grid; place-items: center; color: var(--muted-foreground); } .graph-explorer-end { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0 0.5rem; min-inline-size: 0; padding: 0.5rem 0.625rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--card); color: var(--card-foreground); font: inherit; text-align: start; cursor: pointer; &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } & > .graph-explorer-icon { grid-row: span 2; } } .graph-explorer-end-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .graph-explorer-end-type { font-size: 0.6875rem; color: var(--muted-foreground); } .graph-explorer-rel-table { font-size: 0.8125rem; & :is(.table-cell, .table-head) { padding-block: 0.3rem; } & tr[data-graph-explorer-goto] { cursor: pointer; &:hover { background: var(--accent); } } & .graph-explorer-rel-edge :is(.graph-explorer-edge-pill, .graph-explorer-edge-badge) { box-shadow: none; } & .graph-explorer-bar-col { inline-size: 8rem; } } .graph-explorer-rel-type { display: inline-flex; align-items: center; gap: 0.25rem; } .graph-explorer-relations .table-container { border: 0; border-radius: 0; } .graph-explorer-link { border: 0; padding: 0; background: none; color: inherit; font: inherit; cursor: pointer; text-align: start; &:hover { text-decoration: underline; } &:focus-visible { outline: 2px solid var(--ring); } & > .graph-explorer-icon { color: var(--muted-foreground); } } /* the expanded page: a head, key figures as Statistics, Cards in a grid */ .graph-explorer-page { display: grid; gap: 1.25rem; padding: 1rem 1.25rem 2rem; & .graph-explorer-health { inline-size: 100%; block-size: 12px; } & > .graph-explorer-ends { max-inline-size: 44rem; } } .graph-explorer-page-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 1rem; } /* an expanded page's "Back to graph" points toward the graph: a row of its own above the head, at its start - at its end when the details sit in a west region (the graph is then to their right). Any renderer that puts the collapse action among its actions gets it. */ .graph-explorer-page-head:has(> .graph-explorer-detail-actions > [data-graph-explorer-action="collapse"]) { position: relative; padding-block-start: 2.75rem; & > .graph-explorer-detail-actions > [data-graph-explorer-action="collapse"] { position: absolute; inset-block-start: 0; inset-inline-start: 0; } } :where(.border-layout-west) .graph-explorer-page-head > .graph-explorer-detail-actions > [data-graph-explorer-action="collapse"] { inset-inline: auto 0; } .graph-explorer-page-title { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.015em; overflow-wrap: anywhere; } .graph-explorer-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 0.75rem; } .graph-explorer-kpi { padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground); box-shadow: var(--shadow-xs); & .statistic-value { font-size: 1.5rem; font-variant-numeric: tabular-nums; } &[data-tone] .statistic-value { color: var(--tone); } } .graph-explorer-page-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr)); gap: 1rem; align-items: start; } /* every row behind a node or a link: a Data Grid and the shares of what its filters match */ .graph-explorer-records { display: grid; gap: 0.75rem; min-inline-size: 0; } .graph-explorer-records-count { margin: 0; font-size: 0.8125rem; font-variant-numeric: tabular-nums; } .graph-explorer-records-shares { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr)); gap: 1rem 1.5rem; font-size: 0.8125rem; &:empty { display: none; } } .graph-explorer-records-share { display: grid; gap: 0.5rem; align-content: start; min-inline-size: 0; } .graph-explorer-grid { --data-grid-row-height: 30px; font-size: 0.8125rem; & .graph-explorer-link { max-inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .graph-explorer-page-card { min-inline-size: 0; &[data-wide] { grid-column: 1 / -1; } & .card-title { display: flex; align-items: center; gap: 0.5rem; & > .graph-explorer-icon { color: var(--muted-foreground); } } & .card-content { display: grid; gap: 0.75rem; min-inline-size: 0; } } @container (max-width: 48rem) { .graph-explorer-page-head { grid-template-columns: auto minmax(0, 1fr); & .graph-explorer-detail-actions { grid-column: 1 / -1; } } } @container (max-width: 34rem) { .graph-explorer-layer-label { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } } /* ---------------------------------------------------------- icons */ .graph-explorer-icon { display: inline-block; flex: none; inline-size: 1rem; block-size: 1rem; vertical-align: middle; background-color: currentColor; -webkit-mask: var(--graph-explorer-icon) center / 100% 100% no-repeat; mask: var(--graph-explorer-icon) center / 100% 100% no-repeat; &[data-size="xs"] { inline-size: 0.75rem; block-size: 0.75rem; } &[data-size="sm"] { inline-size: 0.875rem; block-size: 0.875rem; } &[data-size="lg"] { inline-size: 1.5rem; block-size: 1.5rem; } &[data-size="xl"] { inline-size: 2rem; block-size: 2rem; } &[data-tone] { color: var(--tone); } } @keyframes graph-explorer-flow { to { stroke-dashoffset: -9; } } @keyframes graph-explorer-pulse { 50% { opacity: 0.35; } } /* a change stays bright, then fades (the runtime takes the mark away when it ends) */ @keyframes graph-explorer-change-fade { 0%, 60% { opacity: 1; } 100% { opacity: 0; } } /* ---------------------------------------------------------- the Updates outlet: one compact row per change */ /* .virtual-list too: the list's own row height would win where its sheet comes later (an app bundle) */ .graph-explorer-updates.virtual-list, .graph-explorer-updates { --virtual-list-row-height: 2rem; box-sizing: border-box; block-size: 100%; min-block-size: 6rem; border: 0; border-radius: 0; background: transparent; & .virtual-list-row { padding: 0; } } .graph-explorer-update { display: flex; align-items: center; gap: 0.375rem; box-sizing: border-box; inline-size: 100%; block-size: 2rem; padding: 0 0.625rem; border: 0; border-block-end: 1px solid color-mix(in oklch, var(--border) 60%, transparent); background: transparent; color: var(--foreground); font: inherit; font-size: 0.8125rem; text-align: start; cursor: pointer; & > .indicator-dot { color: var(--graph-explorer-layer, var(--graph-accent)); &[data-tone] { color: var(--tone); } } &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } .graph-explorer-update-label { flex: 0 1 auto; max-inline-size: 45%; overflow: hidden; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; } .graph-explorer-update-text { flex: 1 1 0; min-inline-size: 0; overflow: hidden; color: var(--muted-foreground); white-space: nowrap; text-overflow: ellipsis; } .graph-explorer-update-time { flex: none; color: var(--muted-foreground); font-size: 0.75rem; font-variant-numeric: tabular-nums; } @media (prefers-reduced-motion: reduce) { .graph-explorer .graph-explorer-edge, .graph-explorer :is(.graph-explorer-change-dot, .graph-explorer-edge-flash) { animation: none !important; } .graph-explorer :is(.graph-explorer-node, .graph-explorer-ring-item) { transition: none; } } @media (prefers-contrast: more) { .graph-explorer .graph-explorer-edge { stroke-width: 2.75; } :is(.graph-explorer-chip, .graph-explorer-card) { border-width: 2px; border-color: var(--foreground); } } @media (forced-colors: active) { .graph-explorer-part { border-color: CanvasText; } .graph-explorer .graph-explorer-node > :is(.graph-explorer-chip, .graph-explorer-card) { border-color: CanvasText; background: Canvas; } :is(.graph-explorer-chip, .graph-explorer-card, .graph-explorer-sign, .graph-explorer-ring-item) { border: 1px solid CanvasText; background: Canvas; color: CanvasText; forced-color-adjust: none; } .graph-explorer .graph-explorer-edge { stroke: CanvasText; } .graph-explorer .graph-explorer-edge-flash { stroke: Highlight; filter: none; } .graph-explorer .graph-explorer-change-dot { forced-color-adjust: none; color: Highlight; box-shadow: none; } .graph-explorer-update { border-block-end-color: CanvasText; &:is(:hover, :focus-visible) { outline: 2px solid Highlight; } } .graph-explorer .graph-explorer-edge-head { fill: CanvasText; } .graph-explorer .graph-explorer-node[data-selected] > * { outline-color: Highlight; } .graph-explorer-icon { background-color: CanvasText; forced-color-adjust: none; } } /* the icon set: a Lucide subset (see the header) */ .graph-explorer-icon[data-icon="chevron-up"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="chevron-down"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="maximize-2"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 3 21 3 21 9' /%3E %3Cpolyline points='9 21 3 21 3 15' /%3E %3Cline x1='21' x2='14' y1='3' y2='10' /%3E %3Cline x1='3' x2='10' y1='21' y2='14' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="minimize-2"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='4 14 10 14 10 20' /%3E %3Cpolyline points='20 10 14 10 14 4' /%3E %3Cline x1='14' x2='21' y1='10' y2='3' /%3E %3Cline x1='3' x2='10' y1='21' y2='14' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="x"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18' /%3E %3Cpath d='m6 6 12 12' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="list-tree"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12h-8' /%3E %3Cpath d='M21 6H8' /%3E %3Cpath d='M21 18h-8' /%3E %3Cpath d='M3 6v4c0 1.1.9 2 2 2h3' /%3E %3Cpath d='M3 10v6c0 1.1.9 2 2 2h3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="waypoints"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='4.5' r='2.5' /%3E %3Cpath d='m10.2 6.3-3.9 3.9' /%3E %3Ccircle cx='4.5' cy='12' r='2.5' /%3E %3Cpath d='M7 12h10' /%3E %3Ccircle cx='19.5' cy='12' r='2.5' /%3E %3Cpath d='m13.8 17.7 3.9-3.9' /%3E %3Ccircle cx='12' cy='19.5' r='2.5' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="panel-right"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M15 3v18' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="rows-3"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M21 9H3' /%3E %3Cpath d='M21 15H3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="database"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3' /%3E %3Cpath d='M3 5V19A9 3 0 0 0 21 19V5' /%3E %3Cpath d='M3 12A9 3 0 0 0 21 12' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="plug"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22v-5' /%3E %3Cpath d='M9 8V2' /%3E %3Cpath d='M15 8V2' /%3E %3Cpath d='M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="file-text"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' /%3E %3Cpath d='M14 2v4a2 2 0 0 0 2 2h4' /%3E %3Cpath d='M10 9H8' /%3E %3Cpath d='M16 13H8' /%3E %3Cpath d='M16 17H8' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="user"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2' /%3E %3Ccircle cx='12' cy='7' r='4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="clock"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpolyline points='12 6 12 12 16 14' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="radio"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.9 19.1C1 15.2 1 8.8 4.9 4.9' /%3E %3Cpath d='M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5' /%3E %3Ccircle cx='12' cy='12' r='2' /%3E %3Cpath d='M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5' /%3E %3Cpath d='M19.1 4.9C23 8.8 23 15.1 19.1 19' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="plug-zap"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z' /%3E %3Cpath d='m2 22 3-3' /%3E %3Cpath d='M7.5 13.5 10 11' /%3E %3Cpath d='M10.5 16.5 13 14' /%3E %3Cpath d='m18 3-4 4h6l-4 4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="cog"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z' /%3E %3Cpath d='M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' /%3E %3Cpath d='M12 2v2' /%3E %3Cpath d='M12 22v-2' /%3E %3Cpath d='m17 20.66-1-1.73' /%3E %3Cpath d='M11 10.27 7 3.34' /%3E %3Cpath d='m20.66 17-1.73-1' /%3E %3Cpath d='m3.34 7 1.73 1' /%3E %3Cpath d='M14 12h8' /%3E %3Cpath d='M2 12h2' /%3E %3Cpath d='m20.66 7-1.73 1' /%3E %3Cpath d='m3.34 17 1.73-1' /%3E %3Cpath d='m17 3.34-1 1.73' /%3E %3Cpath d='m11 13.73-4 6.93' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="waves"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E %3Cpath d='M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E %3Cpath d='M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="boxes"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z' /%3E %3Cpath d='m7 16.5-4.74-2.85' /%3E %3Cpath d='m7 16.5 5-3' /%3E %3Cpath d='M7 16.5v5.17' /%3E %3Cpath d='M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z' /%3E %3Cpath d='m17 16.5-5-3' /%3E %3Cpath d='m17 16.5 4.74-2.85' /%3E %3Cpath d='M17 16.5v5.17' /%3E %3Cpath d='M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z' /%3E %3Cpath d='M12 8 7.26 5.15' /%3E %3Cpath d='m12 8 4.74-2.85' /%3E %3Cpath d='M12 13.5V8' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="box"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z' /%3E %3Cpath d='m3.3 7 8.7 5 8.7-5' /%3E %3Cpath d='M12 22V12' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="zap"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="eye"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0' /%3E %3Ccircle cx='12' cy='12' r='3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="key-round"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z' /%3E %3Ccircle cx='16.5' cy='7.5' r='.5' fill='currentColor' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="link"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' /%3E %3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="link-2"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 17H7A5 5 0 0 1 7 7h2' /%3E %3Cpath d='M15 7h2a5 5 0 1 1 0 10h-2' /%3E %3Cline x1='8' x2='16' y1='12' y2='12' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="zap-off"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.513 4.856 13.12 2.17a.5.5 0 0 1 .86.46l-1.377 4.317' /%3E %3Cpath d='M15.656 10H20a1 1 0 0 1 .78 1.63l-1.72 1.773' /%3E %3Cpath d='M16.273 16.273 10.88 21.83a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14H4a1 1 0 0 1-.78-1.63l4.507-4.643' /%3E %3Cpath d='m2 2 20 20' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="circle-dashed"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.1 2.182a10 10 0 0 1 3.8 0' /%3E %3Cpath d='M13.9 21.818a10 10 0 0 1-3.8 0' /%3E %3Cpath d='M17.609 3.721a10 10 0 0 1 2.69 2.7' /%3E %3Cpath d='M2.182 13.9a10 10 0 0 1 0-3.8' /%3E %3Cpath d='M20.279 17.609a10 10 0 0 1-2.7 2.69' /%3E %3Cpath d='M21.818 10.1a10 10 0 0 1 0 3.8' /%3E %3Cpath d='M3.721 6.391a10 10 0 0 1 2.7-2.69' /%3E %3Cpath d='M6.391 20.279a10 10 0 0 1-2.69-2.7' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="scissors"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3' /%3E %3Cpath d='M8.12 8.12 12 12' /%3E %3Cpath d='M20 4 8.12 15.88' /%3E %3Ccircle cx='6' cy='18' r='3' /%3E %3Cpath d='M14.8 14.8 20 20' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="shield-check"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' /%3E %3Cpath d='m9 12 2 2 4-4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="triangle-alert"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3' /%3E %3Cpath d='M12 9v4' /%3E %3Cpath d='M12 17h.01' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="search"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8' /%3E %3Cpath d='m21 21-4.3-4.3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="arrow-up"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 7-7 7 7' /%3E %3Cpath d='M12 19V5' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="scan"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V5a2 2 0 0 1 2-2h2' /%3E %3Cpath d='M17 3h2a2 2 0 0 1 2 2v2' /%3E %3Cpath d='M21 17v2a2 2 0 0 1-2 2h-2' /%3E %3Cpath d='M7 21H5a2 2 0 0 1-2-2v-2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="radio-tower"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.9 16.1C1 12.2 1 5.8 4.9 1.9' /%3E %3Cpath d='M7.8 4.7a6.14 6.14 0 0 0-.8 7.5' /%3E %3Ccircle cx='12' cy='9' r='2' /%3E %3Cpath d='M16.2 4.8c2 2 2.26 5.11.8 7.47' /%3E %3Cpath d='M19.1 1.9a9.96 9.96 0 0 1 0 14.1' /%3E %3Cpath d='M9.5 18h5' /%3E %3Cpath d='m8 22 4-11 4 11' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="keyboard"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 8h.01' /%3E %3Cpath d='M12 12h.01' /%3E %3Cpath d='M14 8h.01' /%3E %3Cpath d='M16 12h.01' /%3E %3Cpath d='M18 8h.01' /%3E %3Cpath d='M6 8h.01' /%3E %3Cpath d='M7 16h10' /%3E %3Cpath d='M8 12h.01' /%3E %3Crect width='20' height='16' x='2' y='4' rx='2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="expand"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21 21-6-6m6 6v-4.8m0 4.8h-4.8' /%3E %3Cpath d='M3 16.2V21m0 0h4.8M3 21l6-6' /%3E %3Cpath d='M21 7.8V3m0 0h-4.8M21 3l-6 6' /%3E %3Cpath d='M3 7.8V3m0 0h4.8M3 3l6 6' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="shrink"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 15 6 6m-6-6v4.8m0-4.8h4.8' /%3E %3Cpath d='M9 19.8V15m0 0H4.2M9 15l-6 6' /%3E %3Cpath d='M15 4.2V9m0 0h4.8M15 9l6-6' /%3E %3Cpath d='M9 4.2V9m0 0H4.2M9 9 3 3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="repeat-2"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m2 9 3-3 3 3' /%3E %3Cpath d='M13 18H7a2 2 0 0 1-2-2V6' /%3E %3Cpath d='m22 15-3 3-3-3' /%3E %3Cpath d='M11 6h6a2 2 0 0 1 2 2v10' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="refresh-cw"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8' /%3E %3Cpath d='M21 3v5h-5' /%3E %3Cpath d='M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16' /%3E %3Cpath d='M8 16H3v5' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="users"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' /%3E %3Ccircle cx='9' cy='7' r='4' /%3E %3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87' /%3E %3Cpath d='M16 3.13a4 4 0 0 1 0 7.75' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="activity"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="layers"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z' /%3E %3Cpath d='M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12' /%3E %3Cpath d='M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="gauge"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 14 4-4' /%3E %3Cpath d='M3.34 19a10 10 0 1 1 17.32 0' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="circle-alert"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cline x1='12' x2='12' y1='8' y2='12' /%3E %3Cline x1='12' x2='12.01' y1='16' y2='16' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="arrow-right"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14' /%3E %3Cpath d='m12 5 7 7-7 7' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="arrow-left"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 19-7-7 7-7' /%3E %3Cpath d='M19 12H5' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="arrow-left-right"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 3 4 7l4 4' /%3E %3Cpath d='M4 7h16' /%3E %3Cpath d='m16 21 4-4-4-4' /%3E %3Cpath d='M20 17H4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="git-merge"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='18' r='3' /%3E %3Ccircle cx='6' cy='6' r='3' /%3E %3Cpath d='M6 21V9a9 9 0 0 0 9 9' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="network"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='16' y='16' width='6' height='6' rx='1' /%3E %3Crect x='2' y='16' width='6' height='6' rx='1' /%3E %3Crect x='9' y='2' width='6' height='6' rx='1' /%3E %3Cpath d='M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' /%3E %3Cpath d='M12 12V8' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="info"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='M12 16v-4' /%3E %3Cpath d='M12 8h.01' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="folder-tree"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z' /%3E %3Cpath d='M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z' /%3E %3Cpath d='M3 5a2 2 0 0 0 2 2h3' /%3E %3Cpath d='M3 3v13a2 2 0 0 0 2 2h3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="layout-dashboard"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='7' height='9' x='3' y='3' rx='1' /%3E %3Crect width='7' height='5' x='14' y='3' rx='1' /%3E %3Crect width='7' height='9' x='14' y='12' rx='1' /%3E %3Crect width='7' height='5' x='3' y='16' rx='1' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="table"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v18' /%3E %3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M3 9h18' /%3E %3Cpath d='M3 15h18' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="crosshair"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cline x1='22' x2='18' y1='12' y2='12' /%3E %3Cline x1='6' x2='2' y1='12' y2='12' /%3E %3Cline x1='12' x2='12' y1='6' y2='2' /%3E %3Cline x1='12' x2='12' y1='22' y2='18' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="target"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Ccircle cx='12' cy='12' r='6' /%3E %3Ccircle cx='12' cy='12' r='2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="hash"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='4' x2='20' y1='9' y2='9' /%3E %3Cline x1='4' x2='20' y1='15' y2='15' /%3E %3Cline x1='10' x2='8' y1='3' y2='21' /%3E %3Cline x1='16' x2='14' y1='3' y2='21' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="tag"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z' /%3E %3Ccircle cx='7.5' cy='7.5' r='.5' fill='currentColor' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="workflow"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='8' height='8' x='3' y='3' rx='2' /%3E %3Cpath d='M7 11v4a2 2 0 0 0 2 2h4' /%3E %3Crect width='8' height='8' x='13' y='13' rx='2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="server"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='20' height='8' x='2' y='2' rx='2' ry='2' /%3E %3Crect width='20' height='8' x='2' y='14' rx='2' ry='2' /%3E %3Cline x1='6' x2='6.01' y1='6' y2='6' /%3E %3Cline x1='6' x2='6.01' y1='18' y2='18' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="globe"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20' /%3E %3Cpath d='M2 12h20' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="timer"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='10' x2='14' y1='2' y2='2' /%3E %3Cline x1='12' x2='15' y1='14' y2='11' /%3E %3Ccircle cx='12' cy='14' r='8' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="book-open"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 7v14' /%3E %3Cpath d='M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="send"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z' /%3E %3Cpath d='m21.854 2.147-10.94 10.939' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="external-link"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6' /%3E %3Cpath d='M10 14 21 3' /%3E %3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="circle"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="circle-check"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='m9 12 2 2 4-4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="ban"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='m4.9 4.9 14.2 14.2' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="lock"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='11' x='3' y='11' rx='2' ry='2' /%3E %3Cpath d='M7 11V7a5 5 0 0 1 10 0v4' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="git-branch"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='6' x2='6' y1='3' y2='15' /%3E %3Ccircle cx='18' cy='6' r='3' /%3E %3Ccircle cx='6' cy='18' r='3' /%3E %3Cpath d='M18 9a9 9 0 0 1-9 9' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="split"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3h5v5' /%3E %3Cpath d='M8 3H3v5' /%3E %3Cpath d='M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3' /%3E %3Cpath d='m15 9 6-6' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="route"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='19' r='3' /%3E %3Cpath d='M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15' /%3E %3Ccircle cx='18' cy='5' r='3' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="milestone"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 13v8' /%3E %3Cpath d='M12 3v3' /%3E %3Cpath d='M4 6a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h13a2 2 0 0 0 1.152-.365l3.424-2.317a1 1 0 0 0 0-1.635l-3.424-2.318A2 2 0 0 0 17 6z' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="signal"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 20h.01' /%3E %3Cpath d='M7 20v-4' /%3E %3Cpath d='M12 20v-8' /%3E %3Cpath d='M17 20V8' /%3E %3Cpath d='M22 4v16' /%3E%3C/svg%3E"); } .graph-explorer-icon[data-icon="history"] { --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' /%3E %3Cpath d='M3 3v5h5' /%3E %3Cpath d='M12 7v5l4 2' /%3E%3C/svg%3E"); }}§JS view file
// -- Graph Explorer (graph-explorer) -------------------------------------------// A knowledge graph of 2,000 to 4,000,000 nodes in plain DOM + canvas, after the design study "A Million-Node// Knowledge Graph Renderer for defuss-shadcn" (the graph-explorer PoC, MIT, Aron Homberg)://// - A fixed render budget: a Web Worker owns the graph (CSR edges), the cluster hierarchy, the layouts, the cycles// and the far field. It draws every dot and bulk edge into an OffscreenCanvas and decides each node's tier// (dot / chip / card) with box collision. The main thread projects a bounded pool of DOM slots with the live camera// every frame. VERIFIED: (graph-explorer.e2e "budget") a 1,000,000-node graph keeps fewer than 700 elements in the// explorer however far you zoom.// - One clock: the worker's bitmap is committed in the same requestAnimationFrame as the DOM writes, with a corrective// transform when it was drawn for an older camera.// - Semantic zoom with a floating origin: every cluster is a unit disk; zooming into one dives into it (the frame// rebases), zooming out or panning off its disk moves up; the parent level stays as a dimmed backdrop.// - Plugins: node, edge and detail renderers return html`` (SafeHtml, every interpolation escaped and sanitized);// edge styles are declarative rules the worker evaluates (functions never cross into it). Every markup write goes// through defuss-morph (pooled, keyless slots, morphed only when their markup changed).// - The page composes the rest: a breadcrumb, a search box, an outline, details, relations, a layer switch and// readouts are OUTLETS - any element with data-graph-explorer-for="<id>" and data-graph-explorer-outlet - filled by// this runtime (their content is chrome, data-ce-chrome). The full application is a Border Layout of Panels on the// page, not markup this component builds: Expand maximizes the Panel that holds the details outlet.//// The worker is ONE function (graphExplorerWorker below) started as a classic Blob worker - the pattern Compute &// Explain ships: it works inside opaque-origin sandboxes (the docs cards). Being stringified, it may reference// nothing outside itself.//// State lives ON the element (data-* + the store); the bound `api` is the only state mutator (AGENTS.md "State API").import { defussGlobals, defussQuery, componentState, bindComponent, unbindComponent,} from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// the morph half of the runtime (defuss-morph): a parsed node as a VNode, and morphconst morphRuntime = dfDollar as unknown as { domNodeToVNode: (node: Node) => any; morph: (target: Element, content: any[], options?: Record<string, unknown>) => void;};/** markup as VNodes: the browser's HTML parser (one pass), each top-level node through defuss-morph */const parseVNodes = (markup: string): any[] => [...new DOMParser().parseFromString(`<body>${markup}</body>`, 'text/html').body.childNodes].map((n) => morphRuntime.domNodeToVNode(n), );// 'default' = the top level, nothing selected; 'selected' = nodes picked at the top level; 'focused' = inside a// cluster (its id is the focus), with or without a selection thereconst graphExplorerStates = ['default', 'selected', 'focused'];// -- types ----------------------------------------------------------------------/** Markup made by html`` - every interpolation escaped; the only value a renderer may return. */export interface SafeHtml { /** the markup, its interpolations escaped */ readonly html: string;}/** A tone: a color role of the theme (muted text, ink, an accent, a link, healthy, warning, danger). */export type GraphTone = 'muted' | 'ink' | 'accent' | 'link' | 'ok' | 'warn' | 'danger';/** The breaker facts of a link: one circuit breaker, or the worst of the many an aggregated link bundles. */export interface GraphBreaker { /** closed (healthy), half-open (probing), open (tripped) or cut (by hand) */ state: 'closed' | 'half-open' | 'open' | 'cut'; /** trips counted */ trips: number; /** minutes since the breaker left closed */ since: number; /** probes a half-open breaker sends */ probes: number; /** probes that succeeded */ probesOk: number; /** who cut it */ by: string; /** member breakers open */ open: number; /** member breakers half-open */ half: number; /** member breakers cut */ cut: number; /** breakers the link bundles */ members: number; /** true when the link bundles several breakers (an edge between clusters) */ aggregate: boolean; /** the share of open members, 0 to 1 */ openShare: number; /** the share of members not closed, 0 to 1 */ badShare: number;}/** A cluster's breakers by state, summed over its links at this level. */export interface GraphBreakerCounts { /** open breakers */ open: number; /** half-open breakers */ half: number; /** cut breakers */ cut: number; /** breakers in all */ total: number;}/** The attributes of an entity (a leaf node). */export interface GraphEntityAttrs { /** its owner */ owner: string; /** rows, in thousands */ rows: number; /** hours since its last update */ freshness: number; /** outgoing links */ out: number; /** incoming links */ in: number;}/** A node as renderers see it: a cluster (a domain or a system) or an entity. */export interface GraphNode { /** the stable id: c<number> for a cluster, n<number> for an entity */ id: string; /** its index in the current view */ i: number; /** a cluster has children; an entity is a leaf */ kind: 'cluster' | 'entity'; /** its name */ label: string; /** its type number (0-7), the key of its color */ type: number; /** its type: Domain, API, Worker, Store, Stream - or an entity's Dataset, Endpoint, Document, Person, Job, Topic */ typeName: string; /** the entities below a cluster (1 for an entity) */ members: number; /** a cluster's direct children (0 for an entity) */ children: number; /** its links at this level */ degree: number; /** the size of its strongly connected component - more than 1 = in a cycle */ scc: number; /** its center, in the view's unit disk */ x: number; /** its center, in the view's unit disk */ y: number; /** its radius, in the view's unit disk */ r: number; /** the hierarchy level of the view it is in (0 = the top) */ depth: number; /** a cluster's breakers, summed over its links at this level (null in an entity view) */ breakers?: GraphBreakerCounts | null; /** an entity's attributes */ attrs?: GraphEntityAttrs;}/** How a link is drawn - what the edge rules decide. */export interface GraphEdgeStyle { /** its color role */ tone?: GraphTone; /** its stroke width in px */ width?: number; /** an arrowhead at the target, or none */ arrow?: 'end' | 'none'; /** a dash pattern in px */ dash?: number[]; /** animated dashes that move along a link with traffic (1 = on) */ flow?: number; /** a slow pulse */ pulse?: boolean; /** a gap in the middle: a broken link */ gap?: boolean; /** 'around': drawn as a curve (a cycle's back-edge) */ route?: 'straight' | 'around';}/** A link between two nodes of the view. */export interface GraphEdge { /** its index in the view */ k: number; /** the index of its source node in the view */ a: number; /** the index of its target node in the view */ b: number; /** links from a to b (an aggregate counts its members) */ count: number; /** links from b to a */ count2: number; /** true when it runs both ways */ bidir: boolean; /** true when it is the back-edge of a cycle */ back: boolean; /** 'leaf' between entities, 'agg' between clusters (it bundles the links below) */ kind: 'leaf' | 'agg'; /** the relation: calls, reads or owns */ type: string; /** its breaker facts (null when it has none) */ breaker: GraphBreaker | null; /** true while traffic flows (the live simulation) */ traffic: boolean; /** how the rules draw it */ style: GraphEdgeStyle;}/** One condition of an edge rule; every condition of a rule must hold. */export interface GraphCondition { /** the edge field, dotted for nested ones: 'kind', 'type', 'breaker.state', 'breaker.openShare' */ field: string; /** the value must equal this */ equals?: string | number | boolean; /** the value must be one of these */ in?: (string | number | boolean)[]; /** the value must exist (true) or be missing (false) */ exists?: boolean; /** the value must be at least this */ gte?: number; /** the value must be at most this */ lte?: number;}/** An edge rule: plain data the worker evaluates for every link it draws; the highest priority that matches wins. */export interface GraphRule { /** the conditions, all of which must hold */ when?: GraphCondition[]; /** the style it gives */ style: GraphEdgeStyle; /** its rank among the rules that match (default 0; a plugin registered on one element adds 1000) */ priority?: number;}/** One step of a path from the top: a cluster or the node itself. */export interface GraphPathStep { /** the step's id */ id: string; /** the step's name */ label: string;}/** A relation of a cluster at its level (details of a cluster). */export interface GraphRelation { /** the other cluster's id */ id: string; /** the other cluster's name */ label: string; /** links from the cluster to it */ out: number; /** links from it to the cluster */ in: number; /** the breaker facts of the link */ breaker: GraphBreaker | null; /** true when the link is the back-edge of a cycle */ back: boolean;}/** An outgoing link of an entity (details of an entity). */export interface GraphLink { /** the target's id */ id: string; /** the target's name */ label: string; /** the target's type */ type: string; /** the target's system */ system: string; /** the target's domain */ domain: string; /** the relation: calls, reads or owns */ edge: string; /** true when the target is in the same system */ same: boolean;}/** What the worker knows about a node beyond its view: path, relations, counts. */export interface GraphDetails { /** the node's id */ id: string; /** the path from the top to the node */ path: GraphPathStep[]; /** a cluster's relations at its level, the busiest first (at most 60) */ relations?: GraphRelation[]; /** a cluster's entities */ members?: number; /** a cluster's direct children */ children?: number; /** a cluster's hierarchy level */ level?: number; /** an entity's outgoing links (at most 40) */ out?: GraphLink[]; /** an entity's incoming links (the sources; at most 40) */ in?: GraphLink[]; /** an entity's outgoing links in all */ outDeg?: number; /** an entity's incoming links in all */ inDeg?: number;}/** One end of a link in an edge's details. */export interface GraphEdgeEnd { /** the node's id */ id: string; /** the node's name */ label: string; /** its type: an entity type (Dataset, Endpoint, ...) or a cluster kind (API system, Domain, ...) */ type: string; /** the system it belongs to (entities) */ system?: string;}/** A bundled pair of systems inside an edge between clusters, with its circuit breaker. */export interface GraphEdgePair { /** the calling system */ from: GraphEdgeEnd; /** the called system */ to: GraphEdgeEnd; /** the links between them */ count: number; /** the breaker: closed, half-open, open or cut */ state: string; /** how often it tripped */ trips: number; /** how long it has been in its state (minutes) */ since: number; /** who changed it last */ by: string;}/** A node that sends or receives a share of an edge's links. */export interface GraphEdgeShare { /** its id */ id: string; /** its name */ label: string; /** its type */ type: string; /** its links on this edge */ count: number;}/** One link between two entities, as a sample of an edge. */export interface GraphEdgeSample { /** the source entity */ from: GraphEdgeEnd; /** the target entity */ to: GraphEdgeEnd; /** the relation: calls, reads or owns */ type: string; /** true when it runs from the edge's target back to its source */ back: boolean;}/** What the worker knows about an edge: its links both ways, by relation, the bundled breakers and samples. */export interface GraphEdgeDetails { /** the edge's id: '<source id>><target id>' */ id: string; /** 'leaf' between entities, 'agg' between clusters */ kind: 'leaf' | 'agg'; /** the path from the top to the view the edge is in */ path: GraphPathStep[]; /** the source end */ source: GraphEdgeEnd; /** the target end */ target: GraphEdgeEnd; /** links from the source to the target */ forward: number; /** links from the target back to the source */ backward: number; /** links per relation (calls, reads, owns), both ways */ byType: Record<string, number>; /** the system pairs it bundles with their breakers, the worst first (at most 40) */ pairs: GraphEdgePair[]; /** the source's members that send the most links (at most 8) */ sources: GraphEdgeShare[]; /** the target's members that receive the most links (at most 8) */ targets: GraphEdgeShare[]; /** single links between entities, both ways (at most 40) */ samples: GraphEdgeSample[];}/** A selected link as its detail renderer gets it: the two ends and the view's edge. */export interface GraphEdgeItem { /** always 'edge' */ kind: 'edge'; /** '<source id>><target id>' - the value of data-selected-edge */ id: string; /** the source node */ source: GraphNode; /** the target node */ target: GraphNode; /** the edge in the view: counts, relation, breaker, style */ edge: GraphEdge; /** the hierarchy level of the view (0 = the top) */ depth: number;}/** An edge detail renderer: what the details outlet shows for a selected link (an edge plugin's `detail`). */export interface GraphEdgeDetail { /** a score for a link, or false when it does not describe it; the highest wins (default 0) */ match?: (item: GraphEdgeItem) => number | false; /** the details title for the link */ title?: (item: GraphEdgeItem) => string; /** the markup (or a Promise of it); ctx.expanded asks for the link's full page */ render: (item: GraphEdgeItem, ctx: GraphRenderContext) => SafeHtml | Promise<SafeHtml>; /** whether the link has a page (default: every link) - without one no Expand button shows and E does nothing */ page?: (item: GraphEdgeItem) => boolean;}/** A cluster's view: its children laid out in its unit disk, and the links between them. */export interface GraphView { /** the cluster's id (c0 = the top) */ id: string; /** the path from the top to the cluster */ path: GraphPathStep[]; /** the cluster's children */ children: GraphNode[]; /** the links between them */ edges: GraphEdge[]; /** true when the children are entities */ leafView: boolean;}/** Options of an icon. */export interface GraphIconOptions { /** an accessible name; without one the icon is decorative (aria-hidden) */ label?: string; /** its color role */ tone?: GraphTone; /** xs, sm, lg or xl (default 1rem) */ size?: 'xs' | 'sm' | 'lg' | 'xl'; /** a type number: the icon takes that type's color (with the graph-explorer-type-icon class) */ type?: number; /** extra classes */ cls?: string;}/** What a node or detail renderer gets besides the node. */export interface GraphRenderContext { /** chip or card for a node in the graph; detail for the details outlet; relations for the relations outlet */ tier: 'chip' | 'card' | 'detail' | 'relations'; /** true when the node is selected */ selected: boolean; /** true while the pointer is over the node */ highlighted: boolean; /** true when the node has the keyboard focus */ focused: boolean; /** the hierarchy level of the view (0 = the top) */ depth: number; /** the camera's zoom: screen px per unit of the view's disk */ zoom: number; /** true when the details outlet is expanded (its Panel maximized): render the node's full page */ expanded: boolean; /** the layers shown (data-layers): nodes, relations, labels, health and the plugins' */ layers: string[]; /** true when every node is drawn as a one-line chip (data-compact) */ compact: boolean; /** a node renderer: the parts the shown layers add to this node at this tier - put them where they belong (the * explorer appends them after the markup of a renderer that leaves them out) */ parts: SafeHtml; /** the escaping template tag */ html: (strings: TemplateStringsArray, ...values: unknown[]) => SafeHtml; /** an icon */ icon: (name: string, options?: GraphIconOptions) => SafeHtml; /** the value of a data-graph-explorer-action attribute: enter, bring, expand, collapse, details */ action: (name: string) => string; /** a link drawn by the edge label renderers, at a tier */ edgeLabel: (edge: Partial<GraphEdge>, tier?: 'icon' | 'label' | 'full') => SafeHtml; /** a cluster's view, loaded by the worker */ fetchView: (id: string) => Promise<GraphView>; /** a node's details, loaded by the worker */ fetchDetails: (id: string) => Promise<GraphDetails>; /** an edge's details, loaded by the worker: its links both ways, by relation, its breakers and samples */ fetchEdge: (id: string) => Promise<GraphEdgeDetails>; /** * markup the details keep as it is while its key and its markup stay the same: a library component with a script * (a Chart, a Geo Map, an Avatar) keeps what its script made when the details render again; `mount` runs once * when the island's element is made (bind a grid's records, a chart's data) and what it returns when it goes */ island: (key: string, content: SafeHtml, mount?: GraphIslandMount) => SafeHtml; /** true when the node (or the link) has a page to expand to: a details outlet shows it and its detail renderer offers * one - an Expand button belongs only there */ expandable: boolean; /** every row behind a node or a link, built by the worker: the entities below a cluster, a link's single links */ fetchRows: (id: string) => Promise<GraphRows>; /** a value loaded once per key: undefined until it first arrives, then the node renders again with it; when the data * moves on (a Live mode update) the key loads again and the last answer stays meanwhile */ request: <T>(key: string, load: () => T | Promise<T>) => T | undefined;}/** Runs once when an island's element is made - bind a component to it (a grid's records, a chart's data); the * function it returns runs when the island goes. */export type GraphIslandMount = (el: HTMLElement) => void | (() => void);/** One node of a subtree (subtree()) - a flat record a tree grid takes (data-parent-field="parent"). */export interface GraphTreeRow { /** the node's id (c<number> a cluster, n<number> an entity) */ id: string; /** the parent's id; '' for the subtree's root */ parent: string; /** the node's name */ label: string; /** cluster or entity */ kind: 'cluster' | 'entity'; /** what it is: Graph, Domain, a system kind (API, Worker, Store, Stream) or an entity type (Dataset, Endpoint ...) */ type: string; /** the hierarchy level in the whole graph (0 = the top) */ level: number; /** a cluster's members (every entity below it), an entity's links (in + out) */ count: number; /** the children it has in the graph - also when the subtree stops above them */ children: number;}/** One hit of find(): a cluster or an entity whose name holds the query. */export interface GraphSearchResult { /** the node's id */ id: string; /** the node's name */ label: string; /** cluster or entity */ kind: 'cluster' | 'entity'; /** Domain, a system kind or an entity type */ type: string; /** the clusters above it, joined by ' › ' */ path: string; /** the clusters above it, the top first - id and name each */ parents: { id: string; label: string }[];}/** Every row behind a node or a link (ctx.fetchRows) - the records a Data Grid takes. */export interface GraphRows { /** the node's or the link's id */ id: string; /** entities: every entity below a cluster (or the entity itself); links: a link's single links, both ways */ kind: 'entities' | 'links'; /** an entity: id, label, type, system, domain, owner, out, in, rows (thousands), freshness (hours); a link: id, * from, fromLabel, relation, to, toLabel, direction (forward, back), fromSystem, toSystem */ rows: GraphRow[];}/** One row of GraphRows: plain values. */export type GraphRow = Record<string, string | number>;/** What an edge label renderer gets besides the edge. */export interface GraphEdgeContext { /** icon, label or full - picked by the edge's length on screen, the budgets and the free space */ tier: 'icon' | 'label' | 'full'; /** the hierarchy level of the view (0 = the top) */ depth: number; /** the camera's zoom */ zoom: number; /** the view's id */ view: string | null; /** the escaping template tag */ html: (strings: TemplateStringsArray, ...values: unknown[]) => SafeHtml; /** an icon */ icon: (name: string, options?: GraphIconOptions) => SafeHtml; /** the link's source node (labels in the graph) */ source?: GraphNode; /** the link's target node (labels in the graph) */ target?: GraphNode; /** true when an end of the link is selected */ selected?: boolean;}/** An edge label renderer: DOM labels and icons along links, chosen per zoom level. */export interface GraphEdgeLabel { /** the shortest on-screen length (px) for each tier */ levels?: { icon?: number; label?: number; full?: number }; /** the box [width, height] of each tier, for collision */ sizes?: { icon?: [number, number]; label?: [number, number]; full?: [number, number] }; /** the hierarchy levels it labels (null = all) */ depths?: number[] | null; /** its own cap per tier */ budget?: { icon?: number; label?: number; full?: number } | null; /** a score for an edge, or false when it does not label it */ match: (edge: GraphEdge, ctx: GraphEdgeContext) => number | false; /** the rank of an edge's label (the busiest or worst first) */ priority?: (edge: GraphEdge, ctx: GraphEdgeContext) => number; /** the label's markup */ render: (edge: GraphEdge, ctx: GraphEdgeContext) => SafeHtml;}/** A plugin: a node renderer (kind 'node'), edge rules and labels (kind 'edge') or a detail renderer (kind 'detail'). */export interface GraphPlugin { /** node, edge, detail or update */ kind: 'node' | 'edge' | 'detail' | 'update'; /** its name (error messages, the HUD) */ name: string; /** node / detail: a score for a node, or false; the highest wins, the later of equal ones */ match?: (node: GraphNode) => number | false; /** node: the box of each tier, [width, height] in px - a part's: a chip part adds its width to the chip, a card * part its height to the card (its width is the least the card needs) */ sizes?: GraphSizes | ((node: GraphNode) => GraphSizes); /** node / detail: the markup (a detail renderer may return a Promise of it) */ render?: (node: GraphNode, ctx: GraphRenderContext) => SafeHtml | Promise<SafeHtml>; /** node: a key of what the markup depends on beyond the node (a slot morphs only when it changes) */ key?: (node: GraphNode, ctx: GraphRenderContext) => string; /** node: the layer this renderer draws a part of (a lowercase word) - without one it draws the node itself (the * nodes layer); edge: the layer its rules and labels belong to (default: relations for rules, labels for labels) */ layer?: string; /** node / edge: the layer's name in the layer switch (a plugin's own layer) */ layerLabel?: string; /** node / edge: the layer's icon in the layer switch */ layerIcon?: string; /** node / edge: the layer's colour, any CSS colour (default: the next chart colour) */ layerColor?: string; /** detail: the details title for a node */ title?: (node: GraphNode) => string; /** detail: whether a node has a page (default: every node) - without one no Expand button shows and E does nothing */ page?: (node: GraphNode) => boolean; /** edge: the style rules (plain data) */ rules?: GraphRule[]; /** edge: the label renderer */ label?: GraphEdgeLabel; /** edge: the details of a selected link (the details outlet, its full page when expanded) */ detail?: GraphEdgeDetail; /** update: the row renderer of the Updates outlet */ update?: GraphUpdateRenderer;}/** A node renderer's boxes: [width, height] in px per tier (a part: the width a chip part adds, the height a card part * adds). */export interface GraphSizes { /** the chip's box */ chip?: [number, number]; /** the card's box */ card?: [number, number];}/** A layer of information the layer switch shows or hides. */export interface GraphLayer { /** its name (a word of data-layers) */ name: string; /** its label */ label: string; /** its icon */ icon: string; /** its colour: the CSS colour its switch, its parts and its labels are drawn in */ color: string; /** true for the base layers (nodes, relations) - shown while data-layers is absent */ base: boolean; /** true while it is shown */ shown: boolean;}/** The camera: where the view's unit disk is shown. */export interface GraphCamera { /** the center, in the view's unit disk */ cx: number; /** the center, in the view's unit disk */ cy: number; /** screen px per unit */ z: number;}/** What inspect() reports: the camera, the view, the budget's use and the worker's timing. */export interface GraphInspection { /** the view's id */ view: string | null; /** the camera */ cam: GraphCamera; /** DOM slots in use */ slots: number; /** true while a flight runs */ flight: boolean; /** true while a navigation runs */ navigating: boolean; /** frames behind the camera */ lag: number; /** the elements inside the explorer */ elements: number; /** the worker's last frame: cards, chips, dots, edges, labels, ms */ worker: Record<string, number>; /** the generated graph: nodes, edges, ms */ graph: Record<string, number> | null; /** the view's children with their positions (with { children: true }) */ children?: { id: string; label: string; x: number; y: number; r: number }[]; /** the view's edges by id, with their link counts (with { children: true }) */ edges?: { id: string; count: number; count2: number; kind: string }[]; /** the boxes [x, y, width, height] of the labels the worker drew into the canvas (with { children: true }) */ dotLabels?: number[][];}/** setState() config: what the markup says - the focus, the selection, the layers, the expanded details. */export interface GraphExplorerConfig { /** the cluster the view is in (null = the top); 'focused' needs one */ focus?: string | null; /** the selected node ids, space separated or as a list */ selected?: string | string[]; /** the layers shown, space separated or as a list: nodes, relations, labels, health or a plugin's (none: only the * clusters' circles and dots) - data-layers, absent for the base layers nodes and relations */ layers?: string | string[]; /** every node as a one-line chip (data-compact) */ compact?: boolean; /** the details outlet expanded to the selection's page (the first selected node's, or the link's) */ expanded?: boolean; /** the selected link: '<source id>><target id>' of two nodes in the view; a node selection clears it */ edge?: string | null;}/** setState() configs per state - every state takes the whole config (merged into the current one). */export interface GraphExplorerStateConfigs { /** The top level, nothing selected - the focus and the selection are cleared. */ default: GraphExplorerConfig; /** Nodes (`config.selected`) or a link (`config.edge`) picked at the top level; an id deeper down flies there and lands in focused. */ selected: GraphExplorerConfig; /** Inside the cluster `config.focus` (the view dives there), with or without a selection. */ focused: GraphExplorerConfig;}/** graph-explorer-select: the selection changed. */export interface GraphExplorerSelectDetail { /** the selected node ids */ selected: string[]; /** the selected link ('<source id>><target id>'), null when nodes or nothing is selected */ edge: string | null;}/** graph-explorer-focus: the view moved to another cluster. */export interface GraphExplorerFocusDetail { /** the cluster's id, null at the top */ focus: string | null; /** the path from the top to it */ path: GraphPathStep[];}/** graph-explorer-view: the camera came to rest. */export interface GraphExplorerViewDetail { /** the cluster the view is in, null at the top */ focus: string | null; /** the camera */ camera: GraphCamera;}/** graph-explorer-action: an action button was used (in a card, the details or bound to the explorer). */export interface GraphExplorerActionDetail { /** the action: enter, bring, expand, collapse, details, up, fit, live, help */ action: string; /** the node it acted on, null for the explorer's own actions */ id: string | null;}/** graph-explorer-layers: a layer was shown or hidden, or Compact switched. */export interface GraphExplorerLayersDetail { /** the layers shown now */ layers: string[]; /** true when every node is a one-line chip */ compact: boolean;}/** A change in the graph: a node or a link that changed, as the Updates outlet lists it and the graph marks it (a * glowing dot on a node, a link that flashes in its layer's colour). Live mode's simulation makes them; an app adds its * own with update(). */export interface GraphUpdate { /** a unique id (made when an update leaves it out) */ id: string; /** when it happened, in ms since the epoch */ time: number; /** what changed: a node, or a link between two nodes */ kind: 'node' | 'edge'; /** the node's id, or the link's '<from>><to>' */ target: string; /** a link's view: the cluster whose children it joins ('c0' the top level) - a click goes there first */ view?: string; /** the layer it belongs to: it shows while that layer is shown (nodes, relations, labels, health or a plugin's) */ layer: string; /** a short word for the kind of change: deploy, breaker, traffic, count ... */ type: string; /** what happened, one line */ text: string; /** the target's name (the explorer fills it in for a node or link of the view it shows) */ label: string; /** its tone: ok, warn, danger, accent ... (the row's dot; default: the layer's colour) */ tone?: GraphTone;}/** graph-explorer-update: a node or a link changed. */export interface GraphExplorerUpdateDetail { /** the change, as the Updates outlet lists it */ update: GraphUpdate;}/** What an update renderer gets beside the update. */export interface GraphUpdateContext { /** the escaping template tag every renderer returns its markup through */ html: (strings: TemplateStringsArray, ...values: unknown[]) => SafeHtml; /** an icon of the explorer's set */ icon: (name: string, opts?: GraphIconOptions) => SafeHtml; /** a layer's colour (any CSS colour) */ color: (layer: string) => string; /** a time, formatted for the row (hours, minutes, seconds) */ time: (ms: number) => string;}/** An update renderer: one compact row of the Updates outlet (the explorer wraps it in the button that selects the * target). */export interface GraphUpdateRenderer { /** a score for an update, or false; the highest wins, the later of equal ones (default 0) */ match?: (update: GraphUpdate) => number | false; /** the row's content */ render: (update: GraphUpdate, ctx: GraphUpdateContext) => SafeHtml;}/** graph-explorer-error: the worker or a renderer failed. */export interface GraphExplorerErrorDetail { /** what failed */ message: string;}// -- small helpers --------------------------------------------------------------const TONES = ['muted', 'ink', 'accent', 'link', 'ok', 'warn', 'danger'];const reducedMotion = (): boolean => !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(b, v));const fmt = (n: number): string => Number(n).toLocaleString('en-US');/** a cycle worth marking: small enough to read (a whole strongly connected level is no news) */const smallCycle = (n: { scc: number }): boolean => n.scc > 1 && n.scc <= 12;const live = new Set<any>();/** an id list from a config or an attribute: a list, or words separated by spaces or commas */function keyList(value: unknown): string[] { const list = Array.isArray(value) ? value.map(String) : typeof value === 'string' ? value.split(/[\s,]+/) : []; return [...new Set(list.map((k) => k.trim()).filter(Boolean))];}// -- SafeHtml -------------------------------------------------------------------const SAFE = new WeakSet<object>();const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };const esc = (v: unknown): string => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]);const isSafe = (v: unknown): v is SafeHtml => !!v && typeof v === 'object' && SAFE.has(v as object);const part = (v: unknown): string => (isSafe(v) ? v.html : Array.isArray(v) ? v.map(part).join('') : esc(v));/** * The escaping template tag renderers write markup with: every interpolation is escaped; nested html`` values and * arrays of them pass through as markup. * @param strings - the template's literal parts * @param values - the interpolations * @returns the markup as SafeHtml */function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml { let out = strings[0]; for (let i = 0; i < values.length; i++) out += part(values[i]) + strings[i + 1]; const safe = { html: out }; SAFE.add(safe); return safe;}const BLOCKED = new Set([ 'script', 'iframe', 'object', 'embed', 'style', 'link', 'meta', 'base', 'form', 'frame', 'frameset',]);const URL_ATTRS = new Set(['href', 'src', 'xlink:href', 'action', 'formaction', 'srcset', 'poster', 'background']);/** strips handlers (on*), URLs that run code and active elements: morph would apply an `onerror` attribute as a live * handler and never removes on* attributes on a patch */function sanitize(list: any[]): any[] { const out: any[] = []; for (const n of list || []) { if (!n || typeof n !== 'object') { out.push(n); continue; } if (n.type === '#comment' || BLOCKED.has(String(n.type).toLowerCase())) continue; const attrs: Record<string, unknown> = {}; for (const [k, v] of Object.entries(n.attributes || {})) { const kl = k.toLowerCase(); if (kl.startsWith('on') || typeof v === 'function') continue; if (URL_ATTRS.has(kl) && /^\s*(javascript|data|vbscript):/i.test(String(v).replace(/[\s\0]+/g, ''))) continue; attrs[k] = v; } out.push({ type: n.type, attributes: attrs, children: sanitize(n.children) }); } return out;}function vnodesOf(safe: SafeHtml): any[] { if (!isSafe(safe)) throw new TypeError('graph-explorer: renderers must return html`...` (SafeHtml); plain strings are rejected'); return sanitize(parseVNodes(safe.html));}/** parses many SafeHtml values in one DOMParser pass; one VNode list per input */function vnodesBatch(list: SafeHtml[]): any[][] { if (!list.length) return []; let src = ''; list.forEach((s, k) => { if (!isSafe(s)) throw new TypeError('graph-explorer: renderers must return html`...` (SafeHtml)'); src += '<div data-job="' + k + '">' + s.html + '</div>'; }); const parsed = parseVNodes(src).filter((n) => n && typeof n === 'object'); return parsed.map((w) => sanitize(w.children));}const morph = (el: Element, content: SafeHtml | any[]): void => morphRuntime.morph(el, Array.isArray(content) ? content : vnodesOf(content));const ISLAND = 'data-graph-explorer-island';const SLOT = 'data-graph-explorer-slot';/** * An island (ctx.island): markup the details keep while its key and its markup stay the same. A morph reconciles a * container to its new markup and would take away what a component's script made inside it (a Chart's canvas, a * Geo Map's SVG) - and its data-init, so the component would start again on the same element. */function island(key: string, content: SafeHtml): SafeHtml { return html`<div class="graph-explorer-island" ${ISLAND}="${key}">${content}</div>`;}/** * Morphs an outlet's content and keeps its islands: each live island leaves the DOM for a slot, the morph runs over * slots, then every slot takes back the island of its key when its markup is unchanged, else a new one. */function morphKeeping(el: HTMLElement, content: SafeHtml, mounts?: Map<string, GraphIslandMount>): void { const want = new Map<string, any>(); const slots = (list: any[]): any[] => list.map((n) => { if (!n || typeof n !== 'object' || !n.attributes) return n; const key = n.attributes[ISLAND]; if (key != null) { want.set(String(key), n); return { type: 'div', attributes: { [SLOT]: String(key) }, children: [] }; } return { ...n, children: slots(n.children || []) }; }); const tree = slots(vnodesOf(content)); const kept = new Map<string, HTMLElement>(); for (const isl of dfDollar(el).find(`[${ISLAND}]`).toArray() as HTMLElement[]) { const key = isl.getAttribute(ISLAND) ?? ''; if (kept.has(key)) continue; kept.set(key, isl); dfDollar(isl).replaceWith(dfDollar(`<div ${SLOT}="${esc(key)}"></div>`).get(0)); } // an island left without its slot goes: its mount's cleanup runs const drop = (isl: any): void => { try { isl?._islandCleanup?.(); } catch (err) { console.error('[graph-explorer] an island cleanup failed', err); } if (isl) isl._islandCleanup = null; }; morphRuntime.morph(el, tree); for (const slot of dfDollar(el).find(`[${SLOT}]`).toArray() as HTMLElement[]) { const key = slot.getAttribute(SLOT) ?? '', vnode = want.get(key), sig = JSON.stringify(vnode ?? null); let node = kept.get(key) as any; let made = false; if (!node || node._islandSig !== sig) { drop(node); const box = document.createElement('div'); morphRuntime.morph(box, vnode ? [vnode] : []); node = box.firstElementChild; if (!node) { kept.delete(key); dfDollar(slot).remove(); continue; } node._islandSig = sig; made = true; } kept.delete(key); dfDollar(slot).replaceWith(node); const mount = made ? mounts?.get(key) : undefined; if (mount) try { const cleanup = mount(node); if (typeof cleanup === 'function') node._islandCleanup = cleanup; } catch (err) { console.error('[graph-explorer] an island mount failed', err); } } for (const isl of kept.values()) drop(isl);}/** the islands of an element go: their mounts' cleanups run (an outlet emptied, the explorer destroyed) */function dropIslands(el: Element): void { for (const isl of dfDollar(el).find(`[${ISLAND}]`).toArray() as any[]) { try { isl._islandCleanup?.(); } catch (err) { console.error('[graph-explorer] an island cleanup failed', err); } isl._islandCleanup = null; }}// -- icons: a Lucide subset as CSS masks (graph-explorer.css) -------------------------// An icon is one empty <i>: cheap to morph, nothing for the sanitizer to strip, painted with currentColor.const ICON_NAME = /^[a-z0-9][a-z0-9-]{0,40}$/;/** * An icon of the explorer's set (a Lucide subset drawn as a CSS mask in the current color) - decorative unless it * has a label. * @param name - the icon's name (database, plug-zap, user, ...); an unknown name draws a circle * @param opts - its label, tone, size, type color and classes * @returns the icon's markup */function icon(name: string, opts: GraphIconOptions = {}): SafeHtml { const n = ICON_NAME.test(String(name)) ? String(name) : 'circle'; const label = opts.label ? html` role="img" aria-label="${opts.label}"` : html` aria-hidden="true"`; const tone = opts.tone && TONES.includes(opts.tone) ? html` data-tone="${opts.tone}"` : ''; const size = opts.size ? html` data-size="${opts.size}"` : ''; const type = opts.type != null ? html` data-type="${opts.type}"` : ''; return html`<i class="graph-explorer-icon${opts.cls ? ' ' + opts.cls : ''}" data-icon="${n}" ${label}${tone}${size}${type} ></i>`;}let iconSheet: HTMLStyleElement | null = null;/** * Adds an icon to the set: the inner markup of a 24x24 stroke icon (Lucide's conventions), drawn as a mask. * @param name - its name (lowercase words joined by "-") * @param svg - the shapes inside the <svg> (paths, circles, lines - nothing that loads or runs) */function registerIcon(name: string, svg: string): void { if (!ICON_NAME.test(name)) throw new TypeError('graph-explorer: icon names are lowercase words joined by "-"'); if (/<\s*(script|foreignObject|image|use|style)\b|\bon\w+\s*=|href\s*=/i.test(svg)) throw new TypeError('graph-explorer: icon markup may only hold shapes'); const doc = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>${String(svg).replace(/"/g, "'")}</svg>`; if (!iconSheet) { iconSheet = dfDollar('<style data-graph-explorer-icons></style>').get(0) as HTMLStyleElement; dfDollar(document.head).append(iconSheet); } iconSheet.append( `.graph-explorer-icon[data-icon="${name}"]{--graph-explorer-icon:url("data:image/svg+xml,${encodeURIComponent(doc)}")}\n`, );}const TYPE_ICON: Record<string, string> = { Domain: 'boxes', API: 'plug-zap', Worker: 'cog', Store: 'layers', Stream: 'waves', Dataset: 'database', Endpoint: 'plug', Document: 'file-text', Person: 'user', Job: 'timer', Topic: 'radio',};const typeIcon = (n: { typeName: string; type: number }, opts: GraphIconOptions = {}): SafeHtml => icon(TYPE_ICON[n.typeName] || 'circle', { cls: 'graph-explorer-type-icon', type: n.type, ...opts });const EDGE_ICON: Record<string, string> = { calls: 'zap', reads: 'eye', owns: 'key-round' };// -- the plugin registry ----------------------------------------------------------const globalRegistry: Record<'node' | 'edge' | 'detail' | 'update', any[]> = { node: [], edge: [], detail: [], update: [],};// edge labels: the tier is picked by the edge's visible length on screen (per zoom level), then by budget and// collision; `depths` limits a renderer to hierarchy levels (0 = the top)const DEFAULT_LABEL = { levels: { icon: 36, label: 110, full: 220 }, sizes: { icon: [20, 20], label: [104, 22], full: [196, 24] }, depths: null, budget: null,};const LABEL_TIERS = ['icon', 'label', 'full'];const LABEL_BUDGET: Record<string, number> = { icon: 160, label: 48, full: 16 };let pluginOrder = 0;function registryOf( scope: 'global' | HTMLElement | string | null | undefined,): Record<'node' | 'edge' | 'detail' | 'update', any[]> { if (scope === 'global' || scope == null) return globalRegistry; const inst = resolveTarget(scope)?._graphExplorer; if (!inst) throw new Error('graph-explorer: register(scope) takes "global" or an initialized .graph-explorer element'); return inst.registry;}/** * Registers a node, edge, detail or update plugin - for every explorer ('global') or one element (its plugins win). * Explorers on the page render again with it. * @param scope - 'global', or the .graph-explorer element (or its selector) * @param plugin - the plugin * @returns a function that removes the plugin again */function register(scope: 'global' | HTMLElement | string, plugin: GraphPlugin): () => void { const kinds = ['node', 'edge', 'detail', 'update']; if (!plugin || !kinds.includes(plugin.kind)) throw new TypeError('graph-explorer: plugin.kind must be node, edge, detail or update'); if (plugin.kind === 'update' && typeof plugin.update?.render !== 'function') throw new TypeError('graph-explorer: update plugins need update.render'); if ( plugin.kind === 'node' && (typeof plugin.match !== 'function' || typeof plugin.render !== 'function' || !plugin.sizes) ) throw new TypeError('graph-explorer: node plugins need match, sizes and render'); if (plugin.kind === 'edge' && !Array.isArray(plugin.rules) && !plugin.label && !plugin.detail) throw new TypeError('graph-explorer: edge plugins need rules (plain data), a label renderer or a detail renderer'); if (plugin.kind === 'edge' && plugin.detail && typeof plugin.detail.render !== 'function') throw new TypeError('graph-explorer: an edge detail renderer needs render'); if ( plugin.kind === 'edge' && plugin.label && (typeof plugin.label.render !== 'function' || typeof plugin.label.match !== 'function') ) throw new TypeError('graph-explorer: edge label renderers need match and render'); if (plugin.layer != null && !/^[a-z0-9-]+$/.test(plugin.layer)) throw new TypeError('graph-explorer: a layer is a lowercase word'); if (plugin.kind === 'node' && plugin.layer === 'nodes') throw new TypeError('graph-explorer: a renderer of the nodes layer has no layer - it draws the node itself'); if (plugin.kind === 'detail' && (typeof plugin.match !== 'function' || typeof plugin.render !== 'function')) throw new TypeError('graph-explorer: detail plugins need match and render'); if (plugin.kind === 'edge') JSON.stringify(plugin.rules || []); // rules must be structured-cloneable data const entry: any = { rules: [], ...plugin, _order: pluginOrder++, _scoped: scope !== 'global' && scope != null }; if (entry.label) entry.label = { ...DEFAULT_LABEL, ...entry.label, levels: { ...DEFAULT_LABEL.levels, ...entry.label.levels }, sizes: { ...DEFAULT_LABEL.sizes, ...entry.label.sizes }, }; const reg = registryOf(scope); reg[plugin.kind].push(entry); pluginsChanged(); return () => { const i = reg[plugin.kind].indexOf(entry); if (i >= 0) reg[plugin.kind].splice(i, 1); pluginsChanged(); };}function pluginsChanged(): void { for (const inst of live) inst.onPluginsChanged();}// -- geometry (the worker has the same) ---------------------------------------------const drawR = (isCluster: boolean, rs: number): number => isCluster ? Math.max(1.6, rs) : Math.min(6, Math.max(2.5, rs * 0.18));function anchorBox( isCluster: boolean, tier: number, sx: number, sy: number, rs: number, bw: number, bh: number,): [number, number] { if (isCluster && tier === 2) return [sx - bw / 2, Math.max(sy - rs * 0.86 + 6, sy - rs + 4)]; return [sx - bw / 2, sy - bh / 2];}/** a promoted (SVG) edge's path in screen space: { d, head, mid } */function edgePath(ax, ay, ar, bx, by, br, st, bidir, w): { d: string; head: string; mid: [number, number] } | null { const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy); if (len < ar + br + 2) return null; const ux = dx / len, uy = dy / len, head = st.arrow === 'none' ? 0 : 3.5 + w * 1.4; const f = (n: number): string => n.toFixed(1); const tri = (x, y, vx, vy, s): string => { const bx2 = x - vx * s, by2 = y - vy * s, px = -vy * s * 0.55, py = vx * s * 0.55; return `M${f(x + vx * s * 0.6)} ${f(y + vy * s * 0.6)}L${f(bx2 + px)} ${f(by2 + py)}L${f(bx2 - px)} ${f(by2 - py)}Z`; }; if (st.route === 'around') { const cx = (ax + bx) / 2 - uy * len * 0.22, cy = (ay + by) / 2 + ux * len * 0.22; const s0x = cx - ax, s0y = cy - ay, s0 = Math.hypot(s0x, s0y) || 1, e0x = cx - bx, e0y = cy - by, e0 = Math.hypot(e0x, e0y) || 1; const p0x = ax + (s0x / s0) * ar, p0y = ay + (s0y / s0) * ar, p2x = bx + (e0x / e0) * (br + head * 0.6), p2y = by + (e0y / e0) * (br + head * 0.6); const at = (t: number): [number, number] => { const mt = 1 - t; return [mt * mt * p0x + 2 * mt * t * cx + t * t * p2x, mt * mt * p0y + 2 * mt * t * cy + t * t * p2y]; }; const poly = (t0: number, t1: number): string => { let s = ''; for (let k = 0; k <= 10; k++) { const p = at(t0 + ((t1 - t0) * k) / 10); s += (k ? 'L' : 'M') + f(p[0]) + ' ' + f(p[1]); } return s; }; const d = st.gap && len > 40 ? poly(0, 0.44) + poly(0.56, 1) : poly(0, 1); const hx = p2x - cx, hy = p2y - cy, hl = Math.hypot(hx, hy) || 1; return { d, head: head ? tri(p2x, p2y, hx / hl, hy / hl, head) : '', mid: at(0.5) }; } const off = bidir && head ? head * 0.6 : 0; const p0x = ax + ux * (ar + off), p0y = ay + uy * (ar + off), p2x = bx - ux * (br + head * 0.6), p2y = by - uy * (br + head * 0.6); const mx = (p0x + p2x) / 2, my = (p0y + p2y) / 2; const d = st.gap && len > 40 ? `M${f(p0x)} ${f(p0y)}L${f(mx - ux * 6)} ${f(my - uy * 6)}M${f(mx + ux * 6)} ${f(my + uy * 6)}L${f(p2x)} ${f(p2y)}` : `M${f(p0x)} ${f(p0y)}L${f(p2x)} ${f(p2y)}`; let h = head ? tri(p2x, p2y, ux, uy, head) : ''; if (head && bidir) h += tri(p0x, p0y, -ux, -uy, head); return { d, head: h, mid: [mx, my] };}// -- van Wijk & Nuij camera paths -------------------------------------------------------/** a smooth zoom-pan from p0 to p1 (p = [cx, cy, width in world units]); the duration follows the path's length */function zoomPath(p0: number[], p1: number[]): { at: (t: number) => number[]; ms: number } { const rho = Math.SQRT2, rho2 = 2, rho4 = 4; const [ux0, uy0, w0] = p0, [ux1, uy1, w1] = p1, dx = ux1 - ux0, dy = uy1 - uy0, d2 = dx * dx + dy * dy; let S: number, at: (t: number) => number[]; if (d2 < 1e-12) { S = Math.log(w1 / w0) / rho; at = (t) => [ux0 + t * dx, uy0 + t * dy, w0 * Math.exp(rho * t * S)]; } else { const d1 = Math.sqrt(d2); const b0 = (w1 * w1 - w0 * w0 + rho4 * d2) / (2 * w0 * rho2 * d1), b1 = (w1 * w1 - w0 * w0 - rho4 * d2) / (2 * w1 * rho2 * d1); const r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0), r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1); S = (r1 - r0) / rho; at = (t) => { const s = t * S, c0 = Math.cosh(r0), u = (w0 / (rho2 * d1)) * (c0 * Math.tanh(rho * s + r0) - Math.sinh(r0)); return [ux0 + u * dx, uy0 + u * dy, (w0 * c0) / Math.cosh(rho * s + r0)]; }; } // brisk: a flight shows where the view goes, it does not hold the reader up (1.4 s once felt slow) return { at, ms: clamp(Math.abs(S) * 1000 * 0.3, 160, 460) };}const ease = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);// -- built-in plugins --------------------------------------------------------------------// The graph shows layers of information (the layer switch, data-layers): the nodes layer draws a node itself - a chip// with its name, a card with its actions -, every other layer adds a part to it (labels its counts, health its// breaker state, relations its links), each in the layer's colour. A node renderer without `layer` draws the node; one// with a layer draws that layer's part, so a plugin can add a layer of its own (an owner per entity) or re-skin a// node type while the parts still join it.const STATE_LABEL: Record<string, string> = { closed: 'closed', 'half-open': 'half-open', open: 'open', cut: 'cut' };const expandBtn = (c: GraphRenderContext, label: string): SafeHtml | string => !c.expandable ? '' : c.html`<button type="button" class="btn graph-explorer-card-expand" data-variant="ghost" data-size="icon-sm" data-graph-explorer-action="${c.action('expand')}" aria-label="Expand ${label}" title="Expand (E)">${c.icon('expand')}</button>`;const cycleMark = (c: GraphRenderContext, n: GraphNode): SafeHtml | string => smallCycle(n) ? c.html`<span class="graph-explorer-cycle" title="In a cycle of ${n.scc}">${c.icon('repeat-2', { size: 'xs' })}</span>` : '';/** a cluster's breaker health: the shares of its links by state, for bars */const health = (n: GraphNode) => { const b = n.breakers || { open: 0, half: 0, cut: 0, total: 0 }; const total = Math.max(1, b.total || 0), bad = b.open + b.half + b.cut; const tone = b.open / total >= 0.1 ? 'danger' : bad / total >= 0.1 ? 'warn' : 'ok'; return { ...b, total, closed: Math.max(0, total - bad), bad, tone };};const healthBar = (c: GraphRenderContext, h: ReturnType<typeof health>): SafeHtml => c.html`<span class="graph-explorer-health" role="img" aria-label="${h.open} open, ${h.half} half-open, ${h.cut} cut of ${h.total} links"><span data-tone="danger" style="flex:${h.open}"></span><span data-tone="warn" style="flex:${h.half}"></span><span data-tone="ink" style="flex:${h.cut}"></span><span data-tone="ok" style="flex:${h.closed}"></span></span>`;const breakerKey = (n: GraphNode): string => n.breakers ? `${n.breakers.open}/${n.breakers.half}/${n.breakers.cut}` : '';const freshness = (n: GraphNode) => { const f = n.attrs?.freshness ?? 0; return { f, tone: f > 24 ? 'danger' : f > 12 ? 'warn' : 'ok' };};// the nodes layer: the node itself - its type, its name, Expand; a cluster card zooms inregister('global', { kind: 'node', name: 'builtin-cluster', match: (n) => (n.kind === 'cluster' ? 0 : false), // a card's box: padding, border, the head and the button row (a part adds its own height and the gap) sizes: { chip: [132, 28], card: [236, 74] }, render: (n, c) => { if (c.tier === 'chip') return c.html`<span class="graph-explorer-chip" data-kind="cluster">${typeIcon(n)}<span class="graph-explorer-label">${n.label}</span>${c.parts}</span>`; return c.html`<article class="graph-explorer-card" data-kind="cluster"> <header class="graph-explorer-card-head">${typeIcon(n)}<strong class="graph-explorer-label">${n.label}</strong>${expandBtn(c, n.label)}</header> ${c.parts} <footer class="graph-explorer-card-actions"><button type="button" class="btn graph-explorer-card-enter" data-variant="outline" data-size="xs" data-graph-explorer-action="${c.action('enter')}" title="Zoom into ${n.label}: its ${fmt(n.children)} children (Enter)">Zoom in ⏎</button></footer> </article>`; },});register('global', { kind: 'node', name: 'builtin-entity', match: (n) => (n.kind === 'entity' ? 0 : false), sizes: { chip: [128, 26], card: [220, 42] }, render: (n, c) => { if (c.tier === 'chip') return c.html`<span class="graph-explorer-chip" data-kind="entity">${typeIcon(n)}<span class="graph-explorer-label">${n.label}</span>${c.parts}</span>`; return c.html`<article class="graph-explorer-card" data-kind="entity"> <header class="graph-explorer-card-head">${typeIcon(n)}<strong class="graph-explorer-label">${n.label}</strong>${expandBtn(c, n.label)}</header> ${c.parts} </article>`; },});// the relations layer: how a node is linked - its links, a small cycle it is inregister('global', { kind: 'node', name: 'builtin-relations', layer: 'relations', match: () => 0, // a chip marks only a small cycle sizes: (n) => (smallCycle(n) ? { chip: [22, 0], card: [0, 29] } : { card: [0, 29] }), render: (n, c) => { if (c.tier === 'chip') return c.html`${cycleMark(c, n)}`; return n.kind === 'cluster' ? c.html`<p class="graph-explorer-card-meta">${cycleMark(c, n)}${fmt(n.degree)} links${smallCycle(n) ? c.html` · in a cycle of ${n.scc}` : ''}</p>` : c.html`<p class="graph-explorer-card-meta">${n.attrs?.out} out · ${n.attrs?.in} in</p>`; },});// the labels layer: what a node is and how bigregister('global', { kind: 'node', name: 'builtin-labels', layer: 'labels', match: () => 0, sizes: { chip: [56, 0], card: [0, 29] }, render: (n, c) => { if (c.tier === 'chip') return c.html`<span class="graph-explorer-count">${n.kind === 'cluster' ? fmt(n.members) : n.typeName}</span>`; return n.kind === 'cluster' ? c.html`<p class="graph-explorer-card-meta">${n.typeName} · ${fmt(n.children)} children · ${fmt(n.members)} nodes</p>` : c.html`<p class="graph-explorer-card-meta">${n.typeName} · #${n.id.slice(1)}</p>`; },});// the health layer: a cluster's links by breaker state, an entity's freshnessregister('global', { kind: 'node', name: 'builtin-health', layer: 'health', match: () => 0, sizes: (n) => ({ chip: [52, 0], card: [0, n.kind === 'cluster' ? 41 : 29] }), key: breakerKey, render: (n, c) => { if (n.kind === 'cluster') { const h = health(n); if (c.tier === 'chip') return c.html`<span data-health="${h.tone}">${healthBar(c, h)}</span>`; return c.html`<div data-health="${h.tone}">${healthBar(c, h)}<p class="graph-explorer-card-meta">${h.bad ? `${h.open} open · ${h.half} half-open · ${h.cut} cut` : 'all links closed'} · of ${fmt(h.total)}</p></div>`; } const { f, tone } = freshness(n); if (c.tier === 'chip') return c.html`<span class="graph-explorer-pill" data-tone="${tone}">${f} h</span>`; return c.html`<p class="graph-explorer-meter-row" data-health="${tone}"><span>Freshness</span><span class="graph-explorer-meter" data-tone="${tone}"><span style="width:${Math.min(100, Math.round((f / 48) * 100))}%"></span></span><span>${f} h</span></p>`; },});register('global', { kind: 'edge', name: 'builtin-edges', rules: [ { when: [ { field: 'kind', equals: 'leaf' }, { field: 'type', equals: 'calls' }, ], style: { tone: 'muted', width: 1, arrow: 'end' }, priority: -10, }, { when: [ { field: 'kind', equals: 'leaf' }, { field: 'type', equals: 'reads' }, ], style: { tone: 'link', width: 1, arrow: 'end', dash: [3, 3] }, priority: -10, }, { when: [ { field: 'kind', equals: 'leaf' }, { field: 'type', equals: 'owns' }, ], style: { tone: 'ink', width: 1, arrow: 'end' }, priority: -10, }, { when: [{ field: 'kind', equals: 'agg' }], style: { tone: 'muted', width: 1.25, arrow: 'end' }, priority: -10 }, ], // the relation between entities, link counts between clusters; more words as the edge gets longer label: { // generic link labels come later than problem labels (the circuit breakers: an icon from 30 px) levels: { icon: 64, label: 150, full: 260 }, budget: { icon: 40, label: 20, full: 8 }, // the busiest links only; problems (breakers) get the rest match: () => 0, priority: (e) => Math.log10(1 + e.count + e.count2), render: (e, c) => { if (e.kind === 'leaf') { const ic = c.icon(EDGE_ICON[e.type] || 'link', { tone: e.style?.tone }); if (c.tier === 'icon') return c.html`<span class="graph-explorer-edge-icon" title="${e.type}">${ic}</span>`; return c.html`<span class="graph-explorer-edge-pill">${ic}<span>${e.type}</span>${c.tier === 'full' && e.bidir ? c.html`<span class="graph-explorer-muted">↔</span>` : ''}${c.tier === 'full' && e.back ? c.icon('repeat-2', { size: 'xs' }) : ''}</span>`; } const n = e.count + e.count2; if (c.tier === 'icon') return c.html`<span class="graph-explorer-edge-count" title="${fmt(n)} links">${n > 999 ? Math.round(n / 1000) + 'k' : n}</span>`; return c.html`<span class="graph-explorer-edge-pill">${c.icon('link-2')}<span>${fmt(n)} links</span>${c.tier === 'full' && e.count2 ? c.html`<span class="graph-explorer-muted">${fmt(e.count)} → · ← ${fmt(e.count2)}</span>` : ''}</span>`; }, },});// Circuit breakers (the design study's "Cycles and circuit breakers"): one breaker per link between entities; a link// between clusters bundles many and reports health by share, so one open breaker among 80 does not paint the whole// overview red - at least 30% open is critical, at least 15% not closed is degraded.const BREAKER_ICON: Record<string, string> = { open: 'zap-off', 'half-open': 'circle-dashed', cut: 'scissors', closed: 'shield-check',};const BREAKER_TONE: Record<string, GraphTone> = { open: 'danger', 'half-open': 'warn', cut: 'ink', closed: 'ok' };const verdict = (b: GraphBreaker | null): string | null => !b ? null : b.aggregate ? b.openShare >= 0.3 ? 'critical' : b.badShare >= 0.15 ? 'degraded' : null : b.state !== 'closed' ? b.state : null;const AGG = { field: 'breaker.aggregate', equals: true };register('global', { kind: 'edge', name: 'circuit-breaker', // the breakers' colours and badges are the health layer's layer: 'health', rules: [ { when: [{ field: 'breaker.state', equals: 'closed' }], style: { tone: 'muted', width: 1.25, arrow: 'end' } }, { when: [ { field: 'breaker.state', equals: 'closed' }, { field: 'traffic', equals: true }, ], priority: 2, style: { tone: 'muted', width: 1.25, arrow: 'end', flow: 1 }, }, { when: [{ field: 'breaker.state', equals: 'half-open' }], style: { tone: 'warn', width: 1.5, arrow: 'end', dash: [4, 4], pulse: true }, }, { when: [{ field: 'breaker.state', equals: 'open' }], style: { tone: 'danger', width: 2, arrow: 'end', gap: true }, }, { when: [{ field: 'breaker.state', equals: 'cut' }], style: { tone: 'ink', width: 1.5, arrow: 'none', gap: true } }, { when: [AGG], priority: 10, style: { tone: 'muted', width: 1.25, arrow: 'end', flow: 1 } }, { when: [AGG, { field: 'breaker.badShare', gte: 0.15 }], priority: 11, style: { tone: 'warn', width: 1.5, arrow: 'end', dash: [5, 3] }, }, { when: [AGG, { field: 'breaker.openShare', gte: 0.3 }], priority: 12, style: { tone: 'danger', width: 2, arrow: 'end', gap: true }, }, ], label: { levels: { icon: 30, label: 110, full: 200 }, sizes: { icon: [22, 22], label: [118, 22], full: [210, 24] }, match: (e) => (verdict(e.breaker) ? 10 : false), // ranks above the link labels: open before cut before half-open; aggregates by share priority: (e) => { const b = e.breaker as GraphBreaker; return b.aggregate ? 20 + b.openShare * 30 + b.badShare * 10 : ({ open: 30, cut: 25, 'half-open': 22 } as Record<string, number>)[b.state] || 0; }, render: (e, c) => { const b = e.breaker as GraphBreaker, v = verdict(b); if (b.aggregate) { const tone: GraphTone = v === 'critical' ? 'danger' : 'warn'; const ic = c.icon('triangle-alert', { tone, label: v ?? '' }); if (c.tier === 'icon') return c.html`<span class="graph-explorer-edge-icon" data-tone="${tone}" title="${b.open + b.half + b.cut}/${b.members} breakers not closed">${ic}</span>`; const text = v === 'critical' ? `${b.open}/${b.members} open` : `${b.open + b.half + b.cut}/${b.members} degraded`; return c.html`<span class="graph-explorer-edge-badge" data-tone="${tone}">${ic}<span>${text}</span>${c.tier === 'full' ? c.html`<span class="graph-explorer-edge-sub">${b.half} half-open · ${b.cut} cut</span>` : ''}</span>`; } const tone = BREAKER_TONE[b.state], ic = c.icon(BREAKER_ICON[b.state], { tone, label: b.state }); if (c.tier === 'icon') return c.html`<span class="graph-explorer-edge-icon" data-tone="${tone}" title="breaker ${b.state}">${ic}</span>`; if (c.tier === 'label') return c.html`<span class="graph-explorer-edge-badge" data-tone="${tone}">${ic}<span>${b.state}</span></span>`; const more = b.state === 'half-open' ? `probing ${b.probesOk}/${b.probes}` : b.state === 'open' ? `${b.trips} trips · ${b.since} min` : `cut by ${b.by}`; return c.html`<span class="graph-explorer-edge-badge" data-tone="${tone}">${ic}<span>${b.state}</span><span class="graph-explorer-edge-sub">${more}</span></span>`; }, },});// -- the details: building blocks --------------------------------------------------------// The details and a node's page are built from the library's own components where they fit - Badge, Button, Card,// Statistic, Table - and a few parts of the explorer's (rows, bars, the trail). Plugins use the same classes./** a breaker's state as a Badge */const stateBadge = (c: GraphRenderContext, state: string): SafeHtml => c.html`<span class="badge graph-explorer-state" data-variant="${state === 'closed' ? 'secondary' : 'outline'}" data-state="${state}">${c.icon(BREAKER_ICON[state] || 'circle', { size: 'xs' })}${STATE_LABEL[state] ?? state}</span>`;/** a path as a trail: every step a link, the top plain text; `last` keeps the final step (a view, not a node) */const trail = (c: GraphRenderContext, path: GraphPathStep[], last = false): SafeHtml => c.html`<nav class="graph-explorer-trail" aria-label="Path">${(last ? path : path.slice(0, -1)).map( (p, k) => c.html`${k ? c.html`<span class="graph-explorer-trail-sep" aria-hidden="true">›</span>` : ''}${ p.id === 'c0' ? c.html`<span>${p.label}</span>` : c.html`<button type="button" class="graph-explorer-link" data-graph-explorer-goto="${p.id}">${p.label}</button>` }`, )}</nav>`;/** facts as a grid of label and value; a tone colors the value */const stats = (c: GraphRenderContext, rows: [string, unknown, GraphTone?][]): SafeHtml => c.html`<dl class="graph-explorer-stats">${rows.map( ([k, v, tone]) => c.html`<div class="graph-explorer-stat"${tone ? c.html` data-tone="${tone}"` : ''}><dt>${k}</dt><dd>${v}</dd></div>`, )}</dl>`;/** a titled section of the side details, with a count */const section = (c: GraphRenderContext, title: string, body: SafeHtml, count?: unknown): SafeHtml => c.html`<section class="graph-explorer-section"><h4 class="graph-explorer-section-title"><span>${title}</span>${ count != null ? c.html`<span class="graph-explorer-section-count">${count}</span>` : '' }</h4>${body}</section>`;/** a Card of an expanded page; wide spans the page */const pageCard = ( c: GraphRenderContext, title: string, body: SafeHtml, opts: { note?: string; wide?: boolean; icon?: string } = {},): SafeHtml => c.html`<section class="card graph-explorer-page-card" data-size="sm"${opts.wide ? c.html` data-wide` : ''}><header class="card-header"><h3 class="card-title">${opts.icon ? c.icon(opts.icon, { size: 'sm' }) : ''}${title}</h3>${ opts.note ? c.html`<p class="card-description">${opts.note}</p>` : '' }</header><div class="card-content">${body}</div></section>`;/** a key figure as a Statistic */const kpi = (c: GraphRenderContext, title: string, value: unknown, note?: unknown, tone?: GraphTone): SafeHtml => c.html`<div class="statistic graph-explorer-kpi"${tone ? c.html` data-tone="${tone}"` : ''}><p class="statistic-title">${title}</p><p class="statistic-value">${value}</p>${ note != null ? c.html`<p class="statistic-description">${note}</p>` : '' }</div>`;/** a share as a bar */const shareBar = (c: GraphRenderContext, part: number, whole: number, tone: GraphTone = 'accent'): SafeHtml => { const pct = whole > 0 ? Math.max(1, Math.round((part / whole) * 100)) : 0; return c.html`<span class="graph-explorer-bar" data-tone="${tone}" role="img" aria-label="${pct} %"><span style="inline-size:${pct}%"></span></span>`;};/** one row of a list: the node (a click goes there), its numbers, a badge and the link to inspect */const row = ( c: GraphRenderContext, r: { id: string; label: string; meta?: unknown; icon?: SafeHtml; badge?: SafeHtml | ''; edge?: string | null; bar?: SafeHtml; },): SafeHtml => c.html`<li class="graph-explorer-row"><button type="button" class="graph-explorer-row-main" data-graph-explorer-goto="${r.id}">${r.icon ?? ''}<span class="graph-explorer-row-name">${r.label}</span>${ r.meta != null ? c.html`<span class="graph-explorer-row-meta">${r.meta}</span>` : '' }</button>${r.bar ?? ''}${r.badge ?? ''}${ r.edge ? c.html`<button type="button" class="graph-explorer-row-link" data-graph-explorer-edge="${r.edge}" title="Inspect the link" aria-label="Inspect the link to ${r.label}">${c.icon('link-2', { size: 'sm' })}</button>` : '' }</li>`;const rows = (c: GraphRenderContext, list: SafeHtml[], more = 0): SafeHtml => c.html`<ul class="graph-explorer-rows">${list}</ul>${more > 0 ? c.html`<p class="graph-explorer-more">${fmt(more)} more in Relations</p>` : ''}`;/** the head of the side details: the type as a tile, the kind and the path */const detailHead = (c: GraphRenderContext, tile: SafeHtml, kicker: SafeHtml, path: SafeHtml | ''): SafeHtml => c.html`<header class="graph-explorer-detail-head"><span class="graph-explorer-detail-tile">${tile}</span><div class="graph-explorer-detail-titles"><p class="graph-explorer-detail-kicker">${kicker}</p>${path}</div></header>`;/** the head of an expanded page */const pageHead = ( c: GraphRenderContext, tile: SafeHtml, title: string, kicker: SafeHtml, path: SafeHtml | '', actions: SafeHtml,): SafeHtml => c.html`<header class="graph-explorer-page-head"><span class="graph-explorer-detail-tile" data-size="xl">${tile}</span><div class="graph-explorer-page-titles">${path}<h2 class="graph-explorer-page-title">${title}</h2><p class="graph-explorer-detail-kicker">${kicker}</p></div>${actions}</header>`;/** the Expand / Back to graph button */const pageToggle = (c: GraphRenderContext): SafeHtml | string => !c.expanded && !c.expandable ? '' : c.expanded ? c.html`<button type="button" class="btn" data-variant="outline" data-size="sm" data-graph-explorer-action="${c.action('collapse')}">${c.icon('shrink')} Back to graph</button>` : c.html`<button type="button" class="btn" data-variant="ghost" data-size="sm" data-graph-explorer-action="${c.action('expand')}" title="Expand (E)">${c.icon('expand')} Expand</button>`;/** a node's relations as a table; each row's first cell is drawn by the edge label renderers (a click inspects it) */function relationsTable( c: GraphRenderContext, item: GraphNode, d: GraphDetails, tier: 'icon' | 'label' | 'full' = 'label', limit = 60,): SafeHtml { const rows = item.kind === 'cluster' ? (d.relations ?? []).slice(0, limit).map((r) => ({ id: r.id, label: r.label, where: '', out: r.out, in: r.in, link: item.id + '>' + r.id, edge: { kind: 'agg', type: 'calls', count: r.out || 0, count2: r.in || 0, bidir: !!(r.out && r.in), back: !!r.back, breaker: r.breaker || null, style: { tone: 'muted' }, } as Partial<GraphEdge>, })) : (d.out ?? []).slice(0, limit).map((r) => ({ id: r.id, label: r.label, where: r.same ? '' : r.system, out: 1, in: 0, link: r.same ? item.id + '>' + r.id : null, edge: { kind: 'leaf', type: r.edge, count: 1, count2: 0, bidir: false, back: false, breaker: null, style: { tone: r.edge === 'reads' ? 'link' : r.edge === 'owns' ? 'ink' : 'muted' }, } as Partial<GraphEdge>, })); if (!rows.length) return c.html`<p class="graph-explorer-muted graph-explorer-pad">No relations.</p>`; return c.html`<div class="table-container"><table class="table graph-explorer-rel-table"><thead><tr class="table-row"> <th class="table-head" scope="col">Link</th><th class="table-head" scope="col">${item.kind === 'cluster' ? 'Cluster' : 'Target'}</th> <th class="table-head" scope="col" style="text-align:right">Out</th><th class="table-head" scope="col" style="text-align:right">In</th></tr></thead> <tbody>${rows.map( (r) => c.html`<tr class="table-row" data-graph-explorer-goto="${r.id}"> <td class="table-cell graph-explorer-rel-edge">${ r.link ? c.html`<button type="button" class="graph-explorer-edge-btn" data-graph-explorer-edge="${r.link}" title="Inspect the link">${c.edgeLabel(r.edge, tier)}</button>` : c.edgeLabel(r.edge, tier) }</td> <td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${r.id}">${r.label}</button>${r.where ? c.html`<span class="graph-explorer-muted"> · ${r.where}</span>` : ''}</td> <td class="table-cell" style="text-align:right">${r.out ? fmt(r.out) : ''}</td><td class="table-cell" style="text-align:right">${r.in ? fmt(r.in) : ''}</td></tr>`, )}</tbody></table></div>`;}/** a link's single links as a table (the relations outlet while a link is selected) */function linksTable(c: GraphRenderContext, d: GraphEdgeDetails): SafeHtml { if (!d.samples.length) return c.html`<p class="graph-explorer-muted graph-explorer-pad">No single links.</p>`; return c.html`<div class="table-container"><table class="table graph-explorer-rel-table"><thead><tr class="table-row"> <th class="table-head" scope="col">From</th><th class="table-head" scope="col">Relation</th><th class="table-head" scope="col">To</th><th class="table-head" scope="col">Systems</th></tr></thead> <tbody>${d.samples.map( ( x, ) => c.html`<tr class="table-row"><td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${x.from.id}">${x.from.label}</button></td> <td class="table-cell"><span class="graph-explorer-rel-type">${c.icon(EDGE_ICON[x.type] || 'link', { size: 'xs' })}${x.type}${x.back ? c.html` <span class="graph-explorer-muted">back</span>` : ''}</span></td> <td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${x.to.id}">${x.to.label}</button></td> <td class="table-cell graph-explorer-muted">${x.from.system === x.to.system ? x.from.system : `${x.from.system} → ${x.to.system}`}</td></tr>`, )}</tbody></table></div>`;}/** a node's children as a table with their share (an expanded page: the drill-down goes on from here) */function childrenTable(c: GraphRenderContext, kids: GraphNode[], limit = 40): SafeHtml { const cluster = kids[0]?.kind === 'cluster'; const value = (k: GraphNode): number => (cluster ? k.members : k.degree); const list = [...kids].sort((a, b) => value(b) - value(a)).slice(0, limit); const max = Math.max(1, ...list.map(value)); return c.html`<div class="table-container"><table class="table graph-explorer-rel-table"><thead><tr class="table-row"> <th class="table-head" scope="col">Name</th><th class="table-head" scope="col">Type</th> <th class="table-head" scope="col" style="text-align:right">${cluster ? 'Nodes' : 'Links'}</th><th class="table-head graph-explorer-bar-col" scope="col"><span class="sr-only">Share</span></th></tr></thead> <tbody>${list.map( ( k, ) => c.html`<tr class="table-row"><td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${k.id}">${typeIcon(k, { size: 'xs' })} ${k.label}</button></td> <td class="table-cell graph-explorer-muted">${k.typeName}</td><td class="table-cell" style="text-align:right">${fmt(value(k))}</td> <td class="table-cell graph-explorer-bar-col">${shareBar(c, value(k), max)}</td></tr>`, )}</tbody></table></div>${kids.length > limit ? c.html`<p class="graph-explorer-more">the ${limit} largest of ${fmt(kids.length)}</p>` : ''}`;}// -- the rows grids of a page: every entity below a cluster, every single link of a link -------------------------// A Data Grid (Big Data) takes the worker's rows; dataview aggregations of the same source - its filter scan shared -// show how the rows the filters match split up, and follow every filter change. The Data Grid is a soft dependency:// without df$.shadcn.dataGrid (an app bundle that left it out) a page keeps the sample tables./** a column of a rows grid */interface GridColumn { field: string; label: string; width: string; number?: boolean; filter?: 'text' | 'number' | 'select';}const ENTITY_COLUMNS: GridColumn[] = [ { field: 'label', label: 'Name', width: '14rem', filter: 'text' }, { field: 'type', label: 'Type', width: '8rem', filter: 'select' }, { field: 'system', label: 'System', width: '11rem', filter: 'text' }, { field: 'owner', label: 'Owner', width: '7rem', filter: 'select' }, { field: 'out', label: 'Out', width: '5rem', number: true, filter: 'number' }, { field: 'in', label: 'In', width: '5rem', number: true, filter: 'number' }, { field: 'rows', label: 'Rows (k)', width: '6.5rem', number: true, filter: 'number' }, { field: 'freshness', label: 'Age (h)', width: '6rem', number: true, filter: 'number' },];const LINK_COLUMNS: GridColumn[] = [ { field: 'fromLabel', label: 'From', width: '13rem', filter: 'text' }, { field: 'relation', label: 'Relation', width: '7rem', filter: 'select' }, { field: 'toLabel', label: 'To', width: '13rem', filter: 'text' }, { field: 'direction', label: 'Way', width: '6.5rem', filter: 'select' }, { field: 'fromSystem', label: 'From system', width: '11rem', filter: 'text' }, { field: 'toSystem', label: 'To system', width: '11rem', filter: 'text' },];/** the shares beside a grid: one aggregation each, over the rows its filters match */interface GridShare { title: string; field: string; limit?: number;}const ENTITY_SHARES: GridShare[] = [ { title: 'By type', field: 'type' }, { title: 'By owner', field: 'owner' }, { title: 'Largest systems', field: 'system', limit: 5 },];const LINK_SHARES: GridShare[] = [ { title: 'By relation', field: 'relation' }, { title: 'Way', field: 'direction' }, { title: 'Busiest senders', field: 'fromSystem', limit: 5 },];interface GridSource { rows: readonly GraphRow[]; aggregate( query: { filters?: unknown[] }, spec: Record<string, unknown>, ): { rows: Record<string, unknown>[]; matchedRows: number; totalRows: number };}interface GridApi { setSource(target: HTMLElement, rows: GraphRow[], options?: Record<string, unknown>): void; source(target: HTMLElement): GridSource | undefined;}/** a custom grid cell: the name as a button that opens its node's page */const gotoCell = (idField: string, labelField: string) => (el: HTMLElement, record: GraphRow): void => { const b = document.createElement('button'); b.type = 'button'; b.className = 'graph-explorer-link'; b.setAttribute('data-graph-explorer-goto', String(record[idField])); b.textContent = String(record[labelField]); el.append(b); };/** * Every row behind a node or a link as a Data Grid - filter, sort, open a row's page - with the shares of the rows its * filters match (dataview aggregations of the grid's own source). An island: the grid keeps its records, its scroll and * its query while the page renders again. */function rowsView( c: GraphRenderContext, id: string, kind: 'entities' | 'links', label: string, fallback: () => SafeHtml,): SafeHtml { const grids = df$.dataGrid as GridApi | undefined; if (!grids?.source) return fallback(); const cols = kind === 'entities' ? ENTITY_COLUMNS : LINK_COLUMNS; const shares = kind === 'entities' ? ENTITY_SHARES : LINK_SHARES; const noun = kind === 'entities' ? 'entities' : 'links'; const markup = c.html`<div class="graph-explorer-records"> <p class="graph-explorer-records-count" aria-live="polite">Loading every row...</p> <div class="graph-explorer-records-shares"></div> <div class="data-grid graph-explorer-grid" aria-label="${label}" data-persist="none" data-empty-text="No row matches the filters." style="height: 24rem"> <div class="data-grid-viewport"><div class="data-grid-head"><div class="data-grid-row">${cols.map( (col) => c.html`<div class="data-grid-header" data-field="${col.field}" data-width="${col.width}"${col.number ? c.html` data-type="number"` : ''}${col.filter ? c.html` data-filter="${col.filter}"` : ''}>${col.label}</div>`, )}</div></div><div class="data-grid-body"></div></div> </div></div>`; return c.island('rows:' + id, markup, (el) => { let alive = true, off: (() => void) | null = null; void c.fetchRows(id).then((data) => { if (!alive) return; const grid = dfDollar(el).find('.data-grid').get(0) as HTMLElement & { store?: { value?: { config?: { filters?: unknown[] } }; subscribe(fn: () => void): () => void }; }; const count = dfDollar(el).find('.graph-explorer-records-count').get(0) as HTMLElement; const strip = dfDollar(el).find('.graph-explorer-records-shares').get(0) as HTMLElement; grids.setSource(grid, data.rows, { cells: kind === 'entities' ? { label: gotoCell('id', 'label') } : { fromLabel: gotoCell('from', 'fromLabel'), toLabel: gotoCell('to', 'toLabel') }, }); const source = grids.source(grid); if (!source) return; let seen = ''; const paint = (): void => { const filters = grid.store?.value?.config?.filters ?? []; const key = JSON.stringify(filters); if (key === seen) return; seen = key; const parts = shares.map((sh) => ({ sh, r: source.aggregate( { filters }, { groupBy: sh.field, sort: { by: 'count', direction: 'desc' }, ...(sh.limit ? { limit: sh.limit, other: 'the rest' } : {}), }, ), })); const matched = parts[0]?.r.matchedRows ?? source.rows.length; morph( count, c.html`<strong>${fmt(matched)}</strong> of ${fmt(source.rows.length)} ${noun}${ filters.length ? c.html` <span class="graph-explorer-muted">match the filters</span>` : '' }`, ); morph( strip, c.html`${parts.map( ({ sh, r }) => c.html`<section class="graph-explorer-records-share"><h4 class="graph-explorer-section-title"><span>${sh.title}</span></h4><ul class="graph-explorer-shares">${r.rows.map( (g) => c.html`<li><span class="graph-explorer-share-name">${String(g[sh.field] ?? '-')}</span><span class="graph-explorer-share-value">${fmt(Number(g.count))}</span>${shareBar(c, Number(g.count), matched)}</li>`, )}</ul></section>`, )}`, ); }; paint(); off = grid.store?.subscribe(paint) ?? null; }); return () => { alive = false; off?.(); }; });}/** how a list of nodes splits by one key: the share of each */function composition( c: GraphRenderContext, kids: GraphNode[], keyOf: (k: GraphNode) => string, weight: (k: GraphNode) => number,): SafeHtml { const by = new Map<string, number>(); let total = 0; for (const k of kids) { const w = weight(k); by.set(keyOf(k), (by.get(keyOf(k)) || 0) + w); total += w; } const list = [...by.entries()].sort((a, b) => b[1] - a[1]); return c.html`<ul class="graph-explorer-shares">${list.map( ([k, n]) => c.html`<li><span class="graph-explorer-share-name">${k}</span><span class="graph-explorer-share-value">${fmt(n)} · ${Math.round((n / Math.max(1, total)) * 100)} %</span>${shareBar(c, n, total)}</li>`, )}</ul>`;}/** a breaker's facts at a glance: the health bar and its legend */const healthBlock = (c: GraphRenderContext, h: ReturnType<typeof health>): SafeHtml => c.html`${healthBar(c, h)}<ul class="graph-explorer-legend"><li data-tone="danger">${fmt(h.open)} open</li><li data-tone="warn">${fmt(h.half)} half-open</li><li data-tone="ink">${fmt(h.cut)} cut</li><li data-tone="ok">${fmt(h.closed)} closed</li></ul>`;/** the actions on a node */const nodeActions = (c: GraphRenderContext, item: GraphNode): SafeHtml => c.html`<div class="graph-explorer-detail-actions">${ item.kind === 'cluster' ? c.html`<button type="button" class="btn" data-variant="default" data-size="sm" data-graph-explorer-action="${c.action('enter')}" title="Zoom into ${item.label} and show its ${fmt(item.children)} children (Enter)">${c.icon('scan')} Zoom in</button>` : '' }${ c.expanded ? '' : c.html`<button type="button" class="btn" data-variant="outline" data-size="sm" data-graph-explorer-action="${c.action('bring')}" title="Gather the busiest neighbours of ${item.label} around it and fly to the one you pick (B)">${c.icon('crosshair')} Jump to a neighbour</button>` }${pageToggle(c)}</div>`;register('global', { kind: 'detail', name: 'builtin-detail', match: () => 0, title: (item) => item.label, // ctx.expanded: the details outlet's Panel is maximized - the node's full page, every list a way further down render: (item, c) => { const d = c.request('details:' + item.id, () => c.fetchDetails(item.id)); const cluster = item.kind === 'cluster'; const kicker = c.html`${item.typeName}${cluster ? c.html` · ${fmt(item.members)} nodes` : ''}${ item.scc > 1 ? c.html` · <span class="graph-explorer-cycle-note">${c.icon('repeat-2', { size: 'xs' })} cycle of ${item.scc}</span>` : '' }`; const tile = typeIcon(item, { size: c.expanded ? 'xl' : 'lg' }); if (!d) return c.html`${detailHead(c, tile, kicker, '')}<p class="graph-explorer-muted">Loading details...</p>`; const h = health(item); const relations = d.relations ?? []; if (c.expanded) { const kids = c.request('view:' + item.id, () => (cluster ? c.fetchView(item.id) : null)); const head = pageHead(c, tile, item.label, kicker, trail(c, d.path), nodeActions(c, item)); if (cluster) { const children: GraphNode[] = kids ? kids.children : []; const leaves = children.length > 0 && children[0].kind === 'entity'; const worst = relations.filter((r) => r.breaker && r.breaker.state !== 'closed'); return c.html`<div class="graph-explorer-page">${head} <section class="graph-explorer-kpis"> ${kpi(c, 'Nodes below', fmt(d.members ?? 0), `${fmt(d.children ?? 0)} children`)} ${kpi(c, 'Relations', fmt(relations.length), 'at this level')} ${kpi(c, 'Links not closed', fmt(h.bad), `of ${fmt(h.total)} bundled`, h.bad ? (h.tone as GraphTone) : 'ok')} ${kpi(c, 'Cycle', item.scc > 1 ? `${item.scc} nodes` : 'none', item.scc > 1 ? 'reach each other' : 'acyclic here')} </section> <div class="graph-explorer-page-grid"> ${pageCard( c, leaves ? 'Entity types' : 'System kinds', kids ? composition( c, children, (k) => k.typeName, (k) => (leaves ? 1 : k.members), ) : c.html`<p class="graph-explorer-muted">Loading...</p>`, { icon: 'layers', note: leaves ? 'entities by type' : 'nodes below each kind' }, )} ${ leaves && kids ? pageCard( c, 'Owners', composition( c, children, (k) => k.attrs?.owner ?? '-', () => 1, ), { icon: 'users', note: 'who owns the entities' }, ) : pageCard( c, 'Link health', c.html`${healthBlock(c, h)}${ worst.length ? rows( c, worst.slice(0, 6).map((r) => row(c, { id: r.id, label: r.label, meta: `${fmt(r.out + r.in)} links`, badge: stateBadge(c, r.breaker!.state), edge: item.id + '>' + r.id, }), ), ) : c.html`<p class="graph-explorer-muted">Every breaker is closed.</p>` }`, { icon: 'shield-check', note: 'the breakers its links bundle' }, ) } ${ leaves ? '' : pageCard( c, 'Children', kids ? childrenTable(c, children) : c.html`<p class="graph-explorer-muted">Loading...</p>`, { wide: true, icon: 'boxes', note: 'pick one for its page' }, ) } ${pageCard( c, 'Every entity below', rowsView(c, item.id, 'entities', `Every entity below ${item.label}`, () => kids ? childrenTable(c, children) : c.html`<p class="graph-explorer-muted">Loading...</p>`, ), { wide: true, icon: 'database', note: 'filter, sort, open one - the shares follow the filters' }, )} ${pageCard(c, 'Relations at this level', relationsTable(c, item, d, 'full'), { wide: true, icon: 'link-2', note: 'a name opens its page, a link its traffic', })} </div></div>`; } const a = item.attrs; const stale = (a?.freshness ?? 0) > 24; return c.html`<div class="graph-explorer-page">${head} <section class="graph-explorer-kpis"> ${kpi(c, 'Outgoing', fmt(d.outDeg ?? 0), 'links it starts')} ${kpi(c, 'Incoming', fmt(d.inDeg ?? 0), 'links to it')} ${kpi(c, 'Rows', fmt(a?.rows ?? 0) + 'k')} ${kpi(c, 'Freshness', `${a?.freshness ?? 0} h`, stale ? 'stale' : 'fresh', stale ? 'warn' : 'ok')} </section> <div class="graph-explorer-page-grid"> ${pageCard( c, 'Attributes', stats(c, [ ['Type', item.typeName], ['Owner', a?.owner ?? '-'], ['System', d.path.at(-2)?.label ?? '-'], ['Domain', d.path.at(-3)?.label ?? '-'], ['Id', item.id], ]), { icon: 'info' }, )} ${pageCard( c, 'Incoming', (d.in ?? []).length ? rows( c, (d.in ?? []).map((r) => row(c, { id: r.id, label: r.label, icon: c.icon(EDGE_ICON[r.edge] || 'link', { size: 'xs' }), meta: `${r.edge} · ${r.same ? 'this system' : r.system}`, edge: r.same ? r.id + '>' + item.id : null, }), ), ) : c.html`<p class="graph-explorer-muted">Nothing links here.</p>`, { icon: 'arrow-left', note: `${fmt(d.inDeg ?? 0)} in all` }, )} ${pageCard(c, 'Outgoing', relationsTable(c, item, d, 'full'), { wide: true, icon: 'arrow-right', note: 'a name opens its page, a link its details' })} </div></div>`; } // the side: who it is, what it does, where it leads const head = detailHead(c, tile, kicker, trail(c, d.path)); if (cluster) return c.html`${head}${nodeActions(c, item)} ${stats(c, [ ['Nodes below', fmt(d.members ?? 0)], ['Children', fmt(d.children ?? 0)], ['Relations', fmt(relations.length)], ['Not closed', h.bad ? `${fmt(h.bad)} of ${fmt(h.total)}` : 'none', h.bad ? (h.tone as GraphTone) : 'ok'], ])} ${item.breakers && h.total > 1 ? section(c, 'Link health', healthBlock(c, h)) : ''} ${section( c, 'Relations', relations.length ? rows( c, relations.slice(0, 12).map((r) => row(c, { id: r.id, label: r.label, meta: c.html`${r.out ? c.html`<span>→ ${fmt(r.out)}</span>` : ''}${r.in ? c.html`<span>← ${fmt(r.in)}</span>` : ''}`, badge: r.breaker && r.breaker.state !== 'closed' ? stateBadge(c, r.breaker.state) : '', edge: item.id + '>' + r.id, }), ), relations.length - 12, ) : c.html`<p class="graph-explorer-muted">No relations at this level.</p>`, relations.length, )}`; const a = item.attrs; const stale = (a?.freshness ?? 0) > 24; const linkRows = (list: GraphLink[], incoming: boolean): SafeHtml => rows( c, list.slice(0, 8).map((r) => row(c, { id: r.id, label: r.label, icon: c.icon(EDGE_ICON[r.edge] || 'link', { size: 'xs' }), meta: `${r.edge} · ${r.same ? 'here' : r.system}`, edge: r.same ? (incoming ? r.id + '>' + item.id : item.id + '>' + r.id) : null, }), ), list.length - 8, ); return c.html`${head}${nodeActions(c, item)} ${stats(c, [ ['Owner', a?.owner ?? '-'], ['Rows', fmt(a?.rows ?? 0) + 'k'], ['Freshness', `${a?.freshness ?? 0} h`, stale ? 'warn' : 'ok'], ['Links', `${fmt(d.outDeg ?? 0)} out · ${fmt(d.inDeg ?? 0)} in`], ])} ${section(c, 'Outgoing', (d.out ?? []).length ? linkRows(d.out ?? [], false) : c.html`<p class="graph-explorer-muted">None.</p>`, d.outDeg)} ${section(c, 'Incoming', (d.in ?? []).length ? linkRows(d.in ?? [], true) : c.html`<p class="graph-explorer-muted">None.</p>`, d.inDeg)}`; },});/** one end of a link as a button that opens its node */const endButton = (c: GraphRenderContext, n: GraphNode): SafeHtml => c.html`<button type="button" class="graph-explorer-end" data-graph-explorer-goto="${n.id}">${typeIcon(n, { size: 'sm' })}<span class="graph-explorer-end-name">${n.label}</span><span class="graph-explorer-end-type">${n.typeName}</span></button>`;// the details of a selected link: both directions, the relations it carries, the breakers it bundles, who sends and// who receives, and single links - the drill-down goes on into any of themregister('global', { kind: 'edge', name: 'builtin-edge-detail', detail: { match: () => 0, title: (item) => item.source.label + ' → ' + item.target.label, render: (item, c) => { const d = c.request('edge:' + item.id, () => c.fetchEdge(item.id)); const e = item.edge; const kicker = c.html`${e.kind === 'agg' ? 'Links between clusters' : 'A link'} · ${e.kind === 'agg' ? fmt(e.count + e.count2) + ' links' : e.type}${e.bidir ? ' · both ways' : ''}`; const tile = c.icon(e.breaker && e.breaker.state !== 'closed' ? BREAKER_ICON[e.breaker.state] : 'link-2', { size: c.expanded ? 'xl' : 'lg', }); const ends = c.html`<div class="graph-explorer-ends">${endButton(c, item.source)}<span class="graph-explorer-ends-arrow" aria-label="${e.bidir ? 'both ways' : 'to'}">${c.icon(e.bidir ? 'arrow-left-right' : 'arrow-right', { size: 'sm' })}</span>${endButton(c, item.target)}</div>`; const actions = c.html`<div class="graph-explorer-detail-actions">${pageToggle(c)}</div>`; if (!d) return c.html`${detailHead(c, tile, kicker, '')}${ends}<p class="graph-explorer-muted">Loading the links...</p>`; const total = d.forward + d.backward; const types = Object.entries(d.byType).filter(([, n]) => n > 0); const byType = c.html`<ul class="graph-explorer-shares">${types.map( ([t, n]) => c.html`<li><span class="graph-explorer-share-name">${c.icon(EDGE_ICON[t] || 'link', { size: 'xs' })} ${t}</span><span class="graph-explorer-share-value">${fmt(n)}</span>${shareBar(c, n, total)}</li>`, )}</ul>`; const bad = d.pairs.filter((p) => p.state !== 'closed'); const pairRow = (p: GraphEdgePair): SafeHtml => row(c, { id: p.from.id, label: p.from.label + ' → ' + p.to.label, meta: `${fmt(p.count)} · ${p.trips} trips`, badge: stateBadge(c, p.state), }); const share = (list: GraphEdgeShare[]): SafeHtml => { const max = Math.max(1, ...list.map((x) => x.count)); return rows( c, list .slice(0, 6) .map((x) => row(c, { id: x.id, label: x.label, meta: fmt(x.count), bar: shareBar(c, x.count, max) })), ); }; const dirs = `${item.source.label} → ${item.target.label}`; if (c.expanded) return c.html`<div class="graph-explorer-page">${pageHead(c, tile, dirs, kicker, trail(c, d.path, true), actions)}${ends} <section class="graph-explorer-kpis"> ${kpi(c, 'Forward', fmt(d.forward), `${item.source.label} → ${item.target.label}`)} ${kpi(c, 'Back', fmt(d.backward), `${item.target.label} → ${item.source.label}`)} ${e.kind === 'agg' ? kpi(c, 'Breakers', fmt(d.pairs.length), `${fmt(bad.length)} not closed`, bad.length ? 'warn' : 'ok') : kpi(c, 'Relation', e.type)} ${e.kind === 'agg' ? kpi(c, 'Trips', fmt(d.pairs.reduce((n, p) => n + p.trips, 0)), 'since the last deploy') : ''} </section> <div class="graph-explorer-page-grid"> ${pageCard(c, 'By relation', byType, { icon: 'workflow', note: 'both directions' })} ${d.sources.length ? pageCard(c, 'Top senders', share(d.sources), { icon: 'send', note: `in ${item.source.label}` }) : ''} ${d.targets.length ? pageCard(c, 'Top receivers', share(d.targets), { icon: 'target', note: `in ${item.target.label}` }) : ''} ${ d.pairs.length ? pageCard( c, 'Circuit breakers', c.html`<div class="table-container"><table class="table graph-explorer-rel-table"><thead><tr class="table-row"><th class="table-head" scope="col">Caller</th><th class="table-head" scope="col">Callee</th><th class="table-head" scope="col" style="text-align:right">Links</th><th class="table-head" scope="col">State</th><th class="table-head" scope="col" style="text-align:right">Trips</th><th class="table-head" scope="col">Changed by</th></tr></thead><tbody>${d.pairs.map( (p) => c.html`<tr class="table-row"><td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${p.from.id}">${p.from.label}</button></td><td class="table-cell"><button type="button" class="graph-explorer-link" data-graph-explorer-goto="${p.to.id}">${p.to.label}</button></td><td class="table-cell" style="text-align:right">${fmt(p.count)}</td><td class="table-cell">${stateBadge(c, p.state)}</td><td class="table-cell" style="text-align:right">${p.trips}</td><td class="table-cell graph-explorer-muted">${p.by} · ${p.since} min ago</td></tr>`, )}</tbody></table></div>`, { wide: true, icon: 'shield-check', note: 'the system pairs this link bundles, the worst first' }, ) : '' } ${pageCard( c, 'Every single link', rowsView(c, item.id, 'links', `Every link from ${item.source.label} to ${item.target.label}`, () => linksTable(c, d), ), { wide: true, icon: 'git-branch', note: 'both ways - filter, sort, open an end - the shares follow the filters', }, )} </div></div>`; return c.html`${detailHead(c, tile, kicker, trail(c, d.path, true))}${ends}${actions} ${stats(c, [ ['Forward', fmt(d.forward)], ['Back', fmt(d.backward)], ['Breaker', e.breaker ? stateBadge(c, e.breaker.state) : 'none'], [ e.kind === 'agg' ? 'Pairs' : 'Relation', e.kind === 'agg' ? `${fmt(d.pairs.length)} · ${fmt(bad.length)} not closed` : e.type, bad.length ? 'warn' : undefined, ], ])} ${section(c, 'By relation', byType)} ${bad.length ? section(c, 'Breakers not closed', rows(c, bad.slice(0, 5).map(pairRow), bad.length - 5), bad.length) : ''} ${d.sources.length > 1 ? section(c, 'Top senders', share(d.sources)) : ''} ${d.targets.length > 1 ? section(c, 'Top receivers', share(d.targets)) : ''}`; }, },});// -- outlets ---------------------------------------------------------------------------// An outlet is a container the page places anywhere - typically a Panel's body - that names the explorer// (data-graph-explorer-for) and what it shows (data-graph-explorer-outlet). Its content is the runtime's// (data-ce-chrome): the HTML Preview Editor never writes it back into a source.const FOR = 'data-graph-explorer-for';const OUTLETS = ['breadcrumb', 'search', 'outline', 'details', 'relations', 'layers', 'updates'];// -- layers --------------------------------------------------------------------------------// What the graph shows is layers of information, each a filter of its own: the base layers nodes (the chips and cards)// and relations (the lines) are shown while data-layers is absent; with none the clusters' circles and dots remain./** the built-in layers, in the switch's order (the plugins' come after them) */const BUILTIN_LAYERS = [ { name: 'nodes', label: 'Nodes', icon: 'circle', base: true }, { name: 'relations', label: 'Relations', icon: 'network', base: true }, { name: 'labels', label: 'Labels', icon: 'tag', base: false }, { name: 'health', label: 'Health', icon: 'activity', base: false },];const BASE_LAYERS = ['nodes', 'relations'];/** a layers value (a list or space separated words) as a list of names, each once */function layerList(value: unknown): string[] { const words = Array.isArray(value) ? value.map(String) : String(value ?? '').split(/\s+/); return [...new Set(words.map((w) => w.trim()).filter((w) => /^[a-z0-9-]+$/.test(w)))];}/** the layers an element shows: data-layers, or the base layers while it is absent */function layersOf(el: HTMLElement): string[] { return el.hasAttribute('data-layers') ? layerList(el.getAttribute('data-layers')) : [...BASE_LAYERS];}const sameLayers = (a: string[], b: string[]): boolean => a.length === b.length && [...a].sort().join(' ') === [...b].sort().join(' ');/** the elements bound to an explorer that match `sel`: the bound elements themselves and their descendants */function boundOf(el: HTMLElement, sel: string): HTMLElement[] { if (!el.id) return []; const out: HTMLElement[] = []; for (const bound of dfDollar(`[${FOR}="${CSS.escape(el.id)}"]`).toArray() as HTMLElement[]) { if (bound.matches(sel)) out.push(bound); out.push(...(dfDollar(bound).find(sel).toArray() as HTMLElement[])); } return [...new Set(out)];}/** the chrome element of an outlet (made once; an outlet emptied by a morph of the page gets it again) */function outletChrome(outlet: HTMLElement, kind: string, make: () => HTMLElement): HTMLElement { let chrome = dfDollar(outlet).children(`.graph-explorer-${kind}[data-ce-chrome]`).get(0); if (!chrome) { chrome = make(); dfDollar(outlet).append(chrome); } return chrome;}const resolveTarget = (target: string | HTMLElement): HTMLElement | undefined => typeof target === 'string' ? dfDollar(target).get(0) : target;// -- the State API -------------------------------------------------------------------------/** * The markup of a state, on the live element and on render()'s detached copy: the focus, the selection, the layers * and the expanded details as data-* attributes. Everything drawn (canvas, slots, labels, outlets) is the runtime's. */function applyMarkup(el: HTMLElement, name: string, config: GraphExplorerConfig): void { // the base layers are no attribute; any other set is written (an empty one too: only the circles) if (config.layers !== undefined) { const want = layerList(config.layers); if (!sameLayers(want, layersOf(el))) dfDollar(el).attr('data-layers', sameLayers(want, BASE_LAYERS) ? null : want.join(' ')); } if (config.compact !== undefined && el.hasAttribute('data-compact') !== !!config.compact) dfDollar(el).attr('data-compact', config.compact ? '' : null); const selected = name === 'default' ? [] : keyList(config.selected ?? ''); if (selected.join(' ') !== keyList(el.getAttribute('data-selected') ?? '').join(' ')) dfDollar(el).attr('data-selected', selected.length ? selected.join(' ') : null); // a link and nodes are never selected together: the nodes win const edge = name === 'default' || selected.length ? null : edgeKey(config.edge); if ((el.getAttribute('data-selected-edge') || null) !== edge) dfDollar(el).attr('data-selected-edge', edge); const focus = name === 'focused' ? config.focus || null : null; if ((el.getAttribute('data-focus') || null) !== focus) dfDollar(el).attr('data-focus', focus); const expanded = (selected.length > 0 || !!edge) && !!config.expanded; if (el.hasAttribute('data-expanded') !== expanded) dfDollar(el).attr('data-expanded', expanded ? '' : null);}/** a link's id ('<source>><target>') when the value is one, else null */function edgeKey(value: unknown): string | null { const v = typeof value === 'string' ? value.trim() : ''; return /^[cn]\d+>[cn]\d+$/.test(v) ? v : null;}/** the state the markup amounts to */function stateNameOf(focus: string | null, selected: string[], edge: string | null): string { return focus ? 'focused' : selected.length || edge ? 'selected' : 'default';}/** what the element's attributes say */function configOf( el: HTMLElement,): Required<Omit<GraphExplorerConfig, 'selected' | 'layers'>> & { selected: string[]; layers: string[] } { return { focus: el.getAttribute('data-focus') || null, selected: keyList(el.getAttribute('data-selected') ?? ''), layers: layersOf(el), compact: el.hasAttribute('data-compact'), expanded: el.hasAttribute('data-expanded'), edge: edgeKey(el.getAttribute('data-selected-edge')), };}/** the state's DOM work: the markup, then the view follows it (a new focus flies there) */function triggerStateChange( el: HTMLElement, name: string, config: GraphExplorerConfig, incoming: GraphExplorerConfig,): Promise<void> { const inst = el._graphExplorer; const asked = name; // a config that names a focus or a selection asks for the state that shows it (the State tab edits one field of // the current state: a selection entered at the top lands in 'selected') if (incoming.focus && name !== 'focused') name = 'focused'; else if (name === 'default' && (keyList(incoming.selected ?? '').length || edgeKey(incoming.edge))) name = 'selected'; // nodes and a link exclude each other: the one this setState names wins (a merged config may hold both) if (edgeKey(incoming.edge) && !keyList(incoming.selected ?? '').length) config = { ...config, selected: [] }; else if (keyList(incoming.selected ?? '').length) config = { ...config, edge: null }; let landed = name; const selected = keyList(config.selected ?? ''); const edge = selected.length ? null : edgeKey(config.edge); if (name === 'focused' && !config.focus) landed = selected.length || edge ? 'selected' : 'default'; if (name === 'selected' && !selected.length && !edge) landed = 'default'; applyMarkup(el, landed, config); if (landed !== asked) el.dataset.stateName = landed; return inst ? inst.sync() : Promise.resolve();}/** Registry-level API; pass the element explicitly. Unknown names throw. */export const graphExplorerApi = componentState({ component: 'graph-explorer', states: graphExplorerStates, mergeConfig: true, apply: (el, state, _previous, incoming) => triggerStateChange( el as HTMLElement, state.name, state.config as GraphExplorerConfig, incoming as GraphExplorerConfig, ), // the state the attributes show now - a click, a zoom into a cluster or the layer switch change them without setState read: (el) => { const config = configOf(el as HTMLElement); return { name: stateNameOf(config.focus, config.selected, config.edge), config: config as unknown as Record<string, unknown>, }; }, markup: (el, state) => applyMarkup(el as HTMLElement, state.name, state.config as GraphExplorerConfig),});df$.graphExplorerApi = graphExplorerApi;df$.graphExplorerStates = graphExplorerStates;// -- updates: what changed, newest first ------------------------------------------------------// A change marks its node (a glowing dot at its top right) or its link (the line flashes in its layer's colour and// fades) for CHANGE_MS, and joins the Updates outlet: a Virtual List of every update the shown layers own, newest// first, each row a button that selects the node or the link. The list keeps the last UPDATE_LIMIT.const CHANGE_MS = 8000;const TIME_FMT = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });const UPDATE_LIMIT = 10_000;const UPDATE_ICONS: Record<string, string> = { deploy: 'send', breaker: 'zap', traffic: 'activity', count: 'hash',};// the built-in row: the layer's dot (or the tone's), the target, what happened, whenregister('global', { kind: 'update', name: 'builtin-update', update: { match: () => 0, render: (u, c) => c.html`<span class="indicator-dot" data-size="sm" ${u.tone ? c.html`data-tone="${u.tone}"` : ''} aria-hidden="true"></span>${c.icon(UPDATE_ICONS[u.type] ?? 'circle', { size: 'sm' })}<span class="graph-explorer-update-label">${u.label}</span><span class="graph-explorer-update-text">${u.text}</span><time class="graph-explorer-update-time" datetime="${new Date(u.time).toISOString()}">${c.time(u.time)}</time>`, },});// -- the explorer ---------------------------------------------------------------------------const HELP_ROWS: [string, string][] = [ ['←↑→↓', 'move the focus to the nearest node in that direction'], ['Shift + arrows, drag', 'pan (panning into a neighbour cluster moves you there)'], ['+ - / wheel, pinch', 'zoom (zooming into a cluster enters it)'], ['Enter / double click', 'zoom into a cluster: its children show; expand an entity'], ['E', 'expand the focused node: its full page in the details outlet (Esc returns)'], ['Space / click', 'select and show details (Shift adds)'], ['click a link', 'select the link: its traffic, breakers and single links (E expands, Esc lets it go)'], ['] [', 'focus the next / previous neighbour'], ['B / press and hold', 'jump to a neighbour: the busiest ones gather around the node, pick one to fly there'], ['1 - 9', 'show or hide a layer (Nodes, Relations, Labels, Health, ...)'], ['Esc', 'close the topmost overlay, then move out'], ['Backspace', 'move out'], ['0', 'fit the view'], ['/', 'search'], ['?', 'this map'],];class Explorer { [key: string]: any; constructor(el: HTMLElement) { this.el = el; this.registry = { node: [], edge: [], detail: [], update: [] }; this.n = clamp(parseInt(el.dataset.nodes || '1000000', 10) || 1000000, 2000, 4000000); this.seed = parseInt(el.dataset.seed || '7', 10) || 7; this.uid = el.id || 'graph-explorer-' + Math.random().toString(36).slice(2, 8); this.views = new Map(); // view id -> payload this.view = null; this.cam = { cx: 0, cy: 0, z: 1 }; this.bitmapCam = null; this.camSeq = 0; this.sent = null; this.sentAt = 0; this.inflight = 0; this.pendingBitmap = null; this.tiers = null; this.slots = new Map(); // node index -> slot this.free = []; this.selected = new Set(); this.hover = -1; this.kbd = -1; this.dataRev = new Map(); this.requests = new Map(); this.islandMounts = new Map(); // island key -> its mount, set by the renderer that made the island this.rowCache = new Map(); // id -> Promise<GraphRows>, the last two this.edgePaths = new Map(); this.signKeys = ''; this.labelHosts = new Map(); // edge labels: pooled hosts keyed by edge this.labelPool = []; this.labelVer = 0; this.shown = new Set(layersOf(el)); // the layers shown this.compact = el.hasAttribute('data-compact'); this.layerColors = new Map(); // layer -> its CSS colour this.monochrome = false; // the theme's chart colours are one hue: the layers turn chart-1 around the hue circle this.expanded = false; this.edge = null; // the selected link ('<source>><target>'), or null this.dotLabels = []; // the canvas labels' boxes of the last frame (the edge labels keep clear of them) this.updates = []; // GraphUpdate[], newest first (UPDATE_LIMIT) this.updatesShown = []; // the ones the shown layers own - the Updates outlet's rows this.changes = new Map(); // target -> { u, at }: what changed in the last CHANGE_MS (the dots, the flashes) this.changeDots = new Map(); // node id -> its dot this.updateSeq = 0; this.flight = null; this.navigating = false; this.loading = true; this.stats = { frames: 0, fpsT: performance.now(), fps: 0, worker: {} }; this.searchSeq = 0; this.results = []; this.activeResult = -1; this.waiters = new Map(); this.syncChain = Promise.resolve(); this.ready = new Promise<void>((resolve) => (this.resolveReady = resolve)); this.mount(); this.startWorker(); this.bindEvents(); this.observeTheme(); live.add(this); } // ---------------------------------------------------- the viewport's chrome mount(): void { const el = this.el, id = this.uid; const help = html`<div class="card graph-explorer-help" id="${id}-help" popover data-ce-chrome> <div class="card-header"><h3 class="card-title">Keyboard and pointer</h3></div> <div class="card-content"> <table class="graph-explorer-keys"> <tbody> ${HELP_ROWS.map( ([k, v]) => html`<tr> <td>${k.split(' / ').map((key, j) => html`${j ? ' / ' : ''}<kbd class="kbd">${key}</kbd>`)}</td> <td>${v}</td> </tr>`, )} </tbody> </table> </div> </div>`; const chrome = html`<canvas class="graph-explorer-canvas" aria-hidden="true" data-ce-chrome></canvas> <svg class="graph-explorer-edges" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" data-ce-chrome> <g class="graph-explorer-edge-group"></g> </svg> <div class="graph-explorer-edge-labels" aria-hidden="true" data-ce-chrome></div> <div class="graph-explorer-world" data-ce-chrome></div> <div class="graph-explorer-signs" data-ce-chrome></div> <div class="graph-explorer-changes" aria-hidden="true" data-ce-chrome></div> <div class="graph-explorer-ring" hidden data-ce-chrome></div> <div class="graph-explorer-focus-ring" hidden aria-hidden="true" data-ce-chrome></div> <div class="graph-explorer-loading" data-ce-chrome> <span class="graph-explorer-loading-text">Generating ${fmt(this.n)} nodes...</span><progress></progress> </div> <pre class="graph-explorer-hud" aria-hidden="true" hidden data-ce-chrome></pre> <output class="graph-explorer-live" data-ce-chrome></output> <span class="graph-explorer-probe" aria-hidden="true" data-ce-chrome></span> ${help}`; // the authored content (a fallback text for no script) stays; the chrome goes after it. Parsed as HTML: the <svg> // keeps its namespace and the HTML around it stays HTML (df$('<svg>...') would make everything SVG); the nodes go // in through df$ const doc = new DOMParser().parseFromString(`<body>${chrome.html}</body>`, 'text/html'); for (const node of Array.from(doc.body.children)) dfDollar(el).append(document.adoptNode(node)); if (!el.hasAttribute('tabindex')) el.tabIndex = 0; if (!el.hasAttribute('role')) dfDollar(el).attr('role', 'application'); dfDollar(el).attr('aria-roledescription', 'graph'); dfDollar(el).attr('aria-describedby', `${id}-help`); dfDollar(el).attr('aria-busy', 'true'); dfDollar(el).attr('data-graph-status', 'loading'); const $ = (sel: string) => dfDollar(el).children(sel).get(0) as any; this.canvas = $('.graph-explorer-canvas'); this.svgG = dfDollar(el).find('.graph-explorer-edge-group').get(0); this.labelsEl = $('.graph-explorer-edge-labels'); this.world = $('.graph-explorer-world'); this.signs = $('.graph-explorer-signs'); this.changesEl = $('.graph-explorer-changes'); this.ring = $('.graph-explorer-ring'); this.focusRing = $('.graph-explorer-focus-ring'); this.loadingEl = $('.graph-explorer-loading'); this.hud = $('.graph-explorer-hud'); this.liveEl = $('.graph-explorer-live'); this.probe = $('.graph-explorer-probe'); this.help = $('.graph-explorer-help'); this.bitmapCtx = this.canvas.getContext('bitmaprenderer'); if (el.hasAttribute('data-hud')) this.hud.hidden = false; } // ---------------------------------------------------- the worker startWorker(): void { this.workerUrl = URL.createObjectURL( new Blob([`(${graphExplorerWorker.toString()})(self);`], { type: 'text/javascript' }), ); this.worker = new Worker(this.workerUrl, { name: 'defuss-graph-explorer' }); this.worker.onmessage = (ev: MessageEvent) => this.onWorker(ev.data); this.worker.onerror = (ev: ErrorEvent) => this.fail(ev.message || 'worker error'); this.postRules(); this.postLayers(); this.post({ t: 'init', n: this.n, seed: this.seed }); this.t0 = performance.now(); } post(m: Record<string, unknown>, transfer?: Transferable[]): void { if (!this.destroyed) this.worker.postMessage(m, transfer || []); } fail(message: string): void { dfDollar(this.el).attr('data-graph-status', 'error'); dfDollar(this.el).attr('aria-busy', null); dfDollar(this.loadingEl) .find('.graph-explorer-loading-text') .text(`The graph could not be built: ${message.split('\n')[0]}`); // the worker or a renderer failed this.el.dispatchEvent( new CustomEvent<GraphExplorerErrorDetail>('graph-explorer-error', { detail: { message }, bubbles: true }), ); } onWorker(m: any): void { switch (m.t) { case 'ready': this.genStats = m.stats; this.genMs = Math.round(performance.now() - this.t0); this.postColors(); this.post({ t: 'focus', id: m.root }); break; case 'view': this.onView(m); break; case 'frame': this.onFrame(m); break; case 'search': this.onSearch(m); break; case 'details': this.resolve('details:' + m.id, m); break; case 'rows': this.resolve('rows:' + m.id, m); break; case 'subtree': this.resolve('subtree:' + m.id + ':' + m.levels, m.rows); break; case 'edge': this.resolve('edge:' + m.id, m); break; case 'path': this.resolve('path:' + m.id, m.path); break; case 'patch': this.onPatch(m); break; case 'updates': this.addUpdates(m.items); break; case 'error': this.fail(String(m.message)); break; } } waitFor(key: string, send: () => void): Promise<any> { let w = this.waiters.get(key); if (!w) { w = {}; w.promise = new Promise((r) => (w.resolve = r)); this.waiters.set(key, w); send(); } return w.promise; } resolve(key: string, value: unknown): void { const w = this.waiters.get(key); if (w) { this.waiters.delete(key); w.resolve(value); } } fetchView(id: string): Promise<any> { if (this.views.has(id)) return Promise.resolve(this.views.get(id)); return this.waitFor('view:' + id, () => this.post({ t: 'focus', id })); } postRules(): void { const rules: GraphRule[] = []; for (const reg of [globalRegistry, this.registry]) for (const p of reg.edge) { if (!this.shown.has(p.layer || 'relations')) continue; for (const r of p.rules || []) rules.push({ when: r.when || [], style: r.style || {}, priority: (r.priority ?? 0) + (p._scoped ? 1000 : 0), }); } this.post({ t: 'rules', rules }); } onPluginsChanged(): void { this.renderUpdates(); this.postRules(); this.labelVer++; this.renderLayers(); if (this.view) { this.assignRenderers(); for (const s of this.slots.values()) s._key = ''; this.applyTiers(this.tiers, true); this.renderPanel(); this.refreshDecor(true); } } // ---------------------------------------------------- theme → the canvas palette postColors(): void { const probe = this.probe; const read = (v: string): string => { probe.style.color = ''; probe.style.color = v; return getComputedStyle(probe).color; }; const forced = !!globalThis.matchMedia?.('(forced-colors: active)').matches; const tok = (name: string, fallback: string): string => (forced ? read(fallback) : read(`var(${name})`)); const palette: any = { paper: tok('--background', 'Canvas'), ink: tok('--foreground', 'CanvasText'), muted: tok('--muted-foreground', 'GrayText'), border: tok('--border', 'GrayText'), ring: tok('--ring', 'Highlight'), tones: { muted: tok('--muted-foreground', 'GrayText'), ink: tok('--foreground', 'CanvasText'), accent: tok('--chart-2', 'Highlight'), link: tok('--chart-4', 'LinkText'), ok: tok('--chart-3', 'CanvasText'), warn: tok('--chart-1', 'Mark'), danger: tok('--destructive', 'Highlight'), }, cat: ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5'].map((v) => tok(v, 'CanvasText')), font: getComputedStyle(this.el).fontFamily || 'system-ui, sans-serif', dark: document.documentElement.classList.contains('dark') || getComputedStyle(document.documentElement).colorScheme === 'dark', }; for (const shift of [150, 210, 280]) palette.cat.push(forced ? palette.ink : read(`oklch(from var(--chart-1) l c calc(h + ${shift}))`) || palette.ink); // the layers' colours: the chart colours, unless the theme's are one hue const mono = !forced && this.chartsMonochrome(palette.cat.slice(0, 5)); if (mono !== this.monochrome) { this.monochrome = mono; this.renderLayers(); if (this.view) { for (const s of this.slots.values()) s._key = ''; this.applyTiers(this.tiers, true); this.refreshDecor(true); } } else this.renderLayers(); // a calm line is the relations layer's colour (its SVG twins read --graph-explorer-relations), a chip or a card // has the nodes layer's const relations = this.colorOf('relations'); this.el.style.setProperty('--graph-explorer-relations', relations); this.el.style.setProperty('--graph-explorer-nodes', this.colorOf('nodes')); if (!forced) palette.tones.muted = read(relations) || palette.tones.muted; this.palette = palette; this.post({ t: 'colors', palette }); } /** true when the theme's chart colours are one hue (the default theme's are five blues): no two of the coloured * ones are more than 40 degrees apart */ chartsMonochrome(colors: string[]): boolean { const canvas = dfDollar('<canvas width="1" height="1"></canvas>').get(0) as HTMLCanvasElement; const g = canvas.getContext('2d', { willReadFrequently: true }); if (!g) return false; const hues: number[] = []; for (const color of colors) { g.clearRect(0, 0, 1, 1); g.fillStyle = '#000'; g.fillStyle = color; g.fillRect(0, 0, 1, 1); const [r, gr, b] = [...g.getImageData(0, 0, 1, 1).data].map((v) => v / 255); const max = Math.max(r, gr, b), min = Math.min(r, gr, b), d = max - min; if (d < 0.12) continue; // grey: no hue const h = max === r ? ((gr - b) / d) % 6 : max === gr ? (b - r) / d + 2 : (r - gr) / d + 4; hues.push((h * 60 + 360) % 360); } let spread = 0; for (const a of hues) for (const b of hues) spread = Math.max(spread, Math.min(Math.abs(a - b), 360 - Math.abs(a - b))); return spread < 40; } observeTheme(): void { const again = (): void => { if (this.genStats) this.postColors(); }; this.themeObserver = new MutationObserver(again); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'], }); this.mediaQueries = globalThis.matchMedia ? ['(prefers-color-scheme: dark)', '(prefers-contrast: more)', '(forced-colors: active)'].map((q) => globalThis.matchMedia(q), ) : []; this.onMedia = again; for (const mq of this.mediaQueries) mq.addEventListener('change', again); // the docs' theme switcher swaps a stylesheet: its event, and any sheet that loads later this.onThemeEvent = () => requestAnimationFrame(again); document.addEventListener('defuss-theme-change', this.onThemeEvent); document.addEventListener('load', this.onThemeEvent, true); } // ---------------------------------------------------- views onView(m: any): void { const first = !this.views.has(m.id); this.views.set(m.id, m); m.byId = new Map(m.children.map((c) => [c.id, c.i])); m.edgeById = new Map(m.edges.map((e) => [m.children[e.a].id + '>' + m.children[e.b].id, e.k])); m.adj = m.children.map(() => []); m.edges.forEach((e) => { m.adj[e.a].push(e.k); m.adj[e.b].push(e.k); }); for (const c of m.children) c.breakers = null; this.computeBreakerCounts(m); this.resolve('view:' + m.id, m); if (first && !this.view) { this.setView(m); this.fit(false); this.loading = false; this.loadingEl.hidden = true; dfDollar(this.el).attr('aria-busy', null); dfDollar(this.el).attr('data-graph-status', 'ready'); this.resolveReady(); } } computeBreakerCounts(m: any): void { if (m.leafView) return; for (const c of m.children) c.breakers = { open: 0, half: 0, cut: 0, total: 0 }; for (const e of m.edges) { if (!e.breaker) continue; for (const end of [e.a, e.b]) { const b = m.children[end].breakers; b.open += e.breaker.open; b.half += e.breaker.half; b.cut += e.breaker.cut; b.total += e.breaker.members || 1; } } } setView(m: any): void { this.view = m; for (const s of this.slots.values()) { s.hidden = true; s._key = ''; this.free.push(s); } this.slots.clear(); this.tiers = null; this.hover = -1; this.kbd = -1; this.selected = new Set([...this.selected].filter((id) => m.byId.has(id))); if (this.edge && this.edgeIndex(this.edge) < 0) this.edge = null; for (const p of this.edgePaths.values()) { p.hit.remove(); p.line.remove(); p.head.remove(); p.flash?.remove(); } this.edgePaths.clear(); this.assignRenderers(); if (!this.updatesRendered) this.renderUpdates(); this.renderLayers(); this.renderCrumbs(); this.renderOutline(); this.renderPanel(); this.refreshDecor(true); this.signKeys = ''; morph(this.signs, []); this.closeRing(); this.commitState(); // the view moved to another cluster (null = the top) this.el.dispatchEvent( new CustomEvent<GraphExplorerFocusDetail>('graph-explorer-focus', { detail: { focus: m.id === 'c0' ? null : m.id, path: m.path }, bubbles: true, }), ); this.announce(`${m.path.map((p) => p.label).join(' › ')} · ${fmt(m.children.length)} nodes`); this.camDirty = true; this.requestTick(); } nodeOf(i: number): GraphNode { return { ...this.view.children[i], depth: this.view.path.length - 1 }; } /** a renderer's boxes for a node (sizes may be a function of the node) */ sizesOf(r: any, node: GraphNode): GraphSizes { try { return (typeof r.sizes === 'function' ? r.sizes(node) : r.sizes) || {}; } catch (err) { console.error('[graph-explorer] sizes of "' + r.name + '" failed', err); return {}; } } /** each child's renderer (the nodes layer), the parts of the other layers shown and its boxes - the worker places * the chips and cards by them; a node without either has no box (only its circle shows) */ assignRenderers(): void { const m = this.view; const head = this.shown.has('nodes'); const partLayers = this.layerDefs() .filter((l) => l.name !== 'nodes' && l.shown) .map((l) => l.name); m.renderers = m.children.map((c: GraphNode) => (head ? this.pick('node', c) : null)); m.boxes = []; const sizes = new Float32Array(m.children.length * 4); m.children.forEach((_c: GraphNode, i: number) => { const node = this.nodeOf(i), r = m.renderers[i]; const hs = r ? this.sizesOf(r, node) : {}; const chip0 = hs.chip || [140, 26], card0 = hs.card || chip0; let [chipW, chipH] = r ? chip0 : [0, 0], [cardW, cardH] = r ? card0 : [0, 0]; const chipParts: any[] = [], cardParts: any[] = []; for (const layer of partLayers) { const p = this.pickPart(layer, node); if (!p) continue; const ps = this.sizesOf(p, node); if (ps.chip) { chipW += ps.chip[0]; chipH = Math.max(chipH, ps.chip[1]); chipParts.push(p); } if (ps.card) { cardW = Math.max(cardW, ps.card[0]); cardH += ps.card[1]; cardParts.push(p); } } // the parts without their node: a bare box of them if (!r && chipParts.length) { chipW += 12; chipH = Math.max(chipH, 22); } if (!r && cardParts.length) { cardW = Math.max(cardW, 168); cardH += 14; } if (this.compact) { cardW = chipW; cardH = chipH; } m.boxes[i] = { chip: chipParts, card: this.compact ? chipParts : cardParts, chipSize: [chipW, chipH], cardSize: [cardW, cardH], }; sizes.set([chipW, chipH, cardW, cardH], i * 4); }); this.post({ t: 'sizes', id: m.id, sizes }); } pick(kind: 'node' | 'detail', item: GraphNode): any { for (const reg of [this.registry, globalRegistry]) { let best = null, score = -Infinity; for (const p of reg[kind]) { if (kind === 'node' && p.layer) continue; // a part, not the node const s = p.match(item); if (s === false || s == null) continue; if (s > score || (s === score && p._order > best._order)) { best = p; score = s; } } if (best) return best; } return null; } /** the renderer of a layer's part for a node (the element's plugins first, the highest score wins) */ pickPart(layer: string, item: GraphNode): any { for (const reg of [this.registry, globalRegistry]) { let best = null, score = -Infinity; for (const p of reg.node) { if (p.layer !== layer) continue; let s; try { s = p.match(item); } catch (err) { console.error(err); s = false; } if (s === false || s == null) continue; if (s > score || (s === score && p._order > best._order)) { best = p; score = s; } } if (best) return best; } return null; } // ---------------------------------------------------- layers /** every layer the switch offers: the built-in ones, then the plugins' in the order they came */ layerDefs(): GraphLayer[] { const out: Omit<GraphLayer, 'shown'>[] = BUILTIN_LAYERS.map((l) => ({ ...l, color: '' })); const seen = new Set(out.map((l) => l.name)); const plugins = [...globalRegistry.node, ...globalRegistry.edge, ...this.registry.node, ...this.registry.edge]; for (const p of plugins.sort((a, b) => a._order - b._order)) { if (!p.layer || seen.has(p.layer)) continue; seen.add(p.layer); out.push({ name: p.layer, label: p.layerLabel || p.layer, icon: p.layerIcon || 'layers', color: p.layerColor || '', base: false, }); } return out.map((l, k) => ({ ...l, color: l.color || this.layerColor(k), shown: this.shown.has(l.name) })); } /** the k-th layer's colour: the theme's chart colours - or, when they are one hue (the default theme's five blues), * chart-1 turned around the hue circle, so every layer keeps a colour of its own */ layerColor(k: number): string { if (!this.monochrome && k < 5) return `var(--chart-${k + 1})`; return `oklch(from var(--chart-1) 0.66 max(c, 0.15) calc(h + ${(k * 72 + (k >= 5 ? 36 : 0)) % 360}))`; } /** a layer's colour by name */ colorOf(layer: string): string { return this.layerColors.get(layer) || 'var(--muted-foreground)'; } renderLayers(): void { const defs = this.layerDefs(); this.layerColors = new Map(defs.map((l) => [l.name, l.color])); const sig = defs.map((l) => l.name + '=' + l.color + '=' + l.shown).join('|') + '|' + this.compact; const first = Math.max( 0, defs.findIndex((l) => l.shown), ); for (const outlet of this.outlets('layers')) { const box = outletChrome( outlet, 'layers', () => dfDollar('<div class="graph-explorer-layers" data-ce-chrome></div>').get(0) as HTMLElement, ); if (box._sig === sig) continue; box._sig = sig; // a Toggle Group of the layers, each with its colour as an Indicator dot; Compact after a Separator morph( box, html`<div class="toggle-group" role="group" aria-label="Layers" data-type="multiple" data-size="sm" data-spacing > ${defs.map( (l, k) => html`<button type="button" class="toggle graph-explorer-layer" key="${l.name}" data-graph-explorer-layer="${l.name}" aria-pressed="${l.shown ? 'true' : 'false'}" tabindex="${k === first ? '0' : '-1'}" style="--graph-explorer-layer: ${l.color}" title="${l.label}: ${l.shown ? 'shown' : 'hidden'}${k < 9 ? ` (${k + 1})` : ''}" > <span class="indicator-dot" aria-hidden="true"></span>${icon(l.icon)}<span class="graph-explorer-layer-label" >${l.label}</span > </button>`, )} </div> <div class="separator" data-orientation="vertical" role="separator"></div> <button type="button" class="toggle graph-explorer-compact" data-size="sm" data-graph-explorer-compact aria-pressed="${this.compact ? 'true' : 'false'}" title="Compact: every node as a one-line chip" > ${icon('rows-3')}<span class="graph-explorer-layer-label">Compact</span> </button>`, ); } } /** show these layers (unknown names are kept for a plugin that registers later); `user` commits it as a change * the user made */ setLayers(names: string[], user = true): void { const next = new Set(layerList(names)); if (sameLayers([...next], [...this.shown])) return; this.shown = next; this.layersChanged(user); } /** show or hide one layer */ toggleLayer(name: string): void { const next = new Set<string>(this.shown); if (next.has(name)) next.delete(name); else next.add(name); this.setLayers([...next]); const l = this.layerDefs().find((x) => x.name === name); this.announce(`${l?.label ?? name}: ${this.shown.has(name) ? 'shown' : 'hidden'}`); } /** every node as a one-line chip, or back */ setCompact(on: boolean, user = true): void { if (this.compact === !!on) return; this.compact = !!on; this.layersChanged(user); this.announce(`Compact ${this.compact ? 'on' : 'off'}`); } /** the graph after a layer or Compact changed: the worker's lines and names, the boxes, the labels, the switch */ layersChanged(user: boolean): void { this.renderLayers(); this.postLayers(); this.postRules(); this.labelVer++; this.signKeys = ''; if (this.view) { this.assignRenderers(); for (const s of this.slots.values()) s._key = ''; this.applyTiers(this.tiers, true); this.refreshDecor(true); this.camChanged(); } this.renderUpdates(); if (user) this.commitState(); // a layer was shown or hidden, or Compact switched this.el.dispatchEvent( new CustomEvent<GraphExplorerLayersDetail>('graph-explorer-layers', { detail: { layers: [...this.shown], compact: this.compact }, bubbles: true, }), ); } /** what the worker draws itself: the lines (relations) and the names beside small nodes (nodes) */ postLayers(): void { this.post({ t: 'layers', nodes: this.shown.has('nodes'), relations: this.shown.has('relations') }); } // ---------------------------------------------------- the camera loop requestTick(): void { if (!this.raf && !this.destroyed) this.raf = requestAnimationFrame((t) => this.tick(t)); } size(): void { const r = this.el.getBoundingClientRect(); this.w = Math.max(1, r.width); this.h = Math.max(1, r.height); this.vmin = Math.min(this.w, this.h); } tick(now: number): void { this.raf = 0; if (!this.view || this.destroyed) return; if (!this.w) this.size(); if (this.flight) this.stepFlight(now); else if (!this.navigating) this.autoDepth(); if (this.pendingBitmap) { const f = this.pendingBitmap; this.pendingBitmap = null; this.bitmapCtx.transferFromImageBitmap(f.bitmap); this.bitmapCam = f.cam; this.camDirty = true; this.stats.frames++; } if (this.camDirty) { this.camDirty = false; this.project(); } this.maybeSend(); if (now - this.stats.fpsT > 500) this.sampleHud(now); if (this.flight || this.pendingBitmap || this.inflight) this.requestTick(); else if (!this.hud.hidden) { clearTimeout(this.hudT); this.hudT = setTimeout(() => this.sampleHud(performance.now()), 600); } } // the HUD samples while frames flow, and once after the loop goes idle, so it never shows stale numbers sampleHud(now: number): void { this.stats.fps = Math.round((this.stats.frames * 1000) / Math.max(1, now - this.stats.fpsT)); this.stats.frames = 0; this.stats.fpsT = now; if (!this.hud.hidden && this.view && !this.destroyed) this.renderHud(); } camChanged(): void { this.camDirty = true; clearTimeout(this.restTimer); this.restTimer = setTimeout(() => { if (!this.view || this.destroyed) return; // the camera came to rest this.el.dispatchEvent( new CustomEvent<GraphExplorerViewDetail>('graph-explorer-view', { detail: { focus: this.view.id === 'c0' ? null : this.view.id, camera: { ...this.cam } }, bubbles: true, }), ); }, 250); this.requestTick(); } maybeSend(): void { const c = this.cam; const sel = [...this.selected].map((id) => this.view.byId.get(id)).filter((i) => i !== undefined); const ends = this.edgeEnds(); const key = [ this.view.cid, c.cx.toFixed(5), c.cy.toFixed(5), c.z.toFixed(3), this.w, this.h, this.hover, this.kbd, sel.join(','), ends.join(','), ].join('|'); const now = performance.now(); // send the same camera again when its frame never came back (superseded inside the worker) if (key === this.sent && !(this.inflight && now - this.sentAt > 400)) return; if (this.inflight && now - this.sentAt < 120) { this.requestTick(); return; } this.sent = key; this.sentAt = now; this.inflight = ++this.camSeq; this.post({ t: 'camera', id: this.camSeq, view: this.view.cid, cx: c.cx, cy: c.cy, z: c.z, w: this.w, h: this.h, dpr: Math.min(2, globalThis.devicePixelRatio || 1), hover: this.hover, kbd: this.kbd, sel, edge: ends.length ? ends : null, }); } onFrame(m: any): void { if (m.camId >= this.inflight) this.inflight = 0; this.stats.worker = m.stats; this.stats.lag = this.camSeq - m.camId; if (!this.view || m.view !== this.view.cid || this.destroyed) { m.bitmap.close(); return; } if (this.pendingBitmap) this.pendingBitmap.bitmap.close(); this.pendingBitmap = { bitmap: m.bitmap, cam: m.cam, camId: m.camId }; this.dotLabels = m.dotLabels || []; this.applyTiers(m.tiers); this.requestTick(); } // ---------------------------------------------------- projection (every frame) toScreen(x: number, y: number): [number, number] { const c = this.cam; return [(x - c.cx) * c.z + this.w / 2, (y - c.cy) * c.z + this.h / 2]; } project(): void { const m = this.view, c = this.cam, isCluster = !m.leafView; if (this.bitmapCam && this.bitmapCam.w === this.w && this.bitmapCam.h === this.h) { const b = this.bitmapCam, k = c.z / b.z; this.canvas.style.transform = `translate(${((b.cx - c.cx) * c.z).toFixed(2)}px, ${((b.cy - c.cy) * c.z).toFixed(2)}px) scale(${k.toFixed(5)})`; } for (const [i, s] of this.slots) { const n = m.children[i]; const [sx, sy] = this.toScreen(n.x, n.y); const [x, y] = anchorBox(isCluster, s._tier, sx, sy, n.r * c.z, s._w, s._h); s._x = Math.round(x); s._y = Math.round(y); s.style.translate = `${s._x}px ${s._y}px`; } this.projectEdges(); this.projectChanges(); this.projectDecor(); this.projectSigns(); this.projectFocusRing(); if (!this.ring.hidden) this.projectRing(); } // ---------------------------------------------------- tiers → pooled slots (morphed only on change) applyTiers(tiers: Int8Array | null, force = false): void { if (!tiers || !this.view) return; this.tiers = tiers; const m = this.view, jobs: SafeHtml[] = [], slotsForJobs: HTMLElement[] = []; // a tier whose box holds nothing (the nodes layer hidden, no part at that tier) is no slot const empty = (i: number): boolean => { const b = m.boxes?.[i]; return !b || (!m.renderers[i] && !(tiers[i] === 2 ? b.card : b.chip).length); }; for (const [i, s] of this.slots) { if (tiers[i] < 1 || empty(i)) { s.hidden = true; s._key = ''; this.slots.delete(i); this.free.push(s); } } for (let i = 0; i < tiers.length; i++) { if (tiers[i] < 1 || empty(i)) continue; let s = this.slots.get(i); if (!s) { s = this.free.pop(); if (!s) { s = dfDollar('<div class="graph-explorer-node"></div>').get(0); dfDollar(this.world).append(s); } s.hidden = false; s._key = ''; this.slots.set(i, s); } // Compact draws every node as its chip const tier = tiers[i] === 2 && !this.compact ? 'card' : 'chip'; const node = this.nodeOf(i), r = m.renderers[i], box = m.boxes[i], parts: any[] = tier === 'card' ? box.card : box.chip, ctx = this.ctxFor(node, tier, i); const keyOf = (x: any): string => { try { return x.key ? x.key(node, ctx) : ''; } catch { return ''; } }; const key = (r ? r.name + ':' + keyOf(r) : '-') + '|' + parts.map((x) => x.name + ':' + keyOf(x)).join(',') + '|' + tier + '|' + (ctx.selected ? 1 : 0) + '|' + (ctx.expandable ? 1 : 0) + '|' + (this.dataRev.get(node.id) || 0); if (s._key === key && !force) continue; const [w, h] = tiers[i] === 2 ? box.cardSize : box.chipSize; if (s._node !== node.id) { s._node = node.id; dfDollar(s).attr('data-node', node.id); } if (s._tier !== tiers[i]) { s._tier = tiers[i]; dfDollar(s).attr('data-tier', tier); } s._w = w; s._h = h; s.style.width = w + 'px'; s.style.height = h + 'px'; dfDollar(s).attr('data-selected', ctx.selected ? '' : null); dfDollar(s).attr('data-kind', node.kind); s._key = key; try { jobs.push(this.composeNode(node, tier, ctx, r, parts)); slotsForJobs.push(s); } catch (err) { console.error('[graph-explorer] renderer "' + (r?.name ?? 'parts') + '" failed', err); } } if (jobs.length) { let lists: any[][]; try { lists = vnodesBatch(jobs); } catch (err) { console.error(err); lists = jobs.map(() => []); } lists.forEach((list, k) => morphRuntime.morph(slotsForJobs[k], list)); } for (const [i, s] of this.slots) dfDollar(s).attr('data-kbd', i === this.kbd ? '' : null); this.el.toggleAttribute('data-dim', this.hover >= 0 || this.selected.size > 0 || !!this.edge); for (const [i, s] of this.slots) s.toggleAttribute('data-hl', this.isHighlighted(i)); this.refreshDecor(false); this.camDirty = true; this.requestTick(); } /** a node's markup at a tier: its renderer's with the shown layers' parts inside (or after it), or the parts * alone when the nodes layer is hidden */ composeNode(node: GraphNode, tier: 'chip' | 'card', ctx: GraphRenderContext, r: any, parts: any[]): SafeHtml { const tag = tier === 'chip' ? 'span' : 'div'; const drawn: SafeHtml[] = []; for (const p of parts) { let out: SafeHtml; try { out = p.render(node, { ...ctx, parts: html`` }); } catch (err) { console.error('[graph-explorer] part "' + p.name + '" failed', err); continue; } if (!isSafe(out) || !out.html.trim()) continue; const layer = p.layer as string; drawn.push( tag === 'span' ? html`<span class="graph-explorer-part" data-layer="${layer}" style="--graph-explorer-layer: ${this.colorOf(layer)}" >${out}</span >` : html`<div class="graph-explorer-part" data-layer="${layer}" style="--graph-explorer-layer: ${this.colorOf(layer)}" > ${out} </div>`, ); } const joined = html`${drawn}`; if (!r) return html`<div class="graph-explorer-parts-only" data-tier="${tier}" data-kind="${node.kind}">${joined}</div>`; const out = r.render(node, { ...ctx, parts: joined }); if (!drawn.length || !isSafe(out) || out.html.includes('graph-explorer-part"')) return out; return html`${out}${joined}`; // a renderer that leaves the parts out: they follow its markup } isHighlighted(i: number): boolean { const ends = this.edgeEnds(); if (ends.length) return ends.includes(i) || i === this.hover; const foci = [...this.selected].map((id) => this.view.byId.get(id)).filter((x) => x !== undefined); if (this.hover >= 0) foci.push(this.hover); if (!foci.length) return true; if (foci.includes(i)) return true; return foci.some((f) => this.view.adj[f].some((k) => { const e = this.view.edges[k]; return e.a === i || e.b === i; }), ); } ctxFor(node: GraphNode, tier: GraphRenderContext['tier'], i: number): GraphRenderContext { return { tier, selected: this.selected.has(node.id), highlighted: i === this.hover, focused: i === this.kbd, depth: node.depth, zoom: this.cam.z, html, icon, expanded: tier === 'detail' && this.expanded, layers: [...this.shown], compact: this.compact, parts: html``, action: (name) => String(name), edgeLabel: (e, t = 'label') => this.edgeLabelHtml(e, t), fetchView: (id) => this.fetchView(id), fetchRows: (id) => this.fetchRows(id), expandable: tier === 'relations' ? false : this.expandableNode(node), request: (key, load) => { const r = this.requests.get(key); if (r && !r.stale) return r.done ? r.value : r.prev; // a first load, or a stale answer's successor: the stale answer stays shown until the new one arrives - // a loading state between two answers made the details flicker on every Live mode update const entry = { done: false, value: undefined, prev: r ? (r.done ? r.value : r.prev) : undefined, stale: false, }; this.requests.set(key, entry); Promise.resolve() .then(load) .then( (value) => { entry.done = true; entry.value = value; this.dataRev.set(node.id, (this.dataRev.get(node.id) || 0) + 1); this.applyTiers(this.tiers); this.renderPanel(); }, (err) => { entry.done = true; entry.value = entry.prev; console.error(err); }, ); return entry.prev; }, fetchDetails: (id) => this.waitFor('details:' + id, () => this.post({ t: 'details', id })), fetchEdge: (id) => { const [a, b] = String(id).split('>'); return this.waitFor('edge:' + a + '>' + b, () => this.post({ t: 'edge', a, b })); }, island: (key, content, mount) => { if (mount) this.islandMounts.set(key, mount); else this.islandMounts.delete(key); return island(key, content); }, }; } /** every row behind a node or a link, from the worker; the last two answers are kept */ /** a node's descendants down to `levels` levels (the node included) - from the worker, which owns the graph */ fetchSubtree(id: string, levels: number): Promise<GraphTreeRow[]> { return this.waitFor('subtree:' + id + ':' + levels, () => this.post({ t: 'subtree', id, levels })); } /** the worker's search as data: a newer search (here or in a search outlet) ends this one with what it found */ find( query: string, onPartial?: (results: GraphSearchResult[], scanned: number) => void, ): Promise<GraphSearchResult[]> { this.finder?.resolve(this.finder.results); this.searchSeq++; const seq = this.searchSeq; return new Promise((resolve) => { this.finder = { seq, resolve, onPartial, results: [] }; this.post({ t: 'search', q: query, seq }); }); } fetchRows(id: string): Promise<GraphRows> { const hit = this.rowCache.get(id); if (hit) { this.rowCache.delete(id); this.rowCache.set(id, hit); return hit; } const p = this.waitFor('rows:' + id, () => this.post({ t: 'rows', id })); this.rowCache.set(id, p); while (this.rowCache.size > 2) this.rowCache.delete(this.rowCache.keys().next().value); return p; } /** whether a node has a page to expand to: a details outlet to show it, and a detail renderer that offers one */ expandableNode(node: GraphNode | undefined): boolean { if (!node || !this.outlets('details').length) return false; const r = this.pick('detail', node); return !!r && (typeof r.page !== 'function' || r.page(node) !== false); } /** whether the selected link has a page to expand to */ expandableEdge(item: GraphEdgeItem | null = this.edgeItem()): boolean { if (!item || !this.outlets('details').length) return false; const r = this.pickEdgeDetail(item); return !!r && (typeof r.page !== 'function' || r.page(item) !== false); } // ---------------------------------------------------- promoted edges (SVG) promotedEdges(): Set<number> { const m = this.view, out = new Set<number>(); // a changed link flashes while its layer shows, the relations layer or not for (const [target, ch] of this.changes) { if (ch.u.kind !== 'edge' || !this.shown.has(ch.u.layer)) continue; const k = this.edgeIndex(target); if (k >= 0) out.add(k); } if (!this.shown.has('relations')) return out; // the relations layer draws every line const foci = [...this.selected].map((id) => m.byId.get(id)).filter((x) => x !== undefined); if (this.hover >= 0) foci.push(this.hover); if (this.kbd >= 0) foci.push(this.kbd); const picked = this.edgeIndex(this.edge); if (picked >= 0) out.add(picked); for (const f of foci) for (const k of m.adj[f]) { if (out.size > 80) break; out.add(k); } return out; } projectEdges(): void { const m = this.view, z = this.cam.z, want = this.promotedEdges(); for (const [k, p] of this.edgePaths) if (!want.has(k)) { p.hit.remove(); p.line.remove(); p.head.remove(); p.flash?.remove(); this.edgePaths.delete(k); } const picked = this.edgeIndex(this.edge); // without the relations layer a link shows only while it flashes const lines = this.shown.has('relations'); const NS = 'http://www.w3.org/2000/svg'; for (const k of want) { const e = m.edges[k], a = m.children[e.a], b = m.children[e.b]; const [ax, ay] = this.toScreen(a.x, a.y), [bx, by] = this.toScreen(b.x, b.y); const st = e.style; const w = e.kind === 'agg' ? Math.min(5, st.width * (1 + Math.min(1.6, Math.log10(e.count + e.count2) * 0.7))) : st.width; const cl = !m.leafView; const g = edgePath(ax, ay, drawR(cl, a.r * z), bx, by, drawR(cl, b.r * z), st, e.bidir, w); let p = this.edgePaths.get(k); if (!p) { p = { hit: document.createElementNS(NS, 'path'), line: document.createElementNS(NS, 'path'), head: document.createElementNS(NS, 'path'), }; // a click on a link selects it: the hit path is wide and transparent, under the drawn line p.hit.setAttribute('class', 'graph-explorer-edge-hit'); p.line.setAttribute('class', 'graph-explorer-edge'); p.head.setAttribute('class', 'graph-explorer-edge-head'); this.svgG.append(p.hit, p.line, p.head); this.edgePaths.set(k, p); } const id = this.edgeId(k); if (p.id !== id) { p.id = id; p.hit.setAttribute('data-graph-explorer-edge', id); } const on = k === picked; if (p.on !== on) { p.on = on; p.line.toggleAttribute('data-selected', on); p.head.toggleAttribute('data-selected', on); } const tone = TONES.includes(st.tone) ? st.tone : 'muted'; if (p.tone !== tone) { p.tone = tone; p.line.setAttribute('data-tone', tone); p.head.setAttribute('data-tone', tone); } const flow = st.flow && e.traffic ? '1' : null, pulse = st.pulse ? '1' : null; if (p.flow !== flow) { p.flow = flow; p.line.toggleAttribute('data-flow', !!flow); } if (p.pulse !== pulse) { p.pulse = pulse; p.line.toggleAttribute('data-pulse', !!pulse); } const dash = !flow && st.dash ? st.dash.join(' ') : ''; if (p.dash !== dash) { p.dash = dash; if (dash) p.line.setAttribute('stroke-dasharray', dash); else p.line.removeAttribute('stroke-dasharray'); } p.line.setAttribute('stroke-width', (w + 0.75).toFixed(2)); p.line.setAttribute('d', g ? g.d : ''); p.hit.setAttribute('d', g ? g.d : ''); p.head.setAttribute('d', g ? g.head : ''); p.mid = g ? g.mid : null; if (p.lines !== lines) { p.lines = lines; p.line.style.visibility = p.head.style.visibility = lines ? '' : 'hidden'; } // a change: the line flashes in its layer's colour and fades (a new change starts it again) const ch = this.changes.get(id); const flash = ch && ch.u.kind === 'edge' && this.shown.has(ch.u.layer) ? ch : null; if (flash && p.flashAt !== flash.at) { p.flash?.remove(); p.flash = document.createElementNS(NS, 'path'); p.flash.setAttribute('class', 'graph-explorer-edge-flash'); p.flash.setAttribute('data-layer', flash.u.layer); p.flash.style.setProperty('--graph-explorer-layer', this.colorOf(flash.u.layer)); p.flash.style.animationDuration = `${CHANGE_MS}ms`; p.line.after(p.flash); p.flashAt = flash.at; } else if (!flash && p.flash) { p.flash.remove(); p.flash = null; p.flashAt = null; } if (p.flash) { p.flash.setAttribute('d', g ? g.d : ''); p.flash.setAttribute('stroke-width', (w + 3).toFixed(2)); } } } // ---------------------------------------------------- edge labels and icons (per zoom level) // Every refresh: the candidates are the edges whose visible length passes a renderer's smallest level and whose // renderer matches this hierarchy depth; ranked (the selection first, then the renderer's priority), each takes the // richest tier its length, the tier budget and the free space allow. Hosts are pooled per edge; a host's content is // morphed only when its markup changed. /** the label renderers - on the graph only the shown layers' (a plugin without a layer: labels) */ labelPlugins(graph = false): any[] { return this.edgePlugins().filter((p) => p.label && (!graph || this.shown.has(p.layer || 'labels'))); } labelFor(e: any, ctx: GraphEdgeContext): any { const sig = e.breaker ? e.breaker.state + (e.breaker.openShare || 0) : ''; if (e._lv !== this.labelVer || e._ls !== sig) { e._lv = this.labelVer; e._ls = sig; let best = null, score = -Infinity; for (const p of this.labelPlugins(true)) { const L = p.label; if (L.depths && !L.depths.includes(ctx.depth)) continue; let sc; try { sc = L.match(e, ctx); } catch (err) { console.error(err); sc = false; } if (sc === false || sc == null) continue; sc += p._scoped ? 1000 : 0; if (sc > score) { best = p; score = sc; } } e._lp = best; } return e._lp; } /** the SafeHtml of the best label renderer for an edge at a tier (the panels and tables use it too) */ edgeLabelHtml(e: any, tier: GraphEdgeContext['tier']): SafeHtml { const depth = this.view ? this.view.path.length - 1 : 0; const ctx = this.edgeCtx(depth, tier); let best = null, score = -Infinity; for (const p of this.labelPlugins()) { let sc; try { sc = p.label.match(e, ctx); } catch { sc = false; } if (sc !== false && sc != null && sc + (p._scoped ? 1000 : 0) > score) { best = p; score = sc + (p._scoped ? 1000 : 0); } } if (!best) return html``; try { return best.label.render(e, ctx); } catch (err) { console.error(err); return html``; } } edgeCtx(depth: number, tier: GraphEdgeContext['tier']): GraphEdgeContext { return { tier, depth, zoom: this.cam.z, html, icon, view: this.view && this.view.id }; } refreshDecor(reset: boolean): void { const m = this.view; if (reset || !m || !this.tiers) { for (const h of this.labelHosts.values()) { h.hidden = true; h._sig = ''; this.labelPool.push(h); } this.labelHosts.clear(); if (!m || !this.tiers) return; } const z = this.cam.z, depth = m.path.length - 1, W = this.w, H = this.h; const foci = new Set([...this.selected].map((id) => m.byId.get(id)).filter((x) => x !== undefined)); for (const end of this.edgeEnds()) foci.add(end); if (this.hover >= 0) foci.add(this.hover); const picked = this.edgeIndex(this.edge); const isCluster = !m.leafView, cands: any[] = []; const ctx0 = this.edgeCtx(depth, 'icon'); for (const e of m.edges) { const a = m.children[e.a], b = m.children[e.b]; const [ax, ay] = this.toScreen(a.x, a.y), [bx, by] = this.toScreen(b.x, b.y); const len = Math.hypot(bx - ax, by - ay) - drawR(isCluster, a.r * z) - drawR(isCluster, b.r * z); if (len < 24) continue; const mx = (ax + bx) / 2, my = (ay + by) / 2; if (mx < -20 || mx > W + 20 || my < -20 || my > H + 20) continue; const p = this.labelFor(e, ctx0); if (!p || len < p.label.levels.icon) continue; const focus = foci.has(e.a) || foci.has(e.b); if (foci.size && !focus && (this.selected.size || this.edge)) continue; // with a selection, label its edges only let pr = 0; try { pr = p.label.priority ? +p.label.priority(e, ctx0) || 0 : 0; } catch { pr = 0; } cands.push({ e, p, len, pr: pr + (e.k === picked ? 5000 : 0) + (focus ? 1000 : 0) + (p._scoped ? 0.5 : 0), ax, ay, bx, by, }); } cands.sort((p, q) => q.pr - p.pr || q.len - p.len); // the collision space: the DOM nodes on screen, then every label placed const cell = 64, grid = new Map<number, number[][]>(); const hit = (x: number, y: number, w: number, h: number): boolean => { for (let gx = Math.floor(x / cell); gx <= Math.floor((x + w) / cell); gx++) for (let gy = Math.floor(y / cell); gy <= Math.floor((y + h) / cell); gy++) { const l = grid.get(gx * 7919 + gy); if (!l) continue; for (const r of l) if (x < r[0] + r[2] + 2 && x + w + 2 > r[0] && y < r[1] + r[3] + 2 && y + h + 2 > r[1]) return true; } return false; }; const put = (x: number, y: number, w: number, h: number): void => { const r = [x, y, w, h]; for (let gx = Math.floor(x / cell); gx <= Math.floor((x + w) / cell); gx++) for (let gy = Math.floor(y / cell); gy <= Math.floor((y + h) / cell); gy++) { const k = gx * 7919 + gy; let l = grid.get(k); if (!l) grid.set(k, (l = [])); l.push(r); } }; for (const [i, sl] of this.slots) { const n = m.children[i], [sx, sy] = this.toScreen(n.x, n.y); const [x, y] = anchorBox(isCluster, sl._tier, sx, sy, n.r * z, sl._w, sl._h); put(x, y, sl._w, sl._h); } // the names the worker drew into the canvas beside small nodes for (const r of this.dotLabels) put(r[0], r[1], r[2], r[3]); const used: Record<string, number> = { icon: 0, label: 0, full: 0 }, usedBy = new Map(), chosen: any[] = []; for (const c of cands) { if (chosen.length >= 200) break; const L = c.p.label; let mine = usedBy.get(c.p); if (!mine) usedBy.set(c.p, (mine = { icon: 0, label: 0, full: 0 })); for (let t = 2; t >= 0; t--) { const tier = LABEL_TIERS[t]; if (c.len < L.levels[tier] || used[tier] >= LABEL_BUDGET[tier]) continue; if (L.budget && L.budget[tier] != null && mine[tier] >= L.budget[tier]) continue; // the renderer's own cap const [w, h] = L.sizes[tier]; if (c.len < w * 0.9 + 8 && t > 0) continue; // the label must fit along the edge let placed = -1; for (const f of [0.5, 0.38, 0.62]) { const x = c.ax + (c.bx - c.ax) * f - w / 2, y = c.ay + (c.by - c.ay) * f - h / 2; if (!hit(x, y, w, h)) { put(x, y, w, h); placed = f; break; } } if (placed < 0) continue; used[tier]++; mine[tier]++; chosen.push({ c, tier, f: placed }); break; } } // pooled hosts: reused per edge, their content morphed only when its markup changed const next = new Map(), jobs: SafeHtml[] = [], jobHosts: HTMLElement[] = []; for (const { c, tier, f } of chosen) { const e = c.e; let hst = this.labelHosts.get(e.k); if (!hst) { hst = this.labelPool.pop(); if (!hst) { hst = dfDollar('<div class="graph-explorer-edge-label"></div>').get(0); dfDollar(this.labelsEl).append(hst); } hst._sig = ''; } this.labelHosts.delete(e.k); next.set(e.k, hst); hst.hidden = false; hst._f = f; hst._k = e.k; // the picked link's mark follows the selection, which the label's markup does not depend on if (hst.hasAttribute('data-selected') !== (e.k === picked)) dfDollar(hst).attr('data-selected', e.k === picked ? '' : null); let out: SafeHtml; try { out = c.p.label.render(e, { ...this.edgeCtx(depth, tier), source: m.children[e.a], target: m.children[e.b], selected: foci.has(e.a) || foci.has(e.b), }); } catch (err) { console.error('[graph-explorer] edge label "' + c.p.name + '" failed', err); continue; } if (!isSafe(out)) continue; const sig = tier + '|' + out.html; if (hst._sig === sig) continue; hst._sig = sig; dfDollar(hst).attr('data-tier', tier); // the label is drawn in its layer's colour const layer = c.p.layer || 'labels'; dfDollar(hst).attr('data-layer', layer); hst.style.setProperty('--graph-explorer-layer', this.colorOf(layer)); dfDollar(hst).attr('data-edge', String(e.k)); dfDollar(hst).attr('data-graph-explorer-edge', this.edgeId(e.k)); jobs.push(out); jobHosts.push(hst); } for (const h of this.labelHosts.values()) { h.hidden = true; h._sig = ''; this.labelPool.push(h); } this.labelHosts = next; if (jobs.length) { let lists: any[][]; try { lists = vnodesBatch(jobs); } catch (err) { console.error(err); lists = jobs.map(() => []); } lists.forEach((list, k) => morphRuntime.morph(jobHosts[k], list)); } this.camDirty = true; } edgePlugins(): any[] { return [...this.registry.edge, ...globalRegistry.edge].sort((a, b) => b._scoped - a._scoped || b._order - a._order); } projectDecor(): void { const m = this.view; if (!m) return; for (const [k, hst] of this.labelHosts) { const e = m.edges[k]; if (!e) { hst.hidden = true; continue; } const a = m.children[e.a], b = m.children[e.b], f = hst._f; const [ax, ay] = this.toScreen(a.x, a.y), [bx, by] = this.toScreen(b.x, b.y); hst.style.translate = `${Math.round(ax + (bx - ax) * f)}px ${Math.round(ay + (by - ay) * f)}px`; } } // ---------------------------------------------------- signposts (relations off screen) projectSigns(): void { const m = this.view, list: any[] = []; const inset = 14, w = this.w, h = this.h; const offscreen = (sx: number, sy: number): boolean => sx < inset || sx > w - inset || sy < inset || sy > h - inset; // the signposts point along relations: hidden with them const rel = this.shown.has('relations'); if (m.parent && rel) for (const ext of m.parent.external) { const [sx, sy] = this.toScreen(ext.x, ext.y); if (offscreen(sx, sy)) list.push({ key: 'x' + ext.id, sx, sy, label: ext.label, count: ext.out + ext.in, dir: ext.out && ext.in ? '↔' : ext.out ? '→' : '←', tone: ext.style.tone, goto: ext.id, ext: true, }); } const sel = rel ? [...this.selected].map((id) => m.byId.get(id)).filter((x) => x !== undefined) : []; for (const f of sel) for (const k of m.adj[f]) { const e = m.edges[k], j = e.a === f ? e.b : e.a, n = m.children[j]; const [sx, sy] = this.toScreen(n.x, n.y); if (offscreen(sx, sy)) list.push({ key: 'n' + n.id, sx, sy, label: n.label, count: e.count + e.count2, dir: e.a === f ? '→' : '←', tone: e.style.tone, goto: n.id, ext: false, }); } list.sort((a, b) => b.count - a.count); const top = list.slice(0, 10); const keys = top.map((s) => s.key + s.tone).join(','); if (keys !== this.signKeys) { this.signKeys = keys; morph( this.signs, html`${top.map( (s) => html`<button type="button" class="graph-explorer-sign" key="${s.key}" data-graph-explorer-goto="${s.goto}" data-ext="${s.ext ? '1' : '0'}" data-tone="${s.tone}" title="Go to ${s.label}" > ${s.dir} ${s.label} · ${fmt(s.count)} </button>`, )}`, ); } const els = dfDollar(this.signs).children().toArray() as HTMLElement[]; const cx = w / 2, cy = h / 2; top.forEach((s, j) => { const el = els[j]; if (!el) return; const dx = s.sx - cx, dy = s.sy - cy, t = Math.min((w / 2 - inset - 60) / Math.abs(dx || 1e-6), (h / 2 - inset - 12) / Math.abs(dy || 1e-6)); el.style.translate = `${Math.round(cx + dx * t)}px ${Math.round(cy + dy * t)}px`; }); } projectFocusRing(): void { const i = this.kbd; if (i < 0 || this.slots.has(i) || !this.view.children[i]) { this.focusRing.hidden = true; return; } const n = this.view.children[i], [sx, sy] = this.toScreen(n.x, n.y), r = Math.max(6, drawR(!this.view.leafView, n.r * this.cam.z)) + 4; this.focusRing.hidden = false; this.focusRing.style.translate = `${Math.round(sx - r)}px ${Math.round(sy - r)}px`; this.focusRing.style.width = this.focusRing.style.height = Math.round(r * 2) + 'px'; } // ---------------------------------------------------- semantic depth: dive / up autoDepth(): void { const m = this.view, c = this.cam, vmin = this.vmin; if (!m || this.loading || this.syncing) return; if (m.parent && 2 * c.z < 0.38 * vmin) { void this.up(false); return; } // panned off this cluster's disk: hand over to the parent frame, which shows the neighbours with their contents // and dives into one once it is centered and big enough (4% hysteresis) if (m.parent && Math.hypot(c.cx, c.cy) > 1.04) { void this.up(false); return; } if (m.leafView) return; for (const n of m.children) { const d = Math.hypot(n.x - c.cx, n.y - c.cy); if (d > n.r) continue; const dia = 2 * n.r * c.z; if (dia >= 0.45 * vmin) void this.fetchView(n.id); if (dia >= 0.85 * vmin) { void this.rebaseDown(n.i); return; } } } async rebaseDown(i: number): Promise<void> { const n = this.view.children[i]; if (this.diving === n.id) return; this.diving = n.id; const child = await this.fetchView(n.id); this.diving = null; if (!this.view || this.view.children[i]?.id !== n.id) return; const conv = (cam) => cam && { ...cam, cx: (cam.cx - n.x) / n.r, cy: (cam.cy - n.y) / n.r, z: cam.z * n.r }; const keep = this.selected; this.cam = conv(this.cam); this.bitmapCam = conv(this.bitmapCam); if (this.flight) this.flight = null; this.selected = new Set([...keep].filter((id) => child.byId.has(id))); this.setView(child); this.camChanged(); } async up(animate = true): Promise<boolean> { const m = this.view; if (!m || !m.parent || this.upping) return false; this.upping = true; const parent = await this.fetchView(m.parent.id); this.upping = false; if (this.view !== m) return false; const p = m.parent; const conv = (cam) => cam && { ...cam, cx: p.x + cam.cx * p.r, cy: p.y + cam.cy * p.r, z: cam.z / p.r }; this.cam = conv(this.cam); this.bitmapCam = conv(this.bitmapCam); this.flight = null; this.setView(parent); this.kbd = parent.byId.get(m.id) ?? -1; this.camChanged(); if (animate) await this.flyTo({ cx: 0, cy: 0, z: this.fitZ() }); return true; } async dive(i: number, animate = true): Promise<void> { const n = this.view.children[i]; if (!n || n.kind !== 'cluster') return; void this.fetchView(n.id); await this.flyTo({ cx: n.x, cy: n.y, z: (0.9 * this.vmin) / (2 * n.r) }, { animate }); if (this.view.children[i] && this.view.children[i].id === n.id) await this.rebaseDown(i); } fitZ(): number { if (!this.w) this.size(); return this.vmin / 2 / 1.04; } fit(animate = true): Promise<string> { return this.flyTo({ cx: 0, cy: 0, z: this.fitZ() }, { animate }); } flyTo(target: GraphCamera, opts: { animate?: boolean; ms?: number } = {}): Promise<string> { if (!this.w) this.size(); const animate = opts.animate !== false && !reducedMotion(); if (this.flight) this.flight.done('interrupted'); if (!animate) { Object.assign(this.cam, target); this.camChanged(); return Promise.resolve('done'); } const c = this.cam, path = zoomPath([c.cx, c.cy, this.vmin / c.z], [target.cx, target.cy, this.vmin / target.z]); return new Promise((resolve) => { this.flight = { path, start: performance.now(), ms: opts.ms || path.ms, done: (r: string) => { this.flight = null; resolve(r); }, }; this.requestTick(); }); } stepFlight(now: number): void { const f = this.flight, t = clamp((now - f.start) / f.ms, 0, 1), [cx, cy, w] = f.path.at(ease(t)); this.cam.cx = cx; this.cam.cy = cy; this.cam.z = this.vmin / w; this.camChanged(); if (t >= 1) f.done('done'); } stopFlight(): void { if (this.flight) this.flight.done('interrupted'); } async navigateTo(id: string, opts: { enter?: boolean; select?: boolean; animate?: boolean } = {}): Promise<void> { if (!id) return; await this.ready; this.navigating = true; const animate = opts.animate !== false; try { const path = await this.waitFor('path:' + id, () => this.post({ t: 'path', id })); if (!path || !path.length) return; const ids = path.map((p) => p.id); // a node is selected among its parent's children: going to the view itself (a trail's last cluster) climbs const above = opts.enter ? ids : ids.slice(0, -1); let climbed = false; while (!above.includes(this.view.id)) { if (!(await this.up(false))) break; climbed = true; } // after a climb the camera still shows the child it left, as big as when it was entered: fit the level first, // or the zoom dives straight back into it if (climbed) await this.fit(animate); let depth = ids.indexOf(this.view.id); while (depth < ids.length - 2) { const nextId = ids[depth + 1], i = this.view.byId.get(nextId); if (i === undefined) break; await this.dive(i, animate); depth = ids.indexOf(this.view.id); } const i = this.view.byId.get(id); if (i !== undefined && opts.enter && this.view.children[i].kind === 'cluster') { await this.dive(i, animate); await this.fit(animate); } else if (i !== undefined) { if (opts.select !== false) this.select([id]); this.kbd = i; await this.focusNode(i, animate); } } finally { this.navigating = false; this.requestTick(); if (this.expanded) this.commitState(); } } focusNode(i: number, animate = true): Promise<string> { const n = this.view.children[i], want = this.view.leafView ? 90 / (2 * n.r) : Math.min((0.6 * this.vmin) / (2 * n.r), (260 / (2 * n.r)) * 1.2); return this.flyTo({ cx: n.x, cy: n.y, z: Math.max(this.cam.z, want) }, { animate }); } // ---------------------------------------------------- selection, hover, the primary action /** the view's edge index of a link id (either direction), or -1 */ edgeIndex(id: string | null): number { if (!id || !this.view?.edgeById) return -1; const [a, b] = id.split('>'); return this.view.edgeById.get(a + '>' + b) ?? this.view.edgeById.get(b + '>' + a) ?? -1; } /** the id of the view's edge k: '<source id>><target id>' */ edgeId(k: number): string { const e = this.view.edges[k]; return this.view.children[e.a].id + '>' + this.view.children[e.b].id; } /** the selected link's ends (view indexes), none without one */ edgeEnds(): number[] { const k = this.edgeIndex(this.edge); if (k < 0) return []; const e = this.view.edges[k]; return [e.a, e.b]; } /** selects a link of the view (null clears it); the node selection goes */ selectEdge(id: string | null): void { const k = this.edgeIndex(id); const next = k >= 0 ? this.edgeId(k) : null; if (next === this.edge && !this.selected.size) return; this.edge = next; this.selected = new Set(); // a picked link takes the keyboard focus from the node that had it, so E expands the link if (next) this.kbd = -1; this.afterSelection(); } /** the selected link's whole page: its detail renderer in expanded mode, the details outlet's Panel maximized */ expandEdge(user = true): void { if (!this.edge || !this.expandableEdge()) return; const panel = this.detailsPanel(); if (panel?.api && panel.api.getState().name !== 'maximized') panel.api.setState('maximized'); this.setExpanded(true, user); } select(ids: string[], additive = false): void { this.edge = null; const next = additive ? new Set(this.selected) : new Set<string>(); for (const id of ids) { if (additive && next.has(id)) next.delete(id); else next.add(id); } this.selected = next; this.afterSelection(); } afterSelection(): void { // the keyboard follows a single selection (a click, select(), setState): E, Enter and the arrows start from the // node just picked - E once expanded the node a search had flown to while setState had selected another const only = this.selected.size === 1 ? this.view?.byId.get([...this.selected][0]) : undefined; if (only !== undefined) this.kbd = only; this.applyTiers(this.tiers); this.renderPanel(); this.renderOutline(); // an expanded page stays while a drill-down moves to the next node if (!this.selected.size && !this.edge && this.expanded && !this.navigating) this.collapseDetails(); this.commitState(); // the selection changed - by a click, a key, a search result or setState this.el.dispatchEvent( new CustomEvent<GraphExplorerSelectDetail>('graph-explorer-select', { detail: { selected: [...this.selected], edge: this.edge }, bubbles: true, }), ); this.camChanged(); } setHover(i: number): void { if (i === this.hover) return; this.hover = i; this.el.toggleAttribute('data-dim', this.hover >= 0 || this.selected.size > 0 || !!this.edge); for (const [j, s] of this.slots) s.toggleAttribute('data-hl', this.isHighlighted(j)); this.camChanged(); } primary(i: number): void { const n = this.view.children[i]; if (!n) return; if (n.kind === 'cluster') void this.dive(i, true); else this.expandNode(i); } nodeAt(ev: PointerEvent): number { const slot = (ev.target as Element)?.closest?.('.graph-explorer-node') as any; if (slot && slot._node) return this.view.byId.get(slot._node) ?? -1; const r = this.el.getBoundingClientRect(), px = ev.clientX - r.left, py = ev.clientY - r.top; const c = this.cam, wx = (px - this.w / 2) / c.z + c.cx, wy = (py - this.h / 2) / c.z + c.cy; let best = -1, bestR = Infinity, bestD = Infinity; for (const n of this.view.children) { const d = Math.hypot(n.x - wx, n.y - wy); if (d <= n.r && n.r < bestR) { best = n.i; bestR = n.r; bestD = 0; } else if (best < 0 || bestD > 0) { const ds = (d - n.r) * c.z; if (ds < 8 && ds < bestD) { best = n.i; bestD = ds; } } } return best; } // ---------------------------------------------------- jump to a neighbour (the Bring & Go ring) bringAndGo(i: number): void { const m = this.view, n = m.children[i]; if (!n) return; const nbrs = m.adj[i] .map((k) => { const e = m.edges[k]; return { j: e.a === i ? e.b : e.a, count: e.count + e.count2, tone: e.style.tone }; }) .sort((a, b) => b.count - a.count) .slice(0, 12); if (!nbrs.length) { this.announce(`${n.label} has no neighbours at this level`); return; } this.ringFor = { i, nbrs, opened: performance.now() }; morph( this.ring, html`<span class="graph-explorer-ring-center" aria-hidden="true"></span>${nbrs.map( (b, k) => html`<button type="button" class="graph-explorer-ring-item" key="r${b.j}" data-graph-explorer-ring="${b.j}" data-tone="${b.tone}" style="--k:${k}" > ${m.children[b.j].label}<span class="graph-explorer-count">${fmt(b.count)}</span> </button>`, )}`, ); this.ring.hidden = false; this.ringEls = dfDollar(this.ring).find('.graph-explorer-ring-item').toArray(); this.ringStart = true; this.projectRing(); requestAnimationFrame(() => { this.ringStart = false; this.projectRing(); }); this.announce(`Jump to a neighbour: ${nbrs.length} neighbours of ${n.label} around it. Choose one to fly there.`); this.ringEls[0]?.focus({ preventScroll: true }); } projectRing(): void { const r = this.ringFor; if (!r) return; const n = this.view.children[r.i], [cx, cy] = this.toScreen(n.x, n.y); const center = dfDollar(this.ring).find('.graph-explorer-ring-center').get(0); if (center) center.style.translate = `${Math.round(cx)}px ${Math.round(cy)}px`; const R = 120 + Math.max(0, r.nbrs.length - 8) * 8; r.nbrs.forEach((b, k) => { const el = this.ringEls[k]; if (!el) return; let x: number, y: number; if (this.ringStart) { const t = this.view.children[b.j]; [x, y] = this.toScreen(t.x, t.y); x = clamp(x, 0, this.w); y = clamp(y, 0, this.h); } else { const a = -Math.PI / 2 + (k / r.nbrs.length) * Math.PI * 2; x = cx + Math.cos(a) * R; y = cy + Math.sin(a) * R; } el.style.translate = `${Math.round(x)}px ${Math.round(y)}px`; }); } closeRing(): boolean { if (this.ring.hidden) return false; this.ring.hidden = true; this.ringFor = null; morph(this.ring, []); return true; } async travel(j: number): Promise<void> { this.closeRing(); const n = this.view.children[j]; if (!n) return; this.kbd = j; this.select([n.id]); await this.focusNode(j); this.el.focus({ preventScroll: true }); } // ---------------------------------------------------- events bindEvents(): void { const vp = this.el, pointers = new Map(); let drag: any = null, lastTap: { i: number; t: number; edge?: string } = { i: -2, t: 0 }, pinch: any = null, press: any = 0; const inChrome = (t: Element): boolean => !!t.closest( '.graph-explorer-sign, .graph-explorer-ring-item, .graph-explorer-help, [data-graph-explorer-action], button, a, input', ); dfDollar(vp).on('pointerdown', (ev: PointerEvent) => { if (inChrome(ev.target as Element) || !this.view) return; vp.focus({ preventScroll: true }); this.stopFlight(); pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); vp.setPointerCapture?.(ev.pointerId); if (pointers.size === 2) { const [p, q] = [...pointers.values()]; pinch = { d: Math.hypot(p.x - q.x, p.y - q.y), z: this.cam.z }; drag = null; clearTimeout(press); return; } // a link label is drawn over the nodes: under it the link wins; a line's wide hit path yields to a node const label = (ev.target as Element).closest?.('.graph-explorer-edge-label[data-graph-explorer-edge]'); const i = label ? -1 : this.nodeAt(ev); const edge = label ?? (i < 0 ? (ev.target as Element).closest?.('[data-graph-explorer-edge]') : null); drag = { x: ev.clientX, y: ev.clientY, sx: ev.clientX, sy: ev.clientY, moved: false, i, edge: edge?.getAttribute('data-graph-explorer-edge') ?? null, }; clearTimeout(press); if (i >= 0) press = setTimeout(() => { if (drag && !drag.moved) { drag.longPressed = true; this.bringAndGo(i); } }, 550); }); dfDollar(vp).on('pointermove', (ev: PointerEvent) => { if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); if (pinch && pointers.size === 2) { const [p, q] = [...pointers.values()], d = Math.hypot(p.x - q.x, p.y - q.y), r = vp.getBoundingClientRect(); this.zoomAt((p.x + q.x) / 2 - r.left, (p.y + q.y) / 2 - r.top, (pinch.z * d) / pinch.d / this.cam.z); return; } if (drag) { const dx = ev.clientX - drag.x, dy = ev.clientY - drag.y; if (!drag.moved && Math.hypot(ev.clientX - drag.sx, ev.clientY - drag.sy) > 5) { drag.moved = true; clearTimeout(press); vp.setAttribute('data-panning', ''); } if (drag.moved && !drag.longPressed) { this.cam.cx -= dx / this.cam.z; this.cam.cy -= dy / this.cam.z; this.camChanged(); } drag.x = ev.clientX; drag.y = ev.clientY; return; } if (ev.pointerType === 'mouse' && this.view && !inChrome(ev.target as Element)) this.setHover((ev.target as Element).closest?.('.graph-explorer-edge-label') ? -1 : this.nodeAt(ev)); }); const end = (ev: PointerEvent): void => { pointers.delete(ev.pointerId); if (pointers.size < 2) pinch = null; clearTimeout(press); vp.removeAttribute('data-panning'); const d = drag; drag = null; if (!d || d.moved || !this.view) return; if (d.longPressed) { const item = document.elementFromPoint(ev.clientX, ev.clientY)?.closest('.graph-explorer-ring-item'); if (item) void this.travel(+(item.getAttribute('data-graph-explorer-ring') ?? -1)); return; } if (this.closeRing()) return; const i = d.i, now = performance.now(); if (i < 0 && d.edge) { const twice = lastTap.i === -3 && lastTap.edge === d.edge && now - lastTap.t < 320; lastTap = { i: -3, t: now, edge: d.edge }; this.selectEdge(d.edge); if (twice) this.expandEdge(); else this.showDetails(); return; } if (i >= 0 && lastTap.i === i && now - lastTap.t < 320) { lastTap = { i: -2, t: 0 }; this.primary(i); return; } lastTap = { i, t: now }; if (i >= 0) { this.kbd = i; this.select([this.view.children[i].id], ev.shiftKey); this.showDetails(); } else if (this.selected.size) this.select([]); }; dfDollar(vp).on('pointerup', end); dfDollar(vp).on('pointercancel', end); dfDollar(vp).on('pointerleave', (ev: PointerEvent) => { if (ev.pointerType === 'mouse' && !drag) this.setHover(-1); }); // a wheel zooms the graph, never the page: passive false to cancel the scroll vp.addEventListener( 'wheel', (ev: WheelEvent) => { if (!this.view) return; ev.preventDefault(); this.stopFlight(); const r = vp.getBoundingClientRect(), k = ev.ctrlKey ? 0.012 : 0.0016; this.zoomAt(ev.clientX - r.left, ev.clientY - r.top, Math.exp(-ev.deltaY * (ev.deltaMode === 1 ? 16 : 1) * k)); }, { passive: false }, ); dfDollar(vp).on('keydown', (ev: KeyboardEvent) => this.onKey(ev)); // the clicks inside the explorer (cards, signposts, the ring); the outlets' clicks come through the document dfDollar(vp).on('click', (ev: MouseEvent) => this.onClick(ev)); this.resizeObserver = new ResizeObserver(() => { this.size(); this.camChanged(); }); this.resizeObserver.observe(vp); // the attributes are the state: written from outside (a page, the HTML Preview Editor), the view follows this.attrObserver = new MutationObserver(() => void this.sync()); this.attrObserver.observe(vp, { attributes: true, attributeFilter: [ 'data-focus', 'data-selected', 'data-selected-edge', 'data-layers', 'data-compact', 'data-expanded', 'data-hud', ], }); } /** a click on an action, a goto, a crumb, a result, an outline row, a layer switch, a signpost or a ring item */ onClick(ev: MouseEvent): void { const t = ev.target as Element; if (!t?.closest) return; const act = t.closest('[data-graph-explorer-action]'); if (act) { const slot = act.closest('.graph-explorer-node') as any; const id = slot ? slot._node : ([...this.selected][0] ?? null); void this.act(act.getAttribute('data-graph-explorer-action') ?? '', id, act as HTMLElement); return; } const upd = t.closest('[data-graph-explorer-update]'); if (upd) { void this.openUpdate(upd.getAttribute('data-graph-explorer-update') ?? ''); return; } const layer = t.closest('[data-graph-explorer-layer]'); if (layer) { this.toggleLayer(layer.getAttribute('data-graph-explorer-layer') ?? ''); return; } if (t.closest('[data-graph-explorer-compact]')) { this.setCompact(!this.compact); return; } const ring = t.closest('[data-graph-explorer-ring]'); if (ring) { void this.travel(+(ring.getAttribute('data-graph-explorer-ring') ?? -1)); return; } const link = t.closest('[data-graph-explorer-edge]'); if (link && this.view) { this.selectEdge(link.getAttribute('data-graph-explorer-edge')); this.showDetails(true); return; } const goto = t.closest('[data-graph-explorer-goto]'); if (goto && this.view) { const id = goto.getAttribute('data-graph-explorer-goto') ?? ''; // an expanded page drills down: the next node's page replaces it, the graph moves along unseen if (this.expanded) void this.navigateTo(id, { animate: false }); else if (goto.getAttribute('data-ext') === '1') void this.goSibling(id); else { const i = this.view.byId.get(id); if (i !== undefined) void this.travel(i); else void this.navigateTo(id); } return; } const crumb = t.closest('[data-graph-explorer-crumb]'); if (crumb) { ev.preventDefault(); void this.upTo(crumb.getAttribute('data-graph-explorer-crumb') ?? 'c0'); return; } const res = t.closest('[data-graph-explorer-result]'); if (res) { this.chooseResult(+(res.getAttribute('data-graph-explorer-result') ?? -1)); return; } const oi = t.closest('[data-graph-explorer-outline]'); if (oi && this.view) { const i = +(oi.getAttribute('data-graph-explorer-outline') ?? -1); if (i >= 0) { this.kbd = i; this.select([this.view.children[i].id]); void this.focusNode(i); } } } /** an action: on a node (enter, bring, details, expand) or on the explorer (collapse, up, fit, live, help) */ async act(action: string, id: string | null, button?: HTMLElement): Promise<void> { if (!this.view) return; const i = id ? this.view.byId.get(id) : undefined; const own = ['up', 'fit', 'live', 'help', 'collapse'].includes(action); // an action button was used this.el.dispatchEvent( new CustomEvent<GraphExplorerActionDetail>('graph-explorer-action', { detail: { action, id: own ? null : id }, bubbles: true, }), ); if (action === 'up') await this.up(true); else if (action === 'fit') await this.fit(true); else if (action === 'help') this.help.togglePopover(); else if (action === 'live') { const on = !this.simulating; this.simulating = on; this.post({ t: 'simulate', on }); for (const b of boundOf(this.el, '[data-graph-explorer-action="live"]')) dfDollar(b).attr('aria-pressed', String(on)); if (button && !button.closest(`[${FOR}]`)) dfDollar(button).attr('aria-pressed', String(on)); } else if (action === 'collapse') this.collapseDetails(); else if (i === undefined) { if (action === 'expand' && this.edge) this.expandEdge(); } else if (action === 'enter') await this.dive(i, true); else if (action === 'bring') this.bringAndGo(i); else if (action === 'details') { this.select([id as string]); this.showDetails(true); } else if (action === 'expand') this.expandNode(i); } zoomAt(px: number, py: number, f: number): void { const c = this.cam, m = this.view; if (!m) return; const wx = (px - this.w / 2) / c.z + c.cx, wy = (py - this.h / 2) / c.z + c.cy; let minZ = this.vmin * 0.05, maxZ = this.vmin * 40; if (!m.parent) minZ = this.fitZ() / 3; if (m.leafView) maxZ = 240 / (m.children[0] ? m.children[0].r : 0.02); c.z = clamp(c.z * f, minZ, maxZ); c.cx = wx - (px - this.w / 2) / c.z; c.cy = wy - (py - this.h / 2) / c.z; this.camChanged(); } async goSibling(id: string): Promise<void> { await this.up(false); const i = this.view.byId.get(id); if (i !== undefined) await this.travel(i); } async upTo(id: string, animate = true): Promise<void> { while (this.view.id !== id && this.view.parent) { if (!(await this.up(false))) break; } await this.fit(animate); } onKey(ev: KeyboardEvent): void { const m = this.view, t = ev.target as Element; if (!m) return; if (ev.key === '/' && !t.closest('input, textarea')) { const input = this.searchInputs()[0]; if (input) { ev.preventDefault(); input.focus(); input.select(); } return; } if (t !== this.el && !t.closest('.graph-explorer-ring')) return; const key = ev.key, pan = (0.15 * this.vmin) / this.cam.z; const dirs: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1], }; if (dirs[key]) { ev.preventDefault(); this.stopFlight(); const [dx, dy] = dirs[key]; if (ev.shiftKey || !this.moveFocus(dx, dy)) { this.cam.cx += dx * pan; this.cam.cy += dy * pan; this.camChanged(); } return; } if (key === 'Enter') { ev.preventDefault(); if (this.kbd >= 0) this.primary(this.kbd); return; } if (key === ' ') { ev.preventDefault(); if (this.kbd >= 0) { this.select([m.children[this.kbd].id], ev.shiftKey); this.showDetails(true); } return; } if (key === 'e' || key === 'E') { ev.preventDefault(); const i = this.kbd >= 0 ? this.kbd : m.byId.get([...this.selected][0]); if (this.edge && !this.selected.size && this.kbd < 0) this.expandEdge(); else if (i !== undefined && i >= 0) this.expandNode(i); return; } // 1 - 9: show or hide the k-th layer if (/^[1-9]$/.test(key) && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { const l = this.layerDefs()[+key - 1]; if (l) { ev.preventDefault(); this.toggleLayer(l.name); } return; } if (key === 'Escape') { ev.preventDefault(); if (this.closeRing()) { this.el.focus(); return; } if (this.help.matches(':popover-open')) { this.help.hidePopover(); return; } if (this.expanded) { this.collapseDetails(); return; } const host = this.el.closest('.panel'); if (host?.hasAttribute('data-maximized') && host.api) { host.api.setState('default'); return; } if (this.edge) { this.selectEdge(null); return; } void this.up(true); return; } if (key === 'Backspace') { ev.preventDefault(); void this.up(true); return; } if (key === '0') { ev.preventDefault(); void this.fit(true); return; } if (key === '+' || key === '=') { ev.preventDefault(); this.zoomAt(this.w / 2, this.h / 2, 1.35); return; } if (key === '-' || key === '_') { ev.preventDefault(); this.zoomAt(this.w / 2, this.h / 2, 1 / 1.35); return; } if (key === ']' || key === '[') { ev.preventDefault(); this.cycleNeighbor(key === ']' ? 1 : -1); return; } if (key === 'b' || key === 'B') { ev.preventDefault(); if (this.kbd >= 0) this.bringAndGo(this.kbd); return; } if (key === '?') { ev.preventDefault(); this.help.togglePopover(); } } moveFocus(dx: number, dy: number): boolean { const m = this.view, c = this.cam; const from = this.kbd >= 0 ? m.children[this.kbd] : { x: c.cx, y: c.cy, i: -1 }; let best = -1, score = Infinity; for (const n of m.children) { if (n.i === from.i || !this.tiers || this.tiers[n.i] < 0) continue; const vx = n.x - from.x, vy = n.y - from.y, d = Math.hypot(vx, vy); if (d < 1e-9) continue; const cos = (vx * dx + vy * dy) / d; if (cos < 0.5) continue; const s = d * (2 - cos); if (s < score) { score = s; best = n.i; } } if (best < 0) return false; this.setKbd(best); return true; } setKbd(i: number): void { this.kbd = i; const m = this.view, n = m.children[i]; for (const [j, s] of this.slots) dfDollar(s).attr('data-kbd', j === i ? '' : null); const [sx, sy] = this.toScreen(n.x, n.y); if (sx < 40 || sx > this.w - 40 || sy < 40 || sy > this.h - 40) void this.flyTo({ cx: n.x, cy: n.y, z: this.cam.z }, { ms: 180 }); this.announce( `${n.label} · ${n.typeName} · ${n.kind === 'cluster' ? fmt(n.members) + ' nodes' : n.degree + ' links'} · ${i + 1} of ${m.children.length} in ${m.path[m.path.length - 1].label}${n.scc > 1 ? ' · in a cycle of ' + n.scc : ''}`, ); this.camChanged(); } cycleNeighbor(dir: number): void { const m = this.view; if (this.kbd < 0) return; if (!this.nbrCursor || this.nbrCursor.of !== this.kbd) { const list = m.adj[this.kbd] .map((k) => { const e = m.edges[k]; return { j: e.a === this.kbd ? e.b : e.a, c: e.count + e.count2 }; }) .sort((a, b) => b.c - a.c) .map((x) => x.j); this.nbrCursor = { of: this.kbd, list, at: -1 }; } const cur = this.nbrCursor; if (!cur.list.length) { this.announce('No neighbours at this level'); return; } cur.at = (cur.at + dir + cur.list.length) % cur.list.length; const origin = cur.of; this.setKbd(cur.list[cur.at]); this.nbrCursor = cur; cur.of = origin; this.kbd = cur.list[cur.at]; } // ---------------------------------------------------- the search outlet searchInputs(): HTMLInputElement[] { return this.outlets('search').map((o) => this.searchBox(o).input); } searchBox(outlet: HTMLElement): { input: HTMLInputElement; list: HTMLElement } { const id = this.uid; const box = outletChrome(outlet, 'search', () => { const made = dfDollar(`<div class="graph-explorer-search" role="search" data-ce-chrome></div>`).get( 0, ) as HTMLElement; morph( made, html`${icon('search', { cls: 'graph-explorer-search-icon' })}<input class="input graph-explorer-search-input" data-size="sm" type="search" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="${id}-results" autocomplete="off" spellcheck="false" enterkeyhint="search" placeholder="Search ${fmt(this.n)} nodes ( / )" aria-label="Search the graph" /> <ul class="graph-explorer-results" id="${id}-results" role="listbox" aria-label="Search results" hidden ></ul>`, ); const input = dfDollar(made).find('input').get(0) as HTMLInputElement; let deb: any = 0; // the scan runs in the worker; the next keystroke aborts it dfDollar(input).on('input', () => { clearTimeout(deb); deb = setTimeout(() => { this.searchSeq++; this.post({ t: 'search', q: input.value, seq: this.searchSeq }); }, 140); }); dfDollar(input).on('keydown', (ev: KeyboardEvent) => { if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { ev.preventDefault(); this.moveResult(ev.key === 'ArrowDown' ? 1 : -1); } else if (ev.key === 'Enter') { ev.preventDefault(); this.chooseResult(this.activeResult >= 0 ? this.activeResult : 0); } else if (ev.key === 'Escape') { ev.preventDefault(); if (!this.closeResults()) this.el.focus(); } }); dfDollar(input).on('blur', () => setTimeout(() => { if (document.activeElement !== input) this.closeResults(); }, 150), ); // a press on a result keeps the input's focus until the click chooses it dfDollar(made).on('pointerdown', (ev: PointerEvent) => { if ((ev.target as Element).closest('[data-graph-explorer-result]')) ev.preventDefault(); }); return made; }); return { input: dfDollar(box).find('input').get(0) as HTMLInputElement, list: dfDollar(box).find('ul').get(0) as HTMLElement, }; } onSearch(m: any): void { if (m.seq !== this.searchSeq) return; const finder = this.finder; if (finder && finder.seq === m.seq) { finder.results = m.results; if (m.partial) finder.onPartial?.(m.results, m.scanned); else { this.finder = null; finder.resolve(m.results); } return; } this.results = m.results; this.activeResult = m.results.length ? 0 : -1; const id = this.uid; for (const outlet of this.outlets('search')) { const { input, list } = this.searchBox(outlet); morph( list, html`${m.results.map( (r, k) => html`<li role="option" id="${id}-r${k}" key="r${r.id}" data-graph-explorer-result="${k}" aria-selected="${k === this.activeResult ? 'true' : 'false'}" > <span class="graph-explorer-result-label">${r.label}</span ><span class="graph-explorer-result-path">${r.type}${r.path ? ' · ' + r.path : ''}</span> </li>`, )} ${m.partial ? html`<li class="graph-explorer-result-more" aria-hidden="true">scanning... ${fmt(m.scanned)} / ${fmt(this.n)}</li>` : ''} ${!m.partial && !m.results.length && m.q ? html`<li class="graph-explorer-result-more">No matches</li>` : ''}`, ); const open = !!m.q && document.activeElement === input; list.hidden = !open; dfDollar(input).attr('aria-expanded', open ? 'true' : 'false'); dfDollar(input).attr( 'aria-activedescendant', open && this.activeResult >= 0 ? `${id}-r${this.activeResult}` : null, ); } } moveResult(d: number): void { if (!this.results.length) return; this.activeResult = (this.activeResult + d + this.results.length) % this.results.length; for (const outlet of this.outlets('search')) { const { input, list } = this.searchBox(outlet); dfDollar(list) .find('[role="option"]') .each((k, el) => { dfDollar(el).attr('aria-selected', k === this.activeResult ? 'true' : 'false'); }); dfDollar(input).attr('aria-activedescendant', `${this.uid}-r${this.activeResult}`); } } chooseResult(k: number): void { const r = this.results[k]; if (!r) return; this.closeResults(); this.el.focus({ preventScroll: true }); void this.navigateTo(r.id); } closeResults(): boolean { let closed = false; for (const outlet of this.outlets('search')) { const { input, list } = this.searchBox(outlet); if (list.hidden) continue; list.hidden = true; dfDollar(input).attr('aria-expanded', 'false'); dfDollar(input).attr('aria-activedescendant', null); closed = true; } return closed; } // ---------------------------------------------------- outlets: details, relations, outline, breadcrumb outlets(kind: string): HTMLElement[] { return boundOf(this.el, `[data-graph-explorer-outlet="${kind}"]`); } outputs(kind: string): HTMLElement[] { return boundOf(this.el, `[data-graph-explorer-output="${kind}"]`); } /** the Panel that holds the details outlet (Expand maximizes it) */ detailsPanel(): HTMLElement | null { return (this.outlets('details')[0]?.closest('.panel') as HTMLElement) ?? null; } /** shows the details outlet's Panel: opens it when closed; `restore` also unfolds it when minimized */ showDetails(restore = false): void { const panel = this.detailsPanel(); if (!panel?.api) return; const state = panel.api.getState().name; if (state === 'closed' || (restore && state === 'minimized')) panel.api.setState('default'); } /** the node's whole page: its detail renderer in expanded mode, the details outlet's Panel maximized */ expandNode(i: number, user = true): void { const n = this.view?.children[i]; if (!n) return; this.kbd = i; if (!this.selected.has(n.id) || this.selected.size !== 1) this.select([n.id]); // no details outlet, or no page for this node: nothing to expand to if (!this.expandableNode(this.nodeOf(i))) return; const panel = this.detailsPanel(); if (panel?.api && panel.api.getState().name !== 'maximized') panel.api.setState('maximized'); this.setExpanded(true, user); } collapseDetails(user = true): void { const panel = this.detailsPanel(); if (panel?.api && panel.api.getState().name === 'maximized') panel.api.setState('default'); this.setExpanded(false, user); } setExpanded(on: boolean, user = true): void { if (this.expanded === on) return; this.expanded = on; this.renderPanel(); if (user) this.commitState(); // focus follows the expanded page, and comes back to the graph after if (!on) this.el.focus({ preventScroll: true }); else dfDollar(this.detailsPanel() ?? document) .find('.panel-maximize input') .get(0) ?.focus({ preventScroll: true }); } onPanelChange(ev: CustomEvent): void { const panel = ev.target as HTMLElement; if (panel === this.detailsPanel()) { const max = ev.detail?.state === 'maximized'; if (max !== this.expanded) { if (max && !this.selected.size && !this.edge) panel.api?.setState('default'); else this.setExpanded(max); } } if (this.outlets('relations').some((o) => o.closest('.panel') === panel) && ev.detail?.state !== 'minimized') this.renderRelations(); } renderPanel(): void { const m = this.view; if (this.edge && m) { this.renderEdgePanel(); return; } const id = [...this.selected][0], i = m && id ? m.byId.get(id) : undefined; const outlets = this.outlets('details'); if (i === undefined) { // a drill-down on an expanded page passes through views without a selection: the page waits for the next node if (this.expanded && this.navigating) return; this.writeOutput('details-title', null); for (const outlet of outlets) morph( this.chrome(outlet, 'details'), html`<div class="graph-explorer-empty"> ${icon('crosshair', { size: 'xl' })} <p><strong>Nothing selected</strong></p> <p class="graph-explorer-muted"> Click a node to see its details here. <kbd class="kbd">E</kbd> or the ${icon('expand', { size: 'xs' })} button expands a node's full page. </p> </div>`, ); if (this.expanded) this.collapseDetails(); this.renderRelations(); return; } const node = this.nodeOf(i), r = this.pick('detail', node); if (!r) return; this.writeOutput('details-title', r.title ? r.title(node) : node.label); if (outlets.length) { const ctx = this.ctxFor(node, 'detail', i); let out; try { out = r.render(node, ctx); } catch (err) { console.error('[graph-explorer] detail renderer "' + r.name + '" failed', err); return; } void Promise.resolve(out).then((safe) => { if ([...this.selected][0] !== id) return; for (const outlet of outlets) morphKeeping(this.chrome(outlet, 'details'), safe, this.islandMounts); }); } this.renderRelations(); } /** the selected link as its detail renderer gets it */ edgeItem(): GraphEdgeItem | null { const k = this.edgeIndex(this.edge); if (k < 0) return null; const m = this.view, e = m.edges[k]; return { kind: 'edge', id: this.edgeId(k), source: this.nodeOf(e.a), target: this.nodeOf(e.b), edge: e, depth: m.path.length - 1, }; } /** the edge plugin whose detail renderer describes a link best (the element's plugins first) */ pickEdgeDetail(item: GraphEdgeItem): GraphEdgeDetail | null { for (const reg of [this.registry, globalRegistry]) { let best: any = null, score = -Infinity; for (const p of reg.edge) { if (!p.detail) continue; const s = p.detail.match ? p.detail.match(item) : 0; if (s === false || s == null) continue; if (s > score || (s === score && p._order > best._order)) { best = p; score = s; } } if (best) return best.detail; } return null; } /** the details of the selected link */ renderEdgePanel(): void { const item = this.edgeItem(); if (!item) return; const r = this.pickEdgeDetail(item); if (!r) return; this.writeOutput('details-title', r.title ? r.title(item) : item.source.label + ' → ' + item.target.label); const outlets = this.outlets('details'); if (outlets.length) { const ctx = { ...this.ctxFor(item.source, 'detail', item.edge.a), expandable: this.expandableEdge(item) }; let out; try { out = r.render(item, ctx); } catch (err) { console.error('[graph-explorer] edge detail renderer failed', err); return; } const id = item.id; void Promise.resolve(out).then((safe) => { if (this.edge !== id) return; for (const outlet of outlets) morphKeeping(this.chrome(outlet, 'details'), safe, this.islandMounts); }); } this.renderRelations(); } /** the relations outlet: the selected node's relations, each edge drawn by the edge label renderers */ renderRelations(): void { const m = this.view, id = [...this.selected][0], i = m && id ? m.byId.get(id) : undefined; const outlets = this.outlets('relations'); const item = this.edge ? this.edgeItem() : null; if (item) { const ctx = this.ctxFor(item.source, 'relations', item.edge.a); const d = ctx.request('edge:' + item.id, () => ctx.fetchEdge(item.id)); this.writeOutput( 'relations-title', `${item.source.label} → ${item.target.label} · ${fmt(item.edge.count + item.edge.count2)}`, ); for (const outlet of outlets) { const panel = outlet.closest('.panel'); if (panel?.hasAttribute('data-minimized')) continue; morphKeeping( this.chrome(outlet, 'relations'), d ? linksTable(ctx, d) : html`<p class="graph-explorer-muted graph-explorer-pad">Loading...</p>`, this.islandMounts, ); } return; } if (i === undefined) { this.writeOutput('relations-title', null); for (const outlet of outlets) morph( this.chrome(outlet, 'relations'), html`<p class="graph-explorer-muted graph-explorer-pad">Select a node to list its relations.</p>`, ); return; } const node = this.nodeOf(i); const ctx = this.ctxFor(node, 'relations', i); const d = ctx.request('details:' + node.id, () => ctx.fetchDetails(node.id)); if (!d) { for (const outlet of outlets) morph( this.chrome(outlet, 'relations'), html`<p class="graph-explorer-muted graph-explorer-pad">Loading...</p>`, ); return; } const total = node.kind === 'cluster' ? (d.relations?.length ?? 0) : (d.outDeg ?? 0); this.writeOutput('relations-title', `${node.label} · ${fmt(total)}`); for (const outlet of outlets) { const panel = outlet.closest('.panel'); if (panel?.hasAttribute('data-minimized')) continue; // folded: the title says enough morphKeeping( this.chrome(outlet, 'relations'), relationsTable(ctx, node, d, panel?.hasAttribute('data-maximized') ? 'full' : 'label', 200), this.islandMounts, ); } } /** the chrome box of a details / relations outlet */ chrome(outlet: HTMLElement, kind: string): HTMLElement { return outletChrome( outlet, kind, () => dfDollar(`<div class="graph-explorer-${kind}" data-ce-chrome></div>`).get(0) as HTMLElement, ); } /** a readout: its text, or (null) the text it was authored with */ writeOutput(kind: string, text: string | null): void { for (const out of this.outputs(kind)) { if (out._graphExplorerText === undefined) out._graphExplorerText = dfDollar(out).text(); const next = text ?? out._graphExplorerText; if (dfDollar(out).text() !== next) dfDollar(out).text(next); } } renderOutline(): void { const m = this.view; if (!m) return; for (const outlet of this.outlets('outline')) { const box = outletChrome(outlet, 'outline', () => { const made = dfDollar('<div class="graph-explorer-outline" data-ce-chrome></div>').get(0) as HTMLElement; morph( made, html`<div class="graph-explorer-outline-head"> <input class="input graph-explorer-outline-filter" data-size="sm" type="search" placeholder="Filter this level" aria-label="Filter the outline" /> </div> <ul class="graph-explorer-outline-list" role="tree" aria-label="Current cluster"></ul>`, ); const list = dfDollar(made).find('ul').get(0) as HTMLElement; dfDollar(made) .find('input') .on('input', () => this.renderOutline()); dfDollar(list).on('keydown', (ev: KeyboardEvent) => { const items = dfDollar(list).find('[data-graph-explorer-outline]').toArray() as HTMLElement[], at = items.indexOf(document.activeElement as HTMLElement); if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { ev.preventDefault(); items[clamp(at + (ev.key === 'ArrowDown' ? 1 : -1), 0, items.length - 1)]?.focus(); } if (ev.key === 'Enter' && at >= 0) { ev.preventDefault(); const i = +(items[at].getAttribute('data-graph-explorer-outline') ?? -1); if (i >= 0) this.primary(i); else void this.upTo(items[at].getAttribute('data-graph-explorer-crumb') ?? 'c0'); } }); dfDollar(list).on('dblclick', (ev: MouseEvent) => { const oi = (ev.target as Element).closest('[data-graph-explorer-outline]'); const i = +(oi?.getAttribute('data-graph-explorer-outline') ?? -1); if (i >= 0) this.primary(i); }); return made; }); const filter = dfDollar(box).find('input').get(0) as HTMLInputElement; const list = dfDollar(box).find('ul').get(0) as HTMLElement; const q = (filter.value || '').trim().toLowerCase(); const kids = m.children .filter((c) => !q || c.label.toLowerCase().includes(q)) .sort((a, b) => b.degree + b.members - (a.degree + a.members)); const shown = kids.slice(0, 300); morph( list, html`${m.path.map( (p, depth) => html`<li role="treeitem" aria-level="${depth + 1}" aria-expanded="true" key="p${p.id}" class="graph-explorer-outline-path" style="--depth:${depth}" > <button type="button" class="graph-explorer-outline-item" data-graph-explorer-crumb="${p.id}" tabindex="-1" > ${p.label} </button> </li>`, )} ${shown.map( (c) => html`<li role="treeitem" aria-level="${m.path.length + 1}" aria-selected="${this.selected.has(c.id) ? 'true' : 'false'}" key="o${c.id}" style="--depth:${m.path.length}" > <button type="button" class="graph-explorer-outline-item" data-graph-explorer-outline="${c.i}" tabindex="-1" > <span class="graph-explorer-type" data-type="${c.type}"></span ><span class="graph-explorer-label">${c.label}</span> ${smallCycle(c) ? html`<span class="graph-explorer-cycle" title="cycle of ${c.scc}">↻</span>` : ''}<span class="graph-explorer-count" >${c.kind === 'cluster' ? fmt(c.members) : c.degree}</span > </button> </li>`, )} ${kids.length > shown.length ? html`<li class="graph-explorer-outline-more">${fmt(kids.length - shown.length)} more - refine the filter</li>` : ''} ${!kids.length ? html`<li class="graph-explorer-outline-more">No match at this level</li>` : ''}`, ); const items = dfDollar(list).find('.graph-explorer-outline-item').toArray() as HTMLElement[]; const first = items[m.path.length] || items[0]; if (first) first.tabIndex = 0; } } renderCrumbs(): void { const m = this.view, last = m.path.length - 1; for (const outlet of this.outlets('breadcrumb')) { const nav = outletChrome(outlet, 'path', () => { const made = dfDollar( '<nav class="breadcrumb graph-explorer-path" aria-label="Graph path" data-ce-chrome></nav>', ).get(0) as HTMLElement; morph(made, html`<ol class="breadcrumb-list"></ol>`); return made; }); morph( dfDollar(nav).find('ol').get(0) as HTMLElement, html`${m.path.map((p, k) => k === last ? html`<li class="breadcrumb-item" key="${p.id}"> <span class="breadcrumb-page" aria-current="page">${p.label}</span> </li>` : html`<li class="breadcrumb-item" key="${p.id}"> <a class="breadcrumb-link" href="#${p.id}" data-graph-explorer-crumb="${p.id}">${p.label}</a> </li> <li class="breadcrumb-separator" aria-hidden="true" key="s${p.id}">›</li>`, )}`, ); } this.writeOutput('view', m.path[last].label); } announce(text: string): void { clearTimeout(this.announceT); this.announceT = setTimeout(() => { const outs = this.outputs('status'); // a bound status readout is the live region; without one, the explorer's own (visually hidden) one for (const out of outs.length ? outs : [this.liveEl]) dfDollar(out).text(text); }, 120); } renderHud(): void { const s = this.stats, w = s.worker || {}, dom = dfDollar(this.el).find('*').toArray().length; dfDollar(this.hud).text( `graph ${fmt(this.genStats.nodes)} nodes · ${fmt(this.genStats.edges)} edges · gen ${fmt(this.genStats.ms)} ms\n` + `view ${this.view.path[this.view.path.length - 1].label} · ${this.view.children.length} children\n` + `DOM ${w.cards ?? 0} cards · ${w.chips ?? 0} chips · ${dom} elements in the explorer\n` + `canvas ${fmt(w.dots ?? 0)} dots · ${fmt(w.edges ?? 0)} edges · ${w.labels ?? 0} labels\n` + `worker ${w.ms ?? 0} ms/frame · ${s.fps} bitmaps/s · lag ${s.lag ?? 0}`, ); } // ---------------------------------------------------- live patches (the breaker simulation) onPatch(m: any): void { const v = this.views.get(m.view); if (!v) return; const touched = new Set<number>(); for (const p of m.edges) { const e = v.edges[p.k]; if (!e) continue; const was = e.breaker && e.breaker.state; e.breaker = p.breaker; e.style = p.style; e.traffic = p.traffic; if (was !== (e.breaker && e.breaker.state)) { touched.add(e.a); touched.add(e.b); } } if (m.external && v.parent) for (const x of m.external) { const ext = v.parent.external.find((q) => q.id === x.id); if (ext) { ext.breaker = x.breaker; ext.style = x.style; } } this.computeBreakerCounts(v); if (v !== this.view) return; for (const i of touched) { const id = v.children[i].id; this.dataRev.set(id, (this.dataRev.get(id) || 0) + 1); this.staleRequest('details:' + id); } // breakers that moved change the selection's details; a restyle (another layer shown) changes no number if (!m.restyle) for (const id of this.selected) this.staleRequest('details:' + id); this.signKeys = ''; this.applyTiers(this.tiers); if (this.selected.size) this.renderPanel(); for (const p of this.edgePaths.values()) p.tone = null; this.camChanged(); } /** the data behind a request moved on: its next use loads it again, showing the last answer until then */ staleRequest(key: string): void { const r = this.requests.get(key); if (r) r.stale = true; } // ---------------------------------------------------- updates: what changed /** a target's name in the view shown: a node's label, a link's 'A → B' */ labelOf(target: string): string { const m = this.view; if (!m) return target; if (target.includes('>')) { const k = this.edgeIndex(target); if (k < 0) return target.replace('>', ' → '); const e = m.edges[k]; return `${m.children[e.a].label} → ${m.children[e.b].label}`; } const i = m.byId.get(target); return i === undefined ? target : m.children[i].label; } /** adds changes: each marks its node or link and joins the Updates outlet (newest first) */ addUpdates(items: Array<Partial<GraphUpdate>>): GraphUpdate[] { const now = Date.now(), at = performance.now(); const added: GraphUpdate[] = []; for (const raw of items || []) { if (!raw || !raw.target) continue; const target = String(raw.target); const kind = raw.kind === 'edge' || (raw.kind !== 'node' && target.includes('>')) ? 'edge' : 'node'; const u: GraphUpdate = { id: raw.id ? String(raw.id) : `u${++this.updateSeq}`, time: Number(raw.time) || now, kind, target, layer: raw.layer ? String(raw.layer) : kind === 'edge' ? 'relations' : 'nodes', type: raw.type ? String(raw.type) : 'change', text: raw.text ? String(raw.text) : '', label: raw.label ? String(raw.label) : this.labelOf(target), }; if (kind === 'edge') u.view = raw.view ? String(raw.view) : (this.view?.id ?? 'c0'); if (raw.tone && TONES.includes(raw.tone)) u.tone = raw.tone; added.push(u); this.changes.set(target, { u, at }); } if (!added.length) return added; this.updates.unshift(...added.slice().reverse()); if (this.updates.length > UPDATE_LIMIT) this.updates.length = UPDATE_LIMIT; for (const u of added) // a node or a link changed - Live mode's simulation or update() this.el.dispatchEvent( new CustomEvent<GraphExplorerUpdateDetail>('graph-explorer-update', { detail: { update: u }, bubbles: true }), ); this.renderUpdates(added.filter((u) => this.shown.has(u.layer)).length); clearTimeout(this.changeT); this.changeT = setTimeout(() => this.pruneChanges(), CHANGE_MS + 50); if (this.view) { this.projectEdges(); this.projectChanges(); } return added; } /** the marks of changes older than CHANGE_MS go */ pruneChanges(): void { const old = performance.now() - CHANGE_MS; for (const [t, ch] of this.changes) if (ch.at <= old) this.changes.delete(t); if (this.changes.size) this.changeT = setTimeout(() => this.pruneChanges(), 500); if (this.view && !this.destroyed) { this.projectEdges(); this.projectChanges(); } } /** a glowing dot at the top right of every changed node in view (its slot's corner, else its circle's) */ projectChanges(): void { const m = this.view; if (!m || !this.changesEl) return; const keep = new Set<string>(); const z = this.cam.z, isCluster = !m.leafView; for (const [target, ch] of this.changes) { if (ch.u.kind !== 'node' || !this.shown.has(ch.u.layer)) continue; const i = m.byId.get(target); if (i === undefined) continue; const n = m.children[i], s = this.slots.get(i); let x: number, y: number; if (s && !s.hidden && s._x != null) { x = s._x + s._w - 2; y = s._y + 2; } else { const [sx, sy] = this.toScreen(n.x, n.y), r = drawR(isCluster, n.r * z) * 0.72; x = sx + r; y = sy - r; } if (x < -8 || y < -8 || x > this.w + 8 || y > this.h + 8) continue; keep.add(target); let dot = this.changeDots.get(target); if (!dot || dot._at !== ch.at) { dot?.remove(); dot = dfDollar( `<span class="indicator-dot graph-explorer-change-dot" data-animate="ping" data-layer="${esc(ch.u.layer)}"></span>`, ).get(0) as HTMLElement; dot.style.setProperty('--graph-explorer-layer', this.colorOf(ch.u.layer)); dot.style.animationDuration = `${CHANGE_MS}ms`; dot._at = ch.at; dfDollar(this.changesEl).append(dot); this.changeDots.set(target, dot); } dot.style.translate = `${Math.round(x)}px ${Math.round(y)}px`; } for (const [t, dot] of this.changeDots) if (!keep.has(t)) { dot.remove(); this.changeDots.delete(t); } } /** the update renderer for an update: the highest match, the element's own before the global ones */ pickUpdate(u: GraphUpdate): GraphUpdateRenderer { let best: any = null, bestScore = -Infinity; for (const p of [...this.registry.update, ...globalRegistry.update]) { const score = p.update.match ? p.update.match(u) : 0; if (score === false || score == null) continue; const rank = score * 1e6 + (p._scoped ? 5e5 : 0) + p._order; if (rank > bestScore) { bestScore = rank; best = p.update; } } return best; } /** one row of the Updates outlet: the renderer's content in the button that selects the target */ updateRow(u: GraphUpdate): SafeHtml { const r = this.pickUpdate(u); let body: SafeHtml | string = u.text; try { body = r.render(u, this.updateCtx); } catch (err) { console.error('[graph-explorer] update renderer', err); } return html`<button type="button" class="graph-explorer-update" data-graph-explorer-update="${u.id}" data-layer="${u.layer}" data-kind="${u.kind}" style="--graph-explorer-layer: ${this.colorOf(u.layer)}" title="${u.label}: ${u.text}" > ${body} </button>`; } get updateCtx(): GraphUpdateContext { return { html, icon, color: (layer: string) => this.colorOf(layer), time: (ms: number) => TIME_FMT.format(ms), }; } /** the Updates outlet: a Virtual List of the shown layers' updates, newest first (a plain list without one) */ renderUpdates(added = 0): void { const outs = this.outlets('updates'); if (!outs.length) return; this.updatesRendered = true; this.updatesShown = this.updates.filter((u) => this.shown.has(u.layer)); // a soft dependency, like the Data Grid: without df$.shadcn.virtualList (an app bundle that left it out) a plain list const vl = df$.virtualList as | { setData(list: HTMLElement, count: number, render: (row: HTMLElement, index: number) => void): void } | undefined; for (const out of outs) { const box = outletChrome( out, 'updates', () => dfDollar( `<div class="graph-explorer-updates${vl ? ' virtual-list' : ''}" aria-label="Updates" data-empty-text="No updates in the shown layers yet" data-ce-chrome></div>`, ).get(0) as HTMLElement, ); if (vl) { // a reader scrolled down keeps the rows they read: the new ones go in above them if (added && box.scrollTop > 0) box.scrollTop += added * (box._rowHeight || 32); vl.setData(box, this.updatesShown.length, (row: HTMLElement, i: number) => { const u = this.updatesShown[i]; if (u) morph(row, this.updateRow(u)); }); } else morph( box, this.updatesShown.length ? html`${this.updatesShown.slice(0, 200).map((u) => this.updateRow(u))}` : html`<p class="graph-explorer-muted graph-explorer-pad">No updates in the shown layers yet</p>`, ); } } /** an update's target, selected: a node is flown to; a link's view is entered first */ async openUpdate(id: string): Promise<void> { const u = this.updates.find((x: GraphUpdate) => x.id === id); if (!u || !this.view) return; if (u.kind === 'node') { await this.navigateTo(u.target); return; } if (u.view && u.view !== this.view.id) { if (u.view === 'c0') await this.upTo('c0'); else await this.navigateTo(u.view, { enter: true, select: false }); } this.selectEdge(u.target); this.showDetails(true); } // ---------------------------------------------------- the State API bridge /** what the explorer shows now, as a state */ liveState(): { name: string; config: Record<string, unknown> } { const v = this.view; const focus = v && v.id !== 'c0' ? v.id : null; const selected = [...this.selected]; const edge = selected.length ? null : this.edge; return { name: stateNameOf(focus, selected, edge), config: { focus, selected, layers: [...this.shown], compact: this.compact, expanded: this.expanded && (selected.length > 0 || !!edge), edge, }, }; } /** record what the user did (a pick, a dive, a layer shown or hidden) the way setState would have written it */ commitState(): void { if (this.syncing || !this.view || this.loading) return; // a drill-down on an expanded page records where it lands, not the views it passes through if (this.navigating && this.expanded) return; const s = this.liveState(); applyMarkup(this.el, s.name, s.config as GraphExplorerConfig); graphExplorerApi.commit(this.el, s.name, configOf(this.el) as unknown as Record<string, unknown>); } /** make the view follow the attributes (the state's markup); serialized - one sync at a time */ sync(): Promise<void> { this.syncChain = this.syncChain.then(() => this.syncNow()).catch((err) => console.error('[graph-explorer]', err)); return this.syncChain; } async syncNow(): Promise<void> { await this.ready; if (this.destroyed) return; // the state the markup declares at load is jumped to; later ones are flown to const animate = !!this.synced && !reducedMotion(); this.synced = true; this.hud.hidden = !this.el.hasAttribute('data-hud'); const want = configOf(this.el); const now = this.liveState().config; const same = want.focus === now.focus && want.selected.join(' ') === (now.selected as string[]).join(' ') && sameLayers(want.layers, now.layers as string[]) && want.compact === now.compact && want.expanded === now.expanded && want.edge === now.edge; if (same) return; this.syncing = true; try { if (!sameLayers(want.layers, [...this.shown]) || want.compact !== this.compact) { this.shown = new Set(want.layers); this.compact = want.compact; this.layersChanged(false); } // the view: the focus, else the cluster of the first selected node that is not at the top, else the top const target = want.focus ?? 'c0'; if (this.view.id !== target) { if (target === 'c0') await this.upTo('c0', animate); else await this.navigateTo(target, { enter: true, select: false, animate }); } const inView = want.selected.filter((id) => this.view.byId.has(id)); const missing = want.selected.find((id) => !this.view.byId.has(id)); if (missing && !want.focus) { await this.navigateTo(missing, { select: false, animate }); } const selected = want.selected.filter((id) => this.view.byId.has(id)); if (selected.join(' ') !== [...this.selected].join(' ') || inView.length !== selected.length) { if (selected.length) this.edge = null; this.selected = new Set(selected); this.afterSelection(); } // a link: its source's cluster unless a focus says where if (!selected.length && want.edge) { if (this.edgeIndex(want.edge) < 0 && !want.focus) await this.navigateTo(want.edge.split('>')[0], { select: false, animate }); if (this.edgeIndex(want.edge) >= 0 && this.edge !== want.edge) this.selectEdge(want.edge); } else if (!selected.length && this.edge) this.selectEdge(null); const first = this.view.byId.get(selected[0]); if (want.expanded && first !== undefined) this.expandNode(first, false); else if (want.expanded && this.edge && !selected.length) this.expandEdge(false); else if (this.expanded) this.collapseDetails(false); } finally { this.syncing = false; } this.commitState(); } destroy(): void { this.destroyed = true; live.delete(this); if (this.raf) cancelAnimationFrame(this.raf); for (const t of [this.hudT, this.restTimer, this.announceT, this.changeT]) clearTimeout(t); for (const mq of this.mediaQueries || []) mq.removeEventListener('change', this.onMedia); document.removeEventListener('defuss-theme-change', this.onThemeEvent); document.removeEventListener('load', this.onThemeEvent, true); this.worker.terminate(); URL.revokeObjectURL(this.workerUrl); this.resizeObserver.disconnect(); this.themeObserver.disconnect(); this.attrObserver.disconnect(); unbindComponent(this.el); delete this.el.api; dfDollar(this.el).children('[data-ce-chrome]').remove(); for (const kind of OUTLETS) for (const o of this.outlets(kind)) { dropIslands(o); dfDollar(o).children('[data-ce-chrome]').remove(); } // data-init stays: a destroyed explorer does not start again by itself (remove the attribute to start it) this.el._graphExplorer = undefined; }}// the outlets' clicks and the Panels' changes: one delegation for every explorer on the pageif (!document.__graphExplorerInit) { document.__graphExplorerInit = true; // an event inside an explorer is its own (the explorer listens itself), also when a bound element holds it const owner = (t: Element | null): any => { if (!t?.closest || t.closest('.graph-explorer')) return undefined; const bound = t.closest(`[${FOR}]`); const id = bound?.getAttribute(FOR); return id ? dfDollar('#' + CSS.escape(id)).get(0)?._graphExplorer : undefined; }; document.addEventListener('click', (ev) => { const inst = owner(ev.target as Element); if (inst) inst.onClick(ev); }); document.addEventListener('keydown', (ev) => { const t = ev.target as Element; const inst = owner(t); if (inst && ev.key === '/' && !t.closest('input, textarea')) inst.onKey(ev); }); document.addEventListener('panel-change', (ev) => { for (const inst of live) inst.onPanelChange(ev as CustomEvent); });}// -- the public API ------------------------------------------------------------------------------const need = (target: string | HTMLElement): any => { const inst = resolveTarget(target)?._graphExplorer; if (!inst) throw new Error('graph-explorer: the element is not initialized'); return inst;};df$.graphExplorer = { html, register, icon, registerIcon, /** * Flies to a node anywhere in the graph - out of the view and into the clusters on its path - and selects it. * @param target - the .graph-explorer element or its selector * @param id - the node's id (c<number> or n<number>) * @returns resolves when the flight has landed */ navigate: (target: string | HTMLElement, id: string): Promise<void> => need(target).navigateTo(id), /** * Enters a cluster from anywhere: flies there and makes it the view (the focus). * @param target - the .graph-explorer element or its selector * @param id - the cluster's id * @returns resolves when the view is inside it */ dive: (target: string | HTMLElement, id: string): Promise<void> => need(target).navigateTo(id, { enter: true, select: false }), /** * Moves up one level. * @param target - the .graph-explorer element or its selector * @returns true when it moved (false at the top) */ up: (target: string | HTMLElement): Promise<boolean> => need(target).up(true), /** * Fits the view's whole cluster into the viewport. * @param target - the .graph-explorer element or its selector * @returns 'done', or 'interrupted' when another flight took over */ fit: (target: string | HTMLElement): Promise<string> => need(target).fit(true), /** * Flies the camera to a point of the view's unit disk. * @param target - the .graph-explorer element or its selector * @param camera - the camera to land on * @returns 'done', or 'interrupted' when another flight took over */ flyTo: (target: string | HTMLElement, camera: GraphCamera): Promise<string> => need(target).flyTo(camera), /** * The way from the top of the graph to a node: every cluster above it and the node itself - the worker answers. * @param target - the .graph-explorer element or its selector * @param id - the node's id (c<number> or n<number>) * @returns id and name of every step, the top (c0) first, the node last */ path: (target: string | HTMLElement, id: string): Promise<GraphPathStep[]> => need(target).waitFor('path:' + id, () => need(target).post({ t: 'path', id })), /** * A node and its descendants down to a number of levels, as flat records linked by parent id - what a tree grid * takes (data-parent-field="parent"). The worker answers: a domain of a million-node graph with two levels below it * is its systems and all their entities. * @param target - the .graph-explorer element or its selector * @param id - the subtree's root (c0 is the whole graph) * @param levels - how many levels to return, the root's included (default 3) * @returns the rows, depth first, the root first */ subtree: (target: string | HTMLElement, id: string, levels: number = 3): Promise<GraphTreeRow[]> => need(target).fetchSubtree(id, levels), /** * Searches the whole graph for names that hold the query (or #<number> for an entity) and answers with the hits - * the clusters first, then up to twelve entities; the worker scans the entities in chunks. A newer search ends this * one with what it found so far. * @param target - the .graph-explorer element or its selector * @param query - the text to find in names * @param onPartial - called with the hits so far and the entities scanned while the scan runs * @returns the hits */ find: ( target: string | HTMLElement, query: string, onPartial?: (results: GraphSearchResult[], scanned: number) => void, ): Promise<GraphSearchResult[]> => need(target).find(query, onPartial), /** * Searches the graph like the search outlet does (the results show in it). * @param target - the .graph-explorer element or its selector * @param query - the text to find in names (or #<number> for an entity) */ search: (target: string | HTMLElement, query: string): void => { const inst = need(target); for (const input of inst.searchInputs()) { input.value = query; input.focus(); } inst.searchSeq++; inst.post({ t: 'search', q: query, seq: inst.searchSeq }); }, /** * Selects nodes of the view (an empty list clears the selection). * @param target - the .graph-explorer element or its selector * @param ids - the node ids */ select: (target: string | HTMLElement, ids: string[]): void => need(target).select(ids.filter((id) => need(target).view?.byId.has(id))), /** * Selects a link of the view (null clears it): its details show and the rest of the graph steps back. * @param target - the .graph-explorer element or its selector * @param id - the link: '<source id>><target id>' of two nodes in the view, or null */ selectEdge: (target: string | HTMLElement, id: string | null): void => need(target).selectEdge(id), /** * Shows exactly these layers: nodes, relations, labels, health or a plugin's (an empty list leaves the circles). * @param target - the .graph-explorer element or its selector * @param layers - the layers' names */ setLayers: (target: string | HTMLElement, layers: string[]): void => need(target).setLayers(layers), /** * The layers the switch offers - the built-in ones and those plugins add - with their colours and whether they show. * @param target - the .graph-explorer element or its selector * @returns name, label, icon, colour, base and shown of each */ layers: (target: string | HTMLElement): GraphLayer[] => need(target).layerDefs(), /** * Draws every node as a one-line chip (on) or by its size (off). * @param target - the .graph-explorer element or its selector * @param on - true for one-line chips */ setCompact: (target: string | HTMLElement, on: boolean): void => need(target).setCompact(on), /** * Expands a node's full page: its detail renderer with ctx.expanded, the details outlet's Panel maximized (navigates * there first when the node is not in view). * @param target - the .graph-explorer element or its selector * @param id - the node's id, or a link's ('<source id>><target id>') * @returns resolves when the page shows */ expand: async (target: string | HTMLElement, id: string): Promise<void> => { const inst = need(target); if (edgeKey(id)) { if (inst.edgeIndex(id) < 0) await inst.navigateTo(id.split('>')[0], { select: false }); inst.selectEdge(id); inst.expandEdge(); return; } if (!inst.view?.byId.has(id)) await inst.navigateTo(id); const k = inst.view.byId.get(id); if (k !== undefined) inst.expandNode(k); }, /** * Collapses the expanded page back to the details. * @param target - the .graph-explorer element or its selector */ collapse: (target: string | HTMLElement): void => need(target).collapseDetails(), /** * Starts or stops Live mode: a simulation in which breakers trip, probe and close, nodes deploy, traffic moves and * counts change - each change an update (a dot on the node, a flash on the link, a row in the Updates outlet). * @param target - the .graph-explorer element or its selector * @param on - true to start, false to stop */ simulate: (target: string | HTMLElement, on: boolean): void => { const inst = need(target); if (!!inst.simulating !== on) void inst.act('live', null); }, /** * Adds changes from the app: each marks its node or link for a while and joins the Updates outlet, newest first. A * link's target is '<from>><to>' in the view its `view` names (default: the view shown); the id, the time, the layer * (nodes for a node, relations for a link), the type and the label are filled in when left out. * @param target - the .graph-explorer element or its selector * @param updates - one change or several * @returns the updates as recorded */ update: (target: string | HTMLElement, updates: Partial<GraphUpdate> | Array<Partial<GraphUpdate>>): GraphUpdate[] => need(target).addUpdates(Array.isArray(updates) ? updates : [updates]), /** * Every update so far, newest first (the last 10,000). * @param target - the .graph-explorer element or its selector * @returns a copy of the list */ updates: (target: string | HTMLElement): GraphUpdate[] => [...need(target).updates], /** * Diagnostics for tests and the HUD: the camera, the view, the budget's use, the worker's timing. * @param target - the .graph-explorer element or its selector * @param opts - { children: true } adds the view's children with their positions * @returns the inspection */ inspect: (target: string | HTMLElement, opts: { children?: boolean } = {}): GraphInspection => { const inst = need(target); const out: GraphInspection = { view: inst.view?.id ?? null, cam: { ...inst.cam }, slots: inst.slots.size, flight: !!inst.flight, navigating: inst.navigating, lag: inst.stats.lag ?? 0, elements: dfDollar(inst.el).find('*').toArray().length, worker: { ...inst.stats.worker }, graph: inst.genStats ? { ...inst.genStats } : null, }; if (opts.children && inst.view) { out.children = inst.view.children.map((n: GraphNode) => ({ id: n.id, label: n.label, x: n.x, y: n.y, r: n.r })); out.edges = inst.view.edges.map((e: GraphEdge, k: number) => ({ id: inst.edgeId(k), count: e.count, count2: e.count2, kind: e.kind, })); out.dotLabels = inst.dotLabels.map((r: number[]) => [...r]); } return out; }, /** * Stops the explorer: the worker ends, the chrome and the outlets' content go, the State API is unbound. * @param target - the .graph-explorer element or its selector */ destroy: (target: string | HTMLElement): void => need(target).destroy(),};function init(): void { (dfDollar('.graph-explorer:not([data-init])').toArray() as HTMLElement[]).forEach((el) => { el.dataset.init = ''; const inst = new Explorer(el); el._graphExplorer = inst; const config = configOf(el); // el.store + el.api (AGENTS.md "State through stores"): the state the attributes declare const name = stateNameOf(config.focus, config.selected, config.edge); bindComponent(el, graphExplorerApi, { name, config: config as unknown as Record<string, unknown> }); // the first state change waits for the graph: el.api.settled() resolves once it is drawn in that state void graphExplorerApi.setState(el, name, {}); }); // outlets placed after the explorer started get their content for (const inst of live) if (inst.view) for (const kind of OUTLETS) for (const o of inst.outlets(kind)) if (!dfDollar(o).children('[data-ce-chrome]').get(0)) { inst.renderLayers(); inst.renderCrumbs(); inst.renderOutline(); inst.renderPanel(); inst.searchInputs(); break; }}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });// -- the worker -------------------------------------------------------------------------------------// Owns the synthetic graph (CSR edges), the cluster hierarchy, the aggregated edges with their breaker state, the// per-cluster layouts (lazy), the cycles (SCC + back-edges), the three budget valves (aggregate, cull, demote with// box collision), the declarative edge-style rules and the far field + bulk edges drawn into an OffscreenCanvas,// sent back to the main thread as an ImageBitmap tagged with the camera id it was drawn for.// Stringified into a Blob worker: nothing in here may reference anything outside it.function graphExplorerWorker(self: any): void { 'use strict'; // ------------------------------------------------------------------ hashing let SEED = 0x5bd1e995; const h32 = (a, b) => { let x = (a | 0) ^ Math.imul((b | 0) + 0x632be5ab, 0x9e3779b1) ^ SEED; x = Math.imul(x ^ (x >>> 16), 0x85ebca6b); x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); return (x ^ (x >>> 16)) >>> 0; }; const rnd = (a, b) => h32(a, b) / 4294967296; // --------------------------------------------------------------- vocabulary const DOMAINS = [ 'Payments', 'Identity', 'Search', 'Catalog', 'Logistics', 'Analytics', 'Messaging', 'Billing', 'Risk', 'Media', 'Inventory', 'Support', 'Pricing', 'Recommendations', 'Notifications', 'Compliance', 'Fulfillment', 'Accounts', 'Marketing', 'Data Platform', 'Observability', 'Edge', 'Storage', 'Partners', 'Mobile', 'Growth', 'Security', 'Content', 'Ads', 'Maps', ]; const SVC_A = [ 'ledger', 'fraud', 'token', 'session', 'order', 'cart', 'invoice', 'refund', 'quota', 'profile', 'consent', 'route', 'shipment', 'stock', 'price', 'promo', 'review', 'feed', 'index', 'query', 'crawl', 'rank', 'asset', 'thumb', 'audit', 'policy', 'key', 'cache', 'event', 'metric', 'trace', 'alert', 'mail', 'push', 'sms', 'geo', 'tile', 'billing', 'tax', 'payout', ]; const SVC_B = [ 'api', 'gateway', 'worker', 'store', 'stream', 'scorer', 'sync', 'indexer', 'router', 'cache', 'broker', 'scheduler', 'service', 'adapter', 'engine', 'proxy', 'registry', 'collector', ]; const SYSTEM_KINDS = ['API', 'Worker', 'Store', 'Stream']; const ENTITY_TYPES = ['Dataset', 'Endpoint', 'Document', 'Person', 'Job', 'Topic']; const KIND_FAVOR = [1, 4, 0, 5]; // API→Endpoint, Worker→Job, Store→Dataset, Stream→Topic const EDGE_TYPES = ['calls', 'reads', 'owns']; const NOUN = [ 'order', 'invoice', 'ledger', 'account', 'payment', 'refund', 'session', 'token', 'profile', 'cart', 'route', 'parcel', 'stock', 'price', 'coupon', 'review', 'rating', 'query', 'index', 'shard', 'asset', 'image', 'video', 'policy', 'audit', 'metric', 'trace', 'event', 'alert', 'report', 'segment', 'model', 'feature', 'schema', 'table', 'topic', 'queue', 'batch', 'job', 'cron', 'contract', 'customer', 'merchant', 'partner', 'device', 'region', 'zone', 'tile', 'consent', 'tax', 'payout', 'quota', 'limit', 'cache', 'key', 'secret', 'webhook', 'ticket', 'case', 'note', 'draft', 'budget', 'forecast', 'campaign', ]; const ADJ = [ 'daily', 'hourly', 'stale', 'fresh', 'raw', 'clean', 'gold', 'silver', 'bronze', 'hot', 'cold', 'warm', 'live', 'shadow', 'legacy', 'next', 'beta', 'canary', 'eu', 'us', 'apac', 'global', 'local', 'shared', 'private', 'public', 'internal', 'external', 'primary', 'replica', 'archived', 'pending', 'active', 'failed', 'retry', 'dead', 'fast', 'slow', 'small', 'large', 'core', 'edge', 'mobile', 'web', 'batch', 'stream', 'nightly', 'weekly', 'monthly', 'q1', 'q2', 'q3', 'q4', 'v1', 'v2', 'v3', 'alpha', 'omega', 'blue', 'green', 'red', 'amber', 'gray', 'prime', ]; const USERS = ['mira', 'jonas', 'li', 'sam', 'ada', 'omar', 'noor', 'tomas']; const STATE_NAMES = ['closed', 'half-open', 'open', 'cut']; const SEVERITY = [0, 1, 3, 2]; // closed < half-open < cut < open // ------------------------------------------------------------------ graph let N = 0, E = 0, C = 0, D = 0, S = 0, P = 0; let leafType, leafCluster, outStart, outTarget, edgeType, inDeg; let cParent, cLevel, cChildStart, cChildCount, cLeafStart, cLeafEnd, cKind, cName; let pairA, pairB, pairCount, pairState, pairTrips, pairSince, pairProbesOk, pairBy, pairVer; let domPairs, rootPairs, pairMap; const views = new Map(); // cluster id -> view (layout, edges, cycles) const previews = new Map(); // cluster id -> cheap phyllotaxis layout for far-field dots function generate(n, seed) { const t0 = performance.now(); SEED = ((seed | 0) * 0x27d4eb2d) ^ 0x5bd1e995; N = n = Math.max(2000, Math.min(4000000, n | 0)); D = Math.min(60, Math.max(6, Math.round(n / 40000))); const S0 = Math.max(D * 3, Math.round(n / 190)); const dw = new Float64Array(D); let dsum = 0; for (let d = 0; d < D; d++) { dw[d] = 0.65 + rnd(d, 11) * 0.7; dsum += dw[d]; } const sysPer = new Int32Array(D); S = 0; for (let d = 0; d < D; d++) { sysPer[d] = Math.max(3, Math.floor((dw[d] / dsum) * S0)); S += sysPer[d]; } for (let d = 0; S < S0; d = (d + 1) % D) { sysPer[d]++; S++; } C = 1 + D + S; cParent = new Int32Array(C); cLevel = new Uint8Array(C); cChildStart = new Int32Array(C); cChildCount = new Int32Array(C); cLeafStart = new Int32Array(C); cLeafEnd = new Int32Array(C); cKind = new Uint8Array(C); cName = Array.from({ length: C }); // leaves per system, summing to n const sw = new Float64Array(S); let ssum = 0; for (let s = 0; s < S; s++) { sw[s] = 0.45 + rnd(s, 13) * 1.1; ssum += sw[s]; } const size = new Int32Array(S); let tot = 0; for (let s = 0; s < S; s++) { size[s] = Math.max(6, Math.floor((sw[s] / ssum) * n)); tot += size[s]; } for (let s = 0; tot < n; s = (s + 1) % S) { size[s]++; tot++; } for (let s = 0; tot > n; s = (s + 1) % S) if (size[s] > 6) { size[s]--; tot--; } leafCluster = new Int32Array(n); leafType = new Uint8Array(n); cParent[0] = -1; cLevel[0] = 0; cChildStart[0] = 1; cChildCount[0] = D; cLeafStart[0] = 0; cLeafEnd[0] = n; cName[0] = 'All domains'; let leaf = 0, sys = 1 + D; for (let d = 0; d < D; d++) { const dom = 1 + d; cParent[dom] = 0; cLevel[dom] = 1; cKind[dom] = d % 8; cName[dom] = DOMAINS[d % DOMAINS.length] + (d >= DOMAINS.length ? ' ' + (Math.floor(d / DOMAINS.length) + 1) : ''); cChildStart[dom] = sys; cChildCount[dom] = sysPer[d]; cLeafStart[dom] = leaf; const taken = new Set(); for (let k = 0; k < sysPer[d]; k++, sys++) { const s = sys - 1 - D, h = h32(sys, 17); let name = SVC_A[h % SVC_A.length] + '-' + SVC_B[(h >>> 8) % SVC_B.length]; if (taken.has(name)) { let j = 2; while (taken.has(name + '-' + j)) j++; name += '-' + j; } taken.add(name); cName[sys] = name; cParent[sys] = dom; cLevel[sys] = 2; cKind[sys] = (h >>> 16) % 4; cLeafStart[sys] = leaf; cChildStart[sys] = leaf; cChildCount[sys] = size[s]; const favored = KIND_FAVOR[cKind[sys]]; for (let i = leaf; i < leaf + size[s]; i++) { leafCluster[i] = sys; leafType[i] = rnd(i, 7) < 0.45 ? favored : h32(i, 8) % 6; } leaf += size[s]; cLeafEnd[sys] = leaf; } cLeafEnd[dom] = leaf; } // dependencies: domains depend on 2-4 domains; systems on 2-5 siblings and sometimes cross-domain const dDeps = []; for (let d = 0; d < D; d++) { const list = [], k = 2 + (h32(d, 21) % 3); for (let j = 0; list.length < k && j < 16; j++) { const dd = h32(d, 30 + j) % D; if (dd !== d && !list.includes(dd)) list.push(dd); } dDeps.push(list); } const sDepStart = new Int32Array(S + 1), sCrossStart = new Int32Array(S + 1); const sDeps = [], sCross = []; for (let s = 0; s < S; s++) { const c = s + 1 + D, dom = cParent[c], first = cChildStart[dom], m = cChildCount[dom]; sDepStart[s] = sDeps.length; const k = Math.min(m - 1, 2 + (h32(c, 23) % 4)), local = []; for (let j = 0; local.length < k && j < 24; j++) { const t = first + (h32(c, 40 + j) % m); if (t !== c && !local.includes(t)) local.push(t); } sDeps.push(...local); sCrossStart[s] = sCross.length; if (rnd(c, 41) < 0.55) { const deps = dDeps[dom - 1], kk = 1 + (h32(c, 42) % 2); for (let j = 0; j < kk; j++) { const dd = 1 + deps[h32(c, 43 + j) % deps.length]; sCross.push(cChildStart[dd] + (h32(c, 50 + j) % cChildCount[dd])); } } } sDepStart[S] = sDeps.length; sCrossStart[S] = sCross.length; // out-degree per leaf (heavy-tailed, mean about 2.3) outStart = new Int32Array(n + 1); for (let i = 0; i < n; i++) { const k = Math.min(12, 1 + Math.floor(-Math.log(1 - rnd(i, 1)) * 1.3)); outStart[i + 1] = outStart[i] + k; } E = outStart[n]; outTarget = new Int32Array(E); edgeType = new Uint8Array(E); inDeg = new Int32Array(n); for (let i = 0; i < n; i++) { const c = leafCluster[i], s = c - 1 - D; const depN = sDepStart[s + 1] - sDepStart[s], crossN = sCrossStart[s + 1] - sCrossStart[s]; for (let e = outStart[i], j = 0; e < outStart[i + 1]; e++, j++) { const u = rnd(i, 2 + j * 3); let tc = c; if (u >= 0.72 && depN > 0) { if (u < 0.92 || crossN === 0) tc = sDeps[sDepStart[s] + (h32(i, 3 + j * 3) % depN)]; else tc = sCross[sCrossStart[s] + (h32(i, 3 + j * 3) % crossN)]; } const tls = cLeafStart[tc], tsz = cLeafEnd[tc] - tls; let t = tls + (h32(i, 4 + j * 3) % tsz); if (t === i) t = tls + ((t - tls + 1) % tsz); outTarget[e] = t; edgeType[e] = tc === c ? h32(i, 5 + j * 3) % 3 : 0; inDeg[t]++; } } // aggregate cross-system edges into system pairs (each pair carries a circuit breaker) pairMap = new Map(); const pa = [], pb = [], pc = []; for (let i = 0; i < n; i++) { const sa = leafCluster[i]; for (let e = outStart[i]; e < outStart[i + 1]; e++) { const sb = leafCluster[outTarget[e]]; if (sa === sb) continue; const key = sa * C + sb; let p = pairMap.get(key); if (p === undefined) { p = pa.length; pairMap.set(key, p); pa.push(sa); pb.push(sb); pc.push(0); } pc[p]++; } } P = pa.length; pairA = Int32Array.from(pa); pairB = Int32Array.from(pb); pairCount = Int32Array.from(pc); pairState = new Uint8Array(P); pairTrips = new Uint16Array(P); pairSince = new Uint16Array(P); pairProbesOk = new Uint8Array(P); pairBy = new Uint8Array(P); pairVer = new Uint32Array(P); domPairs = new Map(); rootPairs = []; for (let p = 0; p < P; p++) { const u = rnd(pairA[p] * 7919 + pairB[p], 51); pairState[p] = u < 0.84 ? 0 : u < 0.91 ? 1 : u < 0.96 ? 2 : 3; const h = h32(p, 52); pairTrips[p] = pairState[p] === 2 ? 1 + (h % 40) : h % 3; pairSince[p] = 1 + ((h >>> 8) % 180); pairProbesOk[p] = (h >>> 16) % 10; pairBy[p] = (h >>> 20) % USERS.length; const da = cParent[pairA[p]], db = cParent[pairB[p]]; if (da === db) { let l = domPairs.get(da); if (!l) domPairs.set(da, (l = [])); l.push(p); } else rootPairs.push(p); } views.clear(); previews.clear(); return { nodes: N, edges: E, clusters: C, domains: D, systems: S, pairs: P, ms: Math.round(performance.now() - t0), }; } // --------------------------------------------------------------- labels const leafLabel = (i) => { const h = h32(i, 99); return NOUN[h % 64] + '-' + ADJ[(h >>> 6) % 64] + '-' + ((h >>> 12) % 1000); }; const idOf = (kindLeaf, idx) => (kindLeaf ? 'n' : 'c') + idx; const parseId = (id) => ({ leaf: id[0] === 'n', idx: parseInt(id.slice(1), 10) }); function pathOf(id) { const { leaf, idx } = parseId(id); const chain = []; let c = leaf ? leafCluster[idx] : idx; if (leaf) chain.push({ id, label: leafLabel(idx) }); while (c >= 0) { chain.push({ id: 'c' + c, label: cName[c] }); c = cParent[c]; } return chain.reverse(); } // ---------------------------------------------------------------- subtree // a node and its descendants down to `levels` levels, depth first - the records of a tree grid. A domain of a // million-node graph with two levels below it is its systems and their entities (tens of thousands of rows) function subtreeOf(id, levels) { const rows = []; const entity = (g, parent) => rows.push({ id: 'n' + g, parent, label: leafLabel(g), kind: 'entity', type: ENTITY_TYPES[leafType[g]], level: 3, count: outStart[g + 1] - outStart[g] + inDeg[g], children: 0, }); const cluster = (c, parent, left) => { rows.push({ id: 'c' + c, parent, label: cName[c], kind: 'cluster', type: cLevel[c] === 0 ? 'Graph' : cLevel[c] === 1 ? 'Domain' : SYSTEM_KINDS[cKind[c]], level: cLevel[c], count: membersOf(c), children: cChildCount[c], }); if (left <= 1) return; const first = cChildStart[c], end = first + cChildCount[c]; for (let k = first; k < end; k++) if (cLevel[c] === 2) entity(k, 'c' + c); else cluster(k, 'c' + c, left - 1); }; const { leaf, idx } = parseId(id); if (leaf) entity(idx, ''); else if (idx >= 0 && idx < C) cluster(idx, '', Math.max(1, levels | 0)); if (rows.length) rows[0].parent = ''; return { t: 'subtree', id, levels, rows }; } // ---------------------------------------------------------------- views function membersOf(c) { return cLeafEnd[c] - cLeafStart[c]; } function buildView(cid, wantLayout = true) { let v = views.get(cid); if (v) { if (v.dirty) refreshEdgeState(v); if (wantLayout && !v.laidOut) layoutView(v); return v; } const leafView = cLevel[cid] === 2; const start = cChildStart[cid], n = cChildCount[cid]; v = { cid, leafView, start, n, edges: [], laidOut: false, dirty: false, sizes: null }; v.type = new Uint8Array(n); v.members = new Int32Array(n); for (let i = 0; i < n; i++) { v.type[i] = leafView ? leafType[start + i] : cKind[start + i]; v.members[i] = leafView ? 1 : membersOf(start + i); } computeEdges(v); computeCycles(v); computeRank(v); computeRadii(v); views.set(cid, v); if (wantLayout) layoutView(v); return v; } function computeEdges(v) { const map = new Map(), raw = []; const add = (a, b, count, kind, type, pairs) => { const key = a * v.n + b; let e = map.get(key); if (!e) { e = { a, b, count: 0, kind, type, pairs: [] }; map.set(key, e); raw.push(e); } e.count += count; if (pairs) e.pairs.push(...pairs); }; if (v.leafView) { const s = v.start, end = s + v.n; for (let i = s; i < end; i++) for (let e = outStart[i]; e < outStart[i + 1]; e++) { const t = outTarget[e]; if (t >= s && t < end && t !== i) add(i - s, t - s, 1, 'leaf', EDGE_TYPES[edgeType[e]], null); } } else if (v.cid === 0) { for (const p of rootPairs) add(cParent[pairA[p]] - 1, cParent[pairB[p]] - 1, pairCount[p], 'agg', 'calls', [p]); } else { for (const p of domPairs.get(v.cid) ?? []) add(pairA[p] - v.start, pairB[p] - v.start, pairCount[p], 'agg', 'calls', [p]); } // merge bidirectional pairs into one edge with two counts const out = []; for (const e of raw) { if (e.merged) continue; const back = map.get(e.b * v.n + e.a); if (back && back !== e) { back.merged = true; out.push({ a: e.a, b: e.b, count: e.count, count2: back.count, bidir: true, back: false, kind: e.kind, type: e.type, pairs: e.pairs.concat(back.pairs), ver: 0, }); } else out.push({ a: e.a, b: e.b, count: e.count, count2: 0, bidir: false, back: false, kind: e.kind, type: e.type, pairs: e.pairs, ver: 0, }); } v.edges = out; v.adj = Array.from({ length: v.n }, () => []); out.forEach((e, i) => { v.adj[e.a].push(i); v.adj[e.b].push(i); }); refreshEdgeState(v); } // breaker facts of an edge: one pair, or the worst of its member pairs (aggregates) function refreshEdgeState(v) { for (const e of v.edges) { if (!e.pairs.length) { e.breaker = null; continue; } let worst = 0, trips = 0, since = 65535, probesOk = 0, by = 0, open = 0, half = 0, cut = 0, ver = 0; for (const p of e.pairs) { const st = pairState[p]; if (SEVERITY[st] > SEVERITY[worst]) { worst = st; probesOk = pairProbesOk[p]; by = pairBy[p]; } if (st === 2) open++; else if (st === 1) half++; else if (st === 3) cut++; trips += pairTrips[p]; if (st !== 0) since = Math.min(since, pairSince[p]); ver += pairVer[p]; } const members = e.pairs.length; e.breaker = { state: STATE_NAMES[worst], trips, since: since === 65535 ? 0 : since, probes: 10, probesOk, by: USERS[by], open, half, cut, members, aggregate: members > (e.bidir ? 2 : 1), openShare: +(open / members).toFixed(3), badShare: +((open + half + cut) / members).toFixed(3), }; e.traffic = e.count >= 2; e.ver = ver; } v.dirty = false; } // Tarjan (iterative) for strongly connected components, DFS for back-edges function computeCycles(v) { const n = v.n, out = Array.from({ length: n }, () => []); v.edges.forEach((e, i) => { out[e.a].push([e.b, i]); if (e.bidir) out[e.b].push([e.a, i]); }); const index = new Int32Array(n).fill(-1), low = new Int32Array(n), onStack = new Uint8Array(n); const comp = new Int32Array(n).fill(-1), stack = []; let idx = 0, nComp = 0; for (let s = 0; s < n; s++) { if (index[s] !== -1) continue; const work = [[s, 0]]; index[s] = low[s] = idx++; stack.push(s); onStack[s] = 1; while (work.length) { const top = work[work.length - 1], u = top[0]; if (top[1] < out[u].length) { const w = out[u][top[1]++][0]; if (index[w] === -1) { index[w] = low[w] = idx++; stack.push(w); onStack[w] = 1; work.push([w, 0]); } else if (onStack[w]) low[u] = Math.min(low[u], index[w]); } else { work.pop(); if (work.length) { const p = work[work.length - 1][0]; low[p] = Math.min(low[p], low[u]); } if (low[u] === index[u]) { let w; do { w = stack.pop(); onStack[w] = 0; comp[w] = nComp; } while (w !== u); nComp++; } } } } const compSize = new Int32Array(nComp); for (let i = 0; i < n; i++) compSize[comp[i]]++; v.scc = new Int32Array(n); for (let i = 0; i < n; i++) v.scc[i] = compSize[comp[i]]; v.comp = comp; // back-edges: an edge to a node that is still on the DFS stack (gray) const color = new Uint8Array(n); const markCycles = () => { for (const e of v.edges) e.cycle = e.back && v.scc[e.a] <= 12; }; for (let s = 0; s < n; s++) { if (color[s]) continue; const work = [[s, 0]]; color[s] = 1; while (work.length) { const top = work[work.length - 1], u = top[0]; if (top[1] < out[u].length) { const [w, ei] = out[u][top[1]++]; const e = v.edges[ei]; if (e.bidir) continue; if (color[w] === 1) e.back = true; else if (color[w] === 0) { color[w] = 1; work.push([w, 0]); } } else { color[u] = 2; work.pop(); } } } markCycles(); } function computeRank(v) { v.degree = new Float64Array(v.n); for (const e of v.edges) { v.degree[e.a] += e.count + e.count2; v.degree[e.b] += e.count + e.count2; } v.rank = new Float64Array(v.n); for (let i = 0; i < v.n; i++) v.rank[i] = Math.log2(1 + v.degree[i]) + Math.log2(1 + v.members[i]) * 0.6 + rnd(v.cid * 31 + i, 77) * 0.01; v.order = Int32Array.from({ length: v.n }, (_, i) => i).sort((a, b) => v.rank[b] - v.rank[a]); } function computeRadii(v) { const n = v.n; v.r = new Float32Array(n); if (v.leafView) { const r = Math.min(0.045, 0.42 / Math.sqrt(n)); v.r.fill(r); return; } let sum = 0; for (let i = 0; i < n; i++) sum += v.members[i]; for (let i = 0; i < n; i++) v.r[i] = Math.min(0.24, Math.sqrt((0.4 * v.members[i]) / sum)); } // golden-angle spiral, highest rank in the middle function phyllotaxis(n, order, r, x, y) { const golden = Math.PI * (3 - Math.sqrt(5)); let maxR = 0; for (let i = 0; i < n; i++) maxR = Math.max(maxR, r[i]); for (let k = 0; k < n; k++) { const i = order[k], rad = Math.sqrt((k + 0.5) / n) * Math.max(0.2, 0.92 - maxR), a = k * golden; x[i] = Math.cos(a) * rad; y[i] = Math.sin(a) * rad; } } function layoutView(v) { const n = v.n, r = v.r; const x = new Float64Array(n), y = new Float64Array(n); phyllotaxis(n, v.order, r, x, y); if (n > 1) { const ea = v.edges.map((e) => e.a), eb = v.edges.map((e) => e.b); const ew = v.edges.map((e) => Math.min(3, 0.4 + Math.log2(1 + e.count + e.count2) * 0.35)); const k = Math.sqrt(1 / n) * 0.75, k2 = k * k; const dx = new Float64Array(n), dy = new Float64Array(n); let temp = 0.08; const iters = n > 220 ? 70 : 100; for (let it = 0; it < iters; it++) { dx.fill(0); dy.fill(0); for (let i = 0; i < n; i++) { const xi = x[i], yi = y[i], ri = r[i]; for (let j = i + 1; j < n; j++) { let ddx = xi - x[j], ddy = yi - y[j]; let d2 = ddx * ddx + ddy * ddy; if (d2 < 1e-12) { ddx = 1e-4 * ((i % 7) - 3); ddy = 1e-4 * ((j % 5) - 2); d2 = ddx * ddx + ddy * ddy; } const d = Math.sqrt(d2), min = ri + r[j] + 0.012; let f = k2 / d; if (d < min) f += (min - d) * 6; const fx = (ddx / d) * f, fy = (ddy / d) * f; dx[i] += fx; dy[i] += fy; dx[j] -= fx; dy[j] -= fy; } } for (let m = 0; m < ea.length; m++) { const a = ea[m], b = eb[m]; const ddx = x[b] - x[a], ddy = y[b] - y[a]; const d = Math.sqrt(ddx * ddx + ddy * ddy) + 1e-9; const f = ((d * d) / k) * ew[m] * 0.5; dx[a] += (ddx / d) * f; dy[a] += (ddy / d) * f; dx[b] -= (ddx / d) * f; dy[b] -= (ddy / d) * f; } for (let i = 0; i < n; i++) { dx[i] -= x[i] * k * 2.2; dy[i] -= y[i] * k * 2.2; const len = Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]) + 1e-12, step = Math.min(len, temp); x[i] += (dx[i] / len) * step; y[i] += (dy[i] / len) * step; } temp *= 0.965; } // fit into the unit disk, then relax overlaps let s = Infinity; for (let i = 0; i < n; i++) { const d = Math.sqrt(x[i] * x[i] + y[i] * y[i]); if (d > 1e-6) s = Math.min(s, (0.94 - r[i]) / d); } if (!Number.isFinite(s)) s = 1; for (let i = 0; i < n; i++) { x[i] *= s; y[i] *= s; } for (let pass = 0; pass < 6; pass++) { for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) { const ddx = x[j] - x[i], ddy = y[j] - y[i], d = Math.sqrt(ddx * ddx + ddy * ddy) + 1e-9, min = r[i] + r[j] + 0.006; if (d < min) { const push = (min - d) / 2; x[i] -= (ddx / d) * push; y[i] -= (ddy / d) * push; x[j] += (ddx / d) * push; y[j] += (ddy / d) * push; } } for (let i = 0; i < n; i++) { const d = Math.sqrt(x[i] * x[i] + y[i] * y[i]), max = 0.96 - r[i]; if (d > max) { x[i] *= max / d; y[i] *= max / d; } } } } v.x = Float32Array.from(x); v.y = Float32Array.from(y); typeOrderFor(v); v.laidOut = true; } function typeOrderFor(l) { const n = l.type.length, buckets = Array.from({ length: 8 }, () => []); for (let i = 0; i < n; i++) buckets[l.type[i] & 7].push(i); l.typeStart = new Int32Array(9); l.typeOrder = new Int32Array(n); let k = 0; for (let t = 0; t < 8; t++) { l.typeStart[t] = k; for (const i of buckets[t]) l.typeOrder[k++] = i; } l.typeStart[8] = k; } // cheap far-field layout for a cluster we have not laid out yet function preview(cid) { const done = views.get(cid); if (done && done.laidOut) return done; let p = previews.get(cid); if (p) return p; const leafView = cLevel[cid] === 2, start = cChildStart[cid], n = cChildCount[cid]; const type = new Uint8Array(n), members = new Int32Array(n); for (let i = 0; i < n; i++) { type[i] = leafView ? leafType[start + i] : cKind[start + i]; members[i] = leafView ? 1 : membersOf(start + i); } const r = new Float32Array(n); if (leafView) r.fill(Math.min(0.045, 0.42 / Math.sqrt(n))); else { let sum = 0; for (let i = 0; i < n; i++) sum += members[i]; for (let i = 0; i < n; i++) r[i] = Math.min(0.24, Math.sqrt((0.4 * members[i]) / sum)); } const order = Int32Array.from({ length: n }, (_, i) => i).sort( (a, b) => members[b] - members[a] || h32(cid, a) - h32(cid, b), ); const x = new Float32Array(n), y = new Float32Array(n); phyllotaxis(n, order, r, x, y); p = { cid, n, type, r, x, y, laidOut: false, edges: [] }; typeOrderFor(p); previews.set(cid, p); return p; } // lazy layouts for big-on-screen children, time-sliced so camera frames stay responsive const layoutQueue = new Map(); // cid -> priority (screen radius) let layoutTimer = 0, lastCam = null; function requestLayout(cid, prio) { const v = views.get(cid); if (v && v.laidOut) return; layoutQueue.set(cid, Math.max(prio, layoutQueue.get(cid) ?? 0)); if (!layoutTimer) layoutTimer = setTimeout(runLayouts, 16); } function runLayouts() { layoutTimer = 0; const t0 = performance.now(); let did = 0; const todo = [...layoutQueue.entries()].sort((a, b) => b[1] - a[1]); for (const [cid] of todo) { layoutQueue.delete(cid); buildView(cid, true); previews.delete(cid); did++; if (performance.now() - t0 > 12) break; } if (layoutQueue.size) layoutTimer = setTimeout(runLayouts, 0); if (did && lastCam) scheduleFrame(lastCam); } // ------------------------------------------------------------ edge rules let rules = [], ruleVersion = 1; // the layers the worker draws itself: the lines (relations) and the names beside small nodes (nodes) let showNodes = true, showRelations = true; const DEFAULT_STYLE = { tone: 'muted', width: 1, arrow: 'end' }; function getField(o, path) { for (const k of path.split('.')) { if (o == null) return undefined; o = o[k]; } return o; } function matches(when, f) { for (const c of when || []) { const val = getField(f, c.field); if ('equals' in c && val !== c.equals) return false; if (c.in && !c.in.includes(val)) return false; if ('exists' in c && (val !== undefined && val !== null) !== c.exists) return false; if ('gte' in c && !(typeof val === 'number' && val >= c.gte)) return false; if ('lte' in c && !(typeof val === 'number' && val <= c.lte)) return false; } return true; } function factsOf(e) { return { kind: e.kind, type: e.type, count: e.count + e.count2, bidir: e.bidir, back: e.back, cycle: !!e.cycle, traffic: !!e.traffic, breaker: e.breaker || undefined, }; } function styleOf(e) { const key = ruleVersion + ':' + e.ver + ':' + (e.breaker ? e.breaker.state : ''); if (e.styleKey === key) return e.style; const f = factsOf(e); let best = null; for (const r of rules) if ( matches(r.when, f) && (!best || r.priority > best.priority || (r.priority === best.priority && r.order > best.order)) ) best = r; const s = Object.assign({}, DEFAULT_STYLE, best ? best.style : null); // cycle overlay: back-edges of small cycles curve around and take the accent (big strongly connected levels stay calm) if (f.cycle) { s.route = s.route || 'around'; if (s.tone === 'muted') { s.tone = 'accent'; s.dash = s.dash || [5, 4]; } } if (s.dash && !s.dash.length) s.dash = null; e.style = s; e.styleKey = key; return s; } // ------------------------------------------------------------ rendering let canvas = null, ctx = null; let palette = { paper: '#ffffff', ink: '#111111', muted: '#71717a', border: '#e4e4e7', ring: '#2563eb', tones: { muted: '#a1a1aa', ink: '#18181b', accent: '#7c3aed', link: '#2563eb', ok: '#16a34a', warn: '#d97706', danger: '#dc2626', }, cat: ['#e76e50', '#2a9d90', '#274754', '#e8c468', '#f4a462', '#8b5cf6', '#ec4899', '#14b8a6'], font: 'system-ui, sans-serif', dark: false, }; let selfStats: Record<string, number> = { ms: 0, dots: 0, edges: 0, labels: 0 }; // far-field dots go straight into pixels: one putImageData instead of tens of thousands of fillRect calls let dotCanvas = null, dotCtx = null, dotImg = null, dotBuf = null, probeCtx = null; const rgbaCache = new Map(); function rgba(color, a) { let base = rgbaCache.get(color); if (!base) { probeCtx = probeCtx || new OffscreenCanvas(1, 1).getContext('2d', { willReadFrequently: true }); probeCtx.clearRect(0, 0, 1, 1); probeCtx.fillStyle = '#888'; probeCtx.fillStyle = color; probeCtx.fillRect(0, 0, 1, 1); const d = probeCtx.getImageData(0, 0, 1, 1).data; base = [d[0], d[1], d[2]]; rgbaCache.set(color, base); } return ((Math.round(a * 255) << 24) | (base[2] << 16) | (base[1] << 8) | base[0]) >>> 0; } const pendingFrame = { cam: null, timer: 0 }; // never let a re-render for an older camera replace a newer pending one function scheduleFrame(cam) { if (!pendingFrame.cam || cam.id >= pendingFrame.cam.id) pendingFrame.cam = cam; if (!pendingFrame.timer) pendingFrame.timer = setTimeout(() => { pendingFrame.timer = 0; const c = pendingFrame.cam; pendingFrame.cam = null; if (c) renderFrame(c); }, 0); } const alpha = (color, a) => { ctx.globalAlpha = a; return color; }; // anchor of a DOM box: cluster cards sit at the top inside their disk, everything else is centered function anchorBox(isCluster, tier, sx, sy, rs, bw, bh) { if (isCluster && tier === 2) return [sx - bw / 2, Math.max(sy - rs * 0.86 + 6, sy - rs + 4)]; return [sx - bw / 2, sy - bh / 2]; } // drawn radius: clusters are containers (their disk), entities stay dots; their layout radius is spacing only const drawR = (isCluster, rs) => (isCluster ? Math.max(1.6, rs) : Math.min(6, Math.max(2.5, rs * 0.18))); function renderFrame(cam) { const t0 = performance.now(); lastCam = cam; const cid = cam.view; const v = buildView(cid, true); const W = Math.max(1, Math.round(cam.w * cam.dpr)), H = Math.max(1, Math.round(cam.h * cam.dpr)); if (!canvas || canvas.width !== W || canvas.height !== H) { canvas = new OffscreenCanvas(W, H); ctx = canvas.getContext('2d'); } ctx.setTransform(cam.dpr, 0, 0, cam.dpr, 0, 0); ctx.globalAlpha = 1; ctx.clearRect(0, 0, cam.w, cam.h); const z = cam.z, ox = cam.w / 2 - cam.cx * z, oy = cam.h / 2 - cam.cy * z; const vw = cam.w, vh = cam.h; let dots = 0, segs = 0, labels = 0; if (!dotCanvas || dotCanvas.width !== W || dotCanvas.height !== H) { dotCanvas = new OffscreenCanvas(W, H); dotCtx = dotCanvas.getContext('2d'); dotImg = dotCtx.createImageData(W, H); dotBuf = new Uint32Array(dotImg.data.buffer); } else dotBuf.fill(0); const ds = cam.dpr >= 1.5 ? 3 : 2, dpr = cam.dpr; let usedDots = false; // far field of one cluster: its children as dots (1-2 px straight into the ImageData, bigger ones as arcs) const farField = (l, x0, y0, r0, a) => { for (let t = 0; t < 8; t++) { const from = l.typeStart[t], to = l.typeStart[t + 1]; if (from === to) continue; const px32 = rgba(palette.cat[t], a); ctx.fillStyle = alpha(palette.cat[t], a); let pathOpen = false; for (let k = from; k < to; k++) { const j = l.typeOrder[k]; const gx = x0 + l.x[j] * r0, gy = y0 + l.y[j] * r0, gr = l.r[j] * r0; if (gx < -4 || gx > vw + 4 || gy < -4 || gy > vh + 4) continue; if (gr < 1.3) { const px = Math.round(gx * dpr) - 1, py = Math.round(gy * dpr) - 1; if (px >= 0 && py >= 0 && px + ds <= W && py + ds <= H) { for (let yy = 0; yy < ds; yy++) { const row = (py + yy) * W + px; for (let xx = 0; xx < ds; xx++) dotBuf[row + xx] = px32; } usedDots = true; } } else { if (!pathOpen) { ctx.beginPath(); pathOpen = true; } ctx.moveTo(gx + gr, gy); ctx.arc(gx, gy, gr, 0, Math.PI * 2); } dots++; } if (pathOpen) ctx.fill(); } }; // backdrop: the parent level, dimmed, with the edges that leave this cluster rising into it if (cid !== 0) { const pv = buildView(cParent[cid], true), fi = cid - pv.start; const fx = pv.x[fi], fy = pv.y[fi], fr = pv.r[fi]; const tx = (x) => ((x - fx) / fr) * z + ox, ty = (y) => ((y - fy) / fr) * z + oy; let backdropDots = 0; for (let j = 0; j < pv.n; j++) { if (j === fi) continue; const sx = tx(pv.x[j]), sy = ty(pv.y[j]), rs = (pv.r[j] / fr) * z; if (sx + rs < -50 || sx - rs > vw + 50 || sy + rs < -50 || sy - rs > vh + 50) continue; const col = palette.cat[pv.type[j] & 7]; ctx.beginPath(); ctx.arc(sx, sy, Math.max(1.5, rs), 0, Math.PI * 2); ctx.fillStyle = alpha(col, 0.05); ctx.fill(); ctx.strokeStyle = alpha(col, 0.28); ctx.lineWidth = 1; ctx.stroke(); // the sibling's contents, so a neighbour panned into view is never an empty disk const sc = pv.start + j; if (rs >= 6 && backdropDots < 60000) { if (rs > 120) requestLayout(sc, rs * 0.5); const l = preview(sc), d0 = dots; if (cLevel[sc] === 2) farField(l, sx, sy, rs, 0.4); else { const cs = cChildStart[sc]; for (let t = 0; t < 8; t++) { ctx.beginPath(); let any = false; for (let k = 0; k < l.n; k++) { if ((l.type[k] & 7) !== t) continue; const gx = sx + l.x[k] * rs, gy = sy + l.y[k] * rs, gr = l.r[k] * rs; if (gx + gr < 0 || gx - gr > vw || gy + gr < 0 || gy - gr > vh || gr < 1.5) continue; ctx.moveTo(gx + gr, gy); ctx.arc(gx, gy, gr, 0, Math.PI * 2); any = true; } if (any) { ctx.strokeStyle = alpha(palette.cat[t], 0.3); ctx.lineWidth = 1; ctx.stroke(); } } for (let k = 0; k < l.n && backdropDots + dots - d0 < 60000; k++) { const gx = sx + l.x[k] * rs, gy = sy + l.y[k] * rs, gr = l.r[k] * rs; if (gr < 4 || gx + gr < 0 || gx - gr > vw || gy + gr < 0 || gy - gr > vh) continue; farField(preview(cs + k), gx, gy, gr, 0.4); } } backdropDots += dots - d0; } if (showNodes && rs > 16 && labels < 80) { ctx.globalAlpha = 0.55; ctx.fillStyle = palette.muted; ctx.font = '11px ' + palette.font; ctx.textAlign = 'center'; ctx.fillText(cName[pv.start + j], sx, sy + 4); labels++; } } const picked = (cam.sel && cam.sel.length > 0) || !!cam.edge; for (const e of showRelations ? pv.edges : []) { const incident = e.a === fi || e.b === fi; const st = styleOf(e); let ax = tx(pv.x[e.a]), ay = ty(pv.y[e.a]), bx = tx(pv.x[e.b]), by = ty(pv.y[e.b]); if (incident) { const len = Math.hypot(bx - ax, by - ay) || 1, ux = (bx - ax) / len, uy = (by - ay) / len; if (e.a === fi) { ax += ux * z; ay += uy * z; } else { bx -= ux * z; by -= uy * z; } } ctx.globalAlpha = incident ? (picked ? 0.3 : 0.7) : 0.1; ctx.strokeStyle = palette.tones[st.tone] || palette.tones.muted; ctx.lineWidth = incident ? Math.min(4, 1 + Math.log10(e.count + e.count2 + 1)) : 0.75; ctx.setLineDash(incident && st.dash ? st.dash : []); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); segs++; } ctx.setLineDash([]); // depth: dim everything outside the focus disk ctx.globalAlpha = palette.dark ? 0.4 : 0.32; ctx.fillStyle = palette.paper; ctx.beginPath(); ctx.rect(0, 0, vw, vh); ctx.arc(ox, oy, z, 0, Math.PI * 2, true); ctx.fill('evenodd'); ctx.globalAlpha = 0.35; ctx.strokeStyle = palette.ink; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(ox, oy, z, 0, Math.PI * 2); ctx.stroke(); } // children in screen space const n = v.n, sx = new Float32Array(n), sy = new Float32Array(n), rs = new Float32Array(n); const vis = new Uint8Array(n); for (let i = 0; i < n; i++) { sx[i] = v.x[i] * z + ox; sy[i] = v.y[i] * z + oy; rs[i] = v.r[i] * z; vis[i] = sx[i] + rs[i] > -40 && sx[i] - rs[i] < vw + 40 && sy[i] + rs[i] > -40 && sy[i] - rs[i] < vh + 40 ? 1 : 0; } const sel = new Set<number>(cam.sel || []); const pair = Array.isArray(cam.edge) && cam.edge.length === 2 ? cam.edge : null; const hl = new Set(); const focusNodes = [...sel]; if (pair) focusNodes.push(pair[0], pair[1]); if (cam.hover >= 0) focusNodes.push(cam.hover); for (const f of focusNodes) { hl.add(f); if (pair) continue; for (const ei of v.adj[f] || []) { const e = v.edges[ei]; hl.add(e.a); hl.add(e.b); } } const dimming = focusNodes.length > 0; // tiers: aggregate (done by the hierarchy), cull (vis), demote (caps + box collision) const tiers = new Int8Array(n).fill(-1); const sizes = v.sizes; const cell = 96, grid = new Map(), boxes = []; const fits = (x, y, w, h) => { const pad = 3, x0 = Math.floor((x - pad) / cell), x1 = Math.floor((x + w + pad) / cell), y0 = Math.floor((y - pad) / cell), y1 = Math.floor((y + h + pad) / cell); for (let gx = x0; gx <= x1; gx++) for (let gy = y0; gy <= y1; gy++) { const list = grid.get(gx * 100003 + gy); if (!list) continue; for (const b of list) if (x < b[0] + b[2] + pad && x + w + pad > b[0] && y < b[1] + b[3] + pad && y + h + pad > b[1]) return false; } return true; }; const place = (x, y, w, h) => { const b = [x, y, w, h]; boxes.push(b); const x0 = Math.floor(x / cell), x1 = Math.floor((x + w) / cell), y0 = Math.floor(y / cell), y1 = Math.floor((y + h) / cell); for (let gx = x0; gx <= x1; gx++) for (let gy = y0; gy <= y1; gy++) { const k = gx * 100003 + gy; let l = grid.get(k); if (!l) grid.set(k, (l = [])); l.push(b); } }; const isCluster = !v.leafView; const order = []; const pushed = new Uint8Array(n); for (const f of [...sel, cam.hover, cam.kbd]) if (f >= 0 && f < n && !pushed[f]) { pushed[f] = 1; order.push(f); } for (const i of v.order) if (!pushed[i]) order.push(i); let cards = 0, chips = 0; const chipMin = isCluster ? 24 : 7, cardMin = isCluster ? 200 : 40; for (const i of order) { if (!vis[i]) continue; tiers[i] = 0; const d = rs[i] * 2, forced = sel.has(i) || i === cam.hover || i === cam.kbd; const cw = sizes ? sizes[i * 4 + 2] : isCluster ? 248 : 220, ch = sizes ? sizes[i * 4 + 3] : isCluster ? 104 : 92; const pw = sizes ? sizes[i * 4] : isCluster ? 150 : 140, ph = sizes ? sizes[i * 4 + 1] : 28; if (d >= cardMin && cards < 120 && (!isCluster || rs[i] * 1.6 > cw)) { const [x, y] = anchorBox(isCluster, 2, sx[i], sy[i], rs[i], cw, ch); if (forced || fits(x, y, cw, ch)) { place(x, y, cw, ch); tiers[i] = 2; cards++; continue; } } if ((d >= chipMin || forced) && chips < 300) { const [x, y] = anchorBox(isCluster, 1, sx[i], sy[i], rs[i], pw, ph); if (forced || fits(x, y, pw, ph)) { place(x, y, pw, ph); tiers[i] = 1; chips++; } } } // children: disks (clusters) or dots (entities), then their own children as far-field dots for (let t = 0; t < 8; t++) { const col = palette.cat[t]; for (let pass = 0; pass < 2; pass++) { // pass 0: highlighted, pass 1: dimmed ctx.beginPath(); let any = false; for (let i = 0; i < n; i++) { if (!vis[i] || (v.type[i] & 7) !== t) continue; const dim = dimming && !hl.has(i); if ((pass === 1) !== dim) continue; const dr = drawR(isCluster, rs[i]); ctx.moveTo(sx[i] + dr, sy[i]); ctx.arc(sx[i], sy[i], dr, 0, Math.PI * 2); any = true; } if (!any) continue; if (isCluster) { ctx.fillStyle = alpha(col, pass ? 0.03 : 0.07); ctx.fill(); ctx.strokeStyle = alpha(col, pass ? 0.22 : 0.6); ctx.lineWidth = 1.25; ctx.stroke(); } else { ctx.fillStyle = alpha(col, pass ? 0.25 : 0.95); ctx.fill(); } } } if (isCluster) { let innerSegs = 0; for (let i = 0; i < n; i++) { if (!vis[i] || rs[i] < 3) continue; const cc = v.start + i; const l = preview(cc); if (rs[i] > 36) requestLayout(cc, rs[i]); const dim = dimming && !hl.has(i); farField(l, sx[i], sy[i], rs[i], dim ? 0.2 : cLevel[cc] < 2 ? 0.55 : 0.85); // inner edges of a child that is big on screen and already laid out if (showRelations && rs[i] > 170 && l.laidOut && innerSegs < 6000) { ctx.strokeStyle = alpha(palette.muted, dim ? 0.06 : 0.22); ctx.lineWidth = 0.75; ctx.beginPath(); for (const e of l.edges) { ctx.moveTo(sx[i] + l.x[e.a] * rs[i], sy[i] + l.y[e.a] * rs[i]); ctx.lineTo(sx[i] + l.x[e.b] * rs[i], sy[i] + l.y[e.b] * rs[i]); if (++innerSegs >= 6000) break; } ctx.stroke(); segs += l.edges.length; } } } if (usedDots) { dotCtx.putImageData(dotImg, 0, 0); ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.drawImage(dotCanvas, 0, 0); ctx.restore(); } // bulk edges, batched by stroke style const buckets = new Map(); for (let ei = 0; showRelations && ei < v.edges.length; ei++) { const e = v.edges[ei]; const a = e.a, b = e.b; if (!vis[a] && !vis[b]) { const minx = Math.min(sx[a], sx[b]), maxx = Math.max(sx[a], sx[b]), miny = Math.min(sy[a], sy[b]), maxy = Math.max(sy[a], sy[b]); if (maxx < 0 || minx > vw || maxy < 0 || miny > vh) continue; } const st = styleOf(e); const incident = dimming && (pair ? (a === pair[0] && b === pair[1]) || (a === pair[1] && b === pair[0]) : focusNodes.includes(a) || focusNodes.includes(b)); const calm = st.tone === 'muted'; const a0 = dimming ? (incident ? 0.95 : 0.1) : calm ? (e.kind === 'agg' ? 0.3 : 0.38) : 0.8; const w = e.kind === 'agg' ? Math.min(5, st.width * (1 + Math.min(1.6, Math.log10(e.count + e.count2) * 0.7))) : st.width; const key = st.tone + '|' + w.toFixed(2) + '|' + (st.dash ? st.dash.join(',') : '') + '|' + a0; let bk = buckets.get(key); if (!bk) buckets.set(key, (bk = { tone: st.tone, w, dash: st.dash, a: a0, lines: [], arrows: [] })); edgeGeometry( sx[a], sy[a], drawR(isCluster, rs[a]), sx[b], sy[b], drawR(isCluster, rs[b]), st, e.bidir, w, bk.lines, bk.arrows, ); segs++; } for (const bk of buckets.values()) { const col = palette.tones[bk.tone] || palette.tones.muted; ctx.globalAlpha = bk.a; ctx.strokeStyle = col; ctx.lineWidth = bk.w; ctx.setLineDash(bk.dash || []); ctx.beginPath(); for (const poly of bk.lines) { ctx.moveTo(poly[0], poly[1]); for (let k = 2; k < poly.length; k += 2) ctx.lineTo(poly[k], poly[k + 1]); } ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = col; ctx.beginPath(); for (const t of bk.arrows) { ctx.moveTo(t[0], t[1]); ctx.lineTo(t[2], t[3]); ctx.lineTo(t[4], t[5]); ctx.closePath(); } ctx.fill(); } // canvas labels for dot-tier children, collision-culled in the same grid as the DOM boxes ctx.globalAlpha = 1; ctx.font = '11px ' + palette.font; ctx.textAlign = 'left'; ctx.fillStyle = palette.muted; const dotLabels = []; for (const i of order) { if (!showNodes || tiers[i] !== 0 || labels >= 160) continue; if (dimming && !hl.has(i)) continue; const text = v.leafView ? leafLabel(v.start + i) : cName[v.start + i]; const x = sx[i] + drawR(isCluster, rs[i]) + 4, y = sy[i] - 7; if (!fits(x, y, text.length * 4.5 + 4, 14)) continue; // a cheap lower bound first, then the measured width const w = Math.ceil(ctx.measureText(text).width) + 4; if (!fits(x, y, w, 14)) continue; place(x, y, w, 14); ctx.fillText(text, x, y + 11); dotLabels.push([x, y, w, 14]); labels++; } const bitmap = canvas.transferToImageBitmap(); selfStats = { ms: +(performance.now() - t0).toFixed(1), dots, edges: segs, labels, cards, chips, view: cid }; self.postMessage( { t: 'frame', camId: cam.id, view: cid, tiers, bitmap, dotLabels, stats: selfStats, cam: { cx: cam.cx, cy: cam.cy, z: cam.z, w: cam.w, h: cam.h }, }, [bitmap, tiers.buffer], ); } // shared edge geometry (graph-explorer.js has the same function for promoted SVG edges) function edgeGeometry(ax, ay, ar, bx, by, br, st, bidir, w, lines, arrows) { const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy); if (len < ar + br + 2) return; const ux = dx / len, uy = dy / len, head = st.arrow === 'none' ? 0 : 3.5 + w * 1.4; if (st.route === 'around') { const cx = (ax + bx) / 2 - uy * len * 0.22, cy = (ay + by) / 2 + ux * len * 0.22; const s0x = cx - ax, s0y = cy - ay, s0 = Math.hypot(s0x, s0y) || 1, e0x = cx - bx, e0y = cy - by, e0 = Math.hypot(e0x, e0y) || 1; const p0x = ax + (s0x / s0) * ar, p0y = ay + (s0y / s0) * ar, p2x = bx + (e0x / e0) * (br + head * 0.6), p2y = by + (e0y / e0) * (br + head * 0.6); const pts = (t0, t1) => { const out = []; for (let k = 0; k <= 10; k++) { const t = t0 + ((t1 - t0) * k) / 10, mt = 1 - t; out.push(mt * mt * p0x + 2 * mt * t * cx + t * t * p2x, mt * mt * p0y + 2 * mt * t * cy + t * t * p2y); } return out; }; if (st.gap && len > 40) { lines.push(pts(0, 0.44)); lines.push(pts(0.56, 1)); } else lines.push(pts(0, 1)); if (head) { const hx = p2x - cx, hy = p2y - cy, hl = Math.hypot(hx, hy) || 1; arrows.push(tri(p2x, p2y, hx / hl, hy / hl, head)); } return; } const p0x = ax + ux * (ar + (bidir && head ? head * 0.6 : 0)), p0y = ay + uy * (ar + (bidir && head ? head * 0.6 : 0)); const p2x = bx - ux * (br + head * 0.6), p2y = by - uy * (br + head * 0.6); if (st.gap && len > 40) { const mx = (p0x + p2x) / 2, my = (p0y + p2y) / 2, g = 6; lines.push([p0x, p0y, mx - ux * g, my - uy * g]); lines.push([mx + ux * g, my + uy * g, p2x, p2y]); } else lines.push([p0x, p0y, p2x, p2y]); if (head) { arrows.push(tri(p2x, p2y, ux, uy, head)); if (bidir) arrows.push(tri(p0x, p0y, -ux, -uy, head)); } } function tri(x, y, ux, uy, s) { const bx = x - ux * s, by = y - uy * s, px = -uy * s * 0.55, py = ux * s * 0.55; return [x + ux * s * 0.6, y + uy * s * 0.6, bx + px, by + py, bx - px, by - py]; } // -------------------------------------------------------- view payloads function viewPayload(cid) { const v = buildView(cid, true); const children = []; for (let i = 0; i < v.n; i++) { const g = v.start + i; const node: any = { i, id: idOf(v.leafView, g), kind: v.leafView ? 'entity' : 'cluster', label: v.leafView ? leafLabel(g) : cName[g], type: v.type[i], typeName: v.leafView ? ENTITY_TYPES[v.type[i]] : cLevel[g] === 1 ? 'Domain' : SYSTEM_KINDS[v.type[i]], members: v.members[i], children: v.leafView ? 0 : cChildCount[g], degree: Math.round(v.degree[i]), scc: v.scc[i], x: v.x[i], y: v.y[i], r: v.r[i], }; if (v.leafView) { const h = h32(g, 61); node.degree = outStart[g + 1] - outStart[g] + inDeg[g]; node.attrs = { owner: USERS[h % USERS.length], rows: ((h >>> 4) % 9000) + 12, freshness: 1 + ((h >>> 18) % 48), out: outStart[g + 1] - outStart[g], in: inDeg[g], }; } children.push(node); } const edges = v.edges.map((e, k) => ({ k, a: e.a, b: e.b, count: e.count, count2: e.count2, bidir: e.bidir, back: e.back, kind: e.kind, type: e.type, breaker: e.breaker, traffic: !!e.traffic, style: styleOf(e), })); let parent = null; if (cid !== 0) { const pv = buildView(cParent[cid], true), fi = cid - pv.start, fr = pv.r[fi]; const external = []; for (const e of pv.edges) { if (e.a !== fi && e.b !== fi) continue; const j = e.a === fi ? e.b : e.a; external.push({ id: 'c' + (pv.start + j), label: cName[pv.start + j], x: (pv.x[j] - pv.x[fi]) / fr, y: (pv.y[j] - pv.y[fi]) / fr, r: pv.r[j] / fr, out: e.a === fi ? e.count : e.count2, in: e.a === fi ? e.count2 : e.count, breaker: e.breaker, style: styleOf(e), }); } parent = { id: 'c' + cParent[cid], x: pv.x[fi], y: pv.y[fi], r: fr, external }; } return { t: 'view', id: 'c' + cid, cid, leafView: v.leafView, path: pathOf('c' + cid), children, edges, parent, stats: { nodes: N, edges: E }, }; } function details(id) { const { leaf, idx } = parseId(id); if (leaf) { const out = []; for (let e = outStart[idx]; e < outStart[idx + 1] && out.length < 40; e++) { const t = outTarget[e]; out.push({ id: 'n' + t, label: leafLabel(t), type: ENTITY_TYPES[leafType[t]], system: cName[leafCluster[t]], domain: cName[cParent[leafCluster[t]]], edge: EDGE_TYPES[edgeType[e]], same: leafCluster[t] === leafCluster[idx], }); } const inc = [], want = Math.min(40, inDeg[idx]), sys = leafCluster[idx], dom = cParent[sys]; const ranges = [ [cLeafStart[sys], cLeafEnd[sys]], [cLeafStart[dom], cLeafStart[sys]], [cLeafEnd[sys], cLeafEnd[dom]], [0, cLeafStart[dom]], [cLeafEnd[dom], N], ]; for (const [r0, r1] of ranges) { for (let i = r0; i < r1 && inc.length < want; i++) for (let e = outStart[i]; e < outStart[i + 1]; e++) if (outTarget[e] === idx) { inc.push({ id: 'n' + i, label: leafLabel(i), type: ENTITY_TYPES[leafType[i]], system: cName[leafCluster[i]], domain: cName[cParent[leafCluster[i]]], edge: EDGE_TYPES[edgeType[e]], same: leafCluster[i] === sys, }); break; } if (inc.length >= want) break; } return { t: 'details', id, out, in: inc, outDeg: outStart[idx + 1] - outStart[idx], inDeg: inDeg[idx], path: pathOf(id), }; } const relations = []; if (idx !== 0) { const pv = buildView(cParent[idx], false), fi = idx - pv.start; for (const ei of pv.adj[fi]) { const e = pv.edges[ei], j = e.a === fi ? e.b : e.a; relations.push({ id: 'c' + (pv.start + j), label: cName[pv.start + j], out: e.a === fi ? e.count : e.count2, in: e.a === fi ? e.count2 : e.count, breaker: e.breaker, back: e.back, }); } relations.sort((a, b) => b.out + b.in - (a.out + a.in)); } return { t: 'details', id, relations: relations.slice(0, 60), members: membersOf(idx), children: cChildCount[idx], level: cLevel[idx], path: pathOf(id), }; } // --------------------------------------------------------------- an edge's details const endOf = (leaf, idx) => leaf ? { id: 'n' + idx, label: leafLabel(idx), type: ENTITY_TYPES[leafType[idx]], system: cName[leafCluster[idx]] } : { id: 'c' + idx, label: cName[idx], type: cLevel[idx] === 1 ? 'Domain' : SYSTEM_KINDS[cKind[idx]] }; // every row behind a node or a link, for a page's grid: a cluster's entities (one contiguous range of leaves - a domain // of a million-node graph holds about 40,000), or a link's single links both ways (one pass over both ends' leaves) function rowsOf(id) { if (id.includes('>')) { const [aId, bId] = id.split('>'); const A = parseId(aId), B = parseId(bId); const span = (p) => (p.leaf ? [p.idx, p.idx + 1] : [cLeafStart[p.idx], cLeafEnd[p.idx]]); const [a0, a1] = span(A), [b0, b1] = span(B); const rows = []; const scan = (s0, s1, t0, t1, back) => { for (let i = s0; i < s1; i++) for (let e = outStart[i]; e < outStart[i + 1]; e++) { const t = outTarget[e]; if (t < t0 || t >= t1) continue; rows.push({ id: e, from: 'n' + i, fromLabel: leafLabel(i), relation: EDGE_TYPES[edgeType[e]], to: 'n' + t, toLabel: leafLabel(t), direction: back ? 'back' : 'forward', fromSystem: cName[leafCluster[i]], toSystem: cName[leafCluster[t]], }); } }; scan(a0, a1, b0, b1, false); scan(b0, b1, a0, a1, true); return { t: 'rows', id, kind: 'links', rows }; } const { leaf, idx } = parseId(id); const s0 = leaf ? idx : cLeafStart[idx], s1 = leaf ? idx + 1 : cLeafEnd[idx]; const rows = []; for (let g = s0; g < s1; g++) { const h = h32(g, 61), sys = leafCluster[g]; rows.push({ id: 'n' + g, label: leafLabel(g), type: ENTITY_TYPES[leafType[g]], system: cName[sys], domain: cName[cParent[sys]], owner: USERS[h % USERS.length], out: outStart[g + 1] - outStart[g], in: inDeg[g], rows: ((h >>> 4) % 9000) + 12, freshness: 1 + ((h >>> 18) % 48), }); } return { t: 'rows', id, kind: 'entities', rows }; } // the links of one edge of a view, both ways: by relation, the system pairs it bundles with their breakers, the // members that send and receive the most, and samples. One pass over the source's and the target's leaves (a // domain of a million-node graph: about 100,000 links) function edgeDetails(aId, bId) { const A = parseId(aId), B = parseId(bId); const byType = {}; for (const t of EDGE_TYPES) byType[t] = 0; const samples = []; const out = { t: 'edge', id: aId + '>' + bId, kind: A.leaf ? 'leaf' : 'agg', path: pathOf('c' + (A.leaf ? leafCluster[A.idx] : cParent[A.idx])), source: endOf(A.leaf, A.idx), target: endOf(B.leaf, B.idx), forward: 0, backward: 0, byType, pairs: [], sources: [], targets: [], samples, }; if (A.leaf) { for (const [from, to, back] of [ [A.idx, B.idx, false], [B.idx, A.idx, true], ] as [number, number, boolean][]) for (let e = outStart[from]; e < outStart[from + 1]; e++) { if (outTarget[e] !== to) continue; if (back) out.backward++; else out.forward++; byType[EDGE_TYPES[edgeType[e]]]++; samples.push({ from: endOf(true, from), to: endOf(true, to), type: EDGE_TYPES[edgeType[e]], back }); } return out; } // a domain edge's members are its systems, a system edge's its entities const domainEdge = cLevel[A.idx] === 1; const keyOf = (leaf) => (domainEdge ? leafCluster[leaf] : leaf); const sent = new Map(), got = new Map(); let fs = 0, bs = 0; const scan = (s0, s1, t0, t1, back) => { let n = 0; for (let i = s0; i < s1; i++) for (let e = outStart[i]; e < outStart[i + 1]; e++) { const t = outTarget[e]; if (t < t0 || t >= t1) continue; n++; byType[EDGE_TYPES[edgeType[e]]]++; if (!back) { const ks = keyOf(i), kt = keyOf(t); sent.set(ks, (sent.get(ks) || 0) + 1); got.set(kt, (got.get(kt) || 0) + 1); } if (back ? bs < 12 : fs < 28) { samples.push({ from: endOf(true, i), to: endOf(true, t), type: EDGE_TYPES[edgeType[e]], back }); if (back) bs++; else fs++; } } return n; }; out.forward = scan(cLeafStart[A.idx], cLeafEnd[A.idx], cLeafStart[B.idx], cLeafEnd[B.idx], false); out.backward = scan(cLeafStart[B.idx], cLeafEnd[B.idx], cLeafStart[A.idx], cLeafEnd[A.idx], true); const top = (m) => [...m.entries()] .sort((x, y) => y[1] - x[1]) .slice(0, 8) .map(([k, count]) => { const end = endOf(!domainEdge, k); return { id: end.id, label: end.label, type: end.type, count }; }); out.sources = top(sent); out.targets = top(got); const pairs = []; if (domainEdge) { for (const p of rootPairs) { const da = cParent[pairA[p]], db = cParent[pairB[p]]; if ((da === A.idx && db === B.idx) || (da === B.idx && db === A.idx)) pairs.push(p); } } else for (const key of [A.idx * C + B.idx, B.idx * C + A.idx]) { const p = pairMap.get(key); if (p !== undefined) pairs.push(p); } out.pairs = pairs .sort((p, q) => SEVERITY[pairState[q]] - SEVERITY[pairState[p]] || pairCount[q] - pairCount[p]) .slice(0, 40) .map((p) => ({ from: endOf(false, pairA[p]), to: endOf(false, pairB[p]), count: pairCount[p], state: STATE_NAMES[pairState[p]], trips: pairTrips[p], since: pairSince[p], by: USERS[pairBy[p]], })); return out; } // --------------------------------------------------------------- search let searchSeq = 0; function search(q, seq) { searchSeq = seq; const query = String(q || '') .trim() .toLowerCase(); if (!query) { self.postMessage({ t: 'search', seq, q, results: [] }); return; } const results = []; const push = (id, label, kind, type) => { const chain = pathOf(id); results.push({ id, label, kind, type, path: chain .slice(1, -1) .map((p) => p.label) .join(' › '), parents: chain.slice(0, -1), }); }; const direct = /^#?(\d+)$/.exec(query); if (direct && +direct[1] < N) push('n' + direct[1], leafLabel(+direct[1]), 'entity', ENTITY_TYPES[leafType[+direct[1]]]); for (let c = 1; c < C && results.length < 6; c++) if (cName[c].toLowerCase().includes(query)) push('c' + c, cName[c], 'cluster', cLevel[c] === 1 ? 'Domain' : SYSTEM_KINDS[cKind[c]]); let i = 0; const step = () => { if (seq !== searchSeq) return; // a newer keystroke aborted this scan const end = Math.min(N, i + 120000); for (; i < end && results.length < 12; i++) if (leafLabel(i).includes(query)) push('n' + i, leafLabel(i), 'entity', ENTITY_TYPES[leafType[i]]); if (i < N && results.length < 12) { self.postMessage({ t: 'search', seq, q, results, partial: true, scanned: i }); setTimeout(step, 0); } else self.postMessage({ t: 'search', seq, q, results, scanned: i }); }; step(); } // ------------------------------------------------- Live mode: the simulation // Every step a few breakers between systems trip, probe or close (the health layer), and now and then a node deploys // (nodes), a link's traffic moves (relations) or a count changes (labels) - each change an update for the main thread, // named in the view it happened in. let simTimer = 0; const BREAKER_WORDS = ['Closed', 'Half-open', 'Open']; const BREAKER_TONES = ['ok', 'warn', 'danger']; function simulate(on) { clearInterval(simTimer); simTimer = 0; if (on) simTimer = setInterval(simStep, 1100); } function simStep() { if (!lastCam) return; const v = views.get(lastCam.view); if (!v) return; const t = Date.now() & 0xffff, now = Date.now(), viewId = 'c' + v.cid; const childId = (i) => (v.leafView ? 'n' : 'c') + (v.start + i); const childLabel = (i) => (v.leafView ? leafLabel(v.start + i) : cName[v.start + i]); const items = []; // a level of clusters: each breaker belongs to a link of the view; inside a system: to the system const linked = v.leafView ? [] : v.edges.map((_e, k) => k).filter((k) => v.edges[k].pairs.length); const pool = v.leafView ? (domPairs.get(cParent[v.cid]) ?? []) : null; for (let k = 0; k < 3; k++) { let p, e; if (pool) { if (!pool.length) break; p = pool[h32(t, k) % pool.length]; } else { if (!linked.length) break; e = v.edges[linked[h32(t, k + 20) % linked.length]]; p = e.pairs[h32(t, k) % e.pairs.length]; } const st = pairState[p]; if (st === 3) continue; pairState[p] = st === 0 ? 1 : st === 1 ? (rnd(t, k + 9) < 0.5 ? 0 : 2) : 1; if (pairState[p] === 2) pairTrips[p]++; if (pairState[p] === 1) pairProbesOk[p] = h32(t, k + 3) % 10; pairSince[p] = 1; pairVer[p]++; const dom = cParent[pairA[p]]; const domView = views.get(dom); if (domView) domView.dirty = true; if (cParent[pairB[p]] !== dom && views.get(0)) views.get(0).dirty = true; for (const vv of views.values()) if (vv.cid === dom || vv.cid === 0) vv.dirty = true; const text = `${BREAKER_WORDS[pairState[p]]}: ${cName[pairA[p]]} → ${cName[pairB[p]]}`; const tone = BREAKER_TONES[pairState[p]]; if (e) items.push({ kind: 'edge', target: childId(e.a) + '>' + childId(e.b), view: viewId, label: childLabel(e.a) + ' → ' + childLabel(e.b), layer: 'health', type: 'breaker', text, tone, time: now, }); else items.push({ kind: 'node', target: viewId, label: cName[v.cid], layer: 'health', type: 'breaker', text, tone, time: now, }); } // a node deploys if (v.n && rnd(t, 31) < 0.6) { const i = h32(t, 32) % v.n, g = v.start + i; const text = v.leafView ? `Updated · revision ${100 + (h32(t, 33) % 900)}` : cLevel[g] === 1 ? `${1 + (h32(t, 34) % 5)} systems deployed` : `Deployed v${1 + (h32(t, 35) % 4)}.${h32(t, 36) % 20}.${h32(t, 37) % 30}`; items.push({ kind: 'node', target: childId(i), label: childLabel(i), layer: 'nodes', type: 'deploy', text, time: now, }); } // a link's traffic moves if (v.edges.length && rnd(t, 41) < 0.4) { const e = v.edges[h32(t, 42) % v.edges.length]; const up = rnd(t, 43) < 0.5; items.push({ kind: 'edge', target: childId(e.a) + '>' + childId(e.b), view: viewId, label: childLabel(e.a) + ' → ' + childLabel(e.b), layer: 'relations', type: 'traffic', text: `Traffic ${up ? 'up' : 'down'} ${5 + (h32(t, 44) % 60)}%`, tone: up ? 'accent' : 'muted', time: now, }); } // a count changes if (v.n && rnd(t, 51) < 0.3) { const i = h32(t, 52) % v.n; const d = 1 + (h32(t, 53) % 5000); items.push({ kind: 'node', target: childId(i), label: childLabel(i), layer: 'labels', type: 'count', text: `${rnd(t, 54) < 0.7 ? '+' : '-'}${d.toLocaleString('en-US')} records`, time: now, }); } if (items.length) self.postMessage({ t: 'updates', items }); postPatch(); scheduleFrame(lastCam); } /** the current view's links as they are now - their breakers and their styles - for the main thread (`restyle`: * only the styles changed, the breakers are as they were) */ function postPatch(restyle = false) { if (!lastCam || !views.get(lastCam.view)) return; const cur = buildView(lastCam.view, false); const patch = cur.edges.map((e, k) => ({ k, breaker: e.breaker, style: styleOf(e), traffic: !!e.traffic })); let ext = null; if (cur.cid !== 0) { const pv = buildView(cParent[cur.cid], false), fi = cur.cid - pv.start; ext = pv.edges .filter((e) => e.a === fi || e.b === fi) .map((e) => ({ id: 'c' + (pv.start + (e.a === fi ? e.b : e.a)), breaker: e.breaker, style: styleOf(e) })); } self.postMessage({ t: 'patch', view: 'c' + cur.cid, edges: patch, external: ext, restyle }); } // ------------------------------------------------------------- messages self.onmessage = (ev) => { const m = ev.data; try { switch (m.t) { case 'init': { self.postMessage({ t: 'progress', phase: 'generating' }); const stats = generate(m.n, m.seed); self.postMessage({ t: 'ready', stats, root: 'c0' }); break; } case 'rules': rules = (m.rules || []).map((r, order) => ({ when: r.when || [], style: r.style || {}, priority: r.priority ?? 0, order, })); ruleVersion++; // the shown layers' rules restyle the links the main thread draws too postPatch(true); if (lastCam) scheduleFrame(lastCam); break; case 'layers': showNodes = !!m.nodes; showRelations = !!m.relations; if (lastCam) scheduleFrame(lastCam); break; case 'colors': palette = m.palette; rgbaCache.clear(); if (lastCam) scheduleFrame(lastCam); break; case 'focus': { const { idx } = parseId(m.id); self.postMessage(viewPayload(idx)); break; } case 'sizes': { const v = views.get(parseId(m.id).idx); if (v && m.sizes.length === v.n * 4) { v.sizes = m.sizes; if (lastCam) scheduleFrame(lastCam); } break; } case 'camera': scheduleFrame(m); break; case 'search': search(m.q, m.seq); break; case 'details': self.postMessage(details(m.id)); break; case 'edge': self.postMessage(edgeDetails(m.a, m.b)); break; case 'rows': self.postMessage(rowsOf(m.id)); break; case 'subtree': self.postMessage(subtreeOf(m.id, m.levels)); break; case 'path': self.postMessage({ t: 'path', id: m.id, path: pathOf(m.id), req: m.req }); break; case 'simulate': simulate(!!m.on); break; } } catch (err) { self.postMessage({ t: 'error', message: String((err && err.stack) || err) }); } };}Comments, ideas or improvements? Edit this page's source on GitHub