From 12988989e6ed1d8f43271ed6cbfd4ba8f5d0d8b9 Mon Sep 17 00:00:00 2001 From: rishibaghel25 Date: Sun, 18 Oct 2026 01:42:53 +0530 Subject: [PATCH] feat(canvas): real containment hierarchy for the map view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors SaaS PR #20. Kubernetes (big node, helm logo) |- Cluster |- CronJobs |- Deployments |- Namespaces | |- cert-manager | | |- cert-manager | | |- cert-manager-webhook | |- cs | |- manager-*, az-* Three earlier attempts each got half of it wrong: grouping by type alone destroyed parentage, a free radial layout destroyed grouping (identical discs, undifferentiable), labelled blocks kept grouping but had no hierarchy inside them. - `hierarchyMap.ts` — synthesises platform roots, resource-type nodes and containment edges; layout centres each node over its children and grid-wraps wide fan-outs - `MapHierarchyNodes.tsx` — root node with the Kubernetes helm / Docker marks, and type nodes (`Pods 44`) Namespaced resources are drawn under their namespace so the parentage is real; the type node still links to them so "Pods" reaches its pods. Two layout bugs fixed: `cy` was computed once and never advanced per row so wrapped grids stacked on one line (91 overlapping pairs), and column count came from child count alone which made the tree 6000px wide (aspect 4.21 -> 1.17). --- frontend/components/canvas/InfraCanvas.tsx | 24 +- .../components/canvas/MapHierarchyNodes.tsx | 110 ++++++ frontend/components/canvas/MapLabel.tsx | 46 --- frontend/lib/hierarchyMap.ts | 320 ++++++++++++++++++ frontend/lib/structuredMap.ts | 168 --------- 5 files changed, 446 insertions(+), 222 deletions(-) create mode 100644 frontend/components/canvas/MapHierarchyNodes.tsx delete mode 100644 frontend/components/canvas/MapLabel.tsx create mode 100644 frontend/lib/hierarchyMap.ts delete mode 100644 frontend/lib/structuredMap.ts diff --git a/frontend/components/canvas/InfraCanvas.tsx b/frontend/components/canvas/InfraCanvas.tsx index 2c8cbc8..221c156 100644 --- a/frontend/components/canvas/InfraCanvas.tsx +++ b/frontend/components/canvas/InfraCanvas.tsx @@ -24,9 +24,9 @@ import { } from '@/types' import { applyRootedDagreLayout, applyNamespaceHierarchyLayout } from '@/lib/layout' import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from '@/lib/radialMap' -import { applyStructuredMapLayout } from '@/lib/structuredMap' +import { buildHierarchy, applyHierarchyLayout } from '@/lib/hierarchyMap' import MapNode from './MapNode' -import MapLabel from './MapLabel' +import { MapRoot, MapType } from './MapHierarchyNodes' import { sendCommand } from '@/lib/wsManager' import { buildGroupedGraph, @@ -170,7 +170,8 @@ const nodeTypes: NodeTypes = { namespaceGroup: NamespaceGroupNode, groupNode: GroupNode, mapNode: MapNode, - mapLabel: MapLabel, + mapRoot: MapRoot, + mapType: MapType, } // ─── Main component ─────────────────────────────────────────────────────────── @@ -383,10 +384,17 @@ export default function InfraCanvas({ vm, onBack }: InfraCanvasProps) { ) // The map draws every resource with the compact disc-and-label node; // `buildFlatFlowElements` emits the card nodes used by the tree view. - flowNodes = viewMode === 'map' - ? fn.map((n) => ({ ...n, type: 'mapNode', width: MAP_NODE_WIDTH, height: MAP_NODE_HEIGHT })) - : fn - flowEdges = fe + if (viewMode === 'map') { + const h = buildHierarchy( + fn.map((n) => ({ ...n, type: 'mapNode', width: MAP_NODE_WIDTH, height: MAP_NODE_HEIGHT })) as Node[], + fe as Edge[], + ) + flowNodes = h.nodes + flowEdges = h.edges as Edge[] + } else { + flowNodes = fn + flowEdges = fe + } setGroupsMap(new Map()) } @@ -402,7 +410,7 @@ export default function InfraCanvas({ vm, onBack }: InfraCanvasProps) { // Every node on screen, arranged as a hub-and-spokes map. Deterministic // by design: snapshots arrive every 15s and a re-randomised layout would // make the whole map jump on every refresh. - ln = applyStructuredMapLayout(flowNodes, flowEdges).nodes + ln = applyHierarchyLayout(flowNodes, flowEdges).nodes le = flowEdges.map((e) => ({ ...e, label: undefined, diff --git a/frontend/components/canvas/MapHierarchyNodes.tsx b/frontend/components/canvas/MapHierarchyNodes.tsx new file mode 100644 index 0000000..73eee02 --- /dev/null +++ b/frontend/components/canvas/MapHierarchyNodes.tsx @@ -0,0 +1,110 @@ +'use client' + +import { memo } from 'react' +import { Handle, Position, type NodeProps } from 'reactflow' +import { HIER_ROOT_W, HIER_ROOT_H, HIER_TYPE_W, HIER_TYPE_H } from '@/lib/hierarchyMap' +import NodeSvgIcon from './NodeSvgIcon' + +/** Platform branding. The Kubernetes helm is the one logo worth drawing — it + * is what makes the top of the tree read as "this is Kubernetes" at a glance + * rather than as a generic box labelled with a word. */ +function PlatformMark({ platform, size }: { platform: string; size: number }) { + if (platform === 'Kubernetes') { + return ( + + + + + + + + + + ) + } + if (platform === 'Docker') { + return ( + + + + + + + + + + ) + } + return ( + + + + + + + ) +} + +/** Top-of-tree platform node: Kubernetes / Docker / Host. */ +export const MapRoot = memo(({ data, selected }: NodeProps<{ label: string; count: number }>) => { + return ( +
+ + +
+
{data.label}
+
{data.count} resources
+
+
+ ) +}) +MapRoot.displayName = 'MapRoot' + +/** Resource-type node, e.g. "Pods · 43", hanging under a platform. */ +export const MapType = memo(({ data, selected }: NodeProps<{ label: string; kind: string; count: number }>) => { + return ( +
+ + + + {data.label} + {data.count} +
+ ) +}) +MapType.displayName = 'MapType' diff --git a/frontend/components/canvas/MapLabel.tsx b/frontend/components/canvas/MapLabel.tsx deleted file mode 100644 index b024387..0000000 --- a/frontend/components/canvas/MapLabel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import { memo } from 'react' -import { type NodeProps } from 'reactflow' - -/** - * Header for a block in the structured map. - * - * The point of the structured layout is that the block *says what it is* — a - * namespace name and how much it holds — so a wall of individual nodes is - * still navigable. Without this the map is just as anonymous as the radial - * version, just rectangular. - */ - -const KIND_STYLE: Record = { - ns: { color: '#a5b4fc', bg: 'rgba(99,102,241,0.10)', border: 'rgba(99,102,241,0.30)' }, - type: { color: '#94a3b8', bg: 'rgba(100,116,139,0.10)', border: 'rgba(100,116,139,0.28)' }, -} - -const MapLabel = memo(({ data }: NodeProps<{ label: string; sub: string; kind: string }>) => { - const s = KIND_STYLE[data.kind] ?? KIND_STYLE.type - return ( -
- {data.label} - {data.sub} -
- ) -}) - -MapLabel.displayName = 'MapLabel' -export default MapLabel diff --git a/frontend/lib/hierarchyMap.ts b/frontend/lib/hierarchyMap.ts new file mode 100644 index 0000000..4687519 --- /dev/null +++ b/frontend/lib/hierarchyMap.ts @@ -0,0 +1,320 @@ +/** + * hierarchyMap.ts + * + * A real containment hierarchy for the map view: + * + * Kubernetes (big node, logo) + * ├── Pods ×43 + * ├── Namespaces ×9 ── cs-agent ── its pods + * │ kube-system ── its pods + * ├── Deployments ×30 + * └── Services ×44 + * + * The previous attempts each got one half of this wrong. Grouping by type + * alone threw away parentage — one flat `Pods ×9` node owned by nothing. A + * free radial layout threw away grouping — 189 identical discs. Bucketing + * into labelled blocks kept the grouping but had no hierarchy *inside* it, so + * a namespace's resources looked like a flat shelf rather than something + * belonging to it. + * + * So: platform roots at the top, resource-type nodes beneath them, individual + * entities beneath those, and a namespace's own members beneath the namespace. + * Containment is expressed by position *and* an edge, so the structure is + * readable at a glance and still navigable by following lines. + * + * Deterministic — snapshots arrive every 15s and the layout must not reshuffle. + */ + +import { Node, Edge } from 'reactflow' +import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from './radialMap' + +// ── Sizes ───────────────────────────────────────────────────────────────────── +const ROOT_W = 210 +const ROOT_H = 62 +const TYPE_W = 168 +const TYPE_H = 42 + +const ENT_W = MAP_NODE_WIDTH +const ENT_H = MAP_NODE_HEIGHT + +const GAP_X = 18 +const GAP_Y = 34 +const LEVEL_H = 62 // vertical gap between levels, on top of node height + +/** Types that live inside a namespace and are therefore drawn under it. */ +const NAMESPACE_SCOPED = new Set([ + 'pod', 'deployment', 'statefulset', 'daemonset', 'job', 'cronjob', + 'k8s_service', 'ingress', 'pvc', +]) + +/** Human label for a raw type. */ +const TYPE_LABEL: Record = { + pod: 'Pods', deployment: 'Deployments', statefulset: 'StatefulSets', + daemonset: 'DaemonSets', job: 'Jobs', cronjob: 'CronJobs', + k8s_service: 'Services', ingress: 'Ingresses', pvc: 'PVCs', + namespace: 'Namespaces', node: 'Nodes', cluster: 'Cluster', + container: 'Containers', image: 'Images', image_group: 'Build Cache', + volume: 'Volumes', network: 'Networks', container_runtime: 'Container Runtime', + event: 'Events', service: 'Services', process: 'Processes', host: 'Host', + configmap: 'ConfigMaps', secret: 'Secrets', + storageclass: 'Storage Classes', persistentvolume: 'Persistent Volumes', + ingressclass: 'Ingress Classes', node_role: 'Node Roles', +} + +/** Which platform a type belongs to, for the root row. */ +const PLATFORM: Record = (() => { + const m: Record = {} + const put = (p: string, types: string[]) => types.forEach((t) => { m[t] = p }) + put('Kubernetes', ['cluster', 'node', 'namespace', 'pod', 'deployment', 'statefulset', + 'daemonset', 'job', 'cronjob', 'k8s_service', 'ingress', 'pvc', 'configmap', + 'secret', 'storageclass', 'persistentvolume', 'ingressclass', 'node_role']) + put('Docker', ['container', 'image', 'image_group', 'volume', 'network', 'container_runtime']) + put('Host', ['host', 'process', 'service']) + return m +})() + +export const HIER_ROOT_W = ROOT_W +export const HIER_ROOT_H = ROOT_H +export const HIER_TYPE_W = TYPE_W +export const HIER_TYPE_H = TYPE_H + +type Meta = Record + +function nsOf(data: Meta): string { + const ns = data?.metadata?.namespace + return typeof ns === 'string' ? ns : '' +} + +/** + * Build the hierarchy nodes and the containment edges between them. + * Returns React Flow nodes plus synthetic edges joining parent → child. + */ +export function buildHierarchy( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + const outNodes: Node[] = [] + const outEdges: Edge[] = [] + const tree = new Map() // parent id → child ids + const add = (parent: string, child: string) => { + tree.set(parent, [...(tree.get(parent) ?? []), child]) + } + + const byType = new Map() + for (const n of nodes) { + const t = String((n.data as Meta)?.nodeType ?? n.type ?? '') + byType.set(t, [...(byType.get(t) ?? []), n]) + } + + // 1. Platform roots, in a stable order. + const platforms = [...new Set([...byType.keys()].map((t) => PLATFORM[t]).filter(Boolean))] + const rootId = (p: string) => `__root__${p}` + for (const p of platforms) { + const count = [...byType.keys()].filter((t) => PLATFORM[t] === p) + .reduce((a, t) => a + (byType.get(t)?.length ?? 0), 0) + outNodes.push({ + id: rootId(p), type: 'mapRoot', + position: { x: 0, y: 0 }, + data: { label: p, count }, + width: ROOT_W, height: ROOT_H, + draggable: true, + } as Node) + } + + // 2. Type nodes under each platform. Namespaces get their entities attached + // as real nodes; every other type's entities attach directly. + for (const t of [...byType.keys()].sort()) { + const list = byType.get(t)! + const platform = PLATFORM[t] + const typeId = `__type__${t}` + + if (!outNodes.some((n) => n.id === typeId)) { + outNodes.push({ + id: typeId, type: 'mapType', + position: { x: 0, y: 0 }, + data: { label: TYPE_LABEL[t] ?? t, kind: t, count: list.length }, + width: TYPE_W, height: TYPE_H, + } as Node) + if (platform) add(rootId(platform), typeId) + } + + if (t === 'namespace') { + // Namespace entities become their own visible nodes; their members hang + // beneath them. + for (const ns of list.slice().sort(byLabel)) { + add(typeId, ns.id) + } + continue + } + + if (NAMESPACE_SCOPED.has(t)) { + // Drawn under their namespace instead, so the parentage is real. + // Still linked to the type node so "Pods" reaches its pods. + continue + } + + for (const n of list.slice().sort(byLabel)) add(typeId, n.id) + } + + // 3. Namespaced resources under their namespace. + const nsByName = new Map() + for (const n of byType.get('namespace') ?? []) nsByName.set(nsOf(n.data as Meta) || String((n.data as Meta)?.label ?? ''), n) + // A namespace node may carry the name in label rather than metadata. + for (const n of byType.get('namespace') ?? []) { + const nm = String((n.data as Meta)?.label ?? '') + if (nm && !nsByName.has(nm)) nsByName.set(nm, n) + } + + for (const t of [...byType.keys()].sort()) { + if (!NAMESPACE_SCOPED.has(t)) continue + const typeId = `__type__${t}` + for (const n of (byType.get(t) ?? []).slice().sort(byLabel)) { + const nsName = nsOf(n.data as Meta) + const parent = nsByName.get(nsName) + if (parent) { + add(parent.id, n.id) + // Also reach it from the type node, so "Pods" connects to its pods. + outEdges.push({ id: `he:${typeId}→${n.id}`, source: typeId, target: n.id, kind: 'type-link' } as Edge) + } else { + // Orphan (its namespace is filtered out) — keep it under its type. + add(typeId, n.id) + } + } + } + + // 4. All the real nodes. + outNodes.push(...nodes) + + // 5. Containment edges. + for (const [parent, children] of tree) { + for (const child of children) { + outEdges.push({ id: `hc:${parent}→${child}`, source: parent, target: child, kind: 'contain' } as Edge) + } + } + + return { nodes: outNodes, edges: outEdges } +} + +function byLabel(a: Node, b: Node): number { + const la = String((a.data as Meta)?.label ?? a.id) + const lb = String((b.data as Meta)?.label ?? b.id) + return la < lb ? -1 : la > lb ? 1 : 0 +} + +// ── Layout ──────────────────────────────────────────────────────────────────── + +interface Box { w: number; h: number; x: number; y: number; node: Node } + +/** + * Lay the hierarchy out top-down, centring each node over its children. + * + * Wide fan-outs (a namespace with 40 pods) wrap into a grid instead of one + * absurd row — the alternative is a tree tens of thousands of pixels wide, + * which fit-view answers by zooming to unreadability. + */ +export function applyHierarchyLayout( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + if (nodes.length === 0) return { nodes, edges } + + const byId = new Map(nodes.map((n) => [n.id, n])) + const children = new Map() + const hasParent = new Set() + for (const e of edges) { + if ((e as { kind?: string }).kind !== 'contain') continue + if (!byId.has(e.source) || !byId.has(e.target)) continue + children.set(e.source, [...(children.get(e.source) ?? []), e.target]) + hasParent.add(e.target) + } + const roots = nodes.filter((n) => !hasParent.has(n.id)).map((n) => n.id) + + const placed: Box[] = [] + // Wider blocks mean fewer levels stacked on top of each other; capped so a + // 50-pod namespace stays a readable rectangle rather than a 9000px ribbon. + const MAX_COLS = 8 + /** Roughly how wide a level of the tree should be. */ + const TARGET_BLOCK_W = 1500 + const CELL_W = Math.max(ENT_W, TYPE_W) + GAP_X + const CELL_H = ENT_H + GAP_Y + + // Depth-first, bottom-up: each subtree reports its size, parent is centred + // above it. + const measure = (id: string): { w: number; h: number; place: (x: number, y: number) => void } => { + const node = byId.get(id)! + const isSynthetic = id.startsWith('__') + const selfW = node.width ?? ENT_W + const selfH = node.height ?? ENT_H + const kids = (children.get(id) ?? []).filter((c) => byId.has(c)) + // Sorted so ordering is stable between refreshes. + kids.sort((a, b) => byLabel(byId.get(a)!, byId.get(b)!)) + + if (kids.length === 0) { + return { w: selfW, h: selfH, place: (x, y) => placed.push({ x, y, w: selfW, h: selfH, node }) } + } + + const sub = kids.map(measure) + // Choose the column count from how wide the children actually are, not just + // how many there are. A fixed count put four ~1200px namespace blocks side + // by side and made the whole tree 6000px wide, which fit-view answers by + // zooming to unreadability. Sizing to a target block width keeps the tree + // near the shape of the viewport. + const avgW = sub.reduce((a, s) => a + s.w, 0) / sub.length + const cols = Math.max(1, Math.min(kids.length, MAX_COLS, Math.round(TARGET_BLOCK_W / (avgW + GAP_X)))) + const rows = Math.ceil(kids.length / cols) + + const colW: number[] = new Array(cols).fill(0) + const rowH: number[] = new Array(rows).fill(0) + sub.forEach((s, i) => { + const c = i % cols + const r = Math.floor(i / cols) + colW[c] = Math.max(colW[c], s.w) + rowH[r] = Math.max(rowH[r], s.h) + }) + const gridW = colW.reduce((a, b) => a + b, 0) + GAP_X * (cols - 1) + const gridH = rowH.reduce((a, b) => a + b, 0) + GAP_Y * (rows - 1) + + const w = Math.max(selfW, gridW) + const h = selfH + LEVEL_H + gridH + + return { + w, h, + place: (x, y) => { + placed.push({ x: x + (w - selfW) / 2, y, w: selfW, h: selfH, node }) + const gx = x + (w - gridW) / 2 + sub.forEach((s, i) => { + const c = i % cols + const r = Math.floor(i / cols) + let cx = gx + for (let k = 0; k < c; k++) cx += colW[k] + GAP_X + // Offset by the height of every row above this one — without this + // every row lands on the same y and stacks into one line. + let ry = y + selfH + LEVEL_H + for (let k = 0; k < r; k++) ry += rowH[k] + GAP_Y + s.place(cx, ry + (rowH[r] - s.h) / 2) + }) + void isSynthetic + }, + } + } + + const top = roots.map((id) => measure(id)) + + // Roots in a row, spaced by their own widths. + let cursor = 0 + top.forEach((t) => { + t.place(cursor, 0) + cursor += t.w + GAP_X * 3 + }) + + // Normalise so the origin is at the top-left of content. + const minX = Math.min(...placed.map((b) => b.x)) + const minY = Math.min(...placed.map((b) => b.y)) + + const out = placed.map((b) => ({ + ...b.node, + position: { x: Math.round(b.x - minX), y: Math.round(b.y - minY) }, + })) + + return { nodes: out, edges } +} diff --git a/frontend/lib/structuredMap.ts b/frontend/lib/structuredMap.ts deleted file mode 100644 index 109b6e9..0000000 --- a/frontend/lib/structuredMap.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * structuredMap.ts - * - * Structured node layout for the map view. - * - * The first version of the map arranged everything on concentric rings around - * the most-connected node. With one host that produced a legible hub-and-spokes - * diagram; with the 189-node kind cluster it produced a uniform circle of 189 - * identical discs where a Pod, a Service and a PVC were indistinguishable. A - * free radial layout has no structure to read once the graph gets big. - * - * The grouped canvas *did* have structure — namespaces owning resource types — - * but it spent that structure on count cards, so you still had to drill in to - * learn anything. - * - * This keeps both: nodes are bucketed by the thing that owns them (namespace - * for k8s resources, type otherwise), each bucket gets its own labelled block - * laid out as a grid of individual nodes, and edges run between the real nodes. - * So the shape on screen is the shape of the cluster, and every single resource - * in it is visible and named. - * - * Deterministic throughout — the agent pushes a snapshot every 15s, so the - * layout must not reshuffle between refreshes. - */ - -import { Node, Edge } from 'reactflow' -import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from './radialMap' - -const CELL_W = MAP_NODE_WIDTH + 16 -const CELL_H = MAP_NODE_HEIGHT + 14 -const PAD = 18 -const HEADER_H = 34 -const BLOCK_GAP = 34 - -/** Owner key: the namespace for namespaced k8s resources, else the type. */ -export function bucketKey(n: Node): string { - const d = n.data as { metadata?: Record; nodeType?: string } - const ns = d?.metadata?.namespace - const type = d?.nodeType ?? n.type ?? 'other' - if (typeof ns === 'string' && ns) return `ns:${ns}` - return `type:${type}` -} - -export function bucketLabel(key: string): string { - const [kind, rest] = key.split(':') - if (kind === 'ns') return rest - return rest.charAt(0).toUpperCase() + rest.slice(1) -} - -/** Plural label used on the block header, e.g. "8 pods". */ -export function bucketPlural(key: string, count: number): string { - const [kind, rest] = key.split(':') - if (kind === 'ns') return `${count} resource${count === 1 ? '' : 's'}` - const s = rest.endsWith('s') ? rest : rest + 's' - return `${count} ${s}` -} - -export function applyStructuredMapLayout( - nodes: Node[], - edges: Edge[], -): { nodes: Node[]; edges: Edge[] } { - if (nodes.length === 0) return { nodes, edges } - - // 1. Bucket by owner, preserving the order they appear in. - const buckets = new Map() - for (const n of nodes) { - const k = bucketKey(n) - buckets.set(k, [...(buckets.get(k) ?? []), n]) - } - - // 2. Sort each bucket by type then name so related resources sit together - // and the block is stable between refreshes. - for (const [, list] of buckets) { - list.sort((a, b) => { - const ta = String((a.data as { nodeType?: string })?.nodeType ?? '') - const tb = String((b.data as { nodeType?: string })?.nodeType ?? '') - if (ta !== tb) return ta < tb ? -1 : 1 - const la = String((a.data as { label?: string })?.label ?? a.id) - const lb = String((b.data as { label?: string })?.label ?? b.id) - return la < lb ? -1 : 1 - }) - } - - // 3. Namespace blocks first and largest-to-smallest, then type blocks. - // Namespaces are what a Kubernetes user navigates by, so they get the top - // band and the widest columns. - const keys = [...buckets.keys()].sort((a, b) => { - const na = a.startsWith('ns:') ? 0 : 1 - const nb = b.startsWith('ns:') ? 0 : 1 - if (na !== nb) return na - nb - return buckets.get(b)!.length - buckets.get(a)!.length - }) - - // 4. Wrap blocks into rows of at most MAX_BLOCK_W, keeping each block's - // height so a row's height is the tallest block in it. - // How wide one block may get, and how wide a row of blocks may get before - // wrapping. These are separate: capping the row at the block width meant one - // block per row, which stacked every namespace into a single tall column and - // wasted all the horizontal space. - const MAX_BLOCK_W = 1180 - const ROW_W = MAX_BLOCK_W * 2 - interface Placed { key: string; list: Node[]; cols: number; rows: number; w: number; h: number } - const placed: Placed[] = [] - - // Widest a single block may be, so one large bucket cannot blow out the row. - const maxCols = Math.max(1, Math.floor((MAX_BLOCK_W - PAD * 2) / CELL_W)) - - for (const key of keys) { - const list = buckets.get(key)! - // Aim for a roughly 16:9 block: cols ≈ sqrt(n * 1.8), then clamp so a - // 155-resource namespace becomes a tall readable column rather than a - // single 3500px-wide strip that pushes everything else off screen. - const cols = Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(list.length * 1.8)))) - const rows = Math.ceil(list.length / cols) - const w = Math.max(cols * CELL_W + PAD * 2, 300) - const h = HEADER_H + PAD + rows * CELL_H + PAD - placed.push({ key, list, cols, rows, w, h }) - } - - const out: Node[] = [] - let x = 0 - let y = 0 - let rowH = 0 - - for (const b of placed) { - // Wrap when this block would overflow the row width and the row has content. - if (x > 0 && x + b.w > ROW_W) { - x = 0 - y += rowH + BLOCK_GAP - rowH = 0 - } - - // Header for the block. - out.push({ - id: `__bucket__${b.key}`, - type: 'mapLabel', - position: { x, y }, - data: { - label: bucketLabel(b.key), - sub: bucketPlural(b.key, b.list.length), - kind: b.key.startsWith('ns:') ? 'ns' : 'type', - }, - width: b.w, - height: HEADER_H, - selectable: false, - draggable: false, - zIndex: 1, - } as Node) - - // Nodes in a grid beneath the header. - b.list.forEach((n, i) => { - const col = i % b.cols - const row = Math.floor(i / b.cols) - out.push({ - ...n, - position: { - x: x + PAD + col * CELL_W, - y: y + HEADER_H + PAD + row * CELL_H, - }, - }) - }) - - x += b.w + BLOCK_GAP - rowH = Math.max(rowH, b.h) - } - - return { nodes: out, edges } -}