refactor: switch mindmap layout from d3-hierarchy to dagre with word wrapping
This commit is contained in:
76
package-lock.json
generated
76
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.21",
|
"@ai-sdk/mistral": "^3.0.21",
|
||||||
"@ai-sdk/openai": "^3.0.37",
|
"@ai-sdk/openai": "^3.0.37",
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@floating-ui/dom": "^1.7.5",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@highlightjs/cdn-assets": "^11.11.1",
|
"@highlightjs/cdn-assets": "^11.11.1",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
@@ -34,8 +35,6 @@
|
|||||||
"ai": "^6.0.105",
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"d3-hierarchy": "^3.1.2",
|
|
||||||
"d3-shape": "^3.2.0",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
@@ -69,8 +68,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chokidar": "^1.7.5",
|
"@types/chokidar": "^1.7.5",
|
||||||
"@types/d3-hierarchy": "^3.1.7",
|
"@types/dagre": "^0.7.54",
|
||||||
"@types/d3-shape": "^3.1.8",
|
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -1112,6 +1110,21 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -5621,30 +5634,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-hierarchy": {
|
"node_modules/@types/dagre": {
|
||||||
"version": "3.1.7",
|
"version": "0.7.54",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
|
||||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -8170,36 +8166,6 @@
|
|||||||
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
|
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
|||||||
@@ -43,8 +43,7 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chokidar": "^1.7.5",
|
"@types/chokidar": "^1.7.5",
|
||||||
"@types/d3-hierarchy": "^3.1.7",
|
"@types/dagre": "^0.7.54",
|
||||||
"@types/d3-shape": "^3.1.8",
|
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -76,6 +75,7 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.21",
|
"@ai-sdk/mistral": "^3.0.21",
|
||||||
"@ai-sdk/openai": "^3.0.37",
|
"@ai-sdk/openai": "^3.0.37",
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@floating-ui/dom": "^1.7.5",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@highlightjs/cdn-assets": "^11.11.1",
|
"@highlightjs/cdn-assets": "^11.11.1",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
@@ -97,8 +97,6 @@
|
|||||||
"ai": "^6.0.105",
|
"ai": "^6.0.105",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"d3-cloud": "^1.2.8",
|
"d3-cloud": "^1.2.8",
|
||||||
"d3-hierarchy": "^3.1.2",
|
|
||||||
"d3-shape": "^3.2.0",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* A2UI Mindmap Component
|
* A2UI Mindmap Component
|
||||||
*
|
*
|
||||||
* Renders a centered mind map diagram using d3-hierarchy for tree layout
|
* Renders a centered mind map diagram using dagre for automatic graph layout
|
||||||
* and d3-shape for curved link paths, with inline SVG output.
|
* with variable-sized nodes, word wrapping, and multi-line labels.
|
||||||
*
|
*
|
||||||
* Layout strategy (classic mindmap):
|
* Layout strategy:
|
||||||
* - Root node sits at the horizontal center
|
* - Dagre arranges the tree left-to-right (rankdir: LR)
|
||||||
* - Children are split into two balanced groups: right half and left half
|
* - Each node has dynamically computed width/height based on wrapped text lines
|
||||||
* - Each half is laid out as a vertical tree using d3.tree()
|
* - Links follow dagre routed edge points, rendered as SVG cubic curves
|
||||||
* - Left-side trees are mirrored horizontally
|
|
||||||
* - Horizontal spacing accounts for actual label widths to prevent overlap
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { hierarchy, tree as d3tree, type HierarchyPointNode } from 'd3-hierarchy';
|
import dagre from '@dagrejs/dagre';
|
||||||
import { linkHorizontal } from 'd3-shape';
|
|
||||||
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||||
|
|
||||||
interface A2UIComponentProps {
|
interface A2UIComponentProps {
|
||||||
@@ -31,7 +28,7 @@ interface MindmapNode {
|
|||||||
children?: string[];
|
children?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeNode {
|
export interface TreeNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
@@ -51,6 +48,69 @@ function getNodeColor(depth: number): string {
|
|||||||
return NODE_COLORS[(depth - 1) % NODE_COLORS.length];
|
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. */
|
/** Build a nested tree from a flat node array. First node is the root. */
|
||||||
export function buildTree(nodes: MindmapNode[]): TreeNode | null {
|
export function buildTree(nodes: MindmapNode[]): TreeNode | null {
|
||||||
if (nodes.length === 0) return null;
|
if (nodes.length === 0) return null;
|
||||||
@@ -76,293 +136,110 @@ export function buildTree(nodes: MindmapNode[]): TreeNode | null {
|
|||||||
return toTreeNode(nodes[0].id);
|
return toTreeNode(nodes[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Estimate text width in pixels (rough: 7px per char at 12px font). */
|
/* ── Layout (dagre) ─────────────────────────────────────────── */
|
||||||
function estimateTextWidth(text: string): number {
|
|
||||||
return Math.max(text.length * 7, 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute node box width from label. */
|
export interface PositionedNode {
|
||||||
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 {
|
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
height: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
lines: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutResult {
|
export interface LayoutResult {
|
||||||
svgWidth: number;
|
svgWidth: number;
|
||||||
svgHeight: number;
|
svgHeight: number;
|
||||||
nodes: PositionedNode[];
|
nodes: PositionedNode[];
|
||||||
links: Array<{
|
links: Array<{ points: Array<{ x: number; y: number }> }>;
|
||||||
source: { x: number; y: number };
|
|
||||||
target: { x: number; y: number };
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Compute dagre layout for the full tree. */
|
||||||
* Compute the maximum node width at each depth level of a d3 hierarchy tree.
|
export function computeLayout(root: TreeNode): LayoutResult {
|
||||||
* Used to set proper horizontal spacing per level so nodes don't overlap.
|
const g = new dagre.graphlib.Graph();
|
||||||
*/
|
g.setGraph({
|
||||||
function maxWidthPerDepth(root: HierarchyPointNode<TreeNode>): Map<number, number> {
|
rankdir: 'LR',
|
||||||
const widths = new Map<number, number>();
|
nodesep: NODE_SEP,
|
||||||
for (const n of root.descendants()) {
|
ranksep: RANK_SEP,
|
||||||
const w = nodeWidth(n.data.label);
|
marginx: 16,
|
||||||
const current = widths.get(n.depth) ?? 0;
|
marginy: 16,
|
||||||
if (w > current) widths.set(n.depth, w);
|
});
|
||||||
}
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
return widths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Recursively add nodes and edges
|
||||||
* Lay out one side (right or left) of the mindmap.
|
function addNode(node: TreeNode, depth: number): void {
|
||||||
* Creates a virtual root to act as connection point, then runs d3.tree().
|
const size = nodeSize(node.label);
|
||||||
* Returns positioned nodes (excluding virtual root) and links.
|
g.setNode(node.id, {
|
||||||
*
|
label: node.label,
|
||||||
* @param side 'right' = nodes extend rightward (positive x), 'left' = mirrored
|
width: size.width,
|
||||||
* @param children The children that go on this side
|
height: size.height,
|
||||||
* @param rootId ID of the real root node (for link origins)
|
depth,
|
||||||
* @param depthOffset Depth offset (1, since these are children of root)
|
lines: size.lines,
|
||||||
*/
|
|
||||||
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<TreeNode>().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<number, number>();
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
for (const child of node.children) {
|
||||||
|
addNode(child, depth + 1);
|
||||||
// Build links
|
g.setEdge(node.id, child.id);
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, links };
|
addNode(root, 0);
|
||||||
}
|
dagre.layout(g);
|
||||||
|
|
||||||
export function computeLayout(root: TreeNode): LayoutResult {
|
const nodes: PositionedNode[] = g.nodes().map((id) => {
|
||||||
const rootW = nodeWidth(root.label);
|
const n = g.node(id) as any;
|
||||||
|
|
||||||
// Single node — just center it
|
|
||||||
if (root.children.length === 0) {
|
|
||||||
const pad = 16;
|
|
||||||
return {
|
return {
|
||||||
svgWidth: rootW + pad * 2,
|
id,
|
||||||
svgHeight: NODE_HEIGHT + pad * 2,
|
label: n.label,
|
||||||
nodes: [{
|
x: n.x,
|
||||||
id: root.id,
|
y: n.y,
|
||||||
label: root.label,
|
width: n.width,
|
||||||
x: rootW / 2 + pad,
|
height: n.height,
|
||||||
y: NODE_HEIGHT / 2 + pad,
|
depth: n.depth ?? 0,
|
||||||
width: rootW,
|
lines: n.lines ?? [n.label],
|
||||||
depth: 0,
|
|
||||||
}],
|
|
||||||
links: [],
|
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
// Split children into right and left sides for balanced layout
|
const links = g.edges().map((e) => {
|
||||||
const { right, left } = splitChildren(root.children);
|
const edge = g.edge(e);
|
||||||
|
return { points: edge.points };
|
||||||
// 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 graphLabel = g.graph();
|
||||||
return {
|
return {
|
||||||
svgWidth: maxX - minX,
|
svgWidth: (graphLabel.width ?? 400) as number,
|
||||||
svgHeight: maxY - minY,
|
svgHeight: (graphLabel.height ?? 200) as number,
|
||||||
nodes: allNodes.map((n) => ({ ...n, x: n.x + offsetX, y: n.y + offsetY })),
|
nodes,
|
||||||
links: allLinks.map((l) => ({
|
links,
|
||||||
source: { x: l.source.x + offsetX, y: l.source.y + offsetY },
|
|
||||||
target: { x: l.target.x + offsetX, y: l.target.y + offsetY },
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkGenerator = linkHorizontal<
|
/* ── SVG path from dagre edge points ────────────────────────── */
|
||||||
{ source: { x: number; y: number }; target: { x: number; y: number } },
|
|
||||||
{ x: number; y: number }
|
function edgePath(points: Array<{ x: number; y: number }>): string {
|
||||||
>()
|
if (points.length === 0) return '';
|
||||||
.x((d) => d.x)
|
if (points.length === 1) return `M${points[0].x},${points[0].y}`;
|
||||||
.y((d) => d.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<A2UIComponentProps> = ({ component }) => {
|
export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
const title = component.properties.title as string | undefined;
|
const title = component.properties.title as string | undefined;
|
||||||
@@ -390,7 +267,7 @@ export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
|
|||||||
<path
|
<path
|
||||||
key={`${component.id}-link-${i}`}
|
key={`${component.id}-link-${i}`}
|
||||||
className="assistant-panel-mindmap-link"
|
className="assistant-panel-mindmap-link"
|
||||||
d={linkGenerator(link) ?? ''}
|
d={edgePath(link.points)}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -402,9 +279,9 @@ export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
|
|||||||
<rect
|
<rect
|
||||||
className="assistant-panel-mindmap-node"
|
className="assistant-panel-mindmap-node"
|
||||||
x={node.x - node.width / 2}
|
x={node.x - node.width / 2}
|
||||||
y={node.y - NODE_HEIGHT / 2}
|
y={node.y - node.height / 2}
|
||||||
width={node.width}
|
width={node.width}
|
||||||
height={NODE_HEIGHT}
|
height={node.height}
|
||||||
rx={NODE_RADIUS}
|
rx={NODE_RADIUS}
|
||||||
ry={NODE_RADIUS}
|
ry={NODE_RADIUS}
|
||||||
fill={color}
|
fill={color}
|
||||||
@@ -420,8 +297,23 @@ export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
|
|||||||
y={node.y}
|
y={node.y}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
|
fontSize={FONT_SIZE}
|
||||||
>
|
>
|
||||||
{node.label}
|
{node.lines.length === 1 ? (
|
||||||
|
node.lines[0]
|
||||||
|
) : (
|
||||||
|
node.lines.map((line, li) => (
|
||||||
|
<tspan
|
||||||
|
key={li}
|
||||||
|
x={node.x}
|
||||||
|
dy={li === 0
|
||||||
|
? -((node.lines.length - 1) * LINE_HEIGHT) / 2
|
||||||
|
: LINE_HEIGHT}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</tspan>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -455,7 +455,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-panel-mindmap-label {
|
.assistant-panel-mindmap-label {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
fill: var(--vscode-foreground);
|
fill: var(--vscode-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
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';
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
function makeMindmapComponent(
|
function makeMindmapComponent(
|
||||||
@@ -29,6 +37,48 @@ function makeMindmapComponent(
|
|||||||
const noopAction = vi.fn<(action: A2UIClientAction) => void>();
|
const noopAction = vi.fn<(action: A2UIClientAction) => void>();
|
||||||
|
|
||||||
describe('A2UIMindmap', () => {
|
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', () => {
|
describe('buildTree', () => {
|
||||||
it('builds nested tree from flat node array', () => {
|
it('builds nested tree from flat node array', () => {
|
||||||
const tree = buildTree([
|
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<typeof buildTree> = { 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', () => {
|
describe('computeLayout', () => {
|
||||||
it('computes layout for a simple tree', () => {
|
it('computes layout for a simple tree', () => {
|
||||||
const tree = buildTree([
|
const tree = buildTree([
|
||||||
@@ -126,7 +137,7 @@ describe('A2UIMindmap', () => {
|
|||||||
expect(layout.svgHeight).toBeGreaterThan(0);
|
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([
|
const tree = buildTree([
|
||||||
{ id: 'root', label: 'Root', children: ['a', 'b'] },
|
{ id: 'root', label: 'Root', children: ['a', 'b'] },
|
||||||
{ id: 'a', label: 'A' },
|
{ id: 'a', label: 'A' },
|
||||||
@@ -138,11 +149,9 @@ describe('A2UIMindmap', () => {
|
|||||||
const nodeA = layout.nodes.find((n) => n.id === 'a')!;
|
const nodeA = layout.nodes.find((n) => n.id === 'a')!;
|
||||||
const nodeB = layout.nodes.find((n) => n.id === 'b')!;
|
const nodeB = layout.nodes.find((n) => n.id === 'b')!;
|
||||||
|
|
||||||
// With 2 children, one should be to the right of root, one to the left
|
// LR layout: children should be to the right of root
|
||||||
const rightChild = nodeA.x > root.x ? nodeA : nodeB;
|
expect(nodeA.x).toBeGreaterThan(root.x);
|
||||||
const leftChild = nodeA.x > root.x ? nodeB : nodeA;
|
expect(nodeB.x).toBeGreaterThan(root.x);
|
||||||
expect(rightChild.x).toBeGreaterThan(root.x);
|
|
||||||
expect(leftChild.x).toBeLessThan(root.x);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assigns correct depth values', () => {
|
it('assigns correct depth values', () => {
|
||||||
@@ -167,6 +176,31 @@ describe('A2UIMindmap', () => {
|
|||||||
expect(layout.svgWidth).toBeGreaterThan(0);
|
expect(layout.svgWidth).toBeGreaterThan(0);
|
||||||
expect(layout.svgHeight).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', () => {
|
describe('rendering', () => {
|
||||||
@@ -185,14 +219,14 @@ describe('A2UIMindmap', () => {
|
|||||||
const svg = container.querySelector('.assistant-panel-mindmap-svg');
|
const svg = container.querySelector('.assistant-panel-mindmap-svg');
|
||||||
expect(svg).not.toBeNull();
|
expect(svg).not.toBeNull();
|
||||||
|
|
||||||
// 4 nodes → 4 rect + 4 text
|
// 4 nodes -> 4 rect
|
||||||
const rects = container.querySelectorAll('.assistant-panel-mindmap-node');
|
const rects = container.querySelectorAll('.assistant-panel-mindmap-node');
|
||||||
expect(rects).toHaveLength(4);
|
expect(rects).toHaveLength(4);
|
||||||
|
|
||||||
const labels = container.querySelectorAll('.assistant-panel-mindmap-label');
|
const labels = container.querySelectorAll('.assistant-panel-mindmap-label');
|
||||||
expect(labels).toHaveLength(4);
|
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');
|
const links = container.querySelectorAll('.assistant-panel-mindmap-link');
|
||||||
expect(links).toHaveLength(3);
|
expect(links).toHaveLength(3);
|
||||||
});
|
});
|
||||||
@@ -259,5 +293,17 @@ describe('A2UIMindmap', () => {
|
|||||||
expect(childRect.getAttribute('stroke-width')).toBe('1');
|
expect(childRect.getAttribute('stroke-width')).toBe('1');
|
||||||
expect(childRect.getAttribute('fill-opacity')).toBe('0.15');
|
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(
|
||||||
|
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tspans = container.querySelectorAll('tspan');
|
||||||
|
expect(tspans.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user