@diagrammo/dgmo 0.8.21 → 0.8.22

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