fix: center and balance mindmap layout with left/right splitting
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* A2UI Mindmap Component
|
||||
*
|
||||
* Renders a mind map diagram using d3-hierarchy for layout
|
||||
* and d3-shape for link paths, with inline SVG output.
|
||||
* Renders a centered mind map diagram using d3-hierarchy for tree layout
|
||||
* and d3-shape for curved link paths, with inline SVG output.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { hierarchy, tree as d3tree } from 'd3-hierarchy';
|
||||
import { hierarchy, tree as d3tree, type HierarchyPointNode } from 'd3-hierarchy';
|
||||
import { linkHorizontal } from 'd3-shape';
|
||||
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||
|
||||
@@ -74,103 +81,276 @@ function estimateTextWidth(text: string): number {
|
||||
return Math.max(text.length * 7, 40);
|
||||
}
|
||||
|
||||
/** 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_PADDING_Y = 6;
|
||||
const NODE_RADIUS = 6;
|
||||
const VERTICAL_GAP = 8;
|
||||
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;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
svgWidth: number;
|
||||
svgHeight: number;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
}>;
|
||||
nodes: PositionedNode[];
|
||||
links: Array<{
|
||||
source: { x: number; y: number };
|
||||
target: { x: number; y: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
export function computeLayout(root: TreeNode): LayoutResult {
|
||||
const h = hierarchy(root, (d) => (d.children.length > 0 ? d.children : null));
|
||||
/**
|
||||
* 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<TreeNode>): Map<number, number> {
|
||||
const widths = new Map<number, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
|
||||
// Node sizes: [vertical spacing, horizontal spacing]
|
||||
const nodeVSpacing = NODE_HEIGHT + VERTICAL_GAP;
|
||||
const nodeHSpacing = 180;
|
||||
|
||||
const treeLayout = d3tree<TreeNode>().nodeSize([nodeVSpacing, nodeHSpacing]);
|
||||
const treeLayout = d3tree<TreeNode>().nodeSize([nodeVSpacing, 1]);
|
||||
const treeRoot = treeLayout(h);
|
||||
|
||||
// Collect all positioned nodes
|
||||
const allNodes = treeRoot.descendants();
|
||||
const allLinks = treeRoot.links();
|
||||
// Compute max widths per depth to set proper horizontal offsets
|
||||
const depthWidths = maxWidthPerDepth(treeRoot);
|
||||
|
||||
// Compute node widths based on label text
|
||||
const positioned = allNodes.map((n) => {
|
||||
const textW = estimateTextWidth(n.data.label);
|
||||
const width = textW + NODE_PADDING_X * 2;
|
||||
return {
|
||||
// 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,
|
||||
// d3.tree uses x for vertical, y for horizontal (depth axis)
|
||||
x: n.y, // horizontal position (depth)
|
||||
y: n.x, // vertical position
|
||||
width,
|
||||
depth: n.depth,
|
||||
};
|
||||
});
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
// Compute links — connect right edge of source to left edge of target
|
||||
const nodeById = new Map(positioned.map((n) => [n.id, n]));
|
||||
const links = allLinks.map((link) => {
|
||||
const source = nodeById.get(link.source.data.id)!;
|
||||
const target = nodeById.get(link.target.data.id)!;
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
export function computeLayout(root: TreeNode): LayoutResult {
|
||||
const rootW = nodeWidth(root.label);
|
||||
|
||||
// Single node — just center it
|
||||
if (root.children.length === 0) {
|
||||
const pad = 16;
|
||||
return {
|
||||
source: { x: source.x + source.width / 2, y: source.y },
|
||||
target: { x: target.x - target.width / 2, y: target.y },
|
||||
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: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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 positioned) {
|
||||
const left = n.x - n.width / 2;
|
||||
const right = n.x + n.width / 2;
|
||||
const top = n.y - NODE_HEIGHT / 2;
|
||||
const bottom = n.y + NODE_HEIGHT / 2;
|
||||
if (left < minX) minX = left;
|
||||
if (right > maxX) maxX = right;
|
||||
if (top < minY) minY = top;
|
||||
if (bottom > maxY) maxY = bottom;
|
||||
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;
|
||||
}
|
||||
|
||||
// Add padding
|
||||
const pad = 16;
|
||||
minX -= pad;
|
||||
minY -= pad;
|
||||
maxX += pad;
|
||||
maxY += pad;
|
||||
|
||||
// Translate all coordinates so they start at (0,0)
|
||||
const offsetX = -minX;
|
||||
const offsetY = -minY;
|
||||
|
||||
return {
|
||||
svgWidth: maxX - minX,
|
||||
svgHeight: maxY - minY,
|
||||
nodes: positioned.map((n) => ({
|
||||
...n,
|
||||
x: n.x + offsetX,
|
||||
y: n.y + offsetY,
|
||||
})),
|
||||
links: links.map((l) => ({
|
||||
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 },
|
||||
})),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { A2UIMindmap, buildTree, computeLayout } from '../../../src/renderer/a2ui/components/A2UIMindmap';
|
||||
import { A2UIMindmap, buildTree, computeLayout, splitChildren } from '../../../src/renderer/a2ui/components/A2UIMindmap';
|
||||
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types';
|
||||
|
||||
function makeMindmapComponent(
|
||||
@@ -71,6 +71,45 @@ 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', () => {
|
||||
it('computes layout for a simple tree', () => {
|
||||
const tree = buildTree([
|
||||
@@ -85,13 +124,25 @@ describe('A2UIMindmap', () => {
|
||||
expect(layout.links).toHaveLength(2);
|
||||
expect(layout.svgWidth).toBeGreaterThan(0);
|
||||
expect(layout.svgHeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Root should be leftmost (smallest x)
|
||||
it('places root between left and right children', () => {
|
||||
const tree = buildTree([
|
||||
{ id: 'root', label: 'Root', children: ['a', 'b'] },
|
||||
{ id: 'a', label: 'A' },
|
||||
{ id: 'b', label: 'B' },
|
||||
])!;
|
||||
|
||||
const layout = computeLayout(tree);
|
||||
const root = layout.nodes.find((n) => n.id === 'root')!;
|
||||
const nodeA = layout.nodes.find((n) => n.id === 'a')!;
|
||||
const nodeB = layout.nodes.find((n) => n.id === 'b')!;
|
||||
expect(root.x).toBeLessThan(nodeA.x);
|
||||
expect(root.x).toBeLessThan(nodeB.x);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('assigns correct depth values', () => {
|
||||
|
||||
Reference in New Issue
Block a user