Theme
Design your own
On this page (11)
Component Skill — components/graph-explorer/component-skill.md

Native basis

A <div role="application"> with a canvas for the worker's bitmap, an SVG for the highlighted edges and pooled <div> slots for nodes and labels. The state is on the element as data-* attributes; the drawing and the outlets' content are the runtime's.

Built from

Badge, Button, Breadcrumb, Card, Input, Kbd, Table; Expand works with a Panel.

Web Platform APIs

WorkerOffscreenCanvasbitmaprendererPointer EventspopovermaskResizeObserver

Classes

.graph-explorer.graph-explorer-node.graph-explorer-chip.graph-explorer-card.graph-explorer-edge-label.graph-explorer-icon

Upstream

graph-explorer PoCThe design study's proof of concept this component is ported from (MIT, Aron Homberg)Lucide (lucide-static 0.469.0)The icon subset, drawn as CSS masks (ISC)

Accessibility

• One tab stop: arrows move the focus to the nearest node in that direction, Enter enters a cluster, Space selects, Backspace moves up, ? opens the keyboard map

• The view, the focused node and the neighbour ring (Jump to a neighbour) are announced in a live region (a bound status output, else the explorer's own)

• Reduced motion: jumps instead of flights, no animated edges; forced colors: system colors on the canvas and in the DOM

§Graph Explorer

A synthetic service graph of 100,000 nodes: six domains, their systems, their entities. Wheel or pinch to zoom, drag to pan; click a node to select it, double click or Enter to go in. The State tab writes the same attributes the clicks do.

§Outlets

An outlet is any element the page binds with data-graph-explorer-for="<id>": data-graph-explorer-outlet names what the runtime puts in it - breadcrumb, search, outline, details, relations, layers, updates - and data-graph-explorer-output a readout - details-title, relations-title, view, status. data-graph-explorer-action buttons move up, fit, switch Live mode or open the keyboard map. The Knowledge Graph scaffold places them in Panels.

§Breadcrumb, search, outline and details

Pick a node in the outline or the graph: the details show it. The breadcrumb's crumbs move up; the search flies anywhere.

§Live mode and updates

An update is one change in the graph - a node or a link, the layer it belongs to, a line of text. For eight seconds a changed node carries a glowing dot at its top right and a changed link lights up in its layer's colour and fades. The updates outlet lists every update of the shown layers, newest first, one compact row each in a Virtual List; a row selects its node or its link (the link's view is entered first). The live action starts Live mode, the explorer's simulation: breakers trip and close (Health), nodes deploy (Nodes), traffic moves (Relations), counts change (Labels). An app adds its own with df$.shadcn.graphExplorer.update(el, change), and an update plugin renders the rows its way.

§Updates in Live mode

Press Live mode: changed nodes get a glowing dot, changed links light up in their layer's colour, and every change lands in Updates, newest first. Hide Health in the layer switch and its changes leave the list; click a row to select its node or link. Add a change adds one of the page's own, with its own row renderer.

§Icons

Renderers draw icons with ctx.icon(name, { size, tone, label }): a Lucide subset as CSS masks - one empty element each, cheap to morph, painted in the current color. Sizes xs, sm, lg and xl go around the 1rem default; a tone takes the graph's colour role; with a label the icon is an image, else decorative. df$.shadcn.graphExplorer.registerIcon(name, shapes) adds one.

§Icon sizes and tones

§States

Named states via the shared State API, driven per instance through the bound api. The state is the element's attributes - a click, a zoom into a cluster, the layer switch or an attribute written from outside changes it without setState, and getState() reports it:

  • default - the top level, nothing selected (data-focus and data-selected removed)
  • selected - config.selected nodes picked at the top level; an id deeper down flies there, and the state lands in focused
  • focused - inside the cluster config.focus (the view dives there), with or without a selection

Every state takes layers (data-layers), compact (data-compact) and expanded (data-expanded: the first selected node's page, the details Panel maximized). A config that names a focus or a selection asks for the state that shows it. Events: graph-explorer-select, graph-explorer-focus, graph-explorer-view (the camera came to rest), graph-explorer-layers, graph-explorer-action, graph-explorer-error.

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/graph-explorer-{state}.png.

Machine contract - verified against graph-explorer.schema.json by bun run verify:

StateTypeValuesDefaultDescription
layersstring—"nodes relations"The layers shown, space separated: nodes, relations, labels, health or a plugin's (data-layers, absent for the base layers; empty: only the circles).
compactbooleantrue, falsefalseEvery node as its one-line chip, never a card (data-compact).
focusedstring—""The cluster the view is in (setState('focused', { focus }), observed from data-focus).
selectedstring—""The selected node ids, space separated (observed from data-selected).
edgestring—""The selected link, <source id>><target id> (observed from data-selected-edge); nodes and a link are never selected together.
expandedbooleantrue, falsefalseThe selection's full page in the details outlet - the first selected node's or the link's (data-expanded).

§API

Generated from graph-explorer.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type GraphExplorerState = 'default' | 'selected' | 'focused' - setState(name, config) takes the config of the state it names.

StateDescription
default
The top level, nothing selected - the focus and the selection are cleared.
Config fieldTypeDescription
focus?string | nullthe cluster the view is in (null = the top); 'focused' needs one
selected?string | string[]the selected node ids, space separated or as a list
layers?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
compact?booleanevery node as a one-line chip (data-compact)
expanded?booleanthe details outlet expanded to the selection's page (the first selected node's, or the link's)
edge?string | nullthe selected link: '<source id>><target id>' of two nodes in the view; a node selection clears it
selected
Nodes (`config.selected`) or a link (`config.edge`) picked at the top level; an id deeper down flies there and lands in focused.
Config fieldTypeDescription
focus?string | nullthe cluster the view is in (null = the top); 'focused' needs one
selected?string | string[]the selected node ids, space separated or as a list
layers?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
compact?booleanevery node as a one-line chip (data-compact)
expanded?booleanthe details outlet expanded to the selection's page (the first selected node's, or the link's)
edge?string | nullthe selected link: '<source id>><target id>' of two nodes in the view; a node selection clears it
focused
Inside the cluster `config.focus` (the view dives there), with or without a selection.
Config fieldTypeDescription
focus?string | nullthe cluster the view is in (null = the top); 'focused' needs one
selected?string | string[]the selected node ids, space separated or as a list
layers?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
compact?booleanevery node as a one-line chip (data-compact)
expanded?booleanthe details outlet expanded to the selection's page (the first selected node's, or the link's)
edge?string | nullthe selected link: '<source id>><target id>' of two nodes in the view; a node selection clears it

Every element

MemberDescription
el.api.setState<S extends GraphExplorerState>(name: S, config?: GraphExplorerStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?GraphExplorerStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.graphExplorerApi.setState<S extends GraphExplorerState>(el: HTMLElement, name: S, config?: GraphExplorerStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?GraphExplorerStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.graphExplorerApi.getState(el: HTMLElement): { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.graphExplorerApi.render(state: { name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.graphExplorerApi.store(el: HTMLElement): Store<{ name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: GraphExplorerState; config: GraphExplorerStateConfigs[GraphExplorerState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.graphExplorerApi.commit<S extends GraphExplorerState>(el: HTMLElement, name: S, config?: GraphExplorerStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?GraphExplorerStateConfigs[S]its config
df$.shadcn.graphExplorerStates: GraphExplorerState[]The declared states, 'default' first: default, selected, focused.

df$.shadcn.graphExplorer

MemberDescription
html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml
The escaping template tag renderers write markup with: every interpolation is escaped; nested html`` values and arrays of them pass through as markup.
ArgumentTypeDescription
stringsTemplateStringsArraythe template's literal parts
...valuesunknown[]the interpolations

Returns SafeHtml - the markup as SafeHtml

register(scope: 'global' | HTMLElement | string, plugin: GraphPlugin): ()
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.
ArgumentTypeDescription
scope'global' | HTMLElement | string'global', or the .graph-explorer element (or its selector)
pluginGraphPluginthe plugin

Returns () - a function that removes the plugin again

icon(name: string, opts: GraphIconOptions = {}): SafeHtml
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.
ArgumentTypeDescription
namestringthe icon's name (database, plug-zap, user, ...); an unknown name draws a circle
optsGraphIconOptions = {}its label, tone, size, type color and classes

Returns SafeHtml - the icon's markup

registerIcon(name: string, svg: string): void
Adds an icon to the set: the inner markup of a 24x24 stroke icon (Lucide's conventions), drawn as a mask.
ArgumentTypeDescription
namestringits name (lowercase words joined by "-")
svgstringthe shapes inside the <svg> (paths, circles, lines - nothing that loads or runs)
navigate(target: string | HTMLElement, id: string): Promise<void>
Flies to a node anywhere in the graph - out of the view and into the clusters on its path - and selects it.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstringthe node's id (c<number> or n<number>)

Returns Promise<void> - resolves when the flight has landed

dive(target: string | HTMLElement, id: string): Promise<void>
Enters a cluster from anywhere: flies there and makes it the view (the focus).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstringthe cluster's id

Returns Promise<void> - resolves when the view is inside it

up(target: string | HTMLElement): Promise<boolean>
Moves up one level.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector

Returns Promise<boolean> - true when it moved (false at the top)

fit(target: string | HTMLElement): Promise<string>
Fits the view's whole cluster into the viewport.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector

Returns Promise<string> - 'done', or 'interrupted' when another flight took over

flyTo(target: string | HTMLElement, camera: GraphCamera): Promise<string>
Flies the camera to a point of the view's unit disk.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
cameraGraphCamerathe camera to land on

Returns Promise<string> - 'done', or 'interrupted' when another flight took over

path(target: string | HTMLElement, id: string): Promise<GraphPathStep[]>
The way from the top of the graph to a node: every cluster above it and the node itself - the worker answers.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstringthe node's id (c<number> or n<number>)

Returns Promise<GraphPathStep[]> - id and name of every step, the top (c0) first, the node last

subtree(target: string | HTMLElement, id: string, levels: number = 3): Promise<GraphTreeRow[]>
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.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstringthe subtree's root (c0 is the whole graph)
levelsnumber = 3how many levels to return, the root's included (default 3)

Returns Promise<GraphTreeRow[]> - the rows, depth first, the root first

find(target: string | HTMLElement, query: string, onPartial?: (results: GraphSearchResult[], scanned: number) => void): Promise<GraphSearchResult[]>
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.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
querystringthe text to find in names
onPartial?(results: GraphSearchResult[], scanned: number) => voidcalled with the hits so far and the entities scanned while the scan runs

Returns Promise<GraphSearchResult[]> - the hits

search(target: string | HTMLElement, query: string): void
Searches the graph like the search outlet does (the results show in it).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
querystringthe text to find in names (or #<number> for an entity)
select(target: string | HTMLElement, ids: string[]): void
Selects nodes of the view (an empty list clears the selection).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idsstring[]the node ids
selectEdge(target: string | HTMLElement, id: string | null): void
Selects a link of the view (null clears it): its details show and the rest of the graph steps back.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstring | nullthe link: '<source id>><target id>' of two nodes in the view, or null
setLayers(target: string | HTMLElement, layers: string[]): void
Shows exactly these layers: nodes, relations, labels, health or a plugin's (an empty list leaves the circles).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
layersstring[]the layers' names
layers(target: string | HTMLElement): GraphLayer[]
The layers the switch offers - the built-in ones and those plugins add - with their colours and whether they show.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector

Returns GraphLayer[] - name, label, icon, colour, base and shown of each

setCompact(target: string | HTMLElement, on: boolean): void
Draws every node as a one-line chip (on) or by its size (off).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
onbooleantrue for one-line chips
expand(target: string | HTMLElement, id: string): Promise<void>
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).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
idstringthe node's id, or a link's ('<source id>><target id>')

Returns Promise<void> - resolves when the page shows

collapse(target: string | HTMLElement): void
Collapses the expanded page back to the details.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
simulate(target: string | HTMLElement, on: boolean): void
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).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
onbooleantrue to start, false to stop
update(target: string | HTMLElement, updates: Partial<GraphUpdate> | Array<Partial<GraphUpdate>>): GraphUpdate[]
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.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
updatesPartial<GraphUpdate> | Array<Partial<GraphUpdate>>one change or several

Returns GraphUpdate[] - the updates as recorded

updates(target: string | HTMLElement): GraphUpdate[]
Every update so far, newest first (the last 10,000).
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector

Returns GraphUpdate[] - a copy of the list

inspect(target: string | HTMLElement, opts: { children?: boolean } = {}): GraphInspection
Diagnostics for tests and the HUD: the camera, the view, the budget's use, the worker's timing.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector
opts{ children?: boolean } = {}{ children: true } adds the view's children with their positions

Returns GraphInspection - the inspection

destroy(target: string | HTMLElement): void
Stops the explorer: the worker ends, the chrome and the outlets' content go, the State API is unbound.
ArgumentTypeDescription
targetstring | HTMLElementthe .graph-explorer element or its selector

Events

EventDescription
graph-explorer-action
an action button was used

detail: GraphExplorerActionDetail

FieldTypeDescription
actionstringthe action: enter, bring, expand, collapse, details, up, fit, live, help
idstring | nullthe node it acted on, null for the explorer's own actions
graph-explorer-error
the worker or a renderer failed

detail: GraphExplorerErrorDetail

FieldTypeDescription
messagestringwhat failed
graph-explorer-focus
the view moved to another cluster (null = the top)

detail: GraphExplorerFocusDetail

FieldTypeDescription
focusstring | nullthe cluster's id, null at the top
pathGraphPathStep[]the path from the top to it
graph-explorer-layers
a layer was shown or hidden, or Compact switched

detail: GraphExplorerLayersDetail

FieldTypeDescription
layersstring[]the layers shown now
compactbooleantrue when every node is a one-line chip
graph-explorer-select
the selection changed - by a click, a key, a search result or setState

detail: GraphExplorerSelectDetail

FieldTypeDescription
selectedstring[]the selected node ids
edgestring | nullthe selected link ('<source id>><target id>'), null when nodes or nothing is selected
graph-explorer-update
a node or a link changed - Live mode's simulation or update()

detail: GraphExplorerUpdateDetail

FieldTypeDescription
updateGraphUpdatethe change, as the Updates outlet lists it
graph-explorer-view
the camera came to rest

detail: GraphExplorerViewDetail

FieldTypeDescription
focusstring | nullthe cluster the view is in, null at the top
cameraGraphCamerathe camera

Types

TypeDescription
GraphBreaker
The breaker facts of a link: one circuit breaker, or the worst of the many an aggregated link bundles.
FieldTypeDescription
state'closed' | 'half-open' | 'open' | 'cut'closed (healthy), half-open (probing), open (tripped) or cut (by hand)
tripsnumbertrips counted
sincenumberminutes since the breaker left closed
probesnumberprobes a half-open breaker sends
probesOknumberprobes that succeeded
bystringwho cut it
opennumbermember breakers open
halfnumbermember breakers half-open
cutnumbermember breakers cut
membersnumberbreakers the link bundles
aggregatebooleantrue when the link bundles several breakers (an edge between clusters)
openSharenumberthe share of open members, 0 to 1
badSharenumberthe share of members not closed, 0 to 1
GraphBreakerCounts
A cluster's breakers by state, summed over its links at this level.
FieldTypeDescription
opennumberopen breakers
halfnumberhalf-open breakers
cutnumbercut breakers
totalnumberbreakers in all
GraphCamera
The camera: where the view's unit disk is shown.
FieldTypeDescription
cxnumberthe center, in the view's unit disk
cynumberthe center, in the view's unit disk
znumberscreen px per unit
GraphCondition
One condition of an edge rule; every condition of a rule must hold.
FieldTypeDescription
fieldstringthe edge field, dotted for nested ones: 'kind', 'type', 'breaker.state', 'breaker.openShare'
equals?string | number | booleanthe value must equal this
in?(string | number | boolean)[]the value must be one of these
exists?booleanthe value must exist (true) or be missing (false)
gte?numberthe value must be at least this
lte?numberthe value must be at most this
GraphDetails
What the worker knows about a node beyond its view: path, relations, counts.
FieldTypeDescription
idstringthe node's id
pathGraphPathStep[]the path from the top to the node
relations?GraphRelation[]a cluster's relations at its level, the busiest first (at most 60)
members?numbera cluster's entities
children?numbera cluster's direct children
level?numbera cluster's hierarchy level
out?GraphLink[]an entity's outgoing links (at most 40)
in?GraphLink[]an entity's incoming links (the sources; at most 40)
outDeg?numberan entity's outgoing links in all
inDeg?numberan entity's incoming links in all
GraphEdge
A link between two nodes of the view.
FieldTypeDescription
knumberits index in the view
anumberthe index of its source node in the view
bnumberthe index of its target node in the view
countnumberlinks from a to b (an aggregate counts its members)
count2numberlinks from b to a
bidirbooleantrue when it runs both ways
backbooleantrue when it is the back-edge of a cycle
kind'leaf' | 'agg''leaf' between entities, 'agg' between clusters (it bundles the links below)
typestringthe relation: calls, reads or owns
breakerGraphBreaker | nullits breaker facts (null when it has none)
trafficbooleantrue while traffic flows (the live simulation)
styleGraphEdgeStylehow the rules draw it
GraphEdgeContext
What an edge label renderer gets besides the edge.
FieldTypeDescription
tier'icon' | 'label' | 'full'icon, label or full - picked by the edge's length on screen, the budgets and the free space
depthnumberthe hierarchy level of the view (0 = the top)
zoomnumberthe camera's zoom
viewstring | nullthe view's id
html(strings: TemplateStringsArray, ...values: unknown[]) => SafeHtmlthe escaping template tag
icon(name: string, options?: GraphIconOptions) => SafeHtmlan icon
source?GraphNodethe link's source node (labels in the graph)
target?GraphNodethe link's target node (labels in the graph)
selected?booleantrue when an end of the link is selected
GraphEdgeDetail
An edge detail renderer: what the details outlet shows for a selected link (an edge plugin's `detail`).
FieldTypeDescription
match?(item: GraphEdgeItem) => number | falsea score for a link, or false when it does not describe it; the highest wins (default 0)
title?(item: GraphEdgeItem) => stringthe details title for the link
render(item: GraphEdgeItem, ctx: GraphRenderContext) => SafeHtml | Promise<SafeHtml>the markup (or a Promise of it); ctx.expanded asks for the link's full page
page?(item: GraphEdgeItem) => booleanwhether the link has a page (default: every link) - without one no Expand button shows and E does nothing
GraphEdgeDetails
What the worker knows about an edge: its links both ways, by relation, the bundled breakers and samples.
FieldTypeDescription
idstringthe edge's id: '<source id>><target id>'
kind'leaf' | 'agg''leaf' between entities, 'agg' between clusters
pathGraphPathStep[]the path from the top to the view the edge is in
sourceGraphEdgeEndthe source end
targetGraphEdgeEndthe target end
forwardnumberlinks from the source to the target
backwardnumberlinks from the target back to the source
byTypeRecord<string, number>links per relation (calls, reads, owns), both ways
pairsGraphEdgePair[]the system pairs it bundles with their breakers, the worst first (at most 40)
sourcesGraphEdgeShare[]the source's members that send the most links (at most 8)
targetsGraphEdgeShare[]the target's members that receive the most links (at most 8)
samplesGraphEdgeSample[]single links between entities, both ways (at most 40)
GraphEdgeEnd
One end of a link in an edge's details.
FieldTypeDescription
idstringthe node's id
labelstringthe node's name
typestringits type: an entity type (Dataset, Endpoint, ...) or a cluster kind (API system, Domain, ...)
system?stringthe system it belongs to (entities)
GraphEdgeItem
A selected link as its detail renderer gets it: the two ends and the view's edge.
FieldTypeDescription
kind'edge'always 'edge'
idstring'<source id>><target id>' - the value of data-selected-edge
sourceGraphNodethe source node
targetGraphNodethe target node
edgeGraphEdgethe edge in the view: counts, relation, breaker, style
depthnumberthe hierarchy level of the view (0 = the top)
GraphEdgeLabel
An edge label renderer: DOM labels and icons along links, chosen per zoom level.
FieldTypeDescription
levels?{ icon?: number; label?: number; full?: number }the shortest on-screen length (px) for each tier
sizes?{ icon?: [number, number]; label?: [number, number]; full?: [number, number] }the box [width, height] of each tier, for collision
depths?number[] | nullthe hierarchy levels it labels (null = all)
budget?{ icon?: number; label?: number; full?: number } | nullits own cap per tier
match(edge: GraphEdge, ctx: GraphEdgeContext) => number | falsea score for an edge, or false when it does not label it
priority?(edge: GraphEdge, ctx: GraphEdgeContext) => numberthe rank of an edge's label (the busiest or worst first)
render(edge: GraphEdge, ctx: GraphEdgeContext) => SafeHtmlthe label's markup
GraphEdgePair
A bundled pair of systems inside an edge between clusters, with its circuit breaker.
FieldTypeDescription
fromGraphEdgeEndthe calling system
toGraphEdgeEndthe called system
countnumberthe links between them
statestringthe breaker: closed, half-open, open or cut
tripsnumberhow often it tripped
sincenumberhow long it has been in its state (minutes)
bystringwho changed it last
GraphEdgeSample
One link between two entities, as a sample of an edge.
FieldTypeDescription
fromGraphEdgeEndthe source entity
toGraphEdgeEndthe target entity
typestringthe relation: calls, reads or owns
backbooleantrue when it runs from the edge's target back to its source
GraphEdgeShare
A node that sends or receives a share of an edge's links.
FieldTypeDescription
idstringits id
labelstringits name
typestringits type
countnumberits links on this edge
GraphEdgeStyle
How a link is drawn - what the edge rules decide.
FieldTypeDescription
tone?GraphToneits color role
width?numberits stroke width in px
arrow?'end' | 'none'an arrowhead at the target, or none
dash?number[]a dash pattern in px
flow?numberanimated dashes that move along a link with traffic (1 = on)
pulse?booleana slow pulse
gap?booleana gap in the middle: a broken link
route?'straight' | 'around''around': drawn as a curve (a cycle's back-edge)
GraphEntityAttrs
The attributes of an entity (a leaf node).
FieldTypeDescription
ownerstringits owner
rowsnumberrows, in thousands
freshnessnumberhours since its last update
outnumberoutgoing links
innumberincoming links
GraphExplorerActionDetail
graph-explorer-action: an action button was used (in a card, the details or bound to the explorer).
FieldTypeDescription
actionstringthe action: enter, bring, expand, collapse, details, up, fit, live, help
idstring | nullthe node it acted on, null for the explorer's own actions
GraphExplorerConfig
setState() config: what the markup says - the focus, the selection, the layers, the expanded details.
FieldTypeDescription
focus?string | nullthe cluster the view is in (null = the top); 'focused' needs one
selected?string | string[]the selected node ids, space separated or as a list
layers?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
compact?booleanevery node as a one-line chip (data-compact)
expanded?booleanthe details outlet expanded to the selection's page (the first selected node's, or the link's)
edge?string | nullthe selected link: '<source id>><target id>' of two nodes in the view; a node selection clears it
GraphExplorerErrorDetail
graph-explorer-error: the worker or a renderer failed.
FieldTypeDescription
messagestringwhat failed
GraphExplorerFocusDetail
graph-explorer-focus: the view moved to another cluster.
FieldTypeDescription
focusstring | nullthe cluster's id, null at the top
pathGraphPathStep[]the path from the top to it
GraphExplorerLayersDetail
graph-explorer-layers: a layer was shown or hidden, or Compact switched.
FieldTypeDescription
layersstring[]the layers shown now
compactbooleantrue when every node is a one-line chip
GraphExplorerSelectDetail
graph-explorer-select: the selection changed.
FieldTypeDescription
selectedstring[]the selected node ids
edgestring | nullthe selected link ('<source id>><target id>'), null when nodes or nothing is selected
GraphExplorerUpdateDetail
graph-explorer-update: a node or a link changed.
FieldTypeDescription
updateGraphUpdatethe change, as the Updates outlet lists it
GraphExplorerViewDetail
graph-explorer-view: the camera came to rest.
FieldTypeDescription
focusstring | nullthe cluster the view is in, null at the top
cameraGraphCamerathe camera
GraphIconOptions
Options of an icon.
FieldTypeDescription
label?stringan accessible name; without one the icon is decorative (aria-hidden)
tone?GraphToneits color role
size?'xs' | 'sm' | 'lg' | 'xl'xs, sm, lg or xl (default 1rem)
type?numbera type number: the icon takes that type's color (with the graph-explorer-type-icon class)
cls?stringextra classes
GraphInspection
What inspect() reports: the camera, the view, the budget's use and the worker's timing.
FieldTypeDescription
viewstring | nullthe view's id
camGraphCamerathe camera
slotsnumberDOM slots in use
flightbooleantrue while a flight runs
navigatingbooleantrue while a navigation runs
lagnumberframes behind the camera
elementsnumberthe elements inside the explorer
workerRecord<string, number>the worker's last frame: cards, chips, dots, edges, labels, ms
graphRecord<string, number> | nullthe generated graph: nodes, edges, ms
children?{ id: string; label: string; x: number; y: number; r: number }[]the view's children with their positions (with { children: true })
edges?{ id: string; count: number; count2: number; kind: string }[]the view's edges by id, with their link counts (with { children: true })
dotLabels?number[][]the boxes [x, y, width, height] of the labels the worker drew into the canvas (with { children: true })
GraphIslandMount
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.

= (el: HTMLElement) => void | (() => void)

GraphLayer
A layer of information the layer switch shows or hides.
FieldTypeDescription
namestringits name (a word of data-layers)
labelstringits label
iconstringits icon
colorstringits colour: the CSS colour its switch, its parts and its labels are drawn in
basebooleantrue for the base layers (nodes, relations) - shown while data-layers is absent
shownbooleantrue while it is shown
GraphLink
An outgoing link of an entity (details of an entity).
FieldTypeDescription
idstringthe target's id
labelstringthe target's name
typestringthe target's type
systemstringthe target's system
domainstringthe target's domain
edgestringthe relation: calls, reads or owns
samebooleantrue when the target is in the same system
GraphNode
A node as renderers see it: a cluster (a domain or a system) or an entity.
FieldTypeDescription
idstringthe stable id: c<number> for a cluster, n<number> for an entity
inumberits index in the current view
kind'cluster' | 'entity'a cluster has children; an entity is a leaf
labelstringits name
typenumberits type number (0-7), the key of its color
typeNamestringits type: Domain, API, Worker, Store, Stream - or an entity's Dataset, Endpoint, Document, Person, Job, Topic
membersnumberthe entities below a cluster (1 for an entity)
childrennumbera cluster's direct children (0 for an entity)
degreenumberits links at this level
sccnumberthe size of its strongly connected component - more than 1 = in a cycle
xnumberits center, in the view's unit disk
ynumberits center, in the view's unit disk
rnumberits radius, in the view's unit disk
depthnumberthe hierarchy level of the view it is in (0 = the top)
breakers?GraphBreakerCounts | nulla cluster's breakers, summed over its links at this level (null in an entity view)
attrs?GraphEntityAttrsan entity's attributes
GraphPathStep
One step of a path from the top: a cluster or the node itself.
FieldTypeDescription
idstringthe step's id
labelstringthe step's name
GraphPlugin
A plugin: a node renderer (kind 'node'), edge rules and labels (kind 'edge') or a detail renderer (kind 'detail').
FieldTypeDescription
kind'node' | 'edge' | 'detail' | 'update'node, edge, detail or update
namestringits name (error messages, the HUD)
match?(node: GraphNode) => number | falsenode / detail: a score for a node, or false; the highest wins, the later of equal ones
sizes?GraphSizes | ((node: GraphNode) => GraphSizes)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)
render?(node: GraphNode, ctx: GraphRenderContext) => SafeHtml | Promise<SafeHtml>node / detail: the markup (a detail renderer may return a Promise of it)
key?(node: GraphNode, ctx: GraphRenderContext) => stringnode: a key of what the markup depends on beyond the node (a slot morphs only when it changes)
layer?stringnode: 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)
layerLabel?stringnode / edge: the layer's name in the layer switch (a plugin's own layer)
layerIcon?stringnode / edge: the layer's icon in the layer switch
layerColor?stringnode / edge: the layer's colour, any CSS colour (default: the next chart colour)
title?(node: GraphNode) => stringdetail: the details title for a node
page?(node: GraphNode) => booleandetail: whether a node has a page (default: every node) - without one no Expand button shows and E does nothing
rules?GraphRule[]edge: the style rules (plain data)
label?GraphEdgeLabeledge: the label renderer
detail?GraphEdgeDetailedge: the details of a selected link (the details outlet, its full page when expanded)
update?GraphUpdateRendererupdate: the row renderer of the Updates outlet
GraphRelation
A relation of a cluster at its level (details of a cluster).
FieldTypeDescription
idstringthe other cluster's id
labelstringthe other cluster's name
outnumberlinks from the cluster to it
innumberlinks from it to the cluster
breakerGraphBreaker | nullthe breaker facts of the link
backbooleantrue when the link is the back-edge of a cycle
GraphRenderContext
What a node or detail renderer gets besides the node.
FieldTypeDescription
tier?'chip' | 'card' | 'detail' | 'relations'chip or card for a node in the graph; detail for the details outlet; relations for the relations outlet
selectedbooleantrue when the node is selected
highlightedbooleantrue while the pointer is over the node
focusedbooleantrue when the node has the keyboard focus
depthnumberthe hierarchy level of the view (0 = the top)
zoomnumberthe camera's zoom: screen px per unit of the view's disk
expandedbooleantrue when the details outlet is expanded (its Panel maximized): render the node's full page
layersstring[]the layers shown (data-layers): nodes, relations, labels, health and the plugins'
compactbooleantrue when every node is drawn as a one-line chip (data-compact)
partsSafeHtmla 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)
html(strings: TemplateStringsArray, ...values: unknown[]) => SafeHtmlthe escaping template tag
icon(name: string, options?: GraphIconOptions) => SafeHtmlan icon
action(name: string) => stringthe value of a data-graph-explorer-action attribute: enter, bring, expand, collapse, details
edgeLabel(edge: Partial<GraphEdge>, tier?: 'icon' | 'label' | 'full') => SafeHtmla link drawn by the edge label renderers, at a tier
fetchView(id: string) => Promise<GraphView>a cluster's view, loaded by the worker
fetchDetails(id: string) => Promise<GraphDetails>a node's details, loaded by the worker
fetchEdge(id: string) => Promise<GraphEdgeDetails>an edge's details, loaded by the worker: its links both ways, by relation, its breakers and samples
island(key: string, content: SafeHtml, mount?: GraphIslandMount) => SafeHtmlmarkup 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
expandablebooleantrue 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
fetchRows(id: string) => Promise<GraphRows>every row behind a node or a link, built by the worker: the entities below a cluster, a link's single links
request<T>(key: string, load: () => T | Promise<T>) => T | undefineda 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
GraphRow
One row of GraphRows: plain values.

= Record<string, string | number>

GraphRows
Every row behind a node or a link (ctx.fetchRows) - the records a Data Grid takes.
FieldTypeDescription
idstringthe node's or the link's id
kind'entities' | 'links'entities: every entity below a cluster (or the entity itself); links: a link's single links, both ways
rowsGraphRow[]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
GraphRule
An edge rule: plain data the worker evaluates for every link it draws; the highest priority that matches wins.
FieldTypeDescription
when?GraphCondition[]the conditions, all of which must hold
styleGraphEdgeStylethe style it gives
priority?numberits rank among the rules that match (default 0; a plugin registered on one element adds 1000)
GraphSearchResult
One hit of find(): a cluster or an entity whose name holds the query.
FieldTypeDescription
idstringthe node's id
labelstringthe node's name
kind'cluster' | 'entity'cluster or entity
typestringDomain, a system kind or an entity type
pathstringthe clusters above it, joined by ' › '
parents{ id: string; label: string }[]the clusters above it, the top first - id and name each
GraphSizes
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).
FieldTypeDescription
chip?[number, number]the chip's box
card?[number, number]the card's box
GraphTone
A tone: a color role of the theme (muted text, ink, an accent, a link, healthy, warning, danger).

= 'muted' | 'ink' | 'accent' | 'link' | 'ok' | 'warn' | 'danger'

GraphTreeRow
One node of a subtree (subtree()) - a flat record a tree grid takes (data-parent-field="parent").
FieldTypeDescription
idstringthe node's id (c<number> a cluster, n<number> an entity)
parentstringthe parent's id; '' for the subtree's root
labelstringthe node's name
kind'cluster' | 'entity'cluster or entity
typestringwhat it is: Graph, Domain, a system kind (API, Worker, Store, Stream) or an entity type (Dataset, Endpoint ...)
levelnumberthe hierarchy level in the whole graph (0 = the top)
countnumbera cluster's members (every entity below it), an entity's links (in + out)
childrennumberthe children it has in the graph - also when the subtree stops above them
GraphUpdate
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().
FieldTypeDescription
idstringa unique id (made when an update leaves it out)
timenumberwhen it happened, in ms since the epoch
kind'node' | 'edge'what changed: a node, or a link between two nodes
targetstringthe node's id, or the link's '<from>><to>'
view?stringa link's view: the cluster whose children it joins ('c0' the top level) - a click goes there first
layerstringthe layer it belongs to: it shows while that layer is shown (nodes, relations, labels, health or a plugin's)
typestringa short word for the kind of change: deploy, breaker, traffic, count ...
textstringwhat happened, one line
labelstringthe target's name (the explorer fills it in for a node or link of the view it shows)
tone?GraphToneits tone: ok, warn, danger, accent ... (the row's dot; default: the layer's colour)
GraphUpdateContext
What an update renderer gets beside the update.
FieldTypeDescription
html(strings: TemplateStringsArray, ...values: unknown[]) => SafeHtmlthe escaping template tag every renderer returns its markup through
icon(name: string, opts?: GraphIconOptions) => SafeHtmlan icon of the explorer's set
color(layer: string) => stringa layer's colour (any CSS colour)
time(ms: number) => stringa time, formatted for the row (hours, minutes, seconds)
GraphUpdateRenderer
An update renderer: one compact row of the Updates outlet (the explorer wraps it in the button that selects the target).
FieldTypeDescription
match?(update: GraphUpdate) => number | falsea score for an update, or false; the highest wins, the later of equal ones (default 0)
render(update: GraphUpdate, ctx: GraphUpdateContext) => SafeHtmlthe row's content
GraphView
A cluster's view: its children laid out in its unit disk, and the links between them.
FieldTypeDescription
idstringthe cluster's id (c0 = the top)
pathGraphPathStep[]the path from the top to the cluster
childrenGraphNode[]the cluster's children
edgesGraphEdge[]the links between them
leafViewbooleantrue when the children are entities
SafeHtml
Markup made by html`` - every interpolation escaped; the only value a renderer may return.
FieldTypeDescription
htmlstringthe markup, its interpolations escaped

§CSS view file

/* 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 morph
const 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 there
const 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 in
register('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 in
register('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 big
register('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 freshness
register('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 them
register('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, when
register('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 page
if (!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