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
+117 -32
View File
@@ -235,50 +235,131 @@
gap: 8px; gap: 8px;
} }
/* Time-lapse toggle beneath the dial. Brass pill matching the scope bezel. */ /* Outer wrapper — matches scope dial width at every breakpoint */
.scope-play { .scope-daycycle-wrap {
display: inline-flex; display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 270px; /* matches .scope-ring desktop size */
}
/* Unified pill: icon on left, bar fills the right */
.scope-daycycle-pill {
display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
padding: 4px 12px; padding: 4px 7px 4px 10px;
background: rgba(40, 28, 10, 0.55);
border: 1px solid rgba(200, 146, 42, 0.5); border: 1px solid rgba(200, 146, 42, 0.5);
border-radius: 999px; border-radius: 999px;
background: rgba(40, 28, 10, 0.55); box-sizing: border-box;
color: #e8d8c0; width: 100%;
}
/* Play/stop button — text + icon, no own background (pill provides it) */
.scope-play {
flex-shrink: 0;
background: none;
border: none;
color: rgba(220, 195, 145, 0.8);
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.4px;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s; padding: 0;
} line-height: 1;
.scope-play:hover { white-space: nowrap;
background: rgba(16, 10, 3, 0.8); display: flex;
border-color: rgba(200, 146, 42, 0.85); align-items: center;
color: #f5edd6; gap: 5px;
} transition: color 0.15s;
.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: #160d03;
color: #ffe6b0;
} }
.scope-play:hover { color: #f5edd6; }
.scope-play.is-playing { color: #ffd98a; }
.scope-play.is-playing:hover { color: #ffe6b0; }
.scope-play-icon { font-size: 9px; line-height: 1; } .scope-play-icon { font-size: 9px; line-height: 1; }
/* While playing, the simulated clock is the readout — same size as the idle /* Fixed-width label prevents the bar from shifting when text swaps */
label, but monospaced + tabular so the digits don't jitter as they tick. */ .scope-play-label {
.scope-play.is-playing .scope-play-label { display: inline-block;
min-width: 58px;
font-family: 'JetBrains Mono', ui-monospace, monospace; font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
min-width: 40px; }
text-align: center;
/* Kill all transitions while the user is scrubbing the timeline */
body.tl-scrubbing * { transition: none !important; }
/* Timeline — fills the row beside the play icon, ticks sit below the track */
.day-timeline {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
user-select: none;
-webkit-user-select: none;
}
.day-timeline-track {
position: relative;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.35);
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.6);
cursor: pointer;
overflow: visible;
}
.day-timeline-thumb {
position: absolute;
top: -5px;
width: 3px;
height: 26px;
background: #fff8e8;
border-radius: 2px;
transform: translateX(-50%);
pointer-events: none;
box-shadow: 0 0 6px rgba(255, 230, 150, 0.8), 0 0 0 1px rgba(0,0,0,0.3);
}
.day-timeline-label {
position: absolute;
bottom: calc(100% + 5px);
left: 50%;
transform: translateX(-50%);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #ffd98a; color: #ffd98a;
white-space: nowrap;
pointer-events: none;
background: rgba(20, 12, 4, 0.75);
padding: 2px 5px;
border-radius: 4px;
border: 1px solid rgba(200, 146, 42, 0.4);
}
.day-timeline-sun-marker {
position: absolute;
top: -13px;
font-size: 9px;
color: #ffd98a;
transform: translateX(-50%);
pointer-events: none;
line-height: 1;
text-shadow: 0 0 4px rgba(0,0,0,0.8);
}
.day-timeline-ticks {
display: flex;
justify-content: space-between;
/* indent to align with bar start (icon ≈ 10px + gap 8px + pill padding 7px) */
padding: 0 8px 0 27px;
}
.day-timeline-tick {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9px;
color: rgba(220, 200, 160, 0.65);
font-variant-numeric: tabular-nums;
line-height: 1;
} }
/* The environment selector sits just below the dial, centred under it. /* The environment selector sits just below the dial, centred under it.
@@ -547,6 +628,10 @@
margin: 0 auto; margin: 0 auto;
} }
.scope-daycycle-wrap {
max-width: 250px;
}
.scope-env-row { .scope-env-row {
width: 250px; width: 250px;
} }
+113 -14
View File
@@ -27,7 +27,7 @@ import {
utciCategory, UTCI_BANDS, bandGradient, utciCategory, UTCI_BANDS, bandGradient,
SKIN_TYPES, sunburnMinutes, burnLabel, SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, BUILDING_TYPES, VEHICLE_TYPES, BUILDING_TYPES,
confidenceBand, moonGlyph, confidenceBand, moonGlyph, skyFillForElev,
} from './utils.js'; } from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getCellTagEvents, getUpcomingEvents } from './events.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() { export function UTCIForecast() {
// ── STATE + EFFECTS ─────────────────────────────────────────────────── // ── STATE + EFFECTS ───────────────────────────────────────────────────
@@ -221,6 +305,9 @@ export function UTCIForecast() {
// `playing` toggles the time-lapse; `simMs` is the simulated instant shown. // `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [simMs, setSimMs] = useState(null); 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(() => { useEffect(() => {
if (!playing) return; if (!playing) return;
const start = now.getTime(); const start = now.getTime();
@@ -325,12 +412,15 @@ export function UTCIForecast() {
// Interpolated row at the precise current instant — used for the scope // Interpolated row at the precise current instant — used for the scope
// display even outside playback so there's no jump when play is pressed. // display even outside playback so there's no jump when play is pressed.
const nowRow = interpolateRowAt(hourlyRows, now.getTime()); const nowRow = interpolateRowAt(hourlyRows, now.getTime());
// During time-lapse, advance from simMs; otherwise fall back to nowRow/currentRow. // 24-hour window anchored to now — shared by the play effect and the timeline.
const simRow = (playing && simMs != null) ? interpolateRowAt(hourlyRows, simMs) : null; 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; const scopeRow = simRow || nowRow || currentRow;
// Elevation computed continuously from the simulated instant so the sun // Elevation computed continuously from the simulated instant so the sun
// starts exactly where liveElev left off (same solarElevationDeg call). // 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)) ? solarElevationDeg(location.lat, location.lon, new Date(simMs))
: (liveElev ?? currentRow?.elev ?? 0); : (liveElev ?? currentRow?.elev ?? 0);
const scopeDt = simRow ? simRow.dt : now; const scopeDt = simRow ? simRow.dt : now;
@@ -338,11 +428,9 @@ export function UTCIForecast() {
// Synthesize a storm overlay (lightning) when the simulated hour is wet; // Synthesize a storm overlay (lightning) when the simulated hour is wet;
// outside playback keep the real active event. // outside playback keep the real active event.
const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent; const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent;
// Local clock label for the playback button. The playback is a ROLLING 24h // Local clock label for the playback button / timeline. The window is a ROLLING
// window from now, so it crosses into the next day — prefix the weekday // 24h from now so it may cross midnight — prefix weekday ("Wed 14:30").
// (e.g. "Wed 14:30") so it's clear the scope has rolled past midnight and is const simClock = (simMs != null && scopeDt)
// showing tomorrow's hour, not today's table row for the same clock time.
const simClock = (playing && scopeDt)
? (() => { ? (() => {
const d = new Date(scopeDt.getTime() + utcOffsetMs); const d = new Date(scopeDt.getTime() + utcOffsetMs);
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
@@ -611,13 +699,24 @@ export function UTCIForecast() {
wd=${scopeRow?.wd ?? null} wd=${scopeRow?.wd ?? null}
/> />
${hourlyRows.length > 0 && html` ${hourlyRows.length > 0 && html`
<div class="scope-daycycle-wrap">
<div class="scope-daycycle-pill">
<button type="button" <button type="button"
class=${`scope-play ${playing ? 'is-playing' : ''}`} class=${`scope-play ${(playing || simMs != null) ? 'is-playing' : ''}`}
onClick=${() => { if (playing) { setPlaying(false); setSimMs(null); } else setPlaying(true); }} 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'}> 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-icon">${(playing || simMs != null) ? '◼' : '▶'}</span><span class="scope-play-label">${simClock ?? 'Day cycle'}</span>
<span class="scope-play-label">${playing ? simClock : 'Day cycle'}</span> </button>
</button>`} <${DayTimeline}
windowStart=${windowStart}
windowEnd=${windowEnd}
simMs=${simMs}
setSimMs=${setSimMs}
hourlyRows=${hourlyRows}
utcOffsetMs=${utcOffsetMs}
/>
</div>
</div>`}
</div> </div>
<div class="header-right"> <div class="header-right">