From 78aa59e0a31397e143bac2ec5dea4b691051c03f Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 22:15:08 +0100 Subject: [PATCH] fix: center and balance mindmap layout with left/right splitting --- src/renderer/a2ui/components/A2UIMindmap.tsx | 298 +++++++++++++++---- tests/renderer/a2ui/A2UIMindmap.test.tsx | 59 +++- 2 files changed, 294 insertions(+), 63 deletions(-) diff --git a/src/renderer/a2ui/components/A2UIMindmap.tsx b/src/renderer/a2ui/components/A2UIMindmap.tsx index 8ed2ae3..c2e4c77 100644 --- a/src/renderer/a2ui/components/A2UIMindmap.tsx +++ b/src/renderer/a2ui/components/A2UIMindmap.tsx @@ -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): 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; +} + +/** + * 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().nodeSize([nodeVSpacing, nodeHSpacing]); + const treeLayout = d3tree().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(); + 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 }, })), diff --git a/tests/renderer/a2ui/A2UIMindmap.test.tsx b/tests/renderer/a2ui/A2UIMindmap.test.tsx index f6214ea..0f43b12 100644 --- a/tests/renderer/a2ui/A2UIMindmap.test.tsx +++ b/tests/renderer/a2ui/A2UIMindmap.test.tsx @@ -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 = { 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', () => {