@diagrammo/dgmo 0.8.22 → 0.8.25

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 (90) hide show
  1. package/.claude/commands/dgmo.md +60 -72
  2. package/dist/cli.cjs +123 -116
  3. package/dist/editor.cjs +3 -2
  4. package/dist/editor.cjs.map +1 -1
  5. package/dist/editor.js +3 -2
  6. package/dist/editor.js.map +1 -1
  7. package/dist/highlight.cjs +3 -2
  8. package/dist/highlight.cjs.map +1 -1
  9. package/dist/highlight.js +3 -2
  10. package/dist/highlight.js.map +1 -1
  11. package/dist/index.cjs +1649 -442
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +196 -23
  14. package/dist/index.d.ts +196 -23
  15. package/dist/index.js +1631 -440
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal.cjs +677 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +267 -0
  20. package/dist/internal.d.ts +267 -0
  21. package/dist/internal.js +633 -0
  22. package/dist/internal.js.map +1 -0
  23. package/docs/guide/chart-area.md +17 -17
  24. package/docs/guide/chart-bar-stacked.md +12 -12
  25. package/docs/guide/chart-cycle.md +156 -0
  26. package/docs/guide/chart-doughnut.md +10 -10
  27. package/docs/guide/chart-funnel.md +9 -9
  28. package/docs/guide/chart-heatmap.md +10 -10
  29. package/docs/guide/chart-journey-map.md +179 -0
  30. package/docs/guide/chart-kanban.md +2 -0
  31. package/docs/guide/chart-line.md +19 -19
  32. package/docs/guide/chart-multi-line.md +16 -16
  33. package/docs/guide/chart-pie.md +11 -11
  34. package/docs/guide/chart-polar-area.md +10 -10
  35. package/docs/guide/chart-pyramid.md +111 -0
  36. package/docs/guide/chart-radar.md +9 -9
  37. package/docs/guide/chart-scatter.md +24 -27
  38. package/docs/guide/index.md +3 -3
  39. package/docs/guide/registry.json +5 -0
  40. package/docs/language-reference.md +108 -26
  41. package/fonts/Inter-Bold.ttf +0 -0
  42. package/fonts/Inter-Regular.ttf +0 -0
  43. package/fonts/LICENSE-Inter.txt +92 -0
  44. package/gallery/fixtures/bar-stacked.dgmo +12 -6
  45. package/gallery/fixtures/heatmap.dgmo +12 -6
  46. package/gallery/fixtures/multi-line.dgmo +11 -7
  47. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  48. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  49. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  50. package/gallery/fixtures/quadrant.dgmo +8 -8
  51. package/gallery/fixtures/scatter.dgmo +12 -12
  52. package/package.json +14 -2
  53. package/src/boxes-and-lines/parser.ts +13 -2
  54. package/src/boxes-and-lines/renderer.ts +22 -13
  55. package/src/chart-type-scoring.ts +162 -0
  56. package/src/chart-types.ts +437 -0
  57. package/src/cli.ts +152 -101
  58. package/src/completion.ts +9 -48
  59. package/src/cycle/layout.ts +19 -28
  60. package/src/cycle/renderer.ts +59 -32
  61. package/src/cycle/types.ts +21 -0
  62. package/src/d3.ts +30 -3
  63. package/src/dgmo-router.ts +98 -73
  64. package/src/echarts.ts +1 -1
  65. package/src/editor/keywords.ts +3 -2
  66. package/src/fonts.ts +3 -2
  67. package/src/gantt/parser.ts +5 -1
  68. package/src/index.ts +37 -3
  69. package/src/infra/parser.ts +3 -3
  70. package/src/internal.ts +20 -0
  71. package/src/journey-map/layout.ts +7 -3
  72. package/src/journey-map/parser.ts +5 -1
  73. package/src/journey-map/renderer.ts +112 -47
  74. package/src/kanban/parser.ts +5 -1
  75. package/src/org/collapse.ts +82 -4
  76. package/src/org/parser.ts +1 -1
  77. package/src/org/renderer.ts +221 -4
  78. package/src/pyramid/parser.ts +172 -0
  79. package/src/pyramid/renderer.ts +684 -0
  80. package/src/pyramid/types.ts +28 -0
  81. package/src/render.ts +2 -8
  82. package/src/sequence/parser.ts +64 -22
  83. package/src/sequence/participant-inference.ts +0 -1
  84. package/src/sequence/renderer.ts +97 -265
  85. package/src/sharing.ts +0 -1
  86. package/src/sitemap/parser.ts +1 -1
  87. package/src/tech-radar/interactive.ts +54 -0
  88. package/src/utils/parsing.ts +1 -0
  89. package/src/utils/tag-groups.ts +35 -5
  90. package/src/wireframe/parser.ts +3 -1
@@ -0,0 +1,684 @@
1
+ // ============================================================
2
+ // Pyramid 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 { contrastText, getSeriesColors, mix } from '../palettes/color-utils';
13
+ import { resolveColor } from '../colors';
14
+ import { renderInlineText } from '../utils/inline-markdown';
15
+ import type { PaletteColors } from '../palettes';
16
+ import type { D3ExportDimensions } from '../utils/d3-types';
17
+ import type { ParsedPyramid, PyramidLayer } from './types';
18
+
19
+ // ── Constants ────────────────────────────────────────────────
20
+ const TITLE_AREA_HEIGHT = 50;
21
+ /** Side margin as fraction of viewport. */
22
+ const H_MARGIN_FRAC = 0.03;
23
+ /** Vertical breathing room above/below the pyramid body. */
24
+ const V_MARGIN = 16;
25
+ /** Max base-width fraction when no descriptions. */
26
+ const BASE_WIDTH_FRAC_NO_DESC = 0.78;
27
+ /** Pyramid width share when descriptions are on one side. */
28
+ const PYRAMID_SHARE_WITH_DESC = 0.58;
29
+ /** Pyramid width share when descriptions alternate sides. */
30
+ const PYRAMID_SHARE_ALTERNATE = 0.42;
31
+ /** Height-to-base-width ratio for the pyramid. Taller = more dramatic. */
32
+ const PITCH_RATIO = 0.85;
33
+ /** Gap between pyramid edge and description accent bar. */
34
+ const DESC_GAP = 28;
35
+ /** Width of the colored accent bar on the left of each description. */
36
+ const DESC_ACCENT_WIDTH = 3;
37
+ /** Gap between accent bar and description text. */
38
+ const DESC_ACCENT_GAP = 12;
39
+ /** Approximate ratio of average glyph width to font size (sans-serif). */
40
+ const CHAR_WIDTH_RATIO = 0.55;
41
+
42
+ const LABEL_FONT_MIN = 12;
43
+ const LABEL_FONT_MAX = 22;
44
+ const DESC_FONT_MIN = 11;
45
+ const DESC_FONT_MAX = 15;
46
+
47
+ type Side = 'left' | 'right';
48
+
49
+ interface WrappedDescription {
50
+ /** All wrapped lines, full content. */
51
+ allLines: string[];
52
+ /** Whether the wrapped content exceeds the layer's band cap. */
53
+ overflows: boolean;
54
+ /** Visible line count for the short (truncated) variant. */
55
+ shortLineCount: number;
56
+ }
57
+
58
+ /**
59
+ * Render a pyramid diagram into the given container.
60
+ */
61
+ export function renderPyramid(
62
+ container: HTMLDivElement,
63
+ parsed: ParsedPyramid,
64
+ palette: PaletteColors,
65
+ isDark: boolean,
66
+ onClickItem?: (lineNumber: number) => void,
67
+ exportDims?: D3ExportDimensions
68
+ ): void {
69
+ if (parsed.layers.length === 0) return;
70
+
71
+ // Clear previous render
72
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
73
+ const width = exportDims?.width ?? container.clientWidth;
74
+ const height = exportDims?.height ?? container.clientHeight;
75
+ if (width <= 0 || height <= 0) return;
76
+
77
+ const hasAnyDescription = parsed.layers.some((l) => l.description.length > 0);
78
+
79
+ // ── Geometry (baseline, single-column layout) ───────────────
80
+ const titleH = parsed.title ? TITLE_AREA_HEIGHT : 0;
81
+ const bodyTop = titleH + V_MARGIN;
82
+ const bodyBottom = height - V_MARGIN;
83
+ const bodyHeight = Math.max(60, bodyBottom - bodyTop);
84
+ const sideMargin = width * H_MARGIN_FRAC;
85
+ const usableWidth = width - sideMargin * 2;
86
+
87
+ const N = parsed.layers.length;
88
+
89
+ // Probe layout: single-column first. If any description would overflow its
90
+ // band at single-column width, flip to alternating to buy wider columns.
91
+ const singleProbe = computeLayout({
92
+ width,
93
+ usableWidth,
94
+ sideMargin,
95
+ bodyTop,
96
+ bodyHeight,
97
+ layers: parsed.layers,
98
+ hasDescription: hasAnyDescription,
99
+ alternate: false,
100
+ });
101
+
102
+ const anyOverflow = singleProbe.wraps.some((w) => w.overflows);
103
+ const useAlternate = anyOverflow && hasAnyDescription && N >= 2;
104
+
105
+ const layout = useAlternate
106
+ ? computeLayout({
107
+ width,
108
+ usableWidth,
109
+ sideMargin,
110
+ bodyTop,
111
+ bodyHeight,
112
+ layers: parsed.layers,
113
+ hasDescription: true,
114
+ alternate: true,
115
+ })
116
+ : singleProbe;
117
+
118
+ // ── SVG root ────────────────────────────────────────────────
119
+ const svg = d3Selection
120
+ .select(container)
121
+ .append('svg')
122
+ .attr('width', width)
123
+ .attr('height', height)
124
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
125
+ .style('font-family', FONT_FAMILY);
126
+
127
+ // Inline default: short description visible, full hidden. Highlight state
128
+ // in the app overrides with higher specificity (`.dgmo-pyramid-layer-highlight.pyramid-desc-full`).
129
+ svg
130
+ .append('style')
131
+ .text(
132
+ '.pyramid-desc-full{display:none}.dgmo-pyramid-layer-highlight.pyramid-desc-short{display:none}.dgmo-pyramid-layer-highlight.pyramid-desc-full{display:inline}.dgmo-pyramid-hidden{display:none}'
133
+ );
134
+
135
+ svg
136
+ .append('rect')
137
+ .attr('width', width)
138
+ .attr('height', height)
139
+ .attr('fill', palette.bg);
140
+
141
+ // Title
142
+ if (parsed.title) {
143
+ const titleText = svg
144
+ .append('text')
145
+ .attr('class', 'chart-title')
146
+ .attr('x', width / 2)
147
+ .attr('y', TITLE_Y)
148
+ .attr('text-anchor', 'middle')
149
+ .attr('fill', palette.text)
150
+ .attr('font-family', FONT_FAMILY)
151
+ .attr('font-size', TITLE_FONT_SIZE)
152
+ .attr('font-weight', TITLE_FONT_WEIGHT)
153
+ .attr('data-line-number', parsed.titleLineNumber)
154
+ .text(parsed.title)
155
+ .style('cursor', onClickItem ? 'pointer' : 'default');
156
+ if (onClickItem) {
157
+ titleText.on('click', () => onClickItem(parsed.titleLineNumber));
158
+ }
159
+ }
160
+
161
+ // ── Layer colors ────────────────────────────────────────────
162
+ const seriesColors = getSeriesColors(palette);
163
+ const layerBase = isDark ? palette.surface : palette.bg;
164
+ const resolveSolid = (layer: PyramidLayer, i: number): string => {
165
+ if (layer.color) {
166
+ const named = resolveColor(layer.color, palette);
167
+ if (named) return named;
168
+ }
169
+ return seriesColors[i % seriesColors.length];
170
+ };
171
+
172
+ // ── Render layers ───────────────────────────────────────────
173
+ const diagramG = svg.append('g').attr('class', 'pyramid-body');
174
+
175
+ for (let i = 0; i < N; i++) {
176
+ const layer = parsed.layers[i];
177
+ const topEdgeY = layout.pyramidTop + i * layout.layerH;
178
+ const botEdgeY = topEdgeY + layout.layerH;
179
+ const topHalf = halfWidthAt(i, N, layout.baseWidth, parsed.inverted);
180
+ const botHalf = halfWidthAt(i + 1, N, layout.baseWidth, parsed.inverted);
181
+
182
+ const polyPoints = [
183
+ [layout.pyramidCx - topHalf, topEdgeY],
184
+ [layout.pyramidCx + topHalf, topEdgeY],
185
+ [layout.pyramidCx + botHalf, botEdgeY],
186
+ [layout.pyramidCx - botHalf, botEdgeY],
187
+ ]
188
+ .map((p) => `${p[0]},${p[1]}`)
189
+ .join(' ');
190
+
191
+ const solidColor = resolveSolid(layer, i);
192
+ const fillColor = mix(solidColor, layerBase, 30);
193
+
194
+ const layerG = diagramG
195
+ .append('g')
196
+ .attr('class', 'pyramid-layer')
197
+ .attr('data-line-number', layer.lineNumber);
198
+
199
+ if (onClickItem) {
200
+ const ln = layer.lineNumber;
201
+ layerG.style('cursor', 'pointer').on('click', () => onClickItem(ln));
202
+ }
203
+
204
+ layerG
205
+ .append('polygon')
206
+ .attr('points', polyPoints)
207
+ .attr('fill', fillColor)
208
+ .attr('stroke', solidColor)
209
+ .attr('stroke-width', 2);
210
+
211
+ const midY = (topEdgeY + botEdgeY) / 2;
212
+ const labelFitsInside =
213
+ Math.min(topHalf, botHalf) * 2 > layout.labelFont * 4;
214
+ const textColor = labelFitsInside
215
+ ? contrastText(fillColor, '#eceff4', '#2e3440')
216
+ : palette.text;
217
+
218
+ const labelText = layerG
219
+ .append('text')
220
+ .attr('x', layout.pyramidCx)
221
+ .attr('y', midY)
222
+ .attr('dy', '0.35em')
223
+ .attr('text-anchor', 'middle')
224
+ .attr('fill', textColor)
225
+ .attr('font-family', FONT_FAMILY)
226
+ .attr('font-size', layout.labelFont)
227
+ .attr('font-weight', 600);
228
+ renderInlineText(labelText, layer.label, palette);
229
+
230
+ // Description: render both short (truncated) and full variants.
231
+ // CSS toggles visibility during highlight.
232
+ if (layer.description.length > 0) {
233
+ const side: Side = useAlternate
234
+ ? i % 2 === 0
235
+ ? 'right'
236
+ : 'left'
237
+ : 'right';
238
+ const wrap = layout.wraps[i];
239
+ renderLayerDescriptions(
240
+ diagramG,
241
+ layer,
242
+ side,
243
+ wrap,
244
+ layout,
245
+ midY,
246
+ solidColor,
247
+ palette,
248
+ titleH + V_MARGIN,
249
+ height - V_MARGIN,
250
+ onClickItem
251
+ );
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Render for CLI/export (no click handlers).
258
+ */
259
+ export function renderPyramidForExport(
260
+ container: HTMLDivElement,
261
+ parsed: ParsedPyramid,
262
+ palette: PaletteColors,
263
+ isDark: boolean,
264
+ exportDims?: D3ExportDimensions
265
+ ): void {
266
+ renderPyramid(container, parsed, palette, isDark, undefined, exportDims);
267
+ }
268
+
269
+ // ============================================================
270
+ // Layout
271
+ // ============================================================
272
+
273
+ interface LayoutInput {
274
+ width: number;
275
+ usableWidth: number;
276
+ sideMargin: number;
277
+ bodyTop: number;
278
+ bodyHeight: number;
279
+ layers: PyramidLayer[];
280
+ hasDescription: boolean;
281
+ alternate: boolean;
282
+ }
283
+
284
+ interface PyramidLayout {
285
+ alternate: boolean;
286
+ baseWidth: number;
287
+ pyramidCx: number;
288
+ pyramidTop: number;
289
+ pyramidH: number;
290
+ layerH: number;
291
+ labelFont: number;
292
+ descFont: number;
293
+ descLineHeight: number;
294
+ /** Column widths: text-wrap budget, per side. */
295
+ rightTextX: number;
296
+ rightTextWidth: number;
297
+ leftTextX: number;
298
+ leftTextWidth: number;
299
+ rightAccentX: number;
300
+ leftAccentX: number;
301
+ wraps: WrappedDescription[];
302
+ }
303
+
304
+ function computeLayout(input: LayoutInput): PyramidLayout {
305
+ const {
306
+ width,
307
+ usableWidth,
308
+ sideMargin,
309
+ bodyTop,
310
+ bodyHeight,
311
+ layers,
312
+ hasDescription,
313
+ alternate,
314
+ } = input;
315
+ const N = layers.length;
316
+
317
+ const pyramidShareFrac = hasDescription
318
+ ? alternate
319
+ ? PYRAMID_SHARE_ALTERNATE
320
+ : PYRAMID_SHARE_WITH_DESC
321
+ : BASE_WIDTH_FRAC_NO_DESC;
322
+ const pyramidBandWidth = usableWidth * pyramidShareFrac;
323
+
324
+ const maxBaseByHeight = bodyHeight / PITCH_RATIO;
325
+ const baseWidth = Math.min(pyramidBandWidth, maxBaseByHeight);
326
+ const pyramidH = baseWidth * PITCH_RATIO;
327
+
328
+ let pyramidCx: number;
329
+ if (!hasDescription) {
330
+ pyramidCx = width / 2;
331
+ } else if (alternate) {
332
+ pyramidCx = width / 2;
333
+ } else {
334
+ pyramidCx = sideMargin + pyramidBandWidth / 2;
335
+ }
336
+
337
+ const pyramidTop = bodyTop + (bodyHeight - pyramidH) / 2;
338
+ const layerH = pyramidH / N;
339
+
340
+ // Font sizes scale with layer height.
341
+ const labelFont = clamp(
342
+ Math.round(layerH * 0.38),
343
+ LABEL_FONT_MIN,
344
+ LABEL_FONT_MAX
345
+ );
346
+ const descFont = clamp(
347
+ Math.round(layerH * 0.22),
348
+ DESC_FONT_MIN,
349
+ DESC_FONT_MAX
350
+ );
351
+ const descLineHeight = Math.round(descFont * 1.35);
352
+
353
+ // Description columns.
354
+ // Right column: from right edge of pyramid (+ DESC_GAP) to (width - sideMargin).
355
+ // Left column (alternate only): from sideMargin to left edge of pyramid (- DESC_GAP).
356
+ const pyramidRightEdge = pyramidCx + baseWidth / 2;
357
+ const pyramidLeftEdge = pyramidCx - baseWidth / 2;
358
+
359
+ const rightAccentX = pyramidRightEdge + DESC_GAP;
360
+ const rightTextX = rightAccentX + DESC_ACCENT_WIDTH + DESC_ACCENT_GAP;
361
+ const rightTextWidth = Math.max(80, width - sideMargin - rightTextX);
362
+
363
+ const leftAccentX = pyramidLeftEdge - DESC_GAP - DESC_ACCENT_WIDTH;
364
+ const leftTextWidth = Math.max(
365
+ 80,
366
+ leftAccentX - DESC_ACCENT_GAP - sideMargin
367
+ );
368
+ const leftTextX = sideMargin;
369
+
370
+ // Per-layer wrapping (measurement pass).
371
+ const wraps: WrappedDescription[] = layers.map((layer, i) => {
372
+ if (layer.description.length === 0) {
373
+ return { allLines: [], overflows: false, shortLineCount: 0 };
374
+ }
375
+ const side: Side = alternate ? (i % 2 === 0 ? 'right' : 'left') : 'right';
376
+ const colWidth = side === 'right' ? rightTextWidth : leftTextWidth;
377
+ const wrapped: string[] = [];
378
+ for (const line of layer.description) {
379
+ wrapped.push(...wrapText(line, colWidth, descFont));
380
+ }
381
+ // Visible cap: lines that fit the layer band with a little breathing room.
382
+ const bandCap = Math.max(1, Math.floor(layerH / descLineHeight) - 0);
383
+ const overflows = wrapped.length > bandCap;
384
+ const shortLineCount = overflows ? bandCap : wrapped.length;
385
+ return { allLines: wrapped, overflows, shortLineCount };
386
+ });
387
+
388
+ return {
389
+ alternate,
390
+ baseWidth,
391
+ pyramidCx,
392
+ pyramidTop,
393
+ pyramidH,
394
+ layerH,
395
+ labelFont,
396
+ descFont,
397
+ descLineHeight,
398
+ rightTextX,
399
+ rightTextWidth,
400
+ leftTextX,
401
+ leftTextWidth,
402
+ rightAccentX,
403
+ leftAccentX,
404
+ wraps,
405
+ };
406
+ }
407
+
408
+ function halfWidthAt(
409
+ edgeIdx: number,
410
+ n: number,
411
+ baseWidth: number,
412
+ inverted: boolean
413
+ ): number {
414
+ const topNarrow = !inverted;
415
+ const frac = topNarrow ? edgeIdx / n : (n - edgeIdx) / n;
416
+ return (frac * baseWidth) / 2;
417
+ }
418
+
419
+ // ============================================================
420
+ // Descriptions
421
+ // ============================================================
422
+
423
+ function renderLayerDescriptions(
424
+ parentG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
425
+ layer: PyramidLayer,
426
+ side: Side,
427
+ wrap: WrappedDescription,
428
+ layout: PyramidLayout,
429
+ midY: number,
430
+ accentColor: string,
431
+ palette: PaletteColors,
432
+ topBound: number,
433
+ bottomBound: number,
434
+ onClickItem?: (lineNumber: number) => void
435
+ ): void {
436
+ const { descFont, descLineHeight } = layout;
437
+ const accentX = side === 'right' ? layout.rightAccentX : layout.leftAccentX;
438
+ const textX = side === 'right' ? layout.rightTextX : layout.leftTextX;
439
+ const textAnchor: 'start' | 'end' = side === 'right' ? 'start' : 'end';
440
+ // For right-anchored (left-column) text, x is the right edge of the column.
441
+ const textLineX =
442
+ side === 'right' ? textX : layout.leftAccentX - DESC_ACCENT_GAP;
443
+
444
+ // Full-reveal budget: how many wrapped lines can fit between title and
445
+ // bottom margin. Truncate beyond that.
446
+ const availableH = bottomBound - topBound;
447
+ const fullMaxLines = Math.max(1, Math.floor(availableH / descLineHeight));
448
+ const fullLines = truncateWithEllipsis(wrap.allLines, fullMaxLines);
449
+
450
+ // If the whole (full-budget) content fits the layer band, render once.
451
+ if (!wrap.overflows) {
452
+ renderDescriptionVariant({
453
+ parentG,
454
+ layer,
455
+ lines: wrap.allLines,
456
+ className: 'pyramid-desc',
457
+ accentX,
458
+ accentColor,
459
+ textX: textLineX,
460
+ textAnchor,
461
+ midY,
462
+ descFont,
463
+ descLineHeight,
464
+ palette,
465
+ topBound,
466
+ bottomBound,
467
+ onClickItem,
468
+ variant: 'short',
469
+ });
470
+ return;
471
+ }
472
+
473
+ // Overflow case: render short (visible by default) + full (revealed on highlight).
474
+ const shortLines = buildShortLines(wrap);
475
+ renderDescriptionVariant({
476
+ parentG,
477
+ layer,
478
+ lines: shortLines,
479
+ className: 'pyramid-desc pyramid-desc-short',
480
+ accentX,
481
+ accentColor,
482
+ textX: textLineX,
483
+ textAnchor,
484
+ midY,
485
+ descFont,
486
+ descLineHeight,
487
+ palette,
488
+ topBound,
489
+ bottomBound,
490
+ onClickItem,
491
+ variant: 'short',
492
+ });
493
+
494
+ renderDescriptionVariant({
495
+ parentG,
496
+ layer,
497
+ lines: fullLines,
498
+ className: 'pyramid-desc pyramid-desc-full',
499
+ accentX,
500
+ accentColor,
501
+ textX: textLineX,
502
+ textAnchor,
503
+ midY,
504
+ descFont,
505
+ descLineHeight,
506
+ palette,
507
+ topBound,
508
+ bottomBound,
509
+ onClickItem,
510
+ variant: 'full',
511
+ });
512
+ }
513
+
514
+ function truncateWithEllipsis(lines: string[], maxLines: number): string[] {
515
+ if (lines.length <= maxLines) return lines.slice();
516
+ const visible = lines.slice(0, maxLines);
517
+ if (visible.length === 0) return visible;
518
+ const last = visible[visible.length - 1];
519
+ visible[visible.length - 1] = last.endsWith('…') ? last : `${last} …`;
520
+ return visible;
521
+ }
522
+
523
+ function buildShortLines(wrap: WrappedDescription): string[] {
524
+ if (!wrap.overflows) return wrap.allLines.slice();
525
+ const visible = wrap.allLines.slice(0, wrap.shortLineCount);
526
+ if (visible.length === 0) return [];
527
+ // Append ellipsis to the last visible line (with a space separator if room).
528
+ const last = visible[visible.length - 1];
529
+ visible[visible.length - 1] = last.endsWith('…') ? last : `${last} …`;
530
+ return visible;
531
+ }
532
+
533
+ interface RenderVariantArgs {
534
+ parentG: d3Selection.Selection<SVGGElement, unknown, null, undefined>;
535
+ layer: PyramidLayer;
536
+ lines: string[];
537
+ className: string;
538
+ accentX: number;
539
+ accentColor: string;
540
+ textX: number;
541
+ textAnchor: 'start' | 'end';
542
+ midY: number;
543
+ descFont: number;
544
+ descLineHeight: number;
545
+ palette: PaletteColors;
546
+ topBound: number;
547
+ bottomBound: number;
548
+ onClickItem?: (lineNumber: number) => void;
549
+ variant: 'short' | 'full';
550
+ }
551
+
552
+ function renderDescriptionVariant(args: RenderVariantArgs): void {
553
+ const {
554
+ parentG,
555
+ layer,
556
+ lines,
557
+ className,
558
+ accentX,
559
+ accentColor,
560
+ textX,
561
+ textAnchor,
562
+ midY,
563
+ descFont,
564
+ descLineHeight,
565
+ palette,
566
+ topBound,
567
+ bottomBound,
568
+ onClickItem,
569
+ variant,
570
+ } = args;
571
+ if (lines.length === 0) return;
572
+
573
+ // Center the block on midY, but clamp so it stays between topBound/bottomBound.
574
+ const totalH = lines.length * descLineHeight;
575
+ let startY = midY - totalH / 2 + descLineHeight / 2;
576
+ const accentPad = Math.max(4, Math.round(descFont * 0.3));
577
+ const blockTop = startY - descLineHeight / 2 - accentPad;
578
+ const blockBottom =
579
+ startY +
580
+ (lines.length - 1) * descLineHeight +
581
+ descLineHeight / 2 +
582
+ accentPad;
583
+
584
+ let shift = 0;
585
+ if (blockTop < topBound) shift = topBound - blockTop;
586
+ else if (blockBottom > bottomBound) shift = bottomBound - blockBottom;
587
+ startY += shift;
588
+
589
+ const accentTop = startY - descLineHeight / 2 - accentPad;
590
+ const accentH = totalH + accentPad * 2;
591
+
592
+ const descG = parentG
593
+ .append('g')
594
+ .attr('class', className)
595
+ .attr('data-line-number', layer.lineNumber)
596
+ .attr('data-variant', variant);
597
+
598
+ if (onClickItem) {
599
+ const ln = layer.lineNumber;
600
+ descG.style('cursor', 'pointer').on('click', () => onClickItem(ln));
601
+ }
602
+
603
+ descG
604
+ .append('rect')
605
+ .attr('x', accentX)
606
+ .attr('y', accentTop)
607
+ .attr('width', DESC_ACCENT_WIDTH)
608
+ .attr('height', accentH)
609
+ .attr('rx', DESC_ACCENT_WIDTH / 2)
610
+ .attr('fill', accentColor);
611
+
612
+ for (let j = 0; j < lines.length; j++) {
613
+ const t = descG
614
+ .append('text')
615
+ .attr('x', textX)
616
+ .attr('y', startY + j * descLineHeight)
617
+ .attr('dy', '0.35em')
618
+ .attr('text-anchor', textAnchor)
619
+ .attr('fill', palette.text)
620
+ .attr('font-family', FONT_FAMILY)
621
+ .attr('font-size', descFont)
622
+ .attr('font-weight', j === 0 ? 500 : 400);
623
+ renderInlineText(t, lines[j], palette);
624
+ }
625
+ }
626
+
627
+ // ============================================================
628
+ // Text wrapping
629
+ // ============================================================
630
+
631
+ /**
632
+ * Greedy word-wrap. Uses a font-size heuristic (CHAR_WIDTH_RATIO) to estimate
633
+ * glyph width. Close enough for sans-serif body text at typical sizes.
634
+ *
635
+ * Preserves empty lines (returned as a single empty string) so paragraphs
636
+ * separated by blank lines keep their spacing.
637
+ */
638
+ function wrapText(line: string, maxWidth: number, fontSize: number): string[] {
639
+ if (line === '') return [''];
640
+ const avgCharW = fontSize * CHAR_WIDTH_RATIO;
641
+ const maxChars = Math.max(8, Math.floor(maxWidth / avgCharW));
642
+
643
+ // Respect explicit indentation: if the source line starts with bullet
644
+ // markers ("• ", "- "), we keep the marker on the first wrapped segment
645
+ // and indent subsequent wraps by the marker width.
646
+ const bulletMatch = line.match(/^(\s*(?:•|-)\s+)(.*)$/);
647
+ const indent = bulletMatch ? bulletMatch[1] : '';
648
+ const body = bulletMatch ? bulletMatch[2] : line;
649
+ const hangingIndent = ' '.repeat(indent.length);
650
+
651
+ const words = body.split(/\s+/);
652
+ const out: string[] = [];
653
+ let current = indent;
654
+ for (const word of words) {
655
+ if (word === '') continue;
656
+ const tentative =
657
+ current.length === indent.length ? current + word : `${current} ${word}`;
658
+ if (tentative.length <= maxChars) {
659
+ current = tentative;
660
+ } else {
661
+ if (current.length > indent.length) out.push(current);
662
+ // Word itself longer than a full line? Hard-break.
663
+ if (word.length > maxChars - hangingIndent.length) {
664
+ let remaining = word;
665
+ while (remaining.length > maxChars - hangingIndent.length) {
666
+ const slice = remaining.slice(0, maxChars - hangingIndent.length);
667
+ out.push(hangingIndent + slice);
668
+ remaining = remaining.slice(maxChars - hangingIndent.length);
669
+ }
670
+ current = remaining.length > 0 ? hangingIndent + remaining : '';
671
+ } else {
672
+ current = hangingIndent + word;
673
+ }
674
+ }
675
+ }
676
+ if (current.length > (out.length === 0 ? 0 : indent.length)) {
677
+ if (current.trim().length > 0) out.push(current);
678
+ }
679
+ return out.length > 0 ? out : [line];
680
+ }
681
+
682
+ function clamp(x: number, lo: number, hi: number): number {
683
+ return Math.max(lo, Math.min(hi, x));
684
+ }
@@ -0,0 +1,28 @@
1
+ import type { DgmoError } from '../diagnostics';
2
+
3
+ // ============================================================
4
+ // Pyramid Diagram — Parsed Types
5
+ // ============================================================
6
+
7
+ export interface PyramidLayer {
8
+ label: string;
9
+ lineNumber: number;
10
+ /** Optional palette color name (red/green/blue/…). */
11
+ color?: string;
12
+ /** Description lines — from bare pipe shorthand or indented body. */
13
+ description: string[];
14
+ /** Unconsumed pipe metadata (reserved for future use). */
15
+ metadata: Record<string, string>;
16
+ }
17
+
18
+ export interface ParsedPyramid {
19
+ type: 'pyramid';
20
+ title: string;
21
+ titleLineNumber: number;
22
+ layers: PyramidLayer[];
23
+ /** When true, apex points down instead of up. */
24
+ inverted: boolean;
25
+ options: Record<string, string>;
26
+ diagnostics: DgmoError[];
27
+ error: string | null;
28
+ }