File size: 27,305 Bytes
a6ddf4c
 
 
 
 
 
 
 
 
 
1580edb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85b99c4
 
 
 
 
1580edb
 
 
 
 
 
 
a6ddf4c
85b99c4
1580edb
 
 
 
 
 
 
 
a6ddf4c
7b1476a
112c46c
a6ddf4c
 
112c46c
 
 
 
 
 
 
 
 
 
 
 
 
07b2af1
 
 
 
5f051cd
07b2af1
 
9931a2d
07b2af1
2e2084b
07b2af1
2e2084b
07b2af1
 
 
 
5769022
112c46c
a6ddf4c
 
7b1476a
 
 
 
 
a6ddf4c
 
112c46c
4913b15
1651e4a
3843250
37a1c7c
f61ef49
a6ddf4c
 
 
 
 
 
 
 
 
 
3843250
 
a6ddf4c
 
112c46c
a6ddf4c
 
 
 
112c46c
 
a6ddf4c
 
 
 
a31b4fd
9931a2d
 
 
 
 
a6ddf4c
9931a2d
a6ddf4c
 
 
 
4913b15
 
a6ddf4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b1476a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a6ddf4c
 
 
 
7b1476a
a6ddf4c
 
 
 
 
 
 
7b1476a
a6ddf4c
 
 
 
 
 
 
 
 
7b1476a
 
a6ddf4c
 
 
 
 
 
 
 
 
1651e4a
 
a6ddf4c
1651e4a
a6ddf4c
 
 
 
 
 
 
1651e4a
 
 
a6ddf4c
 
 
1651e4a
 
 
 
 
a6ddf4c
79b6b17
 
 
 
 
f61ef49
1651e4a
 
 
f61ef49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79b6b17
a6ddf4c
 
 
 
 
 
 
 
1651e4a
f61ef49
37a1c7c
f61ef49
1651e4a
 
79b6b17
 
 
 
 
 
1651e4a
 
79b6b17
 
1651e4a
79b6b17
 
 
 
 
 
 
f61ef49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79b6b17
 
 
1651e4a
 
37a1c7c
a6ddf4c
 
 
 
 
 
 
 
 
 
 
79b6b17
 
a6ddf4c
79b6b17
 
a6ddf4c
 
 
79b6b17
 
a6ddf4c
 
1651e4a
f61ef49
79b6b17
 
a6ddf4c
 
79b6b17
 
a6ddf4c
 
 
 
 
e0fd187
 
 
 
a6ddf4c
 
 
 
 
112c46c
 
 
a6ddf4c
 
 
 
 
3c41f86
 
 
 
 
 
 
98cfa92
3c41f86
747de69
3c41f86
 
 
a6ddf4c
 
07b2af1
1580edb
a6ddf4c
7b1476a
 
37a1c7c
 
 
 
5769022
9b0d358
7b1476a
a6ddf4c
 
3c41f86
5769022
 
3c41f86
5769022
3c41f86
9b0d358
a6ddf4c
9b0d358
5769022
a6ddf4c
 
 
 
7b1476a
a6ddf4c
 
 
 
9b0d358
a6ddf4c
 
1580edb
5769022
a6ddf4c
 
 
3c41f86
5769022
a6ddf4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37a1c7c
a6ddf4c
 
 
 
 
 
 
 
 
 
 
 
 
79b6b17
 
a6ddf4c
 
 
 
37a1c7c
 
 
 
 
 
 
 
 
 
 
 
f61ef49
 
 
 
37a1c7c
a6ddf4c
 
 
 
79b6b17
 
 
 
 
 
 
 
3843250
 
 
 
a6ddf4c
 
3843250
a6ddf4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37a1c7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9723e98
37a1c7c
 
 
 
 
 
 
 
 
 
 
 
 
9723e98
37a1c7c
 
 
 
 
 
 
 
 
 
 
 
 
 
1ea487f
 
37a1c7c
 
b3208f9
37a1c7c
 
b3208f9
1ea487f
37a1c7c
 
b3208f9
37a1c7c
 
 
 
b3208f9
37a1c7c
 
b3208f9
37a1c7c
 
b3208f9
37a1c7c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
'use strict';

// In local dev (localhost) read a synthetic index from ./data; in production
// read the real one from the companion HF Dataset.
const IS_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname);
const INDEX_URL = IS_LOCAL
  ? 'data/examples_index.json'
  : 'https://huggingface.co/datasets/maxxxzdn/mosaic-demo-cache/resolve/main/examples_index.json';
const COAST_URL = 'coastlines.json';
const HEADER_BYTES = 512;
// Custom colorscale arrays — avoid any Plotly.js named-scale lookup entirely.
// Each array: [[position 0-1, hex], ...], 0=vmin, 1=vmax.

// RdBu_r: blue(cold/negative) → white → red(warm/positive)
const CS_RDBU_R = [
  [0,'#053061'],[0.125,'#2166ac'],[0.25,'#4393c3'],[0.375,'#92c5de'],
  [0.5,'#f7f7f7'],[0.625,'#f4a582'],[0.75,'#d6604d'],[0.875,'#b2182b'],[1,'#67001f'],
];
// Magma: black → deep purple → red-orange → pale yellow (pops on dark bg)
const CS_MAGMA = [
  [0,'#000004'],[0.125,'#1d1147'],[0.25,'#51127c'],[0.375,'#832681'],
  [0.5,'#b5367a'],[0.625,'#d9555e'],[0.75,'#f17c4a'],[0.875,'#fbb938'],[1,'#fcfdbf'],
];
// Turbo: dark-blue → cyan → green → yellow → orange → dark-red
const CS_TURBO = [
  [0,'#30123b'],[0.125,'#3e47c9'],[0.25,'#21bdbb'],[0.375,'#60ff56'],
  [0.5,'#c1f334'],[0.625,'#fba40a'],[0.75,'#f05b12'],[0.875,'#c81a0c'],[1,'#7a0403'],
];
// RdYlGn: red(negative) → yellow(zero) → green(positive)
const CS_RDYLGN = [
  [0,'#a50026'],[0.125,'#d73027'],[0.25,'#f46d43'],[0.375,'#fdae61'],
  [0.5,'#ffffbf'],[0.625,'#d9ef8b'],[0.75,'#a6d96a'],[0.875,'#66bd63'],[1,'#1a9850'],
];
// Viridis: deep purple → teal → bright yellow
const CS_VIRIDIS = [
  [0,'#440154'],[0.125,'#482878'],[0.25,'#3e4989'],[0.375,'#31688e'],
  [0.5,'#26828e'],[0.625,'#35b779'],[0.75,'#6dcd59'],[0.875,'#b4de2c'],[1,'#fde725'],
];

// Keys must match exactly what getCmapForVar returns or what m.cmap may contain.
const PLOTLY_CMAP = {
  'RdYlGn': CS_RDYLGN,
  'RdBu_r': CS_RDBU_R,
  'magma':   CS_MAGMA,
  'turbo':   CS_TURBO,
  'viridis': CS_VIRIDIS,
  // precomputed-cmap fallbacks (from binary headers)
  'RdBu':    CS_RDBU_R,   // treat plain RdBu as _r since we want blue=cold
  'YlGnBu':  CS_VIRIDIS,  // fallback for humidity if label doesn't match
  'viridis_r': CS_VIRIDIS, // just in case
};
const R = 1.0;
const COAST_R = 1.010;
const DEG = Math.PI / 180;

// Display name overrides (friendlier than the labels stored in the index).
const VAR_DISPLAY = {
  '2 m temperature (T2M)':              'Temperature (2m)',
  '10 m u-wind (U10M)':                 'U-Wind (10m)',
  '10 m v-wind (V10M)':                 'V-Wind (10m)',
  'Mean sea-level pressure (SP)':       'Mean Sea-Level Pressure',
  'Geopotential @ 500 hPa (Z500)':      'Geopotential (500 hPa)',
  'Specific humidity @ 700 hPa (Q700)': 'Specific Humidity (700 hPa)',
  'Temperature @ 850 hPa (T850)':       'Temperature (850 hPa)',
  'u-wind @ 850 hPa (U850)':            'U-Wind (850 hPa)',
  'v-wind @ 850 hPa (V850)':            'V-Wind (850 hPa)',
};

// Pattern-matching cmap picker — robust against label variations across index versions.
function getCmapForVar(label) {
  const l = label.toLowerCase();
  if (l.includes('temperature') || l.includes('t2m') || l.includes('t850'))
    return 'RdBu_r';
  if (l.includes('wind') || l.includes('u10') || l.includes('v10') ||
      l.includes('u850') || l.includes('v850'))
    return 'RdBu_r';
  if (l.includes('pressure') || l.includes('mslp') || l.includes('(sp)') || / sp[^e]/i.test(label))
    return 'viridis';
  if (l.includes('geopotential') || l.includes('z500'))
    return 'viridis';
  if (l.includes('humidity') || l.includes('q700'))
    return 'viridis';
  return null;
}


const app = {
  index:null, coast:null, meta:null,
  truthF32:null,
  meanCache:{}, curMean:null,
  loadingN:0,
  meanFrames:null, rightFrames:null,
  geom:null, coastXYZ:null,
  M:0, T:0, nLon:0, nLat:0, W:0,
  vmean:0, vstd:1,
  LON0: -100,   // 10°E — Europe facing camera; set in init()
  rightMode:'spectra', n:1, t:0, dragging:false,
  curHash:null, curCache:null,
  currentIC:null, currentVar:null, playing:false, timer:null, autoplay:true,
  spectraCache:{}, spectraChart:null,
  curBinUrl:null, curTruthOffset:0, curTLen:0,
};

// IEEE-754 half → float
function h2f(h) {
  const s = (h & 0x8000) >> 15, e = (h & 0x7c00) >> 10, f = h & 0x03ff;
  if (e === 0)   return (s ? -1 : 1) * Math.pow(2,-14) * (f/1024);
  if (e === 31)  return f ? NaN : (s ? -Infinity : Infinity);
  return (s ? -1 : 1) * Math.pow(2, e-15) * (1 + f/1024);
}

const _MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtIC(s){ const [y,m] = s.split('-'); return `${_MON[+m-1]} ${y}`; }  // Feb 2016

async function init() {
  const [index, coast] = await Promise.all([
    fetch(INDEX_URL).then(r => r.json()),
    fetch(COAST_URL).then(r => r.json()),
  ]);
  app.index = index; app.coast = coast;
  // HF Space iframes block the geolocation API; hardcode 10°E (Europe).
  app.LON0 = -100;

  buildTokens('ic-tokens',
    app.index.ics.map(ic => ({ label: fmtIC(ic.init_time), val: ic.hash })),
    onICPick);
  const VAR_ORDER = ['2m_temperature','specific_humidity_700','10m_u_component_of_wind','10m_v_component_of_wind','mean_sea_level_pressure','geopotential_500','temperature_850','u_component_of_wind_850','v_component_of_wind_850'];
  const varEntries = Object.entries(app.index.variables).sort(([a],[b]) => {
    const ca = app.index.variables[a].cache_name, cb = app.index.variables[b].cache_name;
    const ia = VAR_ORDER.indexOf(ca), ib = VAR_ORDER.indexOf(cb);
    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
  });
  buildTokens('var-tokens',
    varEntries.map(([l, v]) => ({ label: VAR_DISPLAY[l] || v.long_name || l, val: l })),
    onVarPick);
  wireControls();
  buildGeometry();

  const icHash = app.index.ics.find(ic => ic.init_time && ic.init_time.startsWith('2021-08'))?.hash
             || app.index.default_ic_hash || app.index.ics[0].hash;
  const varLbl = app.index.default_var_label || Object.keys(app.index.variables)[0];
  markActive('ic-tokens', icHash);
  markActive('var-tokens', varLbl);
  await loadBin(icHash, varLbl);
}
document.addEventListener('DOMContentLoaded', init);

function buildTokens(id, items, onPick) {
  const el = document.getElementById(id);
  el.innerHTML = '';
  items.forEach((it, i) => {
    if (i) el.insertAdjacentHTML('beforeend', '<span class="sep">·</span>');
    const t = document.createElement('span');
    t.className = 'token'; t.textContent = it.label; t.dataset.val = it.val;
    t.onclick = () => onPick(it.val, it.label);
    el.appendChild(t);
  });
}
function markActive(id, val) {
  document.querySelectorAll(`#${id} .token`).forEach(t =>
    t.classList.toggle('active', t.dataset.val === val));
}

// Subdivide polyline segments longer than maxDeg° to keep coastlines on the
// sphere surface (long chords dip below the surface and get occluded).
function interpPoly(poly, maxDeg = 3) {
  const out = [];
  for (let i = 0; i < poly.length; i++) {
    out.push(poly[i]);
    if (i < poly.length - 1) {
      const [lo0, la0] = poly[i], [lo1, la1] = poly[i + 1];
      const d = Math.sqrt((lo1-lo0)**2 + (la1-la0)**2);
      const n = Math.ceil(d / maxDeg);
      for (let k = 1; k < n; k++)
        out.push([lo0 + (lo1-lo0)*k/n, la0 + (la1-la0)*k/n]);
    }
  }
  return out;
}

function buildGeometry() {
  const lon = app.index.lon, lat = app.index.lat;
  const nLon = lon.length, nLat = lat.length, W = nLon + 1;
  app.nLon = nLon; app.nLat = nLat; app.W = W;
  const L0 = app.LON0;

  const mk = () => Array.from({ length: nLat }, () => new Float32Array(W));
  const x = mk(), y = mk(), z = mk();
  for (let j = 0; j < nLat; j++) {
    const clat = Math.cos(lat[j] * DEG), slat = Math.sin(lat[j] * DEG);
    for (let i = 0; i < W; i++) {
      const lonDeg = (i < nLon ? lon[i] : lon[0] + 360);
      const lr = (lonDeg + L0) * DEG;
      x[j][i] = R * clat * Math.cos(lr);
      y[j][i] = R * clat * Math.sin(lr);
      z[j][i] = R * slat;
    }
  }
  app.geom = { x, y, z };

  const X = [], Y = [], Z = [];
  for (const poly of app.coast) {
    for (const [lo, la] of interpPoly(poly, 3)) {
      const lr = (lo + L0) * DEG, clat = Math.cos(la * DEG);
      X.push(COAST_R * clat * Math.cos(lr));
      Y.push(COAST_R * clat * Math.sin(lr));
      Z.push(COAST_R * Math.sin(la * DEG));
    }
    X.push(null); Y.push(null); Z.push(null);
  }
  app.coastXYZ = { x:X, y:Y, z:Z };
}

// Streamed fetch with a progress bar. Returns the full Uint8Array or null.
async function streamFetch(url, label) {
  const resp = await fetch(url);
  if (!resp.ok) { setStatus(`failed to load ${label} (${resp.status})`, 0, false); return null; }
  const total = +resp.headers.get('Content-Length') || 0;
  const reader = resp.body.getReader();
  const chunks = []; let recv = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value); recv += value.length;
    setStatus(total
      ? `loading ${label} · ${(recv/1e6).toFixed(1)} / ${(total/1e6).toFixed(0)} MB`
      : `loading ${label}…`, total ? recv/total*100 : 0, false);
  }
  const buf = new Uint8Array(recv);
  let off = 0; for (const c of chunks) { buf.set(c, off); off += c.length; }
  return buf;
}
function parseHeader(buf) {
  return JSON.parse(new TextDecoder().decode(buf.subarray(0, HEADER_BYTES)).trimEnd());
}

function decodeField(view) {
  const vmean = app.vmean, vstd = app.vstd;
  return Float32Array.from(view, v => h2f(v) * vstd + vmean);
}

// Base file: stream mean only (~2.3 MB), abort early; truth fetched on demand.
async function loadBin(icHash, varLabel) {
  const cacheName = app.index.variables[varLabel].cache_name;
  app.curHash = icHash; app.curCache = cacheName;
  const url = `${app.index.base_url}/${icHash}_${cacheName}.bin`;

  const resp = await fetch(url);
  if (!resp.ok) { setStatus(`failed to load ${varLabel} (${resp.status})`, 0, false); return; }
  const reader = resp.body.getReader();
  const chunks = []; let recv = 0;
  let meta = null, targetBytes = 0;

  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    if (value) { chunks.push(value); recv += value.length; }
    if (!meta && recv >= HEADER_BYTES) {
      const tmp = new Uint8Array(recv);
      let o = 0; for (const c of chunks) { tmp.set(c, o); o += c.length; }
      meta = parseHeader(tmp);
      const { M, T, n_lon, n_lat } = meta;
      const tLen = T * n_lon * n_lat;
      targetBytes = HEADER_BYTES + tLen * 2;     // header + mean only
      app.meta = meta; app.M = M; app.T = T;
      app.vmean = meta.vmean ?? 0; app.vstd = meta.vstd ?? 1;
      app.curBinUrl = url; app.curTruthOffset = targetBytes; app.curTLen = tLen;
      app.truthF32 = null;
    }
    if (meta) {
      setStatus(
        `loading ${varLabel} · ${(recv/1e6).toFixed(1)} / ${(targetBytes/1e6).toFixed(1)} MB`,
        Math.min(recv / targetBytes * 100, 99), false);
      if (recv >= targetBytes) { reader.cancel(); break; }
    }
  }
  if (!meta) return;

  const buf = new Uint8Array(recv);
  let o = 0; for (const c of chunks) { buf.set(c, o); o += c.length; }
  const { M, T } = meta;
  const tLen = app.curTLen;
  app.meanCache = { [M]: decodeField(new Uint16Array(buf.buffer, HEADER_BYTES, tLen)) };
  app.curMean = app.meanCache[app.n] || app.meanCache[M];
  app.currentIC = icHash; app.currentVar = varLabel;
  app.t = Math.min(app.t, T - 1);

  const tl = document.getElementById('timeline');
  tl.max = T - 1; tl.value = app.t;
  rebuildFrames();
  drawFigure();
  setStatus('', 100, true);
  if (app.autoplay) startPlay();
  if (app.n !== M && !app.meanCache[app.n]) loadMeanN(app.n);
  if (app.rightMode === 'spectra') loadSpectra();
  else loadTruth();  // preload truth in background when in truth/diff mode
}

// Mean over the first n members (~2.3 MB) — fetched when the Members selector
// picks 1/4/8 (16 is already in the base, so most visitors fetch nothing more).
async function loadMeanN(n) {
  if (app.meanCache[n]) { applyMean(n); return; }
  if (app.loadingN === n) return;
  app.loadingN = n;
  const hash = app.curHash, cache = app.curCache;
  const buf = await streamFetch(
    `${app.index.base_url}/${hash}_${cache}_m${n}.bin`, `${n}-member mean`);
  app.loadingN = 0;
  if (!buf || hash !== app.curHash || cache !== app.curCache) return;  // switched away
  const { T, n_lon, n_lat } = parseHeader(buf);
  app.meanCache[n] = decodeField(
    new Uint16Array(buf.buffer, HEADER_BYTES, T*n_lon*n_lat));
  setStatus('', 100, true);
  if (app.n === n) applyMean(n);
}

// Ground truth — fetched on demand via HTTP range request (second half of base file).
async function loadTruth() {
  if (app.truthF32) return;
  const hash = app.curHash, cache = app.curCache;
  const { curBinUrl: url, curTruthOffset: from, curTLen: tLen } = app;
  const to = from + tLen * 2 - 1;
  setStatus('loading ground truth…', 0, false);
  try {
    const resp = await fetch(url, { headers: { Range: `bytes=${from}-${to}` } });
    if (!resp.ok && resp.status !== 206) throw new Error(resp.status);
    const reader = resp.body.getReader();
    const chunks = []; let recv = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) { chunks.push(value); recv += value.length; }
      setStatus(
        `loading ground truth · ${(recv/1e6).toFixed(1)} / ${(tLen*2/1e6).toFixed(1)} MB`,
        recv / (tLen * 2) * 100, false);
    }
    if (hash !== app.curHash || cache !== app.curCache) return;
    const buf = new Uint8Array(recv);
    let o = 0; for (const c of chunks) { buf.set(c, o); o += c.length; }
    app.truthF32 = decodeField(new Uint16Array(buf.buffer, 0, tLen));
    setStatus('', 100, true);
    rebuildFrames();
    Plotly.restyle('plot', { surfacecolor: [app.meanFrames[app.t], app.rightFrames[app.t]] }, [0, 2]);
  } catch(e) {
    console.warn('loadTruth:', e);
    setStatus('failed to load ground truth', 0, false);
  }
}

function applyMean(n) {
  if (!app.meanCache[n]) return;
  app.curMean = app.meanCache[n];
  rebuildFrames();
  setT(app.t);
  if (app.rightMode === 'spectra') loadSpectra();
}

function setStatus(text, pct, hide) {
  const s = document.getElementById('status');
  s.style.setProperty('--p', `${pct}%`);
  document.getElementById('status-text').textContent = text;
  s.classList.toggle('hidden', !!hide);
}

// members C-order: idx = ((m*T + t)*nLon + i)*nLat + j   (j=lat, i=lon)
// surfacecolor wants 2D [j][i]; column i=nLon wraps to i=0 to close the seam.
// Mean is a precomputed field for the selected ensemble size (app.curMean);
// no client-side averaging. surfacecolor wants 2D [j][i] with a wrap column.
function rebuildFrames() {
  const { T, nLon, nLat, W } = app;
  const truth = app.truthF32, mean = app.curMean;
  const diff = app.rightMode === 'diff';
  app.meanFrames = []; app.rightFrames = [];
  for (let t = 0; t < T; t++) {
    const mF = Array.from({ length: nLat }, () => new Float32Array(W));
    const rF = Array.from({ length: nLat }, () => new Float32Array(W));
    for (let i = 0; i < nLon; i++) {
      for (let j = 0; j < nLat; j++) {
        const idx = (t*nLon + i)*nLat + j;
        const mv = mean[idx], tv = truth ? truth[idx] : mv;  // placeholder until truth loads
        mF[j][i] = mv;
        rF[j][i] = diff ? mv - tv : tv;
      }
    }
    for (let j = 0; j < nLat; j++) { mF[j][nLon] = mF[j][0]; rF[j][nLon] = rF[j][0]; }
    app.meanFrames.push(mF); app.rightFrames.push(rF);
  }
}

// Perspective (not orthographic): gl3d ignores eye distance for orthographic,
// so eye.y magnitude is the working zoom knob — smaller = bigger globe.
// eye.y controls zoom (smaller |y| = larger globe).
// eye.z tilts the camera upward: z/|eye| = sin(lat) of the globe face center.
// y=-1.2, z=1.1 → |eye|≈1.63 → center lat ≈ 42°N (Mediterranean/Southern Europe).
const CAMERA = { eye:{ x:0, y:-1.2, z:1.1 }, up:{ x:0, y:0, z:1 },
                 center:{ x:0, y:0, z:0 } };

function sceneCfg(domainX) {
  return {
    domain:{ x:domainX, y:[0,1] },
    xaxis:{ visible:false, range:[-1.0,1.0], showspikes:false },
    yaxis:{ visible:false, range:[-1.0,1.0], showspikes:false },
    zaxis:{ visible:false, range:[-1.0,1.0], showspikes:false },
    aspectmode:'data', bgcolor:'rgba(0,0,0,0)', dragmode:'turntable',
    camera: JSON.parse(JSON.stringify(CAMERA)),
  };
}

// cmin/cmax are set once per drawFigure call (from fixed header values) and are
// NOT updated in setT → the colorscale range is consistent across all frames.
function mkCbar(x, units) {
  return {
    x, xanchor:'left', y:0.5, yanchor:'middle',
    len:0.55, thickness:10,
    outlinecolor:'rgba(0,0,0,0)', bgcolor:'rgba(0,0,0,0)',
    tickfont:{ family:'IBM Plex Mono, monospace', size:12, color:'#8b8f98' },
    tickcolor:'#5a5e66', ticks:'outside', ticklen:3, nticks:5,
    title:{ text:units, font:{ family:'IBM Plex Mono, monospace', size:15, color:'#5a5e66' }, side:'top' },
  };
}

function drawFigure() {
  const m = app.meta, g = app.geom, c = app.coastXYZ, { t } = app;
  const cmapKey = getCmapForVar(app.currentVar) || m.cmap;
  const cmap = PLOTLY_CMAP[cmapKey] || CS_VIRIDIS;
  const diff = app.rightMode === 'diff';

  const rl = document.getElementById('right-label');
  if (rl) {
    if (app.rightMode === 'spectra') rl.textContent = 'SPECTRAL POWER RATIO';
    else rl.textContent = diff ? 'RELATIVE ERROR' : 'ERA5 — GROUND TRUTH';
  }

  const vmin = m.vmin, vmax = m.vmax;

  const light = { ambient:0.92, diffuse:0.32, specular:0.04, roughness:0.9, fresnel:0.0 };
  const lightpos = { x:-5000, y:-8000, z:12000 };

  // Both colorbars always shown at the same offset past their scene domain edge
  // (0.43 → 0.445 left, 0.945 → 0.96 right) so layout never shifts on mode switch.
  const cbar0 = mkCbar(0.445, m.units);
  const cbar2 = mkCbar(0.96,  m.units);

  const surf = (scene, color, scl, cmin, cmax, cbar) => ({
    type:'surface', scene, x:g.x, y:g.y, z:g.z, surfacecolor:color,
    colorscale:scl, cmin, cmax,
    showscale:true, colorbar:cbar,
    lighting:light, lightposition:lightpos, hoverinfo:'skip',
  });
  const coast = (scene) => ({
    type:'scatter3d', mode:'lines', scene, x:c.x, y:c.y, z:c.z,
    line:{ color:'#c8cdd6', width:2.5 }, opacity:1,
    hoverinfo:'skip', showlegend:false,
  });

  const data = [
    surf('scene',  app.meanFrames[t], cmap, vmin, vmax, cbar0),
    coast('scene'),
    surf('scene2', app.rightFrames[t],
         diff ? CS_RDBU_R : cmap,
         diff ? -m.diff_abs_max : vmin, diff ? m.diff_abs_max : vmax, cbar2),
    coast('scene2'),
  ];
  const layout = {
    autosize:true,
    margin:{ l:0, r:55, t:0, b:0 },  // consistent right margin for right colorbar
    paper_bgcolor:'rgba(0,0,0,0)', font:{ family:'Inter, sans-serif' },
    showlegend:false,
    scene:  sceneCfg([0.055, 0.43]),
    scene2: sceneCfg([0.57, 0.945]),
  };

  Plotly.newPlot('plot', data, layout,
    { displayModeBar:false, responsive:true, scrollZoom:false })
    .then(syncCameras);
  updateLead();
}

// Drag either globe → both rotate. Camera-only (no data work) so it's smooth.
function syncCameras(gd) {
  let lock = false, dragEnd = null;
  // While the user is actively dragging, suspend per-frame surfacecolor
  // restyles so they don't fight the rotate; resume shortly after release.
  gd.on('plotly_relayouting', () => {
    app.dragging = true;
    clearTimeout(dragEnd);
    dragEnd = setTimeout(() => {
      app.dragging = false;
      if (app.playing) setT(app.t);   // repaint the frame we held on
    }, 180);
  });
  gd.on('plotly_relayout', (e) => {
    if (lock) return;
    const touched = Object.keys(e).some(k => k.startsWith('scene.camera'));
    const touched2 = Object.keys(e).some(k => k.startsWith('scene2.camera'));
    let from = touched ? 'scene' : (touched2 ? 'scene2' : null);
    if (!from) return;
    const cam = gd.layout[from] && gd.layout[from].camera;
    if (!cam) return;
    const tgt = from === 'scene' ? 'scene2' : 'scene';
    lock = true;
    Plotly.relayout(gd, { [`${tgt}.camera`]: cam }).then(() => { lock = false; });
  });
}

function setT(t) {
  app.t = t;
  document.getElementById('timeline').value = t;
  updateLead();
  if (app.dragging) return;   // don't fight an in-progress rotate
  Plotly.restyle('plot',
    { surfacecolor: [app.meanFrames[t], app.rightFrames[t]] }, [0, 2]);
  if (app.rightMode === 'spectra') updateSpectraChart(t);
}
function updateLead() {
  const h = (app.t + 1) * (app.meta.lead_step_h || 6);
  document.getElementById('lead').textContent =
    `+${String(h).padStart(3,'0')} h`;
}

function wireControls() {
  const tl = document.getElementById('timeline');
  tl.addEventListener('input', () => { stopPlay(); setT(+tl.value); });

  document.getElementById('play').addEventListener('click', togglePlay);

  document.querySelectorAll('#mem-tokens .token').forEach(tk => {
    tk.addEventListener('click', () => selectMembers(+tk.dataset.val));
  });

  document.querySelectorAll('#mode-tokens .token').forEach(tk => {
    tk.addEventListener('click', () => {
      const mode = tk.dataset.val;
      if (mode === app.rightMode) return;
      markActive('mode-tokens', mode);
      app.rightMode = mode;
      const panel = document.getElementById('spectra-panel');
      const rl    = document.getElementById('right-label');
      if (mode === 'spectra') {
        panel.classList.remove('hidden');
        if (rl) rl.textContent = 'SPECTRAL POWER RATIO';
        if (app.curMean) loadSpectra();
      } else {
        panel.classList.add('hidden');
        if (app.curMean) {
          if (!app.truthF32) loadTruth();  // async: updates globe when done
          rebuildFrames(); drawFigure();    // draw immediately (placeholder until truth arrives)
        }
      }
    });
  });
}

function selectMembers(n) {
  app.n = n;
  markActive('mem-tokens', String(n));
  if (!app.curMean) return;
  if (app.meanCache[n]) applyMean(n);
  else loadMeanN(n);
}

function togglePlay() {
  if (app.playing) { app.autoplay = false; stopPlay(); }
  else { app.autoplay = true; startPlay(); }
}
function startPlay() {
  if (!app.meanFrames) return;
  clearInterval(app.timer);
  app.playing = true;
  document.getElementById('play').textContent = '❚❚ pause';
  app.timer = setInterval(() => setT((app.t + 1) % app.T), 320);
}
function stopPlay() {
  app.playing = false;
  document.getElementById('play').textContent = '▶ play';
  clearInterval(app.timer);
}

function onICPick(hash) {
  if (hash === app.currentIC) return;
  stopPlay(); markActive('ic-tokens', hash);
  loadBin(hash, app.currentVar);
}
function onVarPick(label) {
  if (label === app.currentVar) return;
  stopPlay(); markActive('var-tokens', label);
  loadBin(app.currentIC, label);
}

// ── spectral power ratio chart ────────────────────────────────────────────────

async function loadSpectra() {
  const cacheName = app.curCache;
  const n         = app.n;
  const key       = `${cacheName}_m${n}`;
  const canvas    = document.getElementById('spectra-chart');
  const msg       = document.getElementById('spectra-msg');

  if (app.spectraCache[key]) { _showSpectraChart(app.spectraCache[key]); return; }

  canvas.style.display = 'none';
  msg.style.display    = 'flex';
  msg.textContent      = 'loading…';

  const suffix = n === 16 ? '' : `_m${n}`;
  const url    = `${app.index.base_url}/spectra_${cacheName}${suffix}.json`;
  try {
    const resp = await fetch(url);
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const data = await resp.json();
    app.spectraCache[key] = data;
    // Guard: user may have switched variable/mode while we were fetching
    if (app.rightMode === 'spectra' && app.curCache === cacheName && app.n === n)
      _showSpectraChart(data);
  } catch(e) {
    if (app.curCache === cacheName && app.n === n) {
      msg.textContent = 'spectral data not available';
    }
  }
}

function _showSpectraChart(data) {
  const canvas = document.getElementById('spectra-chart');
  const msg    = document.getElementById('spectra-msg');
  canvas.style.display = 'block';
  msg.style.display    = 'none';
  initSpectraChart(data);
}

function initSpectraChart(data) {
  const degrees = data.degrees;   // [1, 2, ..., 120]
  const ratios  = data.ratio;     // [T][ndeg]
  const T       = ratios.length;

  // Convert row to Chart.js {x,y} point array for proper log-scale positioning
  const toPoints = row => row.map((y, i) => ({ x: degrees[i], y }));

  const FADED  = 'rgba(255,255,255,0.06)';
  const ACTIVE = '#38bdf8';
  const REF    = 'rgba(255,255,255,0.22)';

  // All 40 timesteps as faded background lines
  const bgSets = ratios.map(row => ({
    data: toPoints(row), borderColor: FADED, borderWidth: 1,
    pointRadius: 0, tension: 0.2, fill: false, spanGaps: false,
  }));

  // Reference line at ratio = 1.0 (perfect spectral alignment)
  const refSet = {
    data: degrees.map(x => ({ x, y: 1.0 })),
    borderColor: REF, borderWidth: 1, borderDash: [4, 3],
    pointRadius: 0, fill: false,
  };

  // Active timestep — always last so it renders on top
  const activeSet = {
    data: toPoints(ratios[app.t]),
    borderColor: ACTIVE, borderWidth: 2,
    pointRadius: 0, tension: 0.2, fill: false, spanGaps: false,
  };

  const ctx = document.getElementById('spectra-chart').getContext('2d');
  if (app.spectraChart) { app.spectraChart.destroy(); app.spectraChart = null; }

  const MONO = "'IBM Plex Mono', monospace";
  app.spectraChart = new Chart(ctx, {
    type: 'line',
    data: { datasets: [...bgSets, refSet, activeSet] },
    options: {
      responsive: true, maintainAspectRatio: false, animation: false,
      plugins: { legend: { display: false }, tooltip: { enabled: false } },
      scales: {
        x: {
          type: 'linear',
          min: 20, max: 120,
          title: {
            display: true, text: 'spherical harmonic degree',
            color: '#8b8f98', font: { family: MONO, size: 15 },
          },
          ticks: {
            color: '#8b8f98', font: { family: MONO, size: 12 },
            maxRotation: 0, maxTicksLimit: 6,
          },
          grid:   { color: 'rgba(255,255,255,0.04)' },
          border: { color: '#5a5e66' },
        },
        y: {
          title: {
            display: true, text: 'model / ERA5',
            color: '#8b8f98', font: { family: MONO, size: 15 },
          },
          ticks: {
            color: '#8b8f98', font: { family: MONO, size: 12 }, maxTicksLimit: 5,
          },
          grid:        { color: 'rgba(255,255,255,0.04)' },
          border:      { color: '#5a5e66' },
          suggestedMin: 0.6, suggestedMax: 1.4,
        },
      },
    },
  });
}

function updateSpectraChart(t) {
  const ch = app.spectraChart;
  if (!ch) return;
  const key  = `${app.curCache}_m${app.n}`;
  const data = app.spectraCache[key];
  if (!data) return;
  // Active dataset is the last one (index = T + 1: T bg + 1 ref + 1 active)
  const activeIdx = data.ratio.length + 1;
  ch.data.datasets[activeIdx].data =
    data.ratio[t].map((y, i) => ({ x: data.degrees[i], y }));
  ch.update('none');
}