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