mosaic / script.js
maxxxzdn's picture
Brighten spectra plot text (match captions) and axis lines
b3208f9 verified
'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');
}