3.0.1
Add draggable timeline
This commit is contained in:
+117
-32
@@ -235,50 +235,131 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Time-lapse toggle beneath the dial. Brass pill matching the scope bezel. */
|
||||
.scope-play {
|
||||
display: inline-flex;
|
||||
/* Outer wrapper — matches scope dial width at every breakpoint */
|
||||
.scope-daycycle-wrap {
|
||||
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;
|
||||
gap: 7px;
|
||||
padding: 4px 12px;
|
||||
gap: 8px;
|
||||
padding: 4px 7px 4px 10px;
|
||||
background: rgba(40, 28, 10, 0.55);
|
||||
border: 1px solid rgba(200, 146, 42, 0.5);
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 28, 10, 0.55);
|
||||
color: #e8d8c0;
|
||||
box-sizing: border-box;
|
||||
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-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.scope-play:hover {
|
||||
background: rgba(16, 10, 3, 0.8);
|
||||
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: #160d03;
|
||||
color: #ffe6b0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.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; }
|
||||
/* While playing, the simulated clock is the readout — same size as the idle
|
||||
label, but monospaced + tabular so the digits don't jitter as they tick. */
|
||||
.scope-play.is-playing .scope-play-label {
|
||||
/* Fixed-width label prevents the bar from shifting when text swaps */
|
||||
.scope-play-label {
|
||||
display: inline-block;
|
||||
min-width: 58px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
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;
|
||||
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.
|
||||
@@ -547,6 +628,10 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.scope-daycycle-wrap {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.scope-env-row {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
+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