@diagrammo/dgmo 0.8.21 → 0.8.23
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 +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -6
- 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/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- 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 +11 -1
- package/src/boxes-and-lines/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -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/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- 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 +1112 -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/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +47 -7
- package/src/utils/tag-groups.ts +46 -60
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Cycle Diagram — Layout Engine
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_EDGE_WIDTH,
|
|
7
|
+
MIN_EDGE_WIDTH,
|
|
8
|
+
arrowHeadLength,
|
|
9
|
+
type ParsedCycle,
|
|
10
|
+
type CycleLayoutNode,
|
|
11
|
+
type CycleLayoutEdge,
|
|
12
|
+
type CycleLayoutResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
/** Minimum arc angle in radians (~15°) to keep arcs readable. */
|
|
16
|
+
const MIN_ARC_ANGLE = (15 * Math.PI) / 180;
|
|
17
|
+
|
|
18
|
+
/** Estimated character width at 13px label font. */
|
|
19
|
+
const LABEL_CHAR_W = 8;
|
|
20
|
+
|
|
21
|
+
/** Estimated character width at 16px circle label font. */
|
|
22
|
+
const CIRCLE_LABEL_CHAR_W = 10;
|
|
23
|
+
|
|
24
|
+
/** Estimated character width at 11px description font. */
|
|
25
|
+
const DESC_CHAR_W = 6.5;
|
|
26
|
+
|
|
27
|
+
/** Minimum node width. */
|
|
28
|
+
const MIN_NODE_WIDTH = 70;
|
|
29
|
+
|
|
30
|
+
/** Maximum node width. */
|
|
31
|
+
const MAX_NODE_WIDTH = 180;
|
|
32
|
+
|
|
33
|
+
/** Node height for label-only nodes. */
|
|
34
|
+
const PLAIN_NODE_HEIGHT = 50;
|
|
35
|
+
|
|
36
|
+
/** Header height (label zone) in described nodes. */
|
|
37
|
+
const HEADER_HEIGHT = 36;
|
|
38
|
+
|
|
39
|
+
/** Extra height per wrapped description line. */
|
|
40
|
+
const DESC_LINE_HEIGHT = 16;
|
|
41
|
+
|
|
42
|
+
/** Vertical padding around description zone. */
|
|
43
|
+
const DESC_PAD_Y = 14;
|
|
44
|
+
|
|
45
|
+
/** Horizontal padding inside node for text. */
|
|
46
|
+
const NODE_PAD_X = 20;
|
|
47
|
+
|
|
48
|
+
/** Minimum circle-node radius. */
|
|
49
|
+
const MIN_CIRCLE_RADIUS = 35;
|
|
50
|
+
|
|
51
|
+
/** Padding inside circle for text. */
|
|
52
|
+
const CIRCLE_PAD = 14;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute cycle diagram layout: positions nodes equidistant (or span-weighted)
|
|
56
|
+
* on a circle, and generates curved edge paths between consecutive nodes.
|
|
57
|
+
*/
|
|
58
|
+
export function computeCycleLayout(
|
|
59
|
+
parsed: ParsedCycle,
|
|
60
|
+
options?: { width?: number; height?: number; hideDescriptions?: boolean }
|
|
61
|
+
): CycleLayoutResult {
|
|
62
|
+
const width = options?.width ?? 800;
|
|
63
|
+
const height = options?.height ?? 600;
|
|
64
|
+
const hideDescriptions = options?.hideDescriptions ?? false;
|
|
65
|
+
|
|
66
|
+
if (parsed.nodes.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
nodes: [],
|
|
69
|
+
edges: [],
|
|
70
|
+
cx: width / 2,
|
|
71
|
+
cy: height / 2,
|
|
72
|
+
radius: 0,
|
|
73
|
+
width,
|
|
74
|
+
height,
|
|
75
|
+
scale: 1,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const nodeCount = parsed.nodes.length;
|
|
80
|
+
const cx = width / 2;
|
|
81
|
+
const cy = height / 2;
|
|
82
|
+
const circleNodes = parsed.options['circle-nodes'] === 'true';
|
|
83
|
+
|
|
84
|
+
// ── Compute node dimensions with word wrapping ──
|
|
85
|
+
const nodeDims = parsed.nodes.map((node) => {
|
|
86
|
+
const hasDesc = !hideDescriptions && node.description.length > 0;
|
|
87
|
+
const labelWidth = Math.max(
|
|
88
|
+
MIN_NODE_WIDTH,
|
|
89
|
+
node.label.length * LABEL_CHAR_W + NODE_PAD_X * 2
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (circleNodes) {
|
|
93
|
+
return computeCircleNodeDims(node, hasDesc);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!hasDesc) {
|
|
97
|
+
return {
|
|
98
|
+
width: Math.min(MAX_NODE_WIDTH, labelWidth),
|
|
99
|
+
height: PLAIN_NODE_HEIGHT,
|
|
100
|
+
wrappedDesc: [] as string[],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Determine node width: fit the label and a reasonable description width
|
|
105
|
+
const nodeWidth = Math.min(MAX_NODE_WIDTH, Math.max(labelWidth, 150));
|
|
106
|
+
const textWidth = nodeWidth - NODE_PAD_X * 2;
|
|
107
|
+
const charsPerLine = Math.max(10, Math.floor(textWidth / DESC_CHAR_W));
|
|
108
|
+
|
|
109
|
+
const wrappedDesc = wrapLines(node.description, charsPerLine);
|
|
110
|
+
|
|
111
|
+
const descHeight =
|
|
112
|
+
HEADER_HEIGHT + wrappedDesc.length * DESC_LINE_HEIGHT + DESC_PAD_Y;
|
|
113
|
+
return { width: nodeWidth, height: descHeight, wrappedDesc };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Uniform circle sizing: all circles match the largest ──
|
|
117
|
+
if (circleNodes) {
|
|
118
|
+
const maxDiam = Math.max(...nodeDims.map((d) => d.width));
|
|
119
|
+
for (const d of nodeDims) {
|
|
120
|
+
d.width = maxDiam;
|
|
121
|
+
d.height = maxDiam;
|
|
122
|
+
// Re-wrap descriptions to fit the larger circle
|
|
123
|
+
const nodeIdx = nodeDims.indexOf(d);
|
|
124
|
+
const node = parsed.nodes[nodeIdx];
|
|
125
|
+
const hasDesc = !hideDescriptions && node.description.length > 0;
|
|
126
|
+
if (hasDesc) {
|
|
127
|
+
d.wrappedDesc = wrapLinesForCircle(node.description, maxDiam / 2);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Compute angles using span weights ──
|
|
133
|
+
const totalSpan = parsed.nodes.reduce((sum, n) => sum + n.span, 0);
|
|
134
|
+
|
|
135
|
+
// Clamp: ensure no arc angle falls below MIN_ARC_ANGLE
|
|
136
|
+
let rawAngles = parsed.nodes.map((n) => (n.span / totalSpan) * 2 * Math.PI);
|
|
137
|
+
const tooSmall = rawAngles.filter((a) => a < MIN_ARC_ANGLE);
|
|
138
|
+
if (tooSmall.length > 0 && tooSmall.length < nodeCount) {
|
|
139
|
+
const deficit = tooSmall.reduce((sum, a) => sum + (MIN_ARC_ANGLE - a), 0);
|
|
140
|
+
const largeTotal = rawAngles
|
|
141
|
+
.filter((a) => a >= MIN_ARC_ANGLE)
|
|
142
|
+
.reduce((sum, a) => sum + a, 0);
|
|
143
|
+
|
|
144
|
+
rawAngles = rawAngles.map((a) => {
|
|
145
|
+
if (a < MIN_ARC_ANGLE) return MIN_ARC_ANGLE;
|
|
146
|
+
return a - (a / largeTotal) * deficit;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Compute radius that prevents node overlap ──
|
|
151
|
+
const isClockwise = parsed.direction === 'clockwise';
|
|
152
|
+
|
|
153
|
+
// For each pair of adjacent nodes, compute the minimum radius so they
|
|
154
|
+
// don't overlap: the chord between adjacent centers must be >= sum of
|
|
155
|
+
// their half-diagonals + a gap.
|
|
156
|
+
const GAP = 20;
|
|
157
|
+
let minRadiusForNodes = 0;
|
|
158
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
159
|
+
const j = (i + 1) % nodeCount;
|
|
160
|
+
const diA = Math.sqrt(nodeDims[i].width ** 2 + nodeDims[i].height ** 2) / 2;
|
|
161
|
+
const diB = Math.sqrt(nodeDims[j].width ** 2 + nodeDims[j].height ** 2) / 2;
|
|
162
|
+
const neededChord = diA + diB + GAP;
|
|
163
|
+
// chord = 2 * r * sin(angle/2) → r = chord / (2 * sin(angle/2))
|
|
164
|
+
const halfAngle = rawAngles[i] / 2;
|
|
165
|
+
if (halfAngle > 0.001) {
|
|
166
|
+
const r = neededChord / (2 * Math.sin(halfAngle));
|
|
167
|
+
minRadiusForNodes = Math.max(minRadiusForNodes, r);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Max radius that fits in the canvas (leave room for the largest node)
|
|
172
|
+
const maxNodeHalf = Math.max(
|
|
173
|
+
...nodeDims.map((d) => Math.max(d.width, d.height) / 2)
|
|
174
|
+
);
|
|
175
|
+
const maxRadius = Math.min(cx, cy) - maxNodeHalf - 10;
|
|
176
|
+
|
|
177
|
+
let radius: number;
|
|
178
|
+
let scale = 1;
|
|
179
|
+
|
|
180
|
+
if (minRadiusForNodes <= maxRadius) {
|
|
181
|
+
// Fill the available canvas — use maxRadius so the diagram scales up
|
|
182
|
+
radius = Math.max(100, maxRadius);
|
|
183
|
+
} else {
|
|
184
|
+
// Nodes are too big to fit without overlap — shrink them
|
|
185
|
+
radius = Math.max(80, maxRadius);
|
|
186
|
+
scale = radius / minRadiusForNodes;
|
|
187
|
+
// Scale down all node dimensions
|
|
188
|
+
for (const d of nodeDims) {
|
|
189
|
+
d.width = Math.max(50, d.width * scale);
|
|
190
|
+
d.height = Math.max(30, d.height * scale);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Compute angular footprints for uniform edge-gap spacing ──
|
|
195
|
+
// Iteratively refine: estimate footprints at current positions, redistribute
|
|
196
|
+
// with uniform gaps, then recompute footprints at the new positions. Converges
|
|
197
|
+
// in 2-3 iterations so positions and footprints are self-consistent.
|
|
198
|
+
const nodeAngles = new Array(nodeCount);
|
|
199
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
200
|
+
nodeAngles[i] =
|
|
201
|
+
-Math.PI / 2 + i * ((2 * Math.PI) / nodeCount) * (isClockwise ? 1 : -1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ITERATIONS = 3;
|
|
205
|
+
const footprints = new Array(nodeCount);
|
|
206
|
+
|
|
207
|
+
for (let iter = 0; iter < ITERATIONS; iter++) {
|
|
208
|
+
// Compute footprints at current estimated positions
|
|
209
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
210
|
+
const theta = nodeAngles[i];
|
|
211
|
+
const approxX = cx + radius * Math.cos(theta);
|
|
212
|
+
const approxY = cy + radius * Math.sin(theta);
|
|
213
|
+
const hw = nodeDims[i].width / 2;
|
|
214
|
+
const hh = nodeDims[i].height / 2;
|
|
215
|
+
let exitCW: number, exitCCW: number;
|
|
216
|
+
if (circleNodes) {
|
|
217
|
+
const nodeR = hw; // width === height for circles
|
|
218
|
+
exitCW = circleNodeExitAngle(nodeR, radius, theta, 1);
|
|
219
|
+
exitCCW = circleNodeExitAngle(nodeR, radius, theta, -1);
|
|
220
|
+
} else {
|
|
221
|
+
exitCW = circleRectExitAngle(
|
|
222
|
+
approxX,
|
|
223
|
+
approxY,
|
|
224
|
+
hw,
|
|
225
|
+
hh,
|
|
226
|
+
cx,
|
|
227
|
+
cy,
|
|
228
|
+
radius,
|
|
229
|
+
theta,
|
|
230
|
+
1
|
|
231
|
+
);
|
|
232
|
+
exitCCW = circleRectExitAngle(
|
|
233
|
+
approxX,
|
|
234
|
+
approxY,
|
|
235
|
+
hw,
|
|
236
|
+
hh,
|
|
237
|
+
cx,
|
|
238
|
+
cy,
|
|
239
|
+
radius,
|
|
240
|
+
theta,
|
|
241
|
+
-1
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
footprints[i] = Math.abs(exitCW - exitCCW);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Distribute remaining arc as gaps (weighted by span)
|
|
248
|
+
const totalFootprint = footprints.reduce(
|
|
249
|
+
(s: number, f: number) => s + f,
|
|
250
|
+
0
|
|
251
|
+
);
|
|
252
|
+
const totalGapAngle = Math.max(0, 2 * Math.PI - totalFootprint);
|
|
253
|
+
const gapAngles = parsed.nodes.map(
|
|
254
|
+
(n) => (n.span / totalSpan) * totalGapAngle
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Reposition nodes
|
|
258
|
+
let cumAngle = -Math.PI / 2;
|
|
259
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
260
|
+
nodeAngles[i] = cumAngle;
|
|
261
|
+
const nextIdx = (i + 1) % nodeCount;
|
|
262
|
+
const advance =
|
|
263
|
+
footprints[i] / 2 + gapAngles[i] + footprints[nextIdx] / 2;
|
|
264
|
+
cumAngle += isClockwise ? advance : -advance;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Build layout nodes at converged positions ──
|
|
269
|
+
const layoutNodes: CycleLayoutNode[] = [];
|
|
270
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
271
|
+
const angle = nodeAngles[i];
|
|
272
|
+
layoutNodes.push({
|
|
273
|
+
label: parsed.nodes[i].label,
|
|
274
|
+
x: cx + radius * Math.cos(angle),
|
|
275
|
+
y: cy + radius * Math.sin(angle),
|
|
276
|
+
angle,
|
|
277
|
+
width: nodeDims[i].width,
|
|
278
|
+
height: nodeDims[i].height,
|
|
279
|
+
wrappedDesc: nodeDims[i].wrappedDesc,
|
|
280
|
+
isCircle: circleNodes,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Compute edge paths ──
|
|
285
|
+
let layoutEdges = computeEdgePaths(
|
|
286
|
+
layoutNodes,
|
|
287
|
+
parsed,
|
|
288
|
+
cx,
|
|
289
|
+
cy,
|
|
290
|
+
radius,
|
|
291
|
+
isClockwise
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// ── Fit-to-canvas: shrink if edge labels overflow ──
|
|
295
|
+
const fitResult = fitToCanvas(
|
|
296
|
+
layoutNodes,
|
|
297
|
+
layoutEdges,
|
|
298
|
+
parsed,
|
|
299
|
+
cx,
|
|
300
|
+
cy,
|
|
301
|
+
radius,
|
|
302
|
+
width,
|
|
303
|
+
height,
|
|
304
|
+
isClockwise
|
|
305
|
+
);
|
|
306
|
+
if (fitResult) {
|
|
307
|
+
radius = fitResult.radius;
|
|
308
|
+
// Reposition nodes on the smaller circle
|
|
309
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
310
|
+
layoutNodes[i].x = cx + radius * Math.cos(nodeAngles[i]);
|
|
311
|
+
layoutNodes[i].y = cy + radius * Math.sin(nodeAngles[i]);
|
|
312
|
+
}
|
|
313
|
+
layoutEdges = computeEdgePaths(
|
|
314
|
+
layoutNodes,
|
|
315
|
+
parsed,
|
|
316
|
+
cx,
|
|
317
|
+
cy,
|
|
318
|
+
radius,
|
|
319
|
+
isClockwise
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
nodes: layoutNodes,
|
|
325
|
+
edges: layoutEdges,
|
|
326
|
+
cx,
|
|
327
|
+
cy,
|
|
328
|
+
radius,
|
|
329
|
+
width,
|
|
330
|
+
height,
|
|
331
|
+
scale,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Helper: word-wrap lines ──
|
|
336
|
+
|
|
337
|
+
function wrapLines(lines: string[], charsPerLine: number): string[] {
|
|
338
|
+
const result: string[] = [];
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
const words = line.split(/\s+/);
|
|
341
|
+
let current = '';
|
|
342
|
+
for (const word of words) {
|
|
343
|
+
const test = current ? `${current} ${word}` : word;
|
|
344
|
+
if (test.length > charsPerLine && current) {
|
|
345
|
+
result.push(current);
|
|
346
|
+
current = word;
|
|
347
|
+
} else {
|
|
348
|
+
current = test;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (current) result.push(current);
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Helper: circle node dimensions ──
|
|
357
|
+
|
|
358
|
+
function computeCircleNodeDims(
|
|
359
|
+
node: { label: string; description: string[] },
|
|
360
|
+
hasDesc: boolean
|
|
361
|
+
): { width: number; height: number; wrappedDesc: string[] } {
|
|
362
|
+
if (!hasDesc) {
|
|
363
|
+
// Label-only circle: radius fits the larger label text
|
|
364
|
+
const textW = node.label.length * CIRCLE_LABEL_CHAR_W;
|
|
365
|
+
const r = Math.max(MIN_CIRCLE_RADIUS, textW / 2 + CIRCLE_PAD);
|
|
366
|
+
return { width: r * 2, height: r * 2, wrappedDesc: [] };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// With descriptions: iteratively find a circle radius that fits the text.
|
|
370
|
+
// Start with a reasonable guess and grow until all text fits.
|
|
371
|
+
let r = MIN_CIRCLE_RADIUS;
|
|
372
|
+
|
|
373
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
374
|
+
const wrappedDesc = wrapLinesForCircle(node.description, r);
|
|
375
|
+
const totalLines = 1 + wrappedDesc.length; // label + desc lines
|
|
376
|
+
const textBlockH = totalLines * DESC_LINE_HEIGHT + CIRCLE_PAD;
|
|
377
|
+
|
|
378
|
+
// Check if text fits vertically within the circle
|
|
379
|
+
if (textBlockH / 2 <= r * 0.85) {
|
|
380
|
+
// Also check the label fits horizontally at its y-position (larger font)
|
|
381
|
+
const labelW = node.label.length * CIRCLE_LABEL_CHAR_W;
|
|
382
|
+
const labelY = -textBlockH / 2 + DESC_LINE_HEIGHT; // relative to center
|
|
383
|
+
const availW = 2 * Math.sqrt(Math.max(0, r * r - labelY * labelY));
|
|
384
|
+
if (labelW <= availW - CIRCLE_PAD) {
|
|
385
|
+
return { width: r * 2, height: r * 2, wrappedDesc };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
r += 10;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const wrappedDesc = wrapLinesForCircle(node.description, r);
|
|
392
|
+
return { width: r * 2, height: r * 2, wrappedDesc };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Wrap description lines to fit inside a circle of given radius.
|
|
397
|
+
* Each line gets a different max width based on its vertical position
|
|
398
|
+
* within the circle — wider at the center, narrower near edges.
|
|
399
|
+
*/
|
|
400
|
+
function wrapLinesForCircle(descriptions: string[], radius: number): string[] {
|
|
401
|
+
// First pass: wrap with center-width to estimate line count
|
|
402
|
+
const centerWidth = radius * 2 * 0.75;
|
|
403
|
+
const centerChars = Math.max(8, Math.floor(centerWidth / DESC_CHAR_W));
|
|
404
|
+
const roughWrapped = wrapLines(descriptions, centerChars);
|
|
405
|
+
const totalLines = 1 + roughWrapped.length; // +1 for label line
|
|
406
|
+
const blockH = totalLines * DESC_LINE_HEIGHT;
|
|
407
|
+
|
|
408
|
+
// Second pass: re-wrap each source line with position-aware width
|
|
409
|
+
const result: string[] = [];
|
|
410
|
+
let lineIdx = 1; // start after label line
|
|
411
|
+
for (const srcLine of descriptions) {
|
|
412
|
+
const words = srcLine.split(/\s+/);
|
|
413
|
+
let current = '';
|
|
414
|
+
for (const word of words) {
|
|
415
|
+
// Compute available width at this line's y position
|
|
416
|
+
const y = -blockH / 2 + (lineIdx + 0.5) * DESC_LINE_HEIGHT;
|
|
417
|
+
const rSq = radius * radius;
|
|
418
|
+
const availPx =
|
|
419
|
+
y * y < rSq ? 2 * Math.sqrt(rSq - y * y) - CIRCLE_PAD * 2 : centerWidth;
|
|
420
|
+
const maxChars = Math.max(6, Math.floor(availPx / DESC_CHAR_W));
|
|
421
|
+
|
|
422
|
+
const test = current ? `${current} ${word}` : word;
|
|
423
|
+
if (test.length > maxChars && current) {
|
|
424
|
+
result.push(current);
|
|
425
|
+
lineIdx++;
|
|
426
|
+
current = word;
|
|
427
|
+
} else {
|
|
428
|
+
current = test;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (current) {
|
|
432
|
+
result.push(current);
|
|
433
|
+
lineIdx++;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Circle-circle intersection exit angle ──
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* For a circular node of radius `nodeR` centered on the cycle circle of radius
|
|
443
|
+
* `cycleR`, compute the angle where the cycle circle exits the node boundary.
|
|
444
|
+
* Uses the law of cosines: the half-angle subtended at the cycle center is
|
|
445
|
+
* arccos(1 - nodeR²/(2·cycleR²)).
|
|
446
|
+
*/
|
|
447
|
+
function circleNodeExitAngle(
|
|
448
|
+
nodeR: number,
|
|
449
|
+
cycleR: number,
|
|
450
|
+
nodeAngle: number,
|
|
451
|
+
direction: number
|
|
452
|
+
): number {
|
|
453
|
+
// Law of cosines in the triangle: cycle-center, node-center, intersection-point
|
|
454
|
+
// sides: R (to intersection), R (to node center), nodeR (node center to intersection)
|
|
455
|
+
// cos(α) = (R² + R² - nodeR²) / (2·R·R) = 1 - nodeR²/(2·R²)
|
|
456
|
+
const cosAlpha = Math.max(
|
|
457
|
+
-1,
|
|
458
|
+
Math.min(1, 1 - (nodeR * nodeR) / (2 * cycleR * cycleR))
|
|
459
|
+
);
|
|
460
|
+
const halfAngle = Math.acos(cosAlpha);
|
|
461
|
+
return nodeAngle + direction * halfAngle;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Is the point (px, py) inside the rect centered at (rx, ry) with half-dims (hw, hh)? */
|
|
465
|
+
function insideRect(
|
|
466
|
+
px: number,
|
|
467
|
+
py: number,
|
|
468
|
+
rx: number,
|
|
469
|
+
ry: number,
|
|
470
|
+
hw: number,
|
|
471
|
+
hh: number
|
|
472
|
+
): boolean {
|
|
473
|
+
return Math.abs(px - rx) < hw && Math.abs(py - ry) < hh;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Find the exact angle where the circle exits the node rect boundary.
|
|
478
|
+
* Uses coarse walk + binary search refinement for pixel-accurate results.
|
|
479
|
+
*/
|
|
480
|
+
function circleRectExitAngle(
|
|
481
|
+
nodeCx: number,
|
|
482
|
+
nodeCy: number,
|
|
483
|
+
halfW: number,
|
|
484
|
+
halfH: number,
|
|
485
|
+
circleCx: number,
|
|
486
|
+
circleCy: number,
|
|
487
|
+
radius: number,
|
|
488
|
+
nodeAngle: number,
|
|
489
|
+
direction: number // +1 or -1
|
|
490
|
+
): number {
|
|
491
|
+
// Coarse walk to find the first angle outside the rect
|
|
492
|
+
const steps = 90;
|
|
493
|
+
const maxSweep = Math.PI;
|
|
494
|
+
const step = maxSweep / steps;
|
|
495
|
+
let insideAngle = nodeAngle;
|
|
496
|
+
let outsideAngle = nodeAngle + direction * step;
|
|
497
|
+
|
|
498
|
+
for (let i = 1; i <= steps; i++) {
|
|
499
|
+
const angle = nodeAngle + direction * step * i;
|
|
500
|
+
const px = circleCx + radius * Math.cos(angle);
|
|
501
|
+
const py = circleCy + radius * Math.sin(angle);
|
|
502
|
+
if (!insideRect(px, py, nodeCx, nodeCy, halfW, halfH)) {
|
|
503
|
+
outsideAngle = angle;
|
|
504
|
+
insideAngle = nodeAngle + direction * step * (i - 1);
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Binary search between insideAngle and outsideAngle for exact boundary
|
|
510
|
+
for (let i = 0; i < 16; i++) {
|
|
511
|
+
const mid = (insideAngle + outsideAngle) / 2;
|
|
512
|
+
const px = circleCx + radius * Math.cos(mid);
|
|
513
|
+
const py = circleCy + radius * Math.sin(mid);
|
|
514
|
+
if (insideRect(px, py, nodeCx, nodeCy, halfW, halfH)) {
|
|
515
|
+
insideAngle = mid;
|
|
516
|
+
} else {
|
|
517
|
+
outsideAngle = mid;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Return the boundary crossing point (midpoint of final bracket)
|
|
522
|
+
return (insideAngle + outsideAngle) / 2;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Compute edge paths for all edges in the parsed diagram. */
|
|
526
|
+
function computeEdgePaths(
|
|
527
|
+
layoutNodes: CycleLayoutNode[],
|
|
528
|
+
parsed: ParsedCycle,
|
|
529
|
+
cx: number,
|
|
530
|
+
cy: number,
|
|
531
|
+
radius: number,
|
|
532
|
+
isClockwise: boolean
|
|
533
|
+
): CycleLayoutEdge[] {
|
|
534
|
+
return parsed.edges.map((edge) => {
|
|
535
|
+
const src = layoutNodes[edge.sourceIndex];
|
|
536
|
+
const tgt = layoutNodes[edge.targetIndex];
|
|
537
|
+
const strokeWidth = Math.max(
|
|
538
|
+
edge.width ?? DEFAULT_EDGE_WIDTH,
|
|
539
|
+
MIN_EDGE_WIDTH
|
|
540
|
+
);
|
|
541
|
+
// Arrowhead effective reach: full length minus the 10% overlap that
|
|
542
|
+
// slides the marker back to cover the stroke/arrowhead junction line.
|
|
543
|
+
const arrowLen = arrowHeadLength(strokeWidth) * 0.9;
|
|
544
|
+
const { path, labelX, labelY, labelAngle } = buildEdgeArc(
|
|
545
|
+
src,
|
|
546
|
+
tgt,
|
|
547
|
+
cx,
|
|
548
|
+
cy,
|
|
549
|
+
radius,
|
|
550
|
+
isClockwise,
|
|
551
|
+
arrowLen
|
|
552
|
+
);
|
|
553
|
+
return {
|
|
554
|
+
sourceIndex: edge.sourceIndex,
|
|
555
|
+
targetIndex: edge.targetIndex,
|
|
556
|
+
path,
|
|
557
|
+
labelX,
|
|
558
|
+
labelY,
|
|
559
|
+
labelAngle,
|
|
560
|
+
label: edge.label,
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Estimated character width at 11px edge label font. */
|
|
566
|
+
const EDGE_LABEL_CHAR_W = 7;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Check if edge labels overflow the canvas and return a reduced radius if needed.
|
|
570
|
+
* Returns null if everything fits.
|
|
571
|
+
*/
|
|
572
|
+
function fitToCanvas(
|
|
573
|
+
nodes: CycleLayoutNode[],
|
|
574
|
+
edges: CycleLayoutEdge[],
|
|
575
|
+
parsed: ParsedCycle,
|
|
576
|
+
cx: number,
|
|
577
|
+
cy: number,
|
|
578
|
+
radius: number,
|
|
579
|
+
width: number,
|
|
580
|
+
height: number,
|
|
581
|
+
_isClockwise: boolean
|
|
582
|
+
): { radius: number } | null {
|
|
583
|
+
const PADDING = 30;
|
|
584
|
+
let contentMinX = Infinity,
|
|
585
|
+
contentMaxX = -Infinity;
|
|
586
|
+
let contentMinY = Infinity,
|
|
587
|
+
contentMaxY = -Infinity;
|
|
588
|
+
|
|
589
|
+
// Node extents
|
|
590
|
+
for (const n of nodes) {
|
|
591
|
+
contentMinX = Math.min(contentMinX, n.x - n.width / 2);
|
|
592
|
+
contentMaxX = Math.max(contentMaxX, n.x + n.width / 2);
|
|
593
|
+
contentMinY = Math.min(contentMinY, n.y - n.height / 2);
|
|
594
|
+
contentMaxY = Math.max(contentMaxY, n.y + n.height / 2);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Edge label extents (estimate text width from character count)
|
|
598
|
+
for (let i = 0; i < edges.length; i++) {
|
|
599
|
+
const le = edges[i];
|
|
600
|
+
const edge = parsed.edges[i];
|
|
601
|
+
|
|
602
|
+
let maxLineLen = 0;
|
|
603
|
+
if (le.label) maxLineLen = Math.max(maxLineLen, le.label.length);
|
|
604
|
+
for (const desc of edge.description) {
|
|
605
|
+
maxLineLen = Math.max(maxLineLen, desc.length);
|
|
606
|
+
}
|
|
607
|
+
if (maxLineLen === 0) continue;
|
|
608
|
+
|
|
609
|
+
const textWidth = maxLineLen * EDGE_LABEL_CHAR_W;
|
|
610
|
+
|
|
611
|
+
// Determine text-anchor direction from label angle (mirrors renderer logic)
|
|
612
|
+
const normAngle =
|
|
613
|
+
((le.labelAngle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
614
|
+
const isRight = normAngle < Math.PI * 0.4 || normAngle > Math.PI * 1.6;
|
|
615
|
+
const isLeft = normAngle > Math.PI * 0.6 && normAngle < Math.PI * 1.4;
|
|
616
|
+
|
|
617
|
+
let labelLeft: number, labelRight: number;
|
|
618
|
+
if (isRight) {
|
|
619
|
+
labelLeft = le.labelX;
|
|
620
|
+
labelRight = le.labelX + textWidth;
|
|
621
|
+
} else if (isLeft) {
|
|
622
|
+
labelLeft = le.labelX - textWidth;
|
|
623
|
+
labelRight = le.labelX;
|
|
624
|
+
} else {
|
|
625
|
+
labelLeft = le.labelX - textWidth / 2;
|
|
626
|
+
labelRight = le.labelX + textWidth / 2;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
contentMinX = Math.min(contentMinX, labelLeft);
|
|
630
|
+
contentMaxX = Math.max(contentMaxX, labelRight);
|
|
631
|
+
|
|
632
|
+
// Vertical: rough estimate for multi-line labels
|
|
633
|
+
let lineCount = le.label ? 1 : 0;
|
|
634
|
+
lineCount += edge.description.length;
|
|
635
|
+
contentMinY = Math.min(contentMinY, le.labelY - 12);
|
|
636
|
+
contentMaxY = Math.max(contentMaxY, le.labelY + (lineCount - 1) * 15);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Check overflow
|
|
640
|
+
const overflowX =
|
|
641
|
+
Math.max(0, PADDING - contentMinX) +
|
|
642
|
+
Math.max(0, contentMaxX - (width - PADDING));
|
|
643
|
+
const overflowY =
|
|
644
|
+
Math.max(0, PADDING - contentMinY) +
|
|
645
|
+
Math.max(0, contentMaxY - (height - PADDING));
|
|
646
|
+
|
|
647
|
+
if (overflowX <= 0 && overflowY <= 0) return null;
|
|
648
|
+
|
|
649
|
+
// Shrink radius proportionally to eliminate overflow
|
|
650
|
+
const contentW = contentMaxX - contentMinX;
|
|
651
|
+
const contentH = contentMaxY - contentMinY;
|
|
652
|
+
const availW = width - 2 * PADDING;
|
|
653
|
+
const availH = height - 2 * PADDING;
|
|
654
|
+
const shrink = Math.min(availW / contentW, availH / contentH);
|
|
655
|
+
const newRadius = Math.max(80, radius * shrink);
|
|
656
|
+
|
|
657
|
+
return { radius: newRadius };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Build an SVG arc path that follows the circle circumference between two nodes.
|
|
662
|
+
* Uses SVG `A` (arc) command so the edge traces the actual circle, not a chord.
|
|
663
|
+
* Start/end points are computed as the exact intersection of the circle with
|
|
664
|
+
* each node's boundary (rect or circle) — no gaps.
|
|
665
|
+
*/
|
|
666
|
+
function buildEdgeArc(
|
|
667
|
+
src: CycleLayoutNode,
|
|
668
|
+
tgt: CycleLayoutNode,
|
|
669
|
+
cx: number,
|
|
670
|
+
cy: number,
|
|
671
|
+
radius: number,
|
|
672
|
+
isClockwise: boolean,
|
|
673
|
+
arrowLength: number = 0
|
|
674
|
+
): { path: string; labelX: number; labelY: number; labelAngle: number } {
|
|
675
|
+
const dir = isClockwise ? 1 : -1;
|
|
676
|
+
|
|
677
|
+
// Start arc from the source node's center angle — the node renders on top
|
|
678
|
+
// of the edge, so the overlap is hidden and there's no visible gap.
|
|
679
|
+
const startAngle = src.angle;
|
|
680
|
+
|
|
681
|
+
// Find where the cycle circle exits the target node
|
|
682
|
+
const nodeEndAngle = tgt.isCircle
|
|
683
|
+
? circleNodeExitAngle(tgt.width / 2, radius, tgt.angle, -dir)
|
|
684
|
+
: circleRectExitAngle(
|
|
685
|
+
tgt.x,
|
|
686
|
+
tgt.y,
|
|
687
|
+
tgt.width / 2,
|
|
688
|
+
tgt.height / 2,
|
|
689
|
+
cx,
|
|
690
|
+
cy,
|
|
691
|
+
radius,
|
|
692
|
+
tgt.angle,
|
|
693
|
+
-dir
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
// Pull back the path endpoint by the arrowhead length so the stroke
|
|
697
|
+
// stops at the arrow base (refX=0 means arrow extends forward from endpoint)
|
|
698
|
+
const arrowPullback = arrowLength > 0 ? arrowLength / radius : 0;
|
|
699
|
+
const endAngle = nodeEndAngle - dir * arrowPullback;
|
|
700
|
+
|
|
701
|
+
const startX = cx + radius * Math.cos(startAngle);
|
|
702
|
+
const startY = cy + radius * Math.sin(startAngle);
|
|
703
|
+
const endX = cx + radius * Math.cos(endAngle);
|
|
704
|
+
const endY = cy + radius * Math.sin(endAngle);
|
|
705
|
+
|
|
706
|
+
// Compute effective sweep for large-arc-flag
|
|
707
|
+
let effectiveSweep = (endAngle - startAngle) * dir;
|
|
708
|
+
if (effectiveSweep <= 0) effectiveSweep += 2 * Math.PI;
|
|
709
|
+
|
|
710
|
+
const largeArc = effectiveSweep > Math.PI ? 1 : 0;
|
|
711
|
+
const sweepFlag = isClockwise ? 1 : 0;
|
|
712
|
+
|
|
713
|
+
const path = `M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArc} ${sweepFlag} ${endX} ${endY}`;
|
|
714
|
+
|
|
715
|
+
// Label position: pushed outward from the arc midpoint
|
|
716
|
+
const midAngle = startAngle + (dir * effectiveSweep) / 2;
|
|
717
|
+
const LABEL_OUTWARD_OFFSET = 16;
|
|
718
|
+
const labelR = radius + LABEL_OUTWARD_OFFSET;
|
|
719
|
+
const labelX = cx + labelR * Math.cos(midAngle);
|
|
720
|
+
const labelY = cy + labelR * Math.sin(midAngle);
|
|
721
|
+
|
|
722
|
+
return { path, labelX, labelY, labelAngle: midAngle };
|
|
723
|
+
}
|