@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.
Files changed (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,1521 @@
1
+ import * as d3 from 'd3-selection';
2
+ import * as d3Shape from 'd3-shape';
3
+ import type { PaletteColors } from '../palettes';
4
+ import { mix, contrastText } from '../palettes/color-utils';
5
+ import { FONT_FAMILY } from '../fonts';
6
+ import { parseJourneyMap } from './parser';
7
+ import {
8
+ layoutJourneyMap,
9
+ scoreToColor,
10
+ TAG_STRIP_HEIGHT,
11
+ type CurvePoint,
12
+ type StepLayout,
13
+ } from './layout';
14
+ import type {
15
+ ParsedJourneyMap,
16
+ JourneyMapAnnotation,
17
+ JourneyMapStep,
18
+ } from './types';
19
+ import { renderInlineText } from '../utils/inline-markdown';
20
+ import { renderLegendD3 } from '../utils/legend-d3';
21
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
22
+ import { resolveActiveTagGroup } from '../utils/tag-groups';
23
+
24
+ // ============================================================
25
+ // Interactive Options
26
+ // ============================================================
27
+
28
+ export interface JourneyMapInteractiveOptions {
29
+ onNavigateToLine?: (line: number) => void;
30
+ exportDims?: { width: number; height: number };
31
+ activeTagGroup?: string | null;
32
+ onActiveTagGroupChange?: (group: string | null) => void;
33
+ /** Current editor cursor line — highlights the matching face + card, dims the rest */
34
+ currentLine?: number | null;
35
+ /** Set of collapsed phase names */
36
+ collapsedPhases?: Set<string>;
37
+ /** Called when a phase is toggled */
38
+ onPhaseToggle?: (phaseName: string) => void;
39
+ }
40
+
41
+ // ============================================================
42
+ // Constants
43
+ // ============================================================
44
+
45
+ // Match kanban styling constants
46
+ const DIAGRAM_PADDING = 20;
47
+ const PADDING = DIAGRAM_PADDING;
48
+ const CARD_RADIUS = 6;
49
+ const CARD_PADDING_X = 10;
50
+ const CARD_PADDING_Y = 6;
51
+ const CARD_HEADER_HEIGHT = 24;
52
+ const CARD_STROKE_WIDTH = 1.5;
53
+ const CARD_META_LINE_HEIGHT = 14;
54
+ const CARD_GAP_INTERNAL = 8;
55
+ const COLUMN_RADIUS = 8;
56
+ const COLUMN_HEADER_HEIGHT = 36;
57
+ const COLUMN_PADDING = 12;
58
+ const FONT_SIZE_TITLE = 18;
59
+ const FONT_SIZE_PHASE = 13;
60
+ const FONT_SIZE_STEP = 12;
61
+ const FONT_SIZE_META = 10;
62
+ const GRID_LINE_OPACITY = 0.15;
63
+ const CURVE_STROKE_WIDTH = 2.5;
64
+ const FACE_RADIUS = 14;
65
+ const DIM_HOVER = 0.25;
66
+ const TITLE_LINE_HEIGHT = 16;
67
+ const TITLE_CHAR_WIDTH = 6.5;
68
+
69
+ // ============================================================
70
+ // Renderer
71
+ // ============================================================
72
+
73
+ export function renderJourneyMap(
74
+ container: HTMLElement,
75
+ parsed: ParsedJourneyMap,
76
+ palette: PaletteColors,
77
+ isDark: boolean,
78
+ options?: JourneyMapInteractiveOptions
79
+ ): void {
80
+ const exportDims = options?.exportDims;
81
+ const onNavigateToLine = options?.onNavigateToLine;
82
+ const activeTagGroup = options?.activeTagGroup ?? null;
83
+ const onActiveTagGroupChange = options?.onActiveTagGroupChange;
84
+ const collapsedPhases = options?.collapsedPhases ?? new Set<string>();
85
+ const onPhaseToggle = options?.onPhaseToggle;
86
+
87
+ const layout = layoutJourneyMap(parsed, palette, {
88
+ collapsedPhases,
89
+ exportDims,
90
+ });
91
+
92
+ // Clear container
93
+ container.innerHTML = '';
94
+
95
+ // For interactive mode, fit the diagram to the container
96
+ const containerW = exportDims?.width ?? container.clientWidth;
97
+ const containerH = exportDims?.height ?? container.clientHeight;
98
+ const useContainerFit = !exportDims && containerW > 0 && containerH > 0;
99
+
100
+ const svg = d3
101
+ .select(container)
102
+ .append('svg')
103
+ .attr('xmlns', 'http://www.w3.org/2000/svg')
104
+ .attr(
105
+ 'width',
106
+ useContainerFit ? containerW : (exportDims?.width ?? layout.totalWidth)
107
+ )
108
+ .attr(
109
+ 'height',
110
+ useContainerFit ? containerH : (exportDims?.height ?? layout.totalHeight)
111
+ )
112
+ .attr('viewBox', `0 0 ${layout.totalWidth} ${layout.totalHeight}`)
113
+ .attr('preserveAspectRatio', 'xMidYMin meet')
114
+ .attr('font-family', FONT_FAMILY);
115
+
116
+ // Background
117
+ svg
118
+ .append('rect')
119
+ .attr('width', layout.totalWidth)
120
+ .attr('height', layout.totalHeight)
121
+ .attr('fill', palette.bg);
122
+
123
+ // Defs for gradients
124
+ const defs = svg.append('defs');
125
+
126
+ // Curve area gradient (green top, red bottom)
127
+ const curveGradient = defs
128
+ .append('linearGradient')
129
+ .attr('id', 'journey-curve-gradient')
130
+ .attr('x1', '0')
131
+ .attr('y1', '0')
132
+ .attr('x2', '0')
133
+ .attr('y2', '1');
134
+ curveGradient
135
+ .append('stop')
136
+ .attr('offset', '0%')
137
+ .attr('stop-color', palette.colors.green)
138
+ .attr('stop-opacity', 0.3);
139
+ curveGradient
140
+ .append('stop')
141
+ .attr('offset', '100%')
142
+ .attr('stop-color', palette.colors.red)
143
+ .attr('stop-opacity', 0.3);
144
+
145
+ // ── Title ────────────────────────────────────────────────
146
+ if (parsed.title) {
147
+ const titleG = svg.append('g').attr('class', 'chart-title');
148
+ if (parsed.titleLineNumber) {
149
+ titleG.attr('data-line-number', parsed.titleLineNumber);
150
+ }
151
+ titleG
152
+ .append('text')
153
+ .attr('x', PADDING)
154
+ .attr('y', PADDING + FONT_SIZE_TITLE)
155
+ .attr('font-size', FONT_SIZE_TITLE)
156
+ .attr('font-weight', 'bold')
157
+ .attr('fill', palette.text)
158
+ .text(parsed.title);
159
+
160
+ if (onNavigateToLine && parsed.titleLineNumber) {
161
+ titleG.style('cursor', 'pointer').on('click', () => {
162
+ onNavigateToLine(parsed.titleLineNumber!);
163
+ });
164
+ }
165
+ }
166
+
167
+ // ── Persona ──────────────────────────────────────────────
168
+ if (parsed.persona) {
169
+ const personaColor = parsed.persona.color ?? palette.textMuted;
170
+ const personaG = svg
171
+ .append('g')
172
+ .attr('class', 'journey-persona')
173
+ .attr('data-line-number', parsed.persona.lineNumber);
174
+
175
+ // Panel dimensions
176
+ const titleRowH = CARD_HEADER_HEIGHT;
177
+ const silhouetteZone = 60; // right-side space reserved for silhouette
178
+ const panelWidth = 280;
179
+ const textAreaWidth = panelWidth - silhouetteZone - CARD_PADDING_X;
180
+ const descLineH = 14;
181
+
182
+ // Wrap description text into lines
183
+ const descLines = parsed.persona.description
184
+ ? wrapText(parsed.persona.description, textAreaWidth, FONT_SIZE_META)
185
+ : [];
186
+ const descRowH =
187
+ descLines.length > 0 ? descLines.length * descLineH + 8 : 0;
188
+ const panelHeight = titleRowH + descRowH;
189
+ const panelX = layout.totalWidth - PADDING - panelWidth;
190
+ const panelY = PADDING;
191
+ const textX = panelX + CARD_PADDING_X;
192
+
193
+ // Clip path so silhouette stays inside the card
194
+ const clipId = 'persona-clip';
195
+ defs
196
+ .append('clipPath')
197
+ .attr('id', clipId)
198
+ .append('rect')
199
+ .attr('x', panelX)
200
+ .attr('y', panelY)
201
+ .attr('width', panelWidth)
202
+ .attr('height', panelHeight)
203
+ .attr('rx', CARD_RADIUS);
204
+
205
+ // Card — same treatment as step cards: colored stroke, 15% blend fill
206
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
207
+ const personaFill = mix(personaColor, cardBaseBg, 15);
208
+
209
+ personaG
210
+ .append('rect')
211
+ .attr('x', panelX)
212
+ .attr('y', panelY)
213
+ .attr('width', panelWidth)
214
+ .attr('height', panelHeight)
215
+ .attr('rx', CARD_RADIUS)
216
+ .attr('fill', personaFill);
217
+
218
+ // Divider line (drawn BEFORE silhouette so it doesn't cut through)
219
+ if (descLines.length > 0) {
220
+ personaG
221
+ .append('line')
222
+ .attr('x1', panelX + 1)
223
+ .attr('x2', panelX + panelWidth - silhouetteZone)
224
+ .attr('y1', panelY + titleRowH)
225
+ .attr('y2', panelY + titleRowH)
226
+ .attr('stroke', personaColor)
227
+ .attr('stroke-opacity', 0.3)
228
+ .attr('stroke-width', 1);
229
+ }
230
+
231
+ // Silhouette (clipped inside card, more right padding, taller)
232
+ const silX = panelX + panelWidth - 32;
233
+ const silY = panelY + panelHeight / 2 - 6;
234
+ const silClip = personaG.append('g').attr('clip-path', `url(#${clipId})`);
235
+ renderPersonaSilhouette(silClip, silX, silY, personaColor, palette, 1.2);
236
+
237
+ // Card border (drawn on top so outline is clean all around)
238
+ personaG
239
+ .append('rect')
240
+ .attr('x', panelX)
241
+ .attr('y', panelY)
242
+ .attr('width', panelWidth)
243
+ .attr('height', panelHeight)
244
+ .attr('rx', CARD_RADIUS)
245
+ .attr('fill', 'none')
246
+ .attr('stroke', personaColor)
247
+ .attr('stroke-width', CARD_STROKE_WIDTH);
248
+
249
+ // Name (left-aligned in title row, matches kanban card title)
250
+ personaG
251
+ .append('text')
252
+ .attr('x', textX)
253
+ .attr('y', panelY + CARD_PADDING_Y + FONT_SIZE_STEP)
254
+ .attr('font-size', FONT_SIZE_STEP)
255
+ .attr('font-weight', '500')
256
+ .attr('fill', palette.text)
257
+ .text(parsed.persona.name);
258
+
259
+ // Description — wrapped lines below divider, with inline markdown
260
+ for (let li = 0; li < descLines.length; li++) {
261
+ const lineEl = personaG
262
+ .append('text')
263
+ .attr('x', textX)
264
+ .attr('y', panelY + titleRowH + descLineH * (li + 1))
265
+ .attr('font-size', FONT_SIZE_META)
266
+ .attr('fill', palette.textMuted);
267
+ renderInlineText(lineEl, descLines[li], palette, FONT_SIZE_META);
268
+ }
269
+
270
+ if (onNavigateToLine) {
271
+ personaG.style('cursor', 'pointer').on('click', () => {
272
+ onNavigateToLine(parsed.persona!.lineNumber);
273
+ });
274
+ }
275
+ }
276
+
277
+ // ── Score group + active group (shared by legend and card coloring) ──
278
+ const scoreGroup = {
279
+ name: 'Score',
280
+ entries: [1, 2, 3, 4, 5].map((s) => ({
281
+ value: String(s),
282
+ color: scoreToColor(s, palette),
283
+ lineNumber: 0,
284
+ })),
285
+ lineNumber: 0,
286
+ };
287
+ const allLegendGroups = [...parsed.tagGroups, scoreGroup];
288
+ const effectiveActiveGroup =
289
+ activeTagGroup ??
290
+ resolveActiveTagGroup(allLegendGroups, parsed.options['active-tag']);
291
+
292
+ // ── Legend ──────────────────────────────────────────────
293
+ if (parsed.options['no-legend'] !== 'on') {
294
+ const legendX = PADDING;
295
+ const legendY = PADDING + (parsed.title ? FONT_SIZE_TITLE + 8 : 0);
296
+ const legendG = svg
297
+ .append('g')
298
+ .attr('class', 'journey-legend')
299
+ .attr('transform', `translate(${legendX},${legendY})`);
300
+
301
+ const legendConfig: LegendConfig = {
302
+ groups: parsed.tagGroups,
303
+ position: {
304
+ placement: 'top-center',
305
+ titleRelation: 'inline-with-title',
306
+ },
307
+ titleWidth: 0,
308
+ mode: exportDims ? 'inline' : 'fixed',
309
+ };
310
+
311
+ const legendState: LegendState = { activeGroup: effectiveActiveGroup };
312
+
313
+ const legendCallbacks: import('../utils/legend-types').LegendCallbacks = {
314
+ ...(onActiveTagGroupChange
315
+ ? {
316
+ onGroupToggle: (groupName: string) => {
317
+ const isDeactivating =
318
+ effectiveActiveGroup?.toLowerCase() === groupName.toLowerCase();
319
+ onActiveTagGroupChange(isDeactivating ? null : groupName);
320
+ },
321
+ }
322
+ : {}),
323
+ ...(!exportDims
324
+ ? {
325
+ onEntryHover: (groupName: string, entryValue: string | null) => {
326
+ if (!entryValue) {
327
+ // Hover out — restore all
328
+ svg.selectAll('.journey-step').style('opacity', null);
329
+ svg
330
+ .selectAll('.journey-face')
331
+ .style('opacity', null)
332
+ .attr('transform', null);
333
+ svg.selectAll('.journey-thought').style('opacity', null);
334
+ return;
335
+ }
336
+
337
+ const isScore = groupName === 'Score';
338
+ const attrName = isScore
339
+ ? 'data-score'
340
+ : `data-tag-${groupName.toLowerCase()}`;
341
+
342
+ const matches = (el: Element) =>
343
+ el.getAttribute(attrName) === entryValue;
344
+
345
+ svg
346
+ .selectAll<SVGGElement, unknown>('.journey-step')
347
+ .each(function () {
348
+ const hit = matches(this);
349
+ d3.select(this).style(
350
+ 'opacity',
351
+ hit ? '1' : String(DIM_HOVER)
352
+ );
353
+ });
354
+
355
+ svg
356
+ .selectAll<SVGGElement, unknown>('.journey-face')
357
+ .each(function () {
358
+ const hit = matches(this);
359
+ const sel = d3.select(this);
360
+ sel.style('opacity', hit ? '1' : String(DIM_HOVER));
361
+ if (hit) {
362
+ const fcx = parseFloat(sel.attr('data-cx') ?? '0');
363
+ const fcy = parseFloat(sel.attr('data-cy') ?? '0');
364
+ sel.attr(
365
+ 'transform',
366
+ `translate(${fcx},${fcy}) scale(1.3) translate(${-fcx},${-fcy})`
367
+ );
368
+ } else {
369
+ sel.attr('transform', null);
370
+ }
371
+ });
372
+
373
+ // Dim thought bubbles that aren't associated with matching faces
374
+ svg
375
+ .selectAll<SVGGElement, unknown>('.journey-thought')
376
+ .style('opacity', String(DIM_HOVER));
377
+ },
378
+ }
379
+ : {}),
380
+ };
381
+
382
+ renderLegendD3(
383
+ legendG,
384
+ legendConfig,
385
+ legendState,
386
+ palette,
387
+ isDark,
388
+ legendCallbacks,
389
+ // Reduce available width if persona card exists to avoid collision
390
+ parsed.persona
391
+ ? layout.totalWidth - legendX - PADDING - 280 - PADDING
392
+ : layout.totalWidth - legendX - PADDING
393
+ );
394
+ }
395
+
396
+ // ── Curve Area ───────────────────────────────────────────
397
+ const curveG = svg.append('g').attr('class', 'journey-curve-area');
398
+
399
+ // Grid lines at score levels 1-5
400
+ for (let score = 1; score <= 5; score++) {
401
+ const y =
402
+ layout.curveAreaBottom -
403
+ ((score - 1) / 4) * (layout.curveAreaBottom - layout.curveAreaTop - 120) -
404
+ 10;
405
+
406
+ curveG
407
+ .append('line')
408
+ .attr('x1', PADDING)
409
+ .attr('x2', layout.totalWidth - PADDING)
410
+ .attr('y1', y)
411
+ .attr('y2', y)
412
+ .attr('stroke', palette.textMuted)
413
+ .attr('stroke-opacity', GRID_LINE_OPACITY)
414
+ .attr('stroke-dasharray', '4,4');
415
+
416
+ // Score label — emotion face icon
417
+ const SCORE_LABEL_R = 8;
418
+ const labelG = curveG
419
+ .append('g')
420
+ .attr('class', 'journey-score-label')
421
+ .attr('data-score', String(score));
422
+ renderScoreFace(
423
+ labelG,
424
+ PADDING - SCORE_LABEL_R - 2,
425
+ y,
426
+ score,
427
+ palette,
428
+ SCORE_LABEL_R
429
+ );
430
+
431
+ // Score label interactivity is wired up in the click-to-lock section below
432
+ if (!exportDims) {
433
+ labelG.style('cursor', 'pointer');
434
+ }
435
+ }
436
+
437
+ // Emotion curve (area fill + line)
438
+ if (layout.curvePoints.length >= 2) {
439
+ // Extend curve to edges with flat continuations
440
+ const first = layout.curvePoints[0];
441
+ const last = layout.curvePoints[layout.curvePoints.length - 1];
442
+ const extendedPoints: CurvePoint[] = [
443
+ {
444
+ x: PADDING,
445
+ y: first.y,
446
+ score: first.score,
447
+ stepIndex: first.stepIndex,
448
+ },
449
+ ...layout.curvePoints,
450
+ {
451
+ x: layout.totalWidth - PADDING,
452
+ y: last.y,
453
+ score: last.score,
454
+ stepIndex: last.stepIndex,
455
+ },
456
+ ];
457
+
458
+ const areaGen = d3Shape
459
+ .area<CurvePoint>()
460
+ .x((d) => d.x)
461
+ .y0(layout.curveAreaBottom + FACE_RADIUS)
462
+ .y1((d) => d.y)
463
+ .curve(d3Shape.curveMonotoneX);
464
+
465
+ curveG
466
+ .append('path')
467
+ .attr('d', areaGen(extendedPoints) ?? '')
468
+ .attr('fill', 'url(#journey-curve-gradient)')
469
+ .attr('stroke', 'none');
470
+
471
+ // Curve line on top
472
+ const lineGen = d3Shape
473
+ .line<CurvePoint>()
474
+ .x((d) => d.x)
475
+ .y((d) => d.y)
476
+ .curve(d3Shape.curveMonotoneX);
477
+
478
+ curveG
479
+ .append('path')
480
+ .attr('d', lineGen(extendedPoints) ?? '')
481
+ .attr('fill', 'none')
482
+ .attr('stroke', palette.primary)
483
+ .attr('stroke-width', CURVE_STROKE_WIDTH)
484
+ .attr('stroke-linecap', 'round');
485
+
486
+ // Curve face icons (clickable → navigate to step line)
487
+ const allSteps =
488
+ parsed.phases.length > 0
489
+ ? parsed.phases.flatMap((p) => p.steps)
490
+ : parsed.steps;
491
+
492
+ for (const pt of layout.curvePoints) {
493
+ const step = allSteps[pt.stepIndex];
494
+ const faceG = renderScoreFace(curveG, pt.x, pt.y, pt.score, palette);
495
+ if (step) {
496
+ faceG.attr('data-line-number', step.lineNumber);
497
+ faceG.attr('data-score', pt.score);
498
+ for (const [key, value] of Object.entries(step.tags)) {
499
+ faceG.attr(`data-tag-${key.toLowerCase()}`, value);
500
+ }
501
+ // Store thought text for hover-to-reveal
502
+ const thoughts = step.annotations.filter((a) => a.type === 'thought');
503
+ if (thoughts.length > 0) {
504
+ faceG.attr(
505
+ 'data-thought',
506
+ thoughts.map((t) => t.text).join(' \u2022 ')
507
+ );
508
+ }
509
+ if (onNavigateToLine) {
510
+ faceG.style('cursor', 'pointer').on('click', () => {
511
+ onNavigateToLine(step.lineNumber);
512
+ });
513
+ }
514
+ }
515
+ }
516
+ } else if (layout.curvePoints.length === 1) {
517
+ // Single point — face only, no curve
518
+ const pt = layout.curvePoints[0];
519
+ const allSteps =
520
+ parsed.phases.length > 0
521
+ ? parsed.phases.flatMap((p) => p.steps)
522
+ : parsed.steps;
523
+ const step = allSteps[pt.stepIndex];
524
+ const faceG = renderScoreFace(curveG, pt.x, pt.y, pt.score, palette);
525
+ if (step) {
526
+ faceG.attr('data-line-number', step.lineNumber);
527
+ faceG.attr('data-score', pt.score);
528
+ for (const [key, value] of Object.entries(step.tags)) {
529
+ faceG.attr(`data-tag-${key.toLowerCase()}`, value);
530
+ }
531
+ const thoughts = step.annotations.filter((a) => a.type === 'thought');
532
+ if (thoughts.length > 0) {
533
+ faceG.attr(
534
+ 'data-thought',
535
+ thoughts.map((t) => t.text).join(' \u2022 ')
536
+ );
537
+ }
538
+ if (onNavigateToLine) {
539
+ faceG.style('cursor', 'pointer').on('click', () => {
540
+ onNavigateToLine(step.lineNumber);
541
+ });
542
+ }
543
+ }
544
+ }
545
+
546
+ // ── Phases + Cards ───────────────────────────────────────
547
+ const phasesG = svg.append('g').attr('class', 'journey-phases');
548
+
549
+ if (layout.phases.length > 0) {
550
+ for (const pl of layout.phases) {
551
+ const isCollapsed = collapsedPhases.has(pl.phase.name);
552
+ const phaseG = phasesG
553
+ .append('g')
554
+ .attr('class', 'journey-phase')
555
+ .attr('data-line-number', pl.phase.lineNumber);
556
+
557
+ // Column background (no stroke — matches kanban)
558
+ const colBg = isDark
559
+ ? mix(palette.surface, palette.bg, 50)
560
+ : mix(palette.surface, palette.bg, 30);
561
+ phaseG
562
+ .append('rect')
563
+ .attr('x', pl.x)
564
+ .attr('y', pl.y)
565
+ .attr('width', pl.width)
566
+ .attr('height', pl.height)
567
+ .attr('rx', COLUMN_RADIUS)
568
+ .attr('fill', colBg);
569
+
570
+ // Column header (no stroke — matches kanban)
571
+ phaseG
572
+ .append('rect')
573
+ .attr('x', pl.x)
574
+ .attr('y', pl.y)
575
+ .attr('width', pl.width)
576
+ .attr('height', COLUMN_HEADER_HEIGHT)
577
+ .attr('rx', COLUMN_RADIUS)
578
+ .attr('fill', pl.headerColor);
579
+
580
+ // Clip bottom corners of header
581
+ phaseG
582
+ .append('rect')
583
+ .attr('x', pl.x)
584
+ .attr('y', pl.y + COLUMN_HEADER_HEIGHT - COLUMN_RADIUS)
585
+ .attr('width', pl.width)
586
+ .attr('height', COLUMN_RADIUS)
587
+ .attr('fill', pl.headerColor);
588
+
589
+ // Column header text (always show full name)
590
+ phaseG
591
+ .append('text')
592
+ .attr('x', pl.x + COLUMN_PADDING)
593
+ .attr('y', pl.y + COLUMN_HEADER_HEIGHT / 2 + FONT_SIZE_PHASE / 2 - 2)
594
+ .attr('font-size', FONT_SIZE_PHASE)
595
+ .attr('font-weight', 'bold')
596
+ .attr('fill', palette.text)
597
+ .text(
598
+ isCollapsed
599
+ ? truncateText(pl.phase.name, pl.width - COLUMN_PADDING * 2)
600
+ : pl.phase.name
601
+ );
602
+
603
+ // Click header to toggle collapse
604
+ if (onPhaseToggle) {
605
+ phaseG.style('cursor', 'pointer').on('click', (event: MouseEvent) => {
606
+ const target = event.target as Element;
607
+ if (
608
+ !target.closest('.journey-step') &&
609
+ !target.closest('.journey-face')
610
+ ) {
611
+ onPhaseToggle(pl.phase.name);
612
+ }
613
+ });
614
+ } else if (onNavigateToLine) {
615
+ phaseG.style('cursor', 'pointer').on('click', (event: MouseEvent) => {
616
+ const target = event.target as Element;
617
+ if (!target.closest('.journey-step')) {
618
+ onNavigateToLine(pl.phase.lineNumber);
619
+ }
620
+ });
621
+ }
622
+
623
+ if (isCollapsed) {
624
+ // Collapsed: mini card rows with colored border matching active tag
625
+ const COLLAPSED_CARD_H = 26;
626
+ const COLLAPSED_GAP = 6;
627
+ const COLLAPSED_FACE_R = 7;
628
+ const listX = pl.x + COLUMN_PADDING;
629
+ const cardW = pl.width - COLUMN_PADDING * 2;
630
+ let itemY = pl.y + COLUMN_HEADER_HEIGHT + CARD_GAP_INTERNAL + 4;
631
+
632
+ for (const step of pl.phase.steps) {
633
+ const itemG = phaseG
634
+ .append('g')
635
+ .attr('class', 'journey-step')
636
+ .attr('data-line-number', step.lineNumber);
637
+
638
+ // Data attributes for legend hover highlighting
639
+ if (step.score !== undefined) {
640
+ itemG.attr('data-score', step.score);
641
+ }
642
+ for (const [key, value] of Object.entries(step.tags)) {
643
+ itemG.attr(`data-tag-${key.toLowerCase()}`, value);
644
+ }
645
+
646
+ // Resolve card color from active tag group
647
+ const stepColor = resolveStepColor(
648
+ step,
649
+ step.score !== undefined
650
+ ? scoreToColor(step.score, palette)
651
+ : palette.surface,
652
+ effectiveActiveGroup,
653
+ parsed.tagGroups,
654
+ palette
655
+ );
656
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
657
+ const rowFill = stepColor
658
+ ? mix(stepColor, cardBaseBg, 15)
659
+ : mix(palette.primary, cardBaseBg, 15);
660
+ const rowStroke = stepColor ?? palette.textMuted;
661
+
662
+ // Card background
663
+ itemG
664
+ .append('rect')
665
+ .attr('x', listX)
666
+ .attr('y', itemY)
667
+ .attr('width', cardW)
668
+ .attr('height', COLLAPSED_CARD_H)
669
+ .attr('rx', CARD_RADIUS)
670
+ .attr('fill', rowFill)
671
+ .attr('stroke', rowStroke)
672
+ .attr('stroke-width', CARD_STROKE_WIDTH);
673
+
674
+ // Face icon (small, left side)
675
+ const faceCx = listX + CARD_PADDING_X + COLLAPSED_FACE_R;
676
+ const faceCy = itemY + COLLAPSED_CARD_H / 2;
677
+ if (step.score !== undefined) {
678
+ const faceG = renderScoreFace(
679
+ itemG,
680
+ faceCx,
681
+ faceCy,
682
+ step.score,
683
+ palette,
684
+ COLLAPSED_FACE_R
685
+ );
686
+ faceG.attr('data-line-number', step.lineNumber);
687
+ faceG.attr('data-score', step.score);
688
+ for (const [key, value] of Object.entries(step.tags)) {
689
+ faceG.attr(`data-tag-${key.toLowerCase()}`, value);
690
+ }
691
+ }
692
+
693
+ // Step title
694
+ const textX = listX + CARD_PADDING_X + COLLAPSED_FACE_R * 2 + 6;
695
+ const maxTextW =
696
+ cardW - CARD_PADDING_X * 2 - COLLAPSED_FACE_R * 2 - 6;
697
+ itemG
698
+ .append('text')
699
+ .attr('x', textX)
700
+ .attr('y', itemY + COLLAPSED_CARD_H / 2 + FONT_SIZE_META / 2 - 1)
701
+ .attr('font-size', FONT_SIZE_META)
702
+ .attr('fill', palette.text)
703
+ .text(truncateText(step.title, maxTextW));
704
+
705
+ if (onNavigateToLine) {
706
+ itemG.style('cursor', 'pointer').on('click', (event: Event) => {
707
+ event.stopPropagation();
708
+ onNavigateToLine(step.lineNumber);
709
+ });
710
+ }
711
+
712
+ itemY += COLLAPSED_CARD_H + COLLAPSED_GAP;
713
+ }
714
+ } else {
715
+ // Expanded: render step cards
716
+ for (const sl of pl.stepLayouts) {
717
+ renderStepCard(
718
+ phaseG,
719
+ sl,
720
+ palette,
721
+ isDark,
722
+ effectiveActiveGroup,
723
+ parsed.tagGroups,
724
+ onNavigateToLine
725
+ );
726
+ }
727
+ }
728
+ }
729
+ } else {
730
+ // Flat mode
731
+ for (const sl of layout.flatStepLayouts) {
732
+ renderStepCard(
733
+ phasesG,
734
+ sl,
735
+ palette,
736
+ isDark,
737
+ effectiveActiveGroup,
738
+ parsed.tagGroups,
739
+ onNavigateToLine
740
+ );
741
+ }
742
+ }
743
+
744
+ // Top-level overlay group for hover elements (renders above everything)
745
+ const overlayG = svg.append('g').attr('class', 'journey-overlay');
746
+
747
+ // ── Hover + click-to-lock dimming ─────────────────────
748
+ if (!exportDims) {
749
+ const DIM_OPACITY = 0.35;
750
+ let lockedLine: number | null = null;
751
+ let lockedScore: number | null = null;
752
+
753
+ // Helper: dim everything except elements matching a score value
754
+ const applyScoreDimming = (activeScore: number) => {
755
+ const scoreStr = String(activeScore);
756
+ svg.selectAll<SVGGElement, unknown>('.journey-step').each(function () {
757
+ const hit = this.getAttribute('data-score') === scoreStr;
758
+ d3.select(this).style('opacity', hit ? '1' : String(DIM_HOVER));
759
+ });
760
+ svg.selectAll<SVGGElement, unknown>('.journey-face').each(function () {
761
+ const hit = this.getAttribute('data-score') === scoreStr;
762
+ const sel = d3.select(this);
763
+ sel.style('opacity', hit ? '1' : String(DIM_HOVER));
764
+ if (hit) {
765
+ const fcx = parseFloat(sel.attr('data-cx') ?? '0');
766
+ const fcy = parseFloat(sel.attr('data-cy') ?? '0');
767
+ sel.attr(
768
+ 'transform',
769
+ `translate(${fcx},${fcy}) scale(1.3) translate(${-fcx},${-fcy})`
770
+ );
771
+ } else {
772
+ sel.attr('transform', null);
773
+ }
774
+ });
775
+ svg
776
+ .selectAll<SVGGElement, unknown>('.journey-thought')
777
+ .style('opacity', String(DIM_HOVER));
778
+ // Highlight the active y-axis score label, dim the rest
779
+ svg
780
+ .selectAll<SVGGElement, unknown>('.journey-score-label')
781
+ .each(function () {
782
+ const sel = d3.select(this);
783
+ const s = sel.attr('data-score');
784
+ sel.style('opacity', s === scoreStr ? '1' : String(DIM_HOVER));
785
+ });
786
+ };
787
+
788
+ const clearScoreDimming = () => {
789
+ svg.selectAll('.journey-step').style('opacity', null);
790
+ svg
791
+ .selectAll('.journey-face')
792
+ .style('opacity', null)
793
+ .attr('transform', null);
794
+ svg.selectAll('.journey-thought').style('opacity', null);
795
+ svg.selectAll('.journey-score-label').style('opacity', null);
796
+ };
797
+
798
+ // Helper: dim everything except elements matching a line number
799
+ const applyDimming = (activeLine: number) => {
800
+ svg.selectAll('.journey-step').each(function () {
801
+ const el = d3.select(this);
802
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
803
+ el.style('opacity', ln === activeLine ? '1' : String(DIM_OPACITY));
804
+ });
805
+ svg.selectAll('.journey-face').each(function () {
806
+ const el = d3.select(this);
807
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
808
+ const isActive = ln === activeLine;
809
+ el.style('opacity', isActive ? '1' : String(DIM_OPACITY));
810
+ if (isActive) {
811
+ const fcx = parseFloat(el.attr('data-cx') ?? '0');
812
+ const fcy = parseFloat(el.attr('data-cy') ?? '0');
813
+ el.attr(
814
+ 'transform',
815
+ `translate(${fcx},${fcy}) scale(1.5) translate(${-fcx},${-fcy})`
816
+ );
817
+ } else {
818
+ el.attr('transform', null);
819
+ }
820
+ });
821
+ // Dim phases slightly (but not as much)
822
+ svg.selectAll('.journey-phase').each(function () {
823
+ const el = d3.select(this);
824
+ const hasActive = el.select(
825
+ `.journey-step[data-line-number="${activeLine}"]`
826
+ );
827
+ if (!hasActive.node()) {
828
+ el.style('opacity', String(DIM_OPACITY + 0.3));
829
+ }
830
+ });
831
+ };
832
+
833
+ const clearDimming = () => {
834
+ svg.selectAll('.journey-step').style('opacity', null);
835
+ svg
836
+ .selectAll('.journey-face')
837
+ .style('opacity', null)
838
+ .attr('transform', null);
839
+ svg.selectAll('.journey-phase').style('opacity', null);
840
+ overlayG.selectAll('.journey-thought-hover').remove();
841
+ };
842
+
843
+ // Show thought bubble for a face in the overlay layer
844
+ const THOUGHT_FONT = 11;
845
+ const THOUGHT_PAD_X = 10;
846
+ const THOUGHT_PAD_Y = 6;
847
+ const THOUGHT_MAX_W = 200;
848
+ const THOUGHT_LINE_H = 14;
849
+ const THOUGHT_GAP = 10;
850
+
851
+ const showThoughtBubble = (
852
+ faceEl: d3.Selection<SVGGElement, unknown, null, undefined>
853
+ ) => {
854
+ overlayG.selectAll('.journey-thought-hover').remove();
855
+
856
+ const thoughtText = faceEl.attr('data-thought');
857
+ if (!thoughtText || !layout.hasThoughts) return;
858
+
859
+ const fcx = parseFloat(faceEl.attr('data-cx') ?? '0');
860
+ const fcy = parseFloat(faceEl.attr('data-cy') ?? '0');
861
+ const score = parseInt(faceEl.attr('data-score') ?? '3', 10);
862
+
863
+ const lines = wrapText(thoughtText, THOUGHT_MAX_W, THOUGHT_FONT);
864
+ const textW = Math.min(
865
+ THOUGHT_MAX_W,
866
+ Math.max(...lines.map((l) => l.length * THOUGHT_FONT * 0.6))
867
+ );
868
+ const bw = textW + THOUGHT_PAD_X * 2;
869
+ const bh = lines.length * THOUGHT_LINE_H + THOUGHT_PAD_Y * 2;
870
+
871
+ // Position above the face, overlaying the curve area (clamp to stay in view)
872
+ const bx = Math.max(
873
+ PADDING,
874
+ Math.min(fcx - bw / 2, layout.totalWidth - PADDING - bw)
875
+ );
876
+ const by = Math.max(PADDING, fcy - FACE_RADIUS - THOUGHT_GAP - bh);
877
+
878
+ const scoreColor = scoreToColor(score, palette);
879
+ const tintedBg = mix(scoreColor, palette.surface, 20);
880
+
881
+ const g = overlayG.append('g').attr('class', 'journey-thought-hover');
882
+
883
+ g.append('rect')
884
+ .attr('x', bx)
885
+ .attr('y', by)
886
+ .attr('width', bw)
887
+ .attr('height', bh)
888
+ .attr('rx', CARD_RADIUS)
889
+ .attr('fill', tintedBg)
890
+ .attr('stroke', scoreColor)
891
+ .attr('stroke-width', CARD_STROKE_WIDTH);
892
+
893
+ // Connector line from bubble bottom to face top
894
+ g.append('line')
895
+ .attr('x1', fcx)
896
+ .attr('y1', by + bh)
897
+ .attr('x2', fcx)
898
+ .attr('y2', fcy - FACE_RADIUS - 1)
899
+ .attr('stroke', scoreColor)
900
+ .attr('stroke-width', CARD_STROKE_WIDTH);
901
+
902
+ const centerX = bx + bw / 2;
903
+ for (let i = 0; i < lines.length; i++) {
904
+ g.append('text')
905
+ .attr('x', centerX)
906
+ .attr('y', by + THOUGHT_PAD_Y + (i + 1) * THOUGHT_LINE_H - 2)
907
+ .attr('text-anchor', 'middle')
908
+ .attr('font-size', THOUGHT_FONT)
909
+ .attr('font-style', 'italic')
910
+ .attr('fill', palette.textMuted)
911
+ .text(lines[i]);
912
+ }
913
+ };
914
+
915
+ // Click background to unlock
916
+ svg.on('click', (event: MouseEvent) => {
917
+ const target = event.target as Element;
918
+ if (
919
+ !target.closest('.journey-face') &&
920
+ !target.closest('.journey-step') &&
921
+ !target.closest('.journey-phase') &&
922
+ !target.closest('.journey-score-label')
923
+ ) {
924
+ lockedLine = null;
925
+ lockedScore = null;
926
+ clearDimming();
927
+ clearScoreDimming();
928
+ }
929
+ });
930
+
931
+ // Find the curve face for a line number and show its thought bubble
932
+ const showThoughtForLine = (ln: number) => {
933
+ svg
934
+ .selectAll<SVGGElement, unknown>('.journey-curve-area .journey-face')
935
+ .each(function () {
936
+ const face = d3.select<SVGGElement, unknown>(this);
937
+ if (parseInt(face.attr('data-line-number') ?? '0', 10) === ln) {
938
+ showThoughtBubble(face);
939
+ }
940
+ });
941
+ };
942
+
943
+ // Hover + click on faces
944
+ svg.selectAll<SVGGElement, unknown>('.journey-face').each(function () {
945
+ const el = d3.select<SVGGElement, unknown>(this);
946
+ el.on('mouseenter', () => {
947
+ if (lockedLine !== null || lockedScore !== null) return;
948
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
949
+ if (ln) {
950
+ applyDimming(ln);
951
+ showThoughtForLine(ln);
952
+ }
953
+ })
954
+ .on('mouseleave', () => {
955
+ if (lockedLine !== null || lockedScore !== null) return;
956
+ clearDimming();
957
+ })
958
+ .on('click', (event: MouseEvent) => {
959
+ event.stopPropagation();
960
+ if (lockedScore !== null) {
961
+ lockedScore = null;
962
+ clearScoreDimming();
963
+ return;
964
+ }
965
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
966
+ if (lockedLine === ln) {
967
+ lockedLine = null;
968
+ clearDimming();
969
+ } else {
970
+ lockedLine = ln;
971
+ applyDimming(ln);
972
+ showThoughtForLine(ln);
973
+ if (onNavigateToLine && ln) onNavigateToLine(ln);
974
+ }
975
+ });
976
+ });
977
+
978
+ // Hover + click on step cards
979
+ svg.selectAll('.journey-step').each(function () {
980
+ const el = d3.select(this);
981
+ el.on('mouseenter', () => {
982
+ if (lockedLine !== null || lockedScore !== null) return;
983
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
984
+ if (ln) {
985
+ applyDimming(ln);
986
+ showThoughtForLine(ln);
987
+ }
988
+ })
989
+ .on('mouseleave', () => {
990
+ if (lockedLine !== null || lockedScore !== null) return;
991
+ clearDimming();
992
+ })
993
+ .on('click', (event: MouseEvent) => {
994
+ event.stopPropagation();
995
+ if (lockedScore !== null) {
996
+ lockedScore = null;
997
+ clearScoreDimming();
998
+ return;
999
+ }
1000
+ const ln = parseInt(el.attr('data-line-number') ?? '0', 10);
1001
+ if (lockedLine === ln) {
1002
+ lockedLine = null;
1003
+ clearDimming();
1004
+ } else {
1005
+ lockedLine = ln;
1006
+ applyDimming(ln);
1007
+ showThoughtForLine(ln);
1008
+ if (onNavigateToLine && ln) onNavigateToLine(ln);
1009
+ }
1010
+ });
1011
+ });
1012
+
1013
+ // Hover + click on y-axis score labels
1014
+ svg
1015
+ .selectAll<SVGGElement, unknown>('.journey-score-label')
1016
+ .each(function () {
1017
+ const el = d3.select<SVGGElement, unknown>(this);
1018
+ const score = parseInt(el.attr('data-score') ?? '0', 10);
1019
+ el.on('mouseenter', () => {
1020
+ if (lockedLine !== null || lockedScore !== null) return;
1021
+ applyScoreDimming(score);
1022
+ })
1023
+ .on('mouseleave', () => {
1024
+ if (lockedLine !== null || lockedScore !== null) return;
1025
+ clearScoreDimming();
1026
+ })
1027
+ .on('click', (event: MouseEvent) => {
1028
+ event.stopPropagation();
1029
+ if (lockedLine !== null) {
1030
+ lockedLine = null;
1031
+ clearDimming();
1032
+ }
1033
+ if (lockedScore === score) {
1034
+ lockedScore = null;
1035
+ clearScoreDimming();
1036
+ } else {
1037
+ lockedScore = score;
1038
+ applyScoreDimming(score);
1039
+ }
1040
+ });
1041
+ });
1042
+ }
1043
+ }
1044
+
1045
+ // ============================================================
1046
+ // Step Card Renderer
1047
+ // ============================================================
1048
+
1049
+ function resolveStepColor(
1050
+ step: JourneyMapStep,
1051
+ scoreColor: string,
1052
+ activeGroup: string | null,
1053
+ tagGroups: import('../utils/tag-groups').TagGroup[],
1054
+ _palette: PaletteColors
1055
+ ): string | undefined {
1056
+ if (!activeGroup) return undefined;
1057
+
1058
+ // "Score" is a synthetic group — use the score-to-color mapping
1059
+ if (activeGroup.toLowerCase() === 'score') {
1060
+ return step.score !== undefined ? scoreColor : undefined;
1061
+ }
1062
+
1063
+ // Tag group — find the matching group and look up the step's tag value
1064
+ const group = tagGroups.find(
1065
+ (g) => g.name.toLowerCase() === activeGroup.toLowerCase()
1066
+ );
1067
+ if (!group) return undefined;
1068
+
1069
+ const tagValue = step.tags[group.name.toLowerCase()];
1070
+ if (!tagValue) return undefined;
1071
+
1072
+ const entry = group.entries.find(
1073
+ (e) => e.value.toLowerCase() === tagValue.toLowerCase()
1074
+ );
1075
+ return entry?.color;
1076
+ }
1077
+
1078
+ function renderStepCard(
1079
+ parent: d3.Selection<SVGGElement, unknown, null, undefined>,
1080
+ sl: StepLayout,
1081
+ palette: PaletteColors,
1082
+ isDark: boolean,
1083
+ activeGroup: string | null,
1084
+ tagGroups: import('../utils/tag-groups').TagGroup[],
1085
+ onNavigateToLine?: (line: number) => void
1086
+ ): void {
1087
+ const stepG = parent
1088
+ .append('g')
1089
+ .attr('class', 'journey-step')
1090
+ .attr('data-line-number', sl.step.lineNumber);
1091
+
1092
+ // Data attributes for legend hover highlighting
1093
+ if (sl.step.score !== undefined) {
1094
+ stepG.attr('data-score', sl.step.score);
1095
+ }
1096
+ for (const [key, value] of Object.entries(sl.step.tags)) {
1097
+ stepG.attr(`data-tag-${key.toLowerCase()}`, value);
1098
+ }
1099
+
1100
+ const cx = sl.x;
1101
+ const cy = sl.y;
1102
+
1103
+ // Card colors — driven by active legend group (matches kanban pattern)
1104
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
1105
+ const resolvedColor = resolveStepColor(
1106
+ sl.step,
1107
+ sl.color,
1108
+ activeGroup,
1109
+ tagGroups,
1110
+ palette
1111
+ );
1112
+ const cardFill = resolvedColor
1113
+ ? mix(resolvedColor, cardBaseBg, 15)
1114
+ : mix(palette.primary, cardBaseBg, 15);
1115
+ const cardStroke = resolvedColor ?? palette.textMuted;
1116
+
1117
+ // Card background
1118
+ stepG
1119
+ .append('rect')
1120
+ .attr('x', cx)
1121
+ .attr('y', cy)
1122
+ .attr('width', sl.width)
1123
+ .attr('height', sl.height)
1124
+ .attr('rx', CARD_RADIUS)
1125
+ .attr('fill', cardFill)
1126
+ .attr('stroke', cardStroke)
1127
+ .attr('stroke-width', CARD_STROKE_WIDTH);
1128
+
1129
+ // Title (wrapped)
1130
+ const titleMaxW = sl.width - CARD_PADDING_X * 2;
1131
+ const titleMaxChars = Math.max(1, Math.floor(titleMaxW / TITLE_CHAR_WIDTH));
1132
+ const titleWords = sl.step.title.split(/\s+/);
1133
+ const titleLines: string[] = [];
1134
+ let titleCur = '';
1135
+ for (const w of titleWords) {
1136
+ const candidate = titleCur ? `${titleCur} ${w}` : w;
1137
+ if (candidate.length > titleMaxChars && titleCur) {
1138
+ titleLines.push(titleCur);
1139
+ titleCur = w;
1140
+ } else {
1141
+ titleCur = candidate;
1142
+ }
1143
+ }
1144
+ if (titleCur) titleLines.push(titleCur);
1145
+
1146
+ for (let i = 0; i < titleLines.length; i++) {
1147
+ stepG
1148
+ .append('text')
1149
+ .attr('x', cx + CARD_PADDING_X)
1150
+ .attr('y', cy + CARD_PADDING_Y + FONT_SIZE_STEP + i * TITLE_LINE_HEIGHT)
1151
+ .attr('font-size', FONT_SIZE_STEP)
1152
+ .attr('font-weight', '500')
1153
+ .attr('fill', palette.text)
1154
+ .text(titleLines[i]);
1155
+ }
1156
+
1157
+ const titleBlockH =
1158
+ CARD_PADDING_Y + titleLines.length * TITLE_LINE_HEIGHT + CARD_PADDING_Y;
1159
+ const cardAnnotations = sl.step.annotations;
1160
+
1161
+ // Separator line between title and content (matches kanban)
1162
+ const hasContent = sl.step.description || cardAnnotations.length > 0;
1163
+
1164
+ if (hasContent) {
1165
+ stepG
1166
+ .append('line')
1167
+ .attr('x1', cx)
1168
+ .attr('y1', cy + titleBlockH)
1169
+ .attr('x2', cx + sl.width)
1170
+ .attr('y2', cy + titleBlockH)
1171
+ .attr('stroke', cardStroke)
1172
+ .attr('stroke-opacity', 0.3)
1173
+ .attr('stroke-width', 1);
1174
+ }
1175
+
1176
+ let metaY = cy + titleBlockH + CARD_META_LINE_HEIGHT;
1177
+
1178
+ // Description (wrapped text, matches kanban metadata style)
1179
+ if (sl.step.description) {
1180
+ const descLines = wrapText(
1181
+ sl.step.description,
1182
+ sl.width - CARD_PADDING_X * 2,
1183
+ FONT_SIZE_META
1184
+ );
1185
+ for (const line of descLines) {
1186
+ stepG
1187
+ .append('text')
1188
+ .attr('x', cx + CARD_PADDING_X)
1189
+ .attr('y', metaY)
1190
+ .attr('font-size', FONT_SIZE_META)
1191
+ .attr('fill', palette.textMuted)
1192
+ .text(line);
1193
+ metaY += CARD_META_LINE_HEIGHT;
1194
+ }
1195
+ }
1196
+
1197
+ // Annotations (icon bullet + indented text)
1198
+ const ANNO_ICON_SIZE = 10;
1199
+ const ANNO_ICON_GAP = 4;
1200
+ const annoIconIndent = ANNO_ICON_SIZE + ANNO_ICON_GAP;
1201
+ const annoTextW = sl.width - CARD_PADDING_X * 2 - annoIconIndent;
1202
+ for (const anno of cardAnnotations) {
1203
+ const annoColor = annotationColor(anno.type, palette);
1204
+ const iconPaths = annotationIconPaths(anno.type);
1205
+ const annoLines = wrapText(anno.text, annoTextW, FONT_SIZE_META);
1206
+ // Icon as bullet, aligned to first line
1207
+ renderAnnotationIcon(
1208
+ stepG,
1209
+ cx + CARD_PADDING_X,
1210
+ metaY - ANNO_ICON_SIZE + 1,
1211
+ ANNO_ICON_SIZE,
1212
+ iconPaths,
1213
+ annoColor
1214
+ );
1215
+ // All text lines indented past the icon
1216
+ for (let li = 0; li < annoLines.length; li++) {
1217
+ stepG
1218
+ .append('text')
1219
+ .attr('x', cx + CARD_PADDING_X + annoIconIndent)
1220
+ .attr('y', metaY)
1221
+ .attr('font-size', FONT_SIZE_META)
1222
+ .attr('fill', annoColor)
1223
+ .text(annoLines[li]);
1224
+ metaY += CARD_META_LINE_HEIGHT;
1225
+ }
1226
+ }
1227
+
1228
+ // Tag strip — colored bar above the card
1229
+ for (const [key, value] of Object.entries(sl.step.tags)) {
1230
+ const group = tagGroups.find(
1231
+ (g) => g.name.toLowerCase() === key.toLowerCase()
1232
+ );
1233
+ const entry = group?.entries.find(
1234
+ (e) => e.value.toLowerCase() === value.toLowerCase()
1235
+ );
1236
+ const stripColor = entry?.color ?? palette.textMuted;
1237
+ const TAG_GAP = 6;
1238
+ const stripY = cy - TAG_STRIP_HEIGHT - TAG_GAP;
1239
+ const cardBaseBg = isDark ? palette.surface : palette.bg;
1240
+ const stripFill = mix(stripColor, cardBaseBg, 15);
1241
+
1242
+ stepG
1243
+ .append('rect')
1244
+ .attr('x', cx)
1245
+ .attr('y', stripY)
1246
+ .attr('width', sl.width)
1247
+ .attr('height', TAG_STRIP_HEIGHT)
1248
+ .attr('rx', CARD_RADIUS)
1249
+ .attr('fill', stripFill)
1250
+ .attr('stroke', stripColor)
1251
+ .attr('stroke-width', CARD_STROKE_WIDTH);
1252
+
1253
+ stepG
1254
+ .append('text')
1255
+ .attr('x', cx + sl.width / 2)
1256
+ .attr('y', stripY + TAG_STRIP_HEIGHT / 2 + FONT_SIZE_META / 2 - 1)
1257
+ .attr('text-anchor', 'middle')
1258
+ .attr('font-size', FONT_SIZE_META)
1259
+ .attr('fill', palette.text)
1260
+ .text(value);
1261
+ }
1262
+
1263
+ if (onNavigateToLine) {
1264
+ stepG.style('cursor', 'pointer').on('click', (event: MouseEvent) => {
1265
+ event.stopPropagation();
1266
+ onNavigateToLine(sl.step.lineNumber);
1267
+ });
1268
+ }
1269
+ }
1270
+
1271
+ // ============================================================
1272
+ // Helpers
1273
+ // ============================================================
1274
+
1275
+ // ============================================================
1276
+ // Persona Silhouette
1277
+ // ============================================================
1278
+
1279
+ function renderPersonaSilhouette(
1280
+ parent: d3.Selection<SVGGElement, unknown, null, undefined>,
1281
+ cx: number,
1282
+ cy: number,
1283
+ color: string,
1284
+ palette: PaletteColors,
1285
+ scale = 1
1286
+ ): void {
1287
+ // Solid color border, muted fill that stands out slightly from card bg
1288
+ const fill = mix(color, palette.bg, 70);
1289
+ const stroke = color;
1290
+ const s = scale;
1291
+
1292
+ // Torso + neck (drawn first so head overlaps the junction cleanly)
1293
+ parent
1294
+ .append('path')
1295
+ .attr(
1296
+ 'd',
1297
+ `M ${cx - 5 * s} ${cy + 6 * s}` +
1298
+ ` L ${cx - 5 * s} ${cy + 11 * s}` +
1299
+ ` L ${cx - 8 * s} ${cy + 11 * s}` +
1300
+ ` Q ${cx - 20 * s} ${cy + 12 * s}, ${cx - 20 * s} ${cy + 22 * s}` +
1301
+ ` L ${cx - 20 * s} ${cy + 36 * s}` +
1302
+ ` L ${cx + 20 * s} ${cy + 36 * s}` +
1303
+ ` L ${cx + 20 * s} ${cy + 22 * s}` +
1304
+ ` Q ${cx + 20 * s} ${cy + 12 * s}, ${cx + 8 * s} ${cy + 11 * s}` +
1305
+ ` L ${cx + 5 * s} ${cy + 11 * s}` +
1306
+ ` L ${cx + 5 * s} ${cy + 6 * s}` +
1307
+ ` Z`
1308
+ )
1309
+ .attr('fill', fill)
1310
+ .attr('stroke', stroke)
1311
+ .attr('stroke-width', 1.2);
1312
+
1313
+ // Head — oval (drawn on top, covers neck junction)
1314
+ parent
1315
+ .append('path')
1316
+ .attr(
1317
+ 'd',
1318
+ `M ${cx} ${cy - 12 * s}` +
1319
+ ` C ${cx + 10 * s} ${cy - 12 * s}, ${cx + 9 * s} ${cy + 2 * s}, ${cx + 6 * s} ${cy + 6 * s}` +
1320
+ ` Q ${cx} ${cy + 9 * s}, ${cx - 6 * s} ${cy + 6 * s}` +
1321
+ ` C ${cx - 9 * s} ${cy + 2 * s}, ${cx - 10 * s} ${cy - 12 * s}, ${cx} ${cy - 12 * s}` +
1322
+ ` Z`
1323
+ )
1324
+ .attr('fill', fill)
1325
+ .attr('stroke', stroke)
1326
+ .attr('stroke-width', 1.2);
1327
+ }
1328
+
1329
+ // ============================================================
1330
+ // Score Face Icon (SVG smiley)
1331
+ // ============================================================
1332
+
1333
+ function renderScoreFace(
1334
+ parent: d3.Selection<SVGGElement, unknown, null, undefined>,
1335
+ cx: number,
1336
+ cy: number,
1337
+ score: number,
1338
+ palette: PaletteColors,
1339
+ radius?: number
1340
+ ): d3.Selection<SVGGElement, unknown, null, undefined> {
1341
+ const r = radius ?? FACE_RADIUS;
1342
+ const color = scoreToColor(score, palette);
1343
+ const g = parent
1344
+ .append('g')
1345
+ .attr('class', 'journey-face')
1346
+ .attr('data-cx', cx)
1347
+ .attr('data-cy', cy);
1348
+
1349
+ // Face circle
1350
+ g.append('circle')
1351
+ .attr('cx', cx)
1352
+ .attr('cy', cy)
1353
+ .attr('r', r)
1354
+ .attr('fill', color)
1355
+ .attr('stroke', palette.bg)
1356
+ .attr('stroke-width', 1.5);
1357
+
1358
+ // Eyes
1359
+ const eyeColor = contrastText(color, '#ffffff', '#000000');
1360
+ const eyeY = cy - r * 0.15;
1361
+ const eyeSpacing = r * 0.32;
1362
+ const eyeR = r * 0.12;
1363
+ g.append('circle')
1364
+ .attr('cx', cx - eyeSpacing)
1365
+ .attr('cy', eyeY)
1366
+ .attr('r', eyeR)
1367
+ .attr('fill', eyeColor);
1368
+ g.append('circle')
1369
+ .attr('cx', cx + eyeSpacing)
1370
+ .attr('cy', eyeY)
1371
+ .attr('r', eyeR)
1372
+ .attr('fill', eyeColor);
1373
+
1374
+ // Mouth — arc curvature based on score
1375
+ // score 5: big smile, score 3: straight, score 1: deep frown
1376
+ const mouthY = cy + r * 0.25;
1377
+ const mouthW = r * 0.45;
1378
+ // Curve amount: positive = smile, negative = frown
1379
+ const curve = ((score - 3) / 2) * r * 0.35;
1380
+
1381
+ g.append('path')
1382
+ .attr(
1383
+ 'd',
1384
+ `M ${cx - mouthW} ${mouthY} Q ${cx} ${mouthY + curve} ${cx + mouthW} ${mouthY}`
1385
+ )
1386
+ .attr('fill', 'none')
1387
+ .attr('stroke', eyeColor)
1388
+ .attr('stroke-width', 1.2)
1389
+ .attr('stroke-linecap', 'round');
1390
+
1391
+ return g;
1392
+ }
1393
+
1394
+ function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
1395
+ const charWidth = fontSize * 0.6;
1396
+ const maxChars = Math.floor(maxWidth / charWidth);
1397
+ if (maxChars <= 0) return [text];
1398
+
1399
+ const words = text.split(/\s+/);
1400
+ const lines: string[] = [];
1401
+ let current = '';
1402
+
1403
+ for (const word of words) {
1404
+ const candidate = current ? `${current} ${word}` : word;
1405
+ if (candidate.length > maxChars && current) {
1406
+ lines.push(current);
1407
+ current = word;
1408
+ } else {
1409
+ current = candidate;
1410
+ }
1411
+ }
1412
+ if (current) lines.push(current);
1413
+ return lines;
1414
+ }
1415
+
1416
+ function truncateText(text: string, maxWidth: number): string {
1417
+ const maxChars = Math.floor(maxWidth / 6.6);
1418
+ if (text.length <= maxChars) return text;
1419
+ return text.substring(0, maxChars - 1) + '\u2026';
1420
+ }
1421
+
1422
+ function annotationColor(
1423
+ type: JourneyMapAnnotation['type'],
1424
+ palette: PaletteColors
1425
+ ): string {
1426
+ switch (type) {
1427
+ case 'pain':
1428
+ return palette.colors.red;
1429
+ case 'opportunity':
1430
+ return palette.colors.green;
1431
+ case 'thought':
1432
+ return palette.textMuted;
1433
+ }
1434
+ }
1435
+
1436
+ // Lucide icon paths (24×24 viewBox, ISC license)
1437
+ const ICON_THUMBS_DOWN: string[] = [
1438
+ 'M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z',
1439
+ 'M17 14V2',
1440
+ ];
1441
+ const ICON_THUMBS_UP: string[] = [
1442
+ 'M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z',
1443
+ 'M7 10v12',
1444
+ ];
1445
+ const ICON_THOUGHT: string[] = [
1446
+ 'M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5',
1447
+ 'M9 18h6',
1448
+ 'M10 22h4',
1449
+ ];
1450
+
1451
+ function annotationIconPaths(type: JourneyMapAnnotation['type']): string[] {
1452
+ switch (type) {
1453
+ case 'pain':
1454
+ return ICON_THUMBS_DOWN;
1455
+ case 'opportunity':
1456
+ return ICON_THUMBS_UP;
1457
+ case 'thought':
1458
+ return ICON_THOUGHT;
1459
+ }
1460
+ }
1461
+
1462
+ /** Render a lucide-style icon (24×24 viewBox) scaled to fit `size` px. */
1463
+ function renderAnnotationIcon(
1464
+ parent: d3.Selection<SVGGElement, unknown, null, undefined>,
1465
+ x: number,
1466
+ y: number,
1467
+ size: number,
1468
+ paths: string[],
1469
+ color: string
1470
+ ): void {
1471
+ const g = parent.append('g').attr('transform', `translate(${x}, ${y})`);
1472
+ const scale = size / 24;
1473
+ const inner = g.append('g').attr('transform', `scale(${scale})`);
1474
+ for (const d of paths) {
1475
+ inner
1476
+ .append('path')
1477
+ .attr('d', d)
1478
+ .attr('fill', 'none')
1479
+ .attr('stroke', color)
1480
+ .attr('stroke-width', 2)
1481
+ .attr('stroke-linecap', 'round')
1482
+ .attr('stroke-linejoin', 'round');
1483
+ }
1484
+ }
1485
+
1486
+ // ============================================================
1487
+ // Export Renderer
1488
+ // ============================================================
1489
+
1490
+ export function renderJourneyMapForExport(
1491
+ content: string,
1492
+ theme: 'light' | 'dark' | 'transparent',
1493
+ palette: PaletteColors
1494
+ ): string {
1495
+ const parsed = parseJourneyMap(content, palette);
1496
+ if (
1497
+ parsed.error ||
1498
+ (parsed.phases.length === 0 && parsed.steps.length === 0)
1499
+ ) {
1500
+ return '';
1501
+ }
1502
+
1503
+ const isDark = theme === 'dark';
1504
+ const layout = layoutJourneyMap(parsed, palette);
1505
+
1506
+ const container = document.createElement('div');
1507
+ renderJourneyMap(container, parsed, palette, isDark, {
1508
+ exportDims: { width: layout.totalWidth, height: layout.totalHeight },
1509
+ });
1510
+
1511
+ const svgEl = container.querySelector('svg');
1512
+ if (!svgEl) return '';
1513
+
1514
+ // Handle transparent background
1515
+ if (theme === 'transparent') {
1516
+ const bgRect = svgEl.querySelector('rect:first-child');
1517
+ if (bgRect) bgRect.remove();
1518
+ }
1519
+
1520
+ return svgEl.outerHTML;
1521
+ }