'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', '·'); 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'); }