@diagrammo/dgmo 0.8.20 → 0.8.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +142 -90
- package/dist/editor.cjs +30 -4
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +30 -4
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +25 -3
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +25 -3
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +21201 -12886
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +646 -89
- package/dist/index.d.ts +646 -89
- package/dist/index.js +21178 -12889
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-mindmap.md +198 -0
- package/docs/guide/chart-sequence.md +23 -1
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/chart-wireframe.md +100 -0
- package/docs/guide/index.md +8 -0
- package/docs/guide/registry.json +1 -0
- package/docs/language-reference.md +249 -4
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +21 -3
- package/src/boxes-and-lines/layout.ts +360 -42
- package/src/boxes-and-lines/parser.ts +94 -11
- package/src/boxes-and-lines/renderer.ts +371 -114
- package/src/boxes-and-lines/types.ts +2 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/completion.ts +253 -0
- package/src/cycle/layout.ts +732 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +539 -0
- package/src/cycle/types.ts +77 -0
- package/src/d3.ts +240 -40
- package/src/dgmo-router.ts +15 -0
- package/src/echarts.ts +7 -4
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +26 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/layout.ts +73 -9
- package/src/graph/state-collapse.ts +78 -0
- package/src/graph/state-parser.ts +5 -10
- package/src/graph/state-renderer.ts +139 -34
- package/src/index.ts +78 -0
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +30 -6
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1456 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +325 -63
- package/src/mindmap/collapse.ts +88 -0
- package/src/mindmap/layout.ts +605 -0
- package/src/mindmap/parser.ts +373 -0
- package/src/mindmap/renderer.ts +544 -0
- package/src/mindmap/text-wrap.ts +217 -0
- package/src/mindmap/types.ts +55 -0
- package/src/org/parser.ts +2 -6
- package/src/render.ts +18 -21
- package/src/sequence/renderer.ts +273 -56
- package/src/sharing.ts +3 -0
- package/src/sitemap/layout.ts +56 -18
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1058 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/export-container.ts +3 -2
- package/src/utils/legend-d3.ts +1 -0
- package/src/utils/legend-layout.ts +5 -3
- package/src/utils/parsing.ts +48 -7
- package/src/utils/tag-groups.ts +46 -60
- package/src/wireframe/layout.ts +460 -0
- package/src/wireframe/parser.ts +956 -0
- package/src/wireframe/renderer.ts +1293 -0
- package/src/wireframe/types.ts +110 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Mindmap Collapse/Expand — prune subtrees of collapsed nodes
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import type { MindmapNode } from './types';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
export interface CollapsedMindmapResult {
|
|
12
|
+
/** Roots with collapsed subtrees pruned (deep-cloned, never mutates original) */
|
|
13
|
+
roots: MindmapNode[];
|
|
14
|
+
/** nodeId → count of hidden descendants */
|
|
15
|
+
hiddenCounts: Map<string, number>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// Helpers
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
function cloneNode(node: MindmapNode): MindmapNode {
|
|
23
|
+
return {
|
|
24
|
+
id: node.id,
|
|
25
|
+
label: node.label,
|
|
26
|
+
description: node.description,
|
|
27
|
+
metadata: { ...node.metadata },
|
|
28
|
+
children: node.children.map(cloneNode),
|
|
29
|
+
parentId: node.parentId,
|
|
30
|
+
lineNumber: node.lineNumber,
|
|
31
|
+
color: node.color,
|
|
32
|
+
collapsed: node.collapsed,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function countDescendants(node: MindmapNode): number {
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const child of node.children) {
|
|
39
|
+
count += 1 + countDescendants(child);
|
|
40
|
+
}
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function computeHiddenCounts(
|
|
45
|
+
nodes: MindmapNode[],
|
|
46
|
+
collapsedIds: Set<string>,
|
|
47
|
+
hiddenCounts: Map<string, number>
|
|
48
|
+
): void {
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
if (collapsedIds.has(node.id) && node.children.length > 0) {
|
|
51
|
+
hiddenCounts.set(node.id, countDescendants(node));
|
|
52
|
+
}
|
|
53
|
+
computeHiddenCounts(node.children, collapsedIds, hiddenCounts);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pruneCollapsed(node: MindmapNode, collapsedIds: Set<string>): void {
|
|
58
|
+
for (const child of node.children) {
|
|
59
|
+
pruneCollapsed(child, collapsedIds);
|
|
60
|
+
}
|
|
61
|
+
if (collapsedIds.has(node.id) && node.children.length > 0) {
|
|
62
|
+
node.children = [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// Main
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
export function collapseMindmapTree(
|
|
71
|
+
roots: MindmapNode[],
|
|
72
|
+
collapsedIds: Set<string>
|
|
73
|
+
): CollapsedMindmapResult {
|
|
74
|
+
const hiddenCounts = new Map<string, number>();
|
|
75
|
+
|
|
76
|
+
if (collapsedIds.size === 0) {
|
|
77
|
+
return { roots, hiddenCounts };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
computeHiddenCounts(roots, collapsedIds, hiddenCounts);
|
|
81
|
+
|
|
82
|
+
const clonedRoots = roots.map(cloneNode);
|
|
83
|
+
for (const root of clonedRoots) {
|
|
84
|
+
pruneCollapsed(root, collapsedIds);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { roots: clonedRoots, hiddenCounts };
|
|
88
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Mindmap Two-Sided Horizontal Tree Layout
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Classic mindmap layout: root centered, children branch left and right.
|
|
6
|
+
// Each side is a recursive vertical stacking — no D3 tree layout needed.
|
|
7
|
+
// Nodes at the same depth share the same X column. Hierarchy reads
|
|
8
|
+
// left→right (right branches) or right→left (left branches).
|
|
9
|
+
|
|
10
|
+
import type { MindmapNode } from './types';
|
|
11
|
+
import type {
|
|
12
|
+
MindmapLayoutNode,
|
|
13
|
+
MindmapLayoutEdge,
|
|
14
|
+
MindmapLayoutResult,
|
|
15
|
+
} from './types';
|
|
16
|
+
import type { ParsedMindmap } from './types';
|
|
17
|
+
import type { PaletteColors } from '../palettes';
|
|
18
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
19
|
+
import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
|
|
20
|
+
import { computeNodeText } from './text-wrap';
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// Constants
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
const ROOT_WIDTH = 180;
|
|
27
|
+
const DEPTH1_WIDTH = 150;
|
|
28
|
+
const LEAF_WIDTH = 120;
|
|
29
|
+
|
|
30
|
+
const SINGLE_LABEL_HEIGHT = 28;
|
|
31
|
+
const LABEL_LINE_HEIGHT = 18; // per line when multi-line
|
|
32
|
+
const DESC_LINE_HEIGHT = 14; // per description line
|
|
33
|
+
const NODE_V_PAD = 10;
|
|
34
|
+
|
|
35
|
+
const H_GAP = 40; // horizontal gap between parent edge and child edge
|
|
36
|
+
const V_GAP = 12; // vertical gap between sibling nodes
|
|
37
|
+
const MARGIN = 40;
|
|
38
|
+
const MULTI_ROOT_GAP = 60; // horizontal gap between independent root trees
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// Direction — which side a subtree grows toward
|
|
42
|
+
// ============================================================
|
|
43
|
+
|
|
44
|
+
type Direction = 'right' | 'left';
|
|
45
|
+
|
|
46
|
+
// ============================================================
|
|
47
|
+
// Internal positioned node (intermediate before final output)
|
|
48
|
+
// ============================================================
|
|
49
|
+
|
|
50
|
+
interface PositionedNode {
|
|
51
|
+
node: MindmapNode;
|
|
52
|
+
x: number; // top-left x
|
|
53
|
+
y: number; // top-left y
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
depth: number;
|
|
57
|
+
direction: Direction;
|
|
58
|
+
subtreeHeight: number; // total height of this node's subtree
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// Main entry point
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
export function layoutMindmap(
|
|
66
|
+
parsed: ParsedMindmap,
|
|
67
|
+
palette: PaletteColors,
|
|
68
|
+
options?: {
|
|
69
|
+
interactive?: boolean;
|
|
70
|
+
hiddenCounts?: Map<string, number>;
|
|
71
|
+
activeTagGroup?: string | null;
|
|
72
|
+
hideDescriptions?: boolean;
|
|
73
|
+
}
|
|
74
|
+
): MindmapLayoutResult {
|
|
75
|
+
const roots = parsed.roots;
|
|
76
|
+
if (roots.length === 0) {
|
|
77
|
+
return { nodes: [], edges: [], width: 0, height: 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hiddenCounts = options?.hiddenCounts ?? new Map<string, number>();
|
|
81
|
+
const activeTagGroup = options?.activeTagGroup ?? null;
|
|
82
|
+
const hideDescriptions = options?.hideDescriptions ?? false;
|
|
83
|
+
|
|
84
|
+
// Populate depth cache for nodeHeight() wrapping calculations
|
|
85
|
+
populateDepthCache(roots);
|
|
86
|
+
|
|
87
|
+
// Inject default tag metadata (idempotent — fills empty metadata keys)
|
|
88
|
+
const allNodes: MindmapNode[] = [];
|
|
89
|
+
const collectAll = (nodes: MindmapNode[]) => {
|
|
90
|
+
for (const n of nodes) {
|
|
91
|
+
allNodes.push(n);
|
|
92
|
+
collectAll(n.children);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
collectAll(roots);
|
|
96
|
+
injectDefaultTagMetadata(allNodes, parsed.tagGroups);
|
|
97
|
+
|
|
98
|
+
// Color resolution happens in finalize() per layout node — NOT by mutating parsed nodes.
|
|
99
|
+
// This allows tag group switching to recolor correctly.
|
|
100
|
+
const tagGroups = parsed.tagGroups;
|
|
101
|
+
|
|
102
|
+
if (roots.length === 1) {
|
|
103
|
+
return layoutSingleRoot(
|
|
104
|
+
roots[0],
|
|
105
|
+
hiddenCounts,
|
|
106
|
+
hideDescriptions,
|
|
107
|
+
tagGroups,
|
|
108
|
+
activeTagGroup
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return layoutMultiRoot(
|
|
112
|
+
roots,
|
|
113
|
+
parsed,
|
|
114
|
+
hiddenCounts,
|
|
115
|
+
hideDescriptions,
|
|
116
|
+
tagGroups,
|
|
117
|
+
activeTagGroup
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================
|
|
122
|
+
// Single root — two-sided horizontal tree
|
|
123
|
+
// ============================================================
|
|
124
|
+
|
|
125
|
+
function layoutSingleRoot(
|
|
126
|
+
root: MindmapNode,
|
|
127
|
+
hiddenCounts: Map<string, number>,
|
|
128
|
+
hideDescriptions: boolean,
|
|
129
|
+
tagGroups: TagGroup[] = [],
|
|
130
|
+
activeTagGroup: string | null = null
|
|
131
|
+
): MindmapLayoutResult {
|
|
132
|
+
const positioned: PositionedNode[] = [];
|
|
133
|
+
const rootW = nodeWidth(0);
|
|
134
|
+
const rootH = nodeHeight(root, hideDescriptions);
|
|
135
|
+
|
|
136
|
+
// Split children into right and left sides, balancing by subtree weight
|
|
137
|
+
const children = root.children;
|
|
138
|
+
const { right: rightChildren, left: leftChildren } = balancedSplit(
|
|
139
|
+
children,
|
|
140
|
+
hideDescriptions
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Compute subtree heights for each side
|
|
144
|
+
const rightHeight = computeGroupHeight(rightChildren, 1, hideDescriptions);
|
|
145
|
+
const leftHeight = computeGroupHeight(leftChildren, 1, hideDescriptions);
|
|
146
|
+
const maxSideHeight = Math.max(rightHeight, leftHeight, rootH);
|
|
147
|
+
|
|
148
|
+
// Root position: centered vertically
|
|
149
|
+
const rootX = 0; // will be offset later
|
|
150
|
+
const rootY = maxSideHeight / 2 - rootH / 2;
|
|
151
|
+
|
|
152
|
+
positioned.push({
|
|
153
|
+
node: root,
|
|
154
|
+
x: rootX,
|
|
155
|
+
y: rootY,
|
|
156
|
+
width: rootW,
|
|
157
|
+
height: rootH,
|
|
158
|
+
depth: 0,
|
|
159
|
+
direction: 'right',
|
|
160
|
+
subtreeHeight: maxSideHeight,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Layout right side
|
|
164
|
+
const rootCenterY = rootY + rootH / 2;
|
|
165
|
+
const rightStartX = rootX + rootW + H_GAP;
|
|
166
|
+
layoutSide(
|
|
167
|
+
rightChildren,
|
|
168
|
+
rightStartX,
|
|
169
|
+
rootCenterY,
|
|
170
|
+
1,
|
|
171
|
+
'right',
|
|
172
|
+
hideDescriptions,
|
|
173
|
+
positioned
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Layout left side
|
|
177
|
+
const leftStartX = rootX - H_GAP;
|
|
178
|
+
layoutSide(
|
|
179
|
+
leftChildren,
|
|
180
|
+
leftStartX,
|
|
181
|
+
rootCenterY,
|
|
182
|
+
1,
|
|
183
|
+
'left',
|
|
184
|
+
hideDescriptions,
|
|
185
|
+
positioned
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Compute bounding box and normalize to positive coordinates
|
|
189
|
+
return finalize(
|
|
190
|
+
positioned,
|
|
191
|
+
hiddenCounts,
|
|
192
|
+
hideDescriptions,
|
|
193
|
+
root,
|
|
194
|
+
tagGroups,
|
|
195
|
+
activeTagGroup
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================
|
|
200
|
+
// Multi-root — each root gets its own two-sided tree, arranged horizontally
|
|
201
|
+
// ============================================================
|
|
202
|
+
|
|
203
|
+
function layoutMultiRoot(
|
|
204
|
+
roots: MindmapNode[],
|
|
205
|
+
_parsed: ParsedMindmap,
|
|
206
|
+
hiddenCounts: Map<string, number>,
|
|
207
|
+
hideDescriptions: boolean,
|
|
208
|
+
tagGroups: TagGroup[],
|
|
209
|
+
activeTagGroup: string | null
|
|
210
|
+
): MindmapLayoutResult {
|
|
211
|
+
const subResults: MindmapLayoutResult[] = [];
|
|
212
|
+
for (const r of roots) {
|
|
213
|
+
subResults.push(
|
|
214
|
+
layoutSingleRoot(
|
|
215
|
+
r,
|
|
216
|
+
hiddenCounts,
|
|
217
|
+
hideDescriptions,
|
|
218
|
+
tagGroups,
|
|
219
|
+
activeTagGroup
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const totalWidth =
|
|
225
|
+
subResults.reduce((sum, r) => sum + r.width, 0) +
|
|
226
|
+
MULTI_ROOT_GAP * (subResults.length - 1);
|
|
227
|
+
const maxHeight = Math.max(...subResults.map((r) => r.height));
|
|
228
|
+
|
|
229
|
+
const allNodes: MindmapLayoutNode[] = [];
|
|
230
|
+
const allEdges: MindmapLayoutEdge[] = [];
|
|
231
|
+
let xOffset = 0;
|
|
232
|
+
|
|
233
|
+
for (const sub of subResults) {
|
|
234
|
+
const yOffset = (maxHeight - sub.height) / 2;
|
|
235
|
+
for (const n of sub.nodes) {
|
|
236
|
+
allNodes.push({ ...n, x: n.x + xOffset, y: n.y + yOffset });
|
|
237
|
+
}
|
|
238
|
+
for (const e of sub.edges) {
|
|
239
|
+
// Offset all coordinates in the path string
|
|
240
|
+
allEdges.push({
|
|
241
|
+
sourceId: e.sourceId,
|
|
242
|
+
targetId: e.targetId,
|
|
243
|
+
path: offsetPath(e.path, xOffset, yOffset),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
xOffset += sub.width + MULTI_ROOT_GAP;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
nodes: allNodes,
|
|
251
|
+
edges: allEdges,
|
|
252
|
+
width: totalWidth,
|
|
253
|
+
height: maxHeight,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================
|
|
258
|
+
// Recursive side layout
|
|
259
|
+
// ============================================================
|
|
260
|
+
|
|
261
|
+
function layoutSide(
|
|
262
|
+
children: MindmapNode[],
|
|
263
|
+
startX: number,
|
|
264
|
+
parentCenterY: number,
|
|
265
|
+
depth: number,
|
|
266
|
+
direction: Direction,
|
|
267
|
+
hideDescriptions: boolean,
|
|
268
|
+
positioned: PositionedNode[]
|
|
269
|
+
): void {
|
|
270
|
+
if (children.length === 0) return;
|
|
271
|
+
|
|
272
|
+
const groupHeight = computeGroupHeight(children, depth, hideDescriptions);
|
|
273
|
+
let currentY = parentCenterY - groupHeight / 2;
|
|
274
|
+
|
|
275
|
+
for (const child of children) {
|
|
276
|
+
const w = nodeWidth(depth);
|
|
277
|
+
const h = nodeHeight(child, hideDescriptions);
|
|
278
|
+
const subtreeH = computeSubtreeHeight(child, depth, hideDescriptions);
|
|
279
|
+
|
|
280
|
+
// Node is vertically centered within its subtree allocation
|
|
281
|
+
const nodeY = currentY + subtreeH / 2 - h / 2;
|
|
282
|
+
const nodeX = direction === 'right' ? startX : startX - w;
|
|
283
|
+
|
|
284
|
+
positioned.push({
|
|
285
|
+
node: child,
|
|
286
|
+
x: nodeX,
|
|
287
|
+
y: nodeY,
|
|
288
|
+
width: w,
|
|
289
|
+
height: h,
|
|
290
|
+
depth,
|
|
291
|
+
direction,
|
|
292
|
+
subtreeHeight: subtreeH,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Recurse into children
|
|
296
|
+
if (child.children.length > 0) {
|
|
297
|
+
const childCenterY = nodeY + h / 2;
|
|
298
|
+
const nextX = direction === 'right' ? nodeX + w + H_GAP : nodeX - H_GAP;
|
|
299
|
+
layoutSide(
|
|
300
|
+
child.children,
|
|
301
|
+
nextX,
|
|
302
|
+
childCenterY,
|
|
303
|
+
depth + 1,
|
|
304
|
+
direction,
|
|
305
|
+
hideDescriptions,
|
|
306
|
+
positioned
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
currentY += subtreeH + V_GAP;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================================
|
|
315
|
+
// Height computation
|
|
316
|
+
// ============================================================
|
|
317
|
+
|
|
318
|
+
/** Total height of a group of siblings (including their subtrees) */
|
|
319
|
+
function computeGroupHeight(
|
|
320
|
+
children: MindmapNode[],
|
|
321
|
+
depth: number,
|
|
322
|
+
hideDescriptions: boolean
|
|
323
|
+
): number {
|
|
324
|
+
if (children.length === 0) return 0;
|
|
325
|
+
let total = 0;
|
|
326
|
+
for (const child of children) {
|
|
327
|
+
total += computeSubtreeHeight(child, depth, hideDescriptions);
|
|
328
|
+
}
|
|
329
|
+
total += V_GAP * (children.length - 1);
|
|
330
|
+
return total;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Height of a single node's subtree (the node + its descendants stacked vertically) */
|
|
334
|
+
function computeSubtreeHeight(
|
|
335
|
+
node: MindmapNode,
|
|
336
|
+
depth: number,
|
|
337
|
+
hideDescriptions: boolean
|
|
338
|
+
): number {
|
|
339
|
+
const h = nodeHeight(node, hideDescriptions);
|
|
340
|
+
if (node.children.length === 0) return h;
|
|
341
|
+
const childrenHeight = computeGroupHeight(
|
|
342
|
+
node.children,
|
|
343
|
+
depth + 1,
|
|
344
|
+
hideDescriptions
|
|
345
|
+
);
|
|
346
|
+
return Math.max(h, childrenHeight);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ============================================================
|
|
350
|
+
// Finalization — normalize coordinates, generate output
|
|
351
|
+
// ============================================================
|
|
352
|
+
|
|
353
|
+
function resolveNodeColor(
|
|
354
|
+
node: MindmapNode,
|
|
355
|
+
tagGroups: TagGroup[],
|
|
356
|
+
activeGroupName: string | null
|
|
357
|
+
): string | undefined {
|
|
358
|
+
// Explicit inline (color) always wins
|
|
359
|
+
if (node.color) return node.color;
|
|
360
|
+
return resolveTagColor(node.metadata, tagGroups, activeGroupName);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function finalize(
|
|
364
|
+
positioned: PositionedNode[],
|
|
365
|
+
hiddenCounts: Map<string, number>,
|
|
366
|
+
_hideDescriptions: boolean,
|
|
367
|
+
_rootMindmapNode: MindmapNode,
|
|
368
|
+
tagGroups: TagGroup[] = [],
|
|
369
|
+
activeTagGroup: string | null = null
|
|
370
|
+
): MindmapLayoutResult {
|
|
371
|
+
// Compute bounding box
|
|
372
|
+
let minX = Infinity,
|
|
373
|
+
minY = Infinity,
|
|
374
|
+
maxX = -Infinity,
|
|
375
|
+
maxY = -Infinity;
|
|
376
|
+
for (const p of positioned) {
|
|
377
|
+
minX = Math.min(minX, p.x);
|
|
378
|
+
minY = Math.min(minY, p.y);
|
|
379
|
+
maxX = Math.max(maxX, p.x + p.width);
|
|
380
|
+
maxY = Math.max(maxY, p.y + p.height);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const offsetX = -minX + MARGIN;
|
|
384
|
+
const offsetY = -minY + MARGIN;
|
|
385
|
+
const totalWidth = maxX - minX + MARGIN * 2;
|
|
386
|
+
const totalHeight = maxY - minY + MARGIN * 2;
|
|
387
|
+
|
|
388
|
+
// Build node index for edge generation
|
|
389
|
+
const nodeMap = new Map<string, PositionedNode>();
|
|
390
|
+
for (const p of positioned) {
|
|
391
|
+
nodeMap.set(p.node.id, p);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const nodes: MindmapLayoutNode[] = [];
|
|
395
|
+
const edges: MindmapLayoutEdge[] = [];
|
|
396
|
+
|
|
397
|
+
for (const p of positioned) {
|
|
398
|
+
nodes.push({
|
|
399
|
+
id: p.node.id,
|
|
400
|
+
label: p.node.label,
|
|
401
|
+
description: p.node.description,
|
|
402
|
+
metadata: p.node.metadata,
|
|
403
|
+
lineNumber: p.node.lineNumber,
|
|
404
|
+
color: resolveNodeColor(p.node, tagGroups, activeTagGroup),
|
|
405
|
+
x: p.x + offsetX,
|
|
406
|
+
y: p.y + offsetY,
|
|
407
|
+
width: p.width,
|
|
408
|
+
height: p.height,
|
|
409
|
+
depth: p.depth,
|
|
410
|
+
angle: 0,
|
|
411
|
+
radius: 0,
|
|
412
|
+
hiddenCount: hiddenCounts.get(p.node.id),
|
|
413
|
+
hasChildren: hiddenCounts.has(p.node.id) || p.node.children.length > 0,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Generate bus-style edges: one trunk + vertical bar + individual drops per parent.
|
|
418
|
+
// This prevents overlapping semi-transparent segments.
|
|
419
|
+
const childrenByParent = new Map<string, PositionedNode[]>();
|
|
420
|
+
for (const p of positioned) {
|
|
421
|
+
if (p.node.parentId) {
|
|
422
|
+
const arr = childrenByParent.get(p.node.parentId) ?? [];
|
|
423
|
+
arr.push(p);
|
|
424
|
+
childrenByParent.set(p.node.parentId, arr);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Split children by direction so left and right get separate bus edges
|
|
429
|
+
const groupedEdges: [string, PositionedNode[]][] = [];
|
|
430
|
+
for (const [parentId, children] of childrenByParent) {
|
|
431
|
+
const rightKids = children.filter((c) => c.direction === 'right');
|
|
432
|
+
const leftKids = children.filter((c) => c.direction === 'left');
|
|
433
|
+
if (rightKids.length > 0) groupedEdges.push([parentId, rightKids]);
|
|
434
|
+
if (leftKids.length > 0) groupedEdges.push([parentId, leftKids]);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const [parentId, children] of groupedEdges) {
|
|
438
|
+
const parent = nodeMap.get(parentId)!;
|
|
439
|
+
const px = parent.x + offsetX;
|
|
440
|
+
const py = parent.y + offsetY;
|
|
441
|
+
const isLeft = children[0].direction === 'left';
|
|
442
|
+
|
|
443
|
+
// Parent connection point
|
|
444
|
+
const srcX = isLeft ? px : px + parent.width;
|
|
445
|
+
const srcY = py + parent.height / 2;
|
|
446
|
+
|
|
447
|
+
// Midpoint X between parent edge and children column
|
|
448
|
+
const firstChild = children[0];
|
|
449
|
+
const childEdgeX = isLeft
|
|
450
|
+
? firstChild.x + offsetX + firstChild.width
|
|
451
|
+
: firstChild.x + offsetX;
|
|
452
|
+
const midX = (srcX + childEdgeX) / 2;
|
|
453
|
+
|
|
454
|
+
if (children.length === 1) {
|
|
455
|
+
// Single child — simple elbow, no bus needed
|
|
456
|
+
const c = children[0];
|
|
457
|
+
const tgtX = isLeft ? c.x + offsetX + c.width : c.x + offsetX;
|
|
458
|
+
const tgtY = c.y + offsetY + c.height / 2;
|
|
459
|
+
edges.push({
|
|
460
|
+
sourceId: parentId,
|
|
461
|
+
targetId: c.node.id,
|
|
462
|
+
path: `M ${srcX} ${srcY} L ${midX} ${srcY} L ${midX} ${tgtY} L ${tgtX} ${tgtY}`,
|
|
463
|
+
});
|
|
464
|
+
} else {
|
|
465
|
+
// Bus pattern: trunk → vertical bar → drops
|
|
466
|
+
// 1. Trunk: parent edge → midX
|
|
467
|
+
edges.push({
|
|
468
|
+
sourceId: parentId,
|
|
469
|
+
targetId: parentId, // self-ref = trunk
|
|
470
|
+
path: `M ${srcX} ${srcY} L ${midX} ${srcY}`,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// 2. Vertical bar from topmost child to bottommost child at midX
|
|
474
|
+
const childYs = children.map((c) => c.y + offsetY + c.height / 2);
|
|
475
|
+
const minChildY = Math.min(...childYs);
|
|
476
|
+
const maxChildY = Math.max(...childYs);
|
|
477
|
+
edges.push({
|
|
478
|
+
sourceId: parentId,
|
|
479
|
+
targetId: parentId, // self-ref = bar
|
|
480
|
+
path: `M ${midX} ${minChildY} L ${midX} ${maxChildY}`,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// 3. Individual horizontal drops from midX to each child
|
|
484
|
+
for (let i = 0; i < children.length; i++) {
|
|
485
|
+
const c = children[i];
|
|
486
|
+
const tgtX = isLeft ? c.x + offsetX + c.width : c.x + offsetX;
|
|
487
|
+
const tgtY = childYs[i];
|
|
488
|
+
edges.push({
|
|
489
|
+
sourceId: parentId,
|
|
490
|
+
targetId: c.node.id,
|
|
491
|
+
path: `M ${midX} ${tgtY} L ${tgtX} ${tgtY}`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { nodes, edges, width: totalWidth, height: totalHeight };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Split children into right and left groups, balancing by subtree height.
|
|
502
|
+
* Preserves source order on each side. Alternating assignment to the
|
|
503
|
+
* lighter side keeps both sides visually balanced.
|
|
504
|
+
*/
|
|
505
|
+
function balancedSplit(
|
|
506
|
+
children: MindmapNode[],
|
|
507
|
+
hideDescriptions: boolean
|
|
508
|
+
): { right: MindmapNode[]; left: MindmapNode[] } {
|
|
509
|
+
if (children.length <= 1) {
|
|
510
|
+
return { right: children, left: [] };
|
|
511
|
+
}
|
|
512
|
+
if (children.length === 2) {
|
|
513
|
+
return { right: [children[0]], left: [children[1]] };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Compute subtree heights for weighting
|
|
517
|
+
const weights = children.map((c, i) => ({
|
|
518
|
+
index: i,
|
|
519
|
+
node: c,
|
|
520
|
+
height: computeSubtreeHeight(c, 1, hideDescriptions),
|
|
521
|
+
}));
|
|
522
|
+
|
|
523
|
+
// Greedy assignment: iterate in source order, assign each to the lighter side
|
|
524
|
+
const right: MindmapNode[] = [];
|
|
525
|
+
const left: MindmapNode[] = [];
|
|
526
|
+
let rightWeight = 0;
|
|
527
|
+
let leftWeight = 0;
|
|
528
|
+
|
|
529
|
+
for (const w of weights) {
|
|
530
|
+
if (rightWeight <= leftWeight) {
|
|
531
|
+
right.push(w.node);
|
|
532
|
+
rightWeight += w.height;
|
|
533
|
+
} else {
|
|
534
|
+
left.push(w.node);
|
|
535
|
+
leftWeight += w.height;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return { right, left };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Offset all coordinates in an SVG path by (dx, dy) */
|
|
543
|
+
function offsetPath(path: string, dx: number, dy: number): string {
|
|
544
|
+
if (dx === 0 && dy === 0) return path;
|
|
545
|
+
return path.replace(
|
|
546
|
+
/([ML])\s*([\d.e+-]+)\s+([\d.e+-]+)/g,
|
|
547
|
+
(_, cmd, x, y) => `${cmd} ${parseFloat(x) + dx} ${parseFloat(y) + dy}`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ============================================================
|
|
552
|
+
// Node sizing
|
|
553
|
+
// ============================================================
|
|
554
|
+
|
|
555
|
+
function nodeWidth(depth: number): number {
|
|
556
|
+
if (depth === 0) return ROOT_WIDTH;
|
|
557
|
+
if (depth === 1) return DEPTH1_WIDTH;
|
|
558
|
+
return LEAF_WIDTH;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function nodeHeight(node: MindmapNode, hideDescriptions: boolean): number {
|
|
562
|
+
const depth = getNodeDepth(node);
|
|
563
|
+
const w = nodeWidth(depth);
|
|
564
|
+
const text = computeNodeText(
|
|
565
|
+
node.label,
|
|
566
|
+
node.description,
|
|
567
|
+
depth,
|
|
568
|
+
w,
|
|
569
|
+
hideDescriptions
|
|
570
|
+
);
|
|
571
|
+
const labelLineCount = text.labelLines.length;
|
|
572
|
+
const labelH =
|
|
573
|
+
labelLineCount <= 1
|
|
574
|
+
? SINGLE_LABEL_HEIGHT
|
|
575
|
+
: LABEL_LINE_HEIGHT * labelLineCount;
|
|
576
|
+
let h = labelH + NODE_V_PAD;
|
|
577
|
+
if (text.descLines.length > 0) {
|
|
578
|
+
h += DESC_LINE_HEIGHT * text.descLines.length + 4; // 4px separator gap
|
|
579
|
+
}
|
|
580
|
+
return h;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Walk parentId chain to compute depth. Cached via roots traversal isn't needed — trees are small. */
|
|
584
|
+
function getNodeDepth(node: MindmapNode): number {
|
|
585
|
+
// The node structure doesn't carry depth directly, but we can
|
|
586
|
+
// infer from the layout context. For nodeHeight we need depth
|
|
587
|
+
// for font sizing. Use a simple heuristic: walk up parentId.
|
|
588
|
+
// Since MindmapNode doesn't have a parent reference (only parentId),
|
|
589
|
+
// and we don't have the node map here, we use a depth cache.
|
|
590
|
+
return nodeDepthCache.get(node.id) ?? 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const nodeDepthCache = new Map<string, number>();
|
|
594
|
+
|
|
595
|
+
/** Populate depth cache by walking the tree. Call before layout. */
|
|
596
|
+
function populateDepthCache(roots: MindmapNode[]): void {
|
|
597
|
+
nodeDepthCache.clear();
|
|
598
|
+
const walk = (nodes: MindmapNode[], depth: number) => {
|
|
599
|
+
for (const n of nodes) {
|
|
600
|
+
nodeDepthCache.set(n.id, depth);
|
|
601
|
+
walk(n.children, depth + 1);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
walk(roots, 0);
|
|
605
|
+
}
|