diff --git a/assets/css/header.css b/assets/css/header.css index ad52d08..f6885d0 100644 --- a/assets/css/header.css +++ b/assets/css/header.css @@ -191,6 +191,42 @@ display: block; } +/* Wraps the dial SVG + the precip FX canvas so the canvas can overlay the + lens. Sizes itself to the SVG (220px, or 250px on mobile). */ +.scope-wrap { + position: relative; + display: inline-block; + line-height: 0; +} + +/* Animated rain/snow/lightning layer, clipped to the lens circle and sitting + on top of the glass. Inset to 95% to match the lens radius (r=95 of 100). */ +.scope-fx { + position: absolute; + top: 50%; + left: 50%; + width: 95%; + height: 95%; + transform: translate(-50%, -50%); + border-radius: 50%; + pointer-events: none; +} + +/* Cloud sprite strips drift horizontally inside the lens; sprites are + duplicated +200 user-units so the wrap is seamless. */ +@keyframes scopeCloudDrift { + from { transform: translateX(0); } + to { transform: translateX(var(--drift, -200px)); } +} +.scope-cloud-layer { + animation: scopeCloudDrift linear infinite; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .scope-cloud-layer { animation: none; } +} + .scope-col { display: flex; flex-direction: column; @@ -198,6 +234,52 @@ gap: 8px; } +/* Time-lapse toggle beneath the dial. Brass pill matching the scope bezel. */ +.scope-play { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 12px; + border: 1px solid rgba(200, 146, 42, 0.5); + border-radius: 999px; + background: rgba(40, 28, 10, 0.55); + color: #e8d8c0; + font-family: 'Manrope', sans-serif; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.scope-play:hover { + background: rgba(200, 146, 42, 0.18); + border-color: rgba(200, 146, 42, 0.85); + color: #f5edd6; +} +.scope-play.is-playing { + padding: 4px 14px; + background: #241708; /* solid dark so the clock pops */ + border-color: rgba(200, 146, 42, 0.9); + color: #ffd98a; +} +.scope-play.is-playing:hover { + background: #2e1d0a; + color: #ffe6b0; +} +.scope-play-icon { font-size: 9px; line-height: 1; } +/* While playing, the simulated clock is the main readout — big, bright and + monospaced so the digits don't jitter as they tick. */ +.scope-play.is-playing .scope-play-label { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; + min-width: 52px; + text-align: center; + color: #ffd98a; +} + /* The environment selector sits just below the dial, centred under it. Kept out of .scope-col so the dial alone defines that column's height and stays vertically centred against the side columns. */ diff --git a/assets/images/tinycloud.png b/assets/images/tinycloud.png new file mode 100644 index 0000000..f8445dc Binary files /dev/null and b/assets/images/tinycloud.png differ diff --git a/assets/js/app.js b/assets/js/app.js index 913427e..623f0ab 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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() {
dev preview · not shipped · each scope is a real ScopeReticle instance
+ + + + +