Add draggable timeline
This commit is contained in:
fraxle
2026-06-13 14:40:23 +01:00
parent d8465675cd
commit 0532ed58a2
2 changed files with 232 additions and 48 deletions
+115 -16
View File
@@ -27,7 +27,7 @@ import {
utciCategory, UTCI_BANDS, bandGradient,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, BUILDING_TYPES,
confidenceBand, moonGlyph,
confidenceBand, moonGlyph, skyFillForElev,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getCellTagEvents, getUpcomingEvents } from './events.js';
@@ -79,6 +79,90 @@ function interpolateRowAt(rows, t) {
};
}
// ── Draggable 24-hour timeline ──────────────────────────────────────────
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
const trackRef = useRef(null);
const draggingRef = useRef(false);
const totalMs = windowEnd - windowStart;
const toFrac = (ms) => Math.max(0, Math.min(1, (ms - windowStart) / totalMs));
const fraction = simMs != null ? toFrac(simMs) : null;
// Find sunrise/sunset within the window by scanning hourly elevation sign changes
let sunriseMs = null, sunsetMs = null;
const winRows = hourlyRows.filter(r => +r.dt >= windowStart - 3600000 && +r.dt <= windowEnd + 3600000);
for (let i = 1; i < winRows.length; i++) {
const a = winRows[i - 1], b = winRows[i];
if (a.elev <= 0 && b.elev > 0 && sunriseMs === null) {
const f = (-a.elev) / (b.elev - a.elev);
const t = +a.dt + f * (+b.dt - +a.dt);
if (t >= windowStart && t <= windowEnd) sunriseMs = t;
}
if (a.elev > 0 && b.elev <= 0 && sunsetMs === null) {
const f = a.elev / (a.elev - b.elev);
const t = +a.dt + f * (+b.dt - +a.dt);
if (t >= windowStart && t <= windowEnd) sunsetMs = t;
}
}
const srFrac = sunriseMs != null ? toFrac(sunriseMs) : null;
const ssFrac = sunsetMs != null ? toFrac(sunsetMs) : null;
// Build gradient from actual per-hour sky colours so the bar mirrors what
// the scope would show at each moment across the window.
const bgGradient = (() => {
if (!winRows.length) return '#0a0810';
const stops = winRows.map(r => {
const pct = (toFrac(+r.dt) * 100).toFixed(2);
const col = skyFillForElev(r.elev, r.dt.getUTCHours() < 12);
return `${col} ${pct}%`;
});
return `linear-gradient(to right, ${stops.join(', ')})`;
})();
// Tick labels at 0h / 6h / 12h / 18h / 24h of the window (actual local clock times)
const ticks = [0, 6, 12, 18, 24].map(h => {
const d = new Date(windowStart + h * 3600000 + utcOffsetMs);
return d.toISOString().slice(11, 16);
});
const msFromClientX = (clientX) => {
const rect = trackRef.current.getBoundingClientRect();
return windowStart + Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * totalMs;
};
const onPointerDown = (e) => {
if (e.button !== 0) return;
e.preventDefault();
try { trackRef.current.setPointerCapture(e.pointerId); } catch (_) {}
draggingRef.current = true;
document.body.classList.add('tl-scrubbing');
setSimMs(msFromClientX(e.clientX));
};
const onPointerMove = (e) => {
if (!draggingRef.current) return;
setSimMs(msFromClientX(e.clientX));
};
const onPointerUp = () => {
draggingRef.current = false;
document.body.classList.remove('tl-scrubbing');
};
const onPointerCancel = onPointerUp;
return html`
<div class="day-timeline">
<div class="day-timeline-track"
ref=${trackRef}
style=${{ background: bgGradient }}
onPointerDown=${onPointerDown}
onPointerMove=${onPointerMove}
onPointerUp=${onPointerUp}
onPointerCancel=${onPointerCancel}>
${srFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${srFrac * 100}%` }}>↑</span>`}
${ssFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${ssFrac * 100}%` }}>↓</span>`}
${fraction != null && html`<div class="day-timeline-thumb" style=${{ left: `${fraction * 100}%` }}></div>`}
</div>
</div>
`;
}
export function UTCIForecast() {
// ── STATE + EFFECTS ───────────────────────────────────────────────────
@@ -221,6 +305,9 @@ export function UTCIForecast() {
// `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
const [playing, setPlaying] = useState(false);
const [simMs, setSimMs] = useState(null);
// Reset scope to live when the user switches days (scope window is always
// "now → +24h" regardless of selected day, so a stale scrub position is confusing).
useEffect(() => { if (!playing) setSimMs(null); }, [selectedDay]);
useEffect(() => {
if (!playing) return;
const start = now.getTime();
@@ -325,12 +412,15 @@ export function UTCIForecast() {
// Interpolated row at the precise current instant — used for the scope
// display even outside playback so there's no jump when play is pressed.
const nowRow = interpolateRowAt(hourlyRows, now.getTime());
// During time-lapse, advance from simMs; otherwise fall back to nowRow/currentRow.
const simRow = (playing && simMs != null) ? interpolateRowAt(hourlyRows, simMs) : null;
// 24-hour window anchored to now — shared by the play effect and the timeline.
const windowStart = now.getTime();
const windowEnd = windowStart + PLAYBACK_WINDOW_MS;
// simMs drives the scope whether set by auto-play or by dragging the timeline.
const simRow = simMs != null ? interpolateRowAt(hourlyRows, simMs) : null;
const scopeRow = simRow || nowRow || currentRow;
// Elevation computed continuously from the simulated instant so the sun
// starts exactly where liveElev left off (same solarElevationDeg call).
const scopeElev = (playing && simMs != null && location?.lat != null)
const scopeElev = (simMs != null && location?.lat != null)
? solarElevationDeg(location.lat, location.lon, new Date(simMs))
: (liveElev ?? currentRow?.elev ?? 0);
const scopeDt = simRow ? simRow.dt : now;
@@ -338,11 +428,9 @@ export function UTCIForecast() {
// 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. The playback is a ROLLING 24h
// window from now, so it crosses into the next day — prefix the weekday
// (e.g. "Wed 14:30") so it's clear the scope has rolled past midnight and is
// showing tomorrow's hour, not today's table row for the same clock time.
const simClock = (playing && scopeDt)
// Local clock label for the playback button / timeline. The window is a ROLLING
// 24h from now so it may cross midnight — prefix weekday ("Wed 14:30").
const simClock = (simMs != null && scopeDt)
? (() => {
const d = new Date(scopeDt.getTime() + utcOffsetMs);
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
@@ -611,13 +699,24 @@ export function UTCIForecast() {
wd=${scopeRow?.wd ?? null}
/>
${hourlyRows.length > 0 && html`
<button type="button"
class=${`scope-play ${playing ? 'is-playing' : ''}`}
onClick=${() => { if (playing) { setPlaying(false); setSimMs(null); } else setPlaying(true); }}
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 class="scope-daycycle-wrap">
<div class="scope-daycycle-pill">
<button type="button"
class=${`scope-play ${(playing || simMs != null) ? 'is-playing' : ''}`}
onClick=${() => { if (playing || simMs != null) { setPlaying(false); setSimMs(null); } else setPlaying(true); }}
title=${playing ? 'Stop time-lapse' : 'Play a 24-hour time-lapse on the scope'}>
<span class="scope-play-icon">${(playing || simMs != null) ? '◼' : '▶'}</span><span class="scope-play-label">${simClock ?? 'Day cycle'}</span>
</button>
<${DayTimeline}
windowStart=${windowStart}
windowEnd=${windowEnd}
simMs=${simMs}
setSimMs=${setSimMs}
hourlyRows=${hourlyRows}
utcOffsetMs=${utcOffsetMs}
/>
</div>
</div>`}
</div>
<div class="header-right">