// ════════════════════════════════════════════════════════════════════════ // app.js — Main UTCIForecast component. // // This is the top-level Preact component that owns all state, fetches // the forecast, runs the per-hour computations, and renders the page. // // Reading order inside UTCIForecast(): // 1. STATE (useState calls) — bits that change on interaction // 2. EFFECTS (useEffect calls) — runs on search / location change // 3. COMPUTATION (hourlyRows, days, …) — API data → display rows // 4. JSX RETURN (the big html`...`) — actual page markup // // QUICK MAP // ────────────────────────────────────────────────────────────────────── // Forecast length .............. fetch URL contains &forecast_days=14 // Free tier day limit .......... const FREE_DAYS = 3 // Preview the Pro view ......... useState(false) on isPro → flip to true // Starting location ............ useState({...}) on `location` near top // Default columns shown ........ useState({...}) on visibleCols // Page tagline / about copy .... search "utci-tagline" or "utci-about-text" // ════════════════════════════════════════════════════════════════════════ import { h, render, Fragment } from '../vendor/preact.js'; import { useState, useRef, useEffect } from '../vendor/preact-hooks.js'; import htm from '../vendor/htm.js'; import { utciCategory, UTCI_BANDS, petCategory, SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS, confidenceBand, moonGlyph, skyFillForElev, titleCaseText, scoreFillColor, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js'; import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; import { ConfigPanel } from './components/ConfigPanel.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js'; import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js'; import { solarElevationDeg } from './physics.js'; import { exportDayXls } from './export.js'; const html = htm.bind(h); // ── Scope time-lapse playback ─────────────────────────────────────────── // Drives the big scope through the next 24h so you can watch the sky/ground // gradients and weather evolve. Speed: 15 simulated minutes every 1 real // second (≈ a full day in ~1.6 minutes). UI advances smoothly every 200ms. const PLAYBACK_SIM_MS_PER_REAL_MS = 900; // 15 min / 1 s = 900× const PLAYBACK_STEP_MS = 200; // UI update cadence const PLAYBACK_WINDOW_MS = 24 * 60 * 60 * 1000; // Interpolates the hourly rows to an arbitrary instant (ms). Returns a row // with smoothly blended elevation / cloud / precip so the scope animates // continuously rather than snapping hour to hour. function interpolateRowAt(rows, t) { if (!rows || rows.length === 0) return null; if (t <= +rows[0].dt) return rows[0]; if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1]; let lo = rows[0], hi = rows[1]; for (let i = 1; i < rows.length; i++) { if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; } } const f = (t - +lo.dt) / (+hi.dt - +lo.dt); const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f; return { ...lo, dt: new Date(t), elev: L(lo.elev, hi.elev), glob: L(lo.glob, hi.glob), utciAdj: L(lo.utciAdj, hi.utciAdj), // Felt-temp inputs — interpolated so the "Why It Feels" panel breakdown // stays smooth and consistent with the (interpolated) utciAdj category. Ta: L(lo.Ta, hi.Ta), Tmrt: L(lo.Tmrt, hi.Tmrt), va: L(lo.va, hi.va), eh: L(lo.eh, hi.eh), cc: L(lo.cc, hi.cc), ccLow: L(lo.ccLow, hi.ccLow), ccMid: L(lo.ccMid, hi.ccMid), ccHigh: L(lo.ccHigh, hi.ccHigh), precip: L(lo.precip, hi.precip), snow: L(lo.snow, hi.snow), visKm: L(lo.visKm, hi.visKm), }; } // Turn a raw fetch failure into something a person can act on. The banner only // ever appears when nothing at all has loaded (see the loadForecast waterfall // in hooks/useForecast.js), so "showing your last saved forecast" is never the // right thing to say here - there isn't one. function friendlyError(msg) { const m = String(msg || ''); if (typeof navigator !== 'undefined' && navigator.onLine === false) { return "You're offline. Reconnect and try again."; } if (/Failed to fetch|NetworkError|Load failed/i.test(m)) { return "Couldn't reach the weather service. Check your connection and try again."; } const status = m.match(/HTTP (\d{3})/); if (status) { const code = Number(status[1]); if (code === 429) return 'Too many requests just now. Give it a minute and try again.'; if (code >= 500) return "The weather service isn't responding. Try again in a moment."; return `The weather service rejected the request (error ${code}).`; } return m; } // ── 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 ─────────────────────────────────────────────────── // All useState, useEffect, useCallback and useRef logic lives in // useAppState. See hooks/useAppState.js for the full reading order. const { location, setLocationAndSave, recentLocations, useMyLocation, locating, locateError, setLocateError, shareForecast, shareState, forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry, searchQuery, setSearchQuery, searchResults, setSearchResults, searching, selectedDay, setSelectedDay, proPromptDay, setProPromptDay, proPromptSource, setProPromptSource, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, isPro, setIsPro, activeProfile, setActiveProfile, activateProfile, activeCols, visibleCols, setVisibleCols, toggleCol, showDecimals, toggleShowDecimals, welcomeOpen, closeWelcome, openWelcome, restoreOpen, openRestore, closeRestore, panelOpen, openPanel, closePanel, showUnits, toggleShowUnits, tableInterval, setTableInterval, forecastView, setForecastView, activityOptions, placeOptions, workOptions, activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel, skinType, setSkinType, vehicleType, setVehicleType, vehicleVent, setVehicleVent, vehicleSpeed, setVehicleSpeed, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType, furColor, setFurColor, indoorManaged, setIndoorManaged, indoorMode, setIndoorMode, pollenType, setPollenTypeAndSave, utciEnv, setUtciEnv, tableRotated, toggleTableRotated, headStickyRef, headTrackRef, headTableRef, bodyScrollRef, bodyTableRef, tableWrapRef, colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, eventTagPopup, eventTagPopupRef, evSlideIndex, evTransition, evSlideTo, handleEventTagClick, handleEventTagEnter, handleEventTagLeave, handleEventTagPopupEnter, handleEventTagPopupLeave, closePopup, closeEventTagPopup, tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, hourlyRows, days, utcOffsetMs, visible, tableRows, nowLocalISO, currentRow, currentCat, liveElev, activeEvents, lensEvent, selectedDayEvents, } = useAppState(); // Search is hidden by default and revealed via the magnifier next to the // location. The input then overlays the location line to save space. const [searchOpen, setSearchOpen] = useState(false); // While closing, keep the overlay mounted so it can fade/close out before // unmounting; cleared when the exit animation ends. const [searchClosing, setSearchClosing] = useState(false); const openSearch = () => { setSearchClosing(false); setSearchOpen(true); }; const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); }; const searchWrapRef = useRef(null); // ── Event note ─────────────────────────────────────────────────────── // A content-width strip above the nav showing one active event at a time // with its full message, cycling through them with a fade. In flow, so it // pushes the page down rather than covering anything (see .event-note). const [noteIndex, setNoteIndex] = useState(0); const [noteFading, setNoteFading] = useState(false); const NOTE_MS = 8000; const NOTE_FADE_MS = 350; const noteGoTo = (next) => { if (next === noteIndex) return; setNoteFading(true); setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS); }; // ── Dismissal ──────────────────────────────────────────────────────── // Closing the strip hides it for the rest of the browser session, but only // for the events that were showing at the time: the dismissal is stored // against a signature of the active ids, so a new event (or a fresh weather // warning) brings the strip back rather than staying silently suppressed. const NOTE_CLOSE_KEY = 'sunscope_event_note_closed'; const NOTE_CLOSE_MS = 260; const noteSig = activeEvents.map(e => e.id).sort().join('|'); const [noteClosedSig, setNoteClosedSig] = useState(() => { try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; } }); // Kept mounted for the collapse animation, then dropped. const [noteClosing, setNoteClosing] = useState(false); const noteDismissed = !!noteSig && noteSig === noteClosedSig; const noteClose = () => { if (noteClosing) return; setNoteClosing(true); setTimeout(() => { try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ } setNoteClosedSig(noteSig); setNoteClosing(false); }, NOTE_CLOSE_MS); }; // Keep the index in range if the event list shrinks between forecasts. useEffect(() => { if (noteIndex >= activeEvents.length) setNoteIndex(0); }, [activeEvents.length]); useEffect(() => { if (activeEvents.length <= 1) return; const id = setInterval(() => { setNoteFading(true); setTimeout(() => { setNoteIndex(i => (i + 1) % activeEvents.length); setNoteFading(false); }, NOTE_FADE_MS); }, NOTE_MS); return () => clearInterval(id); }, [activeEvents.length]); const [colTogglesOpen, setColTogglesOpen] = useState(false); // Which column pills are offered in the Columns bar. The bar as a whole is // Extra-only (see the isPro gate around col-toggles-body), so this just asks // "is this column part of the current profile?" — Custom and Show All offer // the lot, every other profile offers what it actually uses. const colOffered = (key) => activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key]; const searchInputRef = useRef(null); const fscScrollRef = useRef(null); useEffect(() => { const el = fscScrollRef.current; if (!el) return; let isDown = false, startX = 0, startScroll = 0, hasDragged = false; const onMouseDown = (e) => { if (!el.contains(e.target) || e.button !== 0) return; isDown = true; hasDragged = false; startX = e.clientX; startScroll = el.scrollLeft; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; }; const onMouseMove = (e) => { if (!isDown) return; const dx = e.clientX - startX; if (Math.abs(dx) > 5) { hasDragged = true; el.style.cursor = 'grabbing'; el.scrollLeft = startScroll - dx; } }; const onMouseUp = () => { if (!isDown) return; isDown = false; el.style.cursor = ''; document.body.style.userSelect = ''; document.body.style.webkitUserSelect = ''; }; const onClickCapture = (e) => { if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; } }; document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); el.addEventListener('click', onClickCapture, true); return () => { document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); el.removeEventListener('click', onClickCapture, true); }; }, []); const [simpleTemp, setSimpleTemp] = useState('utciAdj'); useEffect(() => { if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT'); else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT'); else setSimpleTemp('utciAdj'); if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) { setForecastView('table'); } }, [activeProfile, outdoorsVariant]); // Focus the input the moment the search opens. useEffect(() => { if (searchOpen) searchInputRef.current?.focus(); }, [searchOpen]); // Close the search on outside-click or Escape, clearing any stray query. useEffect(() => { if (!searchOpen) return; const onDown = (e) => { if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) { closeSearch(); } }; const onKey = (e) => { if (e.key === 'Escape') { closeSearch(); } }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [searchOpen, setSearchQuery]); // ─── SCOPE TIME-LAPSE ────────────────────────────────────────────────── // `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(); const end = start + PLAYBACK_WINDOW_MS; setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev); const id = setInterval(() => { setSimMs(prev => { let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS; if (next > end) next = start; // loop back to "now" return next; }); }, PLAYBACK_STEP_MS); return () => clearInterval(id); }, [playing]); // Columns that mark the start of a logical group - used to draw a faint // vertical border separating groups in the forecast table. const GROUP_ORDER = { felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'], surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'], ambient: ['petShadeT', 'petHomeT', 'air', 'rh', 'dew'], precip: ['precip', 'precipProb', 'lightning'], sky: ['cloud', 'vis'], wind: ['wind', 'dir'], airqual: ['aqi', 'pollen'], solar: ['uvA', 'uvB', 'sun', 'direct', 'diffuse'], }; const GROUP_OF = Object.fromEntries( Object.entries(GROUP_ORDER).flatMap(([g, keys]) => keys.map(k => [k, g])) ); // Returns col-group-start when this column is the leftmost VISIBLE member // of its group. If the canonical first member is hidden the border migrates // to the next visible column in the same group. const isColVisible = (k) => { if (k === 'indoorT') return indoorMode === 'on' && !indoorManaged; if (k === 'managedT') return indoorMode === 'on' && indoorManaged; return !!visibleCols[k]; }; const groupStart = (key) => { const group = GROUP_OF[key]; if (!group) return ''; const first = GROUP_ORDER[group].find(isColVisible); return first === key ? 'col-group-start' : ''; }; // Returns a CSS class encoding the group name - used to tint header cells // and group label spans. const groupColor = (key) => { const g = GROUP_OF[key]; return g ? `grp-${g}` : ''; }; // Display names for the column groups — used for the spanning labels // above the columns normally, and for the divider rows when rotated. const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' }; // Builds the group label row above the column headers. // Each visible group gets one spanning cell; groups with no visible // columns are skipped entirely. Hour always gets a blank lead cell. const groupLabelRow = () => { const groups = Object.keys(GROUP_ORDER); const cells = [html`${glanceDate}`]; for (const g of groups) { const span = GROUP_ORDER[g].filter(isColVisible).length; if (span === 0) continue; cells.push(html`${GROUP_LABELS[g]}`); } return html`${cells}`; }; // Continuous temperature colour scale — airTempRgb / petAirTempRgb now // live in tableColumns.js alongside the cell renderers that use them, and // are imported at the top of this file for the thermal-stress legend. // ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── // whyFeelsLike is derived below, after the playback rows are resolved, so // the panel can track the simulated instant during play/scrub (see panelRow). // 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()); // 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 = (simMs != null && location?.lat != null) ? solarElevationDeg(location.lat, location.lon, new Date(simMs)) : (liveElev ?? currentRow?.elev ?? 0); const scopeDt = simRow ? simRow.dt : now; const scopeCat = scopeRow ? utciCategory(scopeRow.utciAdj) : currentCat; // "Why It Feels" panel always mirrors the scope dial: the simulated instant // during play/scrub, and the interpolated "now" row (scopeRow → nowRow) when // stopped. Sharing scopeRow/scopeCat guarantees the panel's thermal label can // never disagree with the dial's reticle. const panelRow = scopeRow; const panelCat = scopeCat; const whyFeelsLike = computeWhyFeelsLike(panelRow ?? null, UTCI_ENVIRONMENTS[utciEnv]); // 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 / 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' }); return `${wd} ${d.toISOString().slice(11, 16)}`; })() : null; const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000; const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false; // CAMS air quality runs out well before the 14-day forecast does, so the AQI // and Pollen columns hit a wall partway along the day tabs. Without saying so // the empty cells read as a bug, which is worse than the missing data. const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon); const aqBeyondNote = aqBeyond ? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`) .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}` : null; // ─── TABLE COLUMN REGISTRY ─────────────────────────────────────────── // One definition per metric (label, unit, group, colouring, formatting), // shared by both table orientations — see tableColumns.js. The array // order is the column order normally, and the row order when rotated. const columnDefs = buildColumnDefs({ visibleCols, indoorMode, indoorManaged, showDecimals, showUnits, skinType, utciEnv, pollenType, aqBeyond, aqBeyondNote, }); const visibleColumnDefs = columnDefs.filter(d => d.visible); const glanceSummary = computeGlanceSummary( days[selectedDay]?.rows ?? [], activeProfile, outdoorsVariant, skinType, visibleCols, vehicleSpeed, // Farming uses these for seasonal sow/harvest advice (week's dates + weather). activeProfile === 'farming' ? days : null, location?.lat, // Climate normals + selected day's date drive the "vs seasonal average" row. normals, days[selectedDay]?.key, ); // Week-scoped, so it is kept out of the day-scoped "At a glance" panel and // rendered in its own "The Week Ahead" box beneath it. // Scored on the profile's own main field, so the panel answers "when should // I drive / when is the house bearable", not always "when is it nice out". const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". const glanceDate = (() => { const key = days[selectedDay]?.key; if (!key) return ''; const d = new Date(key + 'T00:00Z'); const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const day = d.getUTCDate(); const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' }); const yr = d.getUTCFullYear(); const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day); return `${wd} ${day}${ord} ${mon} ${yr}`; })(); // Pro: export the selected day's full hourly data as a styled spreadsheet. const handleExportDay = () => { exportDayXls(visible, { locationName: location?.name ?? 'Unknown', dateLabel: glanceDate, skinType, }); }; // Day's events surfaced in the "Day at a glance" box. Reuses the same // row shape as glanceSummary items: { icon, label, value, alert }. const hhmm = (iso) => { if (!iso) return ''; const h = parseInt(iso.slice(11, 13), 10); const m = iso.slice(14, 16); const period = h < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; const hhmmEnd = (iso) => { if (!iso) return ''; const h = parseInt(iso.slice(11, 13), 10) + 1; const m = iso.slice(14, 16); const period = (h % 24) < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; const eventGlanceItems = (selectedDayEvents ?? []) .filter(ev => ev.type !== 'promo') .map(ev => ({ icon: ev.emoji, label: ev.title, value: ev.isoRange ? (ev.isoRange[0] === ev.isoRange[1] ? hhmm(ev.isoRange[0]) : `${hhmm(ev.isoRange[0])} – ${hhmmEnd(ev.isoRange[1])}`) : (ev.nightOnly ? 'Overnight' : 'All day'), alert: false, })); // ─── 3b. QUICK-VIEW PLOT GEOMETRY ──────────────────────────────────── // Shared by the simple-view cards and the temperature curve beneath them // so the two can never drift apart. // // The curve is drawn from the full-resolution hourly rows (all 24 hours, // never resampled), but each card covers a bucket of `tableInterval` // hours and is LABELLED with that bucket's LANDING hour — a 4h card // reading "8pm" spans 8–11pm and prints the worst felt temp in that span, // which on a cooling evening is 8pm's own value. So a card must sit over // its landing hour, not over the bucket's temporal middle (which is where // an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card // landed above 9:30pm, past sunset, and pointed into the cold green tail). // // Laying the row out in hour columns fixes that, but a card is `bucket` // hours wide while its landing hour sits only half an hour in from the // bucket's start, so the first card would hang off the left edge. Hence // the inset spacers: a blank half-bucket of track at each end for the // outer cards to overhang into. // // The grid is measured in HALF-hour tracks, because a card centred on its // landing hour starts on a half-hour boundary. All tracks are identical, // and each card SPANS 2*bucket of them (rather than sitting in one track // at width:400%) — spanning divides a card's intrinsic width across the // tracks it covers, so the grid's max-content width stays close to what // it was and mobile doesn't gain a load of extra horizontal scroll. // // tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ] // pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room) // hour j -> centre at (bucket + 2j + 1) / total // card -> spans 2*bucket tracks, starting one half-hour after 2j // => centre = 2j + 1 + bucket == hour j's centre ✓ const fscPlot = (() => { if (!tableRows.length) return null; const cards = tableRows.length; const hourly = visible.length > cards ? visible : tableRows; const hours = hourly.length; const bucket = Math.max(1, tableInterval || 1); const total = 2 * hours + 2 * bucket; // half-hour tracks // Landing-hour index of each card, accumulated so partial buckets at a // day boundary stay correct rather than assuming i * bucket. const landing = []; let j = 0; for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; } // A card spans 2*bucket tracks, so this keeps its 55px minimum. const unit = 55 / (2 * bucket); return { hourly, hours, bucket, landing, cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`, // Fraction of the track width at which hour j's data point sits. hourAt: j2 => (bucket + 2 * j2 + 1) / total, // grid-column for the card whose landing hour is j (lines are 1-based). cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`, }; })(); // Single colour source for the quick view: the curve's gradient stops AND // the cards' thermal tags both read from here, so a card's connector line // can never land on a shade its own tag contradicts. (The tags used to take // the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting // over a yellow-green 22°C point on the continuous ramp.) // // Pet mode goes through petAirTempRgb, which remaps the pet reading onto // the human scale first (petEquivHumanTemp), exactly as the pet table // columns and pet legend do. const fscRgb = (t, whiteMix) => (simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix); // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM // template. Search tips: // • "utci-header" — the top section (title + dial + search) // • "utci-day-tabs" — the 14 day buttons with band colours // • "col-toggles" — the column-customisation row (Pro only) // • "utci-table" — the hourly table itself // • "utci-legend" — the thermal-stress band legend // • "utci-about" — the explainer paragraphs at the bottom // • "utci-footer" — the "reading the table" note return html`
0 && !noteDismissed ? ' has-event-note' : ''}`}> ${activeEvents.length > 0 && !noteDismissed && (() => { const ev = activeEvents[noteIndex] || activeEvents[0]; const isPriority = PRIORITY_WEATHER_IDS.has(ev.id); const fmtDate = (iso) => iso ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : null; // Date line for one event — computed per slide because every event is // rendered (see the stack below), not just the visible one. const dateLineFor = (e) => { const startFmt = fmtDate(e.start); const peakFmt = fmtDate(e.peak); const endFmt = fmtDate(e.end); if (!startFmt || !endFmt) return null; if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt; return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt) ? `${startFmt} – ${endFmt} · Peak ${peakFmt}` : `${startFmt} – ${endFmt}`; }; // Warning banners take their background from the day tabs' weather // palette (ev.tint, set in events/weather-checks.js) so a heat alert // reads the same orange as a hot day tab and a rain alert the same // blue. Pastelised hard — the banner is a wide block of body text, so // it needs far more headroom than a small tab — and drawn edge-in to // echo the tabs' radial "colour radiating inward" look. const noteTintStyle = (isPriority && ev.tint) ? (() => { const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`; const edge = pale(0.55), core = pale(0.82); return { background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`, borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`, }; })() : undefined; return html`
${/* Every slide is rendered, all stacked in one CSS grid cell, so the strip is always as tall as the LONGEST event's copy. Rotating to a two-line message then back no longer resizes the banner and shunts the page up and down. Only the active slide is visible; the rest sit at opacity 0 and are hidden from assistive tech. */''}
${activeEvents.map((e, i) => { const dateLine = dateLineFor(e); const isActive = i === noteIndex && !noteFading; return html`
${e.emoji} ${e.title}
${e.message} ${dateLine && html`${dateLine}`}
`; })}
${activeEvents.length > 1 && html`
${activeEvents.map((_, i) => html`
`}
`; })()}

SunScope

See the world the way your skin does.
<${ScopeReticle} value=${scopeRow?.utciAdj ?? null} cat=${scopeCat} loading=${loading} elev=${scopeElev} dt=${scopeDt} glob=${scopeRow?.glob ?? 0} activeEvent=${scopeEvent} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} cc=${scopeRow?.cc ?? null} ccLow=${scopeRow?.ccLow ?? null} ccMid=${scopeRow?.ccMid ?? null} ccHigh=${scopeRow?.ccHigh ?? null} precip=${scopeRow?.precip ?? 0} snow=${scopeRow?.snow ?? 0} visKm=${scopeRow?.visKm ?? null} env=${utciEnv} wind=${scopeRow?.va ?? 0} gust=${scopeRow?.gust ?? 0} wd=${scopeRow?.wd ?? null} /> ${hourlyRows.length > 0 && html`
<${DayTimeline} windowStart=${windowStart} windowEnd=${windowEnd} simMs=${simMs} setSimMs=${setSimMs} hourlyRows=${hourlyRows} utcOffsetMs=${utcOffsetMs} />
`}
${forecast && panelRow && whyFeelsLike && html`
Why It Feels ${panelCat && html`${panelCat.label}`}
${(() => { const _wflItems = [ { label: 'Air temperature', icon: '🌡', value: panelRow.Ta, isBase: true }, { label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky }, { label: 'Wind', icon: '🌬', value: whyFeelsLike.wind }, { label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity }, ...(whyFeelsLike.environment !== 0 ? [ { label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment } ] : []), ...(whyFeelsLike.precipitation !== 0 ? [ { label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation } ] : []), ]; const _wflDeltaItems = _wflItems.filter(i => !i.isBase); const _wflMax = Math.max(..._wflDeltaItems.map(i => Math.abs(i.value)), 0.1); return _wflItems.map(({ label, icon, value, isBase }) => html`
${icon} ${label} ${!isBase && html` = 0 ? 'bar-pos' : 'bar-neg')} style=${{ width: `${Math.round(Math.abs(value) / _wflMax * 100)}%` }}> `} = 0 ? 'delta-pos' : 'delta-neg'}`}> ${isBase ? '' : value >= 0 ? '+' : ''}${value.toFixed(1)}°
`); })()}
`}
${error && html` `} ${loading && !error && html`
Acquiring forecast data…
`} ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`} ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`} ${forecast && days.length > 0 && (() => { const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant); const fabIcon = activeProfile === 'outdoors' ? (variantIcons[outdoorsVariant] || '🎯') : (FILTER_PROFILES[activeProfile]?.icon || '🎯'); const fabVal = mainConfigKey === 'vehicle' ? (VEHICLE_TYPES[vehicleType]?.name || '') : mainConfigKey === 'indoor' ? (BUILDING_TYPES[buildingType]?.name || '') : mainConfigKey === 'fur' ? (FUR_COLORS[furColor]?.name || '') : (UTCI_ENVIRONMENTS[utciEnv]?.label || ''); return html` `; })()} <${ConfigPanel} open=${panelOpen} onClose=${closePanel} isPro=${isPro} activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions} activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue} outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode} setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay} indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged} buildingType=${buildingType} setBuildingType=${setBuildingType} utciEnv=${utciEnv} setUtciEnv=${setUtciEnv} vehicleType=${vehicleType} setVehicleType=${setVehicleType} vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed} vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent} furColor=${furColor} setFurColor=${setFurColor} skinType=${skinType} setSkinType=${setSkinType} pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave} /> ${forecast && days.length > 0 && html`<${DayTabs} days=${days} selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} isPro=${isPro} openRestore=${openRestore} proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} activeProfile=${activeProfile} outdoorsVariant=${outdoorsVariant} openPanel=${openPanel} vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed} buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} scrollDayTabs=${scrollDayTabs} />`}
View: ${forecastView === 'table' && html` `} ${forecastView === 'simple' && (() => { const showFur = visibleCols.furSurfaceT; const showSolar = visibleCols.utciP; const showShade = visibleCols.shadeT; const showVehicle = visibleCols.vehicleT; const showIndoor = visibleCols.indoorT || visibleCols.managedT; if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null; const furOn = simpleTemp === 'furSurfaceT'; const solarOn = simpleTemp === 'utciAdj'; const shadeOn = simpleTemp === 'shadeT'; const vehicleOn = simpleTemp === 'vehicleT'; const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT'; // Values (fur colour, vehicle type/speed, building type, // ventilation) are set in the config strip above the day // tabs now — this row is just a tab switcher for which // thermal model drives the quick-view cards below. Sits // directly above col-toggles in normal flow (touching, zero // gap) so it reads as a folder tab attached to that box. return html` ${showSolar && html``} ${showShade && html``} ${showVehicle && html``} ${showIndoor && html``} ${showFur && html``} `; })()}
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
${isPro && html`<${Fragment}> ${colOffered('utciP') && html``} ${colOffered('vehicleT') && html``} ${(colOffered('indoorT') || colOffered('managedT')) && html``} ${colOffered('furSurfaceT') && html``} ${colOffered('pawT') && html``} ${colOffered('petShadeT') && html``} ${colOffered('petHomeT') && html``} ${colOffered('burn') && html``} ${colOffered('utci') && html``} ${colOffered('delta') && html``} ${colOffered('tmrt') && html``} ${colOffered('concreteT') && html``} ${colOffered('soilT') && html``} ${colOffered('soilT6') && html``} ${colOffered('soilM') && html``} ${colOffered('shadeT') && html``} ${colOffered('air') && html``} ${colOffered('rh') && html``} ${colOffered('dew') && html``} ${colOffered('precip') && html``} ${colOffered('precipProb') && html``} ${colOffered('lightning') && html``} ${colOffered('cloud') && html``} ${colOffered('vis') && html``} ${colOffered('wind') && html``} ${colOffered('dir') && html``} ${colOffered('aqi') && html``} ${colOffered('pollen') && html``} ${colOffered('uvA') && html``} ${colOffered('uvB') && html``} ${colOffered('sun') && html``} ${colOffered('direct') && html``} ${colOffered('diffuse') && html``} `}
${tableRows.map((r, ri) => { const dispTemp = r[simpleTemp] ?? r.utciAdj; const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp); const h24s = parseInt(r.iso.slice(11, 13), 10); const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const domIcon = (() => { if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0)) return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`; if (r.cloudCat && r.cloudCat !== 'clear') return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`; return null; })(); const windMph = Math.round((r.gust ?? r.va) * 2.237); // Tag shade = the exact gradient stop the curve is painted // with at this card's landing hour (fsc-grad, whiteMix 0.42), // so the connector line lands on the colour the tag is // wearing. Band label and weight still come from the band. const feelRgb = fscRgb(dispTemp, 0.42) || [200, 200, 200]; const feelLum = (feelRgb[0] * 299 + feelRgb[1] * 587 + feelRgb[2] * 114) / 1000; const feelStyle = { background: `rgb(${feelRgb[0]},${feelRgb[1]},${feelRgb[2]})`, color: feelLum > 165 ? '#1a1a1a' : '#ffffff', ...(cat.fontWeight ? { fontWeight: cat.fontWeight } : {}), }; // Card wears the same hue as its feel tag, washed right back so // the tag still reads as the strong swatch on top of it. const cardRgb = fscRgb(dispTemp, 0.70) || [245, 245, 245]; // Border is the same hue a few shades down, so it edges the card // without introducing a colour that isn't already on it. const edgeRgb = fscRgb(dispTemp, 0.40) || [200, 200, 200]; return html`
${localHHMMs}
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} /> ${domIcon && html`
${domIcon}
`}
${r.precipProb}% ${windMph}mph ${r.compass ? r.compass.label : '—'}
${Math.round(dispTemp)}°
${cat.label}
`; })}
${(() => { if (!fscPlot) return null; const fscSvgW = 1000, fscSvgH = 80; const fscSrc = fscPlot.hourly; // Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10 const fscMin = -15, fscMax = 45; const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH; const getT = r => r[simpleTemp] ?? r.utciAdj; // Same hour->x mapping the cards grid uses (see fscPlot above), so a // card's centre and its hour's data point are the same x by // construction — every hour of the day stays on screen. const fscHourX = j => fscPlot.hourAt(j) * fscSvgW; const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1); const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) })); // Connector points — one per card, planted on its landing hour's data // point, which is exactly where that card is centred. const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]); const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO); // Flat runs from each edge into the first/last hour, so the fill still // covers the inset spacer strips at both ends. const fscFillPts = [ { x: 0, y: fscAllPts[0].y }, ...fscAllPts, { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }, ]; let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`; for (let i = 1; i < fscFillPts.length; i++) { const p0 = fscFillPts[i - 1], p1 = fscFillPts[i]; const cpx = (p0.x + p1.x) / 2; fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`; } const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`; return html` ${fscSrc.map((r, j) => { const rgb = fscRgb(getT(r), 0.42) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return html``; })} ${fscSrc.map((r, j) => { const rgb = fscRgb(getT(r), 0.25) || [200, 200, 200]; const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; return html``; })} ${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => { const gy = toY(t); return html` `; })} ${fscPts.map((pt, i) => { const isNow = fscNowFlags[i]; return html` `; })} `; })()}
${(() => { // ── THE HOURLY TABLE, IN EITHER ORIENTATION ────────────── // Normally hours run down the page and metrics across it. // Rotated, the axes swap: hours along the top, metrics down // the side. Both read the same visibleColumnDefs registry // (see tableColumns.js), so the cells are identical either // way — only the axis they are laid out on changes. // Is this hour "now", and is the sun below the horizon? const hourFlags = (r) => ({ isNight: r.elev < 0, isNow: r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO, }); // The scope + "3pm" + event tags cell. Leads each row // normally; heads each column when rotated. const timeCell = (r) => { // r.iso is the local wall-clock string from the API — slice it directly. const h24 = parseInt(r.iso.slice(11, 13), 10); const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`; const rowEvents = getCellTagEvents(selectedDayEvents, r); return html` <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} /> ${localHHMM} ${rowEvents.length > 0 && html` ${rowEvents.map((ev, idx) => html` handleEventTagClick(rowEvents, idx, e)} onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)} onMouseLeave=${handleEventTagLeave} role="button" tabIndex="0" aria-label=${ev.title} >${ev.emoji} `)} `} `; }; // ── ROTATED: hours across the top, metrics down the side ── // One table, unlike the normal orientation's split pair. // That split exists purely to let the header stick to the // viewport while the body scrolls sideways; here the hour // row deliberately scrolls away with the table, so there is // nothing to keep in step and no reason to pay for it. The // metric column still pins on the left — plain CSS sticky, // which works inside the horizontal scroller. // // The key matters: both orientations root at a
, so // without distinct keys Preact diffs one into the other and // reuses the DOM nodes — carrying over the inline column // widths and header transforms that the width-sync leaves // behind, which is what knocks the columns out of // alignment when you flip back. if (tableRotated) return html`
${tableRows.map(r => { const f = hourFlags(r); return html` `; })} ${visibleColumnDefs.map((d, i) => { // A group heading row is emitted whenever the // group changes, standing in for the spanning // group labels above the columns normally. const newGroup = d.group && d.group !== (i > 0 ? visibleColumnDefs[i - 1].group : null) ? d.group : null; return html` <${Fragment} key=${d.key}> ${newGroup && html` ${/* Real cells per hour rather than one spanning cell: a colspan leaves nothing sitting in the "now" column, so the brass bracket running down it breaks at every group divider. These also let the label pin like a metric name. */ tableRows.map(r => html` `)} `} ${tableRows.map((r, hi) => { const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right'); const f = hourFlags(r); return html``; })} `; })}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> ${glanceDate} e.stopPropagation()}> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> ${timeCell(r)}
${GROUP_LABELS[newGroup]}
handleThClick(d.key, e)} onMouseEnter=${(e) => handleThEnter(d.key, e)} onMouseLeave=${handleThLeave}> ${d.label} ${d.unit}${d.headExtra ?? null} ${c.content}
`; // ── NORMAL: hours down the side, metrics across the top ─── // Split into a sticky header table and a scrolling body // table whose column widths useTableScroll keeps in step. return html`
${groupLabelRow()} ${visibleColumnDefs.map(d => html` `)}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}> Rows e.stopPropagation()}> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> handleThClick(d.key, e)} onMouseEnter=${(e) => handleThEnter(d.key, e)} onMouseLeave=${handleThLeave}> ${d.label} ${d.unit}${d.headExtra ?? null}
${tableRows.map((r, rowIdx) => { const f = hourFlags(r); return html` ${visibleColumnDefs.map(d => { const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom'); return html``; })} `; })}
${timeCell(r)}${c.content}
`; })()}
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html` `}
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
${COL_DESCRIPTIONS[colPopup.key].title}

${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}

${COL_DESCRIPTIONS[colPopup.key].link && html` More about this column → `}
`} ${eventTagPopup && (() => { const evs = eventTagPopup.events; const ev = evs[evSlideIndex] || evs[0]; return html`
${ev.emoji} ${ev.title}

${ev.message}

${evs.length > 1 && html`
${evs.map((_, i) => html` evSlideTo(i)} /> `)}
`}
`; })()}
Thermal stress bands
${[ { t: -9, label: 'Freezing', value: '< 0°C' }, { t: 1, label: 'Cold', value: '0–10°C' }, { t: 11, label: 'Cool', value: '10–19°C' }, { t: 21, label: 'Comfortable', value: '19–24°C', bold: true }, { t: 25, label: 'Warm', value: '24–27°C' }, { t: 29, label: 'Caution', value: '27–32°C' }, { t: 36, label: 'Extreme', value: '32–41°C' }, { t: 47, label: 'Danger', value: '41°C+' }, ].map((b, i) => { const rgb = airTempRgb(b.t) || [200, 200, 200]; const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`; const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`; const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`; const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000; const fg = lum > 165 ? '#1a1a1a' : '#ffffff'; return html` ${b.label} ${b.value} `; })}
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html` Pet thermal stress bands
${[ { t: -15, label: 'Freezing', value: '< -8°C' }, { t: 0, label: 'Cold', value: '-8–2°C' }, { t: 9, label: 'Cool', value: '2–11°C' }, { t: 18, label: 'Comfortable', value: '11–25°C', bold: true }, { t: 28, label: 'Warm', value: '25–32°C' }, { t: 36, label: 'Caution', value: '32–40°C' }, { t: 46, label: 'Extreme', value: '40–52°C' }, { t: 60, label: 'Danger', value: '52°C+' }, ].map((b, i) => { // Exact same recipe as the human legend above (same 135deg // light/mid/dark sweep, same luminance-based font colour) - // just reading from petAirTempRgb instead of airTempRgb. const rgb = petAirTempRgb(b.t) || [200, 200, 200]; const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`; const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`; const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`; const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000; const fg = lum > 165 ? '#1a1a1a' : '#ffffff'; return html` ${b.label} ${b.value} `; })}
`}
${(() => { const upcoming = getUpcomingEvents(location, 3650).slice(0, 4); const formatPeak = (iso) => { const d = new Date(iso + 'T00:00Z'); return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); }; const countdownLabel = (days) => { if (days === 0) return 'Tonight!'; if (days === 1) return 'Tomorrow'; if (days < 7) return `In ${days} days`; if (days < 14) return 'Next week'; if (days < 60) return `In ${Math.round(days / 7)} weeks`; return `In ${Math.round(days / 30)} months`; }; return html`
🔭 What's Coming Cosmic events · next 4 events · ${location.name}
${upcoming.length === 0 ? html`
No major cosmic events on the horizon — clear skies ahead.
` : upcoming.map(ev => html`
${ev.emoji}
${ev.title} ${countdownLabel(ev.daysUntil)}
${ev.desc}
${ev.visibilityNote && html` 📍 ${ev.visibilityNote} `}
`) }
`; })()}

What is SunScope?

SunScope is a free hourly weather forecast built around felt temperature, not just air temperature. It uses the Universal Thermal Climate Index (UTCI) — the biometeorological standard used in heat-health warning systems worldwide — as its foundation, combining air temperature, humidity, wind, and solar radiation into a single honest number. Then it goes further. Our SunSoak index layers three extra dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel; an environment modifier that adjusts the solar load for where you actually are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat; and the full radiant heat absorbed from surrounding surfaces. One number that honestly answers: what will my body actually feel out there?

Beyond SunSoak, SunScope calculates vehicle cabin heat (choose your vehicle type; toggle windows open), indoor temperature (seven building types including office blocks; managed heatwave mode), urban concrete surface temperature, UV index and sunburn time by skin type, and soil temperature and moisture for farming and motorhome use. Switch profiles — Places, Activities, Work — to see the data that matters for your situation, or go Custom and build your own view. Learn more →

Reading the table

SunSoak is the number to watch — it reflects everything your body actually experiences, not just what the thermometer says. A large gap between SunSoak and Air temperature means solar radiation is doing significant work on your body. On clear sunny days that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are for the most accurate reading. ${' '} How it works →

${isPro && html` `}
`; }