From 6a0dbb65118a56b65c996a7e3c480d351bcaacd9 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 22:29:49 +0100 Subject: [PATCH] refactor: switch mindmap layout from d3-hierarchy to dagre with word wrapping --- package-lock.json | 76 +-- package.json | 6 +- src/renderer/a2ui/components/A2UIMindmap.tsx | 440 +++++++----------- .../AssistantSidebar/AssistantSidebar.css | 2 +- tests/renderer/a2ui/A2UIMindmap.test.tsx | 142 ++++-- 5 files changed, 284 insertions(+), 382 deletions(-) diff --git a/package-lock.json b/package-lock.json index 952eb47..38c84df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@ai-sdk/mistral": "^3.0.21", "@ai-sdk/openai": "^3.0.37", "@braintree/sanitize-url": "^7.1.2", + "@dagrejs/dagre": "^2.0.4", "@floating-ui/dom": "^1.7.5", "@highlightjs/cdn-assets": "^11.11.1", "@libsql/client": "^0.17.0", @@ -34,8 +35,6 @@ "ai": "^6.0.105", "chokidar": "^5.0.0", "d3-cloud": "^1.2.8", - "d3-hierarchy": "^3.1.2", - "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", @@ -69,8 +68,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/chokidar": "^1.7.5", - "@types/d3-hierarchy": "^3.1.7", - "@types/d3-shape": "^3.1.8", + "@types/dagre": "^0.7.54", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1112,6 +1110,21 @@ "node": ">=20.19.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -5621,30 +5634,13 @@ "@types/node": "*" } }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", "dev": true, "license": "MIT" }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -8170,36 +8166,6 @@ "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", "license": "BSD-3-Clause" }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", diff --git a/package.json b/package.json index b9f9557..c25bb19 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/chokidar": "^1.7.5", - "@types/d3-hierarchy": "^3.1.7", - "@types/d3-shape": "^3.1.8", + "@types/dagre": "^0.7.54", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -76,6 +75,7 @@ "@ai-sdk/mistral": "^3.0.21", "@ai-sdk/openai": "^3.0.37", "@braintree/sanitize-url": "^7.1.2", + "@dagrejs/dagre": "^2.0.4", "@floating-ui/dom": "^1.7.5", "@highlightjs/cdn-assets": "^11.11.1", "@libsql/client": "^0.17.0", @@ -97,8 +97,6 @@ "ai": "^6.0.105", "chokidar": "^5.0.0", "d3-cloud": "^1.2.8", - "d3-hierarchy": "^3.1.2", - "d3-shape": "^3.2.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", diff --git a/src/renderer/a2ui/components/A2UIMindmap.tsx b/src/renderer/a2ui/components/A2UIMindmap.tsx index c2e4c77..8d896b9 100644 --- a/src/renderer/a2ui/components/A2UIMindmap.tsx +++ b/src/renderer/a2ui/components/A2UIMindmap.tsx @@ -1,20 +1,17 @@ /** * A2UI Mindmap Component * - * Renders a centered mind map diagram using d3-hierarchy for tree layout - * and d3-shape for curved link paths, with inline SVG output. + * Renders a centered mind map diagram using dagre for automatic graph layout + * with variable-sized nodes, word wrapping, and multi-line labels. * - * Layout strategy (classic mindmap): - * - Root node sits at the horizontal center - * - Children are split into two balanced groups: right half and left half - * - Each half is laid out as a vertical tree using d3.tree() - * - Left-side trees are mirrored horizontally - * - Horizontal spacing accounts for actual label widths to prevent overlap + * Layout strategy: + * - Dagre arranges the tree left-to-right (rankdir: LR) + * - Each node has dynamically computed width/height based on wrapped text lines + * - Links follow dagre routed edge points, rendered as SVG cubic curves */ import React, { useMemo } from 'react'; -import { hierarchy, tree as d3tree, type HierarchyPointNode } from 'd3-hierarchy'; -import { linkHorizontal } from 'd3-shape'; +import dagre from '@dagrejs/dagre'; import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types'; interface A2UIComponentProps { @@ -31,7 +28,7 @@ interface MindmapNode { children?: string[]; } -interface TreeNode { +export interface TreeNode { id: string; label: string; children: TreeNode[]; @@ -51,6 +48,69 @@ function getNodeColor(depth: number): string { return NODE_COLORS[(depth - 1) % NODE_COLORS.length]; } +/* ── Constants ──────────────────────────────────────────────── */ + +export const FONT_SIZE = 13; +export const CHAR_WIDTH = 7.8; +export const LINE_HEIGHT = 18; +const NODE_PADDING_X = 14; +const NODE_PADDING_Y = 8; +const NODE_RADIUS = 6; +export const MAX_NODE_WIDTH = 180; +const MIN_NODE_WIDTH = 50; +const NODE_SEP = 18; +const RANK_SEP = 40; + +/* ── Text wrapping ──────────────────────────────────────────── */ + +/** Break a label into wrapped lines that fit within maxWidth (in px). */ +export function wrapText(text: string, maxWidth: number): string[] { + const maxChars = Math.max(Math.floor(maxWidth / CHAR_WIDTH), 4); + const words = text.split(/\s+/); + if (words.length === 0) return [text]; + + const lines: string[] = []; + let currentLine = words[0]; + + for (let i = 1; i < words.length; i++) { + const candidate = currentLine + ' ' + words[i]; + if (candidate.length <= maxChars) { + currentLine = candidate; + } else { + lines.push(currentLine); + currentLine = words[i]; + } + } + lines.push(currentLine); + return lines; +} + +/** Estimate text width in px for a single line. */ +function estimateTextWidth(text: string): number { + return Math.max(text.length * CHAR_WIDTH, MIN_NODE_WIDTH); +} + +/** Compute node box dimensions from wrapped lines. */ +export function nodeSize(label: string): { width: number; height: number; lines: string[] } { + const innerMax = MAX_NODE_WIDTH - NODE_PADDING_X * 2; + const singleLineWidth = estimateTextWidth(label); + + // If single line fits, use it + if (singleLineWidth <= innerMax) { + const width = Math.max(singleLineWidth + NODE_PADDING_X * 2, MIN_NODE_WIDTH); + return { width, height: LINE_HEIGHT + NODE_PADDING_Y * 2, lines: [label] }; + } + + // Wrap text + const lines = wrapText(label, innerMax); + const longestLine = Math.max(...lines.map((l) => estimateTextWidth(l))); + const width = Math.min(Math.max(longestLine + NODE_PADDING_X * 2, MIN_NODE_WIDTH), MAX_NODE_WIDTH); + const height = lines.length * LINE_HEIGHT + NODE_PADDING_Y * 2; + return { width, height, lines }; +} + +/* ── Tree building ──────────────────────────────────────────── */ + /** Build a nested tree from a flat node array. First node is the root. */ export function buildTree(nodes: MindmapNode[]): TreeNode | null { if (nodes.length === 0) return null; @@ -76,293 +136,110 @@ export function buildTree(nodes: MindmapNode[]): TreeNode | null { return toTreeNode(nodes[0].id); } -/** Estimate text width in pixels (rough: 7px per char at 12px font). */ -function estimateTextWidth(text: string): number { - return Math.max(text.length * 7, 40); -} +/* ── Layout (dagre) ─────────────────────────────────────────── */ -/** Compute node box width from label. */ -function nodeWidth(label: string): number { - return estimateTextWidth(label) + NODE_PADDING_X * 2; -} - -const NODE_HEIGHT = 28; -const NODE_PADDING_X = 12; -const NODE_RADIUS = 6; -const VERTICAL_GAP = 10; -const HORIZONTAL_GAP = 24; - -/** Count total descendant leaves for balancing. */ -function leafCount(node: TreeNode): number { - if (node.children.length === 0) return 1; - let count = 0; - for (const child of node.children) { - count += leafCount(child); - } - return count; -} - -/** - * Split root children into right and left groups, balanced by subtree size. - * Uses a greedy approach: assign children to the smaller side. - */ -export function splitChildren(children: TreeNode[]): { right: TreeNode[]; left: TreeNode[] } { - if (children.length === 0) return { right: [], left: [] }; - if (children.length === 1) return { right: children, left: [] }; - - // Compute weights (leaf counts) for each child subtree - const weighted = children.map((child) => ({ child, weight: leafCount(child) })); - - const right: TreeNode[] = []; - const left: TreeNode[] = []; - let rightWeight = 0; - let leftWeight = 0; - - for (const { child, weight } of weighted) { - if (rightWeight <= leftWeight) { - right.push(child); - rightWeight += weight; - } else { - left.push(child); - leftWeight += weight; - } - } - - return { right, left }; -} - -interface PositionedNode { +export interface PositionedNode { id: string; label: string; x: number; y: number; width: number; + height: number; depth: number; + lines: string[]; } -interface LayoutResult { +export interface LayoutResult { svgWidth: number; svgHeight: number; nodes: PositionedNode[]; - links: Array<{ - source: { x: number; y: number }; - target: { x: number; y: number }; - }>; + links: Array<{ points: Array<{ x: number; y: number }> }>; } -/** - * Compute the maximum node width at each depth level of a d3 hierarchy tree. - * Used to set proper horizontal spacing per level so nodes don't overlap. - */ -function maxWidthPerDepth(root: HierarchyPointNode): Map { - const widths = new Map(); - for (const n of root.descendants()) { - const w = nodeWidth(n.data.label); - const current = widths.get(n.depth) ?? 0; - if (w > current) widths.set(n.depth, w); - } - return widths; -} +/** Compute dagre layout for the full tree. */ +export function computeLayout(root: TreeNode): LayoutResult { + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: 'LR', + nodesep: NODE_SEP, + ranksep: RANK_SEP, + marginx: 16, + marginy: 16, + }); + g.setDefaultEdgeLabel(() => ({})); -/** - * Lay out one side (right or left) of the mindmap. - * Creates a virtual root to act as connection point, then runs d3.tree(). - * Returns positioned nodes (excluding virtual root) and links. - * - * @param side 'right' = nodes extend rightward (positive x), 'left' = mirrored - * @param children The children that go on this side - * @param rootId ID of the real root node (for link origins) - * @param depthOffset Depth offset (1, since these are children of root) - */ -function layoutSide( - side: 'right' | 'left', - children: TreeNode[], - rootId: string, - rootX: number, - rootY: number, -): { nodes: PositionedNode[]; links: LayoutResult['links'] } { - if (children.length === 0) return { nodes: [], links: [] }; - - // Create a virtual root that holds the side's children - const virtualRoot: TreeNode = { id: `__virtual_${side}`, label: '', children }; - const h = hierarchy(virtualRoot, (d) => (d.children.length > 0 ? d.children : null)); - - const nodeVSpacing = NODE_HEIGHT + VERTICAL_GAP; - const treeLayout = d3tree().nodeSize([nodeVSpacing, 1]); - const treeRoot = treeLayout(h); - - // Compute max widths per depth to set proper horizontal offsets - const depthWidths = maxWidthPerDepth(treeRoot); - - // Compute cumulative x offset for each depth level - // depth 0 = virtual root (at rootX), depth 1 = first children, etc. - const depthX = new Map(); - depthX.set(0, 0); - let cumulativeX = 0; - const maxDepth = Math.max(...depthWidths.keys()); - for (let d = 1; d <= maxDepth; d++) { - const parentWidth = depthWidths.get(d - 1) ?? 60; - const currentWidth = depthWidths.get(d) ?? 60; - cumulativeX += parentWidth / 2 + HORIZONTAL_GAP + currentWidth / 2; - depthX.set(d, cumulativeX); - } - - const mirror = side === 'left' ? -1 : 1; - const nodes: PositionedNode[] = []; - const links: LayoutResult['links'] = []; - - for (const n of treeRoot.descendants()) { - // Skip virtual root - if (n.depth === 0) continue; - - const w = nodeWidth(n.data.label); - const xOffset = depthX.get(n.depth) ?? 0; - - nodes.push({ - id: n.data.id, - label: n.data.label, - x: rootX + mirror * xOffset, - // d3.tree: x = vertical position, y = depth (but we computed our own x) - y: rootY + n.x, - width: w, - depth: n.depth, // depth relative to virtual root; real depth = n.depth + // Recursively add nodes and edges + function addNode(node: TreeNode, depth: number): void { + const size = nodeSize(node.label); + g.setNode(node.id, { + label: node.label, + width: size.width, + height: size.height, + depth, + lines: size.lines, }); - } - - // Build links - for (const link of treeRoot.links()) { - if (link.source.depth === 0) { - // Link from real root to first-level children - const targetNode = nodes.find((n) => n.id === link.target.data.id); - if (targetNode) { - const rootW = nodeWidth(''); // Will be overridden by caller - links.push({ - source: { x: rootX, y: rootY }, - target: { - x: targetNode.x + (side === 'left' ? targetNode.width / 2 : -targetNode.width / 2), - y: targetNode.y, - }, - }); - } - } else { - const sourceNode = nodes.find((n) => n.id === link.source.data.id); - const targetNode = nodes.find((n) => n.id === link.target.data.id); - if (sourceNode && targetNode) { - links.push({ - source: { - x: sourceNode.x + (side === 'left' ? -sourceNode.width / 2 : sourceNode.width / 2), - y: sourceNode.y, - }, - target: { - x: targetNode.x + (side === 'left' ? targetNode.width / 2 : -targetNode.width / 2), - y: targetNode.y, - }, - }); - } + for (const child of node.children) { + addNode(child, depth + 1); + g.setEdge(node.id, child.id); } } - return { nodes, links }; -} + addNode(root, 0); + dagre.layout(g); -export function computeLayout(root: TreeNode): LayoutResult { - const rootW = nodeWidth(root.label); - - // Single node — just center it - if (root.children.length === 0) { - const pad = 16; + const nodes: PositionedNode[] = g.nodes().map((id) => { + const n = g.node(id) as any; return { - svgWidth: rootW + pad * 2, - svgHeight: NODE_HEIGHT + pad * 2, - nodes: [{ - id: root.id, - label: root.label, - x: rootW / 2 + pad, - y: NODE_HEIGHT / 2 + pad, - width: rootW, - depth: 0, - }], - links: [], + id, + label: n.label, + x: n.x, + y: n.y, + width: n.width, + height: n.height, + depth: n.depth ?? 0, + lines: n.lines ?? [n.label], }; - } + }); - // Split children into right and left sides for balanced layout - const { right, left } = splitChildren(root.children); - - // Root starts at origin (0, 0) — we'll translate after - const rootX = 0; - const rootY = 0; - - const rightResult = layoutSide('right', right, root.id, rootX, rootY); - const leftResult = layoutSide('left', left, root.id, rootX, rootY); - - // Merge all nodes - const allNodes: PositionedNode[] = [ - { id: root.id, label: root.label, x: rootX, y: rootY, width: rootW, depth: 0 }, - ...rightResult.nodes.map((n) => ({ ...n, depth: n.depth })), - ...leftResult.nodes.map((n) => ({ ...n, depth: n.depth })), - ]; - - // Fix root→child links to use actual root width - const fixRootLinks = (links: LayoutResult['links'], side: 'right' | 'left') => - links.map((l) => { - if (l.source.x === rootX && l.source.y === rootY) { - return { - source: { - x: rootX + (side === 'right' ? rootW / 2 : -rootW / 2), - y: rootY, - }, - target: l.target, - }; - } - return l; - }); - - const allLinks = [ - ...fixRootLinks(rightResult.links, 'right'), - ...fixRootLinks(leftResult.links, 'left'), - ]; - - // Compute bounding box - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; - for (const n of allNodes) { - const l = n.x - n.width / 2; - const r = n.x + n.width / 2; - const t = n.y - NODE_HEIGHT / 2; - const b = n.y + NODE_HEIGHT / 2; - if (l < minX) minX = l; - if (r > maxX) maxX = r; - if (t < minY) minY = t; - if (b > maxY) maxY = b; - } - - const pad = 16; - minX -= pad; - minY -= pad; - maxX += pad; - maxY += pad; - - const offsetX = -minX; - const offsetY = -minY; + const links = g.edges().map((e) => { + const edge = g.edge(e); + return { points: edge.points }; + }); + const graphLabel = g.graph(); return { - svgWidth: maxX - minX, - svgHeight: maxY - minY, - nodes: allNodes.map((n) => ({ ...n, x: n.x + offsetX, y: n.y + offsetY })), - links: allLinks.map((l) => ({ - source: { x: l.source.x + offsetX, y: l.source.y + offsetY }, - target: { x: l.target.x + offsetX, y: l.target.y + offsetY }, - })), + svgWidth: (graphLabel.width ?? 400) as number, + svgHeight: (graphLabel.height ?? 200) as number, + nodes, + links, }; } -const linkGenerator = linkHorizontal< - { source: { x: number; y: number }; target: { x: number; y: number } }, - { x: number; y: number } ->() - .x((d) => d.x) - .y((d) => d.y); +/* ── SVG path from dagre edge points ────────────────────────── */ + +function edgePath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return ''; + if (points.length === 1) return `M${points[0].x},${points[0].y}`; + + let d = `M${points[0].x},${points[0].y}`; + + if (points.length === 2) { + d += ` L${points[1].x},${points[1].y}`; + return d; + } + + // Smooth cubic curves through edge points + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + const cpx = (prev.x + curr.x) / 2; + d += ` C${cpx},${prev.y} ${cpx},${curr.y} ${curr.x},${curr.y}`; + } + + return d; +} + +/* ── Component ──────────────────────────────────────────────── */ export const A2UIMindmap: React.FC = ({ component }) => { const title = component.properties.title as string | undefined; @@ -390,7 +267,7 @@ export const A2UIMindmap: React.FC = ({ component }) => { ))} @@ -402,9 +279,9 @@ export const A2UIMindmap: React.FC = ({ component }) => { = ({ component }) => { y={node.y} textAnchor="middle" dominantBaseline="central" + fontSize={FONT_SIZE} > - {node.label} + {node.lines.length === 1 ? ( + node.lines[0] + ) : ( + node.lines.map((line, li) => ( + + {line} + + )) + )} ); diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.css b/src/renderer/components/AssistantSidebar/AssistantSidebar.css index 359bf21..30fb32d 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.css +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.css @@ -455,7 +455,7 @@ } .assistant-panel-mindmap-label { - font-size: 12px; + font-size: 13px; fill: var(--vscode-foreground); pointer-events: none; } diff --git a/tests/renderer/a2ui/A2UIMindmap.test.tsx b/tests/renderer/a2ui/A2UIMindmap.test.tsx index 0f43b12..831ecb6 100644 --- a/tests/renderer/a2ui/A2UIMindmap.test.tsx +++ b/tests/renderer/a2ui/A2UIMindmap.test.tsx @@ -1,7 +1,15 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { A2UIMindmap, buildTree, computeLayout, splitChildren } from '../../../src/renderer/a2ui/components/A2UIMindmap'; +import { + A2UIMindmap, + buildTree, + computeLayout, + wrapText, + nodeSize, + MAX_NODE_WIDTH, + LINE_HEIGHT, +} from '../../../src/renderer/a2ui/components/A2UIMindmap'; import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types'; function makeMindmapComponent( @@ -29,6 +37,48 @@ function makeMindmapComponent( const noopAction = vi.fn<(action: A2UIClientAction) => void>(); describe('A2UIMindmap', () => { + describe('wrapText', () => { + it('returns single line for short text', () => { + const lines = wrapText('Hello', 200); + expect(lines).toEqual(['Hello']); + }); + + it('wraps long text into multiple lines', () => { + const long = 'This is a fairly long label that should wrap into multiple lines'; + const innerMax = MAX_NODE_WIDTH - 28; // NODE_PADDING_X * 2 + const lines = wrapText(long, innerMax); + expect(lines.length).toBeGreaterThan(1); + expect(lines.join(' ')).toBe(long); + }); + + it('handles single word that exceeds max width', () => { + const lines = wrapText('Superlongwordwithoutspaces', 50); + // Single word stays as one line + expect(lines).toEqual(['Superlongwordwithoutspaces']); + }); + + it('preserves all words across lines', () => { + const text = 'one two three four five six'; + const lines = wrapText(text, 80); + expect(lines.join(' ')).toBe(text); + }); + }); + + describe('nodeSize', () => { + it('returns single-line size for short label', () => { + const result = nodeSize('Hi'); + expect(result.lines).toEqual(['Hi']); + expect(result.height).toBe(LINE_HEIGHT + 16); // + padding*2 + }); + + it('wraps long label into multiple lines', () => { + const result = nodeSize('A very long label that definitely exceeds the max width'); + expect(result.lines.length).toBeGreaterThan(1); + expect(result.width).toBeLessThanOrEqual(MAX_NODE_WIDTH); + expect(result.height).toBeGreaterThan(LINE_HEIGHT + 16); + }); + }); + describe('buildTree', () => { it('builds nested tree from flat node array', () => { const tree = buildTree([ @@ -71,45 +121,6 @@ describe('A2UIMindmap', () => { }); }); - describe('splitChildren', () => { - it('returns empty arrays for no children', () => { - const { right, left } = splitChildren([]); - expect(right).toHaveLength(0); - expect(left).toHaveLength(0); - }); - - it('puts single child on the right', () => { - const child: ReturnType = { id: 'a', label: 'A', children: [] }; - const { right, left } = splitChildren([child!]); - expect(right).toHaveLength(1); - expect(left).toHaveLength(0); - }); - - it('splits two children across right and left', () => { - const a = { id: 'a', label: 'A', children: [] }; - const b = { id: 'b', label: 'B', children: [] }; - const { right, left } = splitChildren([a, b]); - expect(right).toHaveLength(1); - expect(left).toHaveLength(1); - }); - - it('balances by subtree size', () => { - // 'a' has 3 leaves, 'b' has 1 leaf, 'c' has 1 leaf - const a = { id: 'a', label: 'A', children: [ - { id: 'a1', label: 'A1', children: [] }, - { id: 'a2', label: 'A2', children: [] }, - { id: 'a3', label: 'A3', children: [] }, - ] }; - const b = { id: 'b', label: 'B', children: [] }; - const c = { id: 'c', label: 'C', children: [] }; - const { right, left } = splitChildren([a, b, c]); - // 'a' (weight 3) goes right, then 'b' (weight 1) goes left (0 < 3), - // 'c' (weight 1) also goes left (1 < 3) - expect(right.map((n) => n.id)).toContain('a'); - expect(left.length).toBeGreaterThanOrEqual(1); - }); - }); - describe('computeLayout', () => { it('computes layout for a simple tree', () => { const tree = buildTree([ @@ -126,7 +137,7 @@ describe('A2UIMindmap', () => { expect(layout.svgHeight).toBeGreaterThan(0); }); - it('places root between left and right children', () => { + it('places children to the right of root (LR layout)', () => { const tree = buildTree([ { id: 'root', label: 'Root', children: ['a', 'b'] }, { id: 'a', label: 'A' }, @@ -138,11 +149,9 @@ describe('A2UIMindmap', () => { const nodeA = layout.nodes.find((n) => n.id === 'a')!; const nodeB = layout.nodes.find((n) => n.id === 'b')!; - // With 2 children, one should be to the right of root, one to the left - const rightChild = nodeA.x > root.x ? nodeA : nodeB; - const leftChild = nodeA.x > root.x ? nodeB : nodeA; - expect(rightChild.x).toBeGreaterThan(root.x); - expect(leftChild.x).toBeLessThan(root.x); + // LR layout: children should be to the right of root + expect(nodeA.x).toBeGreaterThan(root.x); + expect(nodeB.x).toBeGreaterThan(root.x); }); it('assigns correct depth values', () => { @@ -167,6 +176,31 @@ describe('A2UIMindmap', () => { expect(layout.svgWidth).toBeGreaterThan(0); expect(layout.svgHeight).toBeGreaterThan(0); }); + + it('provides wrapped lines for each node', () => { + const tree = buildTree([ + { id: 'root', label: 'A long root label for wrapping test purposes here', children: ['a'] }, + { id: 'a', label: 'Short' }, + ])!; + + const layout = computeLayout(tree); + const root = layout.nodes.find((n) => n.id === 'root')!; + expect(root.lines.length).toBeGreaterThanOrEqual(1); + expect(root.lines.join(' ')).toBe('A long root label for wrapping test purposes here'); + }); + + it('links have points arrays', () => { + const tree = buildTree([ + { id: 'root', label: 'Root', children: ['a'] }, + { id: 'a', label: 'A' }, + ])!; + + const layout = computeLayout(tree); + expect(layout.links).toHaveLength(1); + expect(layout.links[0].points.length).toBeGreaterThanOrEqual(2); + expect(layout.links[0].points[0]).toHaveProperty('x'); + expect(layout.links[0].points[0]).toHaveProperty('y'); + }); }); describe('rendering', () => { @@ -185,14 +219,14 @@ describe('A2UIMindmap', () => { const svg = container.querySelector('.assistant-panel-mindmap-svg'); expect(svg).not.toBeNull(); - // 4 nodes → 4 rect + 4 text + // 4 nodes -> 4 rect const rects = container.querySelectorAll('.assistant-panel-mindmap-node'); expect(rects).toHaveLength(4); const labels = container.querySelectorAll('.assistant-panel-mindmap-label'); expect(labels).toHaveLength(4); - // 3 links (root→a, root→b, a→a1) + // 3 links (root->a, root->b, a->a1) const links = container.querySelectorAll('.assistant-panel-mindmap-link'); expect(links).toHaveLength(3); }); @@ -259,5 +293,17 @@ describe('A2UIMindmap', () => { expect(childRect.getAttribute('stroke-width')).toBe('1'); expect(childRect.getAttribute('fill-opacity')).toBe('0.15'); }); + + it('renders multi-line labels with tspan elements', () => { + const comp = makeMindmapComponent({}, [ + { id: 'root', label: 'This is a very long label that should definitely wrap into multiple lines in the mindmap' }, + ]); + const { container } = render( + , + ); + + const tspans = container.querySelectorAll('tspan'); + expect(tspans.length).toBeGreaterThan(1); + }); }); });