tfrere HF Staff commited on
Commit
3050a37
·
1 Parent(s): 1f621f6

add d3-two-lines-chart

Browse files
app/src/content/embeds/d3-two-lines-chart.html ADDED
@@ -0,0 +1,1070 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Two Line Charts Side-by-Side
3
+
4
+ A configurable side-by-side display of two line charts with zoom/pan, smoothing, and hover tooltips.
5
+ Designed to replace Plotly charts showing comparative metrics (e.g., Good vs Bad examples).
6
+
7
+ Configuration via data-config attribute:
8
+ {
9
+ "charts": [
10
+ {
11
+ "title": "✅ Good Example",
12
+ "language": "French",
13
+ "task": "mlmm_hellaswag_fra_cf",
14
+ "metric": "acc_norm_token"
15
+ },
16
+ {
17
+ "title": "❌ Bad Example",
18
+ "language": "Arabic",
19
+ "task": "mlmm_truthfulqa_ara_cf:mc1",
20
+ "metric": "acc_norm_token"
21
+ }
22
+ ],
23
+ "statLabel": "Monotonicity", // Label for the stat value (e.g., "Monotonicity", "SNR", etc.)
24
+ "groupSeeds": true, // If false, show each seed separately; if true, group by runname and average
25
+ "smoothingWindow": 3,
26
+ "smoothingCurve": "monotoneX",
27
+ "xAxisLabel": "Tokens (B)",
28
+ "yAxisLabel": "Score",
29
+ "baseUrl": "./finetasks/data"
30
+ }
31
+
32
+ CSV format expected: run_name, tokens, score, ...
33
+
34
+ Example usage in MDX:
35
+ <HtmlEmbed
36
+ src="embeds/d3-two-lines-chart.html"
37
+ config={{
38
+ charts: [
39
+ { title: "✅ Good", language: "French", task: "mlmm_hellaswag_fra_cf", metric: "acc_norm_token" },
40
+ { title: "❌ Bad", language: "Arabic", task: "mlmm_truthfulqa_ara_cf:mc1", metric: "acc_norm_token" }
41
+ ]
42
+ }}
43
+ />
44
+ -->
45
+ <div class="d3-two-charts"></div>
46
+ <style>
47
+ .d3-two-charts {
48
+ position: relative;
49
+ width: 100%;
50
+ }
51
+
52
+ /* Flex container like Plotly */
53
+ .d3-two-charts__grid {
54
+ display: flex;
55
+ gap: 20px;
56
+ flex-wrap: wrap;
57
+ width: 100%;
58
+ }
59
+
60
+ .chart-cell {
61
+ display: flex;
62
+ flex-direction: column;
63
+ position: relative;
64
+ padding: 16px;
65
+ box-shadow: inset 0 0 0 1px var(--border-color);
66
+ border-radius: 8px;
67
+ background: var(--surface-bg, #fff);
68
+ flex: 1;
69
+ min-width: 300px;
70
+ box-sizing: border-box;
71
+ }
72
+
73
+ .chart-cell__title {
74
+ font-size: 14px;
75
+ font-weight: 700;
76
+ color: var(--text-color);
77
+ margin-bottom: 12px;
78
+ }
79
+
80
+ .chart-cell__stat {
81
+ margin-top: 16px;
82
+ padding: 10px 12px;
83
+ background: var(--page-bg, #f9fafb);
84
+ border-radius: 6px;
85
+ text-align: center;
86
+ font-size: 13px;
87
+ color: var(--text-color);
88
+ border: 1px solid var(--border-color, #e5e7eb);
89
+ }
90
+
91
+ .chart-cell__stat-label {
92
+ font-weight: 600;
93
+ margin-right: 6px;
94
+ }
95
+
96
+ .chart-cell__stat-value {
97
+ font-weight: 700;
98
+ font-size: 15px;
99
+ }
100
+
101
+ .chart-cell__body {
102
+ position: relative;
103
+ width: 100%;
104
+ overflow: hidden;
105
+ }
106
+
107
+ .chart-cell__body svg {
108
+ max-width: 100%;
109
+ height: auto;
110
+ display: block;
111
+ }
112
+
113
+ /* Legend */
114
+ .chart-cell__legend {
115
+ display: flex;
116
+ flex-wrap: wrap;
117
+ gap: 8px 14px;
118
+ justify-content: center;
119
+ margin-top: 12px;
120
+ font-size: 11px;
121
+ color: var(--text-color);
122
+ }
123
+
124
+ .chart-cell__legend .item {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 6px;
128
+ white-space: nowrap;
129
+ cursor: pointer;
130
+ }
131
+
132
+ .chart-cell__legend .swatch {
133
+ width: 12px;
134
+ height: 12px;
135
+ border-radius: 2px;
136
+ border: 1px solid var(--border-color);
137
+ display: inline-block;
138
+ }
139
+
140
+ /* Reset button */
141
+ .chart-cell .reset-button {
142
+ position: absolute;
143
+ top: 16px;
144
+ right: 16px;
145
+ z-index: 10;
146
+ display: none;
147
+ opacity: 0;
148
+ transition: opacity 0.2s ease;
149
+ font-size: 11px;
150
+ padding: 4px 8px;
151
+ border-radius: 4px;
152
+ background: var(--surface-bg);
153
+ color: var(--text-color);
154
+ border: 1px solid var(--border-color);
155
+ cursor: pointer;
156
+ }
157
+
158
+ .chart-cell .reset-button:hover {
159
+ background: var(--page-bg);
160
+ }
161
+
162
+ /* Axes */
163
+ .d3-two-charts .axes path.domain {
164
+ stroke: var(--axis-color, #e5e7eb);
165
+ stroke-width: 1;
166
+ }
167
+
168
+ .d3-two-charts .axes line {
169
+ stroke: var(--axis-color, #e5e7eb);
170
+ }
171
+
172
+ .d3-two-charts .axes text {
173
+ fill: var(--tick-color, #6b7280);
174
+ font-size: 10px;
175
+ }
176
+
177
+ .d3-two-charts .axis-label {
178
+ fill: var(--text-color);
179
+ font-size: 10px;
180
+ font-weight: 300;
181
+ opacity: 0.7;
182
+ stroke: var(--page-bg, white);
183
+ stroke-width: 3px;
184
+ paint-order: stroke fill;
185
+ }
186
+
187
+ .d3-two-charts .grid line {
188
+ stroke: var(--grid-color, #f3f4f6);
189
+ }
190
+
191
+ /* Lines */
192
+ .d3-two-charts path.main-line {
193
+ transition: opacity 0.2s ease;
194
+ }
195
+
196
+ .d3-two-charts path.ghost-line {
197
+ transition: opacity 0.6s ease;
198
+ }
199
+
200
+ /* Ghosting on hover */
201
+ .d3-two-charts.hovering path.main-line.ghost {
202
+ opacity: .25;
203
+ }
204
+
205
+ .d3-two-charts.hovering path.ghost-line.ghost {
206
+ opacity: .05;
207
+ }
208
+
209
+ .d3-two-charts.hovering .chart-cell__legend .item.ghost {
210
+ opacity: .35;
211
+ }
212
+
213
+ /* Tooltip */
214
+ .d3-two-charts .d3-tooltip {
215
+ z-index: 20;
216
+ backdrop-filter: saturate(1.12) blur(8px);
217
+ }
218
+
219
+ .d3-two-charts .d3-tooltip__inner {
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 6px;
223
+ min-width: 180px;
224
+ }
225
+
226
+ .d3-two-charts .d3-tooltip__inner>div:first-child {
227
+ font-weight: 800;
228
+ letter-spacing: 0.1px;
229
+ margin-bottom: 0;
230
+ }
231
+
232
+ .d3-two-charts .d3-tooltip__inner>div:nth-child(2) {
233
+ font-size: 11px;
234
+ color: var(--muted-color, #9ca3af);
235
+ display: block;
236
+ margin-top: -4px;
237
+ margin-bottom: 2px;
238
+ letter-spacing: 0.1px;
239
+ }
240
+
241
+ .d3-two-charts .d3-tooltip__inner>div:nth-child(n+3) {
242
+ padding-top: 6px;
243
+ border-top: 1px solid var(--border-color);
244
+ }
245
+
246
+ .d3-two-charts .d3-tooltip__color-dot {
247
+ display: inline-block;
248
+ width: 12px;
249
+ height: 12px;
250
+ border-radius: 3px;
251
+ border: 1px solid var(--border-color);
252
+ }
253
+ </style>
254
+ <script>
255
+ (() => {
256
+ const ensureD3 = (cb) => {
257
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
258
+ let s = document.getElementById('d3-cdn-script');
259
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
260
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
261
+ s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
262
+ };
263
+
264
+ const bootstrap = () => {
265
+ const scriptEl = document.currentScript;
266
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
267
+ if (!(container && container.classList && container.classList.contains('d3-two-charts'))) {
268
+ const cs = Array.from(document.querySelectorAll('.d3-two-charts')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
269
+ container = cs[cs.length - 1] || null;
270
+ }
271
+ if (!container) return;
272
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
273
+
274
+ const d3 = window.d3;
275
+
276
+ // Language mapping
277
+ const languageMap = {
278
+ 'Arabic': 'ar', 'Turkish': 'tr', 'Swahili': 'sw', 'Russian': 'ru',
279
+ 'Telugu': 'te', 'Thai': 'th', 'Chinese': 'zh', 'French': 'fr', 'Hindi': 'hi'
280
+ };
281
+
282
+ // Run name mapping (same as Plotly version)
283
+ const runNameMap = {
284
+ "orion": "Dataset-A",
285
+ "helios": "Dataset-B",
286
+ "lynx": "Dataset-C",
287
+ "aquila": "Dataset-D",
288
+ "commoncrawl": "CommonCrawl",
289
+ "baseline": "Baseline"
290
+ };
291
+
292
+ function processRunName(runname) {
293
+ if (!runname || typeof runname !== 'string') {
294
+ return String(runname || 'Unknown');
295
+ }
296
+ for (const [key, value] of Object.entries(runNameMap)) {
297
+ if (runname.toLowerCase().includes(key.toLowerCase())) {
298
+ return value;
299
+ }
300
+ }
301
+ return runname;
302
+ }
303
+
304
+ // Read config from HtmlEmbed props
305
+ function readEmbedConfig() {
306
+ let mountEl = container;
307
+ while (mountEl && !mountEl.getAttribute?.('data-config')) {
308
+ mountEl = mountEl.parentElement;
309
+ }
310
+
311
+ let providedConfig = null;
312
+ try {
313
+ const cfg = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null;
314
+ if (cfg && cfg.trim()) {
315
+ providedConfig = cfg.trim().startsWith('{') ? JSON.parse(cfg) : cfg;
316
+ }
317
+ } catch (e) {
318
+ console.error('Failed to parse data-config', e);
319
+ }
320
+ return providedConfig || {};
321
+ }
322
+
323
+ const embedConfig = readEmbedConfig();
324
+
325
+ // Configuration
326
+ const CONFIG = {
327
+ charts: embedConfig.charts || [],
328
+ smoothing: embedConfig.smoothing !== undefined ? embedConfig.smoothing : false,
329
+ smoothingWindow: embedConfig.smoothingWindow || 3,
330
+ smoothingCurve: embedConfig.smoothingCurve || 'monotoneX',
331
+ chartHeight: 280,
332
+ margin: { top: 20, right: 20, bottom: 40, left: 50 },
333
+ zoomExtent: [1.0, 8],
334
+ xAxisLabel: embedConfig.xAxisLabel || 'Tokens (B)',
335
+ yAxisLabel: embedConfig.yAxisLabel || 'Score',
336
+ baseUrl: embedConfig.baseUrl || './finetasks/data',
337
+ statLabel: embedConfig.statLabel || null, // e.g., "Monotonicity", "SNR", "Randomness", etc.
338
+ statColumn: embedConfig.statColumn || 'avg_spearman', // Column name in stats CSV
339
+ groupSeeds: embedConfig.groupSeeds !== undefined ? embedConfig.groupSeeds : true // Group seeds or show separately
340
+ };
341
+
342
+ // Mapping from stat label to column name (for convenience)
343
+ const statColumnMap = {
344
+ 'Monotonicity': 'avg_spearman',
345
+ 'SNR': 'avg_snr',
346
+ 'Kendall\'s Tau': 'avg_kendall_tau_a',
347
+ 'Distance from baseline': 'max_n_std', // Non-Randomness metric
348
+ 'Non-Randomness': 'max_n_std',
349
+ 'Randomness': 'max_n_std'
350
+ };
351
+
352
+ // Auto-detect column from label if not specified
353
+ if (CONFIG.statLabel && !embedConfig.statColumn && statColumnMap[CONFIG.statLabel]) {
354
+ CONFIG.statColumn = statColumnMap[CONFIG.statLabel];
355
+ }
356
+
357
+ if (!CONFIG.charts.length || CONFIG.charts.length !== 2) {
358
+ container.innerHTML = '<p style="color: var(--danger); font-size: 12px;">Error: Exactly 2 charts must be configured</p>';
359
+ return;
360
+ }
361
+
362
+ // Create grid
363
+ const grid = document.createElement('div');
364
+ grid.className = 'd3-two-charts__grid';
365
+ container.appendChild(grid);
366
+
367
+ // Create chart cells
368
+ CONFIG.charts.forEach((chartConfig, idx) => {
369
+ const cell = document.createElement('div');
370
+ cell.className = 'chart-cell';
371
+ cell.innerHTML = `
372
+ <div class="chart-cell__title">${chartConfig.title}</div>
373
+ <button class="reset-button">Reset</button>
374
+ <div class="chart-cell__body"></div>
375
+ <div class="chart-cell__legend"></div>
376
+ <div class="chart-cell__stat"></div>
377
+ `;
378
+ grid.appendChild(cell);
379
+ });
380
+
381
+ // Smoothing
382
+ const getCurve = (smooth) => {
383
+ if (!smooth) return d3.curveLinear;
384
+ switch (CONFIG.smoothingCurve) {
385
+ case 'catmullRom': return d3.curveCatmullRom.alpha(0.5);
386
+ case 'monotoneX': return d3.curveMonotoneX;
387
+ case 'basis': return d3.curveBasis;
388
+ default: return d3.curveLinear;
389
+ }
390
+ };
391
+
392
+ function movingAverage(values, windowSize) {
393
+ if (!Array.isArray(values) || values.length === 0 || windowSize <= 1) return values;
394
+ const half = Math.floor(windowSize / 2);
395
+ const out = new Array(values.length);
396
+ for (let i = 0; i < values.length; i++) {
397
+ let sum = 0; let count = 0;
398
+ const start = Math.max(0, i - half);
399
+ const end = Math.min(values.length - 1, i + half);
400
+ for (let j = start; j <= end; j++) { if (!Number.isNaN(values[j].value)) { sum += values[j].value; count++; } }
401
+ const avg = count ? (sum / count) : values[i].value;
402
+ out[i] = { step: values[i].step, value: avg };
403
+ }
404
+ return out;
405
+ }
406
+
407
+ function applySmoothing(values, smooth) {
408
+ if (!smooth) return values;
409
+ return movingAverage(values, CONFIG.smoothingWindow);
410
+ }
411
+
412
+ // Smart formatter
413
+ function createSmartFormatter(values) {
414
+ if (!values || values.length === 0) return (v) => v;
415
+
416
+ const min = d3.min(values);
417
+ const max = d3.max(values);
418
+ const range = max - min;
419
+
420
+ const allIntegers = values.every(v => Math.abs(v - Math.round(v)) < 0.001);
421
+
422
+ if (max >= 1e9) {
423
+ return (v) => {
424
+ const billions = v / 1e9;
425
+ return allIntegers && billions === Math.round(billions)
426
+ ? d3.format('d')(Math.round(billions)) + 'B'
427
+ : d3.format('.2f')(billions) + 'B';
428
+ };
429
+ }
430
+
431
+ if (max >= 1e6) {
432
+ return (v) => {
433
+ const millions = v / 1e6;
434
+ return allIntegers && millions === Math.round(millions)
435
+ ? d3.format('d')(Math.round(millions)) + 'M'
436
+ : d3.format('.2f')(millions) + 'M';
437
+ };
438
+ }
439
+
440
+ if (max >= 1000 && range >= 100) {
441
+ return (v) => {
442
+ const thousands = v / 1000;
443
+ return allIntegers && thousands === Math.round(thousands)
444
+ ? d3.format('d')(Math.round(thousands)) + 'k'
445
+ : d3.format('.1f')(thousands) + 'k';
446
+ };
447
+ }
448
+
449
+ if (allIntegers) {
450
+ return (v) => d3.format('d')(Math.round(v));
451
+ }
452
+
453
+ if (range < 1) {
454
+ return (v) => d3.format('.3f')(v);
455
+ } else if (range < 10) {
456
+ return (v) => d3.format('.2f')(v);
457
+ } else {
458
+ return (v) => d3.format('.1f')(v);
459
+ }
460
+ }
461
+
462
+ // Colors
463
+ const getRunColors = (n) => {
464
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch (_) { }
465
+ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
466
+ return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', '#9B59B6', '#16A085', ...(d3.schemeTableau10 || [])].slice(0, n);
467
+ };
468
+
469
+ // Init each chart
470
+ function initChart(cellElement, chartConfig) {
471
+ const bodyEl = cellElement.querySelector('.chart-cell__body');
472
+ const resetBtn = cellElement.querySelector('.reset-button');
473
+ const legendEl = cellElement.querySelector('.chart-cell__legend');
474
+ const statEl = cellElement.querySelector('.chart-cell__stat');
475
+
476
+ let smoothEnabled = CONFIG.smoothing;
477
+ let hasMoved = false;
478
+ let allData = [];
479
+ let runList = [];
480
+ let runColorMap = {};
481
+ let baseline = null;
482
+ let monotonicity = null;
483
+
484
+ // Tooltip
485
+ let tip = cellElement.querySelector('.d3-tooltip');
486
+ let tipInner;
487
+ if (!tip) {
488
+ tip = document.createElement('div');
489
+ tip.className = 'd3-tooltip';
490
+ Object.assign(tip.style, {
491
+ position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none',
492
+ padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)',
493
+ background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)', opacity: '0', transition: 'opacity .12s ease', zIndex: '20'
494
+ });
495
+ tipInner = document.createElement('div');
496
+ tipInner.className = 'd3-tooltip__inner';
497
+ tip.appendChild(tipInner);
498
+ cellElement.appendChild(tip);
499
+ } else {
500
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
501
+ }
502
+
503
+ // Create SVG
504
+ const svg = d3.select(bodyEl).append('svg').attr('width', '100%').style('display', 'block');
505
+
506
+ // Clip path
507
+ const clipId = 'clip-' + Math.random().toString(36).slice(2);
508
+ const clipPath = svg.append('defs').append('clipPath').attr('id', clipId);
509
+ const clipRect = clipPath.append('rect');
510
+
511
+ // Groups
512
+ const g = svg.append('g');
513
+ const gGrid = g.append('g').attr('class', 'grid');
514
+ const gAxes = g.append('g').attr('class', 'axes');
515
+ const gPlot = g.append('g').attr('class', 'plot').attr('clip-path', `url(#${clipId})`);
516
+ const gHover = g.append('g').attr('class', 'hover-layer');
517
+ const overlay = g.append('rect').attr('class', 'overlay').attr('fill', 'none').attr('pointer-events', 'all').style('cursor', 'grab')
518
+ .on('mousedown', function () {
519
+ d3.select(this).style('cursor', 'grabbing');
520
+ tip.style.opacity = '0';
521
+ if (hoverLine) hoverLine.style('display', 'none');
522
+ })
523
+ .on('mouseup', function () { d3.select(this).style('cursor', 'grab'); });
524
+
525
+ // Scales
526
+ const xScale = d3.scaleLinear();
527
+ const yScale = d3.scaleLinear();
528
+
529
+ // Hover state
530
+ let hoverLine = null;
531
+ let steps = [];
532
+ let hideTipTimer = null;
533
+
534
+ // Formatters
535
+ let formatStep = (v) => v;
536
+ let formatValue = (v) => v;
537
+
538
+ // Zoom
539
+ const zoom = d3.zoom().scaleExtent(CONFIG.zoomExtent).on('zoom', zoomed);
540
+ overlay.call(zoom);
541
+
542
+ function zoomed(event) {
543
+ const transform = event.transform;
544
+ hasMoved = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
545
+ updateResetButton();
546
+
547
+ const newXScale = transform.rescaleX(xScale);
548
+ const newYScale = transform.rescaleY(yScale);
549
+
550
+ const innerWidth = xScale.range()[1];
551
+
552
+ // Update grid
553
+ const gridTicks = newYScale.ticks(5);
554
+ gGrid.selectAll('line').data(gridTicks).join('line')
555
+ .attr('x1', 0).attr('x2', innerWidth)
556
+ .attr('y1', d => newYScale(d)).attr('y2', d => newYScale(d))
557
+ .attr('stroke', 'var(--grid-color)');
558
+
559
+ // Update lines
560
+ const line = d3.line()
561
+ .x(d => newXScale(d.step))
562
+ .y(d => newYScale(d.value))
563
+ .curve(getCurve(smoothEnabled));
564
+
565
+ gPlot.selectAll('path.ghost-line')
566
+ .attr('d', d => {
567
+ const rawLine = d3.line().x(d => newXScale(d.step)).y(d => newYScale(d.value)).curve(d3.curveLinear);
568
+ return rawLine(d.values);
569
+ });
570
+
571
+ gPlot.selectAll('path.main-line')
572
+ .attr('d', d => line(applySmoothing(d.values, smoothEnabled)));
573
+
574
+ // Update axes
575
+ gAxes.select('.x-axis').call(d3.axisBottom(newXScale).ticks(5).tickSizeOuter(0).tickFormat(formatStep));
576
+ gAxes.select('.y-axis').call(d3.axisLeft(newYScale).ticks(5).tickSizeOuter(0).tickFormat(formatValue));
577
+
578
+ // Update baseline position
579
+ if (baseline !== null) {
580
+ gAxes.select('.baseline-line')
581
+ .attr('y1', newYScale(baseline))
582
+ .attr('y2', newYScale(baseline));
583
+ gAxes.select('.baseline-label')
584
+ .attr('y', newYScale(baseline) - 5);
585
+ }
586
+ }
587
+
588
+ function updateResetButton() {
589
+ if (hasMoved) {
590
+ resetBtn.style.display = 'block';
591
+ requestAnimationFrame(() => { resetBtn.style.opacity = '1'; });
592
+ } else {
593
+ resetBtn.style.opacity = '0';
594
+ setTimeout(() => { if (!hasMoved) resetBtn.style.display = 'none'; }, 200);
595
+ }
596
+ }
597
+
598
+ function render() {
599
+ const rect = bodyEl.getBoundingClientRect();
600
+ const width = Math.max(1, Math.round(rect.width || 400));
601
+ const height = CONFIG.chartHeight;
602
+ svg.attr('width', width).attr('height', height);
603
+
604
+ const margin = CONFIG.margin;
605
+ const innerWidth = width - margin.left - margin.right;
606
+ const innerHeight = height - margin.top - margin.bottom;
607
+
608
+ g.attr('transform', `translate(${margin.left},${margin.top})`);
609
+
610
+ if (!allData.length) return;
611
+
612
+ // Auto-compute domains
613
+ const stepExtent = d3.extent(allData, d => d.step);
614
+ let valueExtent = d3.extent(allData, d => d.value);
615
+
616
+ // Include baseline in the domain and add margin
617
+ if (baseline !== null) {
618
+ const minValue = Math.min(valueExtent[0], baseline);
619
+ const maxValue = Math.max(valueExtent[1], baseline);
620
+ const range = maxValue - minValue;
621
+
622
+ // Add 10% margin below and 10% above to ensure baseline is visible with gap
623
+ valueExtent = [
624
+ minValue - range * 0.1,
625
+ maxValue + range * 0.1
626
+ ];
627
+ } else {
628
+ // Just add 5% margin on both sides
629
+ const range = valueExtent[1] - valueExtent[0];
630
+ valueExtent = [
631
+ valueExtent[0] - range * 0.05,
632
+ valueExtent[1] + range * 0.05
633
+ ];
634
+ }
635
+
636
+ xScale.domain(stepExtent).range([0, innerWidth]);
637
+ yScale.domain(valueExtent).range([innerHeight, 0]);
638
+
639
+ // Create smart formatters
640
+ const stepValues = allData.map(d => d.step);
641
+ const metricValues = allData.map(d => d.value);
642
+ formatStep = createSmartFormatter(stepValues);
643
+ formatValue = createSmartFormatter(metricValues);
644
+
645
+ // Update clip
646
+ clipRect.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
647
+
648
+ // Update overlay
649
+ overlay.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
650
+
651
+ // Update zoom extent
652
+ zoom.extent([[0, 0], [innerWidth, innerHeight]])
653
+ .translateExtent([[0, 0], [innerWidth, innerHeight]]);
654
+
655
+ // Grid
656
+ gGrid.selectAll('line').data(yScale.ticks(5)).join('line')
657
+ .attr('x1', 0).attr('x2', innerWidth)
658
+ .attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
659
+ .attr('stroke', 'var(--grid-color)');
660
+
661
+ // Axes
662
+ gAxes.selectAll('*').remove();
663
+ gAxes.append('g').attr('class', 'x-axis').attr('transform', `translate(0,${innerHeight})`)
664
+ .call(d3.axisBottom(xScale).ticks(5).tickSizeOuter(0).tickFormat(formatStep));
665
+ gAxes.append('g').attr('class', 'y-axis')
666
+ .call(d3.axisLeft(yScale).ticks(5).tickSizeOuter(0).tickFormat(formatValue));
667
+ gAxes.selectAll('.domain, .tick line').attr('stroke', 'var(--axis-color)');
668
+ gAxes.selectAll('text').attr('fill', 'var(--tick-color)');
669
+
670
+ // Axis labels
671
+ gAxes.append('text')
672
+ .attr('class', 'axis-label')
673
+ .attr('x', innerWidth / 2)
674
+ .attr('y', innerHeight + 32)
675
+ .attr('text-anchor', 'middle')
676
+ .text(CONFIG.xAxisLabel);
677
+
678
+ gAxes.append('text')
679
+ .attr('class', 'axis-label')
680
+ .attr('transform', 'rotate(-90)')
681
+ .attr('x', -innerHeight / 2)
682
+ .attr('y', -38)
683
+ .attr('text-anchor', 'middle')
684
+ .text(CONFIG.yAxisLabel);
685
+
686
+ // Baseline reference line
687
+ if (baseline !== null) {
688
+ gAxes.append('line')
689
+ .attr('class', 'baseline-line')
690
+ .attr('x1', 0)
691
+ .attr('x2', innerWidth)
692
+ .attr('y1', yScale(baseline))
693
+ .attr('y2', yScale(baseline))
694
+ .attr('stroke', 'var(--text-color, #666)')
695
+ .attr('stroke-width', 1.5)
696
+ .attr('stroke-dasharray', '5,5')
697
+ .attr('opacity', 0.5);
698
+
699
+ gAxes.append('text')
700
+ .attr('class', 'baseline-label')
701
+ .attr('x', innerWidth - 5)
702
+ .attr('y', yScale(baseline) - 5)
703
+ .attr('text-anchor', 'end')
704
+ .attr('font-size', '10px')
705
+ .attr('fill', 'var(--text-color, #666)')
706
+ .attr('opacity', 0.7)
707
+ .text('Baseline');
708
+ }
709
+
710
+ // Group data by run
711
+ const dataByRun = {};
712
+ runList.forEach(run => { dataByRun[run] = []; });
713
+ allData.forEach(d => {
714
+ if (dataByRun[d.run]) dataByRun[d.run].push({ step: d.step, value: d.value });
715
+ });
716
+ runList.forEach(run => { dataByRun[run].sort((a, b) => a.step - b.step); });
717
+
718
+ const series = runList.map(run => ({ run, color: runColorMap[run], values: dataByRun[run] })).filter(s => s.values.length > 0);
719
+
720
+ // Ghost lines
721
+ const ghostLine = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)).curve(d3.curveLinear);
722
+ gPlot.selectAll('path.ghost-line').data(series, d => d.run).join('path')
723
+ .attr('class', 'ghost-line')
724
+ .attr('fill', 'none')
725
+ .attr('stroke', d => d.color)
726
+ .attr('stroke-width', 1.5)
727
+ .attr('opacity', smoothEnabled ? 0.15 : 0)
728
+ .attr('pointer-events', 'none')
729
+ .attr('d', d => ghostLine(d.values));
730
+
731
+ // Main lines
732
+ const mainLine = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)).curve(getCurve(smoothEnabled));
733
+ gPlot.selectAll('path.main-line').data(series, d => d.run).join('path')
734
+ .attr('class', 'main-line')
735
+ .attr('fill', 'none')
736
+ .attr('stroke', d => d.color)
737
+ .attr('stroke-width', 2)
738
+ .attr('opacity', 0.85)
739
+ .attr('d', d => mainLine(applySmoothing(d.values, smoothEnabled)));
740
+
741
+ // Hover
742
+ setupHover(series, innerWidth, innerHeight);
743
+ }
744
+
745
+ function setupHover(series, innerWidth, innerHeight) {
746
+ gHover.selectAll('*').remove();
747
+
748
+ hoverLine = gHover.append('line')
749
+ .style('stroke', 'var(--text-color)')
750
+ .attr('stroke-opacity', 0.25)
751
+ .attr('stroke-width', 1)
752
+ .attr('y1', 0)
753
+ .attr('y2', innerHeight)
754
+ .style('display', 'none')
755
+ .attr('pointer-events', 'none');
756
+
757
+ const stepSet = new Set();
758
+ series.forEach(s => s.values.forEach(v => stepSet.add(v.step)));
759
+ steps = Array.from(stepSet).sort((a, b) => a - b);
760
+
761
+ overlay.on('mousemove', function (ev) {
762
+ if (ev.buttons === 0) onHoverMove(ev, series);
763
+ }).on('mouseleave', onHoverLeave);
764
+ }
765
+
766
+ function onHoverMove(ev, series) {
767
+ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
768
+
769
+ const [mx, my] = d3.pointer(ev, overlay.node());
770
+ const targetStep = xScale.invert(mx);
771
+ const nearest = steps.reduce((best, t) => Math.abs(t - targetStep) < Math.abs(best - targetStep) ? t : best, steps[0]);
772
+
773
+ const xpx = xScale(nearest);
774
+ hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
775
+
776
+ let html = `<div><strong>${chartConfig.title}</strong></div>`;
777
+ html += `<div>${formatStep(nearest)}</div>`;
778
+
779
+ const entries = series.map(s => {
780
+ const values = s.values;
781
+ let before = null, after = null;
782
+ for (let i = 0; i < values.length; i++) {
783
+ if (values[i].step <= nearest) before = values[i];
784
+ if (values[i].step >= nearest && !after) { after = values[i]; break; }
785
+ }
786
+
787
+ let interpolatedValue = null;
788
+ if (before && after && before.step !== after.step) {
789
+ const t = (nearest - before.step) / (after.step - before.step);
790
+ interpolatedValue = before.value + t * (after.value - before.value);
791
+ } else if (before && before.step === nearest) {
792
+ interpolatedValue = before.value;
793
+ } else if (after && after.step === nearest) {
794
+ interpolatedValue = after.value;
795
+ } else if (before) {
796
+ interpolatedValue = before.value;
797
+ } else if (after) {
798
+ interpolatedValue = after.value;
799
+ }
800
+
801
+ return { run: s.run, color: s.color, value: interpolatedValue };
802
+ }).filter(e => e.value != null);
803
+
804
+ entries.sort((a, b) => b.value - a.value);
805
+
806
+ entries.forEach(e => {
807
+ html += `<div style="display:flex;align-items:center;gap:8px;"><span class="d3-tooltip__color-dot" style="background:${e.color}"></span><span>${e.run}</span><span style="margin-left:auto;font-weight:normal;">${e.value.toFixed(4)}</span></div>`;
808
+ });
809
+
810
+ tipInner.innerHTML = html;
811
+ const offsetX = 12, offsetY = 12;
812
+ tip.style.opacity = '1';
813
+ tip.style.transform = `translate(${Math.round(mx + offsetX + CONFIG.margin.left)}px, ${Math.round(my + offsetY + CONFIG.margin.top)}px)`;
814
+ }
815
+
816
+ function onHoverLeave() {
817
+ hideTipTimer = setTimeout(() => {
818
+ tip.style.opacity = '0';
819
+ tip.style.transform = 'translate(-9999px, -9999px)';
820
+ if (hoverLine) hoverLine.style('display', 'none');
821
+ }, 100);
822
+ }
823
+
824
+ // Reset button
825
+ resetBtn.addEventListener('click', () => {
826
+ overlay.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
827
+ });
828
+
829
+ // Load data
830
+ async function load() {
831
+ try {
832
+ const langCode = languageMap[chartConfig.language] || chartConfig.language.toLowerCase();
833
+ const task = chartConfig.task;
834
+ const baseUrl = window.location.origin;
835
+ const dataUrl = `${baseUrl}/finetasks/data/${langCode}/${task}_data.csv`;
836
+ const statsUrl = `${baseUrl}/finetasks/data/${langCode}/${task}_stats.csv`;
837
+
838
+ console.log('Loading D3 data from:', dataUrl);
839
+ console.log('Loading D3 stats from:', statsUrl);
840
+
841
+ const [dataResponse, statsResponse] = await Promise.all([
842
+ fetch(dataUrl, { cache: 'no-cache' }).catch(e => {
843
+ console.error('Failed to fetch data:', dataUrl, e);
844
+ throw e;
845
+ }),
846
+ fetch(statsUrl, { cache: 'no-cache' }).catch(e => {
847
+ console.error('Failed to fetch stats:', statsUrl, e);
848
+ throw e;
849
+ })
850
+ ]);
851
+
852
+ console.log('Data response status:', dataResponse.status, dataResponse.statusText);
853
+ console.log('Stats response status:', statsResponse.status, statsResponse.statusText);
854
+
855
+ if (!dataResponse.ok) throw new Error(`Failed to load data: ${dataResponse.status} ${dataResponse.statusText}`);
856
+ if (!statsResponse.ok) throw new Error(`Failed to load stats: ${statsResponse.status} ${statsResponse.statusText}`);
857
+
858
+ const csvText = await dataResponse.text();
859
+ const statsText = await statsResponse.text();
860
+
861
+ console.log('CSV text length:', csvText.length);
862
+ console.log('Stats text length:', statsText.length);
863
+
864
+ // Parse CSV
865
+ const rawRows = d3.csvParse(csvText);
866
+ const statsRows = d3.csvParse(statsText);
867
+
868
+ if (!rawRows || rawRows.length === 0) {
869
+ throw new Error('No data found in CSV');
870
+ }
871
+
872
+ console.log('Raw CSV columns:', Object.keys(rawRows[0]));
873
+ console.log('Raw CSV first row:', rawRows[0]);
874
+ console.log('Looking for metric:', chartConfig.metric);
875
+ console.log('Total raw rows:', rawRows.length);
876
+
877
+ // Extract stat value from stats
878
+ if (statsRows && statsRows.length > 0) {
879
+ const statsRow = statsRows.find(row => row.metric === chartConfig.metric);
880
+ if (statsRow && statsRow[CONFIG.statColumn]) {
881
+ monotonicity = parseFloat(statsRow[CONFIG.statColumn]);
882
+ console.log(`${CONFIG.statLabel} value (${CONFIG.statColumn}):`, monotonicity);
883
+ }
884
+ }
885
+
886
+ // Transform to expected format
887
+ // Separate baseline from regular runs
888
+ const dataByRun = {};
889
+ let baselineValue = null;
890
+ let skippedRows = 0;
891
+
892
+ rawRows.forEach((row, idx) => {
893
+ const runname = row.runname || row.run_name || 'unknown';
894
+ const seed = row.seed || '';
895
+ const tokens = parseFloat(row.tokens);
896
+ const metricValue = parseFloat(row[chartConfig.metric]);
897
+
898
+ if (isNaN(tokens) || isNaN(metricValue)) {
899
+ if (idx < 3) {
900
+ console.log(`Skipping row ${idx}: tokens=${row.tokens}, metric=${row[chartConfig.metric]}`);
901
+ }
902
+ skippedRows++;
903
+ return;
904
+ }
905
+
906
+ // Check if this is a baseline row
907
+ if (runname.toLowerCase().includes('baseline')) {
908
+ if (baselineValue === null) {
909
+ baselineValue = metricValue;
910
+ console.log('Baseline value found:', baselineValue);
911
+ }
912
+ return; // Don't include baseline in regular data
913
+ }
914
+
915
+ const processedName = processRunName(runname);
916
+
917
+ // Create key: either just runname (grouped) or runname_seed (separate)
918
+ const runKey = CONFIG.groupSeeds ? processedName : `${processedName}_${seed}`;
919
+
920
+ if (!dataByRun[runKey]) {
921
+ dataByRun[runKey] = {};
922
+ }
923
+
924
+ const tokenKey = tokens;
925
+ if (!dataByRun[runKey][tokenKey]) {
926
+ dataByRun[runKey][tokenKey] = [];
927
+ }
928
+
929
+ dataByRun[runKey][tokenKey].push(metricValue);
930
+ });
931
+
932
+ console.log('Skipped rows:', skippedRows);
933
+ console.log('Grouped by runs:', Object.keys(dataByRun));
934
+
935
+ // Compute means and create final data
936
+ allData = [];
937
+ Object.keys(dataByRun).forEach(runName => {
938
+ Object.keys(dataByRun[runName]).forEach(tokenKey => {
939
+ const values = dataByRun[runName][tokenKey];
940
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
941
+ allData.push({
942
+ run: runName,
943
+ step: parseFloat(tokenKey) / 1e9, // Convert to billions
944
+ value: mean
945
+ });
946
+ });
947
+ });
948
+
949
+ console.log('Processed data points:', allData.length);
950
+ console.log('Unique runs found:', Array.from(new Set(allData.map(d => d.run))));
951
+ console.log('Sample data points:', allData.slice(0, 5));
952
+
953
+ // Validate data
954
+ if (allData.length === 0) {
955
+ throw new Error(`No valid data found for metric: ${chartConfig.metric}`);
956
+ }
957
+
958
+ // Store baseline value
959
+ baseline = baselineValue;
960
+
961
+ // Update stat display with the metric value
962
+ if (statEl && monotonicity !== null && CONFIG.statLabel) {
963
+ statEl.innerHTML = `
964
+ <span class="chart-cell__stat-label">${CONFIG.statLabel}:</span>
965
+ <span class="chart-cell__stat-value">${monotonicity.toFixed(2)}</span>
966
+ `;
967
+ }
968
+
969
+ runList = Array.from(new Set(allData.map(d => d.run))).sort();
970
+
971
+ if (runList.length === 0) {
972
+ throw new Error('No runs found in data');
973
+ }
974
+
975
+ const colors = getRunColors(runList.length);
976
+ runList.forEach((run, i) => { runColorMap[run] = colors[i % colors.length]; });
977
+
978
+ // Build legend
979
+ if (legendEl) {
980
+ legendEl.innerHTML = runList.map(run => {
981
+ const color = runColorMap[run];
982
+ return `<span class="item" data-run="${run}"><span class="swatch" style="background:${color}"></span><span>${run}</span></span>`;
983
+ }).join('');
984
+
985
+ // Add hover interactions
986
+ legendEl.querySelectorAll('.item').forEach(el => {
987
+ el.addEventListener('mouseenter', () => {
988
+ const run = el.getAttribute('data-run');
989
+ container.classList.add('hovering');
990
+ cellElement.querySelectorAll('path.main-line').forEach(path => {
991
+ const pathRun = d3.select(path).datum()?.run;
992
+ path.classList.toggle('ghost', pathRun !== run);
993
+ });
994
+ cellElement.querySelectorAll('path.ghost-line').forEach(path => {
995
+ const pathRun = d3.select(path).datum()?.run;
996
+ path.classList.toggle('ghost', pathRun !== run);
997
+ });
998
+ legendEl.querySelectorAll('.item').forEach(it => {
999
+ it.classList.toggle('ghost', it.getAttribute('data-run') !== run);
1000
+ });
1001
+ });
1002
+
1003
+ el.addEventListener('mouseleave', () => {
1004
+ container.classList.remove('hovering');
1005
+ cellElement.querySelectorAll('path.main-line').forEach(path => path.classList.remove('ghost'));
1006
+ cellElement.querySelectorAll('path.ghost-line').forEach(path => path.classList.remove('ghost'));
1007
+ legendEl.querySelectorAll('.item').forEach(it => it.classList.remove('ghost'));
1008
+ });
1009
+ });
1010
+ }
1011
+
1012
+ render();
1013
+
1014
+ } catch (e) {
1015
+ console.error('Error loading chart:', chartConfig.title, e);
1016
+ const pre = document.createElement('pre');
1017
+ pre.textContent = 'Error loading data: ' + (e && e.message ? e.message : e);
1018
+ pre.style.color = 'var(--danger, #b00020)';
1019
+ pre.style.fontSize = '12px';
1020
+ pre.style.padding = '12px';
1021
+ pre.style.background = 'var(--surface-bg)';
1022
+ pre.style.borderRadius = '6px';
1023
+ pre.style.border = '1px solid var(--danger, #b00020)';
1024
+ bodyEl.appendChild(pre);
1025
+ }
1026
+ }
1027
+
1028
+ // Wrap load in try/catch to prevent one chart from breaking others
1029
+ try {
1030
+ load();
1031
+ } catch (e) {
1032
+ console.error('Failed to initialize chart:', chartConfig.title, e);
1033
+ }
1034
+
1035
+ return { render };
1036
+ }
1037
+
1038
+ // Init all charts
1039
+ const cells = Array.from(grid.querySelectorAll('.chart-cell'));
1040
+ const chartInstances = cells.map((cell, idx) => initChart(cell, CONFIG.charts[idx]));
1041
+
1042
+ // Responsive
1043
+ let resizeTimer;
1044
+ const handleResize = () => {
1045
+ clearTimeout(resizeTimer);
1046
+ resizeTimer = setTimeout(() => {
1047
+ chartInstances.forEach(chart => chart && chart.render && chart.render());
1048
+ }, 100);
1049
+ };
1050
+
1051
+ const ro = window.ResizeObserver ? new ResizeObserver(handleResize) : null;
1052
+ if (ro) {
1053
+ ro.observe(container);
1054
+ }
1055
+
1056
+ window.addEventListener('resize', handleResize);
1057
+
1058
+ setTimeout(() => {
1059
+ chartInstances.forEach(chart => chart && chart.render && chart.render());
1060
+ }, 100);
1061
+ };
1062
+
1063
+ if (document.readyState === 'loading') {
1064
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
1065
+ } else {
1066
+ ensureD3(bootstrap);
1067
+ }
1068
+ })();
1069
+ </script>
1070
+