@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/LICENSE +21 -0
- package/README.md +335 -0
- package/dist/index.cjs +6698 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +685 -0
- package/dist/index.d.ts +685 -0
- package/dist/index.js +6611 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/chartjs.ts +784 -0
- package/src/colors.ts +75 -0
- package/src/d3.ts +5021 -0
- package/src/dgmo-mermaid.ts +247 -0
- package/src/dgmo-router.ts +77 -0
- package/src/echarts.ts +1207 -0
- package/src/index.ts +126 -0
- package/src/palettes/bold.ts +59 -0
- package/src/palettes/catppuccin.ts +76 -0
- package/src/palettes/color-utils.ts +191 -0
- package/src/palettes/gruvbox.ts +77 -0
- package/src/palettes/index.ts +35 -0
- package/src/palettes/mermaid-bridge.ts +220 -0
- package/src/palettes/nord.ts +59 -0
- package/src/palettes/one-dark.ts +62 -0
- package/src/palettes/registry.ts +92 -0
- package/src/palettes/rose-pine.ts +76 -0
- package/src/palettes/solarized.ts +69 -0
- package/src/palettes/tokyo-night.ts +78 -0
- package/src/palettes/types.ts +67 -0
- package/src/sequence/parser.ts +531 -0
- package/src/sequence/participant-inference.ts +178 -0
- package/src/sequence/renderer.ts +1487 -0
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
|
+
}
|