@diagrammo/dgmo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/d3.ts ADDED
@@ -0,0 +1,5021 @@
1
+ import * as d3Scale from 'd3-scale';
2
+ import * as d3Selection from 'd3-selection';
3
+ import * as d3Shape from 'd3-shape';
4
+ import * as d3Array from 'd3-array';
5
+ import cloud from 'd3-cloud';
6
+
7
+ // ============================================================
8
+ // Types
9
+ // ============================================================
10
+
11
+ export type D3ChartType =
12
+ | 'slope'
13
+ | 'wordcloud'
14
+ | 'arc'
15
+ | 'timeline'
16
+ | 'venn'
17
+ | 'quadrant'
18
+ | 'sequence';
19
+
20
+ export interface D3DataItem {
21
+ label: string;
22
+ values: number[];
23
+ color: string | null;
24
+ lineNumber: number;
25
+ }
26
+
27
+ export interface WordCloudWord {
28
+ text: string;
29
+ weight: number;
30
+ lineNumber: number;
31
+ }
32
+
33
+ export type WordCloudRotate = 'none' | 'mixed' | 'angled';
34
+
35
+ export interface WordCloudOptions {
36
+ rotate: WordCloudRotate;
37
+ max: number;
38
+ minSize: number;
39
+ maxSize: number;
40
+ }
41
+
42
+ const DEFAULT_CLOUD_OPTIONS: WordCloudOptions = {
43
+ rotate: 'none',
44
+ max: 0,
45
+ minSize: 14,
46
+ maxSize: 80,
47
+ };
48
+
49
+ export interface ArcLink {
50
+ source: string;
51
+ target: string;
52
+ value: number;
53
+ color: string | null;
54
+ lineNumber: number;
55
+ }
56
+
57
+ export type ArcOrder = 'appearance' | 'name' | 'group' | 'degree';
58
+
59
+ export interface ArcNodeGroup {
60
+ name: string;
61
+ nodes: string[];
62
+ color: string | null;
63
+ lineNumber: number;
64
+ }
65
+
66
+ export type TimelineSort = 'time' | 'group';
67
+
68
+ export interface TimelineEvent {
69
+ date: string;
70
+ endDate: string | null;
71
+ label: string;
72
+ group: string | null;
73
+ lineNumber: number;
74
+ uncertain?: boolean;
75
+ }
76
+
77
+ export interface TimelineGroup {
78
+ name: string;
79
+ color: string | null;
80
+ lineNumber: number;
81
+ }
82
+
83
+ export interface TimelineEra {
84
+ startDate: string;
85
+ endDate: string;
86
+ label: string;
87
+ color: string | null;
88
+ }
89
+
90
+ export interface TimelineMarker {
91
+ date: string;
92
+ label: string;
93
+ color: string | null;
94
+ lineNumber: number;
95
+ }
96
+
97
+ export interface VennSet {
98
+ name: string;
99
+ size: number;
100
+ color: string | null;
101
+ label: string | null;
102
+ lineNumber: number;
103
+ }
104
+
105
+ export interface VennOverlap {
106
+ sets: string[];
107
+ size: number;
108
+ label: string | null;
109
+ lineNumber: number;
110
+ }
111
+
112
+ export interface QuadrantLabel {
113
+ text: string;
114
+ color: string | null;
115
+ lineNumber: number;
116
+ }
117
+
118
+ export interface QuadrantPoint {
119
+ label: string;
120
+ x: number;
121
+ y: number;
122
+ lineNumber: number;
123
+ }
124
+
125
+ export interface QuadrantLabels {
126
+ topRight: QuadrantLabel | null;
127
+ topLeft: QuadrantLabel | null;
128
+ bottomLeft: QuadrantLabel | null;
129
+ bottomRight: QuadrantLabel | null;
130
+ }
131
+
132
+ export interface ParsedD3 {
133
+ type: D3ChartType | null;
134
+ title: string | null;
135
+ orientation: 'horizontal' | 'vertical';
136
+ periods: string[];
137
+ data: D3DataItem[];
138
+ words: WordCloudWord[];
139
+ cloudOptions: WordCloudOptions;
140
+ links: ArcLink[];
141
+ arcOrder: ArcOrder;
142
+ arcNodeGroups: ArcNodeGroup[];
143
+ timelineEvents: TimelineEvent[];
144
+ timelineGroups: TimelineGroup[];
145
+ timelineEras: TimelineEra[];
146
+ timelineMarkers: TimelineMarker[];
147
+ timelineSort: TimelineSort;
148
+ timelineScale: boolean;
149
+ timelineSwimlanes: boolean;
150
+ vennSets: VennSet[];
151
+ vennOverlaps: VennOverlap[];
152
+ vennShowValues: boolean;
153
+ // Quadrant chart fields
154
+ quadrantLabels: QuadrantLabels;
155
+ quadrantPoints: QuadrantPoint[];
156
+ quadrantXAxis: [string, string] | null;
157
+ quadrantXAxisLineNumber: number | null;
158
+ quadrantYAxis: [string, string] | null;
159
+ quadrantYAxisLineNumber: number | null;
160
+ quadrantTitleLineNumber: number | null;
161
+ error: string | null;
162
+ }
163
+
164
+ // ============================================================
165
+ // Color Imports
166
+ // ============================================================
167
+
168
+ import { resolveColor } from './colors';
169
+ import type { PaletteColors } from './palettes';
170
+ import { getSeriesColors } from './palettes';
171
+
172
+ // ============================================================
173
+ // Timeline Date Helper
174
+ // ============================================================
175
+
176
+ /**
177
+ * Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a fractional year number.
178
+ */
179
+ export function parseTimelineDate(s: string): number {
180
+ const parts = s.split('-').map((p) => parseInt(p, 10));
181
+ const year = parts[0];
182
+ const month = parts.length >= 2 ? parts[1] : 1;
183
+ const day = parts.length >= 3 ? parts[2] : 1;
184
+ return year + (month - 1) / 12 + (day - 1) / 365;
185
+ }
186
+
187
+ /**
188
+ * Adds a duration to a date string and returns the resulting date string.
189
+ * Supports: d (days), w (weeks), m (months), y (years)
190
+ * Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
191
+ * Preserves the precision of the input date (YYYY, YYYY-MM, or YYYY-MM-DD).
192
+ */
193
+ export function addDurationToDate(
194
+ startDate: string,
195
+ amount: number,
196
+ unit: 'd' | 'w' | 'm' | 'y'
197
+ ): string {
198
+ const parts = startDate.split('-').map((p) => parseInt(p, 10));
199
+ const year = parts[0];
200
+ const month = parts.length >= 2 ? parts[1] : 1;
201
+ const day = parts.length >= 3 ? parts[2] : 1;
202
+
203
+ const date = new Date(year, month - 1, day);
204
+
205
+ switch (unit) {
206
+ case 'd':
207
+ // Round days to nearest integer
208
+ date.setDate(date.getDate() + Math.round(amount));
209
+ break;
210
+ case 'w':
211
+ // Convert weeks to days, round to nearest integer
212
+ date.setDate(date.getDate() + Math.round(amount * 7));
213
+ break;
214
+ case 'm': {
215
+ // Add whole months, then remaining days
216
+ const wholeMonths = Math.floor(amount);
217
+ const fractionalDays = Math.round((amount - wholeMonths) * 30);
218
+ date.setMonth(date.getMonth() + wholeMonths);
219
+ if (fractionalDays > 0) {
220
+ date.setDate(date.getDate() + fractionalDays);
221
+ }
222
+ break;
223
+ }
224
+ case 'y': {
225
+ // Add whole years, then remaining months
226
+ const wholeYears = Math.floor(amount);
227
+ const fractionalMonths = Math.round((amount - wholeYears) * 12);
228
+ date.setFullYear(date.getFullYear() + wholeYears);
229
+ if (fractionalMonths > 0) {
230
+ date.setMonth(date.getMonth() + fractionalMonths);
231
+ }
232
+ break;
233
+ }
234
+ }
235
+
236
+ // Preserve original precision
237
+ const endYear = date.getFullYear();
238
+ const endMonth = String(date.getMonth() + 1).padStart(2, '0');
239
+ const endDay = String(date.getDate()).padStart(2, '0');
240
+
241
+ if (parts.length === 1) {
242
+ return String(endYear);
243
+ } else if (parts.length === 2) {
244
+ return `${endYear}-${endMonth}`;
245
+ } else {
246
+ return `${endYear}-${endMonth}-${endDay}`;
247
+ }
248
+ }
249
+
250
+ // ============================================================
251
+ // Parser
252
+ // ============================================================
253
+
254
+ /**
255
+ * Parses D3 chart text format into structured data.
256
+ */
257
+ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
258
+ const result: ParsedD3 = {
259
+ type: null,
260
+ title: null,
261
+ orientation: 'horizontal',
262
+ periods: [],
263
+ data: [],
264
+ words: [],
265
+ cloudOptions: { ...DEFAULT_CLOUD_OPTIONS },
266
+ links: [],
267
+ arcOrder: 'appearance',
268
+ arcNodeGroups: [],
269
+ timelineEvents: [],
270
+ timelineGroups: [],
271
+ timelineEras: [],
272
+ timelineMarkers: [],
273
+ timelineSort: 'time',
274
+ timelineScale: true,
275
+ timelineSwimlanes: false,
276
+ vennSets: [],
277
+ vennOverlaps: [],
278
+ vennShowValues: false,
279
+ quadrantLabels: {
280
+ topRight: null,
281
+ topLeft: null,
282
+ bottomLeft: null,
283
+ bottomRight: null,
284
+ },
285
+ quadrantPoints: [],
286
+ quadrantXAxis: null,
287
+ quadrantXAxisLineNumber: null,
288
+ quadrantYAxis: null,
289
+ quadrantYAxisLineNumber: null,
290
+ quadrantTitleLineNumber: null,
291
+ error: null,
292
+ };
293
+
294
+ if (!content || !content.trim()) {
295
+ result.error = 'Empty content';
296
+ return result;
297
+ }
298
+
299
+ const lines = content.split('\n');
300
+ const freeformLines: string[] = [];
301
+ let currentArcGroup: string | null = null;
302
+ let currentTimelineGroup: string | null = null;
303
+
304
+ for (let i = 0; i < lines.length; i++) {
305
+ const line = lines[i].trim();
306
+ const lineNumber = i + 1;
307
+
308
+ // Skip empty lines
309
+ if (!line) continue;
310
+
311
+ // ## Section headers for arc diagram node grouping (before # comment check)
312
+ const sectionMatch = line.match(/^#{2,}\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/);
313
+ if (sectionMatch) {
314
+ if (result.type === 'arc') {
315
+ const name = sectionMatch[1].trim();
316
+ const color = sectionMatch[2]
317
+ ? resolveColor(sectionMatch[2].trim(), palette)
318
+ : null;
319
+ result.arcNodeGroups.push({ name, nodes: [], color, lineNumber });
320
+ currentArcGroup = name;
321
+ } else if (result.type === 'timeline') {
322
+ const name = sectionMatch[1].trim();
323
+ const color = sectionMatch[2]
324
+ ? resolveColor(sectionMatch[2].trim(), palette)
325
+ : null;
326
+ result.timelineGroups.push({ name, color, lineNumber });
327
+ currentTimelineGroup = name;
328
+ }
329
+ continue;
330
+ }
331
+
332
+ // Skip comments
333
+ if (line.startsWith('#') || line.startsWith('//')) {
334
+ continue;
335
+ }
336
+
337
+ // Arc link line: source -> target(color): weight
338
+ if (result.type === 'arc') {
339
+ const linkMatch = line.match(
340
+ /^(.+?)\s*->\s*(.+?)(?:\(([^)]+)\))?\s*(?::\s*(\d+(?:\.\d+)?))?$/
341
+ );
342
+ if (linkMatch) {
343
+ const source = linkMatch[1].trim();
344
+ const target = linkMatch[2].trim();
345
+ const linkColor = linkMatch[3]
346
+ ? resolveColor(linkMatch[3].trim(), palette)
347
+ : null;
348
+ result.links.push({
349
+ source,
350
+ target,
351
+ value: linkMatch[4] ? parseFloat(linkMatch[4]) : 1,
352
+ color: linkColor,
353
+ lineNumber,
354
+ });
355
+ // Assign nodes to current group (first-appearance wins)
356
+ if (currentArcGroup !== null) {
357
+ const group = result.arcNodeGroups.find(
358
+ (g) => g.name === currentArcGroup
359
+ );
360
+ if (group) {
361
+ const allGrouped = new Set(
362
+ result.arcNodeGroups.flatMap((g) => g.nodes)
363
+ );
364
+ if (!allGrouped.has(source)) group.nodes.push(source);
365
+ if (!allGrouped.has(target)) group.nodes.push(target);
366
+ }
367
+ }
368
+ continue;
369
+ }
370
+ }
371
+
372
+ // Timeline era lines: era YYYY->YYYY: Label (color)
373
+ if (result.type === 'timeline') {
374
+ const eraMatch = line.match(
375
+ /^era\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
376
+ );
377
+ if (eraMatch) {
378
+ const colorAnnotation = eraMatch[4]?.trim() || null;
379
+ result.timelineEras.push({
380
+ startDate: eraMatch[1],
381
+ endDate: eraMatch[2],
382
+ label: eraMatch[3].trim(),
383
+ color: colorAnnotation
384
+ ? resolveColor(colorAnnotation, palette)
385
+ : null,
386
+ });
387
+ continue;
388
+ }
389
+
390
+ // Timeline marker lines: marker YYYY: Label (color)
391
+ const markerMatch = line.match(
392
+ /^marker\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
393
+ );
394
+ if (markerMatch) {
395
+ const colorAnnotation = markerMatch[3]?.trim() || null;
396
+ result.timelineMarkers.push({
397
+ date: markerMatch[1],
398
+ label: markerMatch[2].trim(),
399
+ color: colorAnnotation
400
+ ? resolveColor(colorAnnotation, palette)
401
+ : null,
402
+ lineNumber,
403
+ });
404
+ continue;
405
+ }
406
+ }
407
+
408
+ // Timeline event lines: duration, range, or point
409
+ if (result.type === 'timeline') {
410
+ // Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years)
411
+ // Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
412
+ // Supports uncertain end with ? prefix (e.g., ->?3m fades out the last 20%)
413
+ const durationMatch = line.match(
414
+ /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\?)?(\d+(?:\.\d{1,2})?)([dwmy])\s*:\s*(.+)$/
415
+ );
416
+ if (durationMatch) {
417
+ const startDate = durationMatch[1];
418
+ const uncertain = durationMatch[2] === '?';
419
+ const amount = parseFloat(durationMatch[3]);
420
+ const unit = durationMatch[4] as 'd' | 'w' | 'm' | 'y';
421
+ const endDate = addDurationToDate(startDate, amount, unit);
422
+ result.timelineEvents.push({
423
+ date: startDate,
424
+ endDate,
425
+ label: durationMatch[5].trim(),
426
+ group: currentTimelineGroup,
427
+ lineNumber,
428
+ uncertain,
429
+ });
430
+ continue;
431
+ }
432
+
433
+ // Range event: 1655->1667: description (supports uncertain end: 1655->?1667)
434
+ const rangeMatch = line.match(
435
+ /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\?)?(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
436
+ );
437
+ if (rangeMatch) {
438
+ result.timelineEvents.push({
439
+ date: rangeMatch[1],
440
+ endDate: rangeMatch[3],
441
+ label: rangeMatch[4].trim(),
442
+ group: currentTimelineGroup,
443
+ lineNumber,
444
+ uncertain: rangeMatch[2] === '?',
445
+ });
446
+ continue;
447
+ }
448
+
449
+ // Point event: 1718: description
450
+ const pointMatch = line.match(
451
+ /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
452
+ );
453
+ if (pointMatch) {
454
+ result.timelineEvents.push({
455
+ date: pointMatch[1],
456
+ endDate: null,
457
+ label: pointMatch[2].trim(),
458
+ group: currentTimelineGroup,
459
+ lineNumber,
460
+ });
461
+ continue;
462
+ }
463
+ }
464
+
465
+ // Venn overlap line: "A & B: size" or "A & B & C: size \"label\""
466
+ if (result.type === 'venn') {
467
+ const overlapMatch = line.match(
468
+ /^(.+?&.+?)\s*:\s*(\d+(?:\.\d+)?)\s*(?:"([^"]*)")?\s*$/
469
+ );
470
+ if (overlapMatch) {
471
+ const sets = overlapMatch[1]
472
+ .split('&')
473
+ .map((s) => s.trim())
474
+ .filter(Boolean)
475
+ .sort();
476
+ const size = parseFloat(overlapMatch[2]);
477
+ const label = overlapMatch[3] ?? null;
478
+ result.vennOverlaps.push({ sets, size, label, lineNumber });
479
+ continue;
480
+ }
481
+
482
+ // Venn set line: "Name: size" or "Name(color): size \"label\""
483
+ const setMatch = line.match(
484
+ /^(.+?)(?:\(([^)]+)\))?\s*:\s*(\d+(?:\.\d+)?)\s*(?:"([^"]*)")?\s*$/
485
+ );
486
+ if (setMatch) {
487
+ const name = setMatch[1].trim();
488
+ const color = setMatch[2]
489
+ ? resolveColor(setMatch[2].trim(), palette)
490
+ : null;
491
+ const size = parseFloat(setMatch[3]);
492
+ const label = setMatch[4] ?? null;
493
+ result.vennSets.push({ name, size, color, label, lineNumber });
494
+ continue;
495
+ }
496
+ }
497
+
498
+ // Quadrant-specific parsing
499
+ if (result.type === 'quadrant') {
500
+ // x-axis: Low, High
501
+ const xAxisMatch = line.match(/^x-axis\s*:\s*(.+)/i);
502
+ if (xAxisMatch) {
503
+ const parts = xAxisMatch[1].split(',').map((s) => s.trim());
504
+ if (parts.length >= 2) {
505
+ result.quadrantXAxis = [parts[0], parts[1]];
506
+ result.quadrantXAxisLineNumber = lineNumber;
507
+ }
508
+ continue;
509
+ }
510
+
511
+ // y-axis: Low, High
512
+ const yAxisMatch = line.match(/^y-axis\s*:\s*(.+)/i);
513
+ if (yAxisMatch) {
514
+ const parts = yAxisMatch[1].split(',').map((s) => s.trim());
515
+ if (parts.length >= 2) {
516
+ result.quadrantYAxis = [parts[0], parts[1]];
517
+ result.quadrantYAxisLineNumber = lineNumber;
518
+ }
519
+ continue;
520
+ }
521
+
522
+ // Quadrant position labels: top-right: Label (color)
523
+ const quadrantLabelRe =
524
+ /^(top-right|top-left|bottom-left|bottom-right)\s*:\s*(.+)/i;
525
+ const quadrantMatch = line.match(quadrantLabelRe);
526
+ if (quadrantMatch) {
527
+ const position = quadrantMatch[1].toLowerCase();
528
+ const labelPart = quadrantMatch[2].trim();
529
+ // Check for color annotation: "Label (color)" or "Label(color)"
530
+ const labelColorMatch = labelPart.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
531
+ const text = labelColorMatch ? labelColorMatch[1].trim() : labelPart;
532
+ const color = labelColorMatch
533
+ ? resolveColor(labelColorMatch[2].trim(), palette)
534
+ : null;
535
+ const label: QuadrantLabel = { text, color, lineNumber };
536
+
537
+ if (position === 'top-right') result.quadrantLabels.topRight = label;
538
+ else if (position === 'top-left') result.quadrantLabels.topLeft = label;
539
+ else if (position === 'bottom-left')
540
+ result.quadrantLabels.bottomLeft = label;
541
+ else if (position === 'bottom-right')
542
+ result.quadrantLabels.bottomRight = label;
543
+ continue;
544
+ }
545
+
546
+ // Data points: Label: x, y
547
+ const pointMatch = line.match(
548
+ /^(.+?):\s*([0-9]*\.?[0-9]+)\s*,\s*([0-9]*\.?[0-9]+)\s*$/
549
+ );
550
+ if (pointMatch) {
551
+ const label = pointMatch[1].trim();
552
+ // Skip if it looks like a quadrant position keyword
553
+ const lowerLabel = label.toLowerCase();
554
+ if (
555
+ lowerLabel !== 'top-right' &&
556
+ lowerLabel !== 'top-left' &&
557
+ lowerLabel !== 'bottom-left' &&
558
+ lowerLabel !== 'bottom-right'
559
+ ) {
560
+ result.quadrantPoints.push({
561
+ label,
562
+ x: parseFloat(pointMatch[2]),
563
+ y: parseFloat(pointMatch[3]),
564
+ lineNumber,
565
+ });
566
+ }
567
+ continue;
568
+ }
569
+ }
570
+
571
+ // Check for metadata lines
572
+ const colonIndex = line.indexOf(':');
573
+
574
+ if (colonIndex !== -1) {
575
+ const rawKey = line.substring(0, colonIndex).trim();
576
+ const key = rawKey.toLowerCase();
577
+
578
+ // Check for color annotation in raw key: "Label(color)"
579
+ const colorMatch = rawKey.match(/^(.+?)\(([^)]+)\)\s*$/);
580
+
581
+ if (key === 'chart') {
582
+ const value = line
583
+ .substring(colonIndex + 1)
584
+ .trim()
585
+ .toLowerCase();
586
+ if (
587
+ value === 'slope' ||
588
+ value === 'wordcloud' ||
589
+ value === 'arc' ||
590
+ value === 'timeline' ||
591
+ value === 'venn' ||
592
+ value === 'quadrant' ||
593
+ value === 'sequence'
594
+ ) {
595
+ result.type = value;
596
+ } else {
597
+ result.error = `Unsupported chart type: ${value}. Supported types: slope, wordcloud, arc, timeline, venn, quadrant, sequence`;
598
+ return result;
599
+ }
600
+ continue;
601
+ }
602
+
603
+ if (key === 'title') {
604
+ result.title = line.substring(colonIndex + 1).trim();
605
+ if (result.type === 'quadrant') {
606
+ result.quadrantTitleLineNumber = lineNumber;
607
+ }
608
+ continue;
609
+ }
610
+
611
+ if (key === 'orientation') {
612
+ const v = line
613
+ .substring(colonIndex + 1)
614
+ .trim()
615
+ .toLowerCase();
616
+ if (v === 'horizontal' || v === 'vertical') {
617
+ result.orientation = v;
618
+ }
619
+ continue;
620
+ }
621
+
622
+ if (key === 'order') {
623
+ const v = line
624
+ .substring(colonIndex + 1)
625
+ .trim()
626
+ .toLowerCase();
627
+ if (v === 'name' || v === 'group' || v === 'degree') {
628
+ result.arcOrder = v;
629
+ }
630
+ continue;
631
+ }
632
+
633
+ if (key === 'sort') {
634
+ const v = line
635
+ .substring(colonIndex + 1)
636
+ .trim()
637
+ .toLowerCase();
638
+ if (v === 'time' || v === 'group') {
639
+ result.timelineSort = v;
640
+ }
641
+ continue;
642
+ }
643
+
644
+ if (key === 'swimlanes') {
645
+ const v = line
646
+ .substring(colonIndex + 1)
647
+ .trim()
648
+ .toLowerCase();
649
+ if (v === 'on') {
650
+ result.timelineSwimlanes = true;
651
+ } else if (v === 'off') {
652
+ result.timelineSwimlanes = false;
653
+ }
654
+ continue;
655
+ }
656
+
657
+ if (key === 'values') {
658
+ const v = line
659
+ .substring(colonIndex + 1)
660
+ .trim()
661
+ .toLowerCase();
662
+ if (v === 'off') {
663
+ result.vennShowValues = false;
664
+ } else if (v === 'on') {
665
+ result.vennShowValues = true;
666
+ }
667
+ continue;
668
+ }
669
+
670
+ if (key === 'rotate') {
671
+ const v = line
672
+ .substring(colonIndex + 1)
673
+ .trim()
674
+ .toLowerCase();
675
+ if (v === 'none' || v === 'mixed' || v === 'angled') {
676
+ result.cloudOptions.rotate = v;
677
+ }
678
+ continue;
679
+ }
680
+
681
+ if (key === 'max') {
682
+ const v = parseInt(line.substring(colonIndex + 1).trim(), 10);
683
+ if (!isNaN(v) && v > 0) {
684
+ result.cloudOptions.max = v;
685
+ }
686
+ continue;
687
+ }
688
+
689
+ if (key === 'size') {
690
+ const v = line.substring(colonIndex + 1).trim();
691
+ const parts = v.split(',').map((s) => parseInt(s.trim(), 10));
692
+ if (
693
+ parts.length === 2 &&
694
+ parts.every((n) => !isNaN(n) && n > 0) &&
695
+ parts[0] < parts[1]
696
+ ) {
697
+ result.cloudOptions.minSize = parts[0];
698
+ result.cloudOptions.maxSize = parts[1];
699
+ }
700
+ continue;
701
+ }
702
+
703
+ // Data line: "Label: value1, value2" or "Label(color): value1, value2"
704
+ const labelPart = colorMatch ? colorMatch[1].trim() : rawKey;
705
+ const colorPart = colorMatch
706
+ ? resolveColor(colorMatch[2].trim(), palette)
707
+ : null;
708
+ const valuePart = line.substring(colonIndex + 1).trim();
709
+ const values = valuePart.split(',').map((v) => v.trim());
710
+
711
+ // Check if this looks like a data line (values should be numeric)
712
+ const numericValues: number[] = [];
713
+ let allNumeric = true;
714
+ for (const v of values) {
715
+ const num = parseFloat(v);
716
+ if (isNaN(num)) {
717
+ allNumeric = false;
718
+ break;
719
+ }
720
+ numericValues.push(num);
721
+ }
722
+
723
+ if (allNumeric && numericValues.length > 0) {
724
+ // For wordcloud, single numeric value = word weight
725
+ if (result.type === 'wordcloud' && numericValues.length === 1) {
726
+ result.words.push({
727
+ text: labelPart,
728
+ weight: numericValues[0],
729
+ lineNumber,
730
+ });
731
+ } else {
732
+ result.data.push({
733
+ label: labelPart,
734
+ values: numericValues,
735
+ color: colorPart,
736
+ lineNumber,
737
+ });
738
+ }
739
+ continue;
740
+ }
741
+ }
742
+
743
+ // For wordcloud: collect non-metadata lines for freeform fallback
744
+ if (result.type === 'wordcloud') {
745
+ if (colonIndex === -1 && !line.includes(' ')) {
746
+ // Single bare word — structured mode
747
+ result.words.push({ text: line, weight: 10, lineNumber });
748
+ } else {
749
+ // Multi-word line or non-numeric colon line — freeform text
750
+ freeformLines.push(line);
751
+ }
752
+ continue;
753
+ }
754
+
755
+ // Period line: comma-separated labels with no colon before first comma
756
+ // e.g., "2020, 2024" or "Q1 2023, Q2 2023, Q3 2023"
757
+ if (
758
+ result.periods.length === 0 &&
759
+ line.includes(',') &&
760
+ !line.includes(':')
761
+ ) {
762
+ const periods = line
763
+ .split(',')
764
+ .map((p) => p.trim())
765
+ .filter(Boolean);
766
+ if (periods.length >= 2) {
767
+ result.periods = periods;
768
+ continue;
769
+ }
770
+ }
771
+ }
772
+
773
+ // Validation
774
+ if (!result.type) {
775
+ result.error = 'Missing required "chart:" line (e.g., "chart: slope")';
776
+ return result;
777
+ }
778
+
779
+ // Sequence diagrams are parsed by their own dedicated parser
780
+ if (result.type === 'sequence') {
781
+ return result;
782
+ }
783
+
784
+ if (result.type === 'wordcloud') {
785
+ // If no structured words were found, parse freeform text as word frequencies
786
+ if (result.words.length === 0 && freeformLines.length > 0) {
787
+ result.words = tokenizeFreeformText(freeformLines.join(' '));
788
+ }
789
+ if (result.words.length === 0) {
790
+ result.error =
791
+ 'No words found. Add words as "word: weight", one per line, or paste freeform text';
792
+ return result;
793
+ }
794
+ // Apply max word limit (words are already sorted by weight desc for freeform)
795
+ if (
796
+ result.cloudOptions.max > 0 &&
797
+ result.words.length > result.cloudOptions.max
798
+ ) {
799
+ result.words = result.words
800
+ .slice()
801
+ .sort((a, b) => b.weight - a.weight)
802
+ .slice(0, result.cloudOptions.max);
803
+ }
804
+ return result;
805
+ }
806
+
807
+ if (result.type === 'arc') {
808
+ if (result.links.length === 0) {
809
+ result.error =
810
+ 'No links found. Add links as "Source -> Target: weight" (e.g., "Alice -> Bob: 5")';
811
+ return result;
812
+ }
813
+ // Validate arc ordering vs groups
814
+ if (result.arcNodeGroups.length > 0) {
815
+ if (result.arcOrder === 'name' || result.arcOrder === 'degree') {
816
+ result.error = `Cannot use "order: ${result.arcOrder}" with ## section headers. Use "order: group" or remove section headers.`;
817
+ return result;
818
+ }
819
+ if (result.arcOrder === 'appearance') {
820
+ result.arcOrder = 'group';
821
+ }
822
+ }
823
+ return result;
824
+ }
825
+
826
+ if (result.type === 'timeline') {
827
+ if (result.timelineEvents.length === 0) {
828
+ result.error =
829
+ 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"';
830
+ return result;
831
+ }
832
+ return result;
833
+ }
834
+
835
+ if (result.type === 'venn') {
836
+ if (result.vennSets.length < 2) {
837
+ result.error =
838
+ 'At least 2 sets are required. Add sets as "Name: size" (e.g., "Math: 100")';
839
+ return result;
840
+ }
841
+ if (result.vennSets.length > 3) {
842
+ result.error = 'At most 3 sets are supported. Remove extra sets.';
843
+ return result;
844
+ }
845
+ // Validate overlap references and sizes
846
+ const setMap = new Map(result.vennSets.map((s) => [s.name, s.size]));
847
+ for (const ov of result.vennOverlaps) {
848
+ for (const setName of ov.sets) {
849
+ if (!setMap.has(setName)) {
850
+ result.error = `Overlap references unknown set "${setName}". Define it first as "${setName}: <size>"`;
851
+ return result;
852
+ }
853
+ }
854
+ const minSetSize = Math.min(...ov.sets.map((s) => setMap.get(s)!));
855
+ if (ov.size > minSetSize) {
856
+ result.error = `Overlap size ${ov.size} exceeds smallest constituent set size ${minSetSize}`;
857
+ return result;
858
+ }
859
+ }
860
+ return result;
861
+ }
862
+
863
+ if (result.type === 'quadrant') {
864
+ if (result.quadrantPoints.length === 0) {
865
+ result.error =
866
+ 'No data points found. Add points as "Label: x, y" (e.g., "Item A: 0.5, 0.7")';
867
+ return result;
868
+ }
869
+ return result;
870
+ }
871
+
872
+ // Slope chart validation
873
+ if (result.periods.length < 2) {
874
+ result.error =
875
+ 'Missing or invalid periods line. Provide at least 2 comma-separated period labels (e.g., "2020, 2024")';
876
+ return result;
877
+ }
878
+
879
+ if (result.data.length === 0) {
880
+ result.error =
881
+ 'No data lines found. Add data as "Label: value1, value2" (e.g., "Apple: 25, 35")';
882
+ return result;
883
+ }
884
+
885
+ // Validate value counts match period count
886
+ for (const item of result.data) {
887
+ if (item.values.length !== result.periods.length) {
888
+ result.error = `Data item "${item.label}" has ${item.values.length} value(s) but ${result.periods.length} period(s) are defined`;
889
+ return result;
890
+ }
891
+ }
892
+
893
+ return result;
894
+ }
895
+
896
+ // ============================================================
897
+ // Freeform Text Tokenizer (for word cloud)
898
+ // ============================================================
899
+
900
+ const STOP_WORDS = new Set([
901
+ 'a',
902
+ 'an',
903
+ 'the',
904
+ 'and',
905
+ 'or',
906
+ 'but',
907
+ 'in',
908
+ 'on',
909
+ 'at',
910
+ 'to',
911
+ 'for',
912
+ 'of',
913
+ 'with',
914
+ 'by',
915
+ 'is',
916
+ 'am',
917
+ 'are',
918
+ 'was',
919
+ 'were',
920
+ 'be',
921
+ 'been',
922
+ 'being',
923
+ 'have',
924
+ 'has',
925
+ 'had',
926
+ 'do',
927
+ 'does',
928
+ 'did',
929
+ 'will',
930
+ 'would',
931
+ 'could',
932
+ 'should',
933
+ 'may',
934
+ 'might',
935
+ 'shall',
936
+ 'can',
937
+ 'it',
938
+ 'its',
939
+ 'this',
940
+ 'that',
941
+ 'these',
942
+ 'those',
943
+ 'i',
944
+ 'me',
945
+ 'my',
946
+ 'we',
947
+ 'us',
948
+ 'our',
949
+ 'you',
950
+ 'your',
951
+ 'he',
952
+ 'him',
953
+ 'his',
954
+ 'she',
955
+ 'her',
956
+ 'they',
957
+ 'them',
958
+ 'their',
959
+ 'what',
960
+ 'which',
961
+ 'who',
962
+ 'whom',
963
+ 'how',
964
+ 'when',
965
+ 'where',
966
+ 'why',
967
+ 'not',
968
+ 'no',
969
+ 'nor',
970
+ 'so',
971
+ 'if',
972
+ 'then',
973
+ 'than',
974
+ 'too',
975
+ 'very',
976
+ 'just',
977
+ 'about',
978
+ 'up',
979
+ 'out',
980
+ 'from',
981
+ 'into',
982
+ 'over',
983
+ 'after',
984
+ 'before',
985
+ 'between',
986
+ 'under',
987
+ 'again',
988
+ 'there',
989
+ 'here',
990
+ 'all',
991
+ 'each',
992
+ 'every',
993
+ 'both',
994
+ 'few',
995
+ 'more',
996
+ 'most',
997
+ 'other',
998
+ 'some',
999
+ 'such',
1000
+ 'only',
1001
+ 'own',
1002
+ 'same',
1003
+ 'also',
1004
+ 'as',
1005
+ 'because',
1006
+ 'until',
1007
+ 'while',
1008
+ 'during',
1009
+ 'through',
1010
+ ]);
1011
+
1012
+ function tokenizeFreeformText(text: string): WordCloudWord[] {
1013
+ const counts = new Map<string, number>();
1014
+
1015
+ // Split on non-letter/non-apostrophe chars, lowercase everything
1016
+ const tokens = text
1017
+ .toLowerCase()
1018
+ .split(/[^a-zA-Z']+/)
1019
+ .filter(Boolean);
1020
+
1021
+ for (const raw of tokens) {
1022
+ // Strip leading/trailing apostrophes
1023
+ const word = raw.replace(/^'+|'+$/g, '');
1024
+ if (word.length < 2 || STOP_WORDS.has(word)) continue;
1025
+ counts.set(word, (counts.get(word) ?? 0) + 1);
1026
+ }
1027
+
1028
+ return Array.from(counts.entries())
1029
+ .map(([text, count]) => ({ text, weight: count, lineNumber: 0 }))
1030
+ .sort((a, b) => b.weight - a.weight);
1031
+ }
1032
+
1033
+ // ============================================================
1034
+ // Slope Chart Renderer
1035
+ // ============================================================
1036
+
1037
+ const SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };
1038
+ const SLOPE_LABEL_FONT_SIZE = 12;
1039
+ const SLOPE_CHAR_WIDTH = 7; // approximate px per character at 12px
1040
+
1041
+ /**
1042
+ * Renders a slope chart into the given container using D3.
1043
+ */
1044
+ export function renderSlopeChart(
1045
+ container: HTMLDivElement,
1046
+ parsed: ParsedD3,
1047
+ palette: PaletteColors,
1048
+ isDark: boolean,
1049
+ onClickItem?: (lineNumber: number) => void
1050
+ ): void {
1051
+ // Clear existing content
1052
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1053
+
1054
+ const { periods, data, title } = parsed;
1055
+ if (data.length === 0 || periods.length < 2) return;
1056
+
1057
+ const width = container.clientWidth;
1058
+ const height = container.clientHeight;
1059
+ if (width <= 0 || height <= 0) return;
1060
+
1061
+ // Compute right margin from the longest end-of-line label
1062
+ const maxLabelText = data.reduce((longest, item) => {
1063
+ const text = `${item.values[item.values.length - 1]} — ${item.label}`;
1064
+ return text.length > longest.length ? text : longest;
1065
+ }, '');
1066
+ const estimatedLabelWidth = maxLabelText.length * SLOPE_CHAR_WIDTH;
1067
+ const maxRightMargin = Math.floor(width * 0.35);
1068
+ const rightMargin = Math.min(
1069
+ Math.max(estimatedLabelWidth + 20, 100),
1070
+ maxRightMargin
1071
+ );
1072
+
1073
+ const innerWidth = width - SLOPE_MARGIN.left - rightMargin;
1074
+ const innerHeight = height - SLOPE_MARGIN.top - SLOPE_MARGIN.bottom;
1075
+
1076
+ // Theme colors
1077
+ const textColor = palette.text;
1078
+ const mutedColor = palette.border;
1079
+ const bgColor = palette.overlay;
1080
+ const colors = getSeriesColors(palette);
1081
+
1082
+ // Scales
1083
+ const allValues = data.flatMap((d) => d.values);
1084
+ const [minVal, maxVal] = d3Array.extent(allValues) as [number, number];
1085
+ const valuePadding = (maxVal - minVal) * 0.1 || 1;
1086
+
1087
+ const yScale = d3Scale
1088
+ .scaleLinear()
1089
+ .domain([minVal - valuePadding, maxVal + valuePadding])
1090
+ .range([innerHeight, 0]);
1091
+
1092
+ const xScale = d3Scale
1093
+ .scalePoint<string>()
1094
+ .domain(periods)
1095
+ .range([0, innerWidth])
1096
+ .padding(0);
1097
+
1098
+ // SVG
1099
+ const svg = d3Selection
1100
+ .select(container)
1101
+ .append('svg')
1102
+ .attr('width', width)
1103
+ .attr('height', height)
1104
+ .style('background', bgColor);
1105
+
1106
+ const g = svg
1107
+ .append('g')
1108
+ .attr('transform', `translate(${SLOPE_MARGIN.left},${SLOPE_MARGIN.top})`);
1109
+
1110
+ // Tooltip
1111
+ const tooltip = createTooltip(container, palette, isDark);
1112
+
1113
+ // Title
1114
+ if (title) {
1115
+ svg
1116
+ .append('text')
1117
+ .attr('x', width / 2)
1118
+ .attr('y', 30)
1119
+ .attr('text-anchor', 'middle')
1120
+ .attr('fill', textColor)
1121
+ .attr('font-size', '18px')
1122
+ .attr('font-weight', '700')
1123
+ .text(title);
1124
+ }
1125
+
1126
+ // Period column headers
1127
+ for (const period of periods) {
1128
+ const x = xScale(period)!;
1129
+ g.append('text')
1130
+ .attr('x', x)
1131
+ .attr('y', -15)
1132
+ .attr('text-anchor', 'middle')
1133
+ .attr('fill', textColor)
1134
+ .attr('font-size', '13px')
1135
+ .attr('font-weight', '600')
1136
+ .text(period);
1137
+
1138
+ // Vertical guide line
1139
+ g.append('line')
1140
+ .attr('x1', x)
1141
+ .attr('y1', 0)
1142
+ .attr('x2', x)
1143
+ .attr('y2', innerHeight)
1144
+ .attr('stroke', mutedColor)
1145
+ .attr('stroke-width', 1)
1146
+ .attr('stroke-dasharray', '4,4');
1147
+ }
1148
+
1149
+ // Line generator
1150
+ const lineGen = d3Shape
1151
+ .line<number>()
1152
+ .x((_d, i) => xScale(periods[i])!)
1153
+ .y((d) => yScale(d));
1154
+
1155
+ // Render each data series
1156
+ data.forEach((item, idx) => {
1157
+ const color = item.color ?? colors[idx % colors.length];
1158
+
1159
+ // Tooltip content – overall change for this series
1160
+ const firstVal = item.values[0];
1161
+ const lastVal = item.values[item.values.length - 1];
1162
+ const absChange = lastVal - firstVal;
1163
+ const pctChange = firstVal !== 0 ? (absChange / firstVal) * 100 : null;
1164
+ const sign = absChange > 0 ? '+' : '';
1165
+ const pctPart =
1166
+ pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
1167
+ const tipHtml =
1168
+ `<strong>${item.label}</strong><br>` +
1169
+ `${periods[0]}: ${firstVal} → ${periods[periods.length - 1]}: ${lastVal}<br>` +
1170
+ `Change: ${sign}${absChange}${pctPart}`;
1171
+
1172
+ // Line
1173
+ g.append('path')
1174
+ .datum(item.values)
1175
+ .attr('fill', 'none')
1176
+ .attr('stroke', color)
1177
+ .attr('stroke-width', 2.5)
1178
+ .attr('d', lineGen);
1179
+
1180
+ // Invisible wider path for easier hover targeting
1181
+ g.append('path')
1182
+ .datum(item.values)
1183
+ .attr('fill', 'none')
1184
+ .attr('stroke', 'transparent')
1185
+ .attr('stroke-width', 14)
1186
+ .attr('d', lineGen)
1187
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1188
+ .on('mouseenter', (event: MouseEvent) =>
1189
+ showTooltip(tooltip, tipHtml, event)
1190
+ )
1191
+ .on('mousemove', (event: MouseEvent) =>
1192
+ showTooltip(tooltip, tipHtml, event)
1193
+ )
1194
+ .on('mouseleave', () => hideTooltip(tooltip))
1195
+ .on('click', () => {
1196
+ if (onClickItem && item.lineNumber) onClickItem(item.lineNumber);
1197
+ });
1198
+
1199
+ // Points and value labels
1200
+ item.values.forEach((val, i) => {
1201
+ const x = xScale(periods[i])!;
1202
+ const y = yScale(val);
1203
+
1204
+ // Point circle
1205
+ g.append('circle')
1206
+ .attr('cx', x)
1207
+ .attr('cy', y)
1208
+ .attr('r', 4)
1209
+ .attr('fill', color)
1210
+ .attr('stroke', bgColor)
1211
+ .attr('stroke-width', 1.5)
1212
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1213
+ .on('mouseenter', (event: MouseEvent) =>
1214
+ showTooltip(tooltip, tipHtml, event)
1215
+ )
1216
+ .on('mousemove', (event: MouseEvent) =>
1217
+ showTooltip(tooltip, tipHtml, event)
1218
+ )
1219
+ .on('mouseleave', () => hideTooltip(tooltip))
1220
+ .on('click', () => {
1221
+ if (onClickItem && item.lineNumber) onClickItem(item.lineNumber);
1222
+ });
1223
+
1224
+ // Value label — skip last point (shown in series label instead)
1225
+ const isFirst = i === 0;
1226
+ const isLast = i === periods.length - 1;
1227
+ if (!isLast) {
1228
+ g.append('text')
1229
+ .attr('x', isFirst ? x - 10 : x)
1230
+ .attr('y', y)
1231
+ .attr('dy', '0.35em')
1232
+ .attr('text-anchor', isFirst ? 'end' : 'middle')
1233
+ .attr('fill', textColor)
1234
+ .attr('font-size', '12px')
1235
+ .text(val.toString());
1236
+ }
1237
+ });
1238
+
1239
+ // Series label with value at end of line — wraps if it exceeds available space
1240
+ const lastX = xScale(periods[periods.length - 1])!;
1241
+ const lastY = yScale(lastVal);
1242
+ const labelText = `${lastVal} — ${item.label}`;
1243
+ const availableWidth = rightMargin - 15;
1244
+ const maxChars = Math.floor(availableWidth / SLOPE_CHAR_WIDTH);
1245
+
1246
+ const labelEl = g
1247
+ .append('text')
1248
+ .attr('x', lastX + 10)
1249
+ .attr('y', lastY)
1250
+ .attr('text-anchor', 'start')
1251
+ .attr('fill', color)
1252
+ .attr('font-size', `${SLOPE_LABEL_FONT_SIZE}px`)
1253
+ .attr('font-weight', '500');
1254
+
1255
+ if (labelText.length <= maxChars) {
1256
+ labelEl.attr('dy', '0.35em').text(labelText);
1257
+ } else {
1258
+ // Wrap into lines that fit the available width
1259
+ const words = labelText.split(/\s+/);
1260
+ const lines: string[] = [];
1261
+ let current = '';
1262
+ for (const word of words) {
1263
+ const test = current ? `${current} ${word}` : word;
1264
+ if (test.length > maxChars && current) {
1265
+ lines.push(current);
1266
+ current = word;
1267
+ } else {
1268
+ current = test;
1269
+ }
1270
+ }
1271
+ if (current) lines.push(current);
1272
+
1273
+ const lineHeight = SLOPE_LABEL_FONT_SIZE * 1.2;
1274
+ const totalHeight = (lines.length - 1) * lineHeight;
1275
+ const startDy = -totalHeight / 2;
1276
+
1277
+ lines.forEach((line, li) => {
1278
+ labelEl
1279
+ .append('tspan')
1280
+ .attr('x', lastX + 10)
1281
+ .attr(
1282
+ 'dy',
1283
+ li === 0
1284
+ ? `${startDy + SLOPE_LABEL_FONT_SIZE * 0.35}px`
1285
+ : `${lineHeight}px`
1286
+ )
1287
+ .text(line);
1288
+ });
1289
+ }
1290
+ });
1291
+ }
1292
+
1293
+ // ============================================================
1294
+ // Arc Node Ordering
1295
+ // ============================================================
1296
+
1297
+ /**
1298
+ * Orders arc diagram nodes based on the selected ordering strategy.
1299
+ */
1300
+ export function orderArcNodes(
1301
+ links: ArcLink[],
1302
+ order: ArcOrder,
1303
+ groups: ArcNodeGroup[]
1304
+ ): string[] {
1305
+ // Collect all unique nodes in first-appearance order
1306
+ const nodeSet = new Set<string>();
1307
+ for (const link of links) {
1308
+ nodeSet.add(link.source);
1309
+ nodeSet.add(link.target);
1310
+ }
1311
+ const allNodes = Array.from(nodeSet);
1312
+
1313
+ if (order === 'name') {
1314
+ return allNodes.slice().sort((a, b) => a.localeCompare(b));
1315
+ }
1316
+
1317
+ if (order === 'degree') {
1318
+ const degree = new Map<string, number>();
1319
+ for (const node of allNodes) degree.set(node, 0);
1320
+ for (const link of links) {
1321
+ degree.set(link.source, degree.get(link.source)! + link.value);
1322
+ degree.set(link.target, degree.get(link.target)! + link.value);
1323
+ }
1324
+ return allNodes.slice().sort((a, b) => {
1325
+ const diff = degree.get(b)! - degree.get(a)!;
1326
+ return diff !== 0 ? diff : a.localeCompare(b);
1327
+ });
1328
+ }
1329
+
1330
+ if (order === 'group') {
1331
+ if (groups.length > 0) {
1332
+ // Explicit groups: order by ## header order, appearance within each group
1333
+ const ordered: string[] = [];
1334
+ const placed = new Set<string>();
1335
+ for (const group of groups) {
1336
+ for (const node of group.nodes) {
1337
+ if (!placed.has(node)) {
1338
+ ordered.push(node);
1339
+ placed.add(node);
1340
+ }
1341
+ }
1342
+ }
1343
+ // Orphans at end in first-appearance order
1344
+ for (const node of allNodes) {
1345
+ if (!placed.has(node)) {
1346
+ ordered.push(node);
1347
+ placed.add(node);
1348
+ }
1349
+ }
1350
+ return ordered;
1351
+ }
1352
+ // No explicit groups: connectivity clustering via BFS
1353
+ const adj = new Map<string, Set<string>>();
1354
+ for (const node of allNodes) adj.set(node, new Set());
1355
+ for (const link of links) {
1356
+ adj.get(link.source)!.add(link.target);
1357
+ adj.get(link.target)!.add(link.source);
1358
+ }
1359
+
1360
+ const degree = new Map<string, number>();
1361
+ for (const node of allNodes) degree.set(node, 0);
1362
+ for (const link of links) {
1363
+ degree.set(link.source, degree.get(link.source)! + link.value);
1364
+ degree.set(link.target, degree.get(link.target)! + link.value);
1365
+ }
1366
+
1367
+ const visited = new Set<string>();
1368
+ const components: string[][] = [];
1369
+
1370
+ const remaining = new Set(allNodes);
1371
+ while (remaining.size > 0) {
1372
+ // Pick highest-degree unvisited node as BFS root
1373
+ let root = '';
1374
+ let maxDeg = -1;
1375
+ for (const node of remaining) {
1376
+ if (degree.get(node)! > maxDeg) {
1377
+ maxDeg = degree.get(node)!;
1378
+ root = node;
1379
+ }
1380
+ }
1381
+ // BFS
1382
+ const component: string[] = [];
1383
+ const queue = [root];
1384
+ visited.add(root);
1385
+ remaining.delete(root);
1386
+ while (queue.length > 0) {
1387
+ const curr = queue.shift()!;
1388
+ component.push(curr);
1389
+ for (const neighbor of adj.get(curr)!) {
1390
+ if (!visited.has(neighbor)) {
1391
+ visited.add(neighbor);
1392
+ remaining.delete(neighbor);
1393
+ queue.push(neighbor);
1394
+ }
1395
+ }
1396
+ }
1397
+ components.push(component);
1398
+ }
1399
+ // Sort components by size descending
1400
+ components.sort((a, b) => b.length - a.length);
1401
+ return components.flat();
1402
+ }
1403
+
1404
+ // 'appearance' — first-appearance order (default)
1405
+ return allNodes;
1406
+ }
1407
+
1408
+ // ============================================================
1409
+ // Arc Diagram Renderer
1410
+ // ============================================================
1411
+
1412
+ const ARC_MARGIN = { top: 60, right: 40, bottom: 60, left: 40 };
1413
+
1414
+ /**
1415
+ * Renders an arc diagram into the given container using D3.
1416
+ */
1417
+ export function renderArcDiagram(
1418
+ container: HTMLDivElement,
1419
+ parsed: ParsedD3,
1420
+ palette: PaletteColors,
1421
+ _isDark: boolean,
1422
+ onClickItem?: (lineNumber: number) => void
1423
+ ): void {
1424
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
1425
+
1426
+ const { links, title, orientation, arcOrder, arcNodeGroups } = parsed;
1427
+ if (links.length === 0) return;
1428
+
1429
+ const width = container.clientWidth;
1430
+ const height = container.clientHeight;
1431
+ if (width <= 0 || height <= 0) return;
1432
+
1433
+ const isVertical = orientation === 'vertical';
1434
+ const margin = isVertical
1435
+ ? {
1436
+ top: ARC_MARGIN.top,
1437
+ right: ARC_MARGIN.right,
1438
+ bottom: ARC_MARGIN.bottom,
1439
+ left: 120,
1440
+ }
1441
+ : ARC_MARGIN;
1442
+
1443
+ const innerWidth = width - margin.left - margin.right;
1444
+ const innerHeight = height - margin.top - margin.bottom;
1445
+
1446
+ // Theme colors
1447
+ const textColor = palette.text;
1448
+ const mutedColor = palette.border;
1449
+ const bgColor = palette.overlay;
1450
+ const colors = getSeriesColors(palette);
1451
+
1452
+ // Order nodes by selected strategy
1453
+ const nodes = orderArcNodes(links, arcOrder, arcNodeGroups);
1454
+
1455
+ // Build node color map from group colors
1456
+ const nodeColorMap = new Map<string, string>();
1457
+ for (const group of arcNodeGroups) {
1458
+ if (group.color) {
1459
+ for (const node of group.nodes) {
1460
+ if (!nodeColorMap.has(node)) {
1461
+ nodeColorMap.set(node, group.color);
1462
+ }
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ // Build group-to-nodes lookup for group hover
1468
+ const groupNodeSets = new Map<string, Set<string>>();
1469
+ for (const group of arcNodeGroups) {
1470
+ groupNodeSets.set(group.name, new Set(group.nodes));
1471
+ }
1472
+
1473
+ // Scales
1474
+ const values = links.map((l) => l.value);
1475
+ const [minVal, maxVal] = d3Array.extent(values) as [number, number];
1476
+ const strokeScale = d3Scale
1477
+ .scaleLinear()
1478
+ .domain([minVal, maxVal])
1479
+ .range([1.5, 6]);
1480
+
1481
+ // SVG
1482
+ const svg = d3Selection
1483
+ .select(container)
1484
+ .append('svg')
1485
+ .attr('width', width)
1486
+ .attr('height', height)
1487
+ .style('background', bgColor);
1488
+
1489
+ const g = svg
1490
+ .append('g')
1491
+ .attr('transform', `translate(${margin.left},${margin.top})`);
1492
+
1493
+ // Title
1494
+ if (title) {
1495
+ svg
1496
+ .append('text')
1497
+ .attr('x', width / 2)
1498
+ .attr('y', 30)
1499
+ .attr('text-anchor', 'middle')
1500
+ .attr('fill', textColor)
1501
+ .attr('font-size', '18px')
1502
+ .attr('font-weight', '700')
1503
+ .text(title);
1504
+ }
1505
+
1506
+ // Build adjacency map for hover interactions
1507
+ const neighbors = new Map<string, Set<string>>();
1508
+ for (const node of nodes) neighbors.set(node, new Set());
1509
+ for (const link of links) {
1510
+ neighbors.get(link.source)!.add(link.target);
1511
+ neighbors.get(link.target)!.add(link.source);
1512
+ }
1513
+
1514
+ const FADE_OPACITY = 0.1;
1515
+
1516
+ function handleMouseEnter(hovered: string) {
1517
+ const connected = neighbors.get(hovered)!;
1518
+
1519
+ g.selectAll<SVGPathElement, unknown>('.arc-link').each(function () {
1520
+ const el = d3Selection.select(this);
1521
+ const src = el.attr('data-source');
1522
+ const tgt = el.attr('data-target');
1523
+ const isRelated = src === hovered || tgt === hovered;
1524
+ el.attr('stroke-opacity', isRelated ? 0.85 : FADE_OPACITY);
1525
+ });
1526
+
1527
+ g.selectAll<SVGGElement, unknown>('.arc-node').each(function () {
1528
+ const el = d3Selection.select(this);
1529
+ const name = el.attr('data-node');
1530
+ const isRelated = name === hovered || connected.has(name!);
1531
+ el.attr('opacity', isRelated ? 1 : FADE_OPACITY);
1532
+ });
1533
+ }
1534
+
1535
+ function handleMouseLeave() {
1536
+ g.selectAll<SVGPathElement, unknown>('.arc-link').attr(
1537
+ 'stroke-opacity',
1538
+ 0.7
1539
+ );
1540
+ g.selectAll<SVGGElement, unknown>('.arc-node').attr('opacity', 1);
1541
+ g.selectAll<SVGRectElement, unknown>('.arc-group-band').attr(
1542
+ 'fill-opacity',
1543
+ 0.08
1544
+ );
1545
+ g.selectAll<SVGTextElement, unknown>('.arc-group-label').attr(
1546
+ 'fill-opacity',
1547
+ 0.7
1548
+ );
1549
+ }
1550
+
1551
+ function handleGroupEnter(groupName: string) {
1552
+ const members = groupNodeSets.get(groupName);
1553
+ if (!members) return;
1554
+
1555
+ g.selectAll<SVGPathElement, unknown>('.arc-link').each(function () {
1556
+ const el = d3Selection.select(this);
1557
+ const isRelated =
1558
+ members.has(el.attr('data-source')!) ||
1559
+ members.has(el.attr('data-target')!);
1560
+ el.attr('stroke-opacity', isRelated ? 0.85 : FADE_OPACITY);
1561
+ });
1562
+
1563
+ g.selectAll<SVGGElement, unknown>('.arc-node').each(function () {
1564
+ const el = d3Selection.select(this);
1565
+ el.attr('opacity', members.has(el.attr('data-node')!) ? 1 : FADE_OPACITY);
1566
+ });
1567
+
1568
+ g.selectAll<SVGRectElement, unknown>('.arc-group-band').each(function () {
1569
+ const el = d3Selection.select(this);
1570
+ el.attr(
1571
+ 'fill-opacity',
1572
+ el.attr('data-group') === groupName ? 0.18 : 0.03
1573
+ );
1574
+ });
1575
+
1576
+ g.selectAll<SVGTextElement, unknown>('.arc-group-label').each(function () {
1577
+ const el = d3Selection.select(this);
1578
+ el.attr('fill-opacity', el.attr('data-group') === groupName ? 1 : 0.2);
1579
+ });
1580
+ }
1581
+
1582
+ if (isVertical) {
1583
+ // Vertical layout: nodes along Y axis, arcs curve to the right
1584
+ const yScale = d3Scale
1585
+ .scalePoint<string>()
1586
+ .domain(nodes)
1587
+ .range([0, innerHeight])
1588
+ .padding(0.5);
1589
+
1590
+ const baseX = innerWidth / 2;
1591
+
1592
+ // Group bands (shaded regions bounding grouped nodes)
1593
+ if (arcNodeGroups.length > 0) {
1594
+ const bandPad = (yScale.step?.() ?? 20) * 0.4;
1595
+ const bandHalfW = 60;
1596
+ for (const group of arcNodeGroups) {
1597
+ const groupNodes = group.nodes.filter((n) => nodes.includes(n));
1598
+ if (groupNodes.length === 0) continue;
1599
+ const positions = groupNodes.map((n) => yScale(n)!);
1600
+ const minY = Math.min(...positions) - bandPad;
1601
+ const maxY = Math.max(...positions) + bandPad;
1602
+ const bandColor = group.color ?? mutedColor;
1603
+
1604
+ g.append('rect')
1605
+ .attr('class', 'arc-group-band')
1606
+ .attr('data-group', group.name)
1607
+ .attr('x', baseX - bandHalfW)
1608
+ .attr('y', minY)
1609
+ .attr('width', bandHalfW * 2)
1610
+ .attr('height', maxY - minY)
1611
+ .attr('rx', 4)
1612
+ .attr('fill', bandColor)
1613
+ .attr('fill-opacity', 0.08)
1614
+ .style('cursor', 'pointer')
1615
+ .on('mouseenter', () => handleGroupEnter(group.name))
1616
+ .on('mouseleave', handleMouseLeave)
1617
+ .on('click', () => {
1618
+ if (onClickItem) onClickItem(group.lineNumber);
1619
+ });
1620
+
1621
+ g.append('text')
1622
+ .attr('class', 'arc-group-label')
1623
+ .attr('data-group', group.name)
1624
+ .attr('x', baseX - bandHalfW + 6)
1625
+ .attr('y', minY + 12)
1626
+ .attr('fill', bandColor)
1627
+ .attr('font-size', '10px')
1628
+ .attr('font-weight', '600')
1629
+ .attr('fill-opacity', 0.7)
1630
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1631
+ .text(group.name)
1632
+ .on('mouseenter', () => handleGroupEnter(group.name))
1633
+ .on('mouseleave', handleMouseLeave)
1634
+ .on('click', () => {
1635
+ if (onClickItem) onClickItem(group.lineNumber);
1636
+ });
1637
+ }
1638
+ }
1639
+
1640
+ // Dashed vertical baseline
1641
+ g.append('line')
1642
+ .attr('x1', baseX)
1643
+ .attr('y1', 0)
1644
+ .attr('x2', baseX)
1645
+ .attr('y2', innerHeight)
1646
+ .attr('stroke', mutedColor)
1647
+ .attr('stroke-width', 1)
1648
+ .attr('stroke-dasharray', '4,4');
1649
+
1650
+ // Arcs
1651
+ links.forEach((link, idx) => {
1652
+ const y1 = yScale(link.source)!;
1653
+ const y2 = yScale(link.target)!;
1654
+ const midY = (y1 + y2) / 2;
1655
+ const distance = Math.abs(y2 - y1);
1656
+ const controlX = baseX + distance * 0.4;
1657
+ const color = link.color ?? colors[idx % colors.length];
1658
+
1659
+ g.append('path')
1660
+ .attr('class', 'arc-link')
1661
+ .attr('data-source', link.source)
1662
+ .attr('data-target', link.target)
1663
+ .attr('d', `M ${baseX},${y1} Q ${controlX},${midY} ${baseX},${y2}`)
1664
+ .attr('fill', 'none')
1665
+ .attr('stroke', color)
1666
+ .attr('stroke-width', strokeScale(link.value))
1667
+ .attr('stroke-opacity', 0.7)
1668
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1669
+ .on('click', () => {
1670
+ if (onClickItem && link.lineNumber) onClickItem(link.lineNumber);
1671
+ });
1672
+ });
1673
+
1674
+ // Node circles and labels
1675
+ for (const node of nodes) {
1676
+ const y = yScale(node)!;
1677
+ const nodeColor = nodeColorMap.get(node) ?? textColor;
1678
+ // Find the first link involving this node
1679
+ const nodeLink = onClickItem
1680
+ ? links.find((l) => l.source === node || l.target === node)
1681
+ : undefined;
1682
+
1683
+ const nodeG = g
1684
+ .append('g')
1685
+ .attr('class', 'arc-node')
1686
+ .attr('data-node', node)
1687
+ .style('cursor', 'pointer')
1688
+ .on('mouseenter', () => handleMouseEnter(node))
1689
+ .on('mouseleave', handleMouseLeave)
1690
+ .on('click', () => {
1691
+ if (onClickItem && nodeLink?.lineNumber)
1692
+ onClickItem(nodeLink.lineNumber);
1693
+ });
1694
+
1695
+ nodeG
1696
+ .append('circle')
1697
+ .attr('cx', baseX)
1698
+ .attr('cy', y)
1699
+ .attr('r', 5)
1700
+ .attr('fill', nodeColor)
1701
+ .attr('stroke', bgColor)
1702
+ .attr('stroke-width', 1.5);
1703
+
1704
+ // Label to the left of baseline
1705
+ nodeG
1706
+ .append('text')
1707
+ .attr('x', baseX - 14)
1708
+ .attr('y', y)
1709
+ .attr('dy', '0.35em')
1710
+ .attr('text-anchor', 'end')
1711
+ .attr('fill', textColor)
1712
+ .attr('font-size', '11px')
1713
+ .text(node);
1714
+ }
1715
+ } else {
1716
+ // Horizontal layout (default): nodes along X axis, arcs curve upward
1717
+ const xScale = d3Scale
1718
+ .scalePoint<string>()
1719
+ .domain(nodes)
1720
+ .range([0, innerWidth])
1721
+ .padding(0.5);
1722
+
1723
+ const baseY = innerHeight / 2;
1724
+
1725
+ // Group bands (shaded regions bounding grouped nodes)
1726
+ if (arcNodeGroups.length > 0) {
1727
+ const bandPad = (xScale.step?.() ?? 20) * 0.4;
1728
+ const bandHalfH = 40;
1729
+ for (const group of arcNodeGroups) {
1730
+ const groupNodes = group.nodes.filter((n) => nodes.includes(n));
1731
+ if (groupNodes.length === 0) continue;
1732
+ const positions = groupNodes.map((n) => xScale(n)!);
1733
+ const minX = Math.min(...positions) - bandPad;
1734
+ const maxX = Math.max(...positions) + bandPad;
1735
+ const bandColor = group.color ?? mutedColor;
1736
+
1737
+ g.append('rect')
1738
+ .attr('class', 'arc-group-band')
1739
+ .attr('data-group', group.name)
1740
+ .attr('x', minX)
1741
+ .attr('y', baseY - bandHalfH)
1742
+ .attr('width', maxX - minX)
1743
+ .attr('height', bandHalfH * 2)
1744
+ .attr('rx', 4)
1745
+ .attr('fill', bandColor)
1746
+ .attr('fill-opacity', 0.08)
1747
+ .style('cursor', 'pointer')
1748
+ .on('mouseenter', () => handleGroupEnter(group.name))
1749
+ .on('mouseleave', handleMouseLeave)
1750
+ .on('click', () => {
1751
+ if (onClickItem) onClickItem(group.lineNumber);
1752
+ });
1753
+
1754
+ g.append('text')
1755
+ .attr('class', 'arc-group-label')
1756
+ .attr('data-group', group.name)
1757
+ .attr('x', (minX + maxX) / 2)
1758
+ .attr('y', baseY + bandHalfH - 4)
1759
+ .attr('text-anchor', 'middle')
1760
+ .attr('fill', bandColor)
1761
+ .attr('font-size', '10px')
1762
+ .attr('font-weight', '600')
1763
+ .attr('fill-opacity', 0.7)
1764
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1765
+ .text(group.name)
1766
+ .on('mouseenter', () => handleGroupEnter(group.name))
1767
+ .on('mouseleave', handleMouseLeave)
1768
+ .on('click', () => {
1769
+ if (onClickItem) onClickItem(group.lineNumber);
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ // Dashed horizontal baseline
1775
+ g.append('line')
1776
+ .attr('x1', 0)
1777
+ .attr('y1', baseY)
1778
+ .attr('x2', innerWidth)
1779
+ .attr('y2', baseY)
1780
+ .attr('stroke', mutedColor)
1781
+ .attr('stroke-width', 1)
1782
+ .attr('stroke-dasharray', '4,4');
1783
+
1784
+ // Arcs
1785
+ links.forEach((link, idx) => {
1786
+ const x1 = xScale(link.source)!;
1787
+ const x2 = xScale(link.target)!;
1788
+ const midX = (x1 + x2) / 2;
1789
+ const distance = Math.abs(x2 - x1);
1790
+ const controlY = baseY - distance * 0.4;
1791
+ const color = link.color ?? colors[idx % colors.length];
1792
+
1793
+ g.append('path')
1794
+ .attr('class', 'arc-link')
1795
+ .attr('data-source', link.source)
1796
+ .attr('data-target', link.target)
1797
+ .attr('d', `M ${x1},${baseY} Q ${midX},${controlY} ${x2},${baseY}`)
1798
+ .attr('fill', 'none')
1799
+ .attr('stroke', color)
1800
+ .attr('stroke-width', strokeScale(link.value))
1801
+ .attr('stroke-opacity', 0.7)
1802
+ .style('cursor', onClickItem ? 'pointer' : 'default')
1803
+ .on('click', () => {
1804
+ if (onClickItem && link.lineNumber) onClickItem(link.lineNumber);
1805
+ });
1806
+ });
1807
+
1808
+ // Node circles and labels
1809
+ for (const node of nodes) {
1810
+ const x = xScale(node)!;
1811
+ const nodeColor = nodeColorMap.get(node) ?? textColor;
1812
+ // Find the first link involving this node
1813
+ const nodeLink = onClickItem
1814
+ ? links.find((l) => l.source === node || l.target === node)
1815
+ : undefined;
1816
+
1817
+ const nodeG = g
1818
+ .append('g')
1819
+ .attr('class', 'arc-node')
1820
+ .attr('data-node', node)
1821
+ .style('cursor', 'pointer')
1822
+ .on('mouseenter', () => handleMouseEnter(node))
1823
+ .on('mouseleave', handleMouseLeave)
1824
+ .on('click', () => {
1825
+ if (onClickItem && nodeLink?.lineNumber)
1826
+ onClickItem(nodeLink.lineNumber);
1827
+ });
1828
+
1829
+ nodeG
1830
+ .append('circle')
1831
+ .attr('cx', x)
1832
+ .attr('cy', baseY)
1833
+ .attr('r', 5)
1834
+ .attr('fill', nodeColor)
1835
+ .attr('stroke', bgColor)
1836
+ .attr('stroke-width', 1.5);
1837
+
1838
+ // Label below baseline
1839
+ nodeG
1840
+ .append('text')
1841
+ .attr('x', x)
1842
+ .attr('y', baseY + 20)
1843
+ .attr('text-anchor', 'middle')
1844
+ .attr('fill', textColor)
1845
+ .attr('font-size', '11px')
1846
+ .text(node);
1847
+ }
1848
+ }
1849
+ }
1850
+
1851
+ // ============================================================
1852
+ // Timeline Era Bands
1853
+ // ============================================================
1854
+
1855
+ function getEraColors(palette: PaletteColors): string[] {
1856
+ return [
1857
+ palette.colors.blue,
1858
+ palette.colors.green,
1859
+ palette.colors.yellow,
1860
+ palette.colors.orange,
1861
+ palette.colors.purple,
1862
+ ];
1863
+ }
1864
+
1865
+ /**
1866
+ * Renders semi-transparent era background bands behind timeline events.
1867
+ */
1868
+ function renderEras(
1869
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1870
+ eras: TimelineEra[],
1871
+ scale: d3Scale.ScaleLinear<number, number>,
1872
+ isVertical: boolean,
1873
+ innerWidth: number,
1874
+ innerHeight: number,
1875
+ onEnter: (eraStart: number, eraEnd: number) => void,
1876
+ onLeave: () => void,
1877
+ hasScale: boolean = false,
1878
+ tooltip: HTMLDivElement | null = null,
1879
+ palette?: PaletteColors
1880
+ ): void {
1881
+ const eraColors = palette
1882
+ ? getEraColors(palette)
1883
+ : ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
1884
+ eras.forEach((era, i) => {
1885
+ const startVal = parseTimelineDate(era.startDate);
1886
+ const endVal = parseTimelineDate(era.endDate);
1887
+ const start = scale(startVal);
1888
+ const end = scale(endVal);
1889
+ const color = era.color || eraColors[i % eraColors.length];
1890
+
1891
+ const eraG = g
1892
+ .append('g')
1893
+ .attr('class', 'tl-era')
1894
+ .attr('data-era-start', String(startVal))
1895
+ .attr('data-era-end', String(endVal))
1896
+ .style('cursor', 'pointer')
1897
+ .on('mouseenter', function (event: MouseEvent) {
1898
+ onEnter(startVal, endVal);
1899
+ if (tooltip) showTooltip(tooltip, buildEraTooltipHtml(era), event);
1900
+ })
1901
+ .on('mouseleave', function () {
1902
+ onLeave();
1903
+ if (tooltip) hideTooltip(tooltip);
1904
+ })
1905
+ .on('mousemove', function (event: MouseEvent) {
1906
+ if (tooltip) showTooltip(tooltip, buildEraTooltipHtml(era), event);
1907
+ });
1908
+
1909
+ if (isVertical) {
1910
+ const y = Math.min(start, end);
1911
+ const h = Math.abs(end - start);
1912
+ eraG
1913
+ .append('rect')
1914
+ .attr('x', 0)
1915
+ .attr('y', y)
1916
+ .attr('width', innerWidth)
1917
+ .attr('height', h)
1918
+ .attr('fill', color)
1919
+ .attr('opacity', 0.08);
1920
+ eraG
1921
+ .append('text')
1922
+ .attr('x', 6)
1923
+ .attr('y', y + 18)
1924
+ .attr('text-anchor', 'start')
1925
+ .attr('fill', color)
1926
+ .attr('font-size', '13px')
1927
+ .attr('font-weight', '600')
1928
+ .attr('opacity', 0.8)
1929
+ .text(era.label);
1930
+ } else {
1931
+ const x = Math.min(start, end);
1932
+ const w = Math.abs(end - start);
1933
+ // When scale is on, extend the shading above the chart area
1934
+ // so the label sits above the scale marks but inside the band.
1935
+ const rectTop = hasScale ? -48 : 0;
1936
+ eraG
1937
+ .append('rect')
1938
+ .attr('x', x)
1939
+ .attr('y', rectTop)
1940
+ .attr('width', w)
1941
+ .attr('height', innerHeight - rectTop)
1942
+ .attr('fill', color)
1943
+ .attr('opacity', 0.08);
1944
+ eraG
1945
+ .append('text')
1946
+ .attr('x', x + w / 2)
1947
+ .attr('y', hasScale ? -32 : 18)
1948
+ .attr('text-anchor', 'middle')
1949
+ .attr('fill', color)
1950
+ .attr('font-size', '13px')
1951
+ .attr('font-weight', '600')
1952
+ .attr('opacity', 0.8)
1953
+ .text(era.label);
1954
+ }
1955
+ });
1956
+ }
1957
+
1958
+ /**
1959
+ * Renders timeline markers as dashed vertical lines with diamond indicators and labels.
1960
+ */
1961
+ function renderMarkers(
1962
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1963
+ markers: TimelineMarker[],
1964
+ scale: d3Scale.ScaleLinear<number, number>,
1965
+ isVertical: boolean,
1966
+ innerWidth: number,
1967
+ innerHeight: number,
1968
+ _hasScale: boolean = false,
1969
+ tooltip: HTMLDivElement | null = null,
1970
+ palette?: PaletteColors
1971
+ ): void {
1972
+ // Default marker color - bright orange/red that "pops"
1973
+ const defaultColor = palette?.accent || '#d08770';
1974
+
1975
+ markers.forEach((marker) => {
1976
+ const dateVal = parseTimelineDate(marker.date);
1977
+ const pos = scale(dateVal);
1978
+ const color = marker.color || defaultColor;
1979
+ const lineOpacity = 0.5;
1980
+ const diamondSize = 5;
1981
+
1982
+ const markerG = g
1983
+ .append('g')
1984
+ .attr('class', 'tl-marker')
1985
+ .attr('data-marker-date', String(dateVal))
1986
+ .style('cursor', 'pointer')
1987
+ .on('mouseenter', function (event: MouseEvent) {
1988
+ if (tooltip) {
1989
+ showTooltip(tooltip, formatDateLabel(marker.date), event);
1990
+ }
1991
+ })
1992
+ .on('mouseleave', function () {
1993
+ if (tooltip) hideTooltip(tooltip);
1994
+ })
1995
+ .on('mousemove', function (event: MouseEvent) {
1996
+ if (tooltip) {
1997
+ showTooltip(tooltip, formatDateLabel(marker.date), event);
1998
+ }
1999
+ });
2000
+
2001
+ if (isVertical) {
2002
+ // Vertical orientation: horizontal dashed line across the chart
2003
+ markerG
2004
+ .append('line')
2005
+ .attr('x1', 0)
2006
+ .attr('y1', pos)
2007
+ .attr('x2', innerWidth)
2008
+ .attr('y2', pos)
2009
+ .attr('stroke', color)
2010
+ .attr('stroke-width', 1.5)
2011
+ .attr('stroke-dasharray', '6 4')
2012
+ .attr('opacity', lineOpacity);
2013
+
2014
+ // Label above diamond
2015
+ markerG
2016
+ .append('text')
2017
+ .attr('x', -diamondSize - 8)
2018
+ .attr('y', pos - diamondSize - 4)
2019
+ .attr('text-anchor', 'middle')
2020
+ .attr('fill', color)
2021
+ .attr('font-size', '11px')
2022
+ .attr('font-weight', '600')
2023
+ .text(marker.label);
2024
+
2025
+ // Diamond at the left edge
2026
+ markerG
2027
+ .append('path')
2028
+ .attr(
2029
+ 'd',
2030
+ `M${-diamondSize - 8},${pos} l${diamondSize},-${diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} Z`
2031
+ )
2032
+ .attr('fill', color)
2033
+ .attr('opacity', 0.9);
2034
+ } else {
2035
+ // Horizontal orientation: vertical dashed line down the chart
2036
+ // Label above diamond, diamond below, then dashed line to chart bottom
2037
+ const labelY = 6;
2038
+ const diamondY = labelY + 14;
2039
+
2040
+ // Label above diamond
2041
+ markerG
2042
+ .append('text')
2043
+ .attr('x', pos)
2044
+ .attr('y', labelY)
2045
+ .attr('text-anchor', 'middle')
2046
+ .attr('fill', color)
2047
+ .attr('font-size', '11px')
2048
+ .attr('font-weight', '600')
2049
+ .text(marker.label);
2050
+
2051
+ // Diamond below label
2052
+ markerG
2053
+ .append('path')
2054
+ .attr(
2055
+ 'd',
2056
+ `M${pos},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`
2057
+ )
2058
+ .attr('fill', color)
2059
+ .attr('opacity', 0.9);
2060
+
2061
+ // Line starts from bottom of diamond and goes down to chart bottom
2062
+ markerG
2063
+ .append('line')
2064
+ .attr('x1', pos)
2065
+ .attr('y1', diamondY + diamondSize)
2066
+ .attr('x2', pos)
2067
+ .attr('y2', innerHeight)
2068
+ .attr('stroke', color)
2069
+ .attr('stroke-width', 1.5)
2070
+ .attr('stroke-dasharray', '6 4')
2071
+ .attr('opacity', lineOpacity);
2072
+ }
2073
+ });
2074
+ }
2075
+
2076
+ // ============================================================
2077
+ // Timeline Time Scale
2078
+ // ============================================================
2079
+
2080
+ const MONTH_ABBR = [
2081
+ 'Jan',
2082
+ 'Feb',
2083
+ 'Mar',
2084
+ 'Apr',
2085
+ 'May',
2086
+ 'Jun',
2087
+ 'Jul',
2088
+ 'Aug',
2089
+ 'Sep',
2090
+ 'Oct',
2091
+ 'Nov',
2092
+ 'Dec',
2093
+ ];
2094
+
2095
+ /**
2096
+ * Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD) to a human-readable label.
2097
+ * '1718' → '1718'
2098
+ * '1718-05' → 'May 1718'
2099
+ * '1718-05-22' → 'May 22, 1718'
2100
+ */
2101
+ export function formatDateLabel(dateStr: string): string {
2102
+ const parts = dateStr.split('-');
2103
+ const year = parts[0];
2104
+ if (parts.length === 1) return year;
2105
+ const month = MONTH_ABBR[parseInt(parts[1], 10) - 1];
2106
+ if (parts.length === 2) return `${month} ${year}`;
2107
+ const day = parseInt(parts[2], 10);
2108
+ return `${month} ${day}, ${year}`;
2109
+ }
2110
+
2111
+ /**
2112
+ * Computes adaptive tick marks for a timeline scale.
2113
+ * - Multi-year spans → year ticks
2114
+ * - Within ~1 year → month ticks
2115
+ * - Within ~3 months → week ticks (1st, 8th, 15th, 22nd)
2116
+ *
2117
+ * Optional boundary parameters add ticks at exact data start/end:
2118
+ * - boundaryStart/boundaryEnd: numeric date values
2119
+ * - boundaryStartLabel/boundaryEndLabel: formatted labels for those dates
2120
+ */
2121
+ export function computeTimeTicks(
2122
+ domainMin: number,
2123
+ domainMax: number,
2124
+ scale: d3Scale.ScaleLinear<number, number>,
2125
+ boundaryStart?: number,
2126
+ boundaryEnd?: number,
2127
+ boundaryStartLabel?: string,
2128
+ boundaryEndLabel?: string
2129
+ ): { pos: number; label: string }[] {
2130
+ const minYear = Math.floor(domainMin);
2131
+ const maxYear = Math.floor(domainMax);
2132
+ const span = domainMax - domainMin;
2133
+
2134
+ let ticks: { pos: number; label: string }[] = [];
2135
+
2136
+ // Year ticks for multi-year spans (need at least 2 boundaries)
2137
+ const firstYear = Math.ceil(domainMin);
2138
+ const lastYear = Math.floor(domainMax);
2139
+ if (lastYear >= firstYear + 1) {
2140
+ for (let y = firstYear; y <= lastYear; y++) {
2141
+ ticks.push({ pos: scale(y), label: String(y) });
2142
+ }
2143
+ } else if (span > 0.25) {
2144
+ // Month ticks for spans > ~3 months
2145
+ const crossesYear = maxYear > minYear;
2146
+ for (let y = minYear; y <= maxYear + 1; y++) {
2147
+ for (let m = 1; m <= 12; m++) {
2148
+ const val = y + (m - 1) / 12;
2149
+ if (val > domainMax) break;
2150
+ if (val >= domainMin) {
2151
+ ticks.push({
2152
+ pos: scale(val),
2153
+ label: crossesYear
2154
+ ? `${MONTH_ABBR[m - 1]} '${String(y).slice(-2)}`
2155
+ : MONTH_ABBR[m - 1],
2156
+ });
2157
+ }
2158
+ }
2159
+ }
2160
+ } else {
2161
+ // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
2162
+ for (let y = minYear; y <= maxYear + 1; y++) {
2163
+ for (let m = 1; m <= 12; m++) {
2164
+ for (const d of [1, 8, 15, 22]) {
2165
+ const val = y + (m - 1) / 12 + (d - 1) / 365;
2166
+ if (val > domainMax) break;
2167
+ if (val >= domainMin) {
2168
+ ticks.push({
2169
+ pos: scale(val),
2170
+ label: `${MONTH_ABBR[m - 1]} ${d}`,
2171
+ });
2172
+ }
2173
+ }
2174
+ }
2175
+ }
2176
+ }
2177
+
2178
+ // Add boundary ticks at exact data start/end if provided
2179
+ // When a boundary tick collides with a standard tick, replace the standard tick
2180
+ const collisionThreshold = 40; // pixels
2181
+
2182
+ if (boundaryStart !== undefined && boundaryStartLabel) {
2183
+ const boundaryPos = scale(boundaryStart);
2184
+ // Remove any standard ticks that would collide with the start boundary
2185
+ ticks = ticks.filter(
2186
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
2187
+ );
2188
+ ticks.unshift({ pos: boundaryPos, label: boundaryStartLabel });
2189
+ }
2190
+
2191
+ if (boundaryEnd !== undefined && boundaryEndLabel) {
2192
+ const boundaryPos = scale(boundaryEnd);
2193
+ // Remove any standard ticks that would collide with the end boundary
2194
+ ticks = ticks.filter(
2195
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
2196
+ );
2197
+ ticks.push({ pos: boundaryPos, label: boundaryEndLabel });
2198
+ }
2199
+
2200
+ return ticks;
2201
+ }
2202
+
2203
+ /**
2204
+ * Renders adaptive tick marks along the time axis.
2205
+ * Optional boundary parameters add ticks at exact data start/end.
2206
+ */
2207
+ function renderTimeScale(
2208
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2209
+ scale: d3Scale.ScaleLinear<number, number>,
2210
+ isVertical: boolean,
2211
+ innerWidth: number,
2212
+ innerHeight: number,
2213
+ textColor: string,
2214
+ boundaryStart?: number,
2215
+ boundaryEnd?: number,
2216
+ boundaryStartLabel?: string,
2217
+ boundaryEndLabel?: string
2218
+ ): void {
2219
+ const [domainMin, domainMax] = scale.domain();
2220
+ const ticks = computeTimeTicks(
2221
+ domainMin,
2222
+ domainMax,
2223
+ scale,
2224
+ boundaryStart,
2225
+ boundaryEnd,
2226
+ boundaryStartLabel,
2227
+ boundaryEndLabel
2228
+ );
2229
+ if (ticks.length < 2) return;
2230
+
2231
+ const tickLen = 6;
2232
+ const opacity = 0.4;
2233
+
2234
+ const guideOpacity = 0.15;
2235
+
2236
+ for (const tick of ticks) {
2237
+ if (isVertical) {
2238
+ // Guide line spanning full width
2239
+ g.append('line')
2240
+ .attr('x1', 0)
2241
+ .attr('y1', tick.pos)
2242
+ .attr('x2', innerWidth)
2243
+ .attr('y2', tick.pos)
2244
+ .attr('stroke', textColor)
2245
+ .attr('stroke-width', 1)
2246
+ .attr('stroke-dasharray', '4 4')
2247
+ .attr('opacity', guideOpacity);
2248
+
2249
+ // Left edge
2250
+ g.append('line')
2251
+ .attr('x1', -tickLen)
2252
+ .attr('y1', tick.pos)
2253
+ .attr('x2', 0)
2254
+ .attr('y2', tick.pos)
2255
+ .attr('stroke', textColor)
2256
+ .attr('stroke-width', 1)
2257
+ .attr('opacity', opacity);
2258
+
2259
+ g.append('text')
2260
+ .attr('x', -tickLen - 3)
2261
+ .attr('y', tick.pos)
2262
+ .attr('dy', '0.35em')
2263
+ .attr('text-anchor', 'end')
2264
+ .attr('fill', textColor)
2265
+ .attr('font-size', '10px')
2266
+ .attr('opacity', opacity)
2267
+ .text(tick.label);
2268
+
2269
+ // Right edge
2270
+ g.append('line')
2271
+ .attr('x1', innerWidth)
2272
+ .attr('y1', tick.pos)
2273
+ .attr('x2', innerWidth + tickLen)
2274
+ .attr('y2', tick.pos)
2275
+ .attr('stroke', textColor)
2276
+ .attr('stroke-width', 1)
2277
+ .attr('opacity', opacity);
2278
+
2279
+ g.append('text')
2280
+ .attr('x', innerWidth + tickLen + 3)
2281
+ .attr('y', tick.pos)
2282
+ .attr('dy', '0.35em')
2283
+ .attr('text-anchor', 'start')
2284
+ .attr('fill', textColor)
2285
+ .attr('font-size', '10px')
2286
+ .attr('opacity', opacity)
2287
+ .text(tick.label);
2288
+ } else {
2289
+ // Guide line spanning full height
2290
+ g.append('line')
2291
+ .attr('class', 'tl-scale-tick')
2292
+ .attr('x1', tick.pos)
2293
+ .attr('y1', 0)
2294
+ .attr('x2', tick.pos)
2295
+ .attr('y2', innerHeight)
2296
+ .attr('stroke', textColor)
2297
+ .attr('stroke-width', 1)
2298
+ .attr('stroke-dasharray', '4 4')
2299
+ .attr('opacity', guideOpacity);
2300
+
2301
+ // Bottom edge
2302
+ g.append('line')
2303
+ .attr('class', 'tl-scale-tick')
2304
+ .attr('x1', tick.pos)
2305
+ .attr('y1', innerHeight)
2306
+ .attr('x2', tick.pos)
2307
+ .attr('y2', innerHeight + tickLen)
2308
+ .attr('stroke', textColor)
2309
+ .attr('stroke-width', 1)
2310
+ .attr('opacity', opacity);
2311
+
2312
+ g.append('text')
2313
+ .attr('class', 'tl-scale-tick')
2314
+ .attr('x', tick.pos)
2315
+ .attr('y', innerHeight + tickLen + 12)
2316
+ .attr('text-anchor', 'middle')
2317
+ .attr('fill', textColor)
2318
+ .attr('font-size', '10px')
2319
+ .attr('opacity', opacity)
2320
+ .text(tick.label);
2321
+
2322
+ // Top edge
2323
+ g.append('line')
2324
+ .attr('class', 'tl-scale-tick')
2325
+ .attr('x1', tick.pos)
2326
+ .attr('y1', -tickLen)
2327
+ .attr('x2', tick.pos)
2328
+ .attr('y2', 0)
2329
+ .attr('stroke', textColor)
2330
+ .attr('stroke-width', 1)
2331
+ .attr('opacity', opacity);
2332
+
2333
+ g.append('text')
2334
+ .attr('class', 'tl-scale-tick')
2335
+ .attr('x', tick.pos)
2336
+ .attr('y', -tickLen - 4)
2337
+ .attr('text-anchor', 'middle')
2338
+ .attr('fill', textColor)
2339
+ .attr('font-size', '10px')
2340
+ .attr('opacity', opacity)
2341
+ .text(tick.label);
2342
+ }
2343
+ }
2344
+ }
2345
+
2346
+ // ============================================================
2347
+ // Timeline Event Date Scale Helpers
2348
+ // ============================================================
2349
+
2350
+ /**
2351
+ * Shows event start/end dates on the scale, fading existing scale ticks.
2352
+ * For horizontal timelines, displays dates at the top of the scale.
2353
+ */
2354
+ function showEventDatesOnScale(
2355
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2356
+ scale: d3Scale.ScaleLinear<number, number>,
2357
+ startDate: string,
2358
+ endDate: string | null,
2359
+ innerHeight: number,
2360
+ accentColor: string
2361
+ ): void {
2362
+ // Fade existing scale ticks
2363
+ g.selectAll('.tl-scale-tick').attr('opacity', 0.1);
2364
+
2365
+ const tickLen = 6;
2366
+ const startPos = scale(parseTimelineDate(startDate));
2367
+ const startLabel = formatDateLabel(startDate);
2368
+
2369
+ // Start date - top
2370
+ g.append('line')
2371
+ .attr('class', 'tl-event-date')
2372
+ .attr('x1', startPos)
2373
+ .attr('y1', -tickLen)
2374
+ .attr('x2', startPos)
2375
+ .attr('y2', innerHeight)
2376
+ .attr('stroke', accentColor)
2377
+ .attr('stroke-width', 1.5)
2378
+ .attr('stroke-dasharray', '4 4')
2379
+ .attr('opacity', 0.6);
2380
+
2381
+ g.append('text')
2382
+ .attr('class', 'tl-event-date')
2383
+ .attr('x', startPos)
2384
+ .attr('y', -tickLen - 4)
2385
+ .attr('text-anchor', 'middle')
2386
+ .attr('fill', accentColor)
2387
+ .attr('font-size', '10px')
2388
+ .attr('font-weight', '600')
2389
+ .text(startLabel);
2390
+
2391
+ // Start date - bottom
2392
+ g.append('text')
2393
+ .attr('class', 'tl-event-date')
2394
+ .attr('x', startPos)
2395
+ .attr('y', innerHeight + tickLen + 12)
2396
+ .attr('text-anchor', 'middle')
2397
+ .attr('fill', accentColor)
2398
+ .attr('font-size', '10px')
2399
+ .attr('font-weight', '600')
2400
+ .text(startLabel);
2401
+
2402
+ if (endDate) {
2403
+ const endPos = scale(parseTimelineDate(endDate));
2404
+ const endLabel = formatDateLabel(endDate);
2405
+
2406
+ // End date - top
2407
+ g.append('line')
2408
+ .attr('class', 'tl-event-date')
2409
+ .attr('x1', endPos)
2410
+ .attr('y1', -tickLen)
2411
+ .attr('x2', endPos)
2412
+ .attr('y2', innerHeight)
2413
+ .attr('stroke', accentColor)
2414
+ .attr('stroke-width', 1.5)
2415
+ .attr('stroke-dasharray', '4 4')
2416
+ .attr('opacity', 0.6);
2417
+
2418
+ g.append('text')
2419
+ .attr('class', 'tl-event-date')
2420
+ .attr('x', endPos)
2421
+ .attr('y', -tickLen - 4)
2422
+ .attr('text-anchor', 'middle')
2423
+ .attr('fill', accentColor)
2424
+ .attr('font-size', '10px')
2425
+ .attr('font-weight', '600')
2426
+ .text(endLabel);
2427
+
2428
+ // End date - bottom
2429
+ g.append('text')
2430
+ .attr('class', 'tl-event-date')
2431
+ .attr('x', endPos)
2432
+ .attr('y', innerHeight + tickLen + 12)
2433
+ .attr('text-anchor', 'middle')
2434
+ .attr('fill', accentColor)
2435
+ .attr('font-size', '10px')
2436
+ .attr('font-weight', '600')
2437
+ .text(endLabel);
2438
+ }
2439
+ }
2440
+
2441
+ /**
2442
+ * Hides event dates and restores scale tick visibility.
2443
+ */
2444
+ function hideEventDatesOnScale(
2445
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
2446
+ ): void {
2447
+ // Remove event date elements
2448
+ g.selectAll('.tl-event-date').remove();
2449
+
2450
+ // Restore scale tick visibility
2451
+ g.selectAll('.tl-scale-tick').each(function () {
2452
+ const el = d3Selection.select(this);
2453
+ // Restore original opacity based on element type
2454
+ const isDashed = el.attr('stroke-dasharray');
2455
+ el.attr('opacity', isDashed ? 0.15 : 0.4);
2456
+ });
2457
+ }
2458
+
2459
+ // ============================================================
2460
+ // Timeline Tooltip Helpers
2461
+ // ============================================================
2462
+
2463
+ function createTooltip(
2464
+ container: HTMLElement,
2465
+ palette: PaletteColors,
2466
+ isDark: boolean
2467
+ ): HTMLDivElement {
2468
+ container.style.position = 'relative';
2469
+
2470
+ // Reuse existing tooltip element if present (avoids DOM churn on re-renders)
2471
+ const existing = container.querySelector<HTMLDivElement>('[data-d3-tooltip]');
2472
+ if (existing) {
2473
+ existing.style.display = 'none';
2474
+ existing.style.background = palette.surface;
2475
+ existing.style.color = palette.text;
2476
+ existing.style.boxShadow = isDark
2477
+ ? '0 2px 6px rgba(0,0,0,0.3)'
2478
+ : '0 2px 6px rgba(0,0,0,0.12)';
2479
+ return existing;
2480
+ }
2481
+
2482
+ const tip = document.createElement('div');
2483
+ tip.setAttribute('data-d3-tooltip', '');
2484
+ tip.style.position = 'absolute';
2485
+ tip.style.display = 'none';
2486
+ tip.style.pointerEvents = 'none';
2487
+ tip.style.background = palette.surface;
2488
+ tip.style.color = palette.text;
2489
+ tip.style.padding = '6px 10px';
2490
+ tip.style.borderRadius = '4px';
2491
+ tip.style.fontSize = '12px';
2492
+ tip.style.lineHeight = '1.4';
2493
+ tip.style.whiteSpace = 'nowrap';
2494
+ tip.style.zIndex = '10';
2495
+ tip.style.boxShadow = isDark
2496
+ ? '0 2px 6px rgba(0,0,0,0.3)'
2497
+ : '0 2px 6px rgba(0,0,0,0.12)';
2498
+ container.appendChild(tip);
2499
+ return tip;
2500
+ }
2501
+
2502
+ function showTooltip(
2503
+ tooltip: HTMLDivElement,
2504
+ html: string,
2505
+ event: MouseEvent
2506
+ ): void {
2507
+ tooltip.innerHTML = html;
2508
+ tooltip.style.display = 'block';
2509
+ const container = tooltip.parentElement!;
2510
+ const rect = container.getBoundingClientRect();
2511
+ let left = event.clientX - rect.left + 12;
2512
+ let top = event.clientY - rect.top - 28;
2513
+ // Clamp so tooltip stays inside the container
2514
+ const tipW = tooltip.offsetWidth;
2515
+ const tipH = tooltip.offsetHeight;
2516
+ if (left + tipW > rect.width) left = rect.width - tipW - 4;
2517
+ if (top < 0) top = event.clientY - rect.top + 16;
2518
+ if (top + tipH > rect.height) top = rect.height - tipH - 4;
2519
+ tooltip.style.left = `${left}px`;
2520
+ tooltip.style.top = `${top}px`;
2521
+ }
2522
+
2523
+ function hideTooltip(tooltip: HTMLDivElement): void {
2524
+ tooltip.style.display = 'none';
2525
+ }
2526
+
2527
+ function buildEventTooltipHtml(ev: TimelineEvent): string {
2528
+ const datePart = ev.endDate
2529
+ ? `${formatDateLabel(ev.date)} → ${formatDateLabel(ev.endDate)}`
2530
+ : formatDateLabel(ev.date);
2531
+ return `<strong>${ev.label}</strong><br>${datePart}`;
2532
+ }
2533
+
2534
+ function buildEraTooltipHtml(era: TimelineEra): string {
2535
+ return `<strong>${era.label}</strong><br>${formatDateLabel(era.startDate)} → ${formatDateLabel(era.endDate)}`;
2536
+ }
2537
+
2538
+ // ============================================================
2539
+ // Timeline Renderer
2540
+ // ============================================================
2541
+
2542
+ /**
2543
+ * Renders a timeline chart into the given container using D3.
2544
+ * Supports horizontal (default) and vertical orientation.
2545
+ */
2546
+ export function renderTimeline(
2547
+ container: HTMLDivElement,
2548
+ parsed: ParsedD3,
2549
+ palette: PaletteColors,
2550
+ isDark: boolean,
2551
+ onClickItem?: (lineNumber: number) => void
2552
+ ): void {
2553
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2554
+
2555
+ const {
2556
+ timelineEvents,
2557
+ timelineGroups,
2558
+ timelineEras,
2559
+ timelineMarkers,
2560
+ timelineSort,
2561
+ timelineScale,
2562
+ timelineSwimlanes,
2563
+ title,
2564
+ orientation,
2565
+ } = parsed;
2566
+ if (timelineEvents.length === 0) return;
2567
+
2568
+ const tooltip = createTooltip(container, palette, isDark);
2569
+
2570
+ const width = container.clientWidth;
2571
+ const height = container.clientHeight;
2572
+ if (width <= 0 || height <= 0) return;
2573
+
2574
+ const isVertical = orientation === 'vertical';
2575
+
2576
+ // Theme colors
2577
+ const textColor = palette.text;
2578
+ const mutedColor = palette.border;
2579
+ const bgColor = palette.overlay;
2580
+ const colors = getSeriesColors(palette);
2581
+
2582
+ // Assign colors to groups
2583
+ const groupColorMap = new Map<string, string>();
2584
+ timelineGroups.forEach((grp, i) => {
2585
+ groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
2586
+ });
2587
+
2588
+ function eventColor(ev: TimelineEvent): string {
2589
+ if (ev.group && groupColorMap.has(ev.group)) {
2590
+ return groupColorMap.get(ev.group)!;
2591
+ }
2592
+ return textColor;
2593
+ }
2594
+
2595
+ // Convert dates to numeric values and find boundary dates
2596
+ let minDate = Infinity;
2597
+ let maxDate = -Infinity;
2598
+ let earliestStartDateStr = '';
2599
+ let latestEndDateStr = '';
2600
+
2601
+ for (const ev of timelineEvents) {
2602
+ const startNum = parseTimelineDate(ev.date);
2603
+ const endNum = ev.endDate ? parseTimelineDate(ev.endDate) : startNum;
2604
+
2605
+ if (startNum < minDate) {
2606
+ minDate = startNum;
2607
+ earliestStartDateStr = ev.date;
2608
+ }
2609
+ if (endNum > maxDate) {
2610
+ maxDate = endNum;
2611
+ latestEndDateStr = ev.endDate ?? ev.date;
2612
+ }
2613
+ }
2614
+ const datePadding = (maxDate - minDate) * 0.05 || 0.5;
2615
+
2616
+ const FADE_OPACITY = 0.1;
2617
+
2618
+ // ------------------------------------------------------------------
2619
+ // Shared hover helpers (operate on CSS classes, orientation-agnostic)
2620
+ // ------------------------------------------------------------------
2621
+
2622
+ function fadeToGroup(
2623
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2624
+ groupName: string
2625
+ ) {
2626
+ g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
2627
+ const el = d3Selection.select(this);
2628
+ const evGroup = el.attr('data-group');
2629
+ el.attr('opacity', evGroup === groupName ? 1 : FADE_OPACITY);
2630
+ });
2631
+ g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').each(
2632
+ function () {
2633
+ const el = d3Selection.select(this);
2634
+ const name = el.attr('data-group');
2635
+ el.attr('opacity', name === groupName ? 1 : FADE_OPACITY);
2636
+ }
2637
+ );
2638
+ g.selectAll<SVGGElement, unknown>('.tl-marker').attr(
2639
+ 'opacity',
2640
+ FADE_OPACITY
2641
+ );
2642
+ }
2643
+
2644
+ function fadeToEra(
2645
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
2646
+ eraStart: number,
2647
+ eraEnd: number
2648
+ ) {
2649
+ g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
2650
+ const el = d3Selection.select(this);
2651
+ const date = parseFloat(el.attr('data-date')!);
2652
+ const endDate = el.attr('data-end-date');
2653
+ const evEnd = endDate ? parseFloat(endDate) : date;
2654
+ const inside = evEnd >= eraStart && date <= eraEnd;
2655
+ el.attr('opacity', inside ? 1 : FADE_OPACITY);
2656
+ });
2657
+ g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
2658
+ 'opacity',
2659
+ FADE_OPACITY
2660
+ );
2661
+ g.selectAll<SVGGElement, unknown>('.tl-era').each(function () {
2662
+ const el = d3Selection.select(this);
2663
+ const s = parseFloat(el.attr('data-era-start')!);
2664
+ const e = parseFloat(el.attr('data-era-end')!);
2665
+ const isSelf = s === eraStart && e === eraEnd;
2666
+ el.attr('opacity', isSelf ? 1 : FADE_OPACITY);
2667
+ });
2668
+ g.selectAll<SVGGElement, unknown>('.tl-marker').each(function () {
2669
+ const el = d3Selection.select(this);
2670
+ const date = parseFloat(el.attr('data-marker-date')!);
2671
+ const inside = date >= eraStart && date <= eraEnd;
2672
+ el.attr('opacity', inside ? 1 : FADE_OPACITY);
2673
+ });
2674
+ }
2675
+
2676
+ function fadeReset(
2677
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
2678
+ ) {
2679
+ g.selectAll<SVGGElement, unknown>(
2680
+ '.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker'
2681
+ ).attr('opacity', 1);
2682
+ g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);
2683
+ }
2684
+
2685
+ // ================================================================
2686
+ // VERTICAL orientation (time flows top→bottom)
2687
+ // ================================================================
2688
+ if (isVertical) {
2689
+ if (timelineSort === 'group' && timelineGroups.length > 0) {
2690
+ // === GROUPED: one column/lane per group, vertical ===
2691
+ const groupNames = timelineGroups.map((gr) => gr.name);
2692
+ const ungroupedEvents = timelineEvents.filter(
2693
+ (ev) => ev.group === null || !groupNames.includes(ev.group)
2694
+ );
2695
+ const laneNames =
2696
+ ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
2697
+
2698
+ const laneCount = laneNames.length;
2699
+ const scaleMargin = timelineScale ? 40 : 0;
2700
+ const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
2701
+ const margin = {
2702
+ top: 104 + markerMargin,
2703
+ right: 40 + scaleMargin,
2704
+ bottom: 40,
2705
+ left: 60 + scaleMargin,
2706
+ };
2707
+ const innerWidth = width - margin.left - margin.right;
2708
+ const innerHeight = height - margin.top - margin.bottom;
2709
+ const laneWidth = innerWidth / laneCount;
2710
+
2711
+ const yScale = d3Scale
2712
+ .scaleLinear()
2713
+ .domain([minDate - datePadding, maxDate + datePadding])
2714
+ .range([0, innerHeight]);
2715
+
2716
+ const svg = d3Selection
2717
+ .select(container)
2718
+ .append('svg')
2719
+ .attr('width', width)
2720
+ .attr('height', height)
2721
+ .style('background', bgColor);
2722
+
2723
+ const g = svg
2724
+ .append('g')
2725
+ .attr('transform', `translate(${margin.left},${margin.top})`);
2726
+
2727
+ if (title) {
2728
+ svg
2729
+ .append('text')
2730
+ .attr('x', width / 2)
2731
+ .attr('y', 30)
2732
+ .attr('text-anchor', 'middle')
2733
+ .attr('fill', textColor)
2734
+ .attr('font-size', '18px')
2735
+ .attr('font-weight', '700')
2736
+ .text(title);
2737
+ }
2738
+
2739
+ renderEras(
2740
+ g,
2741
+ timelineEras,
2742
+ yScale,
2743
+ true,
2744
+ innerWidth,
2745
+ innerHeight,
2746
+ (s, e) => fadeToEra(g, s, e),
2747
+ () => fadeReset(g),
2748
+ timelineScale,
2749
+ tooltip,
2750
+ palette
2751
+ );
2752
+
2753
+ renderMarkers(
2754
+ g,
2755
+ timelineMarkers,
2756
+ yScale,
2757
+ true,
2758
+ innerWidth,
2759
+ innerHeight,
2760
+ timelineScale,
2761
+ tooltip,
2762
+ palette
2763
+ );
2764
+
2765
+ if (timelineScale) {
2766
+ renderTimeScale(
2767
+ g,
2768
+ yScale,
2769
+ true,
2770
+ innerWidth,
2771
+ innerHeight,
2772
+ textColor,
2773
+ minDate,
2774
+ maxDate,
2775
+ formatDateLabel(earliestStartDateStr),
2776
+ formatDateLabel(latestEndDateStr)
2777
+ );
2778
+ }
2779
+
2780
+ laneNames.forEach((laneName, laneIdx) => {
2781
+ const laneX = laneIdx * laneWidth;
2782
+ const laneColor = groupColorMap.get(laneName) ?? textColor;
2783
+ const laneCenter = laneX + laneWidth / 2;
2784
+
2785
+ const headerG = g
2786
+ .append('g')
2787
+ .attr('class', 'tl-lane-header')
2788
+ .attr('data-group', laneName)
2789
+ .style('cursor', 'pointer')
2790
+ .on('mouseenter', () => fadeToGroup(g, laneName))
2791
+ .on('mouseleave', () => fadeReset(g));
2792
+
2793
+ headerG
2794
+ .append('text')
2795
+ .attr('x', laneCenter)
2796
+ .attr('y', -15)
2797
+ .attr('text-anchor', 'middle')
2798
+ .attr('fill', laneColor)
2799
+ .attr('font-size', '12px')
2800
+ .attr('font-weight', '600')
2801
+ .text(laneName);
2802
+
2803
+ g.append('line')
2804
+ .attr('x1', laneCenter)
2805
+ .attr('y1', 0)
2806
+ .attr('x2', laneCenter)
2807
+ .attr('y2', innerHeight)
2808
+ .attr('stroke', mutedColor)
2809
+ .attr('stroke-width', 1)
2810
+ .attr('stroke-dasharray', '4,4');
2811
+
2812
+ const laneEvents = timelineEvents.filter((ev) =>
2813
+ laneName === '(Other)'
2814
+ ? ev.group === null || !groupNames.includes(ev.group)
2815
+ : ev.group === laneName
2816
+ );
2817
+
2818
+ for (const ev of laneEvents) {
2819
+ const y = yScale(parseTimelineDate(ev.date));
2820
+ const evG = g
2821
+ .append('g')
2822
+ .attr('class', 'tl-event')
2823
+ .attr('data-group', laneName)
2824
+ .attr('data-date', String(parseTimelineDate(ev.date)))
2825
+ .attr(
2826
+ 'data-end-date',
2827
+ ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
2828
+ )
2829
+ .style('cursor', 'pointer')
2830
+ .on('mouseenter', function (event: MouseEvent) {
2831
+ fadeToGroup(g, laneName);
2832
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
2833
+ })
2834
+ .on('mouseleave', function () {
2835
+ fadeReset(g);
2836
+ hideTooltip(tooltip);
2837
+ })
2838
+ .on('mousemove', function (event: MouseEvent) {
2839
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
2840
+ })
2841
+ .on('click', () => {
2842
+ if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
2843
+ });
2844
+
2845
+ if (ev.endDate) {
2846
+ const y2 = yScale(parseTimelineDate(ev.endDate));
2847
+ const rectH = Math.max(y2 - y, 4);
2848
+ evG
2849
+ .append('rect')
2850
+ .attr('x', laneCenter - 6)
2851
+ .attr('y', y)
2852
+ .attr('width', 12)
2853
+ .attr('height', rectH)
2854
+ .attr('rx', 4)
2855
+ .attr('fill', laneColor);
2856
+ evG
2857
+ .append('text')
2858
+ .attr('x', laneCenter + 14)
2859
+ .attr('y', y + rectH / 2)
2860
+ .attr('dy', '0.35em')
2861
+ .attr('fill', textColor)
2862
+ .attr('font-size', '10px')
2863
+ .text(ev.label);
2864
+ } else {
2865
+ evG
2866
+ .append('circle')
2867
+ .attr('cx', laneCenter)
2868
+ .attr('cy', y)
2869
+ .attr('r', 4)
2870
+ .attr('fill', laneColor)
2871
+ .attr('stroke', bgColor)
2872
+ .attr('stroke-width', 1.5);
2873
+ evG
2874
+ .append('text')
2875
+ .attr('x', laneCenter + 10)
2876
+ .attr('y', y)
2877
+ .attr('dy', '0.35em')
2878
+ .attr('fill', textColor)
2879
+ .attr('font-size', '10px')
2880
+ .text(ev.label);
2881
+ }
2882
+ }
2883
+ });
2884
+ } else {
2885
+ // === TIME SORT, vertical: single vertical axis ===
2886
+ const scaleMargin = timelineScale ? 40 : 0;
2887
+ const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
2888
+ const margin = {
2889
+ top: 104 + markerMargin,
2890
+ right: 200,
2891
+ bottom: 40,
2892
+ left: 60 + scaleMargin,
2893
+ };
2894
+ const innerWidth = width - margin.left - margin.right;
2895
+ const innerHeight = height - margin.top - margin.bottom;
2896
+ const axisX = 20;
2897
+
2898
+ const yScale = d3Scale
2899
+ .scaleLinear()
2900
+ .domain([minDate - datePadding, maxDate + datePadding])
2901
+ .range([0, innerHeight]);
2902
+
2903
+ const sorted = timelineEvents
2904
+ .slice()
2905
+ .sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
2906
+
2907
+ const svg = d3Selection
2908
+ .select(container)
2909
+ .append('svg')
2910
+ .attr('width', width)
2911
+ .attr('height', height)
2912
+ .style('background', bgColor);
2913
+
2914
+ const g = svg
2915
+ .append('g')
2916
+ .attr('transform', `translate(${margin.left},${margin.top})`);
2917
+
2918
+ if (title) {
2919
+ svg
2920
+ .append('text')
2921
+ .attr('x', width / 2)
2922
+ .attr('y', 30)
2923
+ .attr('text-anchor', 'middle')
2924
+ .attr('fill', textColor)
2925
+ .attr('font-size', '18px')
2926
+ .attr('font-weight', '700')
2927
+ .text(title);
2928
+ }
2929
+
2930
+ renderEras(
2931
+ g,
2932
+ timelineEras,
2933
+ yScale,
2934
+ true,
2935
+ innerWidth,
2936
+ innerHeight,
2937
+ (s, e) => fadeToEra(g, s, e),
2938
+ () => fadeReset(g),
2939
+ timelineScale,
2940
+ tooltip,
2941
+ palette
2942
+ );
2943
+
2944
+ renderMarkers(
2945
+ g,
2946
+ timelineMarkers,
2947
+ yScale,
2948
+ true,
2949
+ innerWidth,
2950
+ innerHeight,
2951
+ timelineScale,
2952
+ tooltip,
2953
+ palette
2954
+ );
2955
+
2956
+ if (timelineScale) {
2957
+ renderTimeScale(
2958
+ g,
2959
+ yScale,
2960
+ true,
2961
+ innerWidth,
2962
+ innerHeight,
2963
+ textColor,
2964
+ minDate,
2965
+ maxDate,
2966
+ formatDateLabel(earliestStartDateStr),
2967
+ formatDateLabel(latestEndDateStr)
2968
+ );
2969
+ }
2970
+
2971
+ // Group legend
2972
+ if (timelineGroups.length > 0) {
2973
+ let legendX = 0;
2974
+ const legendY = -55;
2975
+ for (const grp of timelineGroups) {
2976
+ const color = groupColorMap.get(grp.name) ?? textColor;
2977
+ const itemG = g
2978
+ .append('g')
2979
+ .attr('class', 'tl-legend-item')
2980
+ .attr('data-group', grp.name)
2981
+ .style('cursor', 'pointer')
2982
+ .on('mouseenter', () => fadeToGroup(g, grp.name))
2983
+ .on('mouseleave', () => fadeReset(g));
2984
+
2985
+ itemG
2986
+ .append('circle')
2987
+ .attr('cx', legendX)
2988
+ .attr('cy', legendY)
2989
+ .attr('r', 5)
2990
+ .attr('fill', color);
2991
+
2992
+ itemG
2993
+ .append('text')
2994
+ .attr('x', legendX + 10)
2995
+ .attr('y', legendY)
2996
+ .attr('dy', '0.35em')
2997
+ .attr('fill', textColor)
2998
+ .attr('font-size', '11px')
2999
+ .text(grp.name);
3000
+
3001
+ legendX += grp.name.length * 7 + 30;
3002
+ }
3003
+ }
3004
+
3005
+ g.append('line')
3006
+ .attr('x1', axisX)
3007
+ .attr('y1', 0)
3008
+ .attr('x2', axisX)
3009
+ .attr('y2', innerHeight)
3010
+ .attr('stroke', mutedColor)
3011
+ .attr('stroke-width', 1)
3012
+ .attr('stroke-dasharray', '4,4');
3013
+
3014
+ for (const ev of sorted) {
3015
+ const y = yScale(parseTimelineDate(ev.date));
3016
+ const color = eventColor(ev);
3017
+
3018
+ const evG = g
3019
+ .append('g')
3020
+ .attr('class', 'tl-event')
3021
+ .attr('data-group', ev.group || '')
3022
+ .attr('data-date', String(parseTimelineDate(ev.date)))
3023
+ .attr(
3024
+ 'data-end-date',
3025
+ ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
3026
+ )
3027
+ .style('cursor', 'pointer')
3028
+ .on('mouseenter', function (event: MouseEvent) {
3029
+ if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
3030
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3031
+ })
3032
+ .on('mouseleave', function () {
3033
+ fadeReset(g);
3034
+ hideTooltip(tooltip);
3035
+ })
3036
+ .on('mousemove', function (event: MouseEvent) {
3037
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3038
+ })
3039
+ .on('click', () => {
3040
+ if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3041
+ });
3042
+
3043
+ if (ev.endDate) {
3044
+ const y2 = yScale(parseTimelineDate(ev.endDate));
3045
+ const rectH = Math.max(y2 - y, 4);
3046
+ evG
3047
+ .append('rect')
3048
+ .attr('x', axisX - 6)
3049
+ .attr('y', y)
3050
+ .attr('width', 12)
3051
+ .attr('height', rectH)
3052
+ .attr('rx', 4)
3053
+ .attr('fill', color);
3054
+ evG
3055
+ .append('text')
3056
+ .attr('x', axisX + 16)
3057
+ .attr('y', y + rectH / 2)
3058
+ .attr('dy', '0.35em')
3059
+ .attr('fill', textColor)
3060
+ .attr('font-size', '11px')
3061
+ .text(ev.label);
3062
+ } else {
3063
+ evG
3064
+ .append('circle')
3065
+ .attr('cx', axisX)
3066
+ .attr('cy', y)
3067
+ .attr('r', 4)
3068
+ .attr('fill', color)
3069
+ .attr('stroke', bgColor)
3070
+ .attr('stroke-width', 1.5);
3071
+ evG
3072
+ .append('text')
3073
+ .attr('x', axisX + 16)
3074
+ .attr('y', y)
3075
+ .attr('dy', '0.35em')
3076
+ .attr('fill', textColor)
3077
+ .attr('font-size', '11px')
3078
+ .text(ev.label);
3079
+ }
3080
+
3081
+ // Date label to the left
3082
+ evG
3083
+ .append('text')
3084
+ .attr('x', axisX - 14)
3085
+ .attr(
3086
+ 'y',
3087
+ ev.endDate
3088
+ ? yScale(parseTimelineDate(ev.date)) +
3089
+ Math.max(
3090
+ yScale(parseTimelineDate(ev.endDate)) -
3091
+ yScale(parseTimelineDate(ev.date)),
3092
+ 4
3093
+ ) /
3094
+ 2
3095
+ : y
3096
+ )
3097
+ .attr('dy', '0.35em')
3098
+ .attr('text-anchor', 'end')
3099
+ .attr('fill', mutedColor)
3100
+ .attr('font-size', '10px')
3101
+ .text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));
3102
+ }
3103
+ }
3104
+
3105
+ return; // vertical done
3106
+ }
3107
+
3108
+ // ================================================================
3109
+ // HORIZONTAL orientation (default — time flows left→right)
3110
+ // Each event gets its own row, stacked vertically.
3111
+ // ================================================================
3112
+
3113
+ const BAR_H = 22; // range bar thickness (tall enough for text inside)
3114
+ const GROUP_GAP = 12; // vertical gap between group swim-lanes
3115
+
3116
+ if (timelineSort === 'group' && timelineGroups.length > 0) {
3117
+ // === GROUPED: swim-lanes stacked vertically, events on own rows ===
3118
+ const groupNames = timelineGroups.map((gr) => gr.name);
3119
+ const ungroupedEvents = timelineEvents.filter(
3120
+ (ev) => ev.group === null || !groupNames.includes(ev.group)
3121
+ );
3122
+ const laneNames =
3123
+ ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
3124
+
3125
+ // Build lane data
3126
+ const lanes = laneNames.map((name) => ({
3127
+ name,
3128
+ events: timelineEvents.filter((ev) =>
3129
+ name === '(Other)'
3130
+ ? ev.group === null || !groupNames.includes(ev.group)
3131
+ : ev.group === name
3132
+ ),
3133
+ }));
3134
+
3135
+ const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
3136
+ const scaleMargin = timelineScale ? 24 : 0;
3137
+ const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3138
+ // Calculate left margin based on longest group name (~7px per char + padding)
3139
+ const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length));
3140
+ const dynamicLeftMargin = Math.max(120, maxGroupNameLen * 7 + 30);
3141
+ // Group-sorted doesn't need legend space (group names shown on left)
3142
+ const baseTopMargin = title ? 50 : 20;
3143
+ const margin = {
3144
+ top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
3145
+ right: 40,
3146
+ bottom: 40 + scaleMargin,
3147
+ left: dynamicLeftMargin,
3148
+ };
3149
+ const innerWidth = width - margin.left - margin.right;
3150
+ const innerHeight = height - margin.top - margin.bottom;
3151
+ const totalGaps = (lanes.length - 1) * GROUP_GAP;
3152
+ const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
3153
+
3154
+ const xScale = d3Scale
3155
+ .scaleLinear()
3156
+ .domain([minDate - datePadding, maxDate + datePadding])
3157
+ .range([0, innerWidth]);
3158
+
3159
+ const svg = d3Selection
3160
+ .select(container)
3161
+ .append('svg')
3162
+ .attr('width', width)
3163
+ .attr('height', height)
3164
+ .style('background', bgColor);
3165
+
3166
+ const g = svg
3167
+ .append('g')
3168
+ .attr('transform', `translate(${margin.left},${margin.top})`);
3169
+
3170
+ if (title) {
3171
+ svg
3172
+ .append('text')
3173
+ .attr('x', width / 2)
3174
+ .attr('y', 30)
3175
+ .attr('text-anchor', 'middle')
3176
+ .attr('fill', textColor)
3177
+ .attr('font-size', '18px')
3178
+ .attr('font-weight', '700')
3179
+ .text(title);
3180
+ }
3181
+
3182
+ renderEras(
3183
+ g,
3184
+ timelineEras,
3185
+ xScale,
3186
+ false,
3187
+ innerWidth,
3188
+ innerHeight,
3189
+ (s, e) => fadeToEra(g, s, e),
3190
+ () => fadeReset(g),
3191
+ timelineScale,
3192
+ tooltip,
3193
+ palette
3194
+ );
3195
+
3196
+ renderMarkers(
3197
+ g,
3198
+ timelineMarkers,
3199
+ xScale,
3200
+ false,
3201
+ innerWidth,
3202
+ innerHeight,
3203
+ timelineScale,
3204
+ tooltip,
3205
+ palette
3206
+ );
3207
+
3208
+ if (timelineScale) {
3209
+ renderTimeScale(
3210
+ g,
3211
+ xScale,
3212
+ false,
3213
+ innerWidth,
3214
+ innerHeight,
3215
+ textColor,
3216
+ minDate,
3217
+ maxDate,
3218
+ formatDateLabel(earliestStartDateStr),
3219
+ formatDateLabel(latestEndDateStr)
3220
+ );
3221
+ }
3222
+
3223
+ // Offset events below marker area when markers are present
3224
+ let curY = markerMargin;
3225
+
3226
+ // Render swimlane backgrounds first (so they appear behind events)
3227
+ // Extend into left margin to include group names
3228
+ if (timelineSwimlanes) {
3229
+ let swimY = markerMargin;
3230
+ lanes.forEach((lane, idx) => {
3231
+ const laneSpan = lane.events.length * rowH;
3232
+ // Alternate between light gray and transparent for visual separation
3233
+ const fillColor = idx % 2 === 0 ? textColor : 'transparent';
3234
+ g.append('rect')
3235
+ .attr('class', 'tl-swimlane')
3236
+ .attr('data-group', lane.name)
3237
+ .attr('x', -margin.left)
3238
+ .attr('y', swimY)
3239
+ .attr('width', innerWidth + margin.left)
3240
+ .attr('height', laneSpan + (idx < lanes.length - 1 ? GROUP_GAP : 0))
3241
+ .attr('fill', fillColor)
3242
+ .attr('opacity', 0.06);
3243
+ swimY += laneSpan + GROUP_GAP;
3244
+ });
3245
+ }
3246
+
3247
+ for (const lane of lanes) {
3248
+ const laneColor = groupColorMap.get(lane.name) ?? textColor;
3249
+ const laneSpan = lane.events.length * rowH;
3250
+
3251
+ // Group label — left of lane, vertically centred
3252
+ const group = timelineGroups.find((grp) => grp.name === lane.name);
3253
+ const headerG = g
3254
+ .append('g')
3255
+ .attr('class', 'tl-lane-header')
3256
+ .attr('data-group', lane.name)
3257
+ .style('cursor', 'pointer')
3258
+ .on('mouseenter', () => fadeToGroup(g, lane.name))
3259
+ .on('mouseleave', () => fadeReset(g))
3260
+ .on('click', () => {
3261
+ if (onClickItem && group?.lineNumber) onClickItem(group.lineNumber);
3262
+ });
3263
+
3264
+ headerG
3265
+ .append('text')
3266
+ .attr('x', -margin.left + 10)
3267
+ .attr('y', curY + laneSpan / 2)
3268
+ .attr('dy', '0.35em')
3269
+ .attr('text-anchor', 'start')
3270
+ .attr('fill', laneColor)
3271
+ .attr('font-size', '12px')
3272
+ .attr('font-weight', '600')
3273
+ .text(lane.name);
3274
+
3275
+ lane.events.forEach((ev, i) => {
3276
+ const y = curY + i * rowH + rowH / 2;
3277
+ const x = xScale(parseTimelineDate(ev.date));
3278
+
3279
+ const evG = g
3280
+ .append('g')
3281
+ .attr('class', 'tl-event')
3282
+ .attr('data-group', lane.name)
3283
+ .attr('data-date', String(parseTimelineDate(ev.date)))
3284
+ .attr(
3285
+ 'data-end-date',
3286
+ ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
3287
+ )
3288
+ .style('cursor', 'pointer')
3289
+ .on('mouseenter', function (event: MouseEvent) {
3290
+ fadeToGroup(g, lane.name);
3291
+ if (timelineScale) {
3292
+ showEventDatesOnScale(
3293
+ g,
3294
+ xScale,
3295
+ ev.date,
3296
+ ev.endDate,
3297
+ innerHeight,
3298
+ laneColor
3299
+ );
3300
+ } else {
3301
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3302
+ }
3303
+ })
3304
+ .on('mouseleave', function () {
3305
+ fadeReset(g);
3306
+ if (timelineScale) {
3307
+ hideEventDatesOnScale(g);
3308
+ } else {
3309
+ hideTooltip(tooltip);
3310
+ }
3311
+ })
3312
+ .on('mousemove', function (event: MouseEvent) {
3313
+ if (!timelineScale) {
3314
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3315
+ }
3316
+ })
3317
+ .on('click', () => {
3318
+ if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3319
+ });
3320
+
3321
+ if (ev.endDate) {
3322
+ const x2 = xScale(parseTimelineDate(ev.endDate));
3323
+ const rectW = Math.max(x2 - x, 4);
3324
+ // Estimate label width (~7px per char at 13px font) + padding
3325
+ const estLabelWidth = ev.label.length * 7 + 16;
3326
+ const labelFitsInside = rectW >= estLabelWidth;
3327
+
3328
+ let fill: string = laneColor;
3329
+ if (ev.uncertain) {
3330
+ // Create gradient for uncertain end - fades last 20%
3331
+ const gradientId = `uncertain-${ev.lineNumber}`;
3332
+ const defs = svg.select('defs').node() || svg.append('defs').node();
3333
+ d3Selection
3334
+ .select(defs as Element)
3335
+ .append('linearGradient')
3336
+ .attr('id', gradientId)
3337
+ .attr('x1', '0%')
3338
+ .attr('y1', '0%')
3339
+ .attr('x2', '100%')
3340
+ .attr('y2', '0%')
3341
+ .selectAll('stop')
3342
+ .data([
3343
+ { offset: '0%', opacity: 1 },
3344
+ { offset: '80%', opacity: 1 },
3345
+ { offset: '100%', opacity: 0 },
3346
+ ])
3347
+ .enter()
3348
+ .append('stop')
3349
+ .attr('offset', (d) => d.offset)
3350
+ .attr('stop-color', laneColor)
3351
+ .attr('stop-opacity', (d) => d.opacity);
3352
+ fill = `url(#${gradientId})`;
3353
+ }
3354
+
3355
+ evG
3356
+ .append('rect')
3357
+ .attr('x', x)
3358
+ .attr('y', y - BAR_H / 2)
3359
+ .attr('width', rectW)
3360
+ .attr('height', BAR_H)
3361
+ .attr('rx', 4)
3362
+ .attr('fill', fill);
3363
+
3364
+ if (labelFitsInside) {
3365
+ // Text inside bar - always white for readability
3366
+ evG
3367
+ .append('text')
3368
+ .attr('x', x + 8)
3369
+ .attr('y', y)
3370
+ .attr('dy', '0.35em')
3371
+ .attr('text-anchor', 'start')
3372
+ .attr('fill', '#ffffff')
3373
+ .attr('font-size', '13px')
3374
+ .attr('font-weight', '500')
3375
+ .text(ev.label);
3376
+ } else {
3377
+ // Text outside bar - check if it fits on left or must go right
3378
+ const wouldFlipLeft = x + rectW > innerWidth * 0.6;
3379
+ const labelFitsLeft = x - 6 - estLabelWidth > 0;
3380
+ const flipLeft = wouldFlipLeft && labelFitsLeft;
3381
+ evG
3382
+ .append('text')
3383
+ .attr('x', flipLeft ? x - 6 : x + rectW + 6)
3384
+ .attr('y', y)
3385
+ .attr('dy', '0.35em')
3386
+ .attr('text-anchor', flipLeft ? 'end' : 'start')
3387
+ .attr('fill', textColor)
3388
+ .attr('font-size', '13px')
3389
+ .text(ev.label);
3390
+ }
3391
+ } else {
3392
+ // Point event (no end date) - render as circle with label
3393
+ const estLabelWidth = ev.label.length * 7;
3394
+ // Only flip left if past 60% AND label fits without colliding with group name area
3395
+ const wouldFlipLeft = x > innerWidth * 0.6;
3396
+ const labelFitsLeft = x - 10 - estLabelWidth > 0;
3397
+ const flipLeft = wouldFlipLeft && labelFitsLeft;
3398
+ evG
3399
+ .append('circle')
3400
+ .attr('cx', x)
3401
+ .attr('cy', y)
3402
+ .attr('r', 5)
3403
+ .attr('fill', laneColor)
3404
+ .attr('stroke', bgColor)
3405
+ .attr('stroke-width', 1.5);
3406
+ evG
3407
+ .append('text')
3408
+ .attr('x', flipLeft ? x - 10 : x + 10)
3409
+ .attr('y', y)
3410
+ .attr('dy', '0.35em')
3411
+ .attr('text-anchor', flipLeft ? 'end' : 'start')
3412
+ .attr('fill', textColor)
3413
+ .attr('font-size', '12px')
3414
+ .text(ev.label);
3415
+ }
3416
+ });
3417
+
3418
+ curY += laneSpan + GROUP_GAP;
3419
+ }
3420
+ } else {
3421
+ // === TIME SORT, horizontal: each event on its own row ===
3422
+ const sorted = timelineEvents
3423
+ .slice()
3424
+ .sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
3425
+
3426
+ const scaleMargin = timelineScale ? 24 : 0;
3427
+ const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3428
+ const margin = {
3429
+ top: 104 + (timelineScale ? 40 : 0) + markerMargin,
3430
+ right: 40,
3431
+ bottom: 40 + scaleMargin,
3432
+ left: 60,
3433
+ };
3434
+ const innerWidth = width - margin.left - margin.right;
3435
+ const innerHeight = height - margin.top - margin.bottom;
3436
+ const rowH = Math.min(28, innerHeight / sorted.length);
3437
+
3438
+ const xScale = d3Scale
3439
+ .scaleLinear()
3440
+ .domain([minDate - datePadding, maxDate + datePadding])
3441
+ .range([0, innerWidth]);
3442
+
3443
+ const svg = d3Selection
3444
+ .select(container)
3445
+ .append('svg')
3446
+ .attr('width', width)
3447
+ .attr('height', height)
3448
+ .style('background', bgColor);
3449
+
3450
+ const g = svg
3451
+ .append('g')
3452
+ .attr('transform', `translate(${margin.left},${margin.top})`);
3453
+
3454
+ if (title) {
3455
+ svg
3456
+ .append('text')
3457
+ .attr('x', width / 2)
3458
+ .attr('y', 30)
3459
+ .attr('text-anchor', 'middle')
3460
+ .attr('fill', textColor)
3461
+ .attr('font-size', '18px')
3462
+ .attr('font-weight', '700')
3463
+ .text(title);
3464
+ }
3465
+
3466
+ renderEras(
3467
+ g,
3468
+ timelineEras,
3469
+ xScale,
3470
+ false,
3471
+ innerWidth,
3472
+ innerHeight,
3473
+ (s, e) => fadeToEra(g, s, e),
3474
+ () => fadeReset(g),
3475
+ timelineScale,
3476
+ tooltip,
3477
+ palette
3478
+ );
3479
+
3480
+ renderMarkers(
3481
+ g,
3482
+ timelineMarkers,
3483
+ xScale,
3484
+ false,
3485
+ innerWidth,
3486
+ innerHeight,
3487
+ timelineScale,
3488
+ tooltip,
3489
+ palette
3490
+ );
3491
+
3492
+ if (timelineScale) {
3493
+ renderTimeScale(
3494
+ g,
3495
+ xScale,
3496
+ false,
3497
+ innerWidth,
3498
+ innerHeight,
3499
+ textColor,
3500
+ minDate,
3501
+ maxDate,
3502
+ formatDateLabel(earliestStartDateStr),
3503
+ formatDateLabel(latestEndDateStr)
3504
+ );
3505
+ }
3506
+
3507
+ // Group legend at top-left
3508
+ if (timelineGroups.length > 0) {
3509
+ let legendX = 0;
3510
+ const legendY = timelineScale ? -75 : -55;
3511
+ for (const grp of timelineGroups) {
3512
+ const color = groupColorMap.get(grp.name) ?? textColor;
3513
+ const itemG = g
3514
+ .append('g')
3515
+ .attr('class', 'tl-legend-item')
3516
+ .attr('data-group', grp.name)
3517
+ .style('cursor', 'pointer')
3518
+ .on('mouseenter', () => fadeToGroup(g, grp.name))
3519
+ .on('mouseleave', () => fadeReset(g));
3520
+
3521
+ itemG
3522
+ .append('circle')
3523
+ .attr('cx', legendX)
3524
+ .attr('cy', legendY)
3525
+ .attr('r', 5)
3526
+ .attr('fill', color);
3527
+
3528
+ itemG
3529
+ .append('text')
3530
+ .attr('x', legendX + 10)
3531
+ .attr('y', legendY)
3532
+ .attr('dy', '0.35em')
3533
+ .attr('fill', textColor)
3534
+ .attr('font-size', '11px')
3535
+ .text(grp.name);
3536
+
3537
+ legendX += grp.name.length * 7 + 30;
3538
+ }
3539
+ }
3540
+
3541
+ sorted.forEach((ev, i) => {
3542
+ // Offset events below marker area when markers are present
3543
+ const y = markerMargin + i * rowH + rowH / 2;
3544
+ const x = xScale(parseTimelineDate(ev.date));
3545
+ const color = eventColor(ev);
3546
+
3547
+ const evG = g
3548
+ .append('g')
3549
+ .attr('class', 'tl-event')
3550
+ .attr('data-group', ev.group || '')
3551
+ .attr('data-date', String(parseTimelineDate(ev.date)))
3552
+ .attr(
3553
+ 'data-end-date',
3554
+ ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
3555
+ )
3556
+ .style('cursor', 'pointer')
3557
+ .on('mouseenter', function (event: MouseEvent) {
3558
+ if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
3559
+ if (timelineScale) {
3560
+ showEventDatesOnScale(
3561
+ g,
3562
+ xScale,
3563
+ ev.date,
3564
+ ev.endDate,
3565
+ innerHeight,
3566
+ color
3567
+ );
3568
+ } else {
3569
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3570
+ }
3571
+ })
3572
+ .on('mouseleave', function () {
3573
+ fadeReset(g);
3574
+ if (timelineScale) {
3575
+ hideEventDatesOnScale(g);
3576
+ } else {
3577
+ hideTooltip(tooltip);
3578
+ }
3579
+ })
3580
+ .on('mousemove', function (event: MouseEvent) {
3581
+ if (!timelineScale) {
3582
+ showTooltip(tooltip, buildEventTooltipHtml(ev), event);
3583
+ }
3584
+ })
3585
+ .on('click', () => {
3586
+ if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
3587
+ });
3588
+
3589
+ if (ev.endDate) {
3590
+ const x2 = xScale(parseTimelineDate(ev.endDate));
3591
+ const rectW = Math.max(x2 - x, 4);
3592
+ // Estimate label width (~7px per char at 13px font) + padding
3593
+ const estLabelWidth = ev.label.length * 7 + 16;
3594
+ const labelFitsInside = rectW >= estLabelWidth;
3595
+
3596
+ let fill: string = color;
3597
+ if (ev.uncertain) {
3598
+ // Create gradient for uncertain end - fades last 20%
3599
+ const gradientId = `uncertain-ts-${ev.lineNumber}`;
3600
+ const defs = svg.select('defs').node() || svg.append('defs').node();
3601
+ d3Selection
3602
+ .select(defs as Element)
3603
+ .append('linearGradient')
3604
+ .attr('id', gradientId)
3605
+ .attr('x1', '0%')
3606
+ .attr('y1', '0%')
3607
+ .attr('x2', '100%')
3608
+ .attr('y2', '0%')
3609
+ .selectAll('stop')
3610
+ .data([
3611
+ { offset: '0%', opacity: 1 },
3612
+ { offset: '80%', opacity: 1 },
3613
+ { offset: '100%', opacity: 0 },
3614
+ ])
3615
+ .enter()
3616
+ .append('stop')
3617
+ .attr('offset', (d) => d.offset)
3618
+ .attr('stop-color', color)
3619
+ .attr('stop-opacity', (d) => d.opacity);
3620
+ fill = `url(#${gradientId})`;
3621
+ }
3622
+
3623
+ evG
3624
+ .append('rect')
3625
+ .attr('x', x)
3626
+ .attr('y', y - BAR_H / 2)
3627
+ .attr('width', rectW)
3628
+ .attr('height', BAR_H)
3629
+ .attr('rx', 4)
3630
+ .attr('fill', fill);
3631
+
3632
+ if (labelFitsInside) {
3633
+ // Text inside bar - always white for readability
3634
+ evG
3635
+ .append('text')
3636
+ .attr('x', x + 8)
3637
+ .attr('y', y)
3638
+ .attr('dy', '0.35em')
3639
+ .attr('text-anchor', 'start')
3640
+ .attr('fill', '#ffffff')
3641
+ .attr('font-size', '13px')
3642
+ .attr('font-weight', '500')
3643
+ .text(ev.label);
3644
+ } else {
3645
+ // Text outside bar - check if it fits on left or must go right
3646
+ const wouldFlipLeft = x + rectW > innerWidth * 0.6;
3647
+ const labelFitsLeft = x - 6 - estLabelWidth > 0;
3648
+ const flipLeft = wouldFlipLeft && labelFitsLeft;
3649
+ evG
3650
+ .append('text')
3651
+ .attr('x', flipLeft ? x - 6 : x + rectW + 6)
3652
+ .attr('y', y)
3653
+ .attr('dy', '0.35em')
3654
+ .attr('text-anchor', flipLeft ? 'end' : 'start')
3655
+ .attr('fill', textColor)
3656
+ .attr('font-size', '13px')
3657
+ .text(ev.label);
3658
+ }
3659
+ } else {
3660
+ // Point event (no end date) - render as circle with label
3661
+ const estLabelWidth = ev.label.length * 7;
3662
+ // Only flip left if past 60% AND label fits without going off-chart
3663
+ const wouldFlipLeft = x > innerWidth * 0.6;
3664
+ const labelFitsLeft = x - 10 - estLabelWidth > 0;
3665
+ const flipLeft = wouldFlipLeft && labelFitsLeft;
3666
+ evG
3667
+ .append('circle')
3668
+ .attr('cx', x)
3669
+ .attr('cy', y)
3670
+ .attr('r', 5)
3671
+ .attr('fill', color)
3672
+ .attr('stroke', bgColor)
3673
+ .attr('stroke-width', 1.5);
3674
+ evG
3675
+ .append('text')
3676
+ .attr('x', flipLeft ? x - 10 : x + 10)
3677
+ .attr('y', y)
3678
+ .attr('dy', '0.35em')
3679
+ .attr('text-anchor', flipLeft ? 'end' : 'start')
3680
+ .attr('fill', textColor)
3681
+ .attr('font-size', '12px')
3682
+ .text(ev.label);
3683
+ }
3684
+ });
3685
+ }
3686
+ }
3687
+
3688
+ // ============================================================
3689
+ // Word Cloud Helpers
3690
+ // ============================================================
3691
+
3692
+ function getRotateFn(mode: WordCloudRotate): () => number {
3693
+ if (mode === 'mixed') return () => (Math.random() > 0.5 ? 0 : 90);
3694
+ if (mode === 'angled') return () => Math.round(Math.random() * 30 - 15);
3695
+ return () => 0;
3696
+ }
3697
+
3698
+ // ============================================================
3699
+ // Word Cloud Renderer
3700
+ // ============================================================
3701
+
3702
+ /**
3703
+ * Renders a word cloud into the given container using d3-cloud.
3704
+ */
3705
+ export function renderWordCloud(
3706
+ container: HTMLDivElement,
3707
+ parsed: ParsedD3,
3708
+ palette: PaletteColors,
3709
+ _isDark: boolean,
3710
+ onClickItem?: (lineNumber: number) => void
3711
+ ): void {
3712
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
3713
+
3714
+ const { words, title, cloudOptions } = parsed;
3715
+ if (words.length === 0) return;
3716
+
3717
+ const width = container.clientWidth;
3718
+ const height = container.clientHeight;
3719
+ if (width <= 0 || height <= 0) return;
3720
+
3721
+ const titleHeight = title ? 40 : 0;
3722
+ const cloudHeight = height - titleHeight;
3723
+
3724
+ const textColor = palette.text;
3725
+ const bgColor = palette.overlay;
3726
+ const colors = getSeriesColors(palette);
3727
+
3728
+ const { minSize, maxSize } = cloudOptions;
3729
+ const weights = words.map((w) => w.weight);
3730
+ const minWeight = Math.min(...weights);
3731
+ const maxWeight = Math.max(...weights);
3732
+ const range = maxWeight - minWeight || 1;
3733
+
3734
+ const fontSize = (weight: number): number => {
3735
+ const t = (weight - minWeight) / range;
3736
+ return minSize + t * (maxSize - minSize);
3737
+ };
3738
+
3739
+ const rotateFn = getRotateFn(cloudOptions.rotate);
3740
+
3741
+ const svg = d3Selection
3742
+ .select(container)
3743
+ .append('svg')
3744
+ .attr('width', width)
3745
+ .attr('height', height)
3746
+ .style('background', bgColor);
3747
+
3748
+ if (title) {
3749
+ svg
3750
+ .append('text')
3751
+ .attr('x', width / 2)
3752
+ .attr('y', 28)
3753
+ .attr('text-anchor', 'middle')
3754
+ .attr('fill', textColor)
3755
+ .attr('font-size', '18px')
3756
+ .attr('font-weight', '700')
3757
+ .text(title);
3758
+ }
3759
+
3760
+ const g = svg
3761
+ .append('g')
3762
+ .attr(
3763
+ 'transform',
3764
+ `translate(${width / 2},${titleHeight + cloudHeight / 2})`
3765
+ );
3766
+
3767
+ cloud<WordCloudWord & cloud.Word>()
3768
+ .size([width, cloudHeight])
3769
+ .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
3770
+ .padding(4)
3771
+ .rotate(rotateFn)
3772
+ .fontSize((d) => d.size!)
3773
+ .font('system-ui, -apple-system, sans-serif')
3774
+ .on('end', (layoutWords) => {
3775
+ g.selectAll('text')
3776
+ .data(layoutWords)
3777
+ .join('text')
3778
+ .style('font-size', (d) => `${d.size}px`)
3779
+ .style('font-family', 'system-ui, -apple-system, sans-serif')
3780
+ .style('font-weight', '600')
3781
+ .style('fill', (_d, i) => colors[i % colors.length])
3782
+ .style('cursor', (d) =>
3783
+ onClickItem && (d as WordCloudWord).lineNumber ? 'pointer' : 'default'
3784
+ )
3785
+ .attr('text-anchor', 'middle')
3786
+ .attr(
3787
+ 'transform',
3788
+ (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
3789
+ )
3790
+ .text((d) => d.text!)
3791
+ .on('click', (_event, d) => {
3792
+ const ln = (d as WordCloudWord).lineNumber;
3793
+ if (onClickItem && ln) onClickItem(ln);
3794
+ });
3795
+ })
3796
+ .start();
3797
+ }
3798
+
3799
+ // ============================================================
3800
+ // Word Cloud Renderer (for export — returns Promise)
3801
+ // ============================================================
3802
+
3803
+ function renderWordCloudAsync(
3804
+ container: HTMLDivElement,
3805
+ parsed: ParsedD3,
3806
+ palette: PaletteColors,
3807
+ _isDark: boolean
3808
+ ): Promise<void> {
3809
+ return new Promise((resolve) => {
3810
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
3811
+
3812
+ const { words, title, cloudOptions } = parsed;
3813
+ if (words.length === 0) {
3814
+ resolve();
3815
+ return;
3816
+ }
3817
+
3818
+ const width = container.clientWidth;
3819
+ const height = container.clientHeight;
3820
+ if (width <= 0 || height <= 0) {
3821
+ resolve();
3822
+ return;
3823
+ }
3824
+
3825
+ const titleHeight = title ? 40 : 0;
3826
+ const cloudHeight = height - titleHeight;
3827
+
3828
+ const textColor = palette.text;
3829
+ const bgColor = palette.overlay;
3830
+ const colors = getSeriesColors(palette);
3831
+
3832
+ const { minSize, maxSize } = cloudOptions;
3833
+ const weights = words.map((w) => w.weight);
3834
+ const minWeight = Math.min(...weights);
3835
+ const maxWeight = Math.max(...weights);
3836
+ const range = maxWeight - minWeight || 1;
3837
+
3838
+ const fontSize = (weight: number): number => {
3839
+ const t = (weight - minWeight) / range;
3840
+ return minSize + t * (maxSize - minSize);
3841
+ };
3842
+
3843
+ const rotateFn = getRotateFn(cloudOptions.rotate);
3844
+
3845
+ const svg = d3Selection
3846
+ .select(container)
3847
+ .append('svg')
3848
+ .attr('width', width)
3849
+ .attr('height', height)
3850
+ .style('background', bgColor);
3851
+
3852
+ if (title) {
3853
+ svg
3854
+ .append('text')
3855
+ .attr('x', width / 2)
3856
+ .attr('y', 28)
3857
+ .attr('text-anchor', 'middle')
3858
+ .attr('fill', textColor)
3859
+ .attr('font-size', '18px')
3860
+ .attr('font-weight', '700')
3861
+ .text(title);
3862
+ }
3863
+
3864
+ const g = svg
3865
+ .append('g')
3866
+ .attr(
3867
+ 'transform',
3868
+ `translate(${width / 2},${titleHeight + cloudHeight / 2})`
3869
+ );
3870
+
3871
+ cloud<WordCloudWord & cloud.Word>()
3872
+ .size([width, cloudHeight])
3873
+ .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
3874
+ .padding(4)
3875
+ .rotate(rotateFn)
3876
+ .fontSize((d) => d.size!)
3877
+ .font('system-ui, -apple-system, sans-serif')
3878
+ .on('end', (layoutWords) => {
3879
+ g.selectAll('text')
3880
+ .data(layoutWords)
3881
+ .join('text')
3882
+ .style('font-size', (d) => `${d.size}px`)
3883
+ .style('font-family', 'system-ui, -apple-system, sans-serif')
3884
+ .style('font-weight', '600')
3885
+ .style('fill', (_d, i) => colors[i % colors.length])
3886
+ .attr('text-anchor', 'middle')
3887
+ .attr(
3888
+ 'transform',
3889
+ (d) => `translate(${d.x},${d.y}) rotate(${d.rotate})`
3890
+ )
3891
+ .text((d) => d.text!);
3892
+ resolve();
3893
+ })
3894
+ .start();
3895
+ });
3896
+ }
3897
+
3898
+ // ============================================================
3899
+ // Venn Diagram Math Helpers
3900
+ // ============================================================
3901
+
3902
+ function radiusFromArea(area: number): number {
3903
+ return Math.sqrt(area / Math.PI);
3904
+ }
3905
+
3906
+ function circleOverlapArea(r1: number, r2: number, d: number): number {
3907
+ // No overlap
3908
+ if (d >= r1 + r2) return 0;
3909
+ // Full containment
3910
+ if (d + Math.min(r1, r2) <= Math.max(r1, r2)) {
3911
+ return Math.PI * Math.min(r1, r2) ** 2;
3912
+ }
3913
+ const part1 = r1 * r1 * Math.acos((d * d + r1 * r1 - r2 * r2) / (2 * d * r1));
3914
+ const part2 = r2 * r2 * Math.acos((d * d + r2 * r2 - r1 * r1) / (2 * d * r2));
3915
+ const part3 =
3916
+ 0.5 *
3917
+ Math.sqrt((-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2));
3918
+ return part1 + part2 - part3;
3919
+ }
3920
+
3921
+ function distanceForOverlap(
3922
+ r1: number,
3923
+ r2: number,
3924
+ targetArea: number
3925
+ ): number {
3926
+ if (targetArea <= 0) return r1 + r2;
3927
+ const minR = Math.min(r1, r2);
3928
+ if (targetArea >= Math.PI * minR * minR) return Math.abs(r1 - r2);
3929
+ let lo = Math.abs(r1 - r2);
3930
+ let hi = r1 + r2;
3931
+ for (let i = 0; i < 64; i++) {
3932
+ const mid = (lo + hi) / 2;
3933
+ if (circleOverlapArea(r1, r2, mid) > targetArea) {
3934
+ lo = mid;
3935
+ } else {
3936
+ hi = mid;
3937
+ }
3938
+ }
3939
+ return (lo + hi) / 2;
3940
+ }
3941
+
3942
+ interface Point {
3943
+ x: number;
3944
+ y: number;
3945
+ }
3946
+
3947
+ interface Circle {
3948
+ x: number;
3949
+ y: number;
3950
+ r: number;
3951
+ }
3952
+
3953
+ function thirdCirclePosition(
3954
+ ax: number,
3955
+ ay: number,
3956
+ dAC: number,
3957
+ bx: number,
3958
+ by: number,
3959
+ dBC: number
3960
+ ): Point {
3961
+ const dx = bx - ax;
3962
+ const dy = by - ay;
3963
+ const dAB = Math.sqrt(dx * dx + dy * dy);
3964
+ if (dAB === 0) return { x: ax + dAC, y: ay };
3965
+ const cosA = (dAB * dAB + dAC * dAC - dBC * dBC) / (2 * dAB * dAC);
3966
+ const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
3967
+ const ux = dx / dAB;
3968
+ const uy = dy / dAB;
3969
+ // Place C above the AB line
3970
+ return {
3971
+ x: ax + dAC * (cosA * ux - sinA * uy),
3972
+ y: ay + dAC * (cosA * uy + sinA * ux),
3973
+ };
3974
+ }
3975
+
3976
+ function fitCirclesToContainer(
3977
+ circles: Circle[],
3978
+ w: number,
3979
+ h: number,
3980
+ margin: number
3981
+ ): Circle[] {
3982
+ if (circles.length === 0) return [];
3983
+ let minX = Infinity,
3984
+ maxX = -Infinity,
3985
+ minY = Infinity,
3986
+ maxY = -Infinity;
3987
+ for (const c of circles) {
3988
+ minX = Math.min(minX, c.x - c.r);
3989
+ maxX = Math.max(maxX, c.x + c.r);
3990
+ minY = Math.min(minY, c.y - c.r);
3991
+ maxY = Math.max(maxY, c.y + c.r);
3992
+ }
3993
+ const bw = maxX - minX;
3994
+ const bh = maxY - minY;
3995
+ const availW = w - 2 * margin;
3996
+ const availH = h - 2 * margin;
3997
+ const scale = Math.min(availW / bw, availH / bh) * 0.85;
3998
+ const cx = (minX + maxX) / 2;
3999
+ const cy = (minY + maxY) / 2;
4000
+ const tx = w / 2;
4001
+ const ty = h / 2;
4002
+ return circles.map((c) => ({
4003
+ x: (c.x - cx) * scale + tx,
4004
+ y: (c.y - cy) * scale + ty,
4005
+ r: c.r * scale,
4006
+ }));
4007
+ }
4008
+
4009
+ function pointInCircle(p: Point, c: Circle): boolean {
4010
+ const dx = p.x - c.x;
4011
+ const dy = p.y - c.y;
4012
+ return dx * dx + dy * dy <= c.r * c.r + 1e-6;
4013
+ }
4014
+
4015
+ function regionCentroid(circles: Circle[], inside: boolean[]): Point {
4016
+ // Sample points and average those matching the region
4017
+ const N = 500;
4018
+ let minX = Infinity,
4019
+ maxX = -Infinity,
4020
+ minY = Infinity,
4021
+ maxY = -Infinity;
4022
+ for (const c of circles) {
4023
+ minX = Math.min(minX, c.x - c.r);
4024
+ maxX = Math.max(maxX, c.x + c.r);
4025
+ minY = Math.min(minY, c.y - c.r);
4026
+ maxY = Math.max(maxY, c.y + c.r);
4027
+ }
4028
+ let sx = 0,
4029
+ sy = 0,
4030
+ count = 0;
4031
+ for (let i = 0; i < N; i++) {
4032
+ const x = minX + Math.random() * (maxX - minX);
4033
+ const y = minY + Math.random() * (maxY - minY);
4034
+ let match = true;
4035
+ for (let j = 0; j < circles.length; j++) {
4036
+ const isIn = pointInCircle({ x, y }, circles[j]);
4037
+ if (isIn !== inside[j]) {
4038
+ match = false;
4039
+ break;
4040
+ }
4041
+ }
4042
+ if (match) {
4043
+ sx += x;
4044
+ sy += y;
4045
+ count++;
4046
+ }
4047
+ }
4048
+ if (count === 0) {
4049
+ // Fallback: centroid of the circles that should be "inside"
4050
+ let fx = 0,
4051
+ fy = 0,
4052
+ fc = 0;
4053
+ for (let j = 0; j < circles.length; j++) {
4054
+ if (inside[j]) {
4055
+ fx += circles[j].x;
4056
+ fy += circles[j].y;
4057
+ fc++;
4058
+ }
4059
+ }
4060
+ return { x: fx / (fc || 1), y: fy / (fc || 1) };
4061
+ }
4062
+ return { x: sx / count, y: sy / count };
4063
+ }
4064
+
4065
+ // ============================================================
4066
+ // Venn Diagram Renderer
4067
+ // ============================================================
4068
+
4069
+ function blendColors(hexColors: string[]): string {
4070
+ let r = 0,
4071
+ g = 0,
4072
+ b = 0;
4073
+ for (const hex of hexColors) {
4074
+ const h = hex.replace('#', '');
4075
+ r += parseInt(h.substring(0, 2), 16);
4076
+ g += parseInt(h.substring(2, 4), 16);
4077
+ b += parseInt(h.substring(4, 6), 16);
4078
+ }
4079
+ const n = hexColors.length;
4080
+ return `#${Math.round(r / n)
4081
+ .toString(16)
4082
+ .padStart(2, '0')}${Math.round(g / n)
4083
+ .toString(16)
4084
+ .padStart(2, '0')}${Math.round(b / n)
4085
+ .toString(16)
4086
+ .padStart(2, '0')}`;
4087
+ }
4088
+
4089
+ function circlePathD(cx: number, cy: number, r: number): string {
4090
+ return `M${cx - r},${cy} A${r},${r} 0 1,0 ${cx + r},${cy} A${r},${r} 0 1,0 ${cx - r},${cy} Z`;
4091
+ }
4092
+
4093
+ export function renderVenn(
4094
+ container: HTMLDivElement,
4095
+ parsed: ParsedD3,
4096
+ palette: PaletteColors,
4097
+ isDark: boolean,
4098
+ onClickItem?: (lineNumber: number) => void
4099
+ ): void {
4100
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4101
+
4102
+ const { vennSets, vennOverlaps, vennShowValues, title } = parsed;
4103
+ if (vennSets.length < 2) return;
4104
+
4105
+ const width = container.clientWidth;
4106
+ const height = container.clientHeight;
4107
+ if (width <= 0 || height <= 0) return;
4108
+
4109
+ const textColor = palette.text;
4110
+ const bgColor = palette.overlay;
4111
+ const colors = getSeriesColors(palette);
4112
+ const titleHeight = title ? 40 : 0;
4113
+
4114
+ // Compute radii
4115
+ const radii = vennSets.map((s) => radiusFromArea(s.size));
4116
+
4117
+ // Build overlap map keyed by sorted set names
4118
+ const overlapMap = new Map<string, number>();
4119
+ for (const ov of vennOverlaps) {
4120
+ overlapMap.set(ov.sets.join('&'), ov.size);
4121
+ }
4122
+
4123
+ // Layout circles
4124
+ let rawCircles: Circle[];
4125
+ const n = vennSets.length;
4126
+
4127
+ if (n === 2) {
4128
+ const d = distanceForOverlap(
4129
+ radii[0],
4130
+ radii[1],
4131
+ overlapMap.get([vennSets[0].name, vennSets[1].name].sort().join('&')) ?? 0
4132
+ );
4133
+ rawCircles = [
4134
+ { x: -d / 2, y: 0, r: radii[0] },
4135
+ { x: d / 2, y: 0, r: radii[1] },
4136
+ ];
4137
+ } else {
4138
+ // 3 sets: place A and B, then compute C position
4139
+ const names = vennSets.map((s) => s.name);
4140
+ const pairKey = (i: number, j: number) =>
4141
+ [names[i], names[j]].sort().join('&');
4142
+
4143
+ const dAB = distanceForOverlap(
4144
+ radii[0],
4145
+ radii[1],
4146
+ overlapMap.get(pairKey(0, 1)) ?? 0
4147
+ );
4148
+ const dAC = distanceForOverlap(
4149
+ radii[0],
4150
+ radii[2],
4151
+ overlapMap.get(pairKey(0, 2)) ?? 0
4152
+ );
4153
+ const dBC = distanceForOverlap(
4154
+ radii[1],
4155
+ radii[2],
4156
+ overlapMap.get(pairKey(1, 2)) ?? 0
4157
+ );
4158
+
4159
+ const ax = -dAB / 2;
4160
+ const bx = dAB / 2;
4161
+ const cPos = thirdCirclePosition(ax, 0, dAC, bx, 0, dBC);
4162
+
4163
+ rawCircles = [
4164
+ { x: ax, y: 0, r: radii[0] },
4165
+ { x: bx, y: 0, r: radii[1] },
4166
+ { x: cPos.x, y: cPos.y, r: radii[2] },
4167
+ ];
4168
+ }
4169
+
4170
+ const drawH = height - titleHeight;
4171
+ const labelMargin = 100; // extra margin for external labels
4172
+ const circles = fitCirclesToContainer(
4173
+ rawCircles,
4174
+ width,
4175
+ drawH,
4176
+ labelMargin
4177
+ ).map((c) => ({ ...c, y: c.y + titleHeight }));
4178
+
4179
+ // Resolve colors for each set
4180
+ const setColors = vennSets.map(
4181
+ (s, i) => s.color ?? colors[i % colors.length]
4182
+ );
4183
+
4184
+ // SVG
4185
+ const svg = d3Selection
4186
+ .select(container)
4187
+ .append('svg')
4188
+ .attr('width', width)
4189
+ .attr('height', height)
4190
+ .style('background', bgColor);
4191
+
4192
+ // Tooltip
4193
+ const tooltip = createTooltip(container, palette, isDark);
4194
+
4195
+ // Title
4196
+ if (title) {
4197
+ svg
4198
+ .append('text')
4199
+ .attr('x', width / 2)
4200
+ .attr('y', 28)
4201
+ .attr('text-anchor', 'middle')
4202
+ .attr('fill', textColor)
4203
+ .attr('font-size', '18px')
4204
+ .attr('font-weight', '700')
4205
+ .text(title);
4206
+ }
4207
+
4208
+ // ── Clip-path definitions ──
4209
+ // For each circle: a clip-path to include it, and one to exclude it (hole)
4210
+ const defs = svg.append('defs');
4211
+ const pad = 20;
4212
+ circles.forEach((c, i) => {
4213
+ // Include clip: just the circle
4214
+ defs
4215
+ .append('clipPath')
4216
+ .attr('id', `venn-in-${i}`)
4217
+ .append('circle')
4218
+ .attr('cx', c.x)
4219
+ .attr('cy', c.y)
4220
+ .attr('r', c.r);
4221
+
4222
+ // Exclude clip: large rect with circle punched out via evenodd
4223
+ defs
4224
+ .append('clipPath')
4225
+ .attr('id', `venn-out-${i}`)
4226
+ .append('path')
4227
+ .attr(
4228
+ 'd',
4229
+ `M${-pad},${-pad} H${width + pad} V${height + pad} H${-pad} Z ` +
4230
+ circlePathD(c.x, c.y, c.r)
4231
+ )
4232
+ .attr('clip-rule', 'evenodd');
4233
+ });
4234
+
4235
+ // Helper: nest clip-path groups and append a filled rect
4236
+ function drawClippedRegion(
4237
+ parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
4238
+ clipIds: string[],
4239
+ fill: string
4240
+ ): d3Selection.Selection<SVGGElement, unknown, null, undefined> {
4241
+ let g = parent;
4242
+ for (const id of clipIds) {
4243
+ g = g
4244
+ .append('g')
4245
+ .attr('clip-path', `url(#${id})`) as d3Selection.Selection<
4246
+ SVGGElement,
4247
+ unknown,
4248
+ null,
4249
+ undefined
4250
+ >;
4251
+ }
4252
+ g.append('rect')
4253
+ .attr('x', -pad)
4254
+ .attr('y', -pad)
4255
+ .attr('width', width + 2 * pad)
4256
+ .attr('height', height + 2 * pad)
4257
+ .attr('fill', fill);
4258
+ return g;
4259
+ }
4260
+
4261
+ // ── Draw opaque regions ──
4262
+ // Track region groups by which circle indices they relate to (for hover dimming)
4263
+ const regionGroups: {
4264
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>;
4265
+ circleIdxs: number[];
4266
+ }[] = [];
4267
+
4268
+ // Exclusive regions: inside circle i, outside all others
4269
+ const regionsParent = svg.append('g');
4270
+ for (let i = 0; i < n; i++) {
4271
+ const clips = [`venn-in-${i}`];
4272
+ for (let j = 0; j < n; j++) {
4273
+ if (j !== i) clips.push(`venn-out-${j}`);
4274
+ }
4275
+ const g = regionsParent.append('g') as d3Selection.Selection<
4276
+ SVGGElement,
4277
+ unknown,
4278
+ null,
4279
+ undefined
4280
+ >;
4281
+ drawClippedRegion(g, clips, setColors[i]);
4282
+ regionGroups.push({ g, circleIdxs: [i] });
4283
+ }
4284
+
4285
+ // Pairwise overlap regions (excluding any third circle)
4286
+ const pairIndices: [number, number][] = [];
4287
+ for (let i = 0; i < n; i++) {
4288
+ for (let j = i + 1; j < n; j++) {
4289
+ pairIndices.push([i, j]);
4290
+ }
4291
+ }
4292
+ for (const [i, j] of pairIndices) {
4293
+ const clips = [`venn-in-${i}`, `venn-in-${j}`];
4294
+ for (let k = 0; k < n; k++) {
4295
+ if (k !== i && k !== j) clips.push(`venn-out-${k}`);
4296
+ }
4297
+ const blended = blendColors([setColors[i], setColors[j]]);
4298
+ const g = regionsParent.append('g') as d3Selection.Selection<
4299
+ SVGGElement,
4300
+ unknown,
4301
+ null,
4302
+ undefined
4303
+ >;
4304
+ drawClippedRegion(g, clips, blended);
4305
+ regionGroups.push({ g, circleIdxs: [i, j] });
4306
+ }
4307
+
4308
+ // Triple overlap region (if 3 sets)
4309
+ if (n === 3) {
4310
+ const clips = [`venn-in-0`, `venn-in-1`, `venn-in-2`];
4311
+ const blended = blendColors([setColors[0], setColors[1], setColors[2]]);
4312
+ const g = regionsParent.append('g') as d3Selection.Selection<
4313
+ SVGGElement,
4314
+ unknown,
4315
+ null,
4316
+ undefined
4317
+ >;
4318
+ drawClippedRegion(g, clips, blended);
4319
+ regionGroups.push({ g, circleIdxs: [0, 1, 2] });
4320
+ }
4321
+
4322
+ // ── Circle outlines ──
4323
+ const outlineGroup = svg.append('g');
4324
+ circles.forEach((c, i) => {
4325
+ outlineGroup
4326
+ .append('circle')
4327
+ .attr('cx', c.x)
4328
+ .attr('cy', c.y)
4329
+ .attr('r', c.r)
4330
+ .attr('fill', 'none')
4331
+ .attr('stroke', setColors[i])
4332
+ .attr('stroke-width', 2)
4333
+ .style('pointer-events', 'none');
4334
+ });
4335
+
4336
+ // ── External labels with leader lines (pie-chart style) ──
4337
+ interface LabelEntry {
4338
+ centroid: Point;
4339
+ text: string;
4340
+ involvedIdxs: number[]; // which circle indices this label belongs to
4341
+ }
4342
+ const labelEntries: LabelEntry[] = [];
4343
+
4344
+ // Global center of all circles (for projecting outward)
4345
+ const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
4346
+ const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
4347
+
4348
+ // Set name labels (exclusive regions)
4349
+ circles.forEach((_c, i) => {
4350
+ const inside = circles.map((_, j) => j === i);
4351
+ const centroid = regionCentroid(circles, inside);
4352
+ const displayName = vennSets[i].label ?? vennSets[i].name;
4353
+ const text = vennShowValues
4354
+ ? `${displayName} (${vennSets[i].size})`
4355
+ : displayName;
4356
+ labelEntries.push({ centroid, text, involvedIdxs: [i] });
4357
+ });
4358
+
4359
+ // Overlap labels
4360
+ for (const ov of vennOverlaps) {
4361
+ const idxs = ov.sets.map((s) => vennSets.findIndex((vs) => vs.name === s));
4362
+ if (idxs.some((i) => i < 0)) continue;
4363
+ if (!ov.label && !vennShowValues) continue;
4364
+
4365
+ const inside = circles.map((_, j) => idxs.includes(j));
4366
+ const centroid = regionCentroid(circles, inside);
4367
+ let text = '';
4368
+ if (ov.label && vennShowValues) text = `${ov.label} (${ov.size})`;
4369
+ else if (ov.label) text = ov.label;
4370
+ else text = String(ov.size);
4371
+ labelEntries.push({ centroid, text, involvedIdxs: idxs });
4372
+ }
4373
+
4374
+ // Helper: ray-circle exit distance (positive = forward along direction)
4375
+ function rayCircleExit(
4376
+ ox: number,
4377
+ oy: number,
4378
+ dx: number,
4379
+ dy: number,
4380
+ c: Circle
4381
+ ): number {
4382
+ const lx = ox - c.x;
4383
+ const ly = oy - c.y;
4384
+ const b = lx * dx + ly * dy;
4385
+ const det = b * b - (lx * lx + ly * ly - c.r * c.r);
4386
+ if (det < 0) return 0;
4387
+ return -b + Math.sqrt(det);
4388
+ }
4389
+
4390
+ const stubLen = 20;
4391
+ const edgePad = 8; // clearance from circle edge
4392
+ const labelGroup = svg.append('g').style('pointer-events', 'none');
4393
+
4394
+ for (const entry of labelEntries) {
4395
+ const { centroid, text } = entry;
4396
+
4397
+ // Direction from global center to centroid
4398
+ let dx = centroid.x - gcx;
4399
+ let dy = centroid.y - gcy;
4400
+ const mag = Math.sqrt(dx * dx + dy * dy);
4401
+ if (mag < 1e-6) {
4402
+ dx = 1;
4403
+ dy = 0;
4404
+ } else {
4405
+ dx /= mag;
4406
+ dy /= mag;
4407
+ }
4408
+
4409
+ // Exit at the farthest circle edge so the label lands in white space
4410
+ let exitT = 0;
4411
+ for (const c of circles) {
4412
+ const t = rayCircleExit(centroid.x, centroid.y, dx, dy, c);
4413
+ if (t > exitT) exitT = t;
4414
+ }
4415
+
4416
+ // Edge point: outside the exit boundary with padding
4417
+ const edgeX = centroid.x + dx * (exitT + edgePad);
4418
+ const edgeY = centroid.y + dy * (exitT + edgePad);
4419
+
4420
+ // Stub end point
4421
+ const stubX = edgeX + dx * stubLen;
4422
+ const stubY = edgeY + dy * stubLen;
4423
+
4424
+ // For overlap regions (2+ sets), draw leader from centroid into the region
4425
+ // For exclusive regions (single set), just draw from edge outward
4426
+ const isOverlap = entry.involvedIdxs.length > 1;
4427
+ const lineStartX = isOverlap ? centroid.x : edgeX;
4428
+ const lineStartY = isOverlap ? centroid.y : edgeY;
4429
+
4430
+ labelGroup
4431
+ .append('line')
4432
+ .attr('x1', lineStartX)
4433
+ .attr('y1', lineStartY)
4434
+ .attr('x2', stubX)
4435
+ .attr('y2', stubY)
4436
+ .attr('stroke', textColor)
4437
+ .attr('stroke-width', 1);
4438
+
4439
+ // Text positioned right after the stub
4440
+ const isRight = stubX >= gcx;
4441
+ const textAnchor = isRight ? 'start' : 'end';
4442
+ const textX = stubX + (isRight ? 4 : -4);
4443
+
4444
+ labelGroup
4445
+ .append('text')
4446
+ .attr('x', textX)
4447
+ .attr('y', stubY)
4448
+ .attr('text-anchor', textAnchor)
4449
+ .attr('dominant-baseline', 'central')
4450
+ .attr('fill', textColor)
4451
+ .attr('font-size', '14px')
4452
+ .attr('font-weight', 'bold')
4453
+ .text(text);
4454
+ }
4455
+
4456
+ // ── Invisible hover targets (full circles) + interactions ──
4457
+ const hoverGroup = svg.append('g');
4458
+ circles.forEach((c, i) => {
4459
+ const tipName = vennSets[i].label
4460
+ ? `${vennSets[i].label} (${vennSets[i].name})`
4461
+ : vennSets[i].name;
4462
+ const tipHtml = `<strong>${tipName}</strong><br>Size: ${vennSets[i].size}`;
4463
+
4464
+ hoverGroup
4465
+ .append('circle')
4466
+ .attr('cx', c.x)
4467
+ .attr('cy', c.y)
4468
+ .attr('r', c.r)
4469
+ .attr('fill', 'transparent')
4470
+ .style('cursor', onClickItem ? 'pointer' : 'default')
4471
+ .on('mouseenter', (event: MouseEvent) => {
4472
+ for (const rg of regionGroups) {
4473
+ rg.g.style('opacity', rg.circleIdxs.includes(i) ? '1' : '0.25');
4474
+ }
4475
+ showTooltip(tooltip, tipHtml, event);
4476
+ })
4477
+ .on('mousemove', (event: MouseEvent) => {
4478
+ showTooltip(tooltip, tipHtml, event);
4479
+ })
4480
+ .on('mouseleave', () => {
4481
+ for (const rg of regionGroups) {
4482
+ rg.g.style('opacity', '1');
4483
+ }
4484
+ hideTooltip(tooltip);
4485
+ })
4486
+ .on('click', () => {
4487
+ if (onClickItem && vennSets[i].lineNumber)
4488
+ onClickItem(vennSets[i].lineNumber);
4489
+ });
4490
+ });
4491
+ }
4492
+
4493
+ // ============================================================
4494
+ // Quadrant Chart Renderer
4495
+ // ============================================================
4496
+
4497
+ type QuadrantPosition =
4498
+ | 'top-right'
4499
+ | 'top-left'
4500
+ | 'bottom-left'
4501
+ | 'bottom-right';
4502
+
4503
+ /**
4504
+ * Renders a quadrant chart using D3.
4505
+ * Displays 4 colored quadrant regions, axis labels, quadrant labels, and data points.
4506
+ */
4507
+ export function renderQuadrant(
4508
+ container: HTMLDivElement,
4509
+ parsed: ParsedD3,
4510
+ palette: PaletteColors,
4511
+ isDark: boolean,
4512
+ onClickItem?: (lineNumber: number) => void
4513
+ ): void {
4514
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
4515
+
4516
+ const {
4517
+ title,
4518
+ quadrantLabels,
4519
+ quadrantPoints,
4520
+ quadrantXAxis,
4521
+ quadrantYAxis,
4522
+ quadrantTitleLineNumber,
4523
+ quadrantXAxisLineNumber,
4524
+ quadrantYAxisLineNumber,
4525
+ } = parsed;
4526
+
4527
+ if (quadrantPoints.length === 0) return;
4528
+
4529
+ const width = container.clientWidth;
4530
+ const height = container.clientHeight;
4531
+ if (width <= 0 || height <= 0) return;
4532
+
4533
+ const textColor = palette.text;
4534
+ const mutedColor = palette.textMuted;
4535
+ const bgColor = palette.overlay;
4536
+ const borderColor = palette.border;
4537
+
4538
+ // Default quadrant colors with alpha
4539
+ const defaultColors = [
4540
+ palette.colors.blue,
4541
+ palette.colors.green,
4542
+ palette.colors.yellow,
4543
+ palette.colors.purple,
4544
+ ];
4545
+
4546
+ // Margins
4547
+ const margin = { top: title ? 60 : 30, right: 30, bottom: 50, left: 60 };
4548
+ const chartWidth = width - margin.left - margin.right;
4549
+ const chartHeight = height - margin.top - margin.bottom;
4550
+
4551
+ // Scales: data uses 0-1 range
4552
+ const xScale = d3Scale.scaleLinear().domain([0, 1]).range([0, chartWidth]);
4553
+ const yScale = d3Scale.scaleLinear().domain([0, 1]).range([chartHeight, 0]);
4554
+
4555
+ // Create SVG
4556
+ const svg = d3Selection
4557
+ .select(container)
4558
+ .append('svg')
4559
+ .attr('width', width)
4560
+ .attr('height', height)
4561
+ .style('background', bgColor);
4562
+
4563
+ // Tooltip
4564
+ const tooltip = createTooltip(container, palette, isDark);
4565
+
4566
+ // Title
4567
+ if (title) {
4568
+ const titleText = svg
4569
+ .append('text')
4570
+ .attr('x', width / 2)
4571
+ .attr('y', 30)
4572
+ .attr('text-anchor', 'middle')
4573
+ .attr('fill', textColor)
4574
+ .attr('font-size', '18px')
4575
+ .attr('font-weight', '700')
4576
+ .style(
4577
+ 'cursor',
4578
+ onClickItem && quadrantTitleLineNumber ? 'pointer' : 'default'
4579
+ )
4580
+ .text(title);
4581
+
4582
+ if (onClickItem && quadrantTitleLineNumber) {
4583
+ titleText
4584
+ .on('click', () => onClickItem(quadrantTitleLineNumber))
4585
+ .on('mouseenter', function () {
4586
+ d3Selection.select(this).attr('opacity', 0.7);
4587
+ })
4588
+ .on('mouseleave', function () {
4589
+ d3Selection.select(this).attr('opacity', 1);
4590
+ });
4591
+ }
4592
+ }
4593
+
4594
+ // Chart group (translated by margins)
4595
+ const chartG = svg
4596
+ .append('g')
4597
+ .attr('transform', `translate(${margin.left}, ${margin.top})`);
4598
+
4599
+ // Get fill color for each quadrant (solid, no transparency)
4600
+ const getQuadrantFill = (
4601
+ label: QuadrantLabel | null,
4602
+ defaultIdx: number
4603
+ ): string => {
4604
+ return label?.color ?? defaultColors[defaultIdx % defaultColors.length];
4605
+ };
4606
+
4607
+ // Quadrant definitions: position, rect bounds, label position
4608
+ const quadrantDefs: {
4609
+ position: QuadrantPosition;
4610
+ x: number;
4611
+ y: number;
4612
+ w: number;
4613
+ h: number;
4614
+ labelX: number;
4615
+ labelY: number;
4616
+ label: QuadrantLabel | null;
4617
+ colorIdx: number;
4618
+ }[] = [
4619
+ {
4620
+ position: 'top-left',
4621
+ x: 0,
4622
+ y: 0,
4623
+ w: chartWidth / 2,
4624
+ h: chartHeight / 2,
4625
+ labelX: chartWidth / 4,
4626
+ labelY: chartHeight / 4,
4627
+ label: quadrantLabels.topLeft,
4628
+ colorIdx: 1, // green
4629
+ },
4630
+ {
4631
+ position: 'top-right',
4632
+ x: chartWidth / 2,
4633
+ y: 0,
4634
+ w: chartWidth / 2,
4635
+ h: chartHeight / 2,
4636
+ labelX: (chartWidth * 3) / 4,
4637
+ labelY: chartHeight / 4,
4638
+ label: quadrantLabels.topRight,
4639
+ colorIdx: 0, // blue
4640
+ },
4641
+ {
4642
+ position: 'bottom-left',
4643
+ x: 0,
4644
+ y: chartHeight / 2,
4645
+ w: chartWidth / 2,
4646
+ h: chartHeight / 2,
4647
+ labelX: chartWidth / 4,
4648
+ labelY: (chartHeight * 3) / 4,
4649
+ label: quadrantLabels.bottomLeft,
4650
+ colorIdx: 2, // yellow
4651
+ },
4652
+ {
4653
+ position: 'bottom-right',
4654
+ x: chartWidth / 2,
4655
+ y: chartHeight / 2,
4656
+ w: chartWidth / 2,
4657
+ h: chartHeight / 2,
4658
+ labelX: (chartWidth * 3) / 4,
4659
+ labelY: (chartHeight * 3) / 4,
4660
+ label: quadrantLabels.bottomRight,
4661
+ colorIdx: 3, // purple
4662
+ },
4663
+ ];
4664
+
4665
+ // Draw quadrant rectangles
4666
+ const quadrantRects = chartG
4667
+ .selectAll('rect.quadrant')
4668
+ .data(quadrantDefs)
4669
+ .enter()
4670
+ .append('rect')
4671
+ .attr('class', 'quadrant')
4672
+ .attr('x', (d) => d.x)
4673
+ .attr('y', (d) => d.y)
4674
+ .attr('width', (d) => d.w)
4675
+ .attr('height', (d) => d.h)
4676
+ .attr('fill', (d) => getQuadrantFill(d.label, d.colorIdx))
4677
+ .attr('stroke', borderColor)
4678
+ .attr('stroke-width', 0.5);
4679
+
4680
+ // Contrast color for text/points on colored backgrounds
4681
+ const contrastColor = isDark ? '#ffffff' : '#333333';
4682
+ const shadowColor = isDark ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.5)';
4683
+
4684
+ // Draw quadrant labels (large, centered, contrasting color for readability)
4685
+ const quadrantLabelTexts = chartG
4686
+ .selectAll('text.quadrant-label')
4687
+ .data(quadrantDefs.filter((d) => d.label !== null))
4688
+ .enter()
4689
+ .append('text')
4690
+ .attr('class', 'quadrant-label')
4691
+ .attr('x', (d) => d.labelX)
4692
+ .attr('y', (d) => d.labelY)
4693
+ .attr('text-anchor', 'middle')
4694
+ .attr('dominant-baseline', 'central')
4695
+ .attr('fill', contrastColor)
4696
+ .attr('font-size', '16px')
4697
+ .attr('font-weight', '600')
4698
+ .style('text-shadow', `0 1px 2px ${shadowColor}`)
4699
+ .style('cursor', (d) =>
4700
+ onClickItem && d.label?.lineNumber ? 'pointer' : 'default'
4701
+ )
4702
+ .text((d) => d.label!.text);
4703
+
4704
+ if (onClickItem) {
4705
+ quadrantLabelTexts
4706
+ .on('click', (_, d) => {
4707
+ if (d.label?.lineNumber) onClickItem(d.label.lineNumber);
4708
+ })
4709
+ .on('mouseenter', function () {
4710
+ d3Selection.select(this).attr('opacity', 0.7);
4711
+ })
4712
+ .on('mouseleave', function () {
4713
+ d3Selection.select(this).attr('opacity', 1);
4714
+ });
4715
+ }
4716
+
4717
+ // X-axis labels
4718
+ if (quadrantXAxis) {
4719
+ // Low label (left)
4720
+ const xLowLabel = svg
4721
+ .append('text')
4722
+ .attr('x', margin.left)
4723
+ .attr('y', height - 15)
4724
+ .attr('text-anchor', 'start')
4725
+ .attr('fill', textColor)
4726
+ .attr('font-size', '12px')
4727
+ .style(
4728
+ 'cursor',
4729
+ onClickItem && quadrantXAxisLineNumber ? 'pointer' : 'default'
4730
+ )
4731
+ .text(quadrantXAxis[0]);
4732
+
4733
+ // High label (right)
4734
+ const xHighLabel = svg
4735
+ .append('text')
4736
+ .attr('x', width - margin.right)
4737
+ .attr('y', height - 15)
4738
+ .attr('text-anchor', 'end')
4739
+ .attr('fill', textColor)
4740
+ .attr('font-size', '12px')
4741
+ .style(
4742
+ 'cursor',
4743
+ onClickItem && quadrantXAxisLineNumber ? 'pointer' : 'default'
4744
+ )
4745
+ .text(quadrantXAxis[1]);
4746
+
4747
+ // Arrow in the middle
4748
+ svg
4749
+ .append('text')
4750
+ .attr('x', width / 2)
4751
+ .attr('y', height - 15)
4752
+ .attr('text-anchor', 'middle')
4753
+ .attr('fill', mutedColor)
4754
+ .attr('font-size', '12px')
4755
+ .text('→');
4756
+
4757
+ if (onClickItem && quadrantXAxisLineNumber) {
4758
+ [xLowLabel, xHighLabel].forEach((label) => {
4759
+ label
4760
+ .on('click', () => onClickItem(quadrantXAxisLineNumber))
4761
+ .on('mouseenter', function () {
4762
+ d3Selection.select(this).attr('opacity', 0.7);
4763
+ })
4764
+ .on('mouseleave', function () {
4765
+ d3Selection.select(this).attr('opacity', 1);
4766
+ });
4767
+ });
4768
+ }
4769
+ }
4770
+
4771
+ // Y-axis labels
4772
+ if (quadrantYAxis) {
4773
+ // Low label (bottom)
4774
+ const yLowLabel = svg
4775
+ .append('text')
4776
+ .attr('x', 15)
4777
+ .attr('y', height - margin.bottom)
4778
+ .attr('text-anchor', 'start')
4779
+ .attr('fill', textColor)
4780
+ .attr('font-size', '12px')
4781
+ .attr('transform', `rotate(-90, 15, ${height - margin.bottom})`)
4782
+ .style(
4783
+ 'cursor',
4784
+ onClickItem && quadrantYAxisLineNumber ? 'pointer' : 'default'
4785
+ )
4786
+ .text(quadrantYAxis[0]);
4787
+
4788
+ // High label (top)
4789
+ const yHighLabel = svg
4790
+ .append('text')
4791
+ .attr('x', 15)
4792
+ .attr('y', margin.top)
4793
+ .attr('text-anchor', 'end')
4794
+ .attr('fill', textColor)
4795
+ .attr('font-size', '12px')
4796
+ .attr('transform', `rotate(-90, 15, ${margin.top})`)
4797
+ .style(
4798
+ 'cursor',
4799
+ onClickItem && quadrantYAxisLineNumber ? 'pointer' : 'default'
4800
+ )
4801
+ .text(quadrantYAxis[1]);
4802
+
4803
+ // Arrow in the middle
4804
+ svg
4805
+ .append('text')
4806
+ .attr('x', 15)
4807
+ .attr('y', height / 2)
4808
+ .attr('text-anchor', 'middle')
4809
+ .attr('fill', mutedColor)
4810
+ .attr('font-size', '12px')
4811
+ .attr('transform', `rotate(-90, 15, ${height / 2})`)
4812
+ .text('→');
4813
+
4814
+ if (onClickItem && quadrantYAxisLineNumber) {
4815
+ [yLowLabel, yHighLabel].forEach((label) => {
4816
+ label
4817
+ .on('click', () => onClickItem(quadrantYAxisLineNumber))
4818
+ .on('mouseenter', function () {
4819
+ d3Selection.select(this).attr('opacity', 0.7);
4820
+ })
4821
+ .on('mouseleave', function () {
4822
+ d3Selection.select(this).attr('opacity', 1);
4823
+ });
4824
+ });
4825
+ }
4826
+ }
4827
+
4828
+ // Draw center cross lines
4829
+ chartG
4830
+ .append('line')
4831
+ .attr('x1', chartWidth / 2)
4832
+ .attr('y1', 0)
4833
+ .attr('x2', chartWidth / 2)
4834
+ .attr('y2', chartHeight)
4835
+ .attr('stroke', borderColor)
4836
+ .attr('stroke-width', 1);
4837
+
4838
+ chartG
4839
+ .append('line')
4840
+ .attr('x1', 0)
4841
+ .attr('y1', chartHeight / 2)
4842
+ .attr('x2', chartWidth)
4843
+ .attr('y2', chartHeight / 2)
4844
+ .attr('stroke', borderColor)
4845
+ .attr('stroke-width', 1);
4846
+
4847
+ // Get which quadrant a point belongs to
4848
+ const getPointQuadrant = (x: number, y: number): QuadrantPosition => {
4849
+ if (x >= 0.5 && y >= 0.5) return 'top-right';
4850
+ if (x < 0.5 && y >= 0.5) return 'top-left';
4851
+ if (x < 0.5 && y < 0.5) return 'bottom-left';
4852
+ return 'bottom-right';
4853
+ };
4854
+
4855
+ // Draw data points (circles and labels)
4856
+ const pointsG = chartG.append('g').attr('class', 'points');
4857
+
4858
+ quadrantPoints.forEach((point) => {
4859
+ const cx = xScale(point.x);
4860
+ const cy = yScale(point.y);
4861
+ const quadrant = getPointQuadrant(point.x, point.y);
4862
+ const quadDef = quadrantDefs.find((d) => d.position === quadrant);
4863
+ const pointColor =
4864
+ quadDef?.label?.color ?? defaultColors[quadDef?.colorIdx ?? 0];
4865
+
4866
+ const pointG = pointsG.append('g').attr('class', 'point-group');
4867
+
4868
+ // Circle (contrasting fill with colored border for visibility)
4869
+ pointG
4870
+ .append('circle')
4871
+ .attr('cx', cx)
4872
+ .attr('cy', cy)
4873
+ .attr('r', 6)
4874
+ .attr('fill', contrastColor)
4875
+ .attr('stroke', pointColor)
4876
+ .attr('stroke-width', 2);
4877
+
4878
+ // Label (contrasting color with shadow for readability)
4879
+ pointG
4880
+ .append('text')
4881
+ .attr('x', cx)
4882
+ .attr('y', cy - 10)
4883
+ .attr('text-anchor', 'middle')
4884
+ .attr('fill', contrastColor)
4885
+ .attr('font-size', '11px')
4886
+ .style('text-shadow', `0 1px 2px ${shadowColor}`)
4887
+ .text(point.label);
4888
+
4889
+ // Interactivity
4890
+ const tipHtml = `<strong>${point.label}</strong><br>x: ${point.x.toFixed(2)}, y: ${point.y.toFixed(2)}`;
4891
+
4892
+ pointG
4893
+ .style('cursor', onClickItem ? 'pointer' : 'default')
4894
+ .on('mouseenter', (event: MouseEvent) => {
4895
+ showTooltip(tooltip, tipHtml, event);
4896
+ pointG.select('circle').attr('r', 8);
4897
+ })
4898
+ .on('mousemove', (event: MouseEvent) => {
4899
+ showTooltip(tooltip, tipHtml, event);
4900
+ })
4901
+ .on('mouseleave', () => {
4902
+ hideTooltip(tooltip);
4903
+ pointG.select('circle').attr('r', 6);
4904
+ })
4905
+ .on('click', () => {
4906
+ if (onClickItem && point.lineNumber) onClickItem(point.lineNumber);
4907
+ });
4908
+ });
4909
+
4910
+ // Quadrant highlighting on hover and click-to-navigate
4911
+ quadrantRects
4912
+ .style('cursor', onClickItem ? 'pointer' : 'default')
4913
+ .on('mouseenter', function (_, d) {
4914
+ // Dim other quadrants
4915
+ quadrantRects.attr('opacity', (qd) =>
4916
+ qd.position === d.position ? 1 : 0.3
4917
+ );
4918
+ quadrantLabelTexts.attr('opacity', (qd) =>
4919
+ qd.position === d.position ? 1 : 0.3
4920
+ );
4921
+ // Dim points not in this quadrant
4922
+ pointsG.selectAll('g.point-group').each(function (_, i) {
4923
+ const pt = quadrantPoints[i];
4924
+ const ptQuad = getPointQuadrant(pt.x, pt.y);
4925
+ d3Selection
4926
+ .select(this)
4927
+ .attr('opacity', ptQuad === d.position ? 1 : 0.2);
4928
+ });
4929
+ })
4930
+ .on('mouseleave', () => {
4931
+ quadrantRects.attr('opacity', 1);
4932
+ quadrantLabelTexts.attr('opacity', 1);
4933
+ pointsG.selectAll('g.point-group').attr('opacity', 1);
4934
+ })
4935
+ .on('click', (_, d) => {
4936
+ // Navigate to the quadrant label's line in the source
4937
+ if (onClickItem && d.label?.lineNumber) {
4938
+ onClickItem(d.label.lineNumber);
4939
+ }
4940
+ });
4941
+ }
4942
+
4943
+ // ============================================================
4944
+ // Export Renderer
4945
+ // ============================================================
4946
+
4947
+ const EXPORT_WIDTH = 1200;
4948
+ const EXPORT_HEIGHT = 800;
4949
+
4950
+ /**
4951
+ * Renders a D3 chart to an SVG string for export.
4952
+ * Creates a detached DOM element, renders into it, extracts the SVG, then cleans up.
4953
+ */
4954
+ export async function renderD3ForExport(
4955
+ content: string,
4956
+ theme: 'light' | 'dark' | 'transparent',
4957
+ palette?: PaletteColors
4958
+ ): Promise<string> {
4959
+ const parsed = parseD3(content, palette);
4960
+ if (parsed.error) return '';
4961
+ if (parsed.type === 'wordcloud' && parsed.words.length === 0) return '';
4962
+ if (parsed.type === 'slope' && parsed.data.length === 0) return '';
4963
+ if (parsed.type === 'arc' && parsed.links.length === 0) return '';
4964
+ if (parsed.type === 'timeline' && parsed.timelineEvents.length === 0)
4965
+ return '';
4966
+ if (parsed.type === 'venn' && parsed.vennSets.length < 2) return '';
4967
+ if (parsed.type === 'quadrant' && parsed.quadrantPoints.length === 0)
4968
+ return '';
4969
+
4970
+ const isDark = theme === 'dark';
4971
+
4972
+ // Fall back to Nord palette if none provided
4973
+ const { getPalette } = await import('./palettes');
4974
+ const effectivePalette =
4975
+ palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
4976
+
4977
+ // Create a temporary offscreen container
4978
+ const container = document.createElement('div');
4979
+ container.style.width = `${EXPORT_WIDTH}px`;
4980
+ container.style.height = `${EXPORT_HEIGHT}px`;
4981
+ container.style.position = 'absolute';
4982
+ container.style.left = '-9999px';
4983
+ document.body.appendChild(container);
4984
+
4985
+ try {
4986
+ if (parsed.type === 'sequence') {
4987
+ const { parseSequenceDgmo } = await import('./sequence/parser');
4988
+ const { renderSequenceDiagram } = await import('./sequence/renderer');
4989
+ const seqParsed = parseSequenceDgmo(content);
4990
+ if (seqParsed.error || seqParsed.participants.length === 0) return '';
4991
+ renderSequenceDiagram(container, seqParsed, effectivePalette, isDark);
4992
+ } else if (parsed.type === 'wordcloud') {
4993
+ await renderWordCloudAsync(container, parsed, effectivePalette, isDark);
4994
+ } else if (parsed.type === 'arc') {
4995
+ renderArcDiagram(container, parsed, effectivePalette, isDark);
4996
+ } else if (parsed.type === 'timeline') {
4997
+ renderTimeline(container, parsed, effectivePalette, isDark);
4998
+ } else if (parsed.type === 'venn') {
4999
+ renderVenn(container, parsed, effectivePalette, isDark);
5000
+ } else if (parsed.type === 'quadrant') {
5001
+ renderQuadrant(container, parsed, effectivePalette, isDark);
5002
+ } else {
5003
+ renderSlopeChart(container, parsed, effectivePalette, isDark);
5004
+ }
5005
+
5006
+ const svgEl = container.querySelector('svg');
5007
+ if (!svgEl) return '';
5008
+
5009
+ // For transparent theme, remove the background
5010
+ if (theme === 'transparent') {
5011
+ svgEl.style.background = 'none';
5012
+ }
5013
+
5014
+ // Add xmlns for standalone SVG
5015
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5016
+
5017
+ return svgEl.outerHTML;
5018
+ } finally {
5019
+ document.body.removeChild(container);
5020
+ }
5021
+ }