diff --git a/assets/css/header.css b/assets/css/header.css index d01c4e1..051f6a6 100644 --- a/assets/css/header.css +++ b/assets/css/header.css @@ -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; } diff --git a/assets/js/app.js b/assets/js/app.js index 7c10fa7..722ba24 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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` +
+
+ ${srFrac != null && html``} + ${ssFrac != null && html``} + ${fraction != null && html`
`} +
+
+ `; +} + 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` - `} +
+
+ + <${DayTimeline} + windowStart=${windowStart} + windowEnd=${windowEnd} + simMs=${simMs} + setSimMs=${setSimMs} + hourlyRows=${hourlyRows} + utcOffsetMs=${utcOffsetMs} + /> +
+
`}