{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "architecture-canvas",
  "type": "registry:ui",
  "title": "ArchitectureCanvas",
  "description": "Interactive system architecture and flow canvas with animated packet pulses, directional connections, and node inspector.",
  "registryDependencies": [
    "https://ui.arpitbhayani.me/r/aui.json"
  ],
  "files": [
    {
      "path": "ui/architecture-canvas.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ArchitectureStatus = \"ok\" | \"warn\" | \"err\" | \"info\";\n\nexport interface ArchitectureNodeItem {\n  id: string;\n  label: string;\n  badge?: string;\n  description?: string;\n  /** Percentage 0 - 100. Omit x and y to let the canvas place the node automatically. */\n  x?: number;\n  y?: number;\n  status?: ArchitectureStatus;\n  metadata?: Record<string, string>;\n  /** Id of an entry in `groups`. */\n  group?: string;\n  /** Whether this node has been visited / completed. Renders a checkmark or filled dot. */\n  visited?: boolean;\n}\n\nexport type ArchitectureEdgeRouting = \"smoothstep\" | \"bezier\" | \"straight\";\n\nexport interface ArchitectureConnectionItem {\n  from: string;\n  to: string;\n  label?: string;\n  animated?: boolean;\n  variant?: \"solid\" | \"dashed\";\n  status?: \"ok\" | \"warn\" | \"err\" | \"primary\";\n  routing?: ArchitectureEdgeRouting;\n}\n\nexport interface ArchitectureGroupItem {\n  id: string;\n  label: string;\n  kind?: \"service\" | \"layer\" | \"package\" | \"external\";\n  /** Start collapsed into a single node. */\n  defaultCollapsed?: boolean;\n}\n\nexport type ArchitectureDirection = \"LR\" | \"TB\";\n\n/** A node after group collapsing. `collapsedGroupId` is set on a node that stands in for a collapsed group. */\nexport interface ResolvedArchitectureNode extends ArchitectureNodeItem {\n  collapsedGroupId?: string;\n}\n\nexport interface ArchitectureLayout {\n  /** Total canvas size in px. */\n  width: number;\n  height: number;\n  /** Node centers in px, for nodes that had no explicit x/y. */\n  positions: Map<string, { x: number; y: number }>;\n}\n\nexport interface PxBox {\n  id: string;\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n/** Fixed node box used when the canvas places nodes itself. */\nexport const ARCH_NODE_W = 184;\nexport const ARCH_NODE_H = 88;\n\nconst GROUP_GAP = 14;\nconst GROUP_LABEL_H = 30;\n/** Distance from the outermost node centers to the group border. */\nexport const ARCH_GROUP_PAD = {\n  x: ARCH_NODE_W / 2 + GROUP_GAP,\n  top: ARCH_NODE_H / 2 + GROUP_GAP + GROUP_LABEL_H,\n  bottom: ARCH_NODE_H / 2 + GROUP_GAP,\n};\n\nconst MARGIN = 24;\n/** Node-to-node gaps (px) along the flow direction (main) and across it (cross). */\nconst GAPS: Record<ArchitectureDirection, { main: number; cross: number }> = {\n  LR: { main: 64, cross: 28 },\n  TB: { main: 68, cross: 40 },\n};\n/** Extra spacing between top-level items (groups and loose nodes). */\nconst ITEM_GAP_EXTRA = 10;\n\nconst STATUS_RANK: Record<ArchitectureStatus, number> = { ok: 0, info: 1, warn: 2, err: 3 };\n\nexport function groupNodeId(groupId: string): string {\n  return `group:${groupId}`;\n}\n\nexport function initialCollapsedGroups(groups: ArchitectureGroupItem[] = []): string[] {\n  return groups.filter((g) => g.defaultCollapsed).map((g) => g.id);\n}\n\n/** Replaces every collapsed group's members with one stand-in node and re-targets edges. */\nexport function collapseArchitecture(\n  nodes: ArchitectureNodeItem[],\n  connections: ArchitectureConnectionItem[],\n  groups: ArchitectureGroupItem[] = [],\n  collapsed: string[] = []\n): { nodes: ResolvedArchitectureNode[]; connections: ArchitectureConnectionItem[] } {\n  const groupMap = new Map(groups.map((g) => [g.id, g]));\n  const active = new Set(collapsed.filter((id) => groupMap.has(id)));\n  if (active.size === 0) return { nodes, connections };\n\n  const remap = new Map<string, string>();\n  const outNodes: ResolvedArchitectureNode[] = [];\n  const emitted = new Set<string>();\n\n  for (const n of nodes) {\n    if (n.group && active.has(n.group)) {\n      const gid = groupNodeId(n.group);\n      remap.set(n.id, gid);\n      if (emitted.has(n.group)) continue;\n      emitted.add(n.group);\n\n      const members = nodes.filter((m) => m.group === n.group);\n      const g = groupMap.get(n.group)!;\n      const pinned = members.every((m) => m.x !== undefined && m.y !== undefined);\n      const worst = members.reduce<ArchitectureStatus | undefined>((acc, m) => {\n        if (!m.status) return acc;\n        return !acc || STATUS_RANK[m.status] > STATUS_RANK[acc] ? m.status : acc;\n      }, undefined);\n\n      outNodes.push({\n        id: gid,\n        label: g.label,\n        badge: `${members.length} nodes`,\n        description: members.map((m) => m.label).join(\", \"),\n        x: pinned ? members.reduce((s, m) => s + (m.x as number), 0) / members.length : undefined,\n        y: pinned ? members.reduce((s, m) => s + (m.y as number), 0) / members.length : undefined,\n        status: worst,\n        metadata: { Group: g.label, Members: String(members.length) },\n        collapsedGroupId: n.group,\n      });\n    } else {\n      outNodes.push(n);\n    }\n  }\n\n  const seen = new Map<string, ArchitectureConnectionItem>();\n  for (const c of connections) {\n    const from = remap.get(c.from) ?? c.from;\n    const to = remap.get(c.to) ?? c.to;\n    if (from === to) continue;\n    const key = `${from}\\u0000${to}`;\n    const prev = seen.get(key);\n    if (prev) {\n      if (c.animated) prev.animated = true;\n      continue;\n    }\n    seen.set(key, { ...c, from, to });\n  }\n\n  return { nodes: outNodes, connections: Array.from(seen.values()) };\n}\n\n/** Longest-path ranking. Edges that close a cycle are ignored so cyclic graphs still lay out. */\nfunction rankItems(ids: string[], edges: [string, string][]): Map<string, number> {\n  const idSet = new Set(ids);\n  const out = new Map<string, string[]>(ids.map((id) => [id, []]));\n  for (const [a, b] of edges) {\n    if (a !== b && idSet.has(a) && idSet.has(b)) out.get(a)!.push(b);\n  }\n\n  const state = new Map<string, 0 | 1 | 2>(ids.map((id) => [id, 0]));\n  const dag: [string, string][] = [];\n  for (const start of ids) {\n    if (state.get(start) !== 0) continue;\n    const stack: { id: string; i: number }[] = [{ id: start, i: 0 }];\n    state.set(start, 1);\n    while (stack.length) {\n      const top = stack[stack.length - 1];\n      const next = out.get(top.id)![top.i++];\n      if (next === undefined) {\n        state.set(top.id, 2);\n        stack.pop();\n        continue;\n      }\n      const s = state.get(next);\n      if (s === 1) continue; // back edge\n      dag.push([top.id, next]);\n      if (s === 0) {\n        state.set(next, 1);\n        stack.push({ id: next, i: 0 });\n      }\n    }\n  }\n\n  const indeg = new Map<string, number>(ids.map((id) => [id, 0]));\n  const succ = new Map<string, string[]>(ids.map((id) => [id, []]));\n  for (const [a, b] of dag) {\n    indeg.set(b, indeg.get(b)! + 1);\n    succ.get(a)!.push(b);\n  }\n  const rank = new Map<string, number>(ids.map((id) => [id, 0]));\n  const queue = ids.filter((id) => indeg.get(id) === 0);\n  while (queue.length) {\n    const u = queue.shift()!;\n    for (const v of succ.get(u)!) {\n      rank.set(v, Math.max(rank.get(v)!, rank.get(u)! + 1));\n      indeg.set(v, indeg.get(v)! - 1);\n      if (indeg.get(v) === 0) queue.push(v);\n    }\n  }\n  return rank;\n}\n\n/** Ranks, then orders each rank with a few barycenter sweeps to cut edge crossings. */\nfunction layerItems(ids: string[], edges: [string, string][]): string[][] {\n  const rank = rankItems(ids, edges);\n  const count = ids.length ? Math.max(...ids.map((id) => rank.get(id)!)) + 1 : 0;\n  const layers: string[][] = Array.from({ length: count }, () => []);\n  for (const id of ids) layers[rank.get(id)!].push(id);\n\n  const idSet = new Set(ids);\n  const nbrs = new Map<string, string[]>(ids.map((id) => [id, []]));\n  for (const [a, b] of edges) {\n    if (a === b || !idSet.has(a) || !idSet.has(b)) continue;\n    nbrs.get(a)!.push(b);\n    nbrs.get(b)!.push(a);\n  }\n\n  const norm = new Map<string, number>();\n  const refresh = () =>\n    layers.forEach((layer) => layer.forEach((id, i) => norm.set(id, (i + 0.5) / layer.length)));\n  refresh();\n\n  const sweep = (r: number, useLower: boolean) => {\n    const layer = layers[r];\n    const keyed = layer.map((id, i) => {\n      const peers = nbrs.get(id)!.filter((n) => (useLower ? rank.get(n)! < r : rank.get(n)! > r));\n      const bary = peers.length ? peers.reduce((s, n) => s + norm.get(n)!, 0) / peers.length : norm.get(id)!;\n      return { id, bary, i };\n    });\n    keyed.sort((a, b) => a.bary - b.bary || a.i - b.i);\n    layers[r] = keyed.map((k) => k.id);\n    refresh();\n  };\n\n  for (let it = 0; it < 4; it++) {\n    for (let r = 1; r < layers.length; r++) sweep(r, true);\n    for (let r = layers.length - 2; r >= 0; r--) sweep(r, false);\n  }\n  return layers;\n}\n\ninterface Item {\n  id: string;\n  members: string[];\n  /** Local ranks for grouped items, used to reorder members against external edges. */\n  layers?: string[][];\n  /** Member center offsets from the item's top-left corner. */\n  offsets: Map<string, { x: number; y: number }>;\n  w: number;\n  h: number;\n}\n\nfunction localItem(\n  id: string,\n  members: string[],\n  edges: [string, string][],\n  direction: ArchitectureDirection,\n  grouped: boolean,\n  gapMain: number\n): Item {\n  const gaps = { ...GAPS[direction], main: gapMain };\n  const mainStep = (direction === \"LR\" ? ARCH_NODE_W : ARCH_NODE_H) + gaps.main;\n  const crossStep = (direction === \"LR\" ? ARCH_NODE_H : ARCH_NODE_W) + gaps.cross;\n\n  const centers = new Map<string, { x: number; y: number }>();\n  let layers: string[][] | undefined;\n  if (!grouped) {\n    centers.set(members[0], { x: 0, y: 0 });\n  } else {\n    layers = layerItems(members, edges);\n    layers.forEach((layer, r) => {\n      layer.forEach((m, i) => {\n        const cross = (i - (layer.length - 1) / 2) * crossStep;\n        const main = r * mainStep;\n        centers.set(m, direction === \"LR\" ? { x: main, y: cross } : { x: cross, y: main });\n      });\n    });\n  }\n\n  const xs = Array.from(centers.values()).map((c) => c.x);\n  const ys = Array.from(centers.values()).map((c) => c.y);\n  const minX = Math.min(...xs);\n  const maxX = Math.max(...xs);\n  const minY = Math.min(...ys);\n  const maxY = Math.max(...ys);\n  const pad = grouped\n    ? ARCH_GROUP_PAD\n    : { x: ARCH_NODE_W / 2, top: ARCH_NODE_H / 2, bottom: ARCH_NODE_H / 2 };\n\n  const offsets = new Map<string, { x: number; y: number }>();\n  centers.forEach((c, m) => offsets.set(m, { x: c.x - minX + pad.x, y: c.y - minY + pad.top }));\n  return { id, members, layers, offsets, w: maxX - minX + pad.x * 2, h: maxY - minY + pad.top + pad.bottom };\n}\n\n/**\n * Places nodes that have no explicit x/y. Groups are laid out as single blocks so their borders\n * never overlap, then members are laid out inside. Positions are px node centers.\n * Returns null when every node already has coordinates.\n */\nexport function layoutArchitecture(\n  nodes: ArchitectureNodeItem[],\n  connections: ArchitectureConnectionItem[],\n  groups: ArchitectureGroupItem[] = [],\n  direction: ArchitectureDirection = \"LR\"\n): ArchitectureLayout | null {\n  const auto = nodes.filter((n) => n.x === undefined || n.y === undefined);\n  if (auto.length === 0) return null;\n\n  const autoIds = new Set(auto.map((n) => n.id));\n  const groupIds = new Set(groups.map((g) => g.id));\n  const itemOf = new Map<string, string>();\n  const itemMembers = new Map<string, string[]>();\n  const itemOrder: string[] = [];\n\n  for (const n of auto) {\n    const itemId = n.group && groupIds.has(n.group) ? `g:${n.group}` : `n:${n.id}`;\n    itemOf.set(n.id, itemId);\n    if (!itemMembers.has(itemId)) {\n      itemMembers.set(itemId, []);\n      itemOrder.push(itemId);\n    }\n    itemMembers.get(itemId)!.push(n.id);\n  }\n\n  const edgePairs: [string, string][] = connections\n    .filter((c) => autoIds.has(c.from) && autoIds.has(c.to))\n    .map((c) => [c.from, c.to]);\n\n  // Leave room between ranks for edge labels (left-to-right only: labels are wide, not tall).\n  const labelWidth = connections\n    .filter((c) => c.label && autoIds.has(c.from) && autoIds.has(c.to))\n    .reduce((m, c) => Math.max(m, (c.label as string).length * 7.2 + 26), 0);\n  const gapMain =\n    direction === \"LR\" ? Math.max(GAPS.LR.main, Math.min(labelWidth + 44, 200)) : GAPS.TB.main;\n\n  const items = new Map<string, Item>();\n  for (const itemId of itemOrder) {\n    const members = itemMembers.get(itemId)!;\n    const grouped = itemId.startsWith(\"g:\");\n    const inner = edgePairs.filter(([a, b]) => itemOf.get(a) === itemId && itemOf.get(b) === itemId);\n    items.set(itemId, localItem(itemId, members, inner, direction, grouped, gapMain));\n  }\n\n  const metaEdges: [string, string][] = [];\n  for (const [a, b] of edgePairs) {\n    const ia = itemOf.get(a)!;\n    const ib = itemOf.get(b)!;\n    if (ia !== ib) metaEdges.push([ia, ib]);\n  }\n  const layers = layerItems(itemOrder, metaEdges);\n\n  const mainGap = gapMain + ITEM_GAP_EXTRA;\n  const crossGap = GAPS[direction].cross + ITEM_GAP_EXTRA / 2;\n  const mainSize = (it: Item) => (direction === \"LR\" ? it.w : it.h);\n  const crossSize = (it: Item) => (direction === \"LR\" ? it.h : it.w);\n\n  const layerMain = layers.map((l) => Math.max(...l.map((id) => mainSize(items.get(id)!))));\n  const layerCross = layers.map(\n    (l) => l.reduce((s, id) => s + crossSize(items.get(id)!), 0) + crossGap * (l.length - 1)\n  );\n  const totalCross = Math.max(...layerCross);\n  const totalMain = layerMain.reduce((s, m) => s + m, 0) + mainGap * (layers.length - 1);\n\n  const positions = new Map<string, { x: number; y: number }>();\n  const itemPos = new Map<string, { left: number; top: number }>();\n  let mainCursor = MARGIN;\n  layers.forEach((layer, r) => {\n    let crossCursor = MARGIN + (totalCross - layerCross[r]) / 2;\n    for (const id of layer) {\n      const it = items.get(id)!;\n      const mainPos = mainCursor + (layerMain[r] - mainSize(it)) / 2;\n      const left = direction === \"LR\" ? mainPos : crossCursor;\n      const top = direction === \"LR\" ? crossCursor : mainPos;\n      itemPos.set(id, { left, top });\n      it.offsets.forEach((o, m) => positions.set(m, { x: left + o.x, y: top + o.y }));\n      crossCursor += crossSize(it) + crossGap;\n    }\n    mainCursor += layerMain[r] + mainGap;\n  });\n\n  // Members were ordered before their group's final place was known. Reorder each group's\n  // members within a rank by where their outside neighbors ended up, to untangle crossings.\n  const cross = (p: { x: number; y: number }) => (direction === \"LR\" ? p.y : p.x);\n  const nbrs = new Map<string, string[]>(auto.map((n) => [n.id, []]));\n  for (const [a, b] of edgePairs) {\n    nbrs.get(a)!.push(b);\n    nbrs.get(b)!.push(a);\n  }\n  for (let pass = 0; pass < 2; pass++) {\n    for (const it of items.values()) {\n      if (!it.layers) continue;\n      const { left, top } = itemPos.get(it.id)!;\n      for (const layer of it.layers) {\n        if (layer.length < 2) continue;\n        const slots = layer.map((m) => cross(it.offsets.get(m)!)).sort((a, b) => a - b);\n        const keyed = layer\n          .map((m, i) => {\n            const outside = nbrs.get(m)!.filter((n) => itemOf.get(n) !== it.id);\n            const key = outside.length\n              ? outside.reduce((sum, n) => sum + cross(positions.get(n)!), 0) / outside.length\n              : cross(positions.get(m)!);\n            return { m, key, i };\n          })\n          .sort((a, b) => a.key - b.key || a.i - b.i);\n        keyed.forEach((k, idx) => {\n          const o = it.offsets.get(k.m)!;\n          const next = direction === \"LR\" ? { x: o.x, y: slots[idx] } : { x: slots[idx], y: o.y };\n          it.offsets.set(k.m, next);\n          positions.set(k.m, { x: left + next.x, y: top + next.y });\n        });\n      }\n    }\n  }\n\n  const width = (direction === \"LR\" ? totalMain : totalCross) + MARGIN * 2;\n  const height = (direction === \"LR\" ? totalCross : totalMain) + MARGIN * 2;\n  return { width, height, positions };\n}\n\n/** Group borders in px, from final node centers. Only groups with at least one placed member. */\nexport function groupBoxes(\n  groups: ArchitectureGroupItem[],\n  nodes: ArchitectureNodeItem[],\n  center: (id: string) => { x: number; y: number } | undefined\n): PxBox[] {\n  const boxes: PxBox[] = [];\n  for (const g of groups) {\n    const pts = nodes\n      .filter((n) => n.group === g.id)\n      .map((n) => center(n.id))\n      .filter((p): p is { x: number; y: number } => Boolean(p));\n    if (pts.length === 0) continue;\n    const minX = Math.min(...pts.map((p) => p.x));\n    const maxX = Math.max(...pts.map((p) => p.x));\n    const minY = Math.min(...pts.map((p) => p.y));\n    const maxY = Math.max(...pts.map((p) => p.y));\n    boxes.push({\n      id: g.id,\n      x: minX - ARCH_GROUP_PAD.x,\n      y: minY - ARCH_GROUP_PAD.top,\n      w: maxX - minX + ARCH_GROUP_PAD.x * 2,\n      h: maxY - minY + ARCH_GROUP_PAD.top + ARCH_GROUP_PAD.bottom,\n    });\n  }\n  return boxes;\n}\n\n/** Point where the segment from (cx, cy) toward (tx, ty) leaves a centered rectangle. */\nexport function clipToRect(cx: number, cy: number, tx: number, ty: number, hw: number, hh: number) {\n  const dx = tx - cx;\n  const dy = ty - cy;\n  if (dx === 0 && dy === 0) return { x: cx, y: cy };\n  const t = Math.min(dx === 0 ? Infinity : hw / Math.abs(dx), dy === 0 ? Infinity : hh / Math.abs(dy));\n  return { x: cx + dx * t, y: cy + dy * t };\n}\n\nexport interface PxEdge {\n  index: number;\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n  mx: number;\n  my: number;\n  path: string;\n}\n\n/** Computes an SVG path data string and midpoint for straight, bezier, or smoothstep (orthogonal with rounded corners) edges. */\nexport function buildEdgePath(\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n  routing: ArchitectureEdgeRouting = \"smoothstep\",\n  direction: ArchitectureDirection = \"LR\",\n  borderRadius: number = 8\n): { path: string; mx: number; my: number } {\n  if (routing === \"straight\") {\n    return {\n      path: `M ${x1} ${y1} L ${x2} ${y2}`,\n      mx: (x1 + x2) / 2,\n      my: (y1 + y2) / 2,\n    };\n  }\n\n  if (routing === \"bezier\") {\n    if (direction === \"LR\") {\n      const dx = Math.max(32, Math.abs(x2 - x1) * 0.5);\n      const cx1 = x1 + dx;\n      const cy1 = y1;\n      const cx2 = x2 - dx;\n      const cy2 = y2;\n      return {\n        path: `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`,\n        mx: (x1 + x2) / 2,\n        my: (y1 + y2) / 2,\n      };\n    } else {\n      const dy = Math.max(32, Math.abs(y2 - y1) * 0.5);\n      const cx1 = x1;\n      const cy1 = y1 + dy;\n      const cx2 = x2;\n      const cy2 = y2 - dy;\n      return {\n        path: `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`,\n        mx: (x1 + x2) / 2,\n        my: (y1 + y2) / 2,\n      };\n    }\n  }\n\n  // \"smoothstep\" - orthogonal step routing with rounded corners\n  if (direction === \"LR\") {\n    const dx = x2 - x1;\n    const dy = y2 - y1;\n\n    if (dx >= 12) {\n      const mx = x1 + dx / 2;\n      const r = Math.min(borderRadius, Math.abs(dx) / 2, Math.abs(dy) / 2);\n      if (r <= 1 || Math.abs(dy) <= 2) {\n        return {\n          path: `M ${x1} ${y1} L ${mx} ${y1} L ${mx} ${y2} L ${x2} ${y2}`,\n          mx,\n          my: (y1 + y2) / 2,\n        };\n      }\n      const sy = dy > 0 ? 1 : -1;\n      return {\n        path: `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1} ${mx} ${y1 + r * sy} L ${mx} ${y2 - r * sy} Q ${mx} ${y2} ${mx + r} ${y2} L ${x2} ${y2}`,\n        mx,\n        my: (y1 + y2) / 2,\n      };\n    } else {\n      const r = Math.min(borderRadius, 12);\n      const offset = 32;\n      const midY = dy >= 0 ? Math.max(y1, y2) + offset : Math.min(y1, y2) - offset;\n      const sy1 = midY > y1 ? 1 : -1;\n      const sy2 = y2 > midY ? 1 : -1;\n      return {\n        path: `M ${x1} ${y1} L ${x1 + 16} ${y1} Q ${x1 + 16 + r} ${y1} ${x1 + 16 + r} ${y1 + r * sy1} L ${x1 + 16 + r} ${midY - r * sy1} Q ${x1 + 16 + r} ${midY} ${x1 + 16} ${midY} L ${x2 - 16} ${midY} Q ${x2 - 16 - r} ${midY} ${x2 - 16 - r} ${midY + r * sy2} L ${x2 - 16 - r} ${y2 - r * sy2} Q ${x2 - 16 - r} ${y2} ${x2 - 16} ${y2} L ${x2} ${y2}`,\n        mx: (x1 + x2) / 2,\n        my: midY,\n      };\n    }\n  } else {\n    const dx = x2 - x1;\n    const dy = y2 - y1;\n\n    if (dy >= 12) {\n      const my = y1 + dy / 2;\n      const r = Math.min(borderRadius, Math.abs(dx) / 2, Math.abs(dy) / 2);\n      if (r <= 1 || Math.abs(dx) <= 2) {\n        return {\n          path: `M ${x1} ${y1} L ${x1} ${my} L ${x2} ${my} L ${x2} ${y2}`,\n          mx: (x1 + x2) / 2,\n          my,\n        };\n      }\n      const sx = dx > 0 ? 1 : -1;\n      return {\n        path: `M ${x1} ${y1} L ${x1} ${my - r} Q ${x1} ${my} ${x1 + r * sx} ${my} L ${x2 - r * sx} ${my} Q ${x2} ${my} ${x2} ${my + r} L ${x2} ${y2}`,\n        mx: (x1 + x2) / 2,\n        my,\n      };\n    } else {\n      const r = Math.min(borderRadius, 12);\n      const offset = 36;\n      const midX = dx >= 0 ? Math.max(x1, x2) + offset : Math.min(x1, x2) - offset;\n      const sx1 = midX > x1 ? 1 : -1;\n      const sx2 = x2 > midX ? 1 : -1;\n      return {\n        path: `M ${x1} ${y1} L ${x1} ${y1 + 16} Q ${x1} ${y1 + 16 + r} ${x1 + r * sx1} ${y1 + 16 + r} L ${midX - r * sx1} ${y1 + 16 + r} Q ${midX} ${y1 + 16 + r} ${midX} ${y1 + 16} L ${midX} ${y2 - 16} Q ${midX} ${y2 - 16 - r} ${midX + r * sx2} ${y2 - 16 - r} L ${x2 - r * sx2} ${y2 - 16 - r} Q ${x2} ${y2 - 16 - r} ${x2} ${y2 - 16} L ${x2} ${y2}`,\n        mx: midX,\n        my: (y1 + y2) / 2,\n      };\n    }\n  }\n}\n\n/** Edge segments in px, clipped to node boxes. Opposite edges between the same pair are offset apart. */\nexport function routeEdges(\n  connections: ArchitectureConnectionItem[],\n  center: (id: string) => { x: number; y: number } | undefined,\n  defaultRouting: ArchitectureEdgeRouting = \"smoothstep\",\n  direction: ArchitectureDirection = \"LR\"\n): PxEdge[] {\n  const pairs = new Set(connections.map((c) => `${c.from}\\u0000${c.to}`));\n  const hw = ARCH_NODE_W / 2;\n  const hh = ARCH_NODE_H / 2;\n  const edges: PxEdge[] = [];\n\n  connections.forEach((c, index) => {\n    const a = center(c.from);\n    const b = center(c.to);\n    if (!a || !b) return;\n\n    let ox = 0;\n    let oy = 0;\n    if (pairs.has(`${c.to}\\u0000${c.from}`)) {\n      const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;\n      ox = (-(b.y - a.y) / len) * 7;\n      oy = ((b.x - a.x) / len) * 7;\n    }\n    const ac = { x: a.x + ox, y: a.y + oy };\n    const bc = { x: b.x + ox, y: b.y + oy };\n\n    let start = clipToRect(ac.x, ac.y, bc.x, bc.y, hw, hh);\n    let end = clipToRect(bc.x, bc.y, ac.x, ac.y, hw + 3, hh + 3);\n\n    const routing = c.routing ?? defaultRouting;\n    if (routing !== \"straight\") {\n      if (direction === \"LR\" && b.x >= a.x + hw) {\n        start = { x: a.x + hw, y: a.y + oy };\n        end = { x: b.x - hw - 3, y: b.y + oy };\n      } else if (direction === \"TB\" && b.y >= a.y + hh) {\n        start = { x: a.x + ox, y: a.y + hh };\n        end = { x: b.x + ox, y: b.y - hh - 3 };\n      }\n    }\n\n    const geom = buildEdgePath(start.x, start.y, end.x, end.y, routing, direction);\n\n    edges.push({\n      index,\n      x1: start.x,\n      y1: start.y,\n      x2: end.x,\n      y2: end.y,\n      mx: geom.mx,\n      my: geom.my,\n      path: geom.path,\n    });\n  });\n  return edges;\n}\n\nexport interface ArchitectureCanvasProps extends React.HTMLAttributes<HTMLDivElement> {\n  title?: string;\n  subtitle?: string;\n  badge?: React.ReactNode;\n  nodes: ArchitectureNodeItem[];\n  connections?: ArchitectureConnectionItem[];\n  /** Boundaries (services, layers, packages). Members point at a group with `node.group`. */\n  groups?: ArchitectureGroupItem[];\n  /** Flow direction used when nodes have no x/y. Default \"LR\". */\n  direction?: ArchitectureDirection;\n  selectedNodeId?: string;\n  onNodeSelect?: (nodeId: string) => void;\n  /** Edge routing algorithm: \"smoothstep\" (orthogonal with rounded corners), \"bezier\" (smooth spline), or \"straight\". Default \"smoothstep\". */\n  routing?: ArchitectureEdgeRouting;\n  /** Whether to show canvas navigation / zoom controls (+, -, reset). Default false. */\n  showControls?: boolean;\n  /** Whether to show connection port handles on nodes. Default true. */\n  showHandles?: boolean;\n  /** Compact mode for narrow sidebars or split panes. Reduces viewport padding and default min-height. */\n  compact?: boolean;\n  /** Minimum width for the viewport canvas or container. */\n  minWidth?: number | string;\n  /** Minimum height for the viewport canvas or container. Default 380px (or 240px if compact). */\n  minHeight?: number | string;\n  /** Whether to wrap node labels instead of single-line truncation. Default false. */\n  wrapLabels?: boolean;\n}\n\nconst STATUS_BORDER: Record<ArchitectureStatus, string> = {\n  ok: \"border-l-[3px] border-l-emerald-500\",\n  info: \"\",\n  warn: \"border-l-[3px] border-l-amber-500\",\n  err: \"border-l-[3px] border-l-rose-500\",\n};\n\nexport function ArchitectureCanvas({\n  title = \"System Architecture\",\n  subtitle,\n  badge,\n  nodes: inputNodes,\n  connections: inputConnections = [],\n  groups = [],\n  direction = \"LR\",\n  selectedNodeId: controlledSelected,\n  onNodeSelect,\n  routing = \"smoothstep\",\n  showControls = false,\n  showHandles = true,\n  compact = false,\n  minWidth,\n  minHeight,\n  wrapLabels = false,\n  className,\n  ...props\n}: ArchitectureCanvasProps) {\n  const [collapsed, setCollapsed] = React.useState<string[]>(() => initialCollapsedGroups(groups));\n  const [internalSelected, setInternalSelected] = React.useState<string | null>(\n    inputNodes.length > 0 ? inputNodes[0].id : null\n  );\n  const [zoom, setZoom] = React.useState(1);\n\n  const handleZoomIn = () => setZoom((z) => Math.min(2.0, Math.round((z + 0.15) * 100) / 100));\n  const handleZoomOut = () => setZoom((z) => Math.max(0.5, Math.round((z - 0.15) * 100) / 100));\n  const handleZoomReset = () => setZoom(1);\n\n  const { nodes, connections } = React.useMemo(\n    () => collapseArchitecture(inputNodes, inputConnections, groups, collapsed),\n    [inputNodes, inputConnections, groups, collapsed]\n  );\n  const layout = React.useMemo(\n    () => layoutArchitecture(nodes, connections, groups, direction),\n    [nodes, connections, groups, direction]\n  );\n\n  const activeNodeId = controlledSelected !== undefined ? controlledSelected : internalSelected;\n  const activeNode = nodes.find((n) => n.id === activeNodeId);\n\n  const toggleGroup = (id: string) =>\n    setCollapsed((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]));\n\n  const handleNodeClick = (id: string, collapsedGroupId?: string) => {\n    if (collapsedGroupId) toggleGroup(collapsedGroupId);\n    if (controlledSelected === undefined) {\n      setInternalSelected(id);\n    }\n    onNodeSelect?.(id);\n  };\n\n  const nodeMap = new Map<string, ArchitectureNodeItem>();\n  nodes.forEach((n) => nodeMap.set(n.id, n));\n\n  // Px mode: some nodes were placed automatically, so everything is positioned in px.\n  const center = (id: string) => {\n    const n = nodeMap.get(id);\n    if (!n || !layout) return undefined;\n    if (n.x !== undefined && n.y !== undefined) {\n      return { x: (n.x / 100) * layout.width, y: (n.y / 100) * layout.height };\n    }\n    return layout.positions.get(id);\n  };\n\n  const pxEdges = layout ? routeEdges(connections, center, routing, direction) : [];\n  const pxGroups = layout ? groupBoxes(groups, nodes, center) : [];\n  const groupMap = new Map(groups.map((g) => [g.id, g]));\n\n  // Percent mode (all nodes pinned): group borders as calc() around the member bounding box.\n  const pctGroups = layout\n    ? []\n    : groups.flatMap((g) => {\n        const m = nodes.filter((n) => n.group === g.id && n.x !== undefined && n.y !== undefined);\n        if (m.length === 0) return [];\n        const xs = m.map((n) => n.x as number);\n        const ys = m.map((n) => n.y as number);\n        const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];\n        return [\n          {\n            id: g.id,\n            style: {\n              left: `calc(${x0}% - ${ARCH_GROUP_PAD.x}px)`,\n              top: `calc(${y0}% - ${ARCH_GROUP_PAD.top}px)`,\n              width: `calc(${x1 - x0}% + ${ARCH_GROUP_PAD.x * 2}px)`,\n              height: `calc(${y1 - y0}% + ${ARCH_GROUP_PAD.top + ARCH_GROUP_PAD.bottom}px)`,\n            } as React.CSSProperties,\n          },\n        ];\n      });\n\n  const renderGroup = (id: string, style: React.CSSProperties) => {\n    const g = groupMap.get(id);\n    if (!g) return null;\n    return (\n      <div\n        key={`group-${id}`}\n        className={cn(\n          \"absolute z-0 box-border rounded-md border bg-card/60 pointer-events-none\",\n          g.kind === \"external\" ? \"border-dotted border-border bg-transparent\" : \"border-dashed border-border\",\n          g.kind === \"layer\" && \"border-solid\"\n        )}\n        style={style}\n      >\n        <div className=\"flex items-center gap-2 h-[30px] px-2.5 font-mono text-xs text-muted-foreground\">\n          <button\n            type=\"button\"\n            aria-expanded=\"true\"\n            aria-label={`Collapse ${g.label}`}\n            onClick={() => toggleGroup(id)}\n            className=\"pointer-events-auto cursor-pointer size-[1.15rem] leading-none rounded border border-border bg-background text-muted-foreground hover:text-foreground\"\n          >\n            -\n          </button>\n          <span className=\"font-semibold text-foreground\">{g.label}</span>\n          {g.kind && <span className=\"uppercase tracking-wider text-[0.68rem]\">{g.kind}</span>}\n        </div>\n      </div>\n    );\n  };\n\n  const renderNode = (node: (typeof nodes)[number], style: React.CSSProperties, fixed: boolean) => {\n    const isSelected = node.id === activeNodeId;\n    const isVisited = node.visited;\n    return (\n      <div\n        key={node.id}\n        className={cn(\n          \"absolute -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-md shadow-xs cursor-pointer transition-all hover:-translate-y-[52%]\",\n          fixed ? \"overflow-hidden px-3 py-2.5\" : \"min-w-[160px] max-w-[240px] p-3.5\",\n          node.collapsedGroupId && \"border-dashed\",\n          node.status && STATUS_BORDER[node.status],\n          !node.status && isVisited && \"border-l-[3px] border-l-emerald-500/70\",\n          isSelected && \"border-primary ring-2 ring-primary bg-primary/5\"\n        )}\n        style={style}\n        onClick={() => handleNodeClick(node.id, node.collapsedGroupId)}\n        title={node.label}\n      >\n        {showHandles && !node.collapsedGroupId && (\n          <>\n            {direction === \"TB\" ? (\n              <>\n                <span className=\"absolute -top-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-background border border-border pointer-events-none\" aria-hidden=\"true\" />\n                <span className=\"absolute -bottom-1 left-1/2 -translate-x-1/2 size-2 rounded-full bg-background border border-border pointer-events-none\" aria-hidden=\"true\" />\n              </>\n            ) : (\n              <>\n                <span className=\"absolute -left-1 top-1/2 -translate-y-1/2 size-2 rounded-full bg-background border border-border pointer-events-none\" aria-hidden=\"true\" />\n                <span className=\"absolute -right-1 top-1/2 -translate-y-1/2 size-2 rounded-full bg-background border border-border pointer-events-none\" aria-hidden=\"true\" />\n              </>\n            )}\n          </>\n        )}\n        <div className=\"flex items-center justify-between gap-2 mb-1\">\n          <div className=\"flex items-center gap-1.5 min-w-0\">\n            {isVisited && (\n              <span\n                className=\"inline-flex items-center justify-center size-3.5 rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-semibold shrink-0\"\n                title=\"Visited\"\n                aria-label=\"Visited\"\n              >\n                ✓\n              </span>\n            )}\n            <span\n              className={cn(\n                \"font-mono text-sm font-medium tracking-tight text-foreground\",\n                wrapLabels ? \"whitespace-normal break-words leading-tight\" : \"truncate\"\n              )}\n              title={node.label}\n            >\n              {node.collapsedGroupId ? `+ ${node.label}` : node.label}\n            </span>\n          </div>\n          {node.badge && (\n            <span className=\"font-mono text-xs font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0\">\n              {node.badge}\n            </span>\n          )}\n        </div>\n        {node.description && (\n          <p className=\"text-xs text-muted-foreground line-clamp-2 leading-relaxed\">{node.description}</p>\n        )}\n      </div>\n    );\n  };\n\n  const labelClass =\n    \"absolute -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded px-2 py-0.5 font-mono text-xs font-medium text-foreground shadow-xs z-30\";\n\n  const viewportMinHeight = minHeight !== undefined ? minHeight : compact ? \"240px\" : \"380px\";\n  const viewportMinWidth = minWidth !== undefined ? minWidth : undefined;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col border border-border rounded-lg bg-card overflow-hidden shadow-xs my-6\",\n        className\n      )}\n      {...props}\n    >\n      <div className=\"flex items-center justify-between p-3.5 bg-background border-b border-border flex-wrap gap-2\">\n        <div className=\"flex items-center gap-2.5\">\n          <span className=\"font-heading font-bold text-base text-foreground\">{title}</span>\n          {subtitle && <span className=\"text-sm text-muted-foreground\">{subtitle}</span>}\n        </div>\n        {badge && <div>{badge}</div>}\n      </div>\n\n      <div\n        className={cn(\n          \"relative w-full overflow-x-auto overflow-y-hidden bg-background\",\n          compact ? \"p-3.5\" : \"p-6\"\n        )}\n        style={{\n          minHeight: viewportMinHeight,\n          minWidth: viewportMinWidth,\n        }}\n      >\n        {layout ? (\n          <div\n            className=\"relative z-20 mx-auto\"\n            style={{\n              width: layout.width,\n              height: layout.height,\n              transform: zoom !== 1 ? `scale(${zoom})` : undefined,\n              transformOrigin: \"top left\",\n              transition: \"transform 0.15s ease-out\",\n            }}\n          >\n            {pxGroups.map((b) => renderGroup(b.id, { left: b.x, top: b.y, width: b.w, height: b.h }))}\n\n            <svg\n              className=\"absolute inset-0 w-full h-full pointer-events-none z-10\"\n              width={layout.width}\n              height={layout.height}\n              viewBox={`0 0 ${layout.width} ${layout.height}`}\n            >\n              <defs>\n                <marker id=\"aui-reg-arrow-px\" viewBox=\"0 0 10 10\" refX=\"10\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\">\n                  <path d=\"M 0 1 L 10 5 L 0 9 z\" className=\"fill-muted-foreground\" />\n                </marker>\n                <marker id=\"aui-reg-arrow-px-active\" viewBox=\"0 0 10 10\" refX=\"10\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\">\n                  <path d=\"M 0 1 L 10 5 L 0 9 z\" className=\"fill-primary\" />\n                </marker>\n              </defs>\n              {pxEdges.map((e) => {\n                const conn = connections[e.index];\n                const isActive = activeNodeId === conn.from || activeNodeId === conn.to;\n                return (\n                  <g key={`edge-${e.index}`}>\n                    <path\n                      d={e.path}\n                      fill=\"none\"\n                      className={cn(conn.animated && \"animate-pulse\")}\n                      stroke={isActive ? \"hsl(var(--primary))\" : \"hsl(var(--border))\"}\n                      strokeWidth={isActive ? 2 : 1.5}\n                      strokeDasharray={conn.variant === \"dashed\" ? \"4 4\" : undefined}\n                      markerEnd={isActive ? \"url(#aui-reg-arrow-px-active)\" : \"url(#aui-reg-arrow-px)\"}\n                    />\n                    {conn.animated && (\n                      <circle r=\"3.5\" className=\"fill-primary\">\n                        <animateMotion path={e.path} dur=\"2.4s\" repeatCount=\"indefinite\" />\n                      </circle>\n                    )}\n                  </g>\n                );\n              })}\n            </svg>\n\n            {pxEdges.map((e) => {\n              const label = connections[e.index].label;\n              if (!label) return null;\n              return (\n                <div key={`label-${e.index}`} className={labelClass} style={{ left: e.mx, top: e.my }}>\n                  {label}\n                </div>\n              );\n            })}\n\n            {nodes.map((node) => {\n              const c = center(node.id);\n              if (!c) return null;\n              return renderNode(node, { left: c.x, top: c.y, width: ARCH_NODE_W, height: ARCH_NODE_H }, true);\n            })}\n          </div>\n        ) : (\n          <>\n            <svg className=\"absolute inset-0 w-full h-full pointer-events-none z-10\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\">\n              <defs>\n                <marker\n                  id=\"aui-reg-arrow\"\n                  viewBox=\"0 0 10 10\"\n                  refX=\"6\"\n                  refY=\"5\"\n                  markerWidth=\"6\"\n                  markerHeight=\"6\"\n                  orient=\"auto-start-reverse\"\n                >\n                  <path d=\"M 0 1 L 10 5 L 0 9 z\" className=\"fill-muted-foreground\" />\n                </marker>\n                <marker\n                  id=\"aui-reg-arrow-active\"\n                  viewBox=\"0 0 10 10\"\n                  refX=\"6\"\n                  refY=\"5\"\n                  markerWidth=\"6\"\n                  markerHeight=\"6\"\n                  orient=\"auto-start-reverse\"\n                >\n                  <path d=\"M 0 1 L 10 5 L 0 9 z\" className=\"fill-primary\" />\n                </marker>\n              </defs>\n\n              {connections.map((conn, idx) => {\n                const src = nodeMap.get(conn.from);\n                const dst = nodeMap.get(conn.to);\n                if (!src || !dst) return null;\n\n                const isConnActive = activeNodeId === conn.from || activeNodeId === conn.to;\n                const strokeColor = isConnActive ? \"hsl(var(--primary))\" : \"hsl(var(--border))\";\n                const edgeRouting = conn.routing ?? routing;\n                const geom = buildEdgePath(\n                  src.x as number,\n                  src.y as number,\n                  dst.x as number,\n                  dst.y as number,\n                  edgeRouting,\n                  direction\n                );\n\n                return (\n                  <g key={`edge-${idx}`}>\n                    <path\n                      d={geom.path}\n                      fill=\"none\"\n                      className={cn(conn.animated && \"animate-pulse\")}\n                      stroke={strokeColor}\n                      strokeWidth={isConnActive ? 2 : 1.5}\n                      strokeDasharray={conn.variant === \"dashed\" ? \"4 4\" : undefined}\n                      markerEnd={isConnActive ? \"url(#aui-reg-arrow-active)\" : \"url(#aui-reg-arrow)\"}\n                    />\n                    {conn.animated && (\n                      <circle r=\"3.5\" className=\"fill-primary\">\n                        <animateMotion path={geom.path} dur=\"2.4s\" repeatCount=\"indefinite\" />\n                      </circle>\n                    )}\n                  </g>\n                );\n              })}\n            </svg>\n\n            <div\n              className=\"relative z-20 w-full min-w-[640px] h-full min-h-[320px]\"\n              style={{\n                transform: zoom !== 1 ? `scale(${zoom})` : undefined,\n                transformOrigin: \"top left\",\n                transition: \"transform 0.15s ease-out\",\n              }}\n            >\n              {pctGroups.map((g) => renderGroup(g.id, g.style))}\n\n              {connections.map((conn, idx) => {\n                if (!conn.label) return null;\n                const src = nodeMap.get(conn.from);\n                const dst = nodeMap.get(conn.to);\n                if (!src || !dst) return null;\n\n                const edgeRouting = conn.routing ?? routing;\n                const geom = buildEdgePath(\n                  src.x as number,\n                  src.y as number,\n                  dst.x as number,\n                  dst.y as number,\n                  edgeRouting,\n                  direction\n                );\n\n                return (\n                  <div key={`label-${idx}`} className={labelClass} style={{ left: `${geom.mx}%`, top: `${geom.my}%` }}>\n                    {conn.label}\n                  </div>\n                );\n              })}\n\n              {nodes.map((node) => renderNode(node, { left: `${node.x}%`, top: `${node.y}%` }, false))}\n            </div>\n          </>\n        )}\n\n        {showControls && (\n          <div className=\"absolute bottom-3 right-3 inline-flex items-center bg-card border border-border rounded-md shadow-xs z-30 overflow-hidden\" role=\"toolbar\" aria-label=\"Canvas zoom controls\">\n            <button\n              type=\"button\"\n              className=\"inline-flex items-center justify-center size-6.5 text-muted-foreground hover:text-foreground hover:bg-muted font-mono text-sm font-medium border-r border-border\"\n              onClick={handleZoomIn}\n              title=\"Zoom in\"\n              aria-label=\"Zoom in\"\n            >\n              +\n            </button>\n            <button\n              type=\"button\"\n              className=\"inline-flex items-center justify-center size-6.5 text-muted-foreground hover:text-foreground hover:bg-muted font-mono text-sm font-medium border-r border-border\"\n              onClick={handleZoomOut}\n              title=\"Zoom out\"\n              aria-label=\"Zoom out\"\n            >\n              −\n            </button>\n            <button\n              type=\"button\"\n              className=\"px-1.5 font-mono text-[0.7rem] text-muted-foreground hover:text-foreground hover:bg-muted\"\n              onClick={handleZoomReset}\n              title=\"Reset zoom\"\n              aria-label=\"Reset zoom\"\n            >\n              {Math.round(zoom * 100)}%\n            </button>\n          </div>\n        )}\n      </div>\n\n      {activeNode && (\n        <div className=\"p-3.5 bg-card border-t border-border flex items-center justify-between flex-wrap gap-4\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"font-mono text-sm font-medium text-foreground\">\n              Inspecting: {activeNode.label}\n            </span>\n            {activeNode.badge && (\n              <span className=\"font-mono text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground font-medium\">\n                {activeNode.badge}\n              </span>\n            )}\n          </div>\n          {activeNode.metadata && (\n            <div className=\"flex items-center gap-4 flex-wrap\">\n              {Object.entries(activeNode.metadata).map(([key, val]) => (\n                <div key={key} className=\"flex items-center gap-1.5 font-mono text-xs text-muted-foreground\">\n                  <span>{key}:</span>\n                  <strong className=\"text-foreground\">{val}</strong>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ]
}