3.0.1
Add draggable timeline
This commit is contained in:
+115
-16
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user