UI polish: script.js
Browse files
script.js
CHANGED
|
@@ -13,20 +13,20 @@ const PLOTLY_CMAP = {
|
|
| 13 |
magma:['Magma',false], YlGnBu:['YlGnBu',false], RdBu:['RdBu',false],
|
| 14 |
RdBu_r:['RdBu',true],
|
| 15 |
};
|
| 16 |
-
const R = 1.0;
|
| 17 |
-
const COAST_R = 1.
|
| 18 |
-
const LON0 = -90; // deg offset so lon 0° faces the default camera
|
| 19 |
const DEG = Math.PI / 180;
|
| 20 |
|
| 21 |
const app = {
|
| 22 |
index:null, coast:null, meta:null,
|
| 23 |
-
truthF32:null,
|
| 24 |
-
meanCache:{}, curMean:null,
|
| 25 |
-
loadingN:0,
|
| 26 |
-
meanFrames:null, rightFrames:null,
|
| 27 |
-
geom:null, coastXYZ:null,
|
| 28 |
M:0, T:0, nLon:0, nLat:0, W:0,
|
| 29 |
vmean:0, vstd:1,
|
|
|
|
| 30 |
rightMode:'truth', n:16, t:0, dragging:false,
|
| 31 |
curHash:null, curCache:null,
|
| 32 |
currentIC:null, currentVar:null, playing:false, timer:null, autoplay:true,
|
|
@@ -43,12 +43,26 @@ function h2f(h) {
|
|
| 43 |
const _MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| 44 |
function fmtIC(s){ const [y,m] = s.split('-'); return `${_MON[+m-1]} ${y}`; } // Feb 2016
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
async function init() {
|
| 47 |
-
const [index, coast] = await Promise.all([
|
| 48 |
fetch(INDEX_URL).then(r => r.json()),
|
| 49 |
fetch(COAST_URL).then(r => r.json()),
|
|
|
|
| 50 |
]);
|
| 51 |
app.index = index; app.coast = coast;
|
|
|
|
| 52 |
|
| 53 |
buildTokens('ic-tokens',
|
| 54 |
app.index.ics.map(ic => ({ label: fmtIC(ic.init_time), val: ic.hash })),
|
|
@@ -84,13 +98,28 @@ function markActive(id, val) {
|
|
| 84 |
t.classList.toggle('active', t.dataset.val === val));
|
| 85 |
}
|
| 86 |
|
| 87 |
-
//
|
| 88 |
-
//
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
function buildGeometry() {
|
| 91 |
const lon = app.index.lon, lat = app.index.lat;
|
| 92 |
const nLon = lon.length, nLat = lat.length, W = nLon + 1;
|
| 93 |
app.nLon = nLon; app.nLat = nLat; app.W = W;
|
|
|
|
| 94 |
|
| 95 |
const mk = () => Array.from({ length: nLat }, () => new Float32Array(W));
|
| 96 |
const x = mk(), y = mk(), z = mk();
|
|
@@ -98,7 +127,7 @@ function buildGeometry() {
|
|
| 98 |
const clat = Math.cos(lat[j] * DEG), slat = Math.sin(lat[j] * DEG);
|
| 99 |
for (let i = 0; i < W; i++) {
|
| 100 |
const lonDeg = (i < nLon ? lon[i] : lon[0] + 360);
|
| 101 |
-
const lr = (lonDeg +
|
| 102 |
x[j][i] = R * clat * Math.cos(lr);
|
| 103 |
y[j][i] = R * clat * Math.sin(lr);
|
| 104 |
z[j][i] = R * slat;
|
|
@@ -108,8 +137,8 @@ function buildGeometry() {
|
|
| 108 |
|
| 109 |
const X = [], Y = [], Z = [];
|
| 110 |
for (const poly of app.coast) {
|
| 111 |
-
for (const [lo, la] of poly) {
|
| 112 |
-
const lr = (lo +
|
| 113 |
X.push(COAST_R * clat * Math.cos(lr));
|
| 114 |
Y.push(COAST_R * clat * Math.sin(lr));
|
| 115 |
Z.push(COAST_R * Math.sin(la * DEG));
|
|
@@ -249,18 +278,14 @@ function sceneCfg(domainX) {
|
|
| 249 |
};
|
| 250 |
}
|
| 251 |
|
| 252 |
-
// Fixed (paper-anchored) titles — NOT scene annotations, so they don't rotate
|
| 253 |
-
// with the globe.
|
| 254 |
-
function titleAnn(x, text) {
|
| 255 |
-
return { x, y:1.0, xref:'paper', yref:'paper', text, showarrow:false,
|
| 256 |
-
xanchor:'center', yanchor:'top',
|
| 257 |
-
font:{ family:'IBM Plex Mono, monospace', size:16, color:'#aab0bb' } };
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
function drawFigure() {
|
| 261 |
const m = app.meta, g = app.geom, c = app.coastXYZ, { t } = app;
|
| 262 |
const [cmap, rev] = PLOTLY_CMAP[m.cmap] || ['Viridis', false];
|
| 263 |
const diff = app.rightMode === 'diff';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
const light = { ambient:0.92, diffuse:0.32, specular:0.04, roughness:0.9, fresnel:0.0 };
|
| 265 |
const lightpos = { x:-5000, y:-8000, z:12000 };
|
| 266 |
const surf = (scene, color, scl, reverse, cmin, cmax) => ({
|
|
@@ -270,7 +295,7 @@ function drawFigure() {
|
|
| 270 |
});
|
| 271 |
const coast = (scene) => ({
|
| 272 |
type:'scatter3d', mode:'lines', scene, x:c.x, y:c.y, z:c.z,
|
| 273 |
-
line:{ color:'#
|
| 274 |
hoverinfo:'skip', showlegend:false,
|
| 275 |
});
|
| 276 |
|
|
@@ -283,15 +308,11 @@ function drawFigure() {
|
|
| 283 |
coast('scene2'),
|
| 284 |
];
|
| 285 |
const layout = {
|
| 286 |
-
autosize:true, margin:{ l:0, r:0, t:
|
| 287 |
paper_bgcolor:'rgba(0,0,0,0)', font:{ family:'Inter, sans-serif' },
|
| 288 |
showlegend:false,
|
| 289 |
scene: sceneCfg([0.055, 0.43]),
|
| 290 |
scene2: sceneCfg([0.57, 0.945]),
|
| 291 |
-
annotations:[
|
| 292 |
-
titleAnn(0.2425, 'MOSAIC — ENSEMBLE MEAN'),
|
| 293 |
-
titleAnn(0.7575, diff ? 'ERROR (MEAN − TRUTH)' : 'ERA5 — GROUND TRUTH'),
|
| 294 |
-
],
|
| 295 |
};
|
| 296 |
|
| 297 |
Plotly.newPlot('plot', data, layout,
|
|
|
|
| 13 |
magma:['Magma',false], YlGnBu:['YlGnBu',false], RdBu:['RdBu',false],
|
| 14 |
RdBu_r:['RdBu',true],
|
| 15 |
};
|
| 16 |
+
const R = 1.0;
|
| 17 |
+
const COAST_R = 1.010; // coastline shell above the surface (higher = fewer occlusion gaps)
|
|
|
|
| 18 |
const DEG = Math.PI / 180;
|
| 19 |
|
| 20 |
const app = {
|
| 21 |
index:null, coast:null, meta:null,
|
| 22 |
+
truthF32:null,
|
| 23 |
+
meanCache:{}, curMean:null,
|
| 24 |
+
loadingN:0,
|
| 25 |
+
meanFrames:null, rightFrames:null,
|
| 26 |
+
geom:null, coastXYZ:null,
|
| 27 |
M:0, T:0, nLon:0, nLat:0, W:0,
|
| 28 |
vmean:0, vstd:1,
|
| 29 |
+
LON0: -100, // default: ~10°E (Europe); overridden by geolocation
|
| 30 |
rightMode:'truth', n:16, t:0, dragging:false,
|
| 31 |
curHash:null, curCache:null,
|
| 32 |
currentIC:null, currentVar:null, playing:false, timer:null, autoplay:true,
|
|
|
|
| 43 |
const _MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| 44 |
function fmtIC(s){ const [y,m] = s.split('-'); return `${_MON[+m-1]} ${y}`; } // Feb 2016
|
| 45 |
|
| 46 |
+
// Try to get user longitude for globe centering; falls back to 10°E within 500 ms.
|
| 47 |
+
function getInitialLon() {
|
| 48 |
+
return new Promise(resolve => {
|
| 49 |
+
if (!navigator.geolocation) { resolve(10); return; }
|
| 50 |
+
navigator.geolocation.getCurrentPosition(
|
| 51 |
+
pos => resolve(pos.coords.longitude),
|
| 52 |
+
() => resolve(10),
|
| 53 |
+
{ timeout: 500, maximumAge: Infinity }
|
| 54 |
+
);
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
async function init() {
|
| 59 |
+
const [index, coast, userLon] = await Promise.all([
|
| 60 |
fetch(INDEX_URL).then(r => r.json()),
|
| 61 |
fetch(COAST_URL).then(r => r.json()),
|
| 62 |
+
getInitialLon(),
|
| 63 |
]);
|
| 64 |
app.index = index; app.coast = coast;
|
| 65 |
+
app.LON0 = -90 - userLon;
|
| 66 |
|
| 67 |
buildTokens('ic-tokens',
|
| 68 |
app.index.ics.map(ic => ({ label: fmtIC(ic.init_time), val: ic.hash })),
|
|
|
|
| 98 |
t.classList.toggle('active', t.dataset.val === val));
|
| 99 |
}
|
| 100 |
|
| 101 |
+
// Subdivide polyline segments longer than maxDeg° to keep coastlines on the
|
| 102 |
+
// sphere surface (long chords dip below the surface and get occluded).
|
| 103 |
+
function interpPoly(poly, maxDeg = 3) {
|
| 104 |
+
const out = [];
|
| 105 |
+
for (let i = 0; i < poly.length; i++) {
|
| 106 |
+
out.push(poly[i]);
|
| 107 |
+
if (i < poly.length - 1) {
|
| 108 |
+
const [lo0, la0] = poly[i], [lo1, la1] = poly[i + 1];
|
| 109 |
+
const d = Math.sqrt((lo1-lo0)**2 + (la1-la0)**2);
|
| 110 |
+
const n = Math.ceil(d / maxDeg);
|
| 111 |
+
for (let k = 1; k < n; k++)
|
| 112 |
+
out.push([lo0 + (lo1-lo0)*k/n, la0 + (la1-la0)*k/n]);
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
return out;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
function buildGeometry() {
|
| 119 |
const lon = app.index.lon, lat = app.index.lat;
|
| 120 |
const nLon = lon.length, nLat = lat.length, W = nLon + 1;
|
| 121 |
app.nLon = nLon; app.nLat = nLat; app.W = W;
|
| 122 |
+
const L0 = app.LON0;
|
| 123 |
|
| 124 |
const mk = () => Array.from({ length: nLat }, () => new Float32Array(W));
|
| 125 |
const x = mk(), y = mk(), z = mk();
|
|
|
|
| 127 |
const clat = Math.cos(lat[j] * DEG), slat = Math.sin(lat[j] * DEG);
|
| 128 |
for (let i = 0; i < W; i++) {
|
| 129 |
const lonDeg = (i < nLon ? lon[i] : lon[0] + 360);
|
| 130 |
+
const lr = (lonDeg + L0) * DEG;
|
| 131 |
x[j][i] = R * clat * Math.cos(lr);
|
| 132 |
y[j][i] = R * clat * Math.sin(lr);
|
| 133 |
z[j][i] = R * slat;
|
|
|
|
| 137 |
|
| 138 |
const X = [], Y = [], Z = [];
|
| 139 |
for (const poly of app.coast) {
|
| 140 |
+
for (const [lo, la] of interpPoly(poly, 3)) {
|
| 141 |
+
const lr = (lo + L0) * DEG, clat = Math.cos(la * DEG);
|
| 142 |
X.push(COAST_R * clat * Math.cos(lr));
|
| 143 |
Y.push(COAST_R * clat * Math.sin(lr));
|
| 144 |
Z.push(COAST_R * Math.sin(la * DEG));
|
|
|
|
| 278 |
};
|
| 279 |
}
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
function drawFigure() {
|
| 282 |
const m = app.meta, g = app.geom, c = app.coastXYZ, { t } = app;
|
| 283 |
const [cmap, rev] = PLOTLY_CMAP[m.cmap] || ['Viridis', false];
|
| 284 |
const diff = app.rightMode === 'diff';
|
| 285 |
+
|
| 286 |
+
const rl = document.getElementById('right-label');
|
| 287 |
+
if (rl) rl.textContent = diff ? 'ERROR (MEAN − TRUTH)' : 'ERA5 — GROUND TRUTH';
|
| 288 |
+
|
| 289 |
const light = { ambient:0.92, diffuse:0.32, specular:0.04, roughness:0.9, fresnel:0.0 };
|
| 290 |
const lightpos = { x:-5000, y:-8000, z:12000 };
|
| 291 |
const surf = (scene, color, scl, reverse, cmin, cmax) => ({
|
|
|
|
| 295 |
});
|
| 296 |
const coast = (scene) => ({
|
| 297 |
type:'scatter3d', mode:'lines', scene, x:c.x, y:c.y, z:c.z,
|
| 298 |
+
line:{ color:'#c8cdd6', width:2.5 }, opacity:1,
|
| 299 |
hoverinfo:'skip', showlegend:false,
|
| 300 |
});
|
| 301 |
|
|
|
|
| 308 |
coast('scene2'),
|
| 309 |
];
|
| 310 |
const layout = {
|
| 311 |
+
autosize:true, margin:{ l:0, r:0, t:0, b:0 },
|
| 312 |
paper_bgcolor:'rgba(0,0,0,0)', font:{ family:'Inter, sans-serif' },
|
| 313 |
showlegend:false,
|
| 314 |
scene: sceneCfg([0.055, 0.43]),
|
| 315 |
scene2: sceneCfg([0.57, 0.945]),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
};
|
| 317 |
|
| 318 |
Plotly.newPlot('plot', data, layout,
|