Theme
Design your own
On this page (8)

§Details, relations and Expand

The details title is an output (data-graph-explorer-output="details-title"): the renderer's title for the selection, the authored text while nothing is selected - here it sits beside the Panel's own label. Expand - the card's button, the details' button, E or setState(..., { expanded: true }) - maximizes the details Panel; Back, Escape or the Panel's own tool restore it. A Panel holding the relations outlet renders it when it unfolds. The link button at the end of a relation, or a link's label in the graph, selects the link: its details show its traffic both ways, its breakers and who sends and receives most.

§Details beside the graph

Click a domain: the Panel shows its details and the table below its relations. Press E or Expand for its full page - the Panel maximizes over the layout; Back or Escape returns.

§Drill down

An expanded page is the start of a drill-down: every name on it carries data-graph-explorer-goto, and while the page is expanded a click opens that node's page instead of flying the graph there - a domain's systems, a system's entities, an entity's links in and out. The path above the title goes back up; the graph moves along unseen, and the state records only where the drill-down lands, so a router can make every page an address (the Knowledge Graph scaffold does: Back walks back up).

§Every row: grids on the pages

A page holds what is behind a node in full, not a sample: an expanded cluster lists every entity below it in a Data Grid - a domain of a million-node graph holds about 40,000 - and an expanded link every single link both ways. The worker builds the rows (ctx.fetchRows(id)); beside the grid, aggregations of the grid's own df$.dataview source show how the rows its filters match split up - by type, owner and the largest systems, or by relation, way and the busiest senders - and follow every filter. A name in the grid opens its page, so the drill-down goes on from there. A renderer of your own binds a grid or a chart in an island's mount - the Knowledge Graph scaffold puts a million records of a dataset in a grid with charts bound to it.

§A detail renderer of your own

A detail plugin's render(node, ctx) returns the details' markup - or the full page when ctx.expanded is set; title(node) names it. It stays a pure function: data it needs comes through ctx.request(key, load), which returns undefined until the value arrives and then renders again - the worker's own data through ctx.fetchDetails(id), ctx.fetchView(id) and ctx.fetchEdge(id), anything else from your service. The built-in details are made of the library's Badge, Button, Card, Statistic and Table and the explorer's parts - graph-explorer-stats, -section, -rows, -bar, -page, -kpis, -page-grid - and a plugin writes the same classes.

The details re-render whenever the selection or its data changes, and a re-render reconciles the markup: a component with a script would lose what it drew. ctx.island(key, markup) keeps it - the element stays as long as its key and its markup do. Here a Chart of the latency sits in an island in the side details and on the page.

§API systems with their metrics

Enter a domain and pick an API system (the plug icon): its numbers arrive from a pretend metrics service after a moment, its latency is a Chart. Expand shows its page with the latency of the last 24 hours and its endpoints.

§CSS view file

The stylesheet and the script are the Graph Explorer's.

/* Graph Explorer - a knowledge graph of up to millions of nodes: a worker-drawn far field under a bounded pool of DOM
 * nodes, edge labels per zoom level, outlets the page places (breadcrumb, search, outline, details, relations, layers).
 * Its own properties derive from the tweakcn token set only (AGENTS.md "Token boundary rule"); the status colours ok and
 * warn have no token and are literals (amber and green read as degraded and healthy in every theme; a chart colour
 * such as --chart-1 is light blue in the default theme and read as information). The icons are a Lucide subset (ISC, lucide-static 0.469.0 - Copyright (c)
 * Lucide Contributors 2022, portions Cole Bemis 2013-2022 as part of Feather, MIT) drawn as CSS masks: an icon is one
 * empty element, cheap to morph and painted in the current color. */
@layer components {
  /* the explorer and everything bound to it share the graph's colour roles */
  :where(.graph-explorer, [data-graph-explorer-for]) {
    --graph-paper: var(--background);
    --graph-node: var(--popover);
    --graph-node-ink: var(--popover-foreground);
    --graph-edge: color-mix(in oklch, var(--muted-foreground) 55%, transparent);
    --graph-accent: var(--chart-2);
    --graph-link: var(--chart-4);
    --graph-ok: oklch(0.6 0.14 155);
    --graph-warn: oklch(0.72 0.16 65);
    --graph-danger: var(--destructive);
    --graph-focus: var(--ring);
    --graph-radius: var(--radius-md);
  }
  /* a tone is a colour role - inside the explorer and its outlets, and on what renderers draw (an icon on its own
     too: the fallbacks are the roles' defaults) */
  :where(.graph-explorer, [data-graph-explorer-for], [class*="graph-explorer-"]) {
    &[data-tone="muted"],
    & [data-tone="muted"] {
      --tone: var(--muted-foreground);
    }
    &[data-tone="ink"],
    & [data-tone="ink"] {
      --tone: var(--foreground);
    }
    &[data-tone="accent"],
    & [data-tone="accent"] {
      --tone: var(--graph-accent, var(--chart-2));
    }
    &[data-tone="link"],
    & [data-tone="link"] {
      --tone: var(--graph-link, var(--chart-4));
    }
    &[data-tone="ok"],
    & [data-tone="ok"] {
      --tone: var(--graph-ok, oklch(0.6 0.14 155));
    }
    &[data-tone="warn"],
    & [data-tone="warn"] {
      --tone: var(--graph-warn, oklch(0.72 0.16 65));
    }
    &[data-tone="danger"],
    & [data-tone="danger"] {
      --tone: var(--graph-danger, var(--destructive));
    }
  }
  /* ---------------------------------------------------------- the viewport: canvas, edges, slots, labels */
  .graph-explorer {
    position: relative;
    display: block;
    box-sizing: border-box;
    inline-size: 100%;
    block-size: 100%;
    min-block-size: var(--graph-explorer-min-height, 20rem);
    overflow: hidden;
    contain: strict;
    touch-action: none;
    user-select: none;
    cursor: grab;
    outline: none;
    background: var(--graph-paper); /* flat on purpose: a centered gradient reads as an empty cluster */
    color: var(--foreground);
    font-family: var(--font-sans);
    font-size: 0.875rem;
    &:focus-visible {
      box-shadow: inset 0 0 0 2px var(--graph-focus);
    }
    &[data-panning] {
      cursor: grabbing;
    }
    /* framed when it stands alone (in a Panel the panel is the frame) */
    &[data-frame] {
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
    }
    & .graph-explorer-canvas,
    & .graph-explorer-edges,
    & .graph-explorer-world,
    & .graph-explorer-edge-labels,
    & .graph-explorer-signs,
    & .graph-explorer-changes,
    & .graph-explorer-ring {
      position: absolute;
      inset: 0;
    }
    & .graph-explorer-canvas {
      inline-size: 100%;
      block-size: 100%;
      transform-origin: 50% 50%;
      pointer-events: none;
    }
    & .graph-explorer-edges {
      inline-size: 100%;
      block-size: 100%;
      pointer-events: none;
      overflow: visible;
    }
    & .graph-explorer-world,
    & .graph-explorer-signs,
    & .graph-explorer-changes,
    & .graph-explorer-edge-labels {
      pointer-events: none;
    }
    & .graph-explorer-changes {
      overflow: hidden;
    }
    /* a changed node: a glowing dot at its top right in its layer's colour (the Indicator's ping), for a while */
    & .graph-explorer-change-dot {
      position: absolute;
      left: 0;
      top: 0;
      inline-size: 0.625rem;
      block-size: 0.625rem;
      margin: -0.3125rem 0 0 -0.3125rem;
      color: var(--graph-explorer-layer, var(--graph-accent));
      box-shadow:
        0 0 0 2px var(--card),
        0 0 0.5rem 0.125rem color-mix(in oklch, currentColor 70%, transparent);
      animation: graph-explorer-change-fade linear forwards;
    }
    & .graph-explorer-probe {
      position: absolute;
      inline-size: 0;
      block-size: 0;
      overflow: hidden;
    }
    & .graph-explorer-live {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    & .graph-explorer-loading {
      position: absolute;
      inset: 0;
      display: grid;
      place-content: center;
      gap: 0.5rem;
      justify-items: center;
      color: var(--muted-foreground);
      background: var(--graph-paper);
      & progress {
        inline-size: 12rem;
        accent-color: var(--primary);
      }
      &[hidden] {
        display: none;
      }
    }
    /* the pooled node slots */
    & .graph-explorer-node {
      position: absolute;
      left: 0;
      top: 0;
      pointer-events: auto;
      contain: layout style;
      transition: opacity 0.18s ease;
      @starting-style {
        opacity: 0;
      }
      &[hidden] {
        display: none;
      }
    }
    &[data-dim] .graph-explorer-node:not([data-hl]) {
      opacity: 0.32;
    }
    & .graph-explorer-node[data-selected] > * {
      outline: 2px solid var(--graph-focus);
      outline-offset: 1px;
    }
    & .graph-explorer-node[data-kbd] > * {
      box-shadow:
        0 0 0 4px color-mix(in oklch, var(--graph-focus) 40%, transparent),
        var(--shadow-md);
    }
    & .graph-explorer-focus-ring {
      position: absolute;
      left: 0;
      top: 0;
      border-radius: 999px;
      border: 2px solid var(--graph-focus);
      box-shadow: 0 0 0 4px color-mix(in oklch, var(--graph-focus) 30%, transparent);
      pointer-events: none;
      &[hidden] {
        display: none;
      }
    }
    /* the promoted edges (SVG) of the selection, the hover and the keyboard focus */
    & .graph-explorer-edge {
      fill: none;
      stroke: var(--graph-edge);
      stroke-linecap: round;
      &[data-tone] {
        stroke: var(--tone);
      }
      &[data-flow] {
        stroke-dasharray: 2 7;
        animation: graph-explorer-flow 0.9s linear infinite;
      }
      &[data-pulse] {
        animation: graph-explorer-pulse 1.6s ease-in-out infinite;
      }
      /* the selected link */
      &[data-selected] {
        stroke: var(--graph-accent);
        stroke-width: 3.5px;
        filter: drop-shadow(0 0 3px color-mix(in oklch, var(--graph-accent) 55%, transparent));
      }
    }
    /* a changed link: the line lights up in its layer's colour and fades */
    & .graph-explorer-edge-flash {
      fill: none;
      stroke: var(--graph-explorer-layer, var(--graph-accent));
      stroke-linecap: round;
      filter: drop-shadow(0 0 4px var(--graph-explorer-layer, var(--graph-accent)));
      animation: graph-explorer-change-fade ease-out forwards;
    }
    /* a click on a promoted link selects it: a wide transparent stroke under the line */
    & .graph-explorer-edge-hit {
      fill: none;
      stroke: transparent;
      stroke-width: 14px;
      stroke-linecap: round;
      pointer-events: stroke;
      cursor: pointer;
    }
    /* a calm line is the relations layer's colour (set by the runtime from the theme's chart colours) */
    & :is(.graph-explorer-edge, .graph-explorer-edge-head)[data-tone="muted"] {
      --tone: var(--graph-explorer-relations, var(--muted-foreground));
    }
    & .graph-explorer-edge-head {
      fill: var(--graph-edge);
      stroke: none;
      &[data-selected] {
        fill: var(--graph-accent);
      }
      &[data-tone] {
        fill: var(--tone);
      }
    }
    /* the edge labels (DOM, budgeted); a click selects the link */
    & .graph-explorer-edge-label {
      position: absolute;
      left: 0;
      top: 0;
      transform: translate(-50%, -50%);
      white-space: nowrap;
      pointer-events: auto;
      cursor: pointer;
      border-radius: 999px;
      &[hidden] {
        display: none;
      }
      &[data-selected] {
        outline: 2px solid var(--graph-accent);
        outline-offset: 1px;
        z-index: 1;
      }
    }
    /* signposts to relations off screen, and the neighbour ring (Jump to a neighbour) */
    & .graph-explorer-sign {
      position: absolute;
      left: 0;
      top: 0;
      transform: translate(-50%, -50%);
      pointer-events: auto;
      max-inline-size: 14rem;
      padding: 0.125rem 0.5rem;
      border: 1px solid var(--tone, var(--border));
      border-radius: 999px;
      background: color-mix(in oklch, var(--popover) 92%, transparent);
      color: var(--popover-foreground);
      font: inherit;
      font-size: 0.6875rem;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      cursor: pointer;
      box-shadow: var(--shadow-sm);
      &:hover,
      &:focus-visible {
        background: var(--accent);
        outline: 2px solid var(--ring);
      }
    }
    & .graph-explorer-ring {
      pointer-events: auto;
      background: color-mix(in oklch, var(--graph-paper) 55%, transparent);
      &[hidden] {
        display: none;
      }
    }
    & .graph-explorer-ring-center {
      position: absolute;
      left: 0;
      top: 0;
      inline-size: 14px;
      block-size: 14px;
      margin: -7px 0 0 -7px;
      border-radius: 999px;
      background: var(--graph-focus);
      box-shadow: 0 0 0 6px color-mix(in oklch, var(--graph-focus) 30%, transparent);
    }
    & .graph-explorer-ring-item {
      position: absolute;
      left: 0;
      top: 0;
      transform: translate(-50%, -50%);
      transition: translate 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
      transition-delay: calc(var(--k, 0) * 18ms);
      display: flex;
      gap: 0.375rem;
      align-items: center;
      max-inline-size: 11rem;
      padding: 0.25rem 0.625rem;
      border: 1px solid var(--tone, var(--border));
      border-radius: 999px;
      background: var(--popover);
      color: var(--popover-foreground);
      font: inherit;
      font-size: 0.75rem;
      white-space: nowrap;
      overflow: hidden;
      cursor: pointer;
      box-shadow: var(--shadow-md);
      &:hover,
      &:focus-visible {
        outline: 2px solid var(--ring);
      }
    }
    & .graph-explorer-hud {
      position: absolute;
      inset-inline-end: 0.5rem;
      inset-block-end: 0.5rem;
      margin: 0;
      padding: 0.375rem 0.5rem;
      border-radius: var(--radius-sm);
      background: color-mix(in oklch, var(--popover) 88%, transparent);
      color: var(--popover-foreground);
      border: 1px solid var(--border);
      font: 0.6875rem/1.45 var(--font-mono);
      pointer-events: none;
      &[hidden] {
        display: none;
      }
    }
    /* the keyboard map (a popover in the top layer) */
    & .graph-explorer-help {
      max-inline-size: min(34rem, calc(100vw - 2rem));
      padding: 0;
      margin: auto;
      border: 1px solid var(--border);
      cursor: auto;
      user-select: text;
      &::backdrop {
        background: color-mix(in oklch, var(--background) 40%, transparent);
      }
    }
    & .graph-explorer-keys {
      border-collapse: collapse;
      font-size: 0.8125rem;
      & td {
        padding: 0.25rem 0.5rem;
        vertical-align: top;
      }
      & td:first-child {
        white-space: nowrap;
      }
    }
  }
  /* a Panel's body that holds the explorer gives it all its room */
  :where(.panel-body):has(> .graph-explorer) {
    display: grid;
    grid-template: minmax(0, 1fr) / minmax(0, 1fr);
    padding: 0;
    overflow: hidden;
    & > .graph-explorer {
      min-block-size: 0;
    }
  }
  /* ---------------------------------------------------------- what renderers draw: chips, cards, labels */
  .graph-explorer-chip {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    inline-size: 100%;
    block-size: 100%;
    box-sizing: border-box;
    padding: 0 0.5rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--graph-node);
    color: var(--graph-node-ink);
    box-shadow: var(--shadow-xs);
    font-size: 0.75rem;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    &[data-kind="cluster"] {
      font-weight: 500;
    }
  }
  .graph-explorer-card {
    position: relative;
    display: grid;
    align-content: start;
    gap: 0.25rem;
    inline-size: 100%;
    block-size: 100%;
    box-sizing: border-box;
    padding: 0.5rem 0.625rem;
    border: 1px solid var(--border);
    border-radius: var(--graph-radius);
    background: var(--graph-node);
    color: var(--graph-node-ink);
    box-shadow: var(--shadow-md);
    font-size: 0.75rem;
    overflow: hidden;
    cursor: pointer;
    & .graph-explorer-health {
      inline-size: 100%;
      block-size: 8px;
      margin: 0.375rem 0 0.25rem;
    }
  }
  .graph-explorer-card-head {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    min-inline-size: 0;
    font-size: 0.8125rem;
  }
  .graph-explorer-card-meta {
    margin: 0;
    color: var(--muted-foreground);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .graph-explorer-card-flags {
    margin: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    & .badge {
      font-size: 0.6875rem;
    }
  }
  .graph-explorer-card-actions {
    display: flex;
    justify-content: flex-end;
  }
  .graph-explorer-card-expand.btn {
    margin-inline-start: auto;
    inline-size: 1.5rem;
    block-size: 1.5rem;
    min-inline-size: 0;
    padding: 0;
    color: var(--muted-foreground);
  }
  .graph-explorer-label {
    overflow: hidden;
    text-overflow: ellipsis;
    min-inline-size: 0;
    flex: 1 1 auto;
  }
  /* the nodes layer's colour on what it draws: a chip, a card */
  .graph-explorer :is(.graph-explorer-chip, .graph-explorer-card) {
    border-color: color-mix(in oklch, var(--graph-explorer-nodes, var(--border)) 55%, var(--border));
    background: color-mix(in oklch, var(--graph-explorer-nodes, var(--graph-node)) 9%, var(--graph-node));
  }
  /* a layer's part of a node: bordered and tinted in the layer's colour - a band in a card, a pill in a chip */
  .graph-explorer-part {
    --graph-explorer-layer: var(--muted-foreground);
    box-sizing: border-box;
    min-inline-size: 0;
    border: 1px solid color-mix(in oklch, var(--graph-explorer-layer) 70%, transparent);
    background: color-mix(in oklch, var(--graph-explorer-layer) 13%, transparent);
    color: var(--graph-node-ink);
    & .graph-explorer-card-meta,
    & .graph-explorer-meter-row {
      margin: 0;
      color: inherit;
    }
    & .graph-explorer-count {
      margin: 0;
      color: inherit;
    }
    & .graph-explorer-health {
      margin: 0.125rem 0;
    }
  }
  :is(.graph-explorer-card, .graph-explorer-parts-only[data-tier="card"]) > .graph-explorer-part {
    display: grid;
    gap: 0.125rem;
    padding: 0.125rem 0.375rem;
    border-radius: calc(var(--graph-radius) - 2px);
  }
  :is(.graph-explorer-chip, .graph-explorer-parts-only[data-tier="chip"]) > .graph-explorer-part {
    display: inline-flex;
    flex: none;
    align-items: center;
    gap: 0.25rem;
    padding: 0.0625rem 0.3125rem;
    border-radius: 999px;
    line-height: 1;
    &:first-of-type {
      margin-inline-start: auto;
    }
  }
  /* the parts of a node whose own layer is hidden */
  .graph-explorer-parts-only {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    inline-size: 100%;
    block-size: 100%;
    box-sizing: border-box;
    padding-inline: 0.25rem;
    font-size: 0.75rem;
    &[data-tier="card"] {
      display: grid;
      align-content: start;
      padding: 0.375rem;
      border-radius: var(--graph-radius);
      background: color-mix(in oklch, var(--graph-node) 80%, transparent);
      box-shadow: var(--shadow-xs);
    }
  }
  .graph-explorer-count {
    margin-inline-start: auto;
    font-size: 0.6875rem;
    color: var(--muted-foreground);
    font-variant-numeric: tabular-nums;
  }
  .graph-explorer-cycle {
    color: var(--graph-accent);
    font-weight: 700;
  }
  .graph-explorer-muted {
    color: var(--muted-foreground);
    margin: 0;
  }
  .graph-explorer-pad {
    padding: 0.75rem;
  }
  /* a type's color: a dot (the outline) or an icon */
  .graph-explorer-type {
    flex: none;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-radius: 999px;
    background: var(--type-color, var(--muted-foreground));
  }
  .graph-explorer-type-icon {
    color: var(--type-color, currentColor);
  }
  :is(.graph-explorer-type, .graph-explorer-type-icon, .graph-explorer-avatar) {
    &[data-type="0"] {
      --type-color: var(--chart-1);
    }
    &[data-type="1"] {
      --type-color: var(--chart-2);
    }
    &[data-type="2"] {
      --type-color: var(--chart-3);
    }
    &[data-type="3"] {
      --type-color: var(--chart-4);
    }
    &[data-type="4"] {
      --type-color: var(--chart-5);
    }
    &[data-type="5"] {
      --type-color: oklch(from var(--chart-1) l c calc(h + 150));
    }
    &[data-type="6"] {
      --type-color: oklch(from var(--chart-1) l c calc(h + 210));
    }
    &[data-type="7"] {
      --type-color: oklch(from var(--chart-1) l c calc(h + 280));
    }
  }
  .badge.graph-explorer-warn,
  .badge.graph-explorer-state[data-state="half-open"] {
    background: color-mix(in oklch, var(--graph-warn) 18%, transparent);
    color: var(--foreground);
    border-color: color-mix(in oklch, var(--graph-warn) 60%, transparent);
  }
  .badge.graph-explorer-state[data-state="open"] {
    background: var(--destructive);
    color: var(--destructive-foreground, white);
  }
  .badge.graph-explorer-state[data-state="cut"] {
    background: transparent;
    border-color: var(--foreground);
    color: var(--foreground);
  }
  /* the Health and Owners layers */
  .graph-explorer-health {
    display: flex;
    gap: 1px;
    flex: 0 0 auto;
    inline-size: 44px;
    block-size: 6px;
    border-radius: 999px;
    overflow: hidden;
    background: var(--muted);
    & > span {
      min-inline-size: 0;
      background: var(--tone);
    }
    & > span[style*="flex:0"] {
      display: none;
    }
  }
  .graph-explorer-pill {
    flex: none;
    padding: 0 6px;
    border-radius: 999px;
    font-size: 0.625rem;
    line-height: 16px;
    background: color-mix(in oklch, var(--tone) 16%, transparent);
    color: var(--foreground);
  }
  .graph-explorer-meter-row {
    display: grid;
    grid-template-columns: 4.5rem 1fr auto;
    align-items: center;
    gap: 0.5rem;
    margin: 0.375rem 0 0;
    font-size: 0.6875rem;
    color: var(--muted-foreground);
  }
  .graph-explorer-meter {
    block-size: 6px;
    border-radius: 999px;
    background: var(--muted);
    overflow: hidden;
    & > span {
      display: block;
      block-size: 100%;
      background: var(--tone);
    }
  }
  .graph-explorer-avatar {
    flex: none;
    display: inline-grid;
    place-items: center;
    inline-size: 20px;
    block-size: 20px;
    border-radius: 999px;
    font-size: 0.5625rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    color: var(--background);
    background: var(--type-color, var(--chart-1));
  }
  /* edge labels: an icon, a count, a pill or a badge */
  .graph-explorer-edge-icon {
    display: inline-grid;
    place-items: center;
    inline-size: 20px;
    block-size: 20px;
    border-radius: 999px;
    background: var(--popover);
    color: var(--muted-foreground);
    border: 1px solid color-mix(in oklch, var(--tone, var(--muted-foreground)) 45%, transparent);
    box-shadow: var(--shadow-xs);
    &[data-tone] {
      color: var(--tone);
    }
    & .graph-explorer-icon {
      inline-size: 12px;
      block-size: 12px;
    }
  }
  .graph-explorer-edge-count {
    display: inline-block;
    min-inline-size: 20px;
    padding: 0 5px;
    border-radius: 999px;
    text-align: center;
    background: var(--popover);
    color: var(--muted-foreground);
    border: 1px solid var(--border);
    font-size: 0.625rem;
    line-height: 16px;
    font-variant-numeric: tabular-nums;
    box-shadow: var(--shadow-xs);
  }
  .graph-explorer-edge-pill,
  .graph-explorer-edge-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 1px 7px 1px 5px;
    border-radius: 999px;
    border: 1px solid var(--tone, var(--border));
    background: var(--popover);
    color: var(--popover-foreground);
    box-shadow: var(--shadow-xs);
    font-size: 0.6875rem;
    line-height: 18px;
    white-space: nowrap;
    & .graph-explorer-icon {
      inline-size: 12px;
      block-size: 12px;
    }
    & .graph-explorer-muted {
      font-size: 0.625rem;
    }
  }
  .graph-explorer-edge-badge[data-tone="danger"] {
    background: var(--destructive);
    color: var(--destructive-foreground, white);
    border-color: var(--destructive);
    & .graph-explorer-icon {
      color: inherit;
    }
  }
  .graph-explorer-edge-badge[data-tone="warn"] {
    border-color: var(--graph-warn);
  }
  .graph-explorer-edge-sub {
    opacity: 0.8;
    font-size: 0.625rem;
  }
  /* ---------------------------------------------------------- the outlets */
  .graph-explorer-path {
    min-inline-size: 0;
    overflow: hidden;
    & .breadcrumb-list {
      flex-wrap: nowrap;
      white-space: nowrap;
    }
  }
  .graph-explorer-search {
    position: relative;
    min-inline-size: 12rem;
  }
  .graph-explorer-search-input.input {
    inline-size: 100%;
    padding-inline-start: 2rem;
  }
  .graph-explorer-search-icon {
    position: absolute;
    inset-inline-start: 0.625rem;
    top: 50%;
    translate: 0 -50%;
    color: var(--muted-foreground);
    pointer-events: none;
  }
  .graph-explorer-results {
    position: absolute;
    inset-inline: 0;
    top: calc(100% + 4px);
    z-index: 20;
    margin: 0;
    padding: 0.25rem;
    list-style: none;
    max-block-size: 22rem;
    overflow: auto;
    overscroll-behavior: contain;
    background: var(--popover);
    color: var(--popover-foreground);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-lg);
    & [role="option"] {
      display: grid;
      gap: 0.125rem;
      padding: 0.375rem 0.5rem;
      border-radius: var(--radius-sm);
      cursor: pointer;
      &[aria-selected="true"],
      &:hover {
        background: var(--accent);
        color: var(--accent-foreground);
      }
    }
    &[hidden] {
      display: none;
    }
  }
  .graph-explorer-result-label {
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .graph-explorer-result-path,
  .graph-explorer-result-more {
    font-size: 0.75rem;
    color: var(--muted-foreground);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .graph-explorer-result-more {
    padding: 0.375rem 0.5rem;
  }
  /* the layer switch: a Toggle Group, every layer with its colour as an Indicator dot and its tint when shown;
     Compact after a Separator - the toggles keep the Toggle's own sizes (a rule of the same weight here would win or
     lose by the order the stylesheets come in: graphs.css loads after all.css, an app bundle sorts by name) */
  .graph-explorer-layers {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    min-inline-size: 0;
    & .toggle-group {
      gap: 0.25rem;
    }
    & .separator[data-orientation="vertical"] {
      block-size: 1.25rem;
    }
    & .graph-explorer-icon {
      inline-size: 0.875rem;
      block-size: 0.875rem;
    }
    & .toggle.graph-explorer-layer {
      --graph-explorer-layer: var(--muted-foreground);
      & .indicator-dot {
        color: var(--graph-explorer-layer);
        background-color: transparent;
        box-shadow: inset 0 0 0 1.5px currentColor;
      }
      &:hover {
        background-color: color-mix(in oklch, var(--graph-explorer-layer) 10%, transparent);
      }
      &[aria-pressed="true"] {
        background-color: color-mix(in oklch, var(--graph-explorer-layer) 18%, transparent);
        box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--graph-explorer-layer) 60%, transparent);
        color: var(--foreground);
        & .indicator-dot {
          background-color: currentColor;
        }
        &:hover {
          background-color: color-mix(in oklch, var(--graph-explorer-layer) 28%, transparent);
        }
      }
    }
  }
  /* an edge label in its layer's colour (a breaker's danger badge keeps its own) */
  .graph-explorer-edge-label[data-layer]
    > :is(
      .graph-explorer-edge-pill,
      .graph-explorer-edge-badge,
      .graph-explorer-edge-count,
      .graph-explorer-edge-icon
    ):not([data-tone="danger"]) {
    border-color: color-mix(in oklch, var(--graph-explorer-layer) 75%, transparent);
    background: color-mix(in oklch, var(--graph-explorer-layer) 14%, var(--popover));
  }
  [data-graph-explorer-action="live"][aria-pressed="true"] {
    background: var(--primary);
    color: var(--primary-foreground);
    border-color: var(--primary);
  }
  .graph-explorer-outline {
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    block-size: 100%;
    min-block-size: 0;
    overflow: hidden;
  }
  .graph-explorer-outline-head {
    padding: 0.5rem;
    border-block-end: 1px solid var(--border);
  }
  .graph-explorer-outline-filter.input {
    inline-size: 100%;
  }
  .graph-explorer-outline-list {
    list-style: none;
    margin: 0;
    padding: 0.25rem;
    overflow: auto;
    overscroll-behavior: contain;
    & li[aria-selected="true"] > .graph-explorer-outline-item {
      background: var(--secondary);
      color: var(--secondary-foreground);
      font-weight: 600;
    }
  }
  .graph-explorer-outline-item {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    inline-size: 100%;
    padding: 0.25rem 0.375rem;
    padding-inline-start: calc(0.375rem + var(--depth, 0) * 0.625rem);
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.8125rem;
    text-align: start;
    cursor: pointer;
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  .graph-explorer-outline-path .graph-explorer-outline-item {
    color: var(--muted-foreground);
    font-weight: 500;
  }
  .graph-explorer-outline-path:last-of-type .graph-explorer-outline-item {
    color: var(--foreground);
  }
  .graph-explorer-outline-more {
    padding: 0.5rem;
    font-size: 0.75rem;
    color: var(--muted-foreground);
  }
  /* the details (a node's or a link's), and their whole page when expanded. Built from the library's Badge,
     Button, Card, Statistic and Table plus the parts below; plugins use the same classes */
  .graph-explorer-details {
    display: grid;
    align-content: start;
    gap: 0.875rem;
    container-type: inline-size;
    font-size: 0.8125rem;
  }
  .graph-explorer-island {
    min-inline-size: 0;
  }
  .graph-explorer-empty {
    display: grid;
    justify-items: center;
    gap: 0.25rem;
    padding: 2rem 0.5rem;
    text-align: center;
    color: var(--muted-foreground);
    & p {
      margin: 0;
    }
    & > .graph-explorer-icon {
      opacity: 0.5;
    }
  }
  .graph-explorer-detail-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: 0.75rem;
  }
  .graph-explorer-detail-tile {
    display: grid;
    place-items: center;
    inline-size: 2.5rem;
    block-size: 2.5rem;
    border-radius: var(--radius-md);
    background: var(--muted);
    color: var(--graph-accent);
    box-shadow: inset 0 0 0 1px var(--border);
    &[data-size="xl"] {
      inline-size: 3.5rem;
      block-size: 3.5rem;
      border-radius: var(--radius-lg);
    }
  }
  .graph-explorer-detail-titles,
  .graph-explorer-page-titles {
    display: grid;
    gap: 0.125rem;
    min-inline-size: 0;
  }
  .graph-explorer-detail-kicker {
    margin: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    font-size: 0.8125rem;
    font-weight: 500;
    color: var(--foreground);
  }
  .graph-explorer-cycle-note {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    color: var(--muted-foreground);
    font-weight: 400;
  }
  .graph-explorer-trail {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    font-size: 0.75rem;
    color: var(--muted-foreground);
    & .graph-explorer-link {
      color: inherit;
    }
  }
  .graph-explorer-trail-sep {
    opacity: 0.6;
  }
  .graph-explorer-detail-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.375rem;
  }
  /* facts: a grid of label and value cells */
  .graph-explorer-stats {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1px;
    margin: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--border);
  }
  .graph-explorer-stat {
    display: grid;
    gap: 0.125rem;
    min-inline-size: 0;
    padding: 0.5rem 0.625rem;
    background: var(--card);
    & dt {
      font-size: 0.6875rem;
      color: var(--muted-foreground);
    }
    & dd {
      margin: 0;
      font-size: 0.9375rem;
      font-weight: 600;
      font-variant-numeric: tabular-nums;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    &[data-tone] dd {
      color: var(--tone);
    }
    & .badge {
      font-size: 0.6875rem;
    }
  }
  .graph-explorer-section {
    display: grid;
    gap: 0.25rem;
    padding-block-start: 0.75rem;
    border-block-start: 1px solid var(--border);
  }
  .graph-explorer-section-title {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin: 0 0 0.125rem;
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--muted-foreground);
  }
  .graph-explorer-section-count {
    padding: 0 0.375rem;
    border-radius: 999px;
    background: var(--muted);
    font-variant-numeric: tabular-nums;
    letter-spacing: 0;
  }
  /* a list of nodes: a click opens one, the link button inspects the link to it */
  .graph-explorer-rows {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 1px;
  }
  .graph-explorer-row {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    min-inline-size: 0;
    border-radius: var(--radius-sm);
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    & > .graph-explorer-bar {
      flex: 0 0 3.5rem;
      margin-inline-end: 0.375rem;
    }
    & > .badge {
      flex: none;
      font-size: 0.625rem;
    }
  }
  .graph-explorer-row-main {
    flex: 1;
    min-inline-size: 0;
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.3125rem 0.375rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    cursor: pointer;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
    & > .graph-explorer-icon {
      color: var(--muted-foreground);
    }
  }
  .graph-explorer-row-name {
    flex: 1;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .graph-explorer-row-meta {
    display: flex;
    gap: 0.375rem;
    font-size: 0.6875rem;
    color: var(--muted-foreground);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  .graph-explorer-row-link,
  .graph-explorer-edge-btn {
    flex: none;
    display: inline-grid;
    place-items: center;
    border: 0;
    background: none;
    color: var(--muted-foreground);
    font: inherit;
    cursor: pointer;
    &:hover {
      color: var(--foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 1px;
    }
  }
  .graph-explorer-row-link {
    inline-size: 1.625rem;
    block-size: 1.625rem;
    border-radius: var(--radius-sm);
    &:hover {
      background: var(--muted);
    }
  }
  .graph-explorer-edge-btn {
    padding: 0;
    border-radius: 999px;
  }
  .graph-explorer-more {
    margin: 0.125rem 0 0;
    padding-inline: 0.375rem;
    font-size: 0.6875rem;
    color: var(--muted-foreground);
  }
  /* a share as a bar, in the tone of its kind */
  .graph-explorer-bar {
    display: block;
    block-size: 0.375rem;
    min-inline-size: 2rem;
    border-radius: 999px;
    background: color-mix(in oklch, var(--tone, var(--graph-accent)) 16%, transparent);
    overflow: hidden;
    & > span {
      display: block;
      block-size: 100%;
      border-radius: inherit;
      background: var(--tone, var(--graph-accent));
    }
  }
  .graph-explorer-shares {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 0.5rem;
    & li {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 0.25rem 0.75rem;
      align-items: center;
    }
    & .graph-explorer-bar {
      grid-column: 1 / -1;
    }
  }
  .graph-explorer-share-name {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    min-inline-size: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .graph-explorer-share-value {
    font-size: 0.75rem;
    color: var(--muted-foreground);
    font-variant-numeric: tabular-nums;
  }
  .graph-explorer-legend {
    list-style: none;
    margin: 0.375rem 0 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem 0.875rem;
    font-size: 0.6875rem;
    color: var(--muted-foreground);
    font-variant-numeric: tabular-nums;
    & li {
      display: inline-flex;
      align-items: center;
      gap: 0.375rem;
      &::before {
        content: "";
        inline-size: 0.5rem;
        block-size: 0.5rem;
        border-radius: 2px;
        background: var(--tone);
      }
    }
  }
  /* the two ends of a link */
  .graph-explorer-ends {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    align-items: center;
    gap: 0.5rem;
  }
  .graph-explorer-ends-arrow {
    display: grid;
    place-items: center;
    color: var(--muted-foreground);
  }
  .graph-explorer-end {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: 0 0.5rem;
    min-inline-size: 0;
    padding: 0.5rem 0.625rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--card);
    color: var(--card-foreground);
    font: inherit;
    text-align: start;
    cursor: pointer;
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 1px;
    }
    & > .graph-explorer-icon {
      grid-row: span 2;
    }
  }
  .graph-explorer-end-name {
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .graph-explorer-end-type {
    font-size: 0.6875rem;
    color: var(--muted-foreground);
  }
  .graph-explorer-rel-table {
    font-size: 0.8125rem;
    & :is(.table-cell, .table-head) {
      padding-block: 0.3rem;
    }
    & tr[data-graph-explorer-goto] {
      cursor: pointer;
      &:hover {
        background: var(--accent);
      }
    }
    & .graph-explorer-rel-edge :is(.graph-explorer-edge-pill, .graph-explorer-edge-badge) {
      box-shadow: none;
    }
    & .graph-explorer-bar-col {
      inline-size: 8rem;
    }
  }
  .graph-explorer-rel-type {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
  }
  .graph-explorer-relations .table-container {
    border: 0;
    border-radius: 0;
  }
  .graph-explorer-link {
    border: 0;
    padding: 0;
    background: none;
    color: inherit;
    font: inherit;
    cursor: pointer;
    text-align: start;
    &:hover {
      text-decoration: underline;
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
    }
    & > .graph-explorer-icon {
      color: var(--muted-foreground);
    }
  }
  /* the expanded page: a head, key figures as Statistics, Cards in a grid */
  .graph-explorer-page {
    display: grid;
    gap: 1.25rem;
    padding: 1rem 1.25rem 2rem;
    & .graph-explorer-health {
      inline-size: 100%;
      block-size: 12px;
    }
    & > .graph-explorer-ends {
      max-inline-size: 44rem;
    }
  }
  .graph-explorer-page-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: 1rem;
  }
  /* an expanded page's "Back to graph" points toward the graph: a row of its own above the head, at its start - at
     its end when the details sit in a west region (the graph is then to their right). Any renderer that puts the
     collapse action among its actions gets it. */
  .graph-explorer-page-head:has(> .graph-explorer-detail-actions > [data-graph-explorer-action="collapse"]) {
    position: relative;
    padding-block-start: 2.75rem;
    & > .graph-explorer-detail-actions > [data-graph-explorer-action="collapse"] {
      position: absolute;
      inset-block-start: 0;
      inset-inline-start: 0;
    }
  }
  :where(.border-layout-west)
    .graph-explorer-page-head
    > .graph-explorer-detail-actions
    > [data-graph-explorer-action="collapse"] {
    inset-inline: auto 0;
  }
  .graph-explorer-page-title {
    margin: 0;
    font-size: 1.5rem;
    font-weight: 650;
    letter-spacing: -0.015em;
    overflow-wrap: anywhere;
  }
  .graph-explorer-kpis {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
    gap: 0.75rem;
  }
  .graph-explorer-kpi {
    padding: 0.875rem 1rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
    box-shadow: var(--shadow-xs);
    & .statistic-value {
      font-size: 1.5rem;
      font-variant-numeric: tabular-nums;
    }
    &[data-tone] .statistic-value {
      color: var(--tone);
    }
  }
  .graph-explorer-page-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
    gap: 1rem;
    align-items: start;
  }
  /* every row behind a node or a link: a Data Grid and the shares of what its filters match */
  .graph-explorer-records {
    display: grid;
    gap: 0.75rem;
    min-inline-size: 0;
  }
  .graph-explorer-records-count {
    margin: 0;
    font-size: 0.8125rem;
    font-variant-numeric: tabular-nums;
  }
  .graph-explorer-records-shares {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
    gap: 1rem 1.5rem;
    font-size: 0.8125rem;
    &:empty {
      display: none;
    }
  }
  .graph-explorer-records-share {
    display: grid;
    gap: 0.5rem;
    align-content: start;
    min-inline-size: 0;
  }
  .graph-explorer-grid {
    --data-grid-row-height: 30px;
    font-size: 0.8125rem;
    & .graph-explorer-link {
      max-inline-size: 100%;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
  .graph-explorer-page-card {
    min-inline-size: 0;
    &[data-wide] {
      grid-column: 1 / -1;
    }
    & .card-title {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      & > .graph-explorer-icon {
        color: var(--muted-foreground);
      }
    }
    & .card-content {
      display: grid;
      gap: 0.75rem;
      min-inline-size: 0;
    }
  }
  @container (max-width: 48rem) {
    .graph-explorer-page-head {
      grid-template-columns: auto minmax(0, 1fr);
      & .graph-explorer-detail-actions {
        grid-column: 1 / -1;
      }
    }
  }
  @container (max-width: 34rem) {
    .graph-explorer-layer-label {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }
  /* ---------------------------------------------------------- icons */
  .graph-explorer-icon {
    display: inline-block;
    flex: none;
    inline-size: 1rem;
    block-size: 1rem;
    vertical-align: middle;
    background-color: currentColor;
    -webkit-mask: var(--graph-explorer-icon) center / 100% 100% no-repeat;
    mask: var(--graph-explorer-icon) center / 100% 100% no-repeat;
    &[data-size="xs"] {
      inline-size: 0.75rem;
      block-size: 0.75rem;
    }
    &[data-size="sm"] {
      inline-size: 0.875rem;
      block-size: 0.875rem;
    }
    &[data-size="lg"] {
      inline-size: 1.5rem;
      block-size: 1.5rem;
    }
    &[data-size="xl"] {
      inline-size: 2rem;
      block-size: 2rem;
    }
    &[data-tone] {
      color: var(--tone);
    }
  }
  @keyframes graph-explorer-flow {
    to {
      stroke-dashoffset: -9;
    }
  }
  @keyframes graph-explorer-pulse {
    50% {
      opacity: 0.35;
    }
  }
  /* a change stays bright, then fades (the runtime takes the mark away when it ends) */
  @keyframes graph-explorer-change-fade {
    0%,
    60% {
      opacity: 1;
    }
    100% {
      opacity: 0;
    }
  }
  /* ---------------------------------------------------------- the Updates outlet: one compact row per change */
  /* .virtual-list too: the list's own row height would win where its sheet comes later (an app bundle) */
  .graph-explorer-updates.virtual-list,
  .graph-explorer-updates {
    --virtual-list-row-height: 2rem;
    box-sizing: border-box;
    block-size: 100%;
    min-block-size: 6rem;
    border: 0;
    border-radius: 0;
    background: transparent;
    & .virtual-list-row {
      padding: 0;
    }
  }
  .graph-explorer-update {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    box-sizing: border-box;
    inline-size: 100%;
    block-size: 2rem;
    padding: 0 0.625rem;
    border: 0;
    border-block-end: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
    background: transparent;
    color: var(--foreground);
    font: inherit;
    font-size: 0.8125rem;
    text-align: start;
    cursor: pointer;
    & > .indicator-dot {
      color: var(--graph-explorer-layer, var(--graph-accent));
      &[data-tone] {
        color: var(--tone);
      }
    }
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  .graph-explorer-update-label {
    flex: 0 1 auto;
    max-inline-size: 45%;
    overflow: hidden;
    font-weight: 500;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
  .graph-explorer-update-text {
    flex: 1 1 0;
    min-inline-size: 0;
    overflow: hidden;
    color: var(--muted-foreground);
    white-space: nowrap;
    text-overflow: ellipsis;
  }
  .graph-explorer-update-time {
    flex: none;
    color: var(--muted-foreground);
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
  }
  @media (prefers-reduced-motion: reduce) {
    .graph-explorer .graph-explorer-edge,
    .graph-explorer :is(.graph-explorer-change-dot, .graph-explorer-edge-flash) {
      animation: none !important;
    }
    .graph-explorer :is(.graph-explorer-node, .graph-explorer-ring-item) {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .graph-explorer .graph-explorer-edge {
      stroke-width: 2.75;
    }
    :is(.graph-explorer-chip, .graph-explorer-card) {
      border-width: 2px;
      border-color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .graph-explorer-part {
      border-color: CanvasText;
    }
    .graph-explorer .graph-explorer-node > :is(.graph-explorer-chip, .graph-explorer-card) {
      border-color: CanvasText;
      background: Canvas;
    }
    :is(.graph-explorer-chip, .graph-explorer-card, .graph-explorer-sign, .graph-explorer-ring-item) {
      border: 1px solid CanvasText;
      background: Canvas;
      color: CanvasText;
      forced-color-adjust: none;
    }
    .graph-explorer .graph-explorer-edge {
      stroke: CanvasText;
    }
    .graph-explorer .graph-explorer-edge-flash {
      stroke: Highlight;
      filter: none;
    }
    .graph-explorer .graph-explorer-change-dot {
      forced-color-adjust: none;
      color: Highlight;
      box-shadow: none;
    }
    .graph-explorer-update {
      border-block-end-color: CanvasText;
      &:is(:hover, :focus-visible) {
        outline: 2px solid Highlight;
      }
    }
    .graph-explorer .graph-explorer-edge-head {
      fill: CanvasText;
    }
    .graph-explorer .graph-explorer-node[data-selected] > * {
      outline-color: Highlight;
    }
    .graph-explorer-icon {
      background-color: CanvasText;
      forced-color-adjust: none;
    }
  }
  /* the icon set: a Lucide subset (see the header) */
  .graph-explorer-icon[data-icon="chevron-up"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="chevron-down"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="maximize-2"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 3 21 3 21 9' /%3E %3Cpolyline points='9 21 3 21 3 15' /%3E %3Cline x1='21' x2='14' y1='3' y2='10' /%3E %3Cline x1='3' x2='10' y1='21' y2='14' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="minimize-2"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='4 14 10 14 10 20' /%3E %3Cpolyline points='20 10 14 10 14 4' /%3E %3Cline x1='14' x2='21' y1='10' y2='3' /%3E %3Cline x1='3' x2='10' y1='21' y2='14' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="x"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18' /%3E %3Cpath d='m6 6 12 12' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="list-tree"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12h-8' /%3E %3Cpath d='M21 6H8' /%3E %3Cpath d='M21 18h-8' /%3E %3Cpath d='M3 6v4c0 1.1.9 2 2 2h3' /%3E %3Cpath d='M3 10v6c0 1.1.9 2 2 2h3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="waypoints"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='4.5' r='2.5' /%3E %3Cpath d='m10.2 6.3-3.9 3.9' /%3E %3Ccircle cx='4.5' cy='12' r='2.5' /%3E %3Cpath d='M7 12h10' /%3E %3Ccircle cx='19.5' cy='12' r='2.5' /%3E %3Cpath d='m13.8 17.7 3.9-3.9' /%3E %3Ccircle cx='12' cy='19.5' r='2.5' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="panel-right"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M15 3v18' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="rows-3"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M21 9H3' /%3E %3Cpath d='M21 15H3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="database"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3' /%3E %3Cpath d='M3 5V19A9 3 0 0 0 21 19V5' /%3E %3Cpath d='M3 12A9 3 0 0 0 21 12' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="plug"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22v-5' /%3E %3Cpath d='M9 8V2' /%3E %3Cpath d='M15 8V2' /%3E %3Cpath d='M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="file-text"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' /%3E %3Cpath d='M14 2v4a2 2 0 0 0 2 2h4' /%3E %3Cpath d='M10 9H8' /%3E %3Cpath d='M16 13H8' /%3E %3Cpath d='M16 17H8' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="user"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2' /%3E %3Ccircle cx='12' cy='7' r='4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="clock"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpolyline points='12 6 12 12 16 14' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="radio"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.9 19.1C1 15.2 1 8.8 4.9 4.9' /%3E %3Cpath d='M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5' /%3E %3Ccircle cx='12' cy='12' r='2' /%3E %3Cpath d='M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5' /%3E %3Cpath d='M19.1 4.9C23 8.8 23 15.1 19.1 19' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="plug-zap"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z' /%3E %3Cpath d='m2 22 3-3' /%3E %3Cpath d='M7.5 13.5 10 11' /%3E %3Cpath d='M10.5 16.5 13 14' /%3E %3Cpath d='m18 3-4 4h6l-4 4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="cog"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z' /%3E %3Cpath d='M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' /%3E %3Cpath d='M12 2v2' /%3E %3Cpath d='M12 22v-2' /%3E %3Cpath d='m17 20.66-1-1.73' /%3E %3Cpath d='M11 10.27 7 3.34' /%3E %3Cpath d='m20.66 17-1.73-1' /%3E %3Cpath d='m3.34 7 1.73 1' /%3E %3Cpath d='M14 12h8' /%3E %3Cpath d='M2 12h2' /%3E %3Cpath d='m20.66 7-1.73 1' /%3E %3Cpath d='m3.34 17 1.73-1' /%3E %3Cpath d='m17 3.34-1 1.73' /%3E %3Cpath d='m11 13.73-4 6.93' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="waves"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E %3Cpath d='M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E %3Cpath d='M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="boxes"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z' /%3E %3Cpath d='m7 16.5-4.74-2.85' /%3E %3Cpath d='m7 16.5 5-3' /%3E %3Cpath d='M7 16.5v5.17' /%3E %3Cpath d='M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z' /%3E %3Cpath d='m17 16.5-5-3' /%3E %3Cpath d='m17 16.5 4.74-2.85' /%3E %3Cpath d='M17 16.5v5.17' /%3E %3Cpath d='M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z' /%3E %3Cpath d='M12 8 7.26 5.15' /%3E %3Cpath d='m12 8 4.74-2.85' /%3E %3Cpath d='M12 13.5V8' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="box"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z' /%3E %3Cpath d='m3.3 7 8.7 5 8.7-5' /%3E %3Cpath d='M12 22V12' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="zap"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="eye"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0' /%3E %3Ccircle cx='12' cy='12' r='3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="key-round"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z' /%3E %3Ccircle cx='16.5' cy='7.5' r='.5' fill='currentColor' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="link"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' /%3E %3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="link-2"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 17H7A5 5 0 0 1 7 7h2' /%3E %3Cpath d='M15 7h2a5 5 0 1 1 0 10h-2' /%3E %3Cline x1='8' x2='16' y1='12' y2='12' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="zap-off"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.513 4.856 13.12 2.17a.5.5 0 0 1 .86.46l-1.377 4.317' /%3E %3Cpath d='M15.656 10H20a1 1 0 0 1 .78 1.63l-1.72 1.773' /%3E %3Cpath d='M16.273 16.273 10.88 21.83a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14H4a1 1 0 0 1-.78-1.63l4.507-4.643' /%3E %3Cpath d='m2 2 20 20' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="circle-dashed"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.1 2.182a10 10 0 0 1 3.8 0' /%3E %3Cpath d='M13.9 21.818a10 10 0 0 1-3.8 0' /%3E %3Cpath d='M17.609 3.721a10 10 0 0 1 2.69 2.7' /%3E %3Cpath d='M2.182 13.9a10 10 0 0 1 0-3.8' /%3E %3Cpath d='M20.279 17.609a10 10 0 0 1-2.7 2.69' /%3E %3Cpath d='M21.818 10.1a10 10 0 0 1 0 3.8' /%3E %3Cpath d='M3.721 6.391a10 10 0 0 1 2.7-2.69' /%3E %3Cpath d='M6.391 20.279a10 10 0 0 1-2.69-2.7' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="scissors"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3' /%3E %3Cpath d='M8.12 8.12 12 12' /%3E %3Cpath d='M20 4 8.12 15.88' /%3E %3Ccircle cx='6' cy='18' r='3' /%3E %3Cpath d='M14.8 14.8 20 20' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="shield-check"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' /%3E %3Cpath d='m9 12 2 2 4-4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="triangle-alert"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3' /%3E %3Cpath d='M12 9v4' /%3E %3Cpath d='M12 17h.01' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="search"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8' /%3E %3Cpath d='m21 21-4.3-4.3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="arrow-up"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 7-7 7 7' /%3E %3Cpath d='M12 19V5' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="scan"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V5a2 2 0 0 1 2-2h2' /%3E %3Cpath d='M17 3h2a2 2 0 0 1 2 2v2' /%3E %3Cpath d='M21 17v2a2 2 0 0 1-2 2h-2' /%3E %3Cpath d='M7 21H5a2 2 0 0 1-2-2v-2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="radio-tower"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.9 16.1C1 12.2 1 5.8 4.9 1.9' /%3E %3Cpath d='M7.8 4.7a6.14 6.14 0 0 0-.8 7.5' /%3E %3Ccircle cx='12' cy='9' r='2' /%3E %3Cpath d='M16.2 4.8c2 2 2.26 5.11.8 7.47' /%3E %3Cpath d='M19.1 1.9a9.96 9.96 0 0 1 0 14.1' /%3E %3Cpath d='M9.5 18h5' /%3E %3Cpath d='m8 22 4-11 4 11' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="keyboard"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 8h.01' /%3E %3Cpath d='M12 12h.01' /%3E %3Cpath d='M14 8h.01' /%3E %3Cpath d='M16 12h.01' /%3E %3Cpath d='M18 8h.01' /%3E %3Cpath d='M6 8h.01' /%3E %3Cpath d='M7 16h10' /%3E %3Cpath d='M8 12h.01' /%3E %3Crect width='20' height='16' x='2' y='4' rx='2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="expand"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21 21-6-6m6 6v-4.8m0 4.8h-4.8' /%3E %3Cpath d='M3 16.2V21m0 0h4.8M3 21l6-6' /%3E %3Cpath d='M21 7.8V3m0 0h-4.8M21 3l-6 6' /%3E %3Cpath d='M3 7.8V3m0 0h4.8M3 3l6 6' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="shrink"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 15 6 6m-6-6v4.8m0-4.8h4.8' /%3E %3Cpath d='M9 19.8V15m0 0H4.2M9 15l-6 6' /%3E %3Cpath d='M15 4.2V9m0 0h4.8M15 9l6-6' /%3E %3Cpath d='M9 4.2V9m0 0H4.2M9 9 3 3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="repeat-2"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m2 9 3-3 3 3' /%3E %3Cpath d='M13 18H7a2 2 0 0 1-2-2V6' /%3E %3Cpath d='m22 15-3 3-3-3' /%3E %3Cpath d='M11 6h6a2 2 0 0 1 2 2v10' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="refresh-cw"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8' /%3E %3Cpath d='M21 3v5h-5' /%3E %3Cpath d='M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16' /%3E %3Cpath d='M8 16H3v5' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="users"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' /%3E %3Ccircle cx='9' cy='7' r='4' /%3E %3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87' /%3E %3Cpath d='M16 3.13a4 4 0 0 1 0 7.75' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="activity"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="layers"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z' /%3E %3Cpath d='M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12' /%3E %3Cpath d='M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="gauge"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 14 4-4' /%3E %3Cpath d='M3.34 19a10 10 0 1 1 17.32 0' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="circle-alert"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cline x1='12' x2='12' y1='8' y2='12' /%3E %3Cline x1='12' x2='12.01' y1='16' y2='16' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="arrow-right"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14' /%3E %3Cpath d='m12 5 7 7-7 7' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="arrow-left"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 19-7-7 7-7' /%3E %3Cpath d='M19 12H5' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="arrow-left-right"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 3 4 7l4 4' /%3E %3Cpath d='M4 7h16' /%3E %3Cpath d='m16 21 4-4-4-4' /%3E %3Cpath d='M20 17H4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="git-merge"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='18' r='3' /%3E %3Ccircle cx='6' cy='6' r='3' /%3E %3Cpath d='M6 21V9a9 9 0 0 0 9 9' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="network"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='16' y='16' width='6' height='6' rx='1' /%3E %3Crect x='2' y='16' width='6' height='6' rx='1' /%3E %3Crect x='9' y='2' width='6' height='6' rx='1' /%3E %3Cpath d='M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' /%3E %3Cpath d='M12 12V8' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="info"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='M12 16v-4' /%3E %3Cpath d='M12 8h.01' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="folder-tree"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z' /%3E %3Cpath d='M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z' /%3E %3Cpath d='M3 5a2 2 0 0 0 2 2h3' /%3E %3Cpath d='M3 3v13a2 2 0 0 0 2 2h3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="layout-dashboard"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='7' height='9' x='3' y='3' rx='1' /%3E %3Crect width='7' height='5' x='14' y='3' rx='1' /%3E %3Crect width='7' height='9' x='14' y='12' rx='1' /%3E %3Crect width='7' height='5' x='3' y='16' rx='1' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="table"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v18' /%3E %3Crect width='18' height='18' x='3' y='3' rx='2' /%3E %3Cpath d='M3 9h18' /%3E %3Cpath d='M3 15h18' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="crosshair"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cline x1='22' x2='18' y1='12' y2='12' /%3E %3Cline x1='6' x2='2' y1='12' y2='12' /%3E %3Cline x1='12' x2='12' y1='6' y2='2' /%3E %3Cline x1='12' x2='12' y1='22' y2='18' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="target"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Ccircle cx='12' cy='12' r='6' /%3E %3Ccircle cx='12' cy='12' r='2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="hash"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='4' x2='20' y1='9' y2='9' /%3E %3Cline x1='4' x2='20' y1='15' y2='15' /%3E %3Cline x1='10' x2='8' y1='3' y2='21' /%3E %3Cline x1='16' x2='14' y1='3' y2='21' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="tag"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z' /%3E %3Ccircle cx='7.5' cy='7.5' r='.5' fill='currentColor' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="workflow"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='8' height='8' x='3' y='3' rx='2' /%3E %3Cpath d='M7 11v4a2 2 0 0 0 2 2h4' /%3E %3Crect width='8' height='8' x='13' y='13' rx='2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="server"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='20' height='8' x='2' y='2' rx='2' ry='2' /%3E %3Crect width='20' height='8' x='2' y='14' rx='2' ry='2' /%3E %3Cline x1='6' x2='6.01' y1='6' y2='6' /%3E %3Cline x1='6' x2='6.01' y1='18' y2='18' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="globe"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20' /%3E %3Cpath d='M2 12h20' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="timer"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='10' x2='14' y1='2' y2='2' /%3E %3Cline x1='12' x2='15' y1='14' y2='11' /%3E %3Ccircle cx='12' cy='14' r='8' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="book-open"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 7v14' /%3E %3Cpath d='M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="send"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z' /%3E %3Cpath d='m21.854 2.147-10.94 10.939' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="external-link"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6' /%3E %3Cpath d='M10 14 21 3' /%3E %3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="circle"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="circle-check"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='m9 12 2 2 4-4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="ban"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10' /%3E %3Cpath d='m4.9 4.9 14.2 14.2' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="lock"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='11' x='3' y='11' rx='2' ry='2' /%3E %3Cpath d='M7 11V7a5 5 0 0 1 10 0v4' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="git-branch"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='6' x2='6' y1='3' y2='15' /%3E %3Ccircle cx='18' cy='6' r='3' /%3E %3Ccircle cx='6' cy='18' r='3' /%3E %3Cpath d='M18 9a9 9 0 0 1-9 9' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="split"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3h5v5' /%3E %3Cpath d='M8 3H3v5' /%3E %3Cpath d='M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3' /%3E %3Cpath d='m15 9 6-6' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="route"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='19' r='3' /%3E %3Cpath d='M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15' /%3E %3Ccircle cx='18' cy='5' r='3' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="milestone"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 13v8' /%3E %3Cpath d='M12 3v3' /%3E %3Cpath d='M4 6a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h13a2 2 0 0 0 1.152-.365l3.424-2.317a1 1 0 0 0 0-1.635l-3.424-2.318A2 2 0 0 0 17 6z' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="signal"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 20h.01' /%3E %3Cpath d='M7 20v-4' /%3E %3Cpath d='M12 20v-8' /%3E %3Cpath d='M17 20V8' /%3E %3Cpath d='M22 4v16' /%3E%3C/svg%3E");
  }
  .graph-explorer-icon[data-icon="history"] {
    --graph-explorer-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' /%3E %3Cpath d='M3 3v5h5' /%3E %3Cpath d='M12 7v5l4 2' /%3E%3C/svg%3E");
  }
}

§JS view file

// -- Graph Explorer (graph-explorer) -------------------------------------------
// A knowledge graph of 2,000 to 4,000,000 nodes in plain DOM + canvas, after the design study "A Million-Node
// Knowledge Graph Renderer for defuss-shadcn" (the graph-explorer PoC, MIT, Aron Homberg):
//
// - A fixed render budget: a Web Worker owns the graph (CSR edges), the cluster hierarchy, the layouts, the cycles
//   and the far field. It draws every dot and bulk edge into an OffscreenCanvas and decides each node's tier
//   (dot / chip / card) with box collision. The main thread projects a bounded pool of DOM slots with the live camera
//   every frame. VERIFIED: (graph-explorer.e2e "budget") a 1,000,000-node graph keeps fewer than 700 elements in the
//   explorer however far you zoom.
// - One clock: the worker's bitmap is committed in the same requestAnimationFrame as the DOM writes, with a corrective
//   transform when it was drawn for an older camera.
// - Semantic zoom with a floating origin: every cluster is a unit disk; zooming into one dives into it (the frame
//   rebases), zooming out or panning off its disk moves up; the parent level stays as a dimmed backdrop.
// - Plugins: node, edge and detail renderers return html`` (SafeHtml, every interpolation escaped and sanitized);
//   edge styles are declarative rules the worker evaluates (functions never cross into it). Every markup write goes
//   through defuss-morph (pooled, keyless slots, morphed only when their markup changed).
// - The page composes the rest: a breadcrumb, a search box, an outline, details, relations, a layer switch and
//   readouts are OUTLETS - any element with data-graph-explorer-for="<id>" and data-graph-explorer-outlet - filled by
//   this runtime (their content is chrome, data-ce-chrome). The full application is a Border Layout of Panels on the
//   page, not markup this component builds: Expand maximizes the Panel that holds the details outlet.
//
// The worker is ONE function (graphExplorerWorker below) started as a classic Blob worker - the pattern Compute &
// Explain ships: it works inside opaque-origin sandboxes (the docs cards). Being stringified, it may reference
// nothing outside itself.
//
// State lives ON the element (data-* + the store); the bound `api` is the only state mutator (AGENTS.md "State API").
import {
  defussGlobals,
  defussQuery,
  componentState,
  bindComponent,
  unbindComponent,
} from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// the morph half of the runtime (defuss-morph): a parsed node as a VNode, and 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