@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.
Files changed (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,566 @@
1
+ // ============================================================
2
+ // Cycle Diagram — D3 SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import { FONT_FAMILY } from '../fonts';
7
+ import {
8
+ TITLE_FONT_SIZE,
9
+ TITLE_FONT_WEIGHT,
10
+ TITLE_Y,
11
+ } from '../utils/title-constants';
12
+ import { LEGEND_HEIGHT } from '../utils/legend-constants';
13
+ import { renderLegendD3 } from '../utils/legend-d3';
14
+ import type {
15
+ LegendConfig,
16
+ LegendState,
17
+ LegendCallbacks,
18
+ ControlsGroupToggle,
19
+ } from '../utils/legend-types';
20
+ import { contrastText, mix } from '../palettes/color-utils';
21
+ import { resolveColor } from '../colors';
22
+ import { renderInlineText } from '../utils/inline-markdown';
23
+ import type { PaletteColors } from '../palettes';
24
+ import type { D3ExportDimensions } from '../utils/d3-types';
25
+ import type { CompactViewState } from '../sharing';
26
+ import {
27
+ DEFAULT_EDGE_WIDTH,
28
+ MIN_EDGE_WIDTH,
29
+ arrowHeadLength,
30
+ type ParsedCycle,
31
+ } from './types';
32
+ import { computeCycleLayout } from './layout';
33
+
34
+ // ── Constants ────────────────────────────────────────────────
35
+ const NODE_FONT_SIZE = 13;
36
+ const DESC_FONT_SIZE = 11;
37
+ const EDGE_LABEL_FONT_SIZE = 11;
38
+ const DESC_LINE_HEIGHT = 15;
39
+ const TITLE_AREA_HEIGHT = 50;
40
+
41
+ export interface CycleRenderOptions {
42
+ onClickItem?: (lineNumber: number) => void;
43
+ exportDims?: D3ExportDimensions;
44
+ viewState?: CompactViewState;
45
+ hideDescriptions?: boolean;
46
+ controlsExpanded?: boolean;
47
+ onToggleDescriptions?: (active: boolean) => void;
48
+ onToggleControlsExpand?: () => void;
49
+ }
50
+
51
+ /**
52
+ * Render a cycle diagram into the given container.
53
+ */
54
+ export function renderCycle(
55
+ container: HTMLDivElement,
56
+ parsed: ParsedCycle,
57
+ palette: PaletteColors,
58
+ isDark: boolean,
59
+ onClickItem?: (lineNumber: number) => void,
60
+ exportDims?: D3ExportDimensions,
61
+ viewState?: CompactViewState,
62
+ renderOptions?: CycleRenderOptions
63
+ ): void {
64
+ if (parsed.nodes.length === 0) return;
65
+
66
+ // Clear previous render
67
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
68
+ const width = exportDims?.width ?? container.clientWidth;
69
+ const height = exportDims?.height ?? container.clientHeight;
70
+ if (width <= 0 || height <= 0) return;
71
+
72
+ const hideDescriptions =
73
+ (renderOptions?.hideDescriptions ?? false) ||
74
+ parsed.options['hide-descriptions'] === 'true' ||
75
+ viewState?.hd === true;
76
+ const showDescriptions = !hideDescriptions;
77
+
78
+ // Check if descriptions exist in the diagram
79
+ const hasDescriptions =
80
+ parsed.nodes.some((n) => n.description.length > 0) ||
81
+ parsed.edges.some((e) => e.description.length > 0);
82
+ const hasLegend = hasDescriptions && !!renderOptions?.onToggleDescriptions;
83
+
84
+ // Layout
85
+ const legendOffset = hasLegend ? LEGEND_HEIGHT : 0;
86
+ const layoutHeight =
87
+ height - (parsed.title ? TITLE_AREA_HEIGHT : 0) - legendOffset;
88
+ const layout = computeCycleLayout(parsed, {
89
+ width,
90
+ height: layoutHeight,
91
+ hideDescriptions,
92
+ });
93
+
94
+ // Create SVG
95
+ const svg = d3Selection
96
+ .select(container)
97
+ .append('svg')
98
+ .attr('width', width)
99
+ .attr('height', height)
100
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
101
+ .style('font-family', FONT_FAMILY);
102
+
103
+ // Background
104
+ svg
105
+ .append('rect')
106
+ .attr('width', width)
107
+ .attr('height', height)
108
+ .attr('fill', palette.bg);
109
+
110
+ // Title
111
+ if (parsed.title) {
112
+ const titleText = svg
113
+ .append('text')
114
+ .attr('x', width / 2)
115
+ .attr('y', TITLE_Y)
116
+ .attr('text-anchor', 'middle')
117
+ .attr('fill', palette.text)
118
+ .attr('font-family', FONT_FAMILY)
119
+ .attr('font-size', TITLE_FONT_SIZE)
120
+ .attr('font-weight', TITLE_FONT_WEIGHT)
121
+ .attr('data-line-number', parsed.titleLineNumber)
122
+ .text(parsed.title)
123
+ .style('cursor', onClickItem ? 'pointer' : 'default');
124
+ if (onClickItem) {
125
+ titleText.on('click', () => onClickItem(parsed.titleLineNumber));
126
+ }
127
+ }
128
+
129
+ // Legend (controls toggle for descriptions)
130
+ if (hasLegend) {
131
+ const controlsGroup: { toggles: ControlsGroupToggle[] } = {
132
+ toggles: [
133
+ {
134
+ id: 'descriptions',
135
+ type: 'toggle',
136
+ label: 'Descriptions',
137
+ active: !hideDescriptions,
138
+ onToggle: () => {},
139
+ },
140
+ ],
141
+ };
142
+ const legendConfig: LegendConfig = {
143
+ groups: [],
144
+ position: { placement: 'top-center', titleRelation: 'below-title' },
145
+ mode: 'fixed',
146
+ controlsGroup,
147
+ };
148
+ const legendState: LegendState = {
149
+ activeGroup: null,
150
+ controlsExpanded: renderOptions?.controlsExpanded,
151
+ };
152
+ const legendCallbacks: LegendCallbacks = {
153
+ onControlsExpand: renderOptions?.onToggleControlsExpand,
154
+ onControlsToggle: (toggleId, active) => {
155
+ if (
156
+ toggleId === 'descriptions' &&
157
+ renderOptions?.onToggleDescriptions
158
+ ) {
159
+ renderOptions.onToggleDescriptions(active);
160
+ }
161
+ },
162
+ };
163
+ const titleOffset = parsed.title ? TITLE_AREA_HEIGHT : 0;
164
+ const legendG = svg
165
+ .append('g')
166
+ .attr('transform', `translate(0, ${titleOffset + 4})`);
167
+ renderLegendD3(
168
+ legendG,
169
+ legendConfig,
170
+ legendState,
171
+ palette,
172
+ isDark,
173
+ legendCallbacks,
174
+ width
175
+ );
176
+ }
177
+
178
+ // Main diagram group
179
+ const diagramTop = (parsed.title ? TITLE_AREA_HEIGHT : 0) + legendOffset;
180
+ const g = svg.append('g').attr('transform', `translate(0, ${diagramTop})`);
181
+
182
+ // Defs for arrowheads
183
+ const defs = svg.append('defs');
184
+
185
+ // Resolve default node color: first palette color (uniform)
186
+ const defaultNodeColor = palette.primary;
187
+
188
+ // ── Arrowhead markers (per color+width, markerUnits=strokeWidth) ──
189
+ const markerKeys = new Set<string>();
190
+ for (const edge of parsed.edges) {
191
+ const color = resolveEdgeColor(edge, parsed, palette, defaultNodeColor);
192
+ const sw = Math.max(edge.width ?? DEFAULT_EDGE_WIDTH, MIN_EDGE_WIDTH);
193
+ const key = `${color}|${sw}`;
194
+ if (!markerKeys.has(key)) {
195
+ markerKeys.add(key);
196
+ ensureArrowMarker(defs, color, sw);
197
+ }
198
+ }
199
+
200
+ // ── Render edges (below nodes) ──
201
+ for (let i = 0; i < layout.edges.length; i++) {
202
+ const le = layout.edges[i];
203
+ const edge = parsed.edges[i];
204
+ const color = resolveEdgeColor(edge, parsed, palette, defaultNodeColor);
205
+ const strokeWidth = Math.max(
206
+ edge.width ?? DEFAULT_EDGE_WIDTH,
207
+ MIN_EDGE_WIDTH
208
+ );
209
+ const markerId = arrowMarkerId(color, strokeWidth);
210
+
211
+ const edgeG = g.append('g').attr('class', 'cycle-edge');
212
+
213
+ if (edge.lineNumber) {
214
+ edgeG.attr('data-line-number', edge.lineNumber);
215
+ }
216
+
217
+ // Edge path
218
+ const pathEl = edgeG
219
+ .append('path')
220
+ .attr('d', le.path)
221
+ .attr('fill', 'none')
222
+ .attr('stroke', color)
223
+ .attr('stroke-width', strokeWidth)
224
+ .attr('marker-end', `url(#${markerId})`);
225
+
226
+ if (onClickItem && edge.lineNumber) {
227
+ const ln = edge.lineNumber;
228
+ pathEl.style('cursor', 'pointer').on('click', () => onClickItem(ln));
229
+ }
230
+
231
+ // Edge label + descriptions — positioned outside the circle
232
+ const hasEdgeLabel = !!le.label;
233
+ const hasEdgeDesc = showDescriptions && edge.description.length > 0;
234
+
235
+ if (hasEdgeLabel || hasEdgeDesc) {
236
+ // Determine text-anchor based on which side of the circle the label is on
237
+ const normAngle =
238
+ ((le.labelAngle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
239
+ const isRight = normAngle < Math.PI * 0.4 || normAngle > Math.PI * 1.6;
240
+ const isLeft = normAngle > Math.PI * 0.6 && normAngle < Math.PI * 1.4;
241
+ const anchor = isRight ? 'start' : isLeft ? 'end' : 'middle';
242
+
243
+ // Estimate text block dimensions for background
244
+ let lineCount = 0;
245
+ let maxCharLen = 0;
246
+ if (hasEdgeLabel) {
247
+ lineCount++;
248
+ maxCharLen = Math.max(maxCharLen, le.label!.length);
249
+ }
250
+ if (hasEdgeDesc) {
251
+ lineCount += edge.description.length;
252
+ for (const dl of edge.description) {
253
+ maxCharLen = Math.max(maxCharLen, dl.length);
254
+ }
255
+ }
256
+ const bgW = maxCharLen * 7 + 12; // estimated text width + padding
257
+ const bgH = lineCount * DESC_LINE_HEIGHT + 6;
258
+ const bgX = isRight
259
+ ? le.labelX - 4
260
+ : isLeft
261
+ ? le.labelX - bgW + 4
262
+ : le.labelX - bgW / 2;
263
+ const bgY = le.labelY - EDGE_LABEL_FONT_SIZE - 2;
264
+
265
+ // Background rect behind edge label text
266
+ edgeG
267
+ .append('rect')
268
+ .attr('x', bgX)
269
+ .attr('y', bgY)
270
+ .attr('width', bgW)
271
+ .attr('height', bgH)
272
+ .attr('rx', 3)
273
+ .attr('fill', palette.bg)
274
+ .attr('fill-opacity', 0.85);
275
+
276
+ let textY = le.labelY;
277
+
278
+ if (hasEdgeLabel) {
279
+ const labelText = edgeG
280
+ .append('text')
281
+ .attr('x', le.labelX)
282
+ .attr('y', textY)
283
+ .attr('text-anchor', anchor)
284
+ .attr('fill', palette.text)
285
+ .attr('font-family', FONT_FAMILY)
286
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
287
+ .attr('font-weight', '600');
288
+ renderInlineText(labelText, le.label!, palette, EDGE_LABEL_FONT_SIZE);
289
+ textY += DESC_LINE_HEIGHT;
290
+ }
291
+
292
+ if (hasEdgeDesc) {
293
+ edge.description.forEach((line) => {
294
+ const descText = edgeG
295
+ .append('text')
296
+ .attr('x', le.labelX)
297
+ .attr('y', textY)
298
+ .attr('text-anchor', anchor)
299
+ .attr('fill', palette.textMuted)
300
+ .attr('font-family', FONT_FAMILY)
301
+ .attr('font-size', DESC_FONT_SIZE);
302
+ renderInlineText(descText, line, palette, DESC_FONT_SIZE);
303
+ textY += DESC_LINE_HEIGHT;
304
+ });
305
+ }
306
+ }
307
+ }
308
+
309
+ // ── Render nodes ──
310
+ const HEADER_H = 36 * layout.scale;
311
+ const scaledNodeFont = Math.max(9, Math.round(NODE_FONT_SIZE * layout.scale));
312
+ const CIRCLE_LABEL_FONT_SIZE = 16;
313
+ const scaledCircleLabelFont = Math.max(
314
+ 11,
315
+ Math.round(CIRCLE_LABEL_FONT_SIZE * layout.scale)
316
+ );
317
+ const scaledDescFont = Math.max(8, Math.round(DESC_FONT_SIZE * layout.scale));
318
+ const scaledDescLineH = Math.max(
319
+ 11,
320
+ Math.round(DESC_LINE_HEIGHT * layout.scale)
321
+ );
322
+
323
+ for (let i = 0; i < layout.nodes.length; i++) {
324
+ const ln = layout.nodes[i];
325
+ const node = parsed.nodes[i];
326
+ const solidColor = resolveNodeColor(node.color, palette, defaultNodeColor);
327
+ // Muted fill (mix color with background), solid border
328
+ const fillColor = mix(
329
+ solidColor,
330
+ isDark ? palette.surface : palette.bg,
331
+ 30
332
+ );
333
+ const textColor = contrastText(fillColor, '#eceff4', '#2e3440');
334
+ const nodeW = ln.width;
335
+ const nodeH = ln.height;
336
+ const wrappedDesc = ln.wrappedDesc;
337
+ const hasDesc = showDescriptions && wrappedDesc.length > 0;
338
+
339
+ const nodeG = g
340
+ .append('g')
341
+ .attr('class', 'cycle-node')
342
+ .attr('data-line-number', node.lineNumber)
343
+ .style('cursor', onClickItem ? 'pointer' : 'default');
344
+
345
+ if (onClickItem) {
346
+ const lineNum = node.lineNumber;
347
+ nodeG.on('click', () => onClickItem(lineNum));
348
+ }
349
+
350
+ if (ln.isCircle) {
351
+ // ── Circle node shape ──
352
+ const r = nodeW / 2;
353
+ nodeG
354
+ .append('circle')
355
+ .attr('cx', ln.x)
356
+ .attr('cy', ln.y)
357
+ .attr('r', r)
358
+ .attr('fill', fillColor)
359
+ .attr('stroke', solidColor)
360
+ .attr('stroke-width', 2);
361
+
362
+ if (hasDesc) {
363
+ // Label + descriptions vertically centered in circle
364
+ const labelFont = scaledCircleLabelFont;
365
+ const blockH = labelFont + 4 + wrappedDesc.length * scaledDescLineH;
366
+ const startY = ln.y - blockH / 2 + labelFont;
367
+
368
+ const labelText = nodeG
369
+ .append('text')
370
+ .attr('x', ln.x)
371
+ .attr('y', startY)
372
+ .attr('text-anchor', 'middle')
373
+ .attr('fill', textColor)
374
+ .attr('font-family', FONT_FAMILY)
375
+ .attr('font-size', labelFont)
376
+ .attr('font-weight', '600');
377
+ renderInlineText(labelText, node.label, palette, labelFont);
378
+
379
+ let descY = startY + scaledDescLineH + 4;
380
+ wrappedDesc.forEach((line) => {
381
+ const descText = nodeG
382
+ .append('text')
383
+ .attr('x', ln.x)
384
+ .attr('y', descY)
385
+ .attr('text-anchor', 'middle')
386
+ .attr('fill', palette.textMuted)
387
+ .attr('font-family', FONT_FAMILY)
388
+ .attr('font-size', scaledDescFont);
389
+ renderInlineText(descText, line, palette, DESC_FONT_SIZE);
390
+ descY += scaledDescLineH;
391
+ });
392
+ } else {
393
+ // Label centered in circle
394
+ const labelFont = scaledCircleLabelFont;
395
+ const labelText = nodeG
396
+ .append('text')
397
+ .attr('x', ln.x)
398
+ .attr('y', ln.y + labelFont / 3)
399
+ .attr('text-anchor', 'middle')
400
+ .attr('fill', textColor)
401
+ .attr('font-family', FONT_FAMILY)
402
+ .attr('font-size', labelFont)
403
+ .attr('font-weight', '600');
404
+ renderInlineText(labelText, node.label, palette, labelFont);
405
+ }
406
+ } else {
407
+ // ── Rectangular node shape ──
408
+ const rx = 6;
409
+ nodeG
410
+ .append('rect')
411
+ .attr('x', ln.x - nodeW / 2)
412
+ .attr('y', ln.y - nodeH / 2)
413
+ .attr('width', nodeW)
414
+ .attr('height', nodeH)
415
+ .attr('rx', rx)
416
+ .attr('ry', rx)
417
+ .attr('fill', fillColor)
418
+ .attr('stroke', solidColor)
419
+ .attr('stroke-width', 2);
420
+
421
+ if (hasDesc) {
422
+ // ── Described node: header + separator + description ──
423
+ const headerCenterY = ln.y - nodeH / 2 + HEADER_H / 2;
424
+ const labelText = nodeG
425
+ .append('text')
426
+ .attr('x', ln.x)
427
+ .attr('y', headerCenterY + scaledNodeFont / 3)
428
+ .attr('text-anchor', 'middle')
429
+ .attr('fill', textColor)
430
+ .attr('font-family', FONT_FAMILY)
431
+ .attr('font-size', scaledNodeFont)
432
+ .attr('font-weight', '600');
433
+ renderInlineText(labelText, node.label, palette, scaledNodeFont);
434
+
435
+ const sepY = ln.y - nodeH / 2 + HEADER_H;
436
+ nodeG
437
+ .append('line')
438
+ .attr('x1', ln.x - nodeW / 2)
439
+ .attr('y1', sepY)
440
+ .attr('x2', ln.x + nodeW / 2)
441
+ .attr('y2', sepY)
442
+ .attr('stroke', solidColor)
443
+ .attr('stroke-opacity', 0.3)
444
+ .attr('stroke-width', 1);
445
+
446
+ const descStartY = sepY + 4 + scaledDescFont;
447
+ wrappedDesc.forEach((line, li) => {
448
+ const descText = nodeG
449
+ .append('text')
450
+ .attr('x', ln.x)
451
+ .attr('y', descStartY + li * scaledDescLineH)
452
+ .attr('text-anchor', 'middle')
453
+ .attr('fill', palette.textMuted)
454
+ .attr('font-family', FONT_FAMILY)
455
+ .attr('font-size', scaledDescFont);
456
+ renderInlineText(descText, line, palette, DESC_FONT_SIZE);
457
+ });
458
+ } else {
459
+ // ── Plain node: label centered ──
460
+ const labelText = nodeG
461
+ .append('text')
462
+ .attr('x', ln.x)
463
+ .attr('y', ln.y + scaledNodeFont / 3)
464
+ .attr('text-anchor', 'middle')
465
+ .attr('fill', textColor)
466
+ .attr('font-family', FONT_FAMILY)
467
+ .attr('font-size', scaledNodeFont)
468
+ .attr('font-weight', '600');
469
+ renderInlineText(labelText, node.label, palette, scaledNodeFont);
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Render for CLI/export (no click handlers).
477
+ */
478
+ export function renderCycleForExport(
479
+ container: HTMLDivElement,
480
+ parsed: ParsedCycle,
481
+ palette: PaletteColors,
482
+ isDark: boolean,
483
+ exportDims?: D3ExportDimensions,
484
+ viewState?: CompactViewState
485
+ ): void {
486
+ renderCycle(
487
+ container,
488
+ parsed,
489
+ palette,
490
+ isDark,
491
+ undefined,
492
+ exportDims,
493
+ viewState
494
+ );
495
+ }
496
+
497
+ // ── Helpers ──────────────────────────────────────────────────
498
+
499
+ function resolveNodeColor(
500
+ color: string | undefined,
501
+ palette: PaletteColors,
502
+ defaultColor: string
503
+ ): string {
504
+ if (!color) return defaultColor;
505
+ return resolveColor(color, palette) ?? defaultColor;
506
+ }
507
+
508
+ function resolveEdgeColor(
509
+ edge: ParsedCycle['edges'][0],
510
+ parsed: ParsedCycle,
511
+ palette: PaletteColors,
512
+ defaultNodeColor: string
513
+ ): string {
514
+ if (edge.color) {
515
+ return resolveColor(edge.color, palette) ?? defaultNodeColor;
516
+ }
517
+ // Inherit from source node
518
+ const sourceNode = parsed.nodes[edge.sourceIndex];
519
+ if (sourceNode?.color) {
520
+ return resolveColor(sourceNode.color, palette) ?? defaultNodeColor;
521
+ }
522
+ return defaultNodeColor;
523
+ }
524
+
525
+ /** Stable marker ID for a (color, strokeWidth) pair. */
526
+ function arrowMarkerId(color: string, strokeWidth: number): string {
527
+ return `cycle-arrow-${color.replace('#', '')}-w${strokeWidth}`;
528
+ }
529
+
530
+ /**
531
+ * Create an arrowhead marker using markerUnits="strokeWidth" (SVG default)
532
+ * with per-edge dimensions. The marker base automatically equals the stroke
533
+ * width — no gaps or lollipop effects. Marker dimensions are computed so
534
+ * the rendered arrowhead length follows a sublinear formula:
535
+ *
536
+ * rendered length = markerWidth × strokeWidth = arrowHeadLength(sw)
537
+ * → markerWidth = arrowHeadLength(sw) / sw
538
+ *
539
+ * The height is fixed at 1 strokeWidth unit so the base = stroke width.
540
+ */
541
+ function ensureArrowMarker(
542
+ defs: d3Selection.Selection<SVGDefsElement, unknown, null, undefined>,
543
+ color: string,
544
+ strokeWidth: number
545
+ ): void {
546
+ const id = arrowMarkerId(color, strokeWidth);
547
+ // Marker dimensions in strokeWidth units.
548
+ // Rendered size = mw × sw (length) and mh × sw (height).
549
+ const mw = arrowHeadLength(strokeWidth) / strokeWidth;
550
+ // Height proportional to length (½ ratio) but at least 1.5× stroke width
551
+ // so the arrowhead is always visibly wider than the stroke.
552
+ const mh = Math.max(1.5, mw * 0.5);
553
+
554
+ defs
555
+ .append('marker')
556
+ .attr('id', id)
557
+ .attr('viewBox', `0 0 ${mw} ${mh}`)
558
+ .attr('refX', mw * 0.1)
559
+ .attr('refY', mh / 2)
560
+ .attr('markerWidth', mw)
561
+ .attr('markerHeight', mh)
562
+ .attr('orient', 'auto')
563
+ .append('polygon')
564
+ .attr('points', `0,0 ${mw},${mh / 2} 0,${mh}`)
565
+ .attr('fill', color);
566
+ }
@@ -0,0 +1,98 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+
3
+ // ============================================================
4
+ // Cycle Diagram — Parsed Types
5
+ // ============================================================
6
+
7
+ export interface CycleNode {
8
+ label: string;
9
+ lineNumber: number;
10
+ color?: string;
11
+ span: number;
12
+ description: string[];
13
+ metadata: Record<string, string>;
14
+ }
15
+
16
+ export interface CycleEdge {
17
+ sourceIndex: number;
18
+ targetIndex: number;
19
+ label?: string;
20
+ color?: string;
21
+ width?: number;
22
+ description: string[];
23
+ lineNumber?: number;
24
+ metadata: Record<string, string>;
25
+ }
26
+
27
+ export interface ParsedCycle {
28
+ type: 'cycle';
29
+ title: string;
30
+ titleLineNumber: number;
31
+ nodes: CycleNode[];
32
+ edges: CycleEdge[];
33
+ direction: 'clockwise' | 'counterclockwise';
34
+ options: Record<string, string>;
35
+ diagnostics: DgmoError[];
36
+ error: string | null;
37
+ }
38
+
39
+ // ============================================================
40
+ // Cycle Diagram — Layout Types
41
+ // ============================================================
42
+
43
+ export interface CycleLayoutNode {
44
+ label: string;
45
+ x: number;
46
+ y: number;
47
+ angle: number;
48
+ width: number;
49
+ height: number;
50
+ /** Pre-wrapped description lines (fit to node width). Empty if no descriptions. */
51
+ wrappedDesc: string[];
52
+ /** Whether this node should be rendered as a circle. */
53
+ isCircle: boolean;
54
+ }
55
+
56
+ export interface CycleLayoutEdge {
57
+ sourceIndex: number;
58
+ targetIndex: number;
59
+ path: string;
60
+ labelX: number;
61
+ labelY: number;
62
+ /** Angle of the label position on the circle (radians), for text-anchor. */
63
+ labelAngle: number;
64
+ label?: string;
65
+ }
66
+
67
+ // ============================================================
68
+ // Shared arrow-sizing helpers (used by both layout + renderer)
69
+ // ============================================================
70
+
71
+ /** Default edge stroke width. */
72
+ export const DEFAULT_EDGE_WIDTH = 3;
73
+ /** Minimum rendered stroke width — thinner strokes produce unusable arrowheads. */
74
+ export const MIN_EDGE_WIDTH = 2;
75
+
76
+ /**
77
+ * Compute the desired arrowhead length in user-space pixels using sublinear
78
+ * scaling. The renderer uses markerUnits="strokeWidth" with computed marker
79
+ * dimensions so the arrowhead base always matches the stroke width (no gaps,
80
+ * no lollipop effect) while the rendered length follows this formula.
81
+ */
82
+ const BASE_ARROW_SIZE = 8;
83
+ const ARROW_SCALE = 6;
84
+ export function arrowHeadLength(strokeWidth: number): number {
85
+ return BASE_ARROW_SIZE + ARROW_SCALE * Math.sqrt(strokeWidth);
86
+ }
87
+
88
+ export interface CycleLayoutResult {
89
+ nodes: CycleLayoutNode[];
90
+ edges: CycleLayoutEdge[];
91
+ cx: number;
92
+ cy: number;
93
+ radius: number;
94
+ width: number;
95
+ height: number;
96
+ /** Scale factor applied to nodes (1 = no scaling, <1 = shrunk to fit). */
97
+ scale: number;
98
+ }