@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,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
|
+
}
|