thibaud frere commited on
Commit
e2d6261
·
1 Parent(s): df95a5c

update charts

Browse files
app/scripts/generate-trackio-data.mjs ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ // Generate synthetic Trackio-like CSV data with realistic ML curves.
4
+ // - Steps are simple integers (e.g., 1..N)
5
+ // - Metrics: epoch, train_accuracy, val_accuracy, train_loss, val_loss
6
+ // - W&B-like run names (e.g., pleasant-flower-1)
7
+ // - Deterministic with --seed
8
+ //
9
+ // Usage:
10
+ // node app/scripts/generate-trackio-data.mjs \
11
+ // --runs 3 \
12
+ // --steps 10 \
13
+ // --out app/src/content/assets/data/trackio_wandb_synth.csv \
14
+ // [--seed 42] [--epoch-max 3.0] [--amount 1.0] [--start 1]
15
+ //
16
+ // To overwrite the demo file used by the embed:
17
+ // node app/scripts/generate-trackio-data.mjs --runs 3 --steps 10 --out app/src/content/assets/data/trackio_wandb_demo.csv --seed 1337
18
+
19
+ import fs from 'node:fs/promises';
20
+ import path from 'node:path';
21
+
22
+ function parseArgs(argv){
23
+ const args = { runs: 3, steps: 10, out: '', seed: undefined, epochMax: 3.0, amount: 1, start: 1 };
24
+ for (let i = 2; i < argv.length; i++){
25
+ const a = argv[i];
26
+ if (a === '--runs' && argv[i+1]) { args.runs = Math.max(1, parseInt(argv[++i], 10) || 3); continue; }
27
+ if (a === '--steps' && argv[i+1]) { args.steps = Math.max(2, parseInt(argv[++i], 10) || 10); continue; }
28
+ if (a === '--out' && argv[i+1]) { args.out = argv[++i]; continue; }
29
+ if (a === '--seed' && argv[i+1]) { args.seed = Number(argv[++i]); continue; }
30
+ if (a === '--epoch-max' && argv[i+1]) { args.epochMax = Number(argv[++i]) || 3.0; continue; }
31
+ if (a === '--amount' && argv[i+1]) { args.amount = Number(argv[++i]) || 1.0; continue; }
32
+ if (a === '--start' && argv[i+1]) { args.start = parseInt(argv[++i], 10) || 1; continue; }
33
+ }
34
+ if (!args.out) {
35
+ args.out = path.join('app', 'src', 'content', 'assets', 'data', 'trackio_wandb_synth.csv');
36
+ }
37
+ return args;
38
+ }
39
+
40
+ function mulberry32(seed){
41
+ let t = seed >>> 0;
42
+ return function(){
43
+ t += 0x6D2B79F5;
44
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
45
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
46
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
47
+ };
48
+ }
49
+
50
+ function makeRng(seed){
51
+ if (Number.isFinite(seed)) return mulberry32(seed);
52
+ return Math.random;
53
+ }
54
+
55
+ function randn(rng){
56
+ // Box-Muller transform
57
+ let u = 0, v = 0;
58
+ while (u === 0) u = rng();
59
+ while (v === 0) v = rng();
60
+ return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
61
+ }
62
+
63
+ function clamp(x, lo, hi){
64
+ return Math.max(lo, Math.min(hi, x));
65
+ }
66
+
67
+ function logistic(t, k=6, x0=0.5){
68
+ // 1 / (1 + e^{-k (t - x0)}) in [0,1]
69
+ return 1 / (1 + Math.exp(-k * (t - x0)));
70
+ }
71
+
72
+ function expDecay(t, k=3){
73
+ // (1 - e^{-k t}) in [0,1]
74
+ return 1 - Math.exp(-k * t);
75
+ }
76
+
77
+ function pick(array, rng){
78
+ return array[Math.floor(rng() * array.length) % array.length];
79
+ }
80
+
81
+ function buildRunNames(count, rng){
82
+ const adjectives = [
83
+ 'pleasant','brisk','silent','ancient','bold','gentle','rapid','shy','curious','lively',
84
+ 'fearless','soothing','glossy','hidden','misty','bright','calm','keen','noble','swift'
85
+ ];
86
+ const nouns = [
87
+ 'flower','glade','sky','river','forest','ember','comet','meadow','harbor','dawn',
88
+ 'mountain','prairie','breeze','valley','lagoon','desert','monsoon','reef','thunder','willow'
89
+ ];
90
+ const names = new Set();
91
+ let attempts = 0;
92
+ while (names.size < count && attempts < count * 20){
93
+ attempts++;
94
+ const left = pick(adjectives, rng);
95
+ const right = pick(nouns, rng);
96
+ const idx = 1 + Math.floor(rng() * 9);
97
+ names.add(`${left}-${right}-${idx}`);
98
+ }
99
+ return Array.from(names);
100
+ }
101
+
102
+ function formatLike(value, decimals){
103
+ return Number.isFinite(decimals) && decimals >= 0 ? value.toFixed(decimals) : String(value);
104
+ }
105
+
106
+ async function main(){
107
+ const args = parseArgs(process.argv);
108
+ const rng = makeRng(args.seed);
109
+
110
+ // Steps: integers from start .. start+steps-1
111
+ const steps = Array.from({ length: args.steps }, (_, i) => args.start + i);
112
+ const stepNorm = (i) => (i - steps[0]) / (steps[steps.length-1] - steps[0]);
113
+
114
+ const runs = buildRunNames(args.runs, rng);
115
+
116
+ // Per-run slight variations
117
+ const runParams = runs.map((_r, idx) => {
118
+ const r = rng();
119
+ // Final accuracies
120
+ const trainAccFinal = clamp(0.86 + (r - 0.5) * 0.12 * args.amount, 0.78, 0.97);
121
+ const valAccFinal = clamp(trainAccFinal - (0.02 + rng() * 0.05), 0.70, 0.95);
122
+ // Loss plateau
123
+ const lossStart = 7.0 + (rng() - 0.5) * 0.10 * args.amount; // ~7.0 ±0.05
124
+ const lossPlateau = 6.78 + (rng() - 0.5) * 0.04 * args.amount; // ~6.78 ±0.02
125
+ const lossK = 2.0 + rng() * 1.5; // decay speed
126
+ // Acc growth steepness and midpoint
127
+ const kAcc = 4.5 + rng() * 3.0;
128
+ const x0Acc = 0.35 + rng() * 0.25;
129
+ return { trainAccFinal, valAccFinal, lossStart, lossPlateau, lossK, kAcc, x0Acc };
130
+ });
131
+
132
+ const lines = [];
133
+ lines.push('run,step,metric,value,stderr');
134
+
135
+ // EPOCH: linear 0..epochMax across steps
136
+ for (let r = 0; r < runs.length; r++){
137
+ const run = runs[r];
138
+ for (let i = 0; i < steps.length; i++){
139
+ const t = stepNorm(steps[i]);
140
+ const epoch = args.epochMax * t;
141
+ lines.push(`${run},${steps[i]},epoch,${formatLike(epoch, 2)},`);
142
+ }
143
+ }
144
+
145
+ // TRAIN LOSS & VAL LOSS
146
+ for (let r = 0; r < runs.length; r++){
147
+ const run = runs[r];
148
+ const p = runParams[r];
149
+ let prevTrain = null;
150
+ let prevVal = null;
151
+ for (let i = 0; i < steps.length; i++){
152
+ const t = stepNorm(steps[i]);
153
+ const d = expDecay(t, p.lossK); // 0..1
154
+ let trainLoss = p.lossStart - (p.lossStart - p.lossPlateau) * d;
155
+ let valLoss = trainLoss + 0.02 + (rng() * 0.03);
156
+ // Add mild noise
157
+ trainLoss += randn(rng) * 0.01 * args.amount;
158
+ valLoss += randn(rng) * 0.012 * args.amount;
159
+ // Keep reasonable and mostly monotonic (small upward blips allowed)
160
+ if (prevTrain != null) trainLoss = Math.min(prevTrain + 0.01, trainLoss);
161
+ if (prevVal != null) valLoss = Math.min(prevVal + 0.012, valLoss);
162
+ prevTrain = trainLoss; prevVal = valLoss;
163
+ const stderrTrain = clamp(0.03 - 0.02 * t + Math.abs(randn(rng)) * 0.003, 0.006, 0.04);
164
+ const stderrVal = clamp(0.035 - 0.022 * t + Math.abs(randn(rng)) * 0.003, 0.008, 0.045);
165
+ lines.push(`${run},${steps[i]},train_loss,${formatLike(trainLoss, 3)},${formatLike(stderrTrain, 3)}`);
166
+ lines.push(`${run},${steps[i]},val_loss,${formatLike(valLoss, 3)},${formatLike(stderrVal, 3)}`);
167
+ }
168
+ }
169
+
170
+ // TRAIN ACCURACY & VAL ACCURACY (logistic)
171
+ for (let r = 0; r < runs.length; r++){
172
+ const run = runs[r];
173
+ const p = runParams[r];
174
+ for (let i = 0; i < steps.length; i++){
175
+ const t = stepNorm(steps[i]);
176
+ const accBase = logistic(t, p.kAcc, p.x0Acc);
177
+ let trainAcc = clamp(0.55 + accBase * (p.trainAccFinal - 0.55), 0, 1);
178
+ let valAcc = clamp(0.52 + accBase * (p.valAccFinal - 0.52), 0, 1);
179
+ // Gentle noise
180
+ trainAcc = clamp(trainAcc + randn(rng) * 0.005 * args.amount, 0, 1);
181
+ valAcc = clamp(valAcc + randn(rng) * 0.006 * args.amount, 0, 1);
182
+ const stderrTrain = clamp(0.02 - 0.011 * t + Math.abs(randn(rng)) * 0.002, 0.006, 0.03);
183
+ const stderrVal = clamp(0.022 - 0.012 * t + Math.abs(randn(rng)) * 0.002, 0.007, 0.032);
184
+ lines.push(`${run},${steps[i]},train_accuracy,${formatLike(trainAcc, 4)},${formatLike(stderrTrain, 3)}`);
185
+ lines.push(`${run},${steps[i]},val_accuracy,${formatLike(valAcc, 4)},${formatLike(stderrVal, 3)}`);
186
+ }
187
+ }
188
+
189
+ // Ensure directory exists
190
+ await fs.mkdir(path.dirname(args.out), { recursive: true });
191
+ await fs.writeFile(args.out, lines.join('\n') + '\n', 'utf8');
192
+ const relOut = path.relative(process.cwd(), args.out);
193
+ console.log(`Synthetic CSV generated: ${relOut}`);
194
+ }
195
+
196
+ main().catch(err => { console.error(err?.stack || String(err)); process.exit(1); });
app/scripts/jitter-trackio-data.mjs ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ // Jitter Trackio CSV data with small, controlled noise.
4
+ // - Preserves comments (# ...) and blank lines
5
+ // - Leaves 'epoch' values unchanged
6
+ // - Adds mild noise to train/val accuracy (clamped to [0,1])
7
+ // - Adds mild noise to train/val loss (kept >= 0)
8
+ // - Keeps steps untouched
9
+ // Usage:
10
+ // node app/scripts/jitter-trackio-data.mjs \
11
+ // --in app/src/content/assets/data/trackio_wandb_demo.csv \
12
+ // --out app/src/content/assets/data/trackio_wandb_demo.jitter.csv \
13
+ // [--seed 42] [--amount 1.0] [--in-place]
14
+
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ function parseArgs(argv){
19
+ const args = { in: '', out: '', seed: undefined, amount: 1, inPlace: false };
20
+ for (let i = 2; i < argv.length; i++){
21
+ const a = argv[i];
22
+ if (a === '--in' && argv[i+1]) { args.in = argv[++i]; continue; }
23
+ if (a === '--out' && argv[i+1]) { args.out = argv[++i]; continue; }
24
+ if (a === '--seed' && argv[i+1]) { args.seed = Number(argv[++i]); continue; }
25
+ if (a === '--amount' && argv[i+1]) { args.amount = Number(argv[++i]) || 3; continue; }
26
+ if (a === '--in-place') { args.inPlace = true; continue; }
27
+ }
28
+ if (!args.in) throw new Error('--in is required');
29
+ if (args.inPlace) args.out = args.in;
30
+ if (!args.out) {
31
+ const { dir, name, ext } = path.parse(args.in);
32
+ args.out = path.join(dir, `${name}.jitter${ext || '.csv'}`);
33
+ }
34
+ return args;
35
+ }
36
+
37
+ function mulberry32(seed){
38
+ let t = seed >>> 0;
39
+ return function(){
40
+ t += 0x6D2B79F5;
41
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
42
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
43
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
44
+ };
45
+ }
46
+
47
+ function makeRng(seed){
48
+ if (Number.isFinite(seed)) return mulberry32(seed);
49
+ return Math.random;
50
+ }
51
+
52
+ function randn(rng){
53
+ // Box-Muller transform
54
+ let u = 0, v = 0;
55
+ while (u === 0) u = rng();
56
+ while (v === 0) v = rng();
57
+ return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
58
+ }
59
+
60
+ function jitterValue(metric, value, amount, rng){
61
+ const m = metric.toLowerCase();
62
+ if (m === 'epoch') return value; // keep as-is
63
+ if (m.includes('accuracy')){
64
+ const n = Math.max(-0.02 * amount, Math.min(0.02 * amount, randn(rng) * 0.01 * amount));
65
+ return Math.max(0, Math.min(1, value + n));
66
+ }
67
+ if (m.includes('loss')){
68
+ const n = Math.max(-0.03 * amount, Math.min(0.03 * amount, randn(rng) * 0.01 * amount));
69
+ return Math.max(0, value + n);
70
+ }
71
+ // default: tiny noise
72
+ const n = Math.max(-0.01 * amount, Math.min(0.01 * amount, randn(rng) * 0.005 * amount));
73
+ return value + n;
74
+ }
75
+
76
+ function formatNumberLike(original, value){
77
+ const s = String(original);
78
+ const dot = s.indexOf('.')
79
+ const decimals = dot >= 0 ? (s.length - dot - 1) : 0;
80
+ if (!Number.isFinite(value)) return s;
81
+ if (decimals <= 0) return String(Math.round(value));
82
+ return value.toFixed(decimals);
83
+ }
84
+
85
+ async function main(){
86
+ const args = parseArgs(process.argv);
87
+ const rng = makeRng(args.seed);
88
+ const raw = await fs.readFile(args.in, 'utf8');
89
+ const lines = raw.split(/\r?\n/);
90
+ const out = new Array(lines.length);
91
+
92
+ for (let i = 0; i < lines.length; i++){
93
+ const line = lines[i];
94
+ if (!line || line.trim().length === 0) { out[i] = line; continue; }
95
+ if (/^\s*#/.test(line)) { out[i] = line; continue; }
96
+
97
+ // Preserve header line unmodified
98
+ if (i === 0 && /^\s*run\s*,\s*step\s*,\s*metric\s*,\s*value\s*,\s*stderr\s*$/i.test(line)) {
99
+ out[i] = line; continue;
100
+ }
101
+
102
+ const cols = line.split(',');
103
+ if (cols.length < 4) { out[i] = line; continue; }
104
+
105
+ const [run, stepStr, metric, valueStr, stderrStr = ''] = cols;
106
+ const trimmedMetric = (metric || '').trim();
107
+ const valueNum = Number((valueStr || '').trim());
108
+
109
+ if (!Number.isFinite(valueNum)) { out[i] = line; continue; }
110
+
111
+ const jittered = jitterValue(trimmedMetric, valueNum, args.amount, rng);
112
+ const valueOut = formatNumberLike(valueStr, jittered);
113
+
114
+ // Reassemble with original column count and positions
115
+ const result = [run, stepStr, metric, valueOut, stderrStr].join(',');
116
+ out[i] = result;
117
+ }
118
+
119
+ const finalText = out.join('\n');
120
+ await fs.writeFile(args.out, finalText, 'utf8');
121
+ const relIn = path.relative(process.cwd(), args.in);
122
+ const relOut = path.relative(process.cwd(), args.out);
123
+ console.log(`Jittered data written: ${relOut} (from ${relIn})`);
124
+ }
125
+
126
+ main().catch(err => {
127
+ console.error(err?.stack || String(err));
128
+ process.exit(1);
129
+ });
app/src/components/Palettes.astro CHANGED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
3
+ ---
4
+ <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
5
+ <style is:global>
6
+ .palettes { box-sizing: border-box; }
7
+ .palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; max-width: 100%; }
8
+ .palettes .palette-card { position: relative; display: grid; grid-template-columns: 1fr minmax(0, 220px); align-items: stretch; gap: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; min-height: 60px; }
9
+ .palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; flex: 0 0 auto; background-size: cover; background-position: center; }
10
+ .palettes .palette-card__copy { position: absolute; top: 50%; left: 100%; transform: translateY(-50%); z-index: 3; border-left: none; border-top-left-radius: 0; border-bottom-left-radius: 0; }
11
+ .palettes .palette-card__copy svg { width: 18px; height: 18px; fill: currentColor; display: block; color: inherit; }
12
+ .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 2px; margin: 0; min-height: 60px; }
13
+ .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; height: auto; border-radius: 0; border: 1px solid var(--border-color); }
14
+ .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
15
+ .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
16
+ .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: flex-start; gap: 12px; min-width: 0; padding-right: 12px; }
17
+ .palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; position: relative; flex: 0 0 auto; overflow: hidden; }
18
+ .palettes .palette-card__preview .dot { position: absolute; width: 4px; height: 4px; background: #fff; border-radius: 999px; box-shadow: 0 0 0 1px var(--border-color); }
19
+ .palettes .palette-card__preview .donut-hole { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 24px; height: 24px; border-radius: 999px; background: var(--surface-bg); box-shadow: 0 0 0 1px var(--border-color) inset; }
20
+
21
+ .palettes .palette-card__content__info { display: flex; flex-direction: column; }
22
+ .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
23
+ .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
24
+
25
+ .palettes .palettes__select { width: 100%; max-width: 100%; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
26
+ .palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
27
+ .palettes .palettes__controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
28
+ .palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1 1 280px; max-width: 100%; }
29
+ .palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
30
+ .palettes .palettes__label-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
31
+ .palettes .ghost-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; font-variant-numeric: tabular-nums; }
32
+ .palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
33
+ .palettes .palettes__count input[type="range"] { width: 100%; }
34
+ .palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
35
+ .palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
36
+ .palettes input[type="range"]:focus { outline: none; }
37
+ .palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
38
+ .palettes input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: -6px; width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
39
+ .palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
40
+ .palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
41
+ .palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
42
+ html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
43
+ html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
44
+ html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
45
+ html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
46
+ html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
47
+ @media (max-width: 1100px) { .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; } .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); } .palettes .palette-card__content { border-right: none; padding-right: 0; } .palettes .palette-card__copy { display: none; } }
48
+ </style>
49
+ <div class="palettes__controls">
50
+ <div class="palettes__field">
51
+ <label class="palettes__label" for="cb-select">Color vision simulation</label>
52
+ <select id="cb-select" class="palettes__select">
53
+ <option value="none">Normal color vision — typical for most people</option>
54
+ <option value="achromatopsia">Achromatopsia — no color at all</option>
55
+ <option value="protanopia">Protanopia — reduced/absent reds</option>
56
+ <option value="deuteranopia">Deuteranopia — reduced/absent greens</option>
57
+ <option value="tritanopia">Tritanopia — reduced/absent blues</option>
58
+ </select>
59
+ </div>
60
+ <div class="palettes__field">
61
+ <div class="palettes__label-row">
62
+ <label class="palettes__label" for="color-count">Number of colors</label>
63
+ <output id="color-count-out" for="color-count" class="ghost-badge">8</output>
64
+ </div>
65
+ <div class="palettes__count">
66
+ <input id="color-count" type="range" min="6" max="10" step="1" value="8" aria-label="Number of colors" />
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <div class="palettes__grid"></div>
71
+ <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
72
+ <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
73
+ <defs>
74
+ <filter id="cb-protanopia"><feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/></filter>
75
+ <filter id="cb-deuteranopia"><feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/></filter>
76
+ <filter id="cb-tritanopia"><feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/></filter>
77
+ <filter id="cb-achromatopsia"><feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/></filter>
78
+ </defs>
79
+ </svg>
80
+ </div>
81
+ </div>
82
+ <script type="module" is:inline>
83
+ import '/src/scripts/color-palettes.js';
84
+ const ROOT_ID = "{rootId}";
85
+ (() => {
86
+ const cards = [
87
+ { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.' },
88
+ { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
89
+ { key: 'diverging', title: 'Diverging', desc: 'For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.' }
90
+ ];
91
+ const getPaletteColors = (key, count) => {
92
+ const total=Number(count)||6;
93
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') {
94
+ return window.ColorPalettes.getColors(key,total) || [];
95
+ }
96
+ return [];
97
+ };
98
+ const render = () => {
99
+ const root = document.getElementById(ROOT_ID) || document.querySelector('.palettes');
100
+ if (!root) return;
101
+ const grid=root.querySelector('.palettes__grid'); if (!grid) return;
102
+ const input=document.getElementById('color-count'); const total=input ? Number(input.value)||6 : 6;
103
+ const html = cards.map(c => {
104
+ const colors=getPaletteColors(c.key,total);
105
+ const swatches=colors.map(col=>`<div class=\"sw\" style=\"background:${col}\"></div>`).join('');
106
+ const baseHex = (window.ColorPalettes && typeof window.ColorPalettes.getPrimary==='function') ? window.ColorPalettes.getPrimary() : (colors[0] || '#FF0000');
107
+ const hueDeg = (()=>{ try { const s=baseHex.replace('#',''); const v=s.length===3?s.split('').map(ch=>ch+ch).join(''):s; const r=parseInt(v.slice(0,2),16)/255, g=parseInt(v.slice(2,4),16)/255, b=parseInt(v.slice(4,6),16)/255; const M=Math.max(r,g,b), m=Math.min(r,g,b), d=M-m; if (d===0) return 0; let h=0; if (M===r) h=((g-b)/d)%6; else if (M===g) h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if (h<0) h+=360; return h; } catch { return 0; } })();
108
+ const gradient = c.key==='categorical'
109
+ ? (() => {
110
+ const steps = 60; // smooth hue wheel (fixed orientation)
111
+ const wheel = Array.from({ length: steps }, (_, i) => `hsl(${Math.round((i/steps)*360)}, 100%, 50%)`).join(', ');
112
+ return `conic-gradient(${wheel})`;
113
+ })()
114
+ : (colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : `linear-gradient(90deg, var(--border-color), var(--border-color))`);
115
+ const previewInner = (()=>{
116
+ if (c.key !== 'categorical' || !colors.length) return '';
117
+ const ring = 18; const cx = 24; const cy = 24; const offset = (hueDeg/360) * 2 * Math.PI;
118
+ return colors.map((col,i)=>{
119
+ const angle = offset + (i/colors.length) * 2 * Math.PI;
120
+ const x = cx + ring * Math.cos(angle);
121
+ const y = cy + ring * Math.sin(angle);
122
+ return `<span class=\"dot\" style=\"left:${x-2}px; top:${y-2}px\"></span>`;
123
+ }).join('');
124
+ })();
125
+ const donutHole = (c.key === 'categorical') ? '<span class=\"donut-hole\"></span>' : '';
126
+ return `
127
+ <div class="palette-card" data-colors="${colors.join(',')}">
128
+ <div class="palette-card__content">
129
+ <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
130
+ <div class="palette-card__content__info">
131
+ <div class="palette-card__title">${c.title}</div>
132
+ <div class="palette-card__desc">${c.desc}</div>
133
+ </div>
134
+ </div>
135
+ <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
136
+ <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
137
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
138
+ </button>
139
+ </div>`;
140
+ }).join('');
141
+ grid.innerHTML=html;
142
+ };
143
+ const MODE_TO_CLASS = { protanopia:'cb-protanopia', deuteranopia:'cb-deuteranopia', tritanopia:'cb-tritanopia', achromatopsia:'cb-achromatopsia' };
144
+ const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
145
+ const clearCbClasses = () => { const rootEl=document.documentElement; CLEAR_CLASSES.forEach(cls=>rootEl.classList.remove(cls)); };
146
+ const applyCbClass = (mode) => { clearCbClasses(); const cls=MODE_TO_CLASS[mode]; if (cls) document.documentElement.classList.add(cls); };
147
+ const currentCbMode = () => { const rootEl=document.documentElement; for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; } return 'none'; };
148
+ const setupCbSim = () => { const select=document.getElementById('cb-select'); if (!select) return; try { select.value=currentCbMode(); } catch{} select.addEventListener('change', () => applyCbClass(select.value)); };
149
+ const setupCountControl = () => { const input=document.getElementById('color-count'); const out=document.getElementById('color-count-out'); if (!input) return; const clamp=(n,min,max)=>Math.max(min,Math.min(max,n)); const read=()=>clamp(Number(input.value)||6,6,10); const syncOut=()=>{ if (out) out.textContent=String(read()); }; const onChange=()=>{ syncOut(); render(); }; syncOut(); input.addEventListener('input', onChange); document.addEventListener('palettes:updated', () => { syncOut(); render(); }); };
150
+ let copyDelegationSetup=false; const setupCopyDelegation = () => { if (copyDelegationSetup) return; const grid=document.querySelector('.palettes .palettes__grid'); if (!grid) return; grid.addEventListener('click', async (e) => { const btn = e.target.closest ? e.target.closest('.palette-card__copy') : null; if (!btn) return; const card = btn.closest('.palette-card'); if (!card) return; const colors=(card.dataset.colors||'').split(',').filter(Boolean); const json=JSON.stringify(colors,null,2); try { await navigator.clipboard.writeText(json); const old=btn.innerHTML; btn.innerHTML='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; setTimeout(()=> btn.innerHTML=old, 900); } catch { window.prompt('Copy palette', json); } }); copyDelegationSetup=true; };
151
+ const bootstrap = () => {
152
+ setupCbSim();
153
+ setupCountControl();
154
+ setupCopyDelegation();
155
+ // Render immediately
156
+ render();
157
+ // Re-render on palette updates
158
+ document.addEventListener('palettes:updated', render);
159
+ // Force an immediate notify after listeners are attached (ensures initial render)
160
+ try {
161
+ if (window.ColorPalettes && typeof window.ColorPalettes.notify === 'function') window.ColorPalettes.notify();
162
+ else if (window.ColorPalettes && typeof window.ColorPalettes.refresh === 'function') window.ColorPalettes.refresh();
163
+ } catch {}
164
+ };
165
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); } else { bootstrap(); }
166
+ })();
167
+ </script>
168
+
169
+
170
+
app/src/content/assets/data/trackio_wandb_demo.csv CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9c8ae4d27d04319fb18b6b505ac13e4ae96e7bb65e9d42dea6a6b7a69a15c7e5
3
- size 4742
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:582f65833ae2138f8f1b57d7a3a07726278550fb5eed589e2c02b3e7bf84a02d
3
+ size 6244
app/src/content/chapters/vibe-coding-charts.mdx CHANGED
@@ -73,13 +73,12 @@ They can be found in the `app/src/content/embeds` folder and you can also use th
73
  ---
74
  <HtmlEmbed
75
  src="d3-trackio.html"
76
- title="Résultats TrackIO"
77
  />
78
  ---
79
- {/* <HtmlEmbed
80
  src="d3-trackio-oblivion.html"
81
- title="Résultats TrackIO"
82
  frameless
83
  />
84
- --- */}
85
 
 
73
  ---
74
  <HtmlEmbed
75
  src="d3-trackio.html"
76
+ frameless
77
  />
78
  ---
79
+ <HtmlEmbed
80
  src="d3-trackio-oblivion.html"
 
81
  frameless
82
  />
83
+ ---
84
 
app/src/content/embeds/d3-trackio-oblivion.html CHANGED
@@ -13,27 +13,56 @@
13
  /* Futuristic "Oblivion"-inspired styling */
14
  .d3-trackio-oblivion { position: relative;
15
  --cell-gap: 0px;
16
- --obl-cyan: #7FF1FF;
17
- --obl-cyan-dim: rgba(127,241,255,.25);
18
- --obl-accent: #8BF5FF;
19
- --obl-bg: rgba(255,255,255,0.06);
20
- --obl-border: rgba(127,241,255,.25);
21
- --obl-glow: 0 0 0 1px rgba(127,241,255,.35), 0 8px 40px rgba(127,241,255,.12);
22
- background: #0f1115;
23
- --corner-inset: 6px;
24
- --hud-gap: 10px;
25
- --hud-corner-size: 8px;
26
- /* Chart background gradient as a variable for easy theming */
27
- --hud-bg-gradient: radial-gradient(1200px 200px at 20% -10%, rgba(127,241,255,.03), transparent 80%), radial-gradient(900px 200px at 80% 110%, rgba(127,241,255,.03), transparent 80%);
28
- /* Tooltip offset (bottom-right of cursor) */
29
- --tip-offset-x: 0px;
30
- --tip-offset-y: 0px;
31
- padding: var(--hud-gap);
32
- font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
 
 
 
 
 
 
 
 
33
  }
34
  .d3-trackio-oblivion * { font-family: inherit;
35
  }
36
- [data-theme="dark"] .d3-trackio-oblivion { --obl-bg: rgba(12,18,22,.45); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  .d3-trackio-oblivion__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: --cell-gap; }
39
  @media (max-width: 980px) { .d3-trackio-oblivion__grid { grid-template-columns: 1fr; } }
@@ -65,9 +94,14 @@
65
  linear-gradient(var(--obl-cyan), var(--obl-cyan)) bottom right / var(--hud-corner-size) 1px no-repeat,
66
  linear-gradient(var(--obl-cyan), var(--obl-cyan)) bottom right / 1px var(--hud-corner-size) no-repeat; opacity:.85; }
67
  .d3-trackio-oblivion .cell-inner { position: relative; z-index: 2; padding: var(--hud-corner-size) 12px 10px var(--hud-gap); display:flex; flex-direction:column; }
68
- .d3-trackio-oblivion .cell-header { padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
69
  .d3-trackio-oblivion .cell-title { position: relative; font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--obl-cyan); padding-left: 14px; }
70
  .d3-trackio-oblivion .cell-title:before { content:""; position:absolute; left:0; top:50%; transform:translateY(-50%); width: 6px; height: 6px; background: var(--obl-cyan); border: 1px solid var(--obl-border); box-shadow: 0 0 10px rgba(127,241,255,.25) inset; opacity: .5; }
 
 
 
 
 
71
  .d3-trackio-oblivion .cell-body { position: relative; width: 100%; overflow: hidden; }
72
  .d3-trackio-oblivion .cell-body svg { max-width: 100%; height: auto; display: block; }
73
 
@@ -82,7 +116,7 @@
82
  .d3-trackio-oblivion__header .legend-bottom .legend-title { font-size: 11px; font-weight: 900; letter-spacing: 0.18em; color: var(--obl-cyan); text-transform: uppercase; }
83
  .d3-trackio-oblivion__header .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
84
  .d3-trackio-oblivion__header .legend-bottom .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; color: var(--obl-cyan); }
85
- .d3-trackio-oblivion__header .legend-bottom .swatch { width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--obl-border); display: inline-block; box-shadow: 0 0 12px rgba(127,241,255,.18) inset; }
86
 
87
  /* Hover ghosting */
88
  .d3-trackio-oblivion.hovering .lines path.ghost { opacity: .22; }
@@ -98,12 +132,12 @@
98
  transform: translate(-9999px, -9999px);
99
  pointer-events: none;
100
  padding: 10px 12px;
101
- border-radius: 0;
102
  font-size: 12px;
103
  line-height: 1.35;
104
  background: var(--obl-bg);
105
  color: var(--obl-cyan);
106
- box-shadow: 0 8px 32px rgba(127,241,255,.12), 0 2px 8px rgba(0,0,0,.20);
107
  opacity: .5;
108
  transition: opacity .12s ease;
109
  z-index: 1000;
@@ -112,8 +146,10 @@
112
  .d3-trackio-oblivion .d3-tooltip.is-visible { opacity: 1; }
113
  .d3-trackio-oblivion .d3-tooltip__inner { display: flex; flex-direction: column; gap: 6px; min-width: 220px; text-align: left; }
114
  .d3-trackio-oblivion .d3-tooltip__inner > div:first-child { font-weight: 900; letter-spacing: .18em; text-transform: uppercase; color: var(--obl-cyan); }
115
- .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(2) { font-size: 11px; color: var(--obl-cyan); opacity: .8; display: block; margin-top: -4px; margin-bottom: 2px; letter-spacing: 0.06em; }
116
- .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(n+3) { padding-top: 6px; border-top: 1px solid var(--obl-border); }
 
 
117
  .d3-trackio-oblivion .d3-tooltip__color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; border: 1px solid var(--obl-border); box-shadow: 0 0 10px rgba(127,241,255,.2) inset; }
118
  </style>
119
  <script>
@@ -146,7 +182,12 @@
146
  const corners = document.createElement('div'); corners.className = 'cell-corners'; cell.appendChild(corners);
147
  const inner = document.createElement('div'); inner.className = 'cell-inner'; cell.appendChild(inner);
148
  const header = document.createElement('div'); header.className = 'cell-header';
149
- const title = document.createElement('div'); title.className = 'cell-title'; title.textContent = prettyMetricLabel(titleText); header.appendChild(title); inner.appendChild(header);
 
 
 
 
 
150
 
151
  const body = document.createElement('div'); body.className = 'cell-body'; inner.appendChild(body);
152
  const svg = d3.select(body).append('svg').attr('width','100%').style('display','block');
@@ -157,6 +198,7 @@
157
  const gLines = gRoot.append('g').attr('class','lines');
158
  const gPoints = gRoot.append('g').attr('class','points');
159
  const gHover = gRoot.append('g').attr('class','hover');
 
160
 
161
  // Tooltip
162
  cell.style.position = cell.style.position || 'relative';
@@ -165,10 +207,22 @@
165
 
166
  // Layout & scales
167
  let width = 800, height = 180; const margin = { top: 12, right: 20, bottom: 36, left: 44 };
168
- const xScale = d3.scaleLinear(); const yScale = d3.scaleLinear();
169
- const lineGen = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value));
170
-
171
- function updateLayout(axisLabelY){
 
 
 
 
 
 
 
 
 
 
 
 
172
  const rect = cell.getBoundingClientRect();
173
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
174
  height = 180;
@@ -192,13 +246,24 @@
192
  // dedupe
193
  return Array.from(new Set(arr));
194
  };
195
- const xTicksForced = makeTicks(xScale, 8);
196
- const yTicksForced = makeTicks(yScale, 6);
 
 
 
 
 
 
197
  gAxes
198
  .append('g')
199
  .attr('transform', `translate(0,${innerHeight})`)
200
  .call(
201
- d3.axisBottom(xScale).tickValues(xTicksForced)
 
 
 
 
 
202
  )
203
  .call((g) => {
204
  g.selectAll('path, line')
@@ -210,7 +275,7 @@
210
 
211
  gAxes
212
  .append('g')
213
- .call(d3.axisLeft(yScale).tickValues(yTicksForced))
214
  .call((g) => {
215
  g.selectAll('path, line')
216
  .attr('stroke', 'var(--obl-cyan-dim)');
@@ -246,12 +311,31 @@
246
  if (!isFinite(minStep) || !isFinite(maxStep)) return;
247
  const isAccuracy = /accuracy/i.test(metricKey);
248
  const axisLabelY = prettyMetricLabel(metricKey);
249
- xScale.domain([minStep, maxStep]); if (isAccuracy) yScale.domain([0,1]).nice(); else yScale.domain([minVal, maxVal]).nice();
250
- const { innerWidth, innerHeight, xTicksForced, yTicksForced } = updateLayout(axisLabelY);
 
 
 
 
 
 
 
 
 
 
251
 
252
  // Grid as small dots at intersections of y ticks × step positions
 
253
  const gridPoints = [];
254
- xTicksForced.forEach(s => { yTicksForced.forEach(t => { gridPoints.push({ sx: s, ty: t }); }); });
 
 
 
 
 
 
 
 
255
  gGrid.selectAll('circle.grid-dot')
256
  .data(gridPoints)
257
  .join('circle')
@@ -275,14 +359,40 @@
275
  // Points
276
  const allPts = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
277
  const ptsSel = gPoints.selectAll('circle.pt').data(allPts, d=>`${d.run}-${d.step}`);
278
- ptsSel.enter().append('circle').attr('class','pt').attr('r', 2).attr('fill', d=>d.color).attr('fill-opacity', 0.65).attr('stroke','none').attr('cx', d=>xScale(d.step)).attr('cy', d=>yScale(d.value)).merge(ptsSel).transition().duration(150).attr('cx', d=>xScale(d.step)).attr('cy', d=>yScale(d.value));
 
 
 
 
 
 
 
 
 
 
279
  ptsSel.exit().remove();
 
 
280
 
281
  // Hover
282
  gHover.selectAll('*').remove();
283
  const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
284
  const hoverLine = gHover.append('line').style('stroke','var(--obl-cyan)').attr('stroke-opacity', 0.35).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
285
- function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const nearest = steps.reduce((best,s)=> Math.abs(s - xScale.invert(mx)) < Math.abs(best - xScale.invert(mx)) ? s : best, steps[0]); const xpx = xScale(nearest); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); let html = `<div>${prettyMetricLabel(metricKey)}</div><div>Step ${nearest}</div>`; const entries = series.map(s=>{ const m = new Map(s.values.map(v=>[v.step, v])); const pt = m.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null); entries.sort((a,b)=> (a.pt.value - b.pt.value)); const fmt = (vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4)); entries.forEach(e => { const err = (e.pt.stderr!=null && isFinite(e.pt.stderr) && e.pt.stderr>0) ? ` ± ${fmt(e.pt.stderr)}` : ''; html += `<div style=\"display:flex;align-items:center;gap:8px;white-space:nowrap;\"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`; }); tipInner.innerHTML = html; const cssVars = getComputedStyle(cell); const offx = Math.max(0, parseFloat(cssVars.getPropertyValue('--tip-offset-x')) || 0); const offy = Math.max(0, parseFloat(cssVars.getPropertyValue('--tip-offset-y')) || 0); const cellRect = cell.getBoundingClientRect(); const cx = (ev && ev.clientX != null) ? ev.clientX : (cellRect.left + mx); const cy = (ev && ev.clientY != null) ? ev.clientY : (cellRect.top + my); const x = cx - cellRect.left + offx; const y = cy - cellRect.top + offy; tip.classList.add('is-visible'); tip.style.transform=`translate(${x}px, ${y}px)`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  // Animate points at the hovered step to grow slightly
287
  try {
288
  gPoints.selectAll('circle.pt')
@@ -290,7 +400,7 @@
290
  .attr('r', d => (d && d.step === nearest ? 4 : 2));
291
  } catch(_) {}
292
  }
293
- function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.classList.remove('is-visible'); tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); try { gPoints.selectAll('circle.pt').transition().duration(150).ease(d3.easeCubicOut).attr('r', 2); } catch(_) {} }, 100); }
294
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
295
  }
296
 
@@ -298,7 +408,7 @@
298
  }
299
 
300
  const bootstrap = () => {
301
- const scriptEl = THIS_SCRIPT; let host = null; const header = document.createElement('div'); header.className = 'd3-trackio-oblivion__header'; const legend = document.createElement('div'); legend.className = 'legend-bottom'; legend.innerHTML = '<div class="legend-title">Legend</div><div class="items"></div>'; header.appendChild(legend);
302
  if (scriptEl && scriptEl.parentElement && scriptEl.parentElement.querySelector) host = scriptEl.parentElement.querySelector('.d3-trackio-oblivion'); if (!host) { let sib = scriptEl && scriptEl.previousElementSibling; while (sib && !(sib.classList && sib.classList.contains('d3-trackio-oblivion'))) { sib = sib.previousElementSibling; } host = sib || null; }
303
  if (!host) { host = document.querySelector('.d3-trackio-oblivion'); }
304
  if (!host) return; if (host.dataset && host.dataset.mounted==='true') return; if (host.dataset) host.dataset.mounted='true';
 
13
  /* Futuristic "Oblivion"-inspired styling */
14
  .d3-trackio-oblivion { position: relative;
15
  --cell-gap: 0px;
16
+ /* Default: light/neutral theme */
17
+ --obl-cyan: #2f343b;
18
+ --obl-cyan-dim: rgba(47,52,59,.28);
19
+ --obl-accent: #1a1d21;
20
+ --obl-bg: rgba(0,0,0,0.04);
21
+ --obl-border: rgba(47,52,59,.22);
22
+ --ghost-obl-border: rgba(47,52,59,.06);
23
+ --obl-glow: 0 0 0 1px rgba(0,0,0,.05), 0 8px 40px rgba(0,0,0,.06);
24
+ /* Engraved separator colors (light theme) */
25
+ --engrave-light: rgba(255,255,255,.15);
26
+ --engrave-dark: rgba(0,0,0,.10);
27
+ background: transparent;
28
+ --corner-inset: 6px;
29
+ --hud-gap: 10px;
30
+ --hud-corner-size: 8px;
31
+ /* Chart background gradient as a variable for easy theming */
32
+ --hud-bg-gradient: radial-gradient(1200px 200px at 20% -10%, rgba(0,0,0,.05), transparent 80%), radial-gradient(900px 200px at 80% 110%, rgba(0,0,0,.05), transparent 80%);
33
+ /* Tooltip offset (bottom-right of cursor) */
34
+ --tip-offset-x: 10px;
35
+ --tip-offset-y: 10px;
36
+ padding: var(--hud-gap);
37
+ --z-tooltip: 50;
38
+ --z-overlay: 99999999;
39
+ z-index: var(--z-tooltip);
40
+ font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
41
  }
42
  .d3-trackio-oblivion * { font-family: inherit;
43
  }
44
+ /* Dark mode: cyan/Oblivion theme */
45
+ [data-theme="dark"] .d3-trackio-oblivion {
46
+ --obl-cyan: #7FF1FF;
47
+ --obl-cyan-dim: rgba(127,241,255,.25);
48
+ --obl-accent: #8BF5FF;
49
+ --obl-bg: rgba(255,255,255,0.06);
50
+ --obl-border: rgba(127,241,255,.25);
51
+ --ghost-obl-border: rgba(127,241,255,.02);
52
+ --obl-glow: 0 0 0 1px rgba(127,241,255,.35), 0 8px 40px rgba(127,241,255,.12);
53
+ /* Richer cyan gradients for the classic Oblivion blue feel */
54
+ --hud-bg-gradient:
55
+ radial-gradient(1400px 260px at 20% -10%, rgba(127,241,255,.065), transparent 80%),
56
+ radial-gradient(1100px 240px at 80% 110%, rgba(127,241,255,.06), transparent 80%),
57
+ linear-gradient(180deg, rgba(127,241,255,.035), rgba(127,241,255,0) 45%);
58
+ /* Engraved separator colors (dark theme) */
59
+ --engrave-light: rgba(127,241,255,.05);
60
+ --engrave-dark: rgba(0,0,0,.15);
61
+ background: #0f1115;
62
+ }
63
+ /* Tooltip shadows: neutral by default, bluish tint in dark */
64
+ .d3-trackio-oblivion .d3-tooltip { box-shadow: 0 8px 32px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.06); }
65
+ [data-theme="dark"] .d3-trackio-oblivion .d3-tooltip { box-shadow: 0 8px 32px rgba(127,241,255,.05), 0 2px 8px rgba(0,0,0,.10); }
66
 
67
  .d3-trackio-oblivion__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: --cell-gap; }
68
  @media (max-width: 980px) { .d3-trackio-oblivion__grid { grid-template-columns: 1fr; } }
 
94
  linear-gradient(var(--obl-cyan), var(--obl-cyan)) bottom right / var(--hud-corner-size) 1px no-repeat,
95
  linear-gradient(var(--obl-cyan), var(--obl-cyan)) bottom right / 1px var(--hud-corner-size) no-repeat; opacity:.85; }
96
  .d3-trackio-oblivion .cell-inner { position: relative; z-index: 2; padding: var(--hud-corner-size) 12px 10px var(--hud-gap); display:flex; flex-direction:column; }
97
+ .d3-trackio-oblivion .cell-header { padding: 10px 6px 10px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
98
  .d3-trackio-oblivion .cell-title { position: relative; font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--obl-cyan); padding-left: 14px; }
99
  .d3-trackio-oblivion .cell-title:before { content:""; position:absolute; left:0; top:50%; transform:translateY(-50%); width: 6px; height: 6px; background: var(--obl-cyan); border: 1px solid var(--obl-border); box-shadow: 0 0 10px rgba(127,241,255,.25) inset; opacity: .5; }
100
+ /* Fullscreen button (icon only, no behavior here) */
101
+ .d3-trackio-oblivion .cell-action { padding: 0 !important; margin-left: auto; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: 0; background: transparent; color: var(--obl-cyan); opacity: .7; cursor: pointer; z-index: 4; }
102
+ .d3-trackio-oblivion .cell-action:hover { opacity: 1; }
103
+ .d3-trackio-oblivion .cell-action svg { width: 22px; height: 22px; opacity: .2; margin-left: 5px; }
104
+ .d3-trackio-oblivion .cell-action svg, .d3-trackio-oblivion .cell-action svg path { fill: currentColor; stroke: none; }
105
  .d3-trackio-oblivion .cell-body { position: relative; width: 100%; overflow: hidden; }
106
  .d3-trackio-oblivion .cell-body svg { max-width: 100%; height: auto; display: block; }
107
 
 
116
  .d3-trackio-oblivion__header .legend-bottom .legend-title { font-size: 11px; font-weight: 900; letter-spacing: 0.18em; color: var(--obl-cyan); text-transform: uppercase; }
117
  .d3-trackio-oblivion__header .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
118
  .d3-trackio-oblivion__header .legend-bottom .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; color: var(--obl-cyan); }
119
+ .d3-trackio-oblivion__header .legend-bottom .swatch { width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--ghost-obl-border); display: inline-block; box-shadow: 0 0 12px rgba(127,241,255,.18) inset; }
120
 
121
  /* Hover ghosting */
122
  .d3-trackio-oblivion.hovering .lines path.ghost { opacity: .22; }
 
132
  transform: translate(-9999px, -9999px);
133
  pointer-events: none;
134
  padding: 10px 12px;
135
+ border-radius: 8px;
136
  font-size: 12px;
137
  line-height: 1.35;
138
  background: var(--obl-bg);
139
  color: var(--obl-cyan);
140
+ box-shadow: 0 8px 32px rgba(127,241,255,.05), 0 2px 8px rgba(0,0,0,.10);
141
  opacity: .5;
142
  transition: opacity .12s ease;
143
  z-index: 1000;
 
146
  .d3-trackio-oblivion .d3-tooltip.is-visible { opacity: 1; }
147
  .d3-trackio-oblivion .d3-tooltip__inner { display: flex; flex-direction: column; gap: 6px; min-width: 220px; text-align: left; }
148
  .d3-trackio-oblivion .d3-tooltip__inner > div:first-child { font-weight: 900; letter-spacing: .18em; text-transform: uppercase; color: var(--obl-cyan); }
149
+ .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(2) { font-size: 11px; color: var(--obl-cyan); opacity: .4; display: block; margin-top: -4px; margin-bottom: 2px; letter-spacing: 0.06em; }
150
+ .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(n+3) { position: relative; padding-top: 10px; margin-top: 6px; }
151
+ .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(n+3)::before { content:""; position:absolute; left:0; right:0; top:0; height:1px; background: var(--engrave-dark); }
152
+ .d3-trackio-oblivion .d3-tooltip__inner > div:nth-child(n+3)::after { content:""; position:absolute; left:0; right:0; top:1px; height:1px; background: var(--engrave-light); }
153
  .d3-trackio-oblivion .d3-tooltip__color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; border: 1px solid var(--obl-border); box-shadow: 0 0 10px rgba(127,241,255,.2) inset; }
154
  </style>
155
  <script>
 
182
  const corners = document.createElement('div'); corners.className = 'cell-corners'; cell.appendChild(corners);
183
  const inner = document.createElement('div'); inner.className = 'cell-inner'; cell.appendChild(inner);
184
  const header = document.createElement('div'); header.className = 'cell-header';
185
+ const title = document.createElement('div'); title.className = 'cell-title'; title.textContent = prettyMetricLabel(titleText); header.appendChild(title);
186
+ // Fullscreen icon button (no click handler here)
187
+ const fsBtn = document.createElement('button'); fsBtn.className = 'cell-action cell-action--fullscreen'; fsBtn.type = 'button'; fsBtn.title = 'Fullscreen'; fsBtn.setAttribute('aria-label', 'Open fullscreen');
188
+ fsBtn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"/></svg>';
189
+ header.appendChild(fsBtn);
190
+ inner.appendChild(header);
191
 
192
  const body = document.createElement('div'); body.className = 'cell-body'; inner.appendChild(body);
193
  const svg = d3.select(body).append('svg').attr('width','100%').style('display','block');
 
198
  const gLines = gRoot.append('g').attr('class','lines');
199
  const gPoints = gRoot.append('g').attr('class','points');
200
  const gHover = gRoot.append('g').attr('class','hover');
201
+ const host = cell.closest('.d3-trackio-oblivion');
202
 
203
  // Tooltip
204
  cell.style.position = cell.style.position || 'relative';
 
207
 
208
  // Layout & scales
209
  let width = 800, height = 180; const margin = { top: 12, right: 20, bottom: 36, left: 44 };
210
+ let xScale = d3.scaleLinear(); const yScale = d3.scaleLinear();
211
+ const lineGen = d3.line().x(d => xScale(0)).y(d => yScale(d.value));
212
+
213
+ // Generic number abbreviation for axis ticks (K/M/B) with up to 2 decimals
214
+ const formatAbbrev = (value) => {
215
+ const num = Number(value);
216
+ if (!Number.isFinite(num)) return String(value);
217
+ const abs = Math.abs(num);
218
+ const trim2 = (n) => Number(n).toFixed(2).replace(/\.?0+$/, '');
219
+ if (abs >= 1e9) return `${trim2(num / 1e9)}B`;
220
+ if (abs >= 1e6) return `${trim2(num / 1e6)}M`;
221
+ if (abs >= 1e3) return `${trim2(num / 1e3)}K`;
222
+ return trim2(num);
223
+ };
224
+
225
+ function updateLayout(axisLabelY, xTicksArg){
226
  const rect = cell.getBoundingClientRect();
227
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
228
  height = 180;
 
246
  // dedupe
247
  return Array.from(new Set(arr));
248
  };
249
+ const xTicksForced = (Array.isArray(xTicksArg) && xTicksArg.length)
250
+ ? Array.from({ length: xTicksArg.length }, (_, i) => i)
251
+ : makeTicks(xScale, 8);
252
+ const yCount = Math.max(2, Math.min(6, xTicksForced.length));
253
+ const yDom = yScale.domain();
254
+ const yTicksForced = (yCount <= 2)
255
+ ? [yDom[0], yDom[1]]
256
+ : Array.from({ length: yCount }, (_, i) => yDom[0] + ((yDom[1] - yDom[0]) * (i / (yCount - 1))));
257
  gAxes
258
  .append('g')
259
  .attr('transform', `translate(0,${innerHeight})`)
260
  .call(
261
+ d3.axisBottom(xScale)
262
+ .tickValues(xTicksForced)
263
+ .tickFormat((i) => {
264
+ const val = (Array.isArray(xTicksArg) && xTicksArg[i] != null ? xTicksArg[i] : i);
265
+ return formatAbbrev(val);
266
+ })
267
  )
268
  .call((g) => {
269
  g.selectAll('path, line')
 
275
 
276
  gAxes
277
  .append('g')
278
+ .call(d3.axisLeft(yScale).tickValues(yTicksForced).tickFormat((v) => formatAbbrev(v)))
279
  .call((g) => {
280
  g.selectAll('path, line')
281
  .attr('stroke', 'var(--obl-cyan-dim)');
 
311
  if (!isFinite(minStep) || !isFinite(maxStep)) return;
312
  const isAccuracy = /accuracy/i.test(metricKey);
313
  const axisLabelY = prettyMetricLabel(metricKey);
314
+ if (isAccuracy) yScale.domain([0,1]).nice(); else yScale.domain([minVal, maxVal]).nice();
315
+ // Compute unique x steps and build index mapping
316
+ const rawSteps = [];
317
+ runs.forEach(r => (metricData[r]||[]).forEach(pt => rawSteps.push(pt.step)));
318
+ const hoverSteps = Array.from(new Set(rawSteps)).sort((a,b)=>a-b);
319
+ const stepIndex = new Map(hoverSteps.map((s,i)=>[s,i]));
320
+ const indices = hoverSteps.map((_, i) => i);
321
+ // Linear index scale → ticks at edges, equal spacing
322
+ xScale = d3.scaleLinear().domain([0, Math.max(0, indices.length - 1)]);
323
+ // Update line generator X accessor to use index directly
324
+ lineGen.x(d => xScale(stepIndex.get(d.step)));
325
+ const { innerWidth, innerHeight, xTicksForced, yTicksForced } = updateLayout(axisLabelY, hoverSteps);
326
 
327
  // Grid as small dots at intersections of y ticks × step positions
328
+ // Exclude dots that fall on the origin axes lines (left Y-axis and bottom X-axis)
329
  const gridPoints = [];
330
+ const yDomAll = yScale.domain();
331
+ const yMin = Array.isArray(yDomAll) ? yDomAll[0] : null;
332
+ xTicksForced.forEach(i => {
333
+ yTicksForced.forEach(t => {
334
+ if (i !== 0 && (yMin == null || t !== yMin)) {
335
+ gridPoints.push({ sx: i, ty: t });
336
+ }
337
+ });
338
+ });
339
  gGrid.selectAll('circle.grid-dot')
340
  .data(gridPoints)
341
  .join('circle')
 
359
  // Points
360
  const allPts = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
361
  const ptsSel = gPoints.selectAll('circle.pt').data(allPts, d=>`${d.run}-${d.step}`);
362
+ ptsSel.enter().append('circle')
363
+ .attr('class','pt')
364
+ .attr('r', 2)
365
+ .attr('fill', d=>d.color)
366
+ .attr('stroke','none')
367
+ .attr('cx', d=> xScale(stepIndex.get(d.step)))
368
+ .attr('cy', d=>yScale(d.value))
369
+ .merge(ptsSel)
370
+ .transition().duration(150)
371
+ .attr('cx', d=> xScale(stepIndex.get(d.step)))
372
+ .attr('cy', d=>yScale(d.value));
373
  ptsSel.exit().remove();
374
+ // Steps used for hover snapping (unique data steps)
375
+ // already computed above as hoverSteps
376
 
377
  // Hover
378
  gHover.selectAll('*').remove();
379
  const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
380
  const hoverLine = gHover.append('line').style('stroke','var(--obl-cyan)').attr('stroke-opacity', 0.35).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
381
+ // External hover handlers
382
+ cell.__showExternalStep = (stepVal) => {
383
+ if (stepVal == null) { hoverLine.style('display','none'); return; }
384
+ const idx = stepIndex.get(stepVal);
385
+ if (idx == null) { hoverLine.style('display','none'); return; }
386
+ const xpx = xScale(idx);
387
+ hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null);
388
+ };
389
+ cell.__clearExternalStep = () => { hoverLine.style('display','none'); };
390
+ if (!cell.__syncAttached && host) {
391
+ host.addEventListener('trackio-hover-step', (ev) => { const d = ev && ev.detail; if (!d) return; if (cell.__showExternalStep) cell.__showExternalStep(d.step); });
392
+ host.addEventListener('trackio-hover-clear', () => { if (cell.__clearExternalStep) cell.__clearExternalStep(); });
393
+ cell.__syncAttached = true;
394
+ }
395
+ function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const idx = Math.round(Math.max(0, Math.min(hoverSteps.length-1, xScale.invert(mx)))); const nearest = hoverSteps[idx]; const xpx = xScale(idx); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-step', { detail: { step: nearest } })); } catch(_){} } let html = `<div>Step ${formatAbbrev(nearest)}</div><div>${prettyMetricLabel(metricKey)}</div>`; const entries = series.map(s=>{ const m = new Map(s.values.map(v=>[v.step, v])); const pt = m.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null); entries.sort((a,b)=> (a.pt.value - b.pt.value)); const fmt = (vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4)); entries.forEach(e => { html += `<div style=\"display:flex;align-items:center;gap:8px;white-space:nowrap;\"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}</span></div>`; }); tipInner.innerHTML = html; const cssVars = getComputedStyle(cell); const offx = Math.max(0, parseFloat(cssVars.getPropertyValue('--tip-offset-x')) || 0); const offy = Math.max(0, parseFloat(cssVars.getPropertyValue('--tip-offset-y')) || 0); const cellRect = cell.getBoundingClientRect(); const cx = (ev && ev.clientX != null) ? ev.clientX : (cellRect.left + mx); const cy = (ev && ev.clientY != null) ? ev.clientY : (cellRect.top + my); const x = cx - cellRect.left + offx; const y = cy - cellRect.top + offy; tip.classList.add('is-visible'); tip.style.transform=`translate(${x}px, ${y}px)`;
396
  // Animate points at the hovered step to grow slightly
397
  try {
398
  gPoints.selectAll('circle.pt')
 
400
  .attr('r', d => (d && d.step === nearest ? 4 : 2));
401
  } catch(_) {}
402
  }
403
+ function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.classList.remove('is-visible'); tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-clear')); } catch(_){} } try { gPoints.selectAll('circle.pt').transition().duration(150).ease(d3.easeCubicOut).attr('r', 2); } catch(_) {} }, 100); }
404
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
405
  }
406
 
 
408
  }
409
 
410
  const bootstrap = () => {
411
+ const scriptEl = THIS_SCRIPT; let host = null; const header = document.createElement('div'); header.className = 'd3-trackio-oblivion__header'; const legend = document.createElement('div'); legend.className = 'legend-bottom'; legend.innerHTML = '<div class="legend-title">Runs</div><div class="items"></div>'; header.appendChild(legend);
412
  if (scriptEl && scriptEl.parentElement && scriptEl.parentElement.querySelector) host = scriptEl.parentElement.querySelector('.d3-trackio-oblivion'); if (!host) { let sib = scriptEl && scriptEl.previousElementSibling; while (sib && !(sib.classList && sib.classList.contains('d3-trackio-oblivion'))) { sib = sib.previousElementSibling; } host = sib || null; }
413
  if (!host) { host = document.querySelector('.d3-trackio-oblivion'); }
414
  if (!host) return; if (host.dataset && host.dataset.mounted==='true') return; if (host.dataset) host.dataset.mounted='true';
app/src/content/embeds/d3-trackio.html CHANGED
@@ -9,7 +9,18 @@
9
  <noscript>JavaScript is required to render this chart.</noscript>
10
  </div>
11
  <style>
12
- .d3-trackio { position: relative; }
 
 
 
 
 
 
 
 
 
 
 
13
  .d3-trackio__grid {
14
  display: grid;
15
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -30,7 +41,6 @@
30
  }
31
  .d3-trackio .cell-header {
32
  padding: 8px 10px;
33
- border-bottom: 1px solid var(--border-color);
34
  display: flex;
35
  align-items: center;
36
  justify-content: space-between;
@@ -42,6 +52,67 @@
42
  color: var(--text-color);
43
  text-transform: none;
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  .d3-trackio .cell-body { position: relative; width: 100%; overflow: hidden; }
46
  .d3-trackio .cell-body svg { max-width: 100%; height: auto; display: block; }
47
 
@@ -51,22 +122,24 @@
51
  .d3-trackio .axes text { fill: var(--tick-color); }
52
  .d3-trackio .grid line { stroke: var(--grid-color); }
53
 
54
- /* Global header (legend) below the grid for consistency */
55
  .d3-trackio__header {
56
  display: flex;
57
  align-items: flex-start;
58
- justify-content: flex-start;
59
  gap: 12px;
60
- margin: 8px 0 0 0;
61
  flex-wrap: wrap;
 
62
  }
63
  .d3-trackio__header .legend-bottom {
64
  display: flex;
65
  flex-direction: column;
66
- align-items: flex-start;
67
  gap: 6px;
68
  font-size: 12px;
69
  color: var(--text-color);
 
70
  }
71
  .d3-trackio__header .legend-bottom .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); }
72
  .d3-trackio__header .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
@@ -81,7 +154,7 @@
81
 
82
  /* Tooltip styling aligned with other embeds */
83
  .d3-trackio .d3-tooltip {
84
- z-index: 20;
85
  backdrop-filter: saturate(1.12) blur(8px);
86
  }
87
  .d3-trackio .d3-tooltip__inner { display: flex; flex-direction: column; gap: 6px; min-width: 220px; }
@@ -129,6 +202,8 @@
129
  const d3 = window.d3;
130
  const metricKey = cell.getAttribute('data-metric');
131
  const titleText = cell.getAttribute('data-title') || metricKey;
 
 
132
 
133
  // Header
134
  const header = document.createElement('div'); header.className = 'cell-header';
@@ -146,6 +221,9 @@
146
  const gPoints = gRoot.append('g').attr('class','points');
147
  const gHover = gRoot.append('g').attr('class','hover');
148
 
 
 
 
149
  // Tooltip
150
  cell.style.position = cell.style.position || 'relative';
151
  let tip = cell.querySelector('.d3-tooltip'); let tipInner; let hideTipTimer = null;
@@ -160,16 +238,16 @@
160
  } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
161
 
162
  // Layout & scales
163
- let width = 800, height = 200; const margin = { top: 12, right: 20, bottom: 36, left: 56 };
164
  const xScale = d3.scaleLinear();
165
  const yScale = d3.scaleLinear();
166
  const lineGen = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value));
167
 
168
- function updateLayout(axisLabelY){
169
  const rect = cell.getBoundingClientRect();
170
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
171
- // Hauteur fixe (encore plus basse) pour uniformiser les 5 graphiques
172
- height = 200;
173
  svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet');
174
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
175
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
@@ -183,39 +261,67 @@
183
 
184
  // Axes
185
  gAxes.selectAll('*').remove();
186
- let xAxis = d3.axisBottom(xScale).tickSizeOuter(0).ticks(8).tickFormat(formatK);
187
- let yAxis = d3.axisLeft(yScale).ticks(6);
188
- gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(xAxis).call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); });
189
- gAxes.append('g').call(yAxis).call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); });
190
-
191
- // Axis labels
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  gAxes.append('text')
193
  .attr('class', 'x-axis-label')
194
  .attr('x', innerWidth / 2)
195
- .attr('y', innerHeight + Math.max(20, Math.min(36, margin.bottom - 10)))
196
  .attr('fill', 'var(--text-color)')
197
  .attr('text-anchor', 'middle')
198
- .style('font-size', '12px')
199
- .style('font-weight', '700')
 
 
 
200
  .text('Steps');
201
- gAxes.append('text')
202
- .attr('class', 'y-axis-label')
203
- .attr('transform', 'rotate(-90)')
204
- .attr('x', -innerHeight / 2)
205
- .attr('y', -Math.max(16, Math.min(28, margin.left - 8) + 10))
206
- .attr('fill', 'var(--text-color)')
207
- .attr('text-anchor', 'middle')
208
- .style('font-size', '12px')
209
- .style('font-weight', '700')
210
- .text(axisLabelY || 'Value');
211
 
212
- return { innerWidth, innerHeight };
213
  }
214
 
215
- const formatK = (v) => {
216
- const abs = Math.abs(v);
217
- if (abs >= 1000) { const n = v / 1000; const s = d3.format('.1f')(n); return (s.endsWith('.0') ? s.slice(0, -2) : s) + 'k'; }
218
- return d3.format('d')(v);
 
 
 
 
 
 
219
  };
220
 
221
  function render(metricData, colorForRun) {
@@ -242,17 +348,21 @@
242
  runs.forEach(r => { (metricData[r]||[]).forEach(pt => { minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); minVal = Math.min(minVal, pt.value); maxVal = Math.max(maxVal, pt.value); }); });
243
  const isAccuracy = /accuracy/i.test(metricKey);
244
  const axisLabelY = prettyMetricLabel(metricKey);
245
- xScale.domain([minStep, maxStep]);
246
  if (isAccuracy) yScale.domain([0, 1]).nice(); else yScale.domain([minVal, maxVal]).nice();
 
 
 
 
 
 
 
 
247
 
248
- const { innerWidth, innerHeight } = updateLayout(axisLabelY);
249
 
250
- // Vertical grid lines at each available step (same visibility as horizontal)
251
- const stepSetForGrid = new Set();
252
- runs.forEach(r => { (metricData[r]||[]).forEach(pt => stepSetForGrid.add(pt.step)); });
253
- const stepsForGrid = Array.from(stepSetForGrid).sort((a,b)=>a-b);
254
  gGrid.selectAll('line.vstep')
255
- .data(stepsForGrid)
256
  .join(
257
  enter => enter.append('line').attr('class','vstep')
258
  .attr('y1', 0).attr('y2', innerHeight)
@@ -264,62 +374,159 @@
264
  exit => exit.remove()
265
  );
266
 
267
- // Shaded areas from stderr if available
268
  gAreas.selectAll('*').remove();
269
- runs.forEach(r => {
270
- const vals = (metricData[r]||[]).slice().sort((a,b)=>a.step-b.step);
271
- const withErr = vals.filter(v => v && v.stderr != null && isFinite(v.stderr) && v.stderr > 0 && isFinite(v.value));
272
- if (!withErr.length) return;
273
- const upper = withErr.map(d => [xScale(d.step), yScale(d.value + d.stderr)]);
274
- const lower = withErr.slice().reverse().map(d => [xScale(d.step), yScale(d.value - d.stderr)]);
275
- const coords = upper.concat(lower);
276
- const pathData = d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveLinearClosed)(coords);
277
- gAreas.append('path')
278
- .attr('class','area')
279
- .attr('data-run', r)
280
- .attr('d', pathData)
281
- .attr('fill', colorForRun(r))
282
- .attr('opacity', 0.15)
283
- .attr('stroke', 'none');
284
- });
285
 
286
  // Lines
287
  const series = runs.map(r => ({ run: r, color: colorForRun(r), values: (metricData[r]||[]).slice().sort((a,b)=>a.step-b.step) }));
288
  const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
289
  paths.enter().append('path').attr('class','run-line').attr('data-run', d=>d.run).attr('fill','none').attr('stroke-width', 1.5).attr('opacity', 0.9)
290
  .attr('stroke', d=>d.color).attr('d', d=>lineGen(d.values));
291
- paths.transition().duration(260).attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=>lineGen(d.values));
 
 
 
 
292
  paths.exit().remove();
293
 
294
  // Points
295
  const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
296
  const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`);
297
  ptsSel.enter().append('circle').attr('class','pt').attr('data-run', d=>d.run).attr('r', 2).attr('fill', d=>d.color).attr('fill-opacity', 0.6)
298
- .attr('stroke', 'none').attr('cx', d=>xScale(d.step)).attr('cy', d=>yScale(d.value))
299
  .merge(ptsSel)
300
- .transition().duration(150)
301
- .attr('cx', d=>xScale(d.step)).attr('cy', d=>yScale(d.value));
 
 
 
302
  ptsSel.exit().remove();
303
 
304
  // Hover
305
  gHover.selectAll('*').remove();
306
  const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
307
  const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
308
- const stepSet = new Set(); series.forEach(s=>s.values.forEach(v=>stepSet.add(v.step))); const steps = Array.from(stepSet).sort((a,b)=>a-b);
309
- function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const nearest = steps.reduce((best,s)=> Math.abs(s - xScale.invert(mx)) < Math.abs(best - xScale.invert(mx)) ? s : best, steps[0]); const xpx = xScale(nearest); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null);
310
- let html = `<div><strong>${prettyMetricLabel(metricKey)}</strong></div><div><strong>step</strong> ${formatK(nearest)}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  const entries = series.map(s=>{ const map = new Map(s.values.map(v=>[v.step, v])); const pt = map.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null);
312
  entries.sort((a,b)=> (a.pt.value - b.pt.value));
313
  const fmt = (vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4));
314
  entries.forEach(e => {
315
- const err = (e.pt.stderr!=null && isFinite(e.pt.stderr) && e.pt.stderr>0) ? ` ± ${fmt(e.pt.stderr)}` : '';
316
- html += `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`;
317
  });
318
- tipInner.innerHTML = html; const offsetX=12, offsetY=12; tip.style.opacity='1'; tip.style.transform=`translate(${Math.round(mx+offsetX+margin.left)}px, ${Math.round(my+offsetY+margin.top)}px)`; }
319
- function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); }, 100); }
 
 
 
 
 
 
 
320
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
321
  }
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  return { metricKey, render };
324
  }
325
 
@@ -339,11 +546,12 @@
339
  if (!host) return;
340
  if (host.dataset && host.dataset.mounted === 'true') return; if (host.dataset) host.dataset.mounted = 'true';
341
 
342
- // Build global header (legend)
343
  const header = document.createElement('div'); header.className = 'd3-trackio__header';
344
- const legend = document.createElement('div'); legend.className = 'legend-bottom'; legend.innerHTML = '<div class="legend-title">Legend</div><div class="items"></div>';
345
  header.appendChild(legend);
346
- host.appendChild(header);
 
347
 
348
  const cells = Array.from(host.querySelectorAll('.cell'));
349
  if (!cells.length) return;
@@ -457,6 +665,7 @@
457
 
458
  // Resize handling
459
  const rerender = () => { instances.forEach(inst => inst.render(dataByMetric.get(inst.metricKey)||{}, colorForRun)); };
 
460
  if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(host); } else { window.addEventListener('resize', rerender); }
461
 
462
  // Legend hover ghosting across all cells
 
9
  <noscript>JavaScript is required to render this chart.</noscript>
10
  </div>
11
  <style>
12
+ .d3-trackio { position: relative; --z-tooltip: 50; --z-overlay: 99999999;
13
+ /* Softer chart theming (light) */
14
+ --axis-color: rgba(0,0,0,.28);
15
+ --tick-color: rgba(0,0,0,.62);
16
+ --grid-color: rgba(0,0,0,.10);
17
+ }
18
+ /* Softer chart theming (dark) */
19
+ [data-theme="dark"] .d3-trackio {
20
+ --axis-color: rgba(255,255,255,.28);
21
+ --tick-color: rgba(255,255,255,.68);
22
+ --grid-color: rgba(255,255,255,.10);
23
+ }
24
  .d3-trackio__grid {
25
  display: grid;
26
  grid-template-columns: repeat(2, minmax(0, 1fr));
 
41
  }
42
  .d3-trackio .cell-header {
43
  padding: 8px 10px;
 
44
  display: flex;
45
  align-items: center;
46
  justify-content: space-between;
 
52
  color: var(--text-color);
53
  text-transform: none;
54
  }
55
+ .d3-trackio .cell-action {
56
+ margin-left: auto;
57
+ display: inline-flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ width: 36px;
61
+ height: 36px;
62
+ border: 0;
63
+ background: transparent;
64
+ color: var(--text-color);
65
+ opacity: .8;
66
+ cursor: pointer;
67
+ }
68
+ .d3-trackio .cell-action:hover { opacity: 1; }
69
+ .d3-trackio .cell-action svg { width: 28px; height: 28px; }
70
+ .d3-trackio .cell-action svg, .d3-trackio .cell-action svg path { fill: var(--text-color); stroke: none; }
71
+
72
+ /* Fullscreen overlay */
73
+ .d3-trackio__overlay {
74
+ position: fixed;
75
+ inset: 0;
76
+ background: rgba(0,0,0,.48);
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ z-index: var(--z-overlay);
81
+ opacity: 0;
82
+ pointer-events: none;
83
+ transition: opacity .2s ease;
84
+ }
85
+ .d3-trackio__overlay.is-open { opacity: 1; pointer-events: auto; }
86
+ .d3-trackio__modal {
87
+ position: relative;
88
+ width: min(92vw, 1200px);
89
+ height: min(92vh, 900px);
90
+ display: flex;
91
+ align-items: stretch;
92
+ justify-content: stretch;
93
+ transform: scale(.96);
94
+ transition: transform .2s ease;
95
+ }
96
+ .d3-trackio__overlay.is-open .d3-trackio__modal { transform: scale(1); }
97
+ .d3-trackio__modal .cell { width: 100%; height: 100%; }
98
+ .d3-trackio__modal .cell .cell-body { height: calc(100% - 44px); }
99
+ /* Conserver le ratio pour éviter les décalages points/ticks lors du zoom */
100
+ .d3-trackio__modal .cell .cell-body svg { width: 100% !important; height: auto !important; }
101
+ .d3-trackio__modal .cell .cell-action { display: none; }
102
+ .d3-trackio__modal-close {
103
+ position: absolute;
104
+ top: 10px;
105
+ right: 10px;
106
+ background: var(--surface-bg);
107
+ border: 1px solid var(--border-color);
108
+ color: var(--text-color);
109
+ width: 28px;
110
+ height: 28px;
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ cursor: pointer;
115
+ }
116
  .d3-trackio .cell-body { position: relative; width: 100%; overflow: hidden; }
117
  .d3-trackio .cell-body svg { max-width: 100%; height: auto; display: block; }
118
 
 
122
  .d3-trackio .axes text { fill: var(--tick-color); }
123
  .d3-trackio .grid line { stroke: var(--grid-color); }
124
 
125
+ /* Global header (legend) above the grid and centered */
126
  .d3-trackio__header {
127
  display: flex;
128
  align-items: flex-start;
129
+ justify-content: center;
130
  gap: 12px;
131
+ margin: 0 0 10px 0;
132
  flex-wrap: wrap;
133
+ width: 100%;
134
  }
135
  .d3-trackio__header .legend-bottom {
136
  display: flex;
137
  flex-direction: column;
138
+ align-items: center;
139
  gap: 6px;
140
  font-size: 12px;
141
  color: var(--text-color);
142
+ text-align: center;
143
  }
144
  .d3-trackio__header .legend-bottom .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); }
145
  .d3-trackio__header .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
 
154
 
155
  /* Tooltip styling aligned with other embeds */
156
  .d3-trackio .d3-tooltip {
157
+ z-index: var(--z-tooltip);
158
  backdrop-filter: saturate(1.12) blur(8px);
159
  }
160
  .d3-trackio .d3-tooltip__inner { display: flex; flex-direction: column; gap: 6px; min-width: 220px; }
 
202
  const d3 = window.d3;
203
  const metricKey = cell.getAttribute('data-metric');
204
  const titleText = cell.getAttribute('data-title') || metricKey;
205
+ const host = cell.closest('.d3-trackio');
206
+ const shouldSuppress = () => !!(host && host.dataset && host.dataset.suppressTransitions === '1');
207
 
208
  // Header
209
  const header = document.createElement('div'); header.className = 'cell-header';
 
221
  const gPoints = gRoot.append('g').attr('class','points');
222
  const gHover = gRoot.append('g').attr('class','hover');
223
 
224
+ // Legacy flag, kept for compatibility but computed via shouldSuppress()
225
+ let suppressTransitions = false;
226
+
227
  // Tooltip
228
  cell.style.position = cell.style.position || 'relative';
229
  let tip = cell.querySelector('.d3-tooltip'); let tipInner; let hideTipTimer = null;
 
238
  } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
239
 
240
  // Layout & scales
241
+ let width = 800, height = 200; const margin = { top: 10, right: 20, bottom: 46, left: 44 };
242
  const xScale = d3.scaleLinear();
243
  const yScale = d3.scaleLinear();
244
  const lineGen = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value));
245
 
246
+ function updateLayout(axisLabelY, xTicksArg){
247
  const rect = cell.getBoundingClientRect();
248
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
249
+ // Hauteur fixe par défaut, overridable via data-height (pour fullscreen)
250
+ height = Number(cell.getAttribute('data-height')) || 200;
251
  svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet');
252
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
253
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
 
261
 
262
  // Axes
263
  gAxes.selectAll('*').remove();
264
+ // Ticks: for x, force equal spacing on indices and include edges; for y, equally spaced within domain, count ~ x ticks bounded [2,6]
265
+ const makeTicks = (scale, approx) => {
266
+ const arr = scale.ticks(approx);
267
+ const dom = scale.domain();
268
+ if (arr.length === 0 || arr[0] !== dom[0]) arr.unshift(dom[0]);
269
+ if (arr[arr.length - 1] !== dom[dom.length - 1]) arr.push(dom[dom.length - 1]);
270
+ return Array.from(new Set(arr));
271
+ };
272
+ const xTicksForced = (Array.isArray(xTicksArg) && xTicksArg.length)
273
+ ? Array.from({ length: xTicksArg.length }, (_, i) => i)
274
+ : makeTicks(xScale, 8);
275
+ const yCount = Math.max(2, Math.min(6, xTicksForced.length));
276
+ const yDom = yScale.domain();
277
+ const yTicksForced = (yCount <= 2)
278
+ ? [yDom[0], yDom[1]]
279
+ : Array.from({ length: yCount }, (_, i) => yDom[0] + ((yDom[1] - yDom[0]) * (i / (yCount - 1))));
280
+ // X axis with forced ticks (indices) and formatted labels mapped to original steps in render()
281
+ gAxes.append('g')
282
+ .attr('transform', `translate(0,${innerHeight})`)
283
+ .call(
284
+ d3.axisBottom(xScale)
285
+ .tickValues(xTicksForced)
286
+ .tickFormat((i) => {
287
+ // Label mapping will be provided by caller via closure over xTicksArg
288
+ const val = (Array.isArray(xTicksArg) && xTicksArg[i] != null ? xTicksArg[i] : i);
289
+ return formatAbbrev(val);
290
+ })
291
+ )
292
+ .call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); });
293
+ // Y axis with forced ticks
294
+ gAxes.append('g')
295
+ .call(d3.axisLeft(yScale).tickValues(yTicksForced).tickFormat((v)=>formatAbbrev(v)))
296
+ .call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); });
297
+
298
+ // Axis labels (only X; Y-label removed to gain space)
299
  gAxes.append('text')
300
  .attr('class', 'x-axis-label')
301
  .attr('x', innerWidth / 2)
302
+ .attr('y', innerHeight + Math.max(20, Math.min(36, margin.bottom - 12)))
303
  .attr('fill', 'var(--text-color)')
304
  .attr('text-anchor', 'middle')
305
+ .style('font-size', '8px')
306
+ .style('opacity', '.5')
307
+ .style('letter-spacing', '.5px')
308
+ .style('text-transform', 'uppercase')
309
+ .style('font-weight', '500')
310
  .text('Steps');
 
 
 
 
 
 
 
 
 
 
311
 
312
+ return { innerWidth, innerHeight, xTicksForced, yTicksForced };
313
  }
314
 
315
+ // Generic number abbreviation for ticks (K/M/B) with up to 2 decimals
316
+ const formatAbbrev = (value) => {
317
+ const num = Number(value);
318
+ if (!Number.isFinite(num)) return String(value);
319
+ const abs = Math.abs(num);
320
+ const trim2 = (n) => Number(n).toFixed(2).replace(/\.?0+$/, '');
321
+ if (abs >= 1e9) return `${trim2(num / 1e9)}B`;
322
+ if (abs >= 1e6) return `${trim2(num / 1e6)}M`;
323
+ if (abs >= 1e3) return `${trim2(num / 1e3)}K`;
324
+ return trim2(num);
325
  };
326
 
327
  function render(metricData, colorForRun) {
 
348
  runs.forEach(r => { (metricData[r]||[]).forEach(pt => { minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); minVal = Math.min(minVal, pt.value); maxVal = Math.max(maxVal, pt.value); }); });
349
  const isAccuracy = /accuracy/i.test(metricKey);
350
  const axisLabelY = prettyMetricLabel(metricKey);
 
351
  if (isAccuracy) yScale.domain([0, 1]).nice(); else yScale.domain([minVal, maxVal]).nice();
352
+ // Build unique steps and index mapping for equal spacing and snapping
353
+ const stepSet = new Set(); runs.forEach(r => (metricData[r]||[]).forEach(v => stepSet.add(v.step)));
354
+ const hoverSteps = Array.from(stepSet).sort((a,b)=>a-b);
355
+ const stepIndex = new Map(hoverSteps.map((s,i)=>[s,i]));
356
+ xScale.domain([0, Math.max(0, hoverSteps.length - 1)]);
357
+
358
+ // Update line generator X accessor to use index directly
359
+ lineGen.x(d => xScale(stepIndex.get(d.step)));
360
 
361
+ const { innerWidth, innerHeight, xTicksForced } = updateLayout(axisLabelY, hoverSteps);
362
 
363
+ // Vertical grid lines at each step index (same visibility as horizontal)
 
 
 
364
  gGrid.selectAll('line.vstep')
365
+ .data(xTicksForced)
366
  .join(
367
  enter => enter.append('line').attr('class','vstep')
368
  .attr('y1', 0).attr('y2', innerHeight)
 
374
  exit => exit.remove()
375
  );
376
 
377
+ // Remove stderr correction shapes (no uncertainty area)
378
  gAreas.selectAll('*').remove();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
  // Lines
381
  const series = runs.map(r => ({ run: r, color: colorForRun(r), values: (metricData[r]||[]).slice().sort((a,b)=>a.step-b.step) }));
382
  const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
383
  paths.enter().append('path').attr('class','run-line').attr('data-run', d=>d.run).attr('fill','none').attr('stroke-width', 1.5).attr('opacity', 0.9)
384
  .attr('stroke', d=>d.color).attr('d', d=>lineGen(d.values));
385
+ if (shouldSuppress()) {
386
+ paths.attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=>lineGen(d.values));
387
+ } else {
388
+ paths.transition().duration(260).attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=>lineGen(d.values));
389
+ }
390
  paths.exit().remove();
391
 
392
  // Points
393
  const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
394
  const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`);
395
  ptsSel.enter().append('circle').attr('class','pt').attr('data-run', d=>d.run).attr('r', 2).attr('fill', d=>d.color).attr('fill-opacity', 0.6)
396
+ .attr('stroke', 'none').attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value))
397
  .merge(ptsSel)
398
+ .each(function(d){ /* placeholder to keep merge chain intact */ })
399
+ .attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value));
400
+ if (!shouldSuppress()) {
401
+ try { gPoints.selectAll('circle.pt').transition().duration(150).attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value)); } catch(_){}
402
+ }
403
  ptsSel.exit().remove();
404
 
405
  // Hover
406
  gHover.selectAll('*').remove();
407
  const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
408
  const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
409
+
410
+ // Expose external hover handlers for cross-chart sync
411
+ cell.__showExternalStep = (stepVal) => {
412
+ if (stepVal == null) { hoverLine.style('display','none'); return; }
413
+ const idx = stepIndex.get(stepVal);
414
+ if (idx == null) { hoverLine.style('display','none'); return; }
415
+ const xpx = xScale(idx);
416
+ hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null);
417
+ };
418
+ cell.__clearExternalStep = () => { hoverLine.style('display','none'); };
419
+ if (!cell.__syncAttached && host) {
420
+ host.addEventListener('trackio-hover-step', (ev) => { const d = ev && ev.detail; if (!d) return; if (cell.__showExternalStep) cell.__showExternalStep(d.step); });
421
+ host.addEventListener('trackio-hover-clear', () => { if (cell.__clearExternalStep) cell.__clearExternalStep(); });
422
+ cell.__syncAttached = true;
423
+ }
424
+ function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const idx = Math.round(Math.max(0, Math.min(hoverSteps.length-1, xScale.invert(mx)))); const nearest = hoverSteps[idx]; const xpx = xScale(idx); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-step', { detail: { step: nearest } })); } catch(_){} }
425
+ let html = `<div><strong>step</strong> ${formatAbbrev(nearest)}</div><div><strong>${prettyMetricLabel(metricKey)}</strong></div>`;
426
  const entries = series.map(s=>{ const map = new Map(s.values.map(v=>[v.step, v])); const pt = map.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null);
427
  entries.sort((a,b)=> (a.pt.value - b.pt.value));
428
  const fmt = (vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4));
429
  entries.forEach(e => {
430
+ html += `<div style=\"display:flex;align-items:center;gap:8px;white-space:nowrap;\"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}</span></div>`;
 
431
  });
432
+ tipInner.innerHTML = html; const offsetX=12, offsetY=12; tip.style.opacity='1'; tip.style.transform=`translate(${Math.round(mx+offsetX+margin.left)}px, ${Math.round(my+offsetY+margin.top)}px)`;
433
+ // No animation on chart while scaling up/down
434
+ try {
435
+ const sel = gPoints.selectAll('circle.pt');
436
+ if (shouldSuppress()) { sel.interrupt().attr('r', d => (d && d.step === nearest ? 4 : 2)); }
437
+ else { sel.transition().duration(140).ease(d3.easeCubicOut).attr('r', d => (d && d.step === nearest ? 4 : 2)); }
438
+ } catch(_) {}
439
+ }
440
+ function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-clear')); } catch(_){} } try { const sel = gPoints.selectAll('circle.pt'); if (shouldSuppress()) { sel.interrupt().attr('r', 2); } else { sel.transition().duration(150).ease(d3.easeCubicOut).attr('r', 2); } } catch(_) {} }, 100); }
441
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
442
  }
443
 
444
+ // Fullscreen: button + modal behavior
445
+ const fsBtn = document.createElement('button'); fsBtn.className = 'cell-action cell-action--fullscreen'; fsBtn.type = 'button'; fsBtn.title='Fullscreen'; fsBtn.setAttribute('aria-label','Open fullscreen');
446
+ fsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"/></svg>';
447
+ header.appendChild(fsBtn);
448
+
449
+ function ensureOverlay(host){
450
+ let overlay = host.querySelector('.d3-trackio__overlay');
451
+ if (!overlay) {
452
+ overlay = document.createElement('div'); overlay.className='d3-trackio__overlay';
453
+ const modal = document.createElement('div'); modal.className='d3-trackio__modal';
454
+ const close = document.createElement('button'); close.className='d3-trackio__modal-close'; close.type='button'; close.innerHTML='&#10005;';
455
+ overlay.appendChild(modal); overlay.appendChild(close);
456
+ host.appendChild(overlay);
457
+ overlay.addEventListener('click', (e)=>{ if (e.target === overlay) close.click(); });
458
+ close.addEventListener('click', ()=>{
459
+ const moving = modal.querySelector('.cell'); if (!moving) { overlay.classList.remove('is-open'); return; }
460
+ const placeholder = moving.__placeholder;
461
+ if (!placeholder) { overlay.classList.remove('is-open'); return; }
462
+ // Global suppression across charts inside this host
463
+ host.dataset.suppressTransitions = '1';
464
+ // Cancel any in-flight D3 transitions
465
+ try { d3.select(host).selectAll('path.run-line').interrupt(); d3.select(host).selectAll('circle.pt').interrupt(); } catch(_){ }
466
+ // FLIP: animate from current (modal) to placeholder position
467
+ const from = moving.getBoundingClientRect();
468
+ const to = placeholder.getBoundingClientRect();
469
+ const dx = to.left - from.left; const dy = to.top - from.top;
470
+ const sx = Math.max(0.0001, to.width / from.width);
471
+ const sy = Math.max(0.0001, to.height / from.height);
472
+ moving.style.transformOrigin = 'top left';
473
+ moving.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
474
+ moving.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
475
+ const onEnd = () => {
476
+ moving.removeEventListener('transitionend', onEnd);
477
+ overlay.classList.remove('is-open');
478
+ moving.style.transition = '';
479
+ moving.style.transform = '';
480
+ if (placeholder && placeholder.parentNode) { placeholder.parentNode.insertBefore(moving, placeholder); placeholder.remove(); }
481
+ moving.removeAttribute('data-height');
482
+ // Recompute layout without any line/point animation
483
+ try { const h = host; if (h && h.__rerender) h.__rerender(); } catch {}
484
+ requestAnimationFrame(()=>{ delete host.dataset.suppressTransitions; });
485
+ };
486
+ moving.addEventListener('transitionend', onEnd);
487
+ });
488
+ window.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && overlay.classList.contains('is-open')) { const btn = overlay.querySelector('.d3-trackio__modal-close'); btn && btn.click(); }});
489
+ }
490
+ return overlay;
491
+ }
492
+
493
+ fsBtn.addEventListener('click', ()=>{
494
+ const hostNode = cell.closest('.d3-trackio'); if (!hostNode) return;
495
+ const overlay = ensureOverlay(hostNode); const modal = overlay.querySelector('.d3-trackio__modal');
496
+ // If another chart is open, close it first then proceed
497
+ const existing = modal.querySelector('.cell');
498
+ if (overlay.classList.contains('is-open') && existing && existing !== cell) {
499
+ const btn = overlay.querySelector('.d3-trackio__modal-close');
500
+ if (btn) { btn.dispatchEvent(new Event('click')); }
501
+ const waitEnd = () => {
502
+ if (!overlay.classList.contains('is-open')) { fsBtn.click(); return; }
503
+ requestAnimationFrame(waitEnd);
504
+ };
505
+ waitEnd();
506
+ return;
507
+ }
508
+ // Global suppression across charts inside this host
509
+ hostNode.dataset.suppressTransitions = '1';
510
+ // Cancel any in-flight D3 transitions
511
+ try { d3.select(hostNode).selectAll('path.run-line').interrupt(); d3.select(hostNode).selectAll('circle.pt').interrupt(); } catch(_){ }
512
+ const before = cell.getBoundingClientRect();
513
+ const placeholder = document.createElement('div'); placeholder.style.width = cell.offsetWidth + 'px'; placeholder.style.height = cell.offsetHeight + 'px';
514
+ cell.__placeholder = placeholder; cell.parentNode.insertBefore(placeholder, cell);
515
+ modal.appendChild(cell);
516
+ // Do NOT re-render or change SVG internals; scale via CSS only
517
+ const after = cell.getBoundingClientRect();
518
+ const dx = before.left - after.left; const dy = before.top - after.top;
519
+ const sx = Math.max(0.0001, before.width / after.width);
520
+ const sy = Math.max(0.0001, before.height / after.height);
521
+ cell.style.transformOrigin = 'top left';
522
+ cell.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
523
+ cell.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
524
+ overlay.classList.add('is-open');
525
+ requestAnimationFrame(()=>{ requestAnimationFrame(()=>{ cell.style.transform = 'none'; }); });
526
+ const onEnd = () => { cell.removeEventListener('transitionend', onEnd); cell.style.transition = ''; cell.style.transform = ''; requestAnimationFrame(()=>{ /* keep suppress until settled or close */ }); };
527
+ cell.addEventListener('transitionend', onEnd);
528
+ });
529
+
530
  return { metricKey, render };
531
  }
532
 
 
546
  if (!host) return;
547
  if (host.dataset && host.dataset.mounted === 'true') return; if (host.dataset) host.dataset.mounted = 'true';
548
 
549
+ // Build global header (legend) and insert ABOVE the grid
550
  const header = document.createElement('div'); header.className = 'd3-trackio__header';
551
+ const legend = document.createElement('div'); legend.className = 'legend-bottom'; legend.innerHTML = '<div class="legend-title">Runs</div><div class="items"></div>';
552
  header.appendChild(legend);
553
+ const gridNode = host.querySelector('.d3-trackio__grid');
554
+ if (gridNode && gridNode.parentNode === host) { host.insertBefore(header, gridNode); } else { host.insertBefore(header, host.firstChild); }
555
 
556
  const cells = Array.from(host.querySelectorAll('.cell'));
557
  if (!cells.length) return;
 
665
 
666
  // Resize handling
667
  const rerender = () => { instances.forEach(inst => inst.render(dataByMetric.get(inst.metricKey)||{}, colorForRun)); };
668
+ host.__rerender = rerender;
669
  if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(host); } else { window.addEventListener('resize', rerender); }
670
 
671
  // Legend hover ghosting across all cells