@diagrammo/dgmo 0.2.5 → 0.2.7

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.
@@ -0,0 +1,503 @@
1
+ // ============================================================
2
+ // Flowchart SVG Renderer
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import * as d3Shape from 'd3-shape';
7
+ import { FONT_FAMILY } from '../fonts';
8
+ import type { PaletteColors } from '../palettes';
9
+ import type { ParsedGraph } from './types';
10
+ import type { LayoutResult, LayoutNode, LayoutEdge, LayoutGroup } from './layout';
11
+ import { parseFlowchart } from './flowchart-parser';
12
+ import { layoutGraph } from './layout';
13
+
14
+ // ============================================================
15
+ // Constants
16
+ // ============================================================
17
+
18
+ const DIAGRAM_PADDING = 20;
19
+ const TITLE_HEIGHT = 30;
20
+ const TITLE_FONT_SIZE = 18;
21
+ const NODE_FONT_SIZE = 13;
22
+ const EDGE_LABEL_FONT_SIZE = 11;
23
+ const GROUP_LABEL_FONT_SIZE = 11;
24
+ const EDGE_STROKE_WIDTH = 1.5;
25
+ const NODE_STROKE_WIDTH = 1.5;
26
+ const ARROWHEAD_W = 10;
27
+ const ARROWHEAD_H = 7;
28
+ const IO_SKEW = 15;
29
+ const SUBROUTINE_INSET = 8;
30
+ const DOC_WAVE_HEIGHT = 10;
31
+ const GROUP_EXTRA_PADDING = 12;
32
+
33
+ // ============================================================
34
+ // Color helpers (inline mix to avoid cross-module import issues)
35
+ // ============================================================
36
+
37
+ function mix(a: string, b: string, pct: number): string {
38
+ const parse = (h: string) => {
39
+ const r = h.replace('#', '');
40
+ const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
41
+ return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
42
+ };
43
+ const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
44
+ const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
45
+ return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
46
+ }
47
+
48
+ function nodeFill(palette: PaletteColors, isDark: boolean, nodeColor?: string): string {
49
+ if (nodeColor) {
50
+ return mix(nodeColor, isDark ? palette.surface : palette.bg, 25);
51
+ }
52
+ return mix(palette.primary, isDark ? palette.surface : palette.bg, 15);
53
+ }
54
+
55
+ function nodeStroke(palette: PaletteColors, nodeColor?: string): string {
56
+ return nodeColor ?? palette.textMuted;
57
+ }
58
+
59
+ // ============================================================
60
+ // Shape renderers
61
+ // ============================================================
62
+
63
+ type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
64
+
65
+ function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
66
+ const w = node.width;
67
+ const h = node.height;
68
+ const rx = h / 2;
69
+ g.append('rect')
70
+ .attr('x', -w / 2)
71
+ .attr('y', -h / 2)
72
+ .attr('width', w)
73
+ .attr('height', h)
74
+ .attr('rx', rx)
75
+ .attr('ry', rx)
76
+ .attr('fill', nodeFill(palette, isDark, node.color))
77
+ .attr('stroke', nodeStroke(palette, node.color))
78
+ .attr('stroke-width', NODE_STROKE_WIDTH);
79
+ }
80
+
81
+ function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
82
+ const w = node.width;
83
+ const h = node.height;
84
+ g.append('rect')
85
+ .attr('x', -w / 2)
86
+ .attr('y', -h / 2)
87
+ .attr('width', w)
88
+ .attr('height', h)
89
+ .attr('rx', 3)
90
+ .attr('ry', 3)
91
+ .attr('fill', nodeFill(palette, isDark, node.color))
92
+ .attr('stroke', nodeStroke(palette, node.color))
93
+ .attr('stroke-width', NODE_STROKE_WIDTH);
94
+ }
95
+
96
+ function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
97
+ const w = node.width / 2;
98
+ const h = node.height / 2;
99
+ const points = [
100
+ `${0},${-h}`, // top
101
+ `${w},${0}`, // right
102
+ `${0},${h}`, // bottom
103
+ `${-w},${0}`, // left
104
+ ].join(' ');
105
+ g.append('polygon')
106
+ .attr('points', points)
107
+ .attr('fill', nodeFill(palette, isDark, node.color))
108
+ .attr('stroke', nodeStroke(palette, node.color))
109
+ .attr('stroke-width', NODE_STROKE_WIDTH);
110
+ }
111
+
112
+ function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
113
+ const w = node.width / 2;
114
+ const h = node.height / 2;
115
+ const sk = IO_SKEW;
116
+ const points = [
117
+ `${-w + sk},${-h}`, // top-left (shifted right)
118
+ `${w + sk},${-h}`, // top-right (shifted right)
119
+ `${w - sk},${h}`, // bottom-right (shifted left)
120
+ `${-w - sk},${h}`, // bottom-left (shifted left)
121
+ ].join(' ');
122
+ g.append('polygon')
123
+ .attr('points', points)
124
+ .attr('fill', nodeFill(palette, isDark, node.color))
125
+ .attr('stroke', nodeStroke(palette, node.color))
126
+ .attr('stroke-width', NODE_STROKE_WIDTH);
127
+ }
128
+
129
+ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
130
+ const w = node.width;
131
+ const h = node.height;
132
+ const s = nodeStroke(palette, node.color);
133
+ // Outer rectangle
134
+ g.append('rect')
135
+ .attr('x', -w / 2)
136
+ .attr('y', -h / 2)
137
+ .attr('width', w)
138
+ .attr('height', h)
139
+ .attr('rx', 3)
140
+ .attr('ry', 3)
141
+ .attr('fill', nodeFill(palette, isDark, node.color))
142
+ .attr('stroke', s)
143
+ .attr('stroke-width', NODE_STROKE_WIDTH);
144
+ // Left inner border
145
+ g.append('line')
146
+ .attr('x1', -w / 2 + SUBROUTINE_INSET)
147
+ .attr('y1', -h / 2)
148
+ .attr('x2', -w / 2 + SUBROUTINE_INSET)
149
+ .attr('y2', h / 2)
150
+ .attr('stroke', s)
151
+ .attr('stroke-width', NODE_STROKE_WIDTH);
152
+ // Right inner border
153
+ g.append('line')
154
+ .attr('x1', w / 2 - SUBROUTINE_INSET)
155
+ .attr('y1', -h / 2)
156
+ .attr('x2', w / 2 - SUBROUTINE_INSET)
157
+ .attr('y2', h / 2)
158
+ .attr('stroke', s)
159
+ .attr('stroke-width', NODE_STROKE_WIDTH);
160
+ }
161
+
162
+ function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
163
+ const w = node.width;
164
+ const h = node.height;
165
+ const waveH = DOC_WAVE_HEIGHT;
166
+ const left = -w / 2;
167
+ const right = w / 2;
168
+ const top = -h / 2;
169
+ const bottom = h / 2 - waveH;
170
+
171
+ // Path: straight top, straight right side, wavy bottom, straight left side
172
+ const d = [
173
+ `M ${left} ${top}`,
174
+ `L ${right} ${top}`,
175
+ `L ${right} ${bottom}`,
176
+ `C ${right - w * 0.25} ${bottom + waveH * 2}, ${left + w * 0.25} ${bottom - waveH}, ${left} ${bottom}`,
177
+ 'Z',
178
+ ].join(' ');
179
+
180
+ g.append('path')
181
+ .attr('d', d)
182
+ .attr('fill', nodeFill(palette, isDark, node.color))
183
+ .attr('stroke', nodeStroke(palette, node.color))
184
+ .attr('stroke-width', NODE_STROKE_WIDTH);
185
+ }
186
+
187
+ function renderNodeShape(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
188
+ switch (node.shape) {
189
+ case 'terminal':
190
+ renderTerminal(g, node, palette, isDark);
191
+ break;
192
+ case 'process':
193
+ renderProcess(g, node, palette, isDark);
194
+ break;
195
+ case 'decision':
196
+ renderDecision(g, node, palette, isDark);
197
+ break;
198
+ case 'io':
199
+ renderIO(g, node, palette, isDark);
200
+ break;
201
+ case 'subroutine':
202
+ renderSubroutine(g, node, palette, isDark);
203
+ break;
204
+ case 'document':
205
+ renderDocument(g, node, palette, isDark);
206
+ break;
207
+ }
208
+ }
209
+
210
+ // ============================================================
211
+ // Edge path generator
212
+ // ============================================================
213
+
214
+ const lineGenerator = d3Shape.line<{ x: number; y: number }>()
215
+ .x((d) => d.x)
216
+ .y((d) => d.y)
217
+ .curve(d3Shape.curveBasis);
218
+
219
+ // ============================================================
220
+ // Main renderer
221
+ // ============================================================
222
+
223
+ export function renderFlowchart(
224
+ container: HTMLDivElement,
225
+ graph: ParsedGraph,
226
+ layout: LayoutResult,
227
+ palette: PaletteColors,
228
+ isDark: boolean,
229
+ onClickItem?: (lineNumber: number) => void,
230
+ exportDims?: { width?: number; height?: number }
231
+ ): void {
232
+ // Clear existing content (preserve tooltips)
233
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
234
+
235
+ const width = exportDims?.width ?? container.clientWidth;
236
+ const height = exportDims?.height ?? container.clientHeight;
237
+ if (width <= 0 || height <= 0) return;
238
+
239
+ const titleOffset = graph.title ? TITLE_HEIGHT : 0;
240
+
241
+ // Compute scale to fit diagram in viewport
242
+ const diagramW = layout.width;
243
+ const diagramH = layout.height + titleOffset;
244
+ const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
245
+ const scaleY = (height - DIAGRAM_PADDING * 2) / diagramH;
246
+ const scale = Math.min(1, scaleX, scaleY);
247
+
248
+ // Center the diagram
249
+ const scaledW = diagramW * scale;
250
+ const scaledH = diagramH * scale;
251
+ const offsetX = (width - scaledW) / 2;
252
+ const offsetY = (height - scaledH) / 2;
253
+
254
+ // Create SVG
255
+ const svg = d3Selection
256
+ .select(container)
257
+ .append('svg')
258
+ .attr('width', width)
259
+ .attr('height', height)
260
+ .style('background', palette.bg)
261
+ .style('font-family', FONT_FAMILY);
262
+
263
+ // Defs: arrowhead markers
264
+ const defs = svg.append('defs');
265
+
266
+ // Default arrowhead
267
+ defs
268
+ .append('marker')
269
+ .attr('id', 'fc-arrow')
270
+ .attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
271
+ .attr('refX', ARROWHEAD_W)
272
+ .attr('refY', ARROWHEAD_H / 2)
273
+ .attr('markerWidth', ARROWHEAD_W)
274
+ .attr('markerHeight', ARROWHEAD_H)
275
+ .attr('orient', 'auto')
276
+ .append('polygon')
277
+ .attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
278
+ .attr('fill', palette.textMuted);
279
+
280
+ // Collect unique edge colors for custom markers
281
+ const edgeColors = new Set<string>();
282
+ for (const edge of layout.edges) {
283
+ if (edge.color) edgeColors.add(edge.color);
284
+ }
285
+ for (const color of edgeColors) {
286
+ const id = `fc-arrow-${color.replace('#', '')}`;
287
+ defs
288
+ .append('marker')
289
+ .attr('id', id)
290
+ .attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
291
+ .attr('refX', ARROWHEAD_W)
292
+ .attr('refY', ARROWHEAD_H / 2)
293
+ .attr('markerWidth', ARROWHEAD_W)
294
+ .attr('markerHeight', ARROWHEAD_H)
295
+ .attr('orient', 'auto')
296
+ .append('polygon')
297
+ .attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
298
+ .attr('fill', color);
299
+ }
300
+
301
+ // Main content group with scale/translate
302
+ const mainG = svg
303
+ .append('g')
304
+ .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
305
+
306
+ // Title
307
+ if (graph.title) {
308
+ mainG
309
+ .append('text')
310
+ .attr('x', diagramW / 2)
311
+ .attr('y', TITLE_FONT_SIZE)
312
+ .attr('text-anchor', 'middle')
313
+ .attr('fill', palette.text)
314
+ .attr('font-size', TITLE_FONT_SIZE)
315
+ .attr('font-weight', 'bold')
316
+ .attr('class', 'fc-title')
317
+ .text(graph.title);
318
+ }
319
+
320
+ // Content group (offset by title)
321
+ const contentG = mainG
322
+ .append('g')
323
+ .attr('transform', `translate(0, ${titleOffset})`);
324
+
325
+ // Render groups (background layer)
326
+ for (const group of layout.groups) {
327
+ if (group.width === 0 && group.height === 0) continue;
328
+ const gx = group.x - GROUP_EXTRA_PADDING;
329
+ const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
330
+ const gw = group.width + GROUP_EXTRA_PADDING * 2;
331
+ const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
332
+
333
+ const fillColor = group.color
334
+ ? mix(group.color, isDark ? palette.surface : palette.bg, 10)
335
+ : isDark
336
+ ? palette.surface
337
+ : mix(palette.border, palette.bg, 30);
338
+ const strokeColor = group.color ?? palette.textMuted;
339
+
340
+ contentG
341
+ .append('rect')
342
+ .attr('x', gx)
343
+ .attr('y', gy)
344
+ .attr('width', gw)
345
+ .attr('height', gh)
346
+ .attr('rx', 6)
347
+ .attr('fill', fillColor)
348
+ .attr('stroke', strokeColor)
349
+ .attr('stroke-width', 1)
350
+ .attr('stroke-opacity', 0.5)
351
+ .attr('class', 'fc-group');
352
+
353
+ contentG
354
+ .append('text')
355
+ .attr('x', gx + 8)
356
+ .attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
357
+ .attr('fill', strokeColor)
358
+ .attr('font-size', GROUP_LABEL_FONT_SIZE)
359
+ .attr('font-weight', 'bold')
360
+ .attr('opacity', 0.7)
361
+ .attr('class', 'fc-group-label')
362
+ .text(group.label);
363
+ }
364
+
365
+ // Render edges (middle layer)
366
+ for (const edge of layout.edges) {
367
+ if (edge.points.length < 2) continue;
368
+ const edgeG = contentG
369
+ .append('g')
370
+ .attr('class', 'fc-edge-group')
371
+ .attr('data-line-number', String(edge.lineNumber));
372
+
373
+ const edgeColor = edge.color ?? palette.textMuted;
374
+ const markerId = edge.color
375
+ ? `fc-arrow-${edge.color.replace('#', '')}`
376
+ : 'fc-arrow';
377
+
378
+ const pathD = lineGenerator(edge.points);
379
+ if (pathD) {
380
+ edgeG
381
+ .append('path')
382
+ .attr('d', pathD)
383
+ .attr('fill', 'none')
384
+ .attr('stroke', edgeColor)
385
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
386
+ .attr('marker-end', `url(#${markerId})`)
387
+ .attr('class', 'fc-edge');
388
+ }
389
+
390
+ // Edge label at midpoint
391
+ if (edge.label) {
392
+ const midIdx = Math.floor(edge.points.length / 2);
393
+ const midPt = edge.points[midIdx];
394
+
395
+ // Background rect for legibility
396
+ const labelLen = edge.label.length;
397
+ const bgW = labelLen * 7 + 8;
398
+ const bgH = 16;
399
+ edgeG
400
+ .append('rect')
401
+ .attr('x', midPt.x - bgW / 2)
402
+ .attr('y', midPt.y - bgH / 2 - 1)
403
+ .attr('width', bgW)
404
+ .attr('height', bgH)
405
+ .attr('rx', 3)
406
+ .attr('fill', palette.bg)
407
+ .attr('opacity', 0.85)
408
+ .attr('class', 'fc-edge-label-bg');
409
+
410
+ edgeG
411
+ .append('text')
412
+ .attr('x', midPt.x)
413
+ .attr('y', midPt.y + 4)
414
+ .attr('text-anchor', 'middle')
415
+ .attr('fill', edgeColor)
416
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
417
+ .attr('class', 'fc-edge-label')
418
+ .text(edge.label);
419
+ }
420
+ }
421
+
422
+ // Render nodes (top layer)
423
+ for (const node of layout.nodes) {
424
+ const nodeG = contentG
425
+ .append('g')
426
+ .attr('transform', `translate(${node.x}, ${node.y})`)
427
+ .attr('class', 'fc-node')
428
+ .attr('data-line-number', String(node.lineNumber));
429
+
430
+ if (onClickItem) {
431
+ nodeG.style('cursor', 'pointer').on('click', () => {
432
+ onClickItem(node.lineNumber);
433
+ });
434
+ }
435
+
436
+ // Shape
437
+ renderNodeShape(nodeG as GSelection, node, palette, isDark);
438
+
439
+ // Label
440
+ nodeG
441
+ .append('text')
442
+ .attr('x', 0)
443
+ .attr('y', 0)
444
+ .attr('text-anchor', 'middle')
445
+ .attr('dominant-baseline', 'central')
446
+ .attr('fill', palette.text)
447
+ .attr('font-size', NODE_FONT_SIZE)
448
+ .text(node.label);
449
+ }
450
+ }
451
+
452
+ // ============================================================
453
+ // Export convenience function
454
+ // ============================================================
455
+
456
+ export function renderFlowchartForExport(
457
+ content: string,
458
+ theme: 'light' | 'dark' | 'transparent',
459
+ palette: PaletteColors
460
+ ): string {
461
+ const parsed = parseFlowchart(content, palette);
462
+ if (parsed.error || parsed.nodes.length === 0) return '';
463
+
464
+ const layout = layoutGraph(parsed);
465
+ const isDark = theme === 'dark';
466
+
467
+ // Create offscreen container
468
+ const container = document.createElement('div');
469
+ container.style.width = `${layout.width + DIAGRAM_PADDING * 2}px`;
470
+ container.style.height = `${layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? TITLE_HEIGHT : 0)}px`;
471
+ container.style.position = 'absolute';
472
+ container.style.left = '-9999px';
473
+ document.body.appendChild(container);
474
+
475
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
476
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? TITLE_HEIGHT : 0);
477
+
478
+ try {
479
+ renderFlowchart(
480
+ container,
481
+ parsed,
482
+ layout,
483
+ palette,
484
+ isDark,
485
+ undefined,
486
+ { width: exportWidth, height: exportHeight }
487
+ );
488
+
489
+ const svgEl = container.querySelector('svg');
490
+ if (!svgEl) return '';
491
+
492
+ if (theme === 'transparent') {
493
+ svgEl.style.background = 'none';
494
+ }
495
+
496
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
497
+ svgEl.style.fontFamily = FONT_FAMILY;
498
+
499
+ return svgEl.outerHTML;
500
+ } finally {
501
+ document.body.removeChild(container);
502
+ }
503
+ }