BBertozzi commited on
Commit
0444a33
Β·
verified Β·
1 Parent(s): 551a10a

regular index.html shell

Browse files
Files changed (1) hide show
  1. index.html +704 -0
index.html ADDED
@@ -0,0 +1,704 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Clouds-Decoded Β· Sentinel-2 cloud retrievals β€” UK & India β€” clouds-decoded report</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, sans-serif; background: #111; color: #ddd; height: 100vh; display: flex; flex-direction: column; }
10
+ #header { padding: 8px 16px; background: #1a1a2e; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
11
+ #header h1 { font-size: 1rem; font-weight: 500; }
12
+ #daterange { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 0.78rem; color: #888; }
13
+ #daterange .dr-label { text-transform: uppercase; letter-spacing: 0.5px; font-size: 0.68rem; color: #667; }
14
+ #daterange .dr-sep { color: #556; }
15
+ #daterange input[type=date] { background: #1e1e2e; color: #ccc; border: 1px solid #333; border-radius: 4px; padding: 2px 5px; font-size: 0.75rem; color-scheme: dark; }
16
+ #daterange input[type=date]:hover { border-color: #4a6fa5; }
17
+ #date-reset { background: #1e1e2e; color: #999; border: 1px solid #333; border-radius: 4px; padding: 2px 9px; font-size: 0.72rem; cursor: pointer; }
18
+ #date-reset:hover { background: #2e2e4e; color: #ccc; }
19
+ #header .meta { font-size: 0.8rem; color: #888; }
20
+ /* Fixed top: map + chart */
21
+ #top-panels { display: flex; gap: 8px; padding: 8px; flex-shrink: 0; }
22
+ #map-wrap { position: relative; flex-shrink: 0; height: 240px; overflow: hidden; border-radius: 6px; cursor: grab; }
23
+ #map-wrap.grabbing { cursor: grabbing; }
24
+ #map-inner { position: relative; transform-origin: 0 0; will-change: transform; }
25
+ #map-bg { display: block; height: 240px; width: auto; }
26
+ #map-canvas { position: absolute; top: 0; left: 0; pointer-events: auto; }
27
+ #chart-wrap { flex: 1; min-width: 0; }
28
+ #chart-canvas { display: block; width: 100%; cursor: pointer; }
29
+ /* Tile + plot + layer toggle checkboxes */
30
+ #tab-bar, #var-bar, #layer-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 4px 8px; background: #151520; border-bottom: 1px solid #333; flex-shrink: 0; overflow-x: auto; }
31
+ #tab-bar { border-top: 1px solid #222; }
32
+ .bar-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.5px; color: #666; margin-right: 6px; }
33
+ .tchk { padding: 4px 12px; border-radius: 4px; border: 1px solid #333; background: #1e1e2e; color: #bbb; font-size: 0.78rem; white-space: nowrap; display: flex; align-items: center; gap: 5px; user-select: none; }
34
+ .tchk input { margin: 0; cursor: pointer; accent-color: #4a6fa5; }
35
+ .tchk .tname { cursor: pointer; }
36
+ .tchk .tname:hover { color: #fff; text-decoration: underline; }
37
+ .tchk.focused { border-color: #4a6fa5; box-shadow: 0 0 0 1px #4a6fa5 inset; background: #1e3050; }
38
+ .tchk.focused .tname { color: #7eb8f7; }
39
+ .tchk .rgn { color: #777; }
40
+ .tab-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
41
+ /* Scene browser */
42
+ #scene-panel { display: flex; align-items: flex-start; gap: 10px; padding: 8px; flex: 1; min-height: 0; overflow: hidden; }
43
+ #nav-prev, #nav-next, #nav-play { flex-shrink: 0; width: 36px; height: 36px; font-size: 1.2rem; background: #1e1e2e; border: 1px solid #444; border-radius: 6px; color: #ccc; cursor: pointer; margin-top: 4px; }
44
+ #nav-prev:hover, #nav-next:hover, #nav-play:hover { background: #2e2e4e; }
45
+ #nav-play.playing { background: #2e1e1e; border-color: #664444; color: #ff8a65; }
46
+ #speed-select { background: #1e1e2e; color: #888; border: 1px solid #333; border-radius: 4px; font-size: 0.72rem; padding: 2px 4px; margin-top: 4px; width: 52px; }
47
+ #scene-info { flex-shrink: 0; width: 160px; font-size: 0.78rem; line-height: 1.7; }
48
+ #si-date { font-size: 0.95rem; font-weight: 600; color: #7eb8f7; }
49
+ #si-sub { font-size: 0.68rem; color: #666; margin-top: 1px; word-break: break-all; }
50
+ #si-stats { margin-top: 6px; color: #aaa; }
51
+ #si-stats b { color: #ccc; }
52
+ #layer-grid { flex: 1; align-self: stretch; display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; align-content: start; }
53
+ .layer-thumb { text-align: center; }
54
+ .layer-thumb.overview { grid-column: 1 / -1; }
55
+ .layer-thumb.overview img { max-width: 50%; margin: 0 auto; }
56
+ .layer-thumb img { width: 100%; height: auto; display: block; border: 2px solid #2a2a3a; border-radius: 4px; cursor: pointer; }
57
+ .layer-thumb img:hover { border-color: #7eb8f7; }
58
+ .layer-label { font-size: 0.65rem; color: #777; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
59
+ #progress { font-size: 0.75rem; color: #666; }
60
+ /* Lightbox */
61
+ #lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.93); z-index: 100; align-items: center; justify-content: center; }
62
+ #lightbox.active { display: flex; }
63
+ #lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 4px; }
64
+ #lb-close { position: fixed; top: 14px; right: 18px; font-size: 1.8rem; color: #bbb; cursor: pointer; z-index: 101; }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <div id="header">
69
+ <h1>Clouds-Decoded Β· Sentinel-2 cloud retrievals β€” UK & India</h1>
70
+ <span class="meta" id="progress"></span>
71
+ <div id="daterange">
72
+ <span class="dr-label">Time range</span>
73
+ <input type="date" id="date-start" title="start date">
74
+ <span class="dr-sep">β†’</span>
75
+ <input type="date" id="date-end" title="end date">
76
+ <button id="date-reset" title="reset to full range">Reset</button>
77
+ </div>
78
+ </div>
79
+
80
+ <!-- Fixed top: map + timeseries (always visible, all tiles) -->
81
+ <div id="top-panels">
82
+
83
+ <div id="map-wrap" title="scroll to zoom Β· drag to pan Β· double-click to reset">
84
+ <div id="map-inner">
85
+ <img id="map-bg" src="map.png" alt="scene map">
86
+ </div>
87
+ <canvas id="map-canvas"></canvas>
88
+ </div>
89
+ <div id="chart-wrap">
90
+ <canvas id="chart-canvas"></canvas>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Tile toggles: show/hide each tile's dots on the map + line on the timeseries -->
95
+ <div id="tab-bar">
96
+ <span class="bar-label">Tiles</span>
97
+ <div class="tchk" data-tile="T29VMC"><input type="checkbox" data-tile="T29VMC" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#4fc3f7"></span><span class="tname" data-tile="T29VMC" title="view this tile in the browser below">T29VMC <span class="rgn">(UK)</span></span></div><div class="tchk" data-tile="T30UVA"><input type="checkbox" data-tile="T30UVA" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#ffb74d"></span><span class="tname" data-tile="T30UVA" title="view this tile in the browser below">T30UVA <span class="rgn">(UK)</span></span></div><div class="tchk" data-tile="T31VDF"><input type="checkbox" data-tile="T31VDF" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#81c784"></span><span class="tname" data-tile="T31VDF" title="view this tile in the browser below">T31VDF <span class="rgn">(UK)</span></span></div><div class="tchk" data-tile="T42QYE"><input type="checkbox" data-tile="T42QYE" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#ce93d8"></span><span class="tname" data-tile="T42QYE" title="view this tile in the browser below">T42QYE <span class="rgn">(India)</span></span></div><div class="tchk" data-tile="T43PDM"><input type="checkbox" data-tile="T43PDM" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#ff8a65"></span><span class="tname" data-tile="T43PDM" title="view this tile in the browser below">T43PDM <span class="rgn">(India)</span></span></div><div class="tchk" data-tile="T44PNB"><input type="checkbox" data-tile="T44PNB" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#4db6ac"></span><span class="tname" data-tile="T44PNB" title="view this tile in the browser below">T44PNB <span class="rgn">(India)</span></span></div><div class="tchk" data-tile="T45QWB"><input type="checkbox" data-tile="T45QWB" checked title="show / hide on map + chart"><span class="tab-dot" style="background:#e57373"></span><span class="tname" data-tile="T45QWB" title="view this tile in the browser below">T45QWB <span class="rgn">(India)</span></span></div>
98
+ </div>
99
+ <!-- Plot toggles: show/hide each timeseries variable (populated from CHART_VARS) -->
100
+ <div id="var-bar">
101
+ <span class="bar-label">Plots</span>
102
+ </div>
103
+ <!-- Layer toggles: show/hide each per-scene retrieval thumbnail (populated from data) -->
104
+ <div id="layer-bar">
105
+ <span class="bar-label">Layers</span>
106
+ </div>
107
+
108
+ <div id="scene-panel">
109
+ <button id="nav-prev">&#8592;</button>
110
+ <button id="nav-play">&#9654;</button>
111
+ <button id="nav-next">&#8594;</button>
112
+ <select id="speed-select" title="Playback speed">
113
+ <option value="2000">0.5Γ—</option>
114
+ <option value="1000" selected>1Γ—</option>
115
+ <option value="500">2Γ—</option>
116
+ <option value="250">4Γ—</option>
117
+ </select>
118
+ <div id="scene-info">
119
+ <div id="si-date"></div>
120
+ <div id="si-sub"></div>
121
+ <div id="si-stats"></div>
122
+ </div>
123
+ <div id="layer-grid"></div>
124
+ </div>
125
+
126
+ <div id="lightbox">
127
+ <span id="lb-close">&#10005;</span>
128
+ <img id="lb-img" src="" alt="">
129
+ </div>
130
+
131
+ <script src="data.js"></script>
132
+ <script>
133
+ const SCENES = window.__SCENES;
134
+ const FIG_BASE = "https://huggingface.co/datasets/asterisk-labs/clouds-decoded-rain-check/resolve/main/figures/"; // '' = layers hold full paths; else build url = FIG_BASE+scene_id+'/'+name
135
+ const layerURL = (s, name) => FIG_BASE ? (FIG_BASE + s.scene_id + '/' + name) : name;
136
+ const TILE_COLORS = {"T29VMC": "#4fc3f7", "T30UVA": "#ffb74d", "T31VDF": "#81c784", "T42QYE": "#ce93d8", "T43PDM": "#ff8a65", "T44PNB": "#4db6ac", "T45QWB": "#e57373"};
137
+ const MAP_POINTS = window.__MAPPTS;
138
+ const CHART_VARS = [
139
+ { key: 'cloud_frac', label: 'Cloud frac' },
140
+ { key: 'tau__mean', label: 'Ο„ mean' },
141
+ { key: 'tau__p050', label: 'Ο„ p50' },
142
+ { key: 'r_eff_liq__p050', label: 'r_eff liq' },
143
+ { key: 'r_eff_ice__p050', label: 'r_eff ice' },
144
+ { key: 'ice_liq_ratio__mean', label: 'Ice/liq' },
145
+ ];
146
+ const LAYER_ORDER = ['true_colour', 'cloud_height', 'properties_tau', 'properties_ice_liq_ratio', 'properties_r_eff_liq', 'properties_r_eff_ice'];
147
+
148
+ // All retrieval-plot layer types present across scenes, ordered like the grid.
149
+ const ALL_LAYERS = (function() {
150
+ const set = new Set();
151
+ SCENES.forEach(s => (s.layers || []).forEach(p => set.add(p.split('/').pop().replace('.png', ''))));
152
+ const rest = [...set].filter(n => n !== 'overview' && !LAYER_ORDER.includes(n)).sort();
153
+ const ordered = LAYER_ORDER.filter(n => set.has(n)).concat(rest);
154
+ if (set.has('overview')) ordered.push('overview');
155
+ return ordered;
156
+ })();
157
+ let visibleLayers = new Set(ALL_LAYERS);
158
+ function prettyLayer(n) { return n.replace(/_/g, ' ').replace(/^properties /, ''); }
159
+
160
+ // Layer toggles β€” one checkbox per retrieval plot; filters the scene-browser grid.
161
+ (function buildLayerToggles() {
162
+ const bar = document.getElementById('layer-bar');
163
+ if (!bar) return;
164
+ ALL_LAYERS.forEach(n => {
165
+ const label = document.createElement('label');
166
+ label.className = 'tchk';
167
+ label.innerHTML = '<input type="checkbox" data-layer="' + n + '" checked>' + prettyLayer(n);
168
+ label.querySelector('input').addEventListener('change', e => {
169
+ if (e.target.checked) visibleLayers.add(n); else visibleLayers.delete(n);
170
+ showScene(current);
171
+ });
172
+ bar.appendChild(label);
173
+ });
174
+ })();
175
+
176
+ // Global index lookup for map
177
+ const SCENE_IDX = {};
178
+ SCENES.forEach((s, i) => { SCENE_IDX[s.scene_id] = i; });
179
+
180
+ // ── Time-range filter (timeseries) ────────────────────────────────────────────
181
+ const _allT = SCENES.map(s => s.date ? new Date(s.date).getTime() : null).filter(Boolean);
182
+ const T_MIN_FULL = _allT.reduce((a, b) => b < a ? b : a, Infinity);
183
+ const T_MAX_FULL = _allT.reduce((a, b) => b > a ? b : a, -Infinity);
184
+ let dateStart = T_MIN_FULL, dateEnd = T_MAX_FULL; // default = whole series
185
+ const _isoDay = ts => new Date(ts).toISOString().slice(0, 10);
186
+ (function initDateRange() {
187
+ const ds = document.getElementById('date-start'), de = document.getElementById('date-end');
188
+ if (!ds || !de) return;
189
+ ds.min = de.min = _isoDay(T_MIN_FULL); ds.max = de.max = _isoDay(T_MAX_FULL);
190
+ ds.value = _isoDay(T_MIN_FULL); de.value = _isoDay(T_MAX_FULL);
191
+ function apply() {
192
+ dateStart = ds.value ? new Date(ds.value + 'T00:00:00Z').getTime() : T_MIN_FULL;
193
+ dateEnd = de.value ? new Date(de.value + 'T23:59:59Z').getTime() : T_MAX_FULL;
194
+ if (dateEnd < dateStart) { const t = dateStart; dateStart = dateEnd; dateEnd = t; }
195
+ setPlaying(false); current = 0;
196
+ showScene(0); // rescope the scene browser to the range (also redraws chart/map)
197
+ drawChart(); // in case the range is empty (showScene bails early)
198
+ }
199
+ ds.onchange = apply; de.onchange = apply;
200
+ document.getElementById('date-reset').onclick = () => {
201
+ ds.value = _isoDay(T_MIN_FULL); de.value = _isoDay(T_MAX_FULL); apply();
202
+ };
203
+ })();
204
+
205
+ // ── Tile state ────────────────────────────────────────────────────────────────
206
+ let activeTile = null; // null = first tile is selected by default
207
+ let current = 0; // index within activeScenes()
208
+
209
+ function activeScenes() {
210
+ return SCENES.filter(s => {
211
+ if (activeTile && s.tile_id !== activeTile) return false;
212
+ const t = s.date ? new Date(s.date).getTime() : null; // scope to the selected time range
213
+ return t != null && t >= dateStart && t <= dateEnd;
214
+ });
215
+ }
216
+
217
+ // Scene-browser focus (set by clicking a map dot or chart point) β€” independent
218
+ // of the visibility checkboxes.
219
+ function switchTile(tile) {
220
+ setPlaying(false);
221
+ activeTile = tile || null;
222
+ current = 0;
223
+ document.querySelectorAll('#tab-bar .tchk').forEach(l =>
224
+ l.classList.toggle('focused', l.dataset.tile === (tile || ''))
225
+ );
226
+ showScene(0);
227
+ }
228
+
229
+ // Tile checkboxes toggle tile visibility on the map + timeseries (via hiddenTiles).
230
+ // Scope to #tab-bar so this does NOT also bind to the plot/layer checkboxes (which
231
+ // share the .tchk class) β€” otherwise toggling those would fire this handler too.
232
+ document.querySelectorAll('#tab-bar .tchk input').forEach(cb =>
233
+ cb.addEventListener('change', () => {
234
+ if (cb.checked) hiddenTiles.delete(cb.dataset.tile);
235
+ else hiddenTiles.add(cb.dataset.tile);
236
+ drawChart();
237
+ drawMapDots();
238
+ })
239
+ );
240
+
241
+ // Click a tile NAME to select it for viewing in the scene browser (independent of
242
+ // the checkbox, which only shows/hides it on the map + timeseries).
243
+ document.querySelectorAll('#tab-bar .tname').forEach(el =>
244
+ el.addEventListener('click', () => switchTile(el.dataset.tile))
245
+ );
246
+
247
+ // ── Chart ────────────────────────────────────────────────────────────────────
248
+ const chartCanvas = document.getElementById('chart-canvas');
249
+ const chartCtx = chartCanvas.getContext('2d');
250
+ let chartPoints = [];
251
+ let hiddenTiles = new Set();
252
+ let visibleVars = new Set(CHART_VARS.map(v => v.key));
253
+ let legendItems = [];
254
+ const LEGEND_H = 24;
255
+ const AXIS_H = 20; // room for the date axis under the timeseries
256
+ const _MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
257
+
258
+ // Adaptive date ticks: ~6 labels, stepping by years / months / days by span.
259
+ function dateTicks(t0, t1) {
260
+ const D = 86400000, spanD = (t1 - t0) / D, TARGET = 6, out = [];
261
+ const d0 = new Date(t0), d1 = new Date(t1);
262
+ const Y0 = d0.getUTCFullYear(), Y1 = d1.getUTCFullYear();
263
+ if (spanD > 730) { // yearly
264
+ const step = [1,2,5,10,20,50].find(s => (Y1 - Y0) / s <= TARGET) || 100;
265
+ for (let y = Math.ceil(Y0/step)*step; y <= Y1; y += step)
266
+ out.push({ t: Date.UTC(y,0,1), lab: '' + y });
267
+ } else if (spanD > 75) { // monthly
268
+ const months = (Y1 - Y0)*12 + (d1.getUTCMonth() - d0.getUTCMonth());
269
+ const step = [1,2,3,6].find(s => months / s <= TARGET) || 12;
270
+ let y = Y0, m = Math.floor(d0.getUTCMonth()/step)*step, t = Date.UTC(y,m,1);
271
+ while (t <= t1) {
272
+ if (t >= t0) { const dd = new Date(t);
273
+ out.push({ t, lab: _MON[dd.getUTCMonth()] + " '" + (''+dd.getUTCFullYear()).slice(2) }); }
274
+ m += step; if (m >= 12) { m -= 12; y++; } t = Date.UTC(y,m,1);
275
+ }
276
+ } else { // daily
277
+ const step = [1,2,5,10,15,30].find(s => spanD / s <= TARGET) || 30;
278
+ for (let t = Math.ceil(t0/(step*D))*(step*D); t <= t1; t += step*D) {
279
+ const dd = new Date(t); out.push({ t, lab: dd.getUTCDate() + " " + _MON[dd.getUTCMonth()] });
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+
285
+ // Plot toggles β€” one checkbox per timeseries variable, built from CHART_VARS.
286
+ (function buildVarToggles() {
287
+ const bar = document.getElementById('var-bar');
288
+ if (!bar) return;
289
+ CHART_VARS.forEach(v => {
290
+ const label = document.createElement('label');
291
+ label.className = 'tchk';
292
+ label.innerHTML = '<input type="checkbox" data-var="' + v.key + '" checked>' + v.label;
293
+ label.querySelector('input').addEventListener('change', e => {
294
+ if (e.target.checked) visibleVars.add(v.key); else visibleVars.delete(v.key);
295
+ drawChart();
296
+ });
297
+ bar.appendChild(label);
298
+ });
299
+ })();
300
+
301
+ function drawChart() {
302
+ const ROW_H = 60;
303
+ const vars = CHART_VARS.filter(v => visibleVars.has(v.key));
304
+ const nVars = vars.length;
305
+ const W = chartCanvas.offsetWidth || 400;
306
+ const totalH = LEGEND_H + nVars * ROW_H + AXIS_H;
307
+ chartCanvas.width = W;
308
+ chartCanvas.height = totalH;
309
+ chartCanvas.style.height = totalH + 'px';
310
+
311
+ const padL = 44, padR = 10, padT = 8, padB = 6;
312
+ const cw = W - padL - padR;
313
+
314
+ chartCtx.fillStyle = '#0d1117';
315
+ chartCtx.fillRect(0, 0, W, totalH);
316
+
317
+ // ── Legend ──────────────────────────────────────────────────────────────────
318
+ legendItems = [];
319
+ let lx = padL;
320
+ chartCtx.font = '9px monospace';
321
+ Object.entries(TILE_COLORS).forEach(([tid, color]) => {
322
+ const hidden = hiddenTiles.has(tid);
323
+ const tw = chartCtx.measureText(tid).width;
324
+ const itemW = 10 + 4 + tw + 10;
325
+ chartCtx.globalAlpha = hidden ? 0.35 : 1;
326
+ chartCtx.fillStyle = color;
327
+ chartCtx.beginPath();
328
+ chartCtx.arc(lx + 5, LEGEND_H / 2, 4, 0, Math.PI * 2);
329
+ chartCtx.fill();
330
+ chartCtx.fillStyle = hidden ? '#555' : '#ccc';
331
+ chartCtx.textAlign = 'left';
332
+ chartCtx.fillText(tid, lx + 12, LEGEND_H / 2 + 4);
333
+ if (hidden) {
334
+ chartCtx.strokeStyle = '#555'; chartCtx.lineWidth = 1; chartCtx.globalAlpha = 0.5;
335
+ chartCtx.beginPath(); chartCtx.moveTo(lx, LEGEND_H / 2); chartCtx.lineTo(lx + itemW - 8, LEGEND_H / 2); chartCtx.stroke();
336
+ }
337
+ chartCtx.globalAlpha = 1;
338
+ legendItems.push({ tid, x: lx, w: itemW });
339
+ lx += itemW;
340
+ });
341
+
342
+ // ── Separator ───────────────────────────────────────────────────────────────
343
+ chartCtx.strokeStyle = '#333'; chartCtx.lineWidth = 1;
344
+ chartCtx.beginPath(); chartCtx.moveTo(0, LEGEND_H); chartCtx.lineTo(W, LEGEND_H); chartCtx.stroke();
345
+
346
+ // ── Time series rows ────────────────────────────────────────────────────────
347
+ const allDates = SCENES.map(s => s.date ? new Date(s.date).getTime() : null);
348
+ const inRange = t => t != null && t >= dateStart && t <= dateEnd;
349
+ if (!allDates.some(inRange)) return;
350
+ const tMin = dateStart, tMax = dateEnd; // x-axis spans the selected range
351
+ const txT = t => padL + ((t - tMin) / (tMax - tMin || 1)) * cw;
352
+ const tx = i => txT(allDates[i]);
353
+
354
+ const sc = activeScenes();
355
+ const currentGlobal = sc.length ? SCENE_IDX[sc[current]?.scene_id] : -1;
356
+
357
+ chartPoints = [];
358
+
359
+ vars.forEach(({ key, label }, vi) => {
360
+ const yOff = LEGEND_H + vi * ROW_H;
361
+ const ch = ROW_H - padT - padB;
362
+
363
+ const allVals = SCENES.map(s => s.stats[key]).filter(v => v != null);
364
+ if (!allVals.length) return;
365
+ const vMax = Math.max(...allVals) * 1.1 || 1;
366
+ const ty = v => yOff + padT + ch - (v / vMax) * ch;
367
+
368
+ if (vi > 0) {
369
+ chartCtx.strokeStyle = '#222'; chartCtx.lineWidth = 1;
370
+ chartCtx.beginPath(); chartCtx.moveTo(0, yOff); chartCtx.lineTo(W, yOff); chartCtx.stroke();
371
+ }
372
+
373
+ chartCtx.font = '8px monospace'; chartCtx.textAlign = 'right';
374
+ chartCtx.fillStyle = '#666';
375
+ chartCtx.fillText(vMax.toFixed(2), padL - 3, yOff + padT + 4);
376
+ chartCtx.fillText('0', padL - 3, yOff + padT + ch);
377
+ chartCtx.font = 'bold 10px system-ui'; chartCtx.fillStyle = '#bbb'; chartCtx.textAlign = 'left';
378
+ chartCtx.fillText(label, padL + 4, yOff + padT + 4);
379
+
380
+ chartCtx.strokeStyle = '#1e1e2e'; chartCtx.lineWidth = 1;
381
+ chartCtx.beginPath(); chartCtx.moveTo(padL, yOff + padT + ch); chartCtx.lineTo(padL + cw, yOff + padT + ch); chartCtx.stroke();
382
+
383
+ const byTile = {};
384
+ SCENES.forEach((s, i) => {
385
+ const v = s.stats[key];
386
+ if (v == null || !inRange(allDates[i])) return; // date-range filter
387
+ const tid = s.tile_id || '_';
388
+ (byTile[tid] = byTile[tid] || []).push({ i, x: tx(i), y: ty(v) });
389
+ });
390
+
391
+ Object.entries(byTile).forEach(([tid, pts]) => {
392
+ if (hiddenTiles.has(tid)) return;
393
+ const color = TILE_COLORS[tid] || '#aaa';
394
+ const isActiveTile = !activeTile || tid === activeTile;
395
+ chartCtx.strokeStyle = color;
396
+ chartCtx.globalAlpha = isActiveTile ? 0.4 : 0.12;
397
+ chartCtx.lineWidth = 1;
398
+ chartCtx.beginPath();
399
+ pts.forEach((p, j) => j === 0 ? chartCtx.moveTo(p.x, p.y) : chartCtx.lineTo(p.x, p.y));
400
+ chartCtx.stroke();
401
+ pts.forEach(p => {
402
+ const isCurrent = p.i === currentGlobal;
403
+ chartCtx.globalAlpha = isCurrent ? 1 : (isActiveTile ? 0.7 : 0.18);
404
+ chartCtx.fillStyle = color;
405
+ chartCtx.beginPath();
406
+ chartCtx.arc(p.x, p.y, isCurrent ? 4 : 1.5, 0, Math.PI * 2);
407
+ chartCtx.fill();
408
+ if (isCurrent) { chartCtx.strokeStyle = 'white'; chartCtx.lineWidth = 1.5; chartCtx.stroke(); }
409
+ if (isActiveTile && vi === 0) chartPoints.push({ i: p.i, x: p.x, y: p.y, tid });
410
+ });
411
+ });
412
+ chartCtx.globalAlpha = 1;
413
+ });
414
+
415
+ // ── Date axis ─────────────────────────────────────────────────────────────
416
+ const yAxis = LEGEND_H + nVars * ROW_H;
417
+ chartCtx.strokeStyle = '#333'; chartCtx.lineWidth = 1;
418
+ chartCtx.beginPath(); chartCtx.moveTo(padL, yAxis); chartCtx.lineTo(padL + cw, yAxis); chartCtx.stroke();
419
+ chartCtx.font = '9px system-ui'; chartCtx.textAlign = 'center';
420
+ for (const { t, lab } of dateTicks(tMin, tMax)) {
421
+ const x = txT(t);
422
+ if (x < padL - 1 || x > padL + cw + 1) continue;
423
+ chartCtx.strokeStyle = '#444';
424
+ chartCtx.beginPath(); chartCtx.moveTo(x, yAxis); chartCtx.lineTo(x, yAxis + 4); chartCtx.stroke();
425
+ chartCtx.fillStyle = '#8a8a99'; chartCtx.fillText(lab, x, yAxis + 15);
426
+ }
427
+ }
428
+
429
+ chartCanvas.onclick = e => {
430
+ const r = chartCanvas.getBoundingClientRect();
431
+ const mx = (e.clientX - r.left) * (chartCanvas.width / r.width);
432
+ const my = (e.clientY - r.top) * (chartCanvas.height / r.height);
433
+
434
+ // Legend area β€” toggle tile visibility
435
+ if (my < LEGEND_H) {
436
+ for (const item of legendItems) {
437
+ if (mx >= item.x && mx < item.x + item.w) {
438
+ if (hiddenTiles.has(item.tid)) hiddenTiles.delete(item.tid);
439
+ else hiddenTiles.add(item.tid);
440
+ const cb = document.querySelector('.tchk input[data-tile="' + item.tid + '"]');
441
+ if (cb) cb.checked = !hiddenTiles.has(item.tid); // keep checkbox in sync
442
+ drawChart();
443
+ drawMapDots();
444
+ return;
445
+ }
446
+ }
447
+ return;
448
+ }
449
+
450
+ // Time series area β€” navigate to nearest scene
451
+ let closest = null, minDx = Infinity, closestTile = null;
452
+ chartPoints.forEach(p => {
453
+ const d = Math.abs(p.x - mx);
454
+ if (d < minDx) { minDx = d; closest = p.i; closestTile = p.tid; }
455
+ });
456
+ if (closest !== null) {
457
+ if (closestTile && closestTile !== activeTile) switchTile(closestTile);
458
+ const sc = activeScenes();
459
+ const localIdx = sc.findIndex(s => SCENE_IDX[s.scene_id] === closest);
460
+ if (localIdx >= 0) showScene(localIdx);
461
+ }
462
+ };
463
+
464
+ // ── Map ──────────────────────────────────────────────────────────────────────
465
+ const mapCanvas = document.getElementById('map-canvas');
466
+ const mapBg = document.getElementById('map-bg');
467
+ const mapWrap = document.getElementById('map-wrap');
468
+ const mapInner = document.getElementById('map-inner');
469
+ let mapDragMoved = false; // set while panning β†’ not a click
470
+ let mapScale = 1, mapTx = 0, mapTy = 0; // basemap transform state
471
+
472
+ // PNG-pixel β†’ viewport-pixel scale at the current zoom.
473
+ function mapBaseScale() {
474
+ return (mapBg && mapBg.naturalHeight) ? (mapWrap.clientHeight / mapBg.naturalHeight) : 1;
475
+ }
476
+
477
+ function initMap() {
478
+ if (!mapBg || !MAP_POINTS.length || !mapWrap) return;
479
+ mapCanvas.width = mapWrap.clientWidth; // canvas covers the viewport, unscaled
480
+ mapCanvas.height = mapWrap.clientHeight;
481
+ drawMapDots();
482
+ }
483
+
484
+ // Dots live on a viewport-fixed canvas (NOT inside the transformed image), so they
485
+ // keep a constant on-screen size and stay crisp at any zoom.
486
+ function drawMapDots() {
487
+ if (!mapCanvas.width) return;
488
+ const ctx = mapCanvas.getContext('2d');
489
+ ctx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
490
+ const ds = mapBaseScale() * mapScale;
491
+ const sc = activeScenes();
492
+ const currentGlobal = sc.length ? SCENE_IDX[sc[current]?.scene_id] : -1;
493
+
494
+ MAP_POINTS.forEach((p, i) => {
495
+ if (!p) return;
496
+ const tid = SCENES[i].tile_id;
497
+ if (hiddenTiles.has(tid)) return; // checkbox unticked β†’ hide dot
498
+ const x = mapTx + p.x * ds, y = mapTy + p.y * ds;
499
+ if (x < -8 || y < -8 || x > mapCanvas.width + 8 || y > mapCanvas.height + 8) return;
500
+ const isActiveTile = !activeTile || tid === activeTile;
501
+ const isCurrent = i === currentGlobal;
502
+ ctx.globalAlpha = isCurrent ? 1 : (isActiveTile ? 0.7 : 0.3);
503
+ ctx.fillStyle = TILE_COLORS[tid] || '#aaa';
504
+ ctx.beginPath();
505
+ ctx.arc(x, y, isCurrent ? 6 : 4, 0, Math.PI * 2);
506
+ ctx.fill();
507
+ ctx.strokeStyle = isCurrent ? 'white' : 'rgba(255,255,255,0.5)';
508
+ ctx.lineWidth = isCurrent ? 2 : 1;
509
+ ctx.stroke();
510
+ });
511
+ ctx.globalAlpha = 1;
512
+ }
513
+
514
+ if (mapBg) { mapBg.onload = initMap; if (mapBg.complete) initMap(); }
515
+ window.addEventListener('resize', initMap);
516
+
517
+ if (mapCanvas) {
518
+ mapCanvas.onclick = e => {
519
+ if (mapDragMoved) return; // this "click" was actually a pan-drag
520
+ const r = mapCanvas.getBoundingClientRect();
521
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
522
+ const ds = mapBaseScale() * mapScale;
523
+ let closest = null, minD = 16, closestTile = null;
524
+ MAP_POINTS.forEach((p, i) => {
525
+ if (!p || hiddenTiles.has(SCENES[i].tile_id)) return;
526
+ const x = mapTx + p.x * ds, y = mapTy + p.y * ds;
527
+ const d = Math.hypot(x - mx, y - my);
528
+ if (d < minD) { minD = d; closest = i; closestTile = SCENES[i].tile_id; }
529
+ });
530
+ if (closest !== null) {
531
+ if (closestTile && closestTile !== activeTile) switchTile(closestTile);
532
+ const sc = activeScenes();
533
+ const localIdx = sc.findIndex(s => SCENE_IDX[s.scene_id] === closest);
534
+ if (localIdx >= 0) showScene(localIdx);
535
+ }
536
+ };
537
+ }
538
+
539
+ // Pan & zoom: scroll to zoom toward the cursor, drag to pan, double-click to reset.
540
+ // Only the basemap image is transformed; dots are redrawn (constant size) each frame.
541
+ (function() {
542
+ if (!mapWrap || !mapInner) return;
543
+ const MIN = 1, MAX = 12;
544
+ const apply = () => {
545
+ mapInner.style.transform = 'translate(' + mapTx + 'px,' + mapTy + 'px) scale(' + mapScale + ')';
546
+ drawMapDots();
547
+ };
548
+ function clamp() {
549
+ const w = mapWrap.clientWidth, h = mapWrap.clientHeight;
550
+ mapTx = Math.min(0, Math.max(w - w * mapScale, mapTx));
551
+ mapTy = Math.min(0, Math.max(h - h * mapScale, mapTy));
552
+ }
553
+ mapWrap.addEventListener('wheel', e => {
554
+ e.preventDefault();
555
+ const r = mapWrap.getBoundingClientRect();
556
+ const cx = e.clientX - r.left, cy = e.clientY - r.top, prev = mapScale;
557
+ mapScale = Math.min(MAX, Math.max(MIN, mapScale * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
558
+ const k = mapScale / prev;
559
+ mapTx = cx - (cx - mapTx) * k;
560
+ mapTy = cy - (cy - mapTy) * k;
561
+ clamp(); apply();
562
+ }, { passive: false });
563
+ let dragging = false, sx = 0, sy = 0, startX = 0, startY = 0;
564
+ mapWrap.addEventListener('mousedown', e => {
565
+ dragging = true; mapDragMoved = false;
566
+ startX = e.clientX; startY = e.clientY; sx = e.clientX - mapTx; sy = e.clientY - mapTy;
567
+ mapWrap.classList.add('grabbing');
568
+ });
569
+ window.addEventListener('mousemove', e => {
570
+ if (!dragging) return;
571
+ mapTx = e.clientX - sx; mapTy = e.clientY - sy;
572
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) > 4) mapDragMoved = true;
573
+ clamp(); apply();
574
+ });
575
+ window.addEventListener('mouseup', () => { dragging = false; mapWrap.classList.remove('grabbing'); });
576
+ mapWrap.addEventListener('dblclick', () => { mapScale = 1; mapTx = 0; mapTy = 0; apply(); });
577
+ })();
578
+
579
+ // ── Scene browser ─────────────────────────────────────────────────────────────
580
+ function labelFromPath(p) {
581
+ return p.split('/').pop().replace('.png', '').replace(/_/g, ' ');
582
+ }
583
+
584
+ function showScene(idx) {
585
+ const sc = activeScenes();
586
+ if (!sc.length) return;
587
+ current = (idx + sc.length) % sc.length;
588
+ const s = sc[current];
589
+
590
+ document.getElementById('si-date').textContent = s.date || 'β€”';
591
+ document.getElementById('si-sub').textContent = [s.tile_id, s.satellite].filter(Boolean).join(' Β· ');
592
+ document.getElementById('progress').textContent =
593
+ `${s.tile_id || ''} Β· Scene ${current + 1} / ${sc.length}`;
594
+
595
+ const statLines = Object.entries({
596
+ cloud_frac: 'Cloud frac', tau__mean: 'Ο„ mean',
597
+ r_eff_liq__p050: 'r_eff liq p50', ice_liq_ratio__mean: 'Ice/liq',
598
+ }).filter(([k]) => s.stats[k] != null)
599
+ .map(([k, lbl]) => `<b>${lbl}:</b> ${s.stats[k].toFixed(3)}`);
600
+ document.getElementById('si-stats').innerHTML = statLines.join('<br>');
601
+
602
+ const grid = document.getElementById('layer-grid');
603
+ grid.innerHTML = '';
604
+ const sortedLayers = [...s.layers].sort((a, b) => {
605
+ const aName = a.split('/').pop().replace('.png', '');
606
+ const bName = b.split('/').pop().replace('.png', '');
607
+ if (aName === 'overview') return 1;
608
+ if (bName === 'overview') return -1;
609
+ const ai = LAYER_ORDER.indexOf(aName), bi = LAYER_ORDER.indexOf(bName);
610
+ if (ai !== -1 && bi !== -1) return ai - bi;
611
+ if (ai !== -1) return -1;
612
+ if (bi !== -1) return 1;
613
+ return 0;
614
+ });
615
+ sortedLayers
616
+ .filter(path => visibleLayers.has(path.split('/').pop().replace('.png', '')))
617
+ .forEach(path => {
618
+ const name = path.split('/').pop().replace('.png', '');
619
+ const div = document.createElement('div');
620
+ div.className = 'layer-thumb' + (name === 'overview' ? ' overview' : '');
621
+ const img = document.createElement('img');
622
+ const src = layerURL(s, path);
623
+ img.src = src;
624
+ img.onclick = () => openLightbox(src);
625
+ const lbl = document.createElement('div');
626
+ lbl.className = 'layer-label';
627
+ lbl.textContent = labelFromPath(path);
628
+ div.appendChild(img); div.appendChild(lbl);
629
+ grid.appendChild(div);
630
+ });
631
+
632
+ drawChart();
633
+ drawMapDots();
634
+ preloadScene(current + 1);
635
+ preloadScene(current + 2);
636
+ }
637
+
638
+ // ── Lightbox ──────────────────────────────────────────────────────────────────
639
+ function openLightbox(src) {
640
+ document.getElementById('lb-img').src = src;
641
+ document.getElementById('lightbox').classList.add('active');
642
+ }
643
+ document.getElementById('lb-close').onclick = () =>
644
+ document.getElementById('lightbox').classList.remove('active');
645
+ document.getElementById('lightbox').onclick = e => {
646
+ if (e.target.id === 'lightbox') document.getElementById('lightbox').classList.remove('active');
647
+ };
648
+
649
+ // ── Play / Pause ──────────────────────────────────────────────────────────────
650
+ let playTimer = null;
651
+ const playBtn = document.getElementById('nav-play');
652
+
653
+ function setPlaying(on) {
654
+ if (on) {
655
+ const ms = parseInt(document.getElementById('speed-select').value);
656
+ playTimer = setInterval(() => showScene(current + 1), ms);
657
+ playBtn.textContent = '⏸';
658
+ playBtn.classList.add('playing');
659
+ } else {
660
+ clearInterval(playTimer); playTimer = null;
661
+ playBtn.textContent = 'β–Ά';
662
+ playBtn.classList.remove('playing');
663
+ }
664
+ }
665
+
666
+ playBtn.onclick = () => setPlaying(!playTimer);
667
+ document.getElementById('speed-select').onchange = () => {
668
+ if (playTimer) { setPlaying(false); setPlaying(true); }
669
+ };
670
+
671
+ // ── Navigation ────────────────────────────────────────────────────────────────
672
+ document.getElementById('nav-prev').onclick = () => { setPlaying(false); showScene(current - 1); };
673
+ document.getElementById('nav-next').onclick = () => { setPlaying(false); showScene(current + 1); };
674
+ document.addEventListener('keydown', e => {
675
+ if (e.key === 'ArrowLeft') { setPlaying(false); showScene(current - 1); }
676
+ if (e.key === 'ArrowRight') { setPlaying(false); showScene(current + 1); }
677
+ if (e.key === ' ') { e.preventDefault(); setPlaying(!playTimer); }
678
+ if (e.key === 'Escape') {
679
+ setPlaying(false);
680
+ document.getElementById('lightbox').classList.remove('active');
681
+ }
682
+ });
683
+
684
+ window.addEventListener('resize', () => { drawChart(); initMap(); });
685
+
686
+ // ── Preloader ─────────────────────────────────────────────────────────────────
687
+ const _preloadCache = {};
688
+ function preloadScene(idx) {
689
+ const sc = activeScenes();
690
+ if (!sc.length) return;
691
+ const i = (idx + sc.length) % sc.length;
692
+ if (_preloadCache[sc[i].scene_id]) return;
693
+ _preloadCache[sc[i].scene_id] = true;
694
+ sc[i].layers.forEach(path => { const img = new Image(); img.src = layerURL(sc[i], path); });
695
+ }
696
+
697
+ // ── Init ──────────────────────────────────────────────────────────────────────
698
+ // Select the first tile by default
699
+ const firstTile = Object.keys(TILE_COLORS).sort()[0];
700
+ if (firstTile) switchTile(firstTile);
701
+ else showScene(0);
702
+ </script>
703
+ </body>
704
+ </html>