@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.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +143 -93
- package/dist/editor.cjs +17 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +17 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +12 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +12 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +19997 -14886
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +331 -8
- package/dist/index.d.ts +331 -8
- package/dist/index.js +19984 -14889
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +1 -0
- package/docs/language-reference.md +116 -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/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/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/completion.ts +227 -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 +87 -8
- package/src/dgmo-router.ts +9 -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 +14 -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 +50 -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 +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/parser.ts +2 -6
- package/src/sequence/renderer.ts +144 -38
- 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 +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/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +46 -7
- 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
|
+
}
|