regular index.html shell
Browse files- 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">←</button>
|
| 110 |
+
<button id="nav-play">▶</button>
|
| 111 |
+
<button id="nav-next">→</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">✕</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>
|