| 'use strict'; |
|
|
| |
| |
| 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; |
| |
| |
|
|
| |
| 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'], |
| ]; |
| |
| 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'], |
| ]; |
| |
| 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'], |
| ]; |
| |
| 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'], |
| ]; |
| |
| 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'], |
| ]; |
|
|
| |
| const PLOTLY_CMAP = { |
| 'RdYlGn': CS_RDYLGN, |
| 'RdBu_r': CS_RDBU_R, |
| 'magma': CS_MAGMA, |
| 'turbo': CS_TURBO, |
| 'viridis': CS_VIRIDIS, |
| |
| 'RdBu': CS_RDBU_R, |
| 'YlGnBu': CS_VIRIDIS, |
| 'viridis_r': CS_VIRIDIS, |
| }; |
| const R = 1.0; |
| const COAST_R = 1.010; |
| const DEG = Math.PI / 180; |
|
|
| |
| 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)', |
| }; |
|
|
| |
| 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, |
| 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, |
| }; |
|
|
| |
| 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}`; } |
|
|
| 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; |
| |
| 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)); |
| } |
|
|
| |
| |
| 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 }; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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; |
| 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(); |
| } |
|
|
| |
| |
| 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; |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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)), |
| }; |
| } |
|
|
| |
| |
| 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 }; |
|
|
| |
| |
| 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 }, |
| 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(); |
| } |
|
|
| |
| function syncCameras(gd) { |
| let lock = false, dragEnd = null; |
| |
| |
| gd.on('plotly_relayouting', () => { |
| app.dragging = true; |
| clearTimeout(dragEnd); |
| dragEnd = setTimeout(() => { |
| app.dragging = false; |
| if (app.playing) setT(app.t); |
| }, 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; |
| 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(); |
| rebuildFrames(); drawFigure(); |
| } |
| } |
| }); |
| }); |
| } |
|
|
| 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); |
| } |
|
|
| |
|
|
| 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; |
| |
| 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; |
| const ratios = data.ratio; |
| const T = ratios.length; |
|
|
| |
| 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)'; |
|
|
| |
| const bgSets = ratios.map(row => ({ |
| data: toPoints(row), borderColor: FADED, borderWidth: 1, |
| pointRadius: 0, tension: 0.2, fill: false, spanGaps: false, |
| })); |
|
|
| |
| const refSet = { |
| data: degrees.map(x => ({ x, y: 1.0 })), |
| borderColor: REF, borderWidth: 1, borderDash: [4, 3], |
| pointRadius: 0, fill: false, |
| }; |
|
|
| |
| 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; |
| |
| 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'); |
| } |
|
|