2.8
Animated sunscope with day cycle
This commit is contained in:
+92
-6
@@ -39,6 +39,43 @@ import { exportDayXls } from './export.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// ── Scope time-lapse playback ───────────────────────────────────────────
|
||||
// Drives the big scope through the next 24h so you can watch the sky/ground
|
||||
// gradients and weather evolve. Speed: 15 simulated minutes every 5 real
|
||||
// seconds (≈ a full day in 8 minutes). UI advances smoothly every 200ms.
|
||||
const PLAYBACK_SIM_MS_PER_REAL_MS = 180; // 15 min / 5 s = 180×
|
||||
const PLAYBACK_STEP_MS = 200; // UI update cadence
|
||||
const PLAYBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Interpolates the hourly rows to an arbitrary instant (ms). Returns a row
|
||||
// with smoothly blended elevation / cloud / precip so the scope animates
|
||||
// continuously rather than snapping hour to hour.
|
||||
function interpolateRowAt(rows, t) {
|
||||
if (!rows || rows.length === 0) return null;
|
||||
if (t <= +rows[0].dt) return rows[0];
|
||||
if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1];
|
||||
let lo = rows[0], hi = rows[1];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; }
|
||||
}
|
||||
const f = (t - +lo.dt) / (+hi.dt - +lo.dt);
|
||||
const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f;
|
||||
return {
|
||||
...lo,
|
||||
dt: new Date(t),
|
||||
elev: L(lo.elev, hi.elev),
|
||||
glob: L(lo.glob, hi.glob),
|
||||
utciAdj: L(lo.utciAdj, hi.utciAdj),
|
||||
cc: L(lo.cc, hi.cc),
|
||||
ccLow: L(lo.ccLow, hi.ccLow),
|
||||
ccMid: L(lo.ccMid, hi.ccMid),
|
||||
ccHigh: L(lo.ccHigh, hi.ccHigh),
|
||||
precip: L(lo.precip, hi.precip),
|
||||
snow: L(lo.snow, hi.snow),
|
||||
visKm: L(lo.visKm, hi.visKm),
|
||||
};
|
||||
}
|
||||
|
||||
export function UTCIForecast() {
|
||||
|
||||
// ── STATE + EFFECTS ───────────────────────────────────────────────────
|
||||
@@ -120,6 +157,25 @@ export function UTCIForecast() {
|
||||
};
|
||||
}, [searchOpen, setSearchQuery]);
|
||||
|
||||
// ─── SCOPE TIME-LAPSE ──────────────────────────────────────────────────
|
||||
// `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [simMs, setSimMs] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const start = now.getTime();
|
||||
const end = start + PLAYBACK_WINDOW_MS;
|
||||
setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev);
|
||||
const id = setInterval(() => {
|
||||
setSimMs(prev => {
|
||||
let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS;
|
||||
if (next > end) next = start; // loop back to "now"
|
||||
return next;
|
||||
});
|
||||
}, PLAYBACK_STEP_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [playing]);
|
||||
|
||||
// Columns that mark the start of a logical group - used to draw a faint
|
||||
// vertical border separating groups in the forecast table.
|
||||
const GROUP_ORDER = {
|
||||
@@ -205,6 +261,21 @@ export function UTCIForecast() {
|
||||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||||
// Derived from currentRow and the active day - no side effects.
|
||||
const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
|
||||
|
||||
// During time-lapse, the scope reads an interpolated row at the simulated
|
||||
// instant instead of the live "now" row. Everything else stays on real now.
|
||||
const simRow = (playing && simMs != null) ? interpolateRowAt(hourlyRows, simMs) : null;
|
||||
const scopeRow = simRow || currentRow;
|
||||
const scopeElev = simRow ? simRow.elev : (liveElev ?? currentRow?.elev ?? 0);
|
||||
const scopeDt = simRow ? simRow.dt : now;
|
||||
const scopeCat = simRow ? utciCategory(simRow.utciAdj) : currentCat;
|
||||
// Synthesize a storm overlay (lightning) when the simulated hour is wet;
|
||||
// outside playback keep the real active event.
|
||||
const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent;
|
||||
// Local clock label for the playback button, e.g. "14:30".
|
||||
const simClock = (playing && scopeDt)
|
||||
? new Date(scopeDt.getTime() + utcOffsetMs).toISOString().slice(11, 16)
|
||||
: null;
|
||||
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
|
||||
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
|
||||
const glanceSummary = computeGlanceSummary(
|
||||
@@ -440,15 +511,30 @@ export function UTCIForecast() {
|
||||
|
||||
<div class="scope-col">
|
||||
<${ScopeReticle}
|
||||
value=${currentRow?.utciAdj ?? null}
|
||||
cat=${currentCat}
|
||||
value=${scopeRow?.utciAdj ?? null}
|
||||
cat=${scopeCat}
|
||||
loading=${loading}
|
||||
elev=${liveElev ?? currentRow?.elev ?? 0}
|
||||
dt=${now}
|
||||
glob=${currentRow?.glob ?? 0}
|
||||
activeEvent=${lensEvent}
|
||||
elev=${scopeElev}
|
||||
dt=${scopeDt}
|
||||
glob=${scopeRow?.glob ?? 0}
|
||||
activeEvent=${scopeEvent}
|
||||
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
|
||||
cc=${scopeRow?.cc ?? null}
|
||||
ccLow=${scopeRow?.ccLow ?? null}
|
||||
ccMid=${scopeRow?.ccMid ?? null}
|
||||
ccHigh=${scopeRow?.ccHigh ?? null}
|
||||
precip=${scopeRow?.precip ?? 0}
|
||||
snow=${scopeRow?.snow ?? 0}
|
||||
visKm=${scopeRow?.visKm ?? null}
|
||||
/>
|
||||
${hourlyRows.length > 0 && html`
|
||||
<button type="button"
|
||||
class=${`scope-play ${playing ? 'is-playing' : ''}`}
|
||||
onClick=${() => setPlaying(p => !p)}
|
||||
title=${playing ? 'Stop time-lapse' : 'Play a 24-hour time-lapse on the scope'}>
|
||||
<span class="scope-play-icon">${playing ? '◼' : '▶'}</span>
|
||||
<span class="scope-play-label">${playing ? simClock : 'Day cycle'}</span>
|
||||
</button>`}
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
+151
-4
File diff suppressed because one or more lines are too long
@@ -82,10 +82,8 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||
}
|
||||
|
||||
if (id === 'wet') {
|
||||
const sl = [-55,-30,-5,20,45,65].map(function(x){
|
||||
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
|
||||
}).join('');
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
|
||||
// Rain is now drawn continuously on the scope FX canvas; no static overlay.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id === 'wind') {
|
||||
@@ -96,12 +94,10 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||
}
|
||||
|
||||
if (id === 'storm') {
|
||||
// Driving rain streaks plus a forked lightning bolt.
|
||||
const sl = [-55,-30,-5,20,45,65].map(function(x){
|
||||
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-22) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.55)" stroke-width="1" stroke-linecap="round" />';
|
||||
}).join('');
|
||||
// Driving rain + lightning flashes are drawn on the FX canvas; keep just
|
||||
// the static forked bolt as the event signifier.
|
||||
const bolt = '<path d="M ' + (cx+6) + ' ' + (cy-lensR+18) + ' L ' + (cx-14) + ' ' + (cy+8) + ' L ' + (cx-2) + ' ' + (cy+8) + ' L ' + (cx-18) + ' ' + (cy+lensR-14) + ' L ' + (cx+18) + ' ' + (cy-2) + ' L ' + (cx+4) + ' ' + (cy-2) + ' Z" fill="rgba(255,224,120,0.7)" stroke="rgba(255,200,60,0.8)" stroke-width="1" stroke-linejoin="round" />';
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + sl + bolt + '</g>';
|
||||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + bolt + '</g>';
|
||||
}
|
||||
|
||||
if (id === 'frost') {
|
||||
|
||||
Reference in New Issue
Block a user