feat: add render_mindmap a2ui tool with d3-hierarchy layout

This commit is contained in:
2026-03-01 22:09:17 +01:00
parent 3addd92728
commit 71d05a0fa5
13 changed files with 683 additions and 7 deletions

View File

@@ -24,6 +24,7 @@ import { A2UIList } from './components/A2UIList';
import { A2UIRow } from './components/A2UIRow';
import { A2UIColumn } from './components/A2UIColumn';
import { A2UIDivider } from './components/A2UIDivider';
import { A2UIMindmap } from './components/A2UIMindmap';
export interface A2UIComponentProps {
component: A2UIResolvedComponent;
@@ -53,6 +54,7 @@ const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
row: A2UIRow,
column: A2UIColumn,
divider: A2UIDivider,
mindmap: A2UIMindmap,
};
interface A2UIRendererProps {

View File

@@ -0,0 +1,252 @@
/**
* A2UI Mindmap Component
*
* Renders a mind map diagram using d3-hierarchy for layout
* and d3-shape for link paths, with inline SVG output.
*/
import React, { useMemo } from 'react';
import { hierarchy, tree as d3tree } from 'd3-hierarchy';
import { linkHorizontal } from 'd3-shape';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface MindmapNode {
id: string;
label: string;
children?: string[];
}
interface TreeNode {
id: string;
label: string;
children: TreeNode[];
}
const NODE_COLORS = [
'var(--vscode-charts-blue, #75beff)',
'var(--vscode-charts-green, #89d185)',
'var(--vscode-charts-orange, #d18616)',
'var(--vscode-charts-purple, #b180d7)',
'var(--vscode-charts-red, #f14c4c)',
'var(--vscode-charts-yellow, #e2e210)',
];
function getNodeColor(depth: number): string {
if (depth === 0) return 'var(--vscode-focusBorder, #007fd4)';
return NODE_COLORS[(depth - 1) % NODE_COLORS.length];
}
/** 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;
const nodeMap = new Map<string, MindmapNode>();
for (const node of nodes) {
nodeMap.set(node.id, node);
}
function toTreeNode(id: string): TreeNode | null {
const node = nodeMap.get(id);
if (!node) return null;
const children: TreeNode[] = [];
if (node.children) {
for (const childId of node.children) {
const child = toTreeNode(childId);
if (child) children.push(child);
}
}
return { id: node.id, label: node.label, children };
}
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);
}
const NODE_HEIGHT = 28;
const NODE_PADDING_X = 12;
const NODE_PADDING_Y = 6;
const NODE_RADIUS = 6;
const VERTICAL_GAP = 8;
interface LayoutResult {
svgWidth: number;
svgHeight: number;
nodes: Array<{
id: string;
label: string;
x: number;
y: number;
width: number;
depth: number;
}>;
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));
// Node sizes: [vertical spacing, horizontal spacing]
const nodeVSpacing = NODE_HEIGHT + VERTICAL_GAP;
const nodeHSpacing = 180;
const treeLayout = d3tree<TreeNode>().nodeSize([nodeVSpacing, nodeHSpacing]);
const treeRoot = treeLayout(h);
// Collect all positioned nodes
const allNodes = treeRoot.descendants();
const allLinks = treeRoot.links();
// 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 {
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,
};
});
// 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)!;
return {
source: { x: source.x + source.width / 2, y: source.y },
target: { x: target.x - target.width / 2, y: target.y },
};
});
// 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;
}
// 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) => ({
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<
{ source: { x: number; y: number }; target: { x: number; y: number } },
{ x: number; y: number }
>()
.x((d) => d.x)
.y((d) => d.y);
export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
const title = component.properties.title as string | undefined;
const nodes = (component.boundValue as MindmapNode[]) ??
(component.properties.nodes as MindmapNode[]) ?? [];
const layout = useMemo(() => {
const root = buildTree(nodes);
if (!root) return null;
return computeLayout(root);
}, [nodes]);
if (!layout) return null;
return (
<div className="assistant-panel-mindmap">
{title && <p className="assistant-panel-mindmap-title">{title}</p>}
<svg
className="assistant-panel-mindmap-svg"
viewBox={`0 0 ${layout.svgWidth} ${layout.svgHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Links */}
{layout.links.map((link, i) => (
<path
key={`${component.id}-link-${i}`}
className="assistant-panel-mindmap-link"
d={linkGenerator(link) ?? ''}
fill="none"
/>
))}
{/* Nodes */}
{layout.nodes.map((node) => {
const color = getNodeColor(node.depth);
return (
<g key={`${component.id}-node-${node.id}`}>
<rect
className="assistant-panel-mindmap-node"
x={node.x - node.width / 2}
y={node.y - NODE_HEIGHT / 2}
width={node.width}
height={NODE_HEIGHT}
rx={NODE_RADIUS}
ry={NODE_RADIUS}
fill={color}
fillOpacity={node.depth === 0 ? 0.25 : 0.15}
stroke={color}
strokeWidth={node.depth === 0 ? 2 : 1}
>
<title>{node.label}</title>
</rect>
<text
className="assistant-panel-mindmap-label"
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
>
{node.label}
</text>
</g>
);
})}
</svg>
</div>
);
};

View File

@@ -430,3 +430,32 @@
flex-direction: column;
gap: 8px;
}
/* ---- Mindmap ---- */
.assistant-panel-mindmap {
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-mindmap-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-mindmap-svg {
width: 100%;
max-height: 400px;
}
.assistant-panel-mindmap-link {
stroke: var(--vscode-panel-border);
stroke-width: 1.5;
}
.assistant-panel-mindmap-label {
font-size: 12px;
fill: var(--vscode-foreground);
pointer-events: none;
}