@diagrammo/dgmo 0.8.22 → 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/dist/cli.cjs +111 -109
- package/dist/editor.cjs +3 -0
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +3 -0
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +3 -0
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +1010 -215
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +97 -11
- package/dist/index.d.ts +97 -11
- package/dist/index.js +1001 -213
- 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/registry.json +5 -0
- package/docs/language-reference.md +62 -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/package.json +11 -1
- package/src/cli.ts +5 -35
- package/src/completion.ts +9 -44
- package/src/cycle/layout.ts +19 -28
- package/src/cycle/renderer.ts +59 -32
- package/src/cycle/types.ts +21 -0
- package/src/d3.ts +21 -1
- package/src/dgmo-router.ts +73 -3
- package/src/echarts.ts +1 -1
- package/src/editor/keywords.ts +3 -0
- package/src/index.ts +13 -2
- package/src/infra/parser.ts +2 -2
- package/src/internal.ts +16 -0
- package/src/journey-map/renderer.ts +112 -47
- package/src/org/collapse.ts +81 -0
- 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 +2 -2
- package/src/tech-radar/interactive.ts +54 -0
- package/src/utils/parsing.ts +1 -0
|
@@ -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
|
+
}
|