<!-- GENERATED by scripts/build-llms.mjs from llms/retrieval.md — do not edit this file. -->

# `lr-graph`

- **Import** `import '@aceshooting/lyra-ui/components/lr-graph.js';` (stable tag alias; registers the tag)
- **Class** `LyraGraph`, also available unregistered from `@aceshooting/lyra-ui/components/retrieval/graph/graph.class.js`
- **Family** `components/retrieval/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** `d3-drag`, `d3-force`, `d3-selection`, `d3-zoom` — see `llms/peers.md`
- **Themeable via** 19 parts, 17 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-graph`

A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.

A zero-width canvas link paints neither a stroke nor an arrowhead; its relationship remains in the
nonvisual topology summary.

In both renderers, roving navigation transfers real focus through nodes, operable links, then
community hulls; zero-width, fully transparent, and dangling links remain outside that focus order.

**Properties:**

- `nodes: LyraGraphNode[] = []` (attribute: false) — readonly `LyraGraphNode { id: string; label?: string;
accessibleLabel?: string; description?: string; radius?: number; color?: string; type?: string;
expandable?: boolean; communityId?: string }`;
  `accessibleLabel` supplies richer spoken text than the visible label, while `description` renders
  as the preferred bounded tooltip/summary text in both renderers. `radius` is clamped to `[6, 24]` (an unset/non-finite value
  falls back to the midpoint, `15`) so a node can never render invisibly small or absurdly large.
  `type` is a key into `nodeTypes` (matched by `LyraNodeTypeStyle.id`); unknown/absent renders as an
  untyped default circle with the token fill, but an unmatched `type` still participates in
  `hiddenTypes` filtering by its raw string value
- `nodeTypes: LyraNodeTypeStyle[] = []` (attribute: false) — readonly `LyraNodeTypeStyle { id: string; label: string;
color?: string; shape?: 'circle' | 'square' | 'diamond' }`, one entry per `LyraGraphNode.type` value:
  `label` feeds the spoken "typed node" summary, and `shape`/`color` drive rendering per node.
  Per-node fill resolution precedence is `LyraGraphNode.color` (most specific) > the matched
  `LyraNodeTypeStyle.color` > an ordered categorical fallback palette assigned by the type's index in
  `nodeTypes` (`--lr-graph-cat-1` through `-8`, wrapping every 8 entries) > the untyped
  `--lr-node-fill` default; both data-driven color sources are sanitized the same way as
  `LyraGraphNode.color` itself. A typed node with no matching `nodeTypes` entry renders as a plain
  circle with the untyped default fill
- `hiddenTypes: string[] = []` (attribute: false) — hides nodes whose raw `type` is listed and every
  incident link from rendering, layout, keyboard navigation, the data-list alternative, and the
  accessible counts. Hidden positions are retained by id, so showing a type restores its prior
  layout even when no matching `nodeTypes` entry exists
- `links: LyraGraphLink[] = []` (attribute: false) — readonly `LyraGraphLink { id?: string; source: string; target:
string; width?: number; label?: string; accessibleLabel?: string; description?: string; directed?:
boolean; color?: string; dash?: number[] }` (source/target are node ids). `directed` adds an
  arrowhead; `color` and `dash` style the individual stroke; `label` provides a spoken-name and SVG
  tooltip fallback but is not rendered as visible edge text; `accessibleLabel` and `description`
  can override the spoken name and tooltip independently. `width` is normalized before reaching
  SVG, canvas paint, or canvas picking: negative values clamp to `0`, while a non-finite or unset
  value uses `1.5`. A zero-width or fully transparent link remains in the nonvisual topology
  summary but is excluded from pointer picking and keyboard navigation, so invisible geometry never
  becomes an operable control. A link whose `source` id doesn't resolve to a real node is still dropped entirely
  (there's no position to draw a stub from). A link whose `target` id doesn't resolve instead renders
  as a short, dashed, non-interactive stub off `source`'s own position
  (`[part='link'][data-dangling]`, `aria-hidden="true"`) rather than being silently dropped — e.g. for
  a wiki-style `[[link]]` reference to a not-yet-created node. A dangling stub is excluded from
  `d3-force`'s own simulation input and from click/keyboard interaction.
- `fitTo: 'none' | 'container' = 'none'` (attribute `fit-to`) — where the drawing space comes from.
  `'none'` uses the numeric `width`/`height` below, unchanged. `'container'` measures the host's own
  content box and feeds that to the SVG `viewBox`, the layout's centring force,
  `focusNode()`/`fit()`'s camera math and the loading skeleton, so the drawing always matches the box
  it is rendered into and no host-side `ResizeObserver` is needed. The first measurement is taken
  synchronously before the first paint, with the host's own height already applied, so the first
  painted frame is already the right size; every later one arrives on the component's own
  host-resize watcher and is rounded to whole CSS pixels, so sub-pixel jitter changes nothing. A resize re-centres the running layout in place (`forceCenter`
  plus a low-alpha restart) and never rebuilds the simulation, so settled node positions, pins and an
  in-flight drag all survive it. Works in both renderers and across a renderer switch. While
  `'container'` is in effect the measured box wins over `width`/`height`; it falls back to them when
  the box is unmeasurable (detached, `display: none`, or a realm with no `ResizeObserver`). It does
  **not** change how the host itself is sized — an outer `block-size`,
  `--lr-canvas-reserved-height` and `height` still do that, and `'container'` simply follows
  whichever of them won
- `width: number = 800` — ignored while `fitTo` is `'container'`
- `height: number = 600` — also sizes the rendered host itself (see
  `--lr-canvas-reserved-height`'s entry below) whenever neither that nor an explicit outer
  `block-size` overrides it. Only the drawing space is ignored while `fitTo` is `'container'`; the
  host sizing above still applies
- `chargeStrength: number = -300` (attribute `charge-strength` — live-reactive, see gotchas)
- `linkDistance: number = 100` (attribute `link-distance` — live-reactive, see gotchas)
- `minZoom: number = 0.1` (attribute `min-zoom`)
- `maxZoom: number = 8` (attribute `max-zoom`)
- `accessibleLabel: string | null = null` (attribute `aria-label`) — setting the JS property while
  the host attribute is absent names the SVG/canvas owner. Authored host `aria-label` presence,
  including an explicitly empty value, instead makes the host the sole named graph owner; the
  inner renderer drops its parallel role/name. Removing the attribute restores the inner owner and
  its localized node/link-count fallback
- `seed?: number` — when set, seeds each node's initial x/y deterministically
  (keyed by node **id**, not array index/order) instead of `forceSimulation()`'s own random start,
  and settles the simulation synchronously instead of animating the settle (same effect
  `prefers-reduced-motion` has, see gotchas)
- `showEdgeLabels: boolean = false` (attribute `show-edge-labels`) — draws each resolved
  (non-dangling) link's `label` as visible SVG text (`[part="link-label"]`) at the segment midpoint.
  Off by default: `LyraGraphLink.label` stays spoken/tooltip-only, matching pre-existing behavior, unless
  this is set
- `edgeLabelMinZoom: number = 0.6` (attribute `edge-label-min-zoom`) — below this zoom scale, every
  drawn edge label is hidden (toggled via a `data-edge-labels-hidden` attribute on the zoomed `<g>`,
  not a Lit re-render, so it tracks pan/zoom smoothly). Ignored entirely when `showEdgeLabels` is
  `false`
- `nodeLabels?: 'always' | 'zoom' | 'none'` (attribute `node-labels`) — node-label visibility.
  `'always'` draws every node's label unconditionally; `'zoom'` hides them below the same
  canvas-declutter zoom threshold used before this property existed (toggled via a
  `data-node-labels-hidden` attribute on the zoomed `<g>` for `renderer="svg"`, mirroring
  `showEdgeLabels`/`edgeLabelMinZoom`'s own mechanism — not a Lit re-render); `'none'` never renders
  them. Left unset (the default), each renderer keeps its own exact pre-existing behavior —
  `'always'` for `renderer="svg"`, `'zoom'` for `renderer="canvas"` — so this is purely additive
- `layout: 'force' | 'layered' = 'force'` — `'force'` runs the `d3-force` simulation described
  throughout this section, unchanged. `'layered'` swaps in a deterministic Sugiyama-lite layered
  layout instead (longest-path layering, barycenter crossing reduction, cycle-safe — back edges are
  reversed only for layering, never mutating caller data): positions are computed synchronously
  with no settle animation, sized from each node's own radius and spaced by `linkDistance` (the
  layer gap) and a fixed 12px in-layer gap. Node drag is disabled in this mode (dragging would fight
  a computed layout) and `chargeStrength` becomes a documented no-op; pan/zoom, keyboard roving,
  `focusNode()`/`fit()`, hulls, edge labels, and `hiddenTypes` filtering all work identically to
  force mode. Switching `layout` at runtime repositions every node without a tween. The layering
  algorithm itself lives in the standalone, dependency-free `layeredLayout()` export
  (`@aceshooting/lyra-ui/utilities/layered-layout`), reusable by any other layered-diagram consumer
- `renderer: 'svg' | 'canvas' = 'svg'` — `'canvas'` swaps the per-node/per-link SVG DOM for a single
  DPR-aware `<canvas>`; every event/method/property behaves identically to `'svg'`, with hit-testing
  resolved via an offscreen color-picking canvas instead of DOM event targets. Trade-offs: no
  `::part(node)`/`::part(link)` styling (pixels, not elements — theme via cssprops instead), no
  native SVG `<title>` tooltip (replaced by `part="tooltip"`), and a drawn focus ring instead of a
  CSS one. Keyboard roving/announcements are preserved through an offscreen `part="cursor-item"`
  button per visible node/link/hull; the canvas repaints a non-color dashed/ring focus cue for the
  currently focused node, link, or hull and uses a system color under forced colors. In both renderers, node, link, and community-hull picking keeps at
  least 24 CSS px of screen-space geometry as the viewport zoom changes; this enlarges interaction
  only, not the visible marks. Every data-driven and token-derived canvas color is resolved through
  the live computed CSS cascade before painting, so CSS-wide keywords such as `inherit`/`unset`,
  custom-property references, and modern color functions reach Canvas as concrete colors.

**Methods:** `focusNode(id, options?: { zoom? })` and `fit(options?: { padding?: number })` control the
camera; `getNodePosition(id)` returns the current `{ x, y }` in graph-local drawing coordinates, or
`undefined` when the id is not currently simulated.

**Events:** `lr-node-click` (`detail: { nodeId, x, y }`, where `x` and `y` are the clicked node's current
local drawing coordinates), `lr-link-click` (`detail: { sourceNodeId, targetNodeId,
linkId? }`; the optional `linkId` is the stable `LyraGraphLink.id` supplied by the caller), `lr-node-enter`/
`lr-node-leave` (`detail: { nodeId }`, hover start/end, suppressed while dragging/panning; canvas emits once per hit-identity transition or exit),
`lr-link-enter`/`lr-link-leave` (`detail: { sourceNodeId, targetNodeId, linkId? }`, same hover contract),
`lr-node-expand` (`detail: { nodeId }`, a node was double-activated — native `dblclick`, or two
Enter/Space activations within 500ms — regardless of `LyraGraphNode.expandable`), `lr-community-click`
(`detail: { communityId }`, a hull was activated), `lr-selection-change`
(`detail: { nodeIds, linkIds }`, a controlled selection intent), and `lr-viewport-change`
(`detail: { k, x, y }`, a frame-coalesced camera/layout signal)

**Slots:** none.

**CSS parts:** `base`, `svg`, `node`, `link`, `arrowhead` (the marker path shared by directed links),
`label` (`renderer="svg"` only; not rendered at all when `nodeLabels` is `'none'`), `link-label` (a
drawn edge label, only rendered when `showEdgeLabels` is set),
`expand-indicator` (the "+" badge on a node with `expandable: true`), `focus-halo` (the persistent
ring tracking `focusNodeId`'s node), `hull` (a community hull), `community-label`,
`live-region`, `data-list`, `empty`, `error` (neutral visible message shown instead of the graph when
the optional `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection` peers fail to load; that transition is
announced through a shared assertive light-DOM region — distinct from the empty state, which means
the peers loaded fine but `nodes` is empty),
`canvas`/`tooltip`/`cursor-items`/`cursor-item`
(`renderer="canvas"` only — the drawing surface, its hover tooltip replacing the SVG `<title>`, and
the offscreen keyboard-roving items)

**Themeable custom properties:** `--lr-canvas-reserved-height` (default
`var(--lr-size-24rem)`) sets the host block size and is shared with the optional pre-upgrade
reservation stylesheet. Below it in the fallback chain, the normalized `height` property sizes the
host too (through a private, not-directly-settable custom property) — setting
`--lr-canvas-reserved-height` always overrides `height`, and an explicit outer `block-size` still
wins over both. `--lr-node-fill` (set inline per-node from `LyraGraphNode.color`,
falls back to `--lr-color-brand`) and `--lr-link-color` (set inline per-link from
`LyraGraphLink.color`, falling back to `--lr-color-border`); also uses `--lr-color-text` +
`--lr-font` (label text), `--lr-focus-ring-*` (node/link `:focus-visible` outline).
The ordered categorical fallback palette for a typed node with no `LyraNodeTypeStyle.color` is
`--lr-graph-cat-1` (default `var(--lr-theme-graph-cat-1,#8250df)`),
`--lr-graph-cat-2` (default `var(--lr-theme-graph-cat-2,#bf3989)`),
`--lr-graph-cat-3` (default `var(--lr-theme-graph-cat-3,#0a7d91)`),
`--lr-graph-cat-4` (default `var(--lr-theme-graph-cat-4,#57606a)`),
`--lr-graph-cat-5` (default `var(--lr-theme-graph-cat-5,#b083f5)`),
`--lr-graph-cat-6` (default `var(--lr-theme-graph-cat-6,#f470b8)`),
`--lr-graph-cat-7` (default `var(--lr-theme-graph-cat-7,#52d6e8)`), and
`--lr-graph-cat-8` (default `var(--lr-theme-graph-cat-8,#c9d1d9)`). Assignment follows the type's
index in `nodeTypes` and wraps every eight entries; the `--lr-theme-graph-cat-*` inputs are the
preferred theme-level overrides.
`--lr-graph-edge-label-halo` (default `var(--lr-color-surface)`) — the legibility halo painted
behind a drawn `[part="link-label"]` (via `paint-order: stroke`).
`--lr-graph-focus-halo-color` (default `var(--lr-color-brand)`) — `[part="focus-halo"]` and canvas keyboard-focus cue stroke.
`--lr-graph-selected-color` (default `var(--lr-color-success)`) — selected node/link stroke.
`--lr-graph-dimmed-opacity` (default `0.35`) — opacity of a node/link listed in
`dimmedNodeIds`/`dimmedLinkIds`.
`--lr-graph-hull-fill` (default `var(--lr-color-brand)`) — community hull fill/stroke color
(overridden inline per hull from `LyraGraphCommunity.color`).
`--lr-graph-hull-opacity` (default `0.12`) — hull element opacity (composites fill+stroke as one).
Under `renderer="canvas"` these five are read from computed style at paint time (there are no
per-node elements to inherit them), so they must be set on or above the `<lr-graph>` host itself.
The canvas repaints when inherited theme inputs change (including ancestor class/data attributes,
adopted stylesheets, and media-query theme transitions); a host does not need to reassign `nodes`
to make new token values visible.

**Optional peer deps:** `d3-force`, `d3-drag`, `d3-zoom`, `d3-selection` (all four required
together; lazy-`import()`ed once per page). Each loaded module is validated for the named callable
capabilities the graph uses; a missing package or malformed module fails closed through the
localized `part="error"` alert. Install with
`pnpm add d3-force d3-drag d3-zoom d3-selection`.

```html
<lr-graph style="display:block;height:500px"></lr-graph>
<script type="module">
  import "@aceshooting/lyra-ui/components/retrieval/graph/graph.js";

  const g = document.querySelector("lr-graph");
  g.nodes = [
    {
      id: "a",
      label: "A",
      accessibleLabel: "Source document A",
      description: "The source document",
    },
    { id: "b", label: "B", description: "The cited document" },
  ];
  g.links = [
    {
      id: "citation-a-b",
      source: "a",
      target: "b",
      label: "cites",
      accessibleLabel: "Document A cites document B",
      description: "Citation relationship",
      directed: true,
      color: "var(--lr-color-brand)",
      dash: [6, 3],
    },
  ];
  g.addEventListener("lr-node-click", (e) => console.log(e.detail.nodeId));
  g.addEventListener("lr-link-click", (e) =>
    console.log(e.detail.linkId, e.detail.sourceNodeId, e.detail.targetNodeId)
  );
</script>
```

**Known gotchas:**

- per-tick full re-render is expensive: every d3-force tick (up to ~300 by default,
  continuously while dragging via `alphaTarget(0.3)`) writes node/link positions straight onto the
  already-rendered DOM via `setAttribute()` rather than reassigning `simNodes`/`simLinks` (that
  reassignment — and the Lit re-render/`applyInteractions()` re-scan it used to force on every tick —
  now only happens once per structural `nodes`/`links` change). Still a noticeable cost building up
  the initial layout or while a node is actively being dragged, just no longer once per tick on an
  otherwise-settled graph.
- `chargeStrength`/`linkDistance` **are** live-reactive post-mount now (retuned on the existing
  force objects and the simulation nudged via `alpha(0.3).restart()`) — no need to also touch
  `nodes`/`links` to see the effect.
- `width`/`height` are also live-reactive post-mount: changing either re-centers the `forceCenter`
  force on the new midpoint and nudges the simulation via `alpha(0.1).restart()`, in addition to
  resizing the rendered `viewBox` — both branches apply independently, so setting `width` and
  `chargeStrength` in the same synchronous batch retunes both, not just one.
- zoom is bounded via `minZoom`/`maxZoom` (`d3-zoom`'s `.scaleExtent(...)`, live-reactive in both SVG and canvas); pan/
  zoom/drag are still pointer-only with no keyboard equivalent. Links (`<line part="link">`) are now
  keyboard-operable too (`tabindex="0"`, `role="button"`, `aria-label`, Enter/Space), matching nodes.
- while the `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection` peers are resolving, the host shows an
  unannounced decorative `<lr-skeleton>` sized to `width`/`height` plus an ordinary visually hidden,
  localized loading label; the graph host itself carries `aria-busy="true"`. If the peers fail to
  load (for example, because they are not installed), the graph fails closed with a localized
  neutral `part="error"` message and announces the transition through a shared assertive light-DOM
  region instead of leaving an empty SVG.
- `LyraGraphNode.color`, node-type colors, `LyraGraphLink.color`, and community colors are accepted only
  when the browser parses them as CSS `color`; declaration breaks and `url()` paint servers are
  ignored in favor of the normal token/palette fallback. `LyraGraphLink.dash` is used only when every
  entry is finite and non-negative; an empty or invalid array falls back to a solid line rather
  than partially applying malformed SVG stroke data.
- a structural `nodes`/`links` change now carries over each already-settled node's position (and any
  in-progress drag) by id when rebuilding the simulation, instead of discarding every node's (x, y)
  and re-running the whole ~300-tick random-start settle from scratch — only genuinely new ids get a
  fresh start. Handy for a streaming/incrementally-updated graph, whose existing layout no longer
  jumps every time a node/link is appended.
- under `prefers-reduced-motion: reduce`, or whenever `seed` is set, the simulation converges
  synchronously (ticked in a loop down to `alphaMin` before first paint) instead of animating over
  ~300 rendered frames; user-initiated motion (dragging a node) is unaffected either way.
- in canvas mode, `pointercancel`, lost pointer capture, and disconnect all release a live node's
  force pin and reset the simulation target; a canceled drag never leaves the node pinned.
- With no authored host `aria-label`, the SVG/canvas carries `role="group"` and the localized
  node/link-count name (e.g. "Node-link diagram with 5 nodes and 4 links"). An authored host label
  moves that one graph role/name to the host instead of duplicating it. Node `<text part="label">`s
  stay `aria-hidden="true"` because each node control already owns its label.
- `nodeTypes` and `showEdgeLabels` are live-reactive post-mount: either changing re-scans/rebinds the
  cached per-node and per-link DOM element arrays, alongside the existing `simNodes`/`simLinks`
  structural-change trigger — no need to also touch `nodes`/`links` to see a type/shape/color or
  edge-label change take effect.
- when `showEdgeLabels` is `false` (the default), a resolved link renders as a bare `<line
part="link">` with no extra wrapping element, so existing consumers who never set it see
  byte-for-byte identical link DOM; setting it wraps each link's `<line>` and its
  `[part="link-label"]` `<text>` together. `edgeLabelMinZoom`'s hide/show gate is applied once at
  mount (against d3-zoom's known identity transform) as well as on every subsequent pan/zoom, so
  labels never wrongly start visible before the first user gesture.

**Selection & focus:** `selectionMode: 'none' | 'single' | 'multiple' = 'none'` (attribute
`selection-mode`) gates click/keyboard selection; the component never mutates
`selectedNodeIds: string[] = []` / `selectedLinkIds: string[] = []` (both attribute: false) itself,
only emits `lr-selection-change` (`detail: { nodeIds, linkIds }`) — the host assigns them back,
mirroring `lr-heatmap`'s `selectedCell` contract. `dimmedNodeIds: string[] = []` / `dimmedLinkIds:
string[] = []` (both attribute: false) are the same controlled shape for dimming instead of
selecting — the component never assigns either itself, only renders `data-dimmed` on the matching
`[part="node"]`/`[part="link"]`, themed via `--lr-graph-dimmed-opacity` (default `0.35` — visible out
of the box with no host styling required); a host typically computes the set from a
`lr-node-enter`/`lr-link-enter` hover (the complement of the hovered id's neighbor set) and assigns
it back — `lr-knowledge-graph-explorer`'s own `highlight` property is exactly this composition,
built-in. Empty (the default) renders every node/link at full opacity, unchanged from today.
`nodes`, `links`, `nodeTypes`, and `communities` use nonblank first-wins identities before lookup,
layout, rendering, selection, or events. A link uses its explicit `id`, or `source->target` when
`id` is omitted; a blank explicit `id` is invalid, while distinct explicit ids deliberately allow
parallel links between the same endpoints. Retained identity spelling is not trimmed or rewritten.
Community `memberIds` and controlled id arrays follow the same nonblank first-wins rule.
`communities: LyraGraphCommunity[] = []` (attribute: false) draws one translucent convex-hull blob per
entry behind links/nodes. Each entry is `LyraGraphCommunity { id: string; label?: string; memberIds:
string[]; color?: string }`; membership is the union of `memberIds` and nodes whose `communityId`
matches the entry id. `focusNodeId: string | null = null` (attribute `focus-node-id`) tracks a persistent
focus ring (`[part="focus-halo"]`) around one node;
`focusNode(id, options?)` and `fit(options?)` are the imperative camera-tween counterparts (pan/zoom
to a node, or to fit the whole graph), both resolving once the tween settles. `lr-viewport-change`
(`detail: { k, x, y }`, the live d3-zoom camera transform) fires at most once per animation frame,
coalescing every source that can move a rendered node's screen position — a pan/zoom gesture, a
`focusNode()`/`fit()` tween, and every simulation tick — so a consumer anchoring its own UI (e.g. a
details popover) to a node's `getBoundingClientRect()` can re-read it from this event instead of
polling its own `requestAnimationFrame` loop.

---
