Files
sunscope/assets/js/app.js
T
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

2151 lines
122 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ════════════════════════════════════════════════════════════════════════
// 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 "nav-logo-tag" 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 { WeeklyNudge } from './components/WeeklyNudge.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`
<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 ───────────────────────────────────────────────────
// 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,
weeklyNudgeOpen, closeWeeklyNudge,
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, setTableRotated,
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.
// Dashboard sign-in state, purely for the Account menu's Sign out/in
// item - a lightweight session check against the dashboard's own auth
// (see api/me.php), independent of isPro (which is the separate
// localStorage-based Extra flag and has nothing to do with being signed
// in to the journal).
const [dashUser, setDashUser] = useState(null);
useEffect(() => {
fetch('./api/me.php', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d) => setDashUser(d.user ?? null))
.catch(() => {});
}, []);
const signOutDashboard = () => {
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
.then(() => setDashUser(null))
.catch(() => {});
};
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;
// Pending cross-fade timer. Held in a ref so a location change (or the
// strip closing) can cancel it: a fade that lands after the event list has
// been replaced used to set an index past the end of the new list, leaving
// the banner mounted with every slide hidden — a strip with nothing in it.
const noteFadeTimer = useRef(null);
const noteClosingRef = useRef(false);
const cancelNoteFade = () => {
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
setNoteFading(false);
};
const noteGoTo = (next) => {
if (next === noteIndex) return;
setNoteFading(true);
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
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 noteSlotRef = useRef(null);
const noteClose = () => {
if (noteClosing) return;
// Pin the slot to the height it actually has, then run it to zero on the
// next frame. The slot is bottom-anchored (see .event-note-slot), so the
// banner rides its shrinking bottom edge up behind the header while the
// page below closes the gap at exactly the same rate — one property, one
// movement. It is measured here rather than guessed in CSS because every
// stylesheet-side version of this (max-height, a 1fr → 0fr row) had to
// interpolate against an assumed size, which is what stalled part-way.
const el = noteSlotRef.current;
if (el) {
const reduced = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
el.style.height = `${el.offsetHeight}px`;
// Inline rather than through the is-closing class: that class only lands
// on the next render, which can be after the frame below.
el.style.transition = `height ${reduced ? 0.01 : NOTE_CLOSE_MS / 1000}s linear`;
void el.offsetHeight; // flush, so the next value starts a transition
requestAnimationFrame(() => {
if (noteSlotRef.current) noteSlotRef.current.style.height = '0px';
});
}
noteClosingRef.current = true;
cancelNoteFade();
setNoteClosing(true);
setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig);
setNoteClosing(false);
noteClosingRef.current = false;
}, NOTE_CLOSE_MS);
};
// Start from the first slide whenever the event set itself changes — a new
// location brings a different list, and carrying an index (or a half-run
// fade) across leaves the strip showing nothing.
useEffect(() => {
cancelNoteFade();
setNoteIndex(0);
}, [noteSig]);
useEffect(() => {
if (activeEvents.length <= 1) return;
const id = setInterval(() => {
// Nothing to cross-fade to mid-close — the height animation would have
// to re-target a banner whose content just changed under it.
if (noteClosingRef.current) return;
setNoteFading(true);
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
setNoteIndex(i => (i + 1) % activeEvents.length);
setNoteFading(false);
}, NOTE_FADE_MS);
}, NOTE_MS);
return () => {
clearInterval(id);
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
};
}, [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);
// Quick view's card strip scrolls sideways like the table does, so it gets
// the same edge fades and chevrons — see .forecast-simple-wrap in table.css.
const [fscCanScrollLeft, setFscCanScrollLeft] = useState(false);
const [fscCanScrollRight, setFscCanScrollRight] = useState(false);
useEffect(() => {
const el = fscScrollRef.current;
if (!el) return;
const update = () => {
setFscCanScrollLeft(el.scrollLeft > 1);
setFscCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
// The strip's width and its content's width both move it: the viewport on
// resize, the card row when the day, interval or profile changes. Watching
// both keeps the deps list out of it.
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
if (ro) {
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
}
return () => {
el.removeEventListener('scroll', update);
if (ro) ro.disconnect();
};
}, []);
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');
// Skips the very first run so a page refresh keeps the saved Simple /
// Table / Detailed choice — the profile-driven jump to the table below
// is meant for an actual profile *switch*, not for restoring state.
const profileViewFirstRun = useRef(true);
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 (profileViewFirstRun.current) {
profileViewFirstRun.current = false;
} else 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', 'utci', 'burn'],
surface: ['concreteT', 'soilT', 'soilT6', 'soilM'],
pets: ['furSurfaceT', 'pawT', 'petShadeT', 'petHomeT'],
ambient: ['air', 'rh', 'dew'],
precip: ['precip', 'precipProb'],
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', pets: 'Pets', 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`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDate}</span></th>`];
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${GROUP_LABELS[g]}</th>`);
}
return html`<tr class="grp-label-row">${cells}</tr>`;
};
// 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}`;
})();
// Compact form for the rotated table's corner cell, e.g. "Thu 27 Aug 2026".
// That cell sets the width of the pinned metric-name column, and the long
// form ("Thu 27th August 2026") pushed it wider than the names need.
const glanceDateShort = (() => {
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 mon = d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' });
return `${wd} ${d.getUTCDate()} ${mon} ${d.getUTCFullYear()}`;
})();
// 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 811pm 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%). The card itself is a fixed width centred in that span,
// so a longer bucket buys spacing between cards rather than a fatter card.
//
// 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 ✓
// Quick-view card width, and the minimum clearance between two cards. Kept
// here rather than only in CSS because the grid track sizing is derived from
// them; .fsc-card sets the same width.
const FSC_CARD_W = 92, FSC_CARD_GAP = 4;
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; }
// Every card is a fixed FSC_CARD_W wide at every interval (see .fsc-card),
// centred in the 2*bucket tracks it spans. So the tracks only have to be
// wide enough that neighbouring cards clear each other: a span of at least
// card + gutter. At 1h that is the tight case; at 3h/4h the same rule
// leaves a wider gap between cards, which is what the extra hours per card
// should look like. The 1fr half of the minmax lets the gaps grow further
// on a wide screen — the cards never do.
const unit = (FSC_CARD_W + FSC_CARD_GAP) / (2 * bucket);
return {
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`
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
<header class="utci-topnav" id="site-nav">
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
<div class="nav-logo">
<h1 class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></h1>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</div>
<nav class="nav-links" id="nav-drawer" aria-label="Main">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak & the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Subscription</a>
${dashUser
? html`<a href="#" onClick=${(e) => { e.preventDefault(); signOutDashboard(); }}>Sign out</a>`
: html`<a href="./dashboard.html">Sign in</a>`}
</div>
</div>
</nav>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
onClick=${() => {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
<span></span><span></span><span></span>
</button>
</header>
${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0];
// Guards the window between an event list changing and the reset
// effect above running: an index past the end would hide every slide.
const slideIndex = noteIndex < activeEvents.length ? noteIndex : 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`
${/* The slot clips the banner: on close it collapses to nothing while
the note itself slides up inside it, so the strip reads as
tucking back up under the header rather than fading in place. */''}
<div ref=${noteSlotRef} class=${`event-note-slot${noteClosing ? ' is-closing' : ''}`}>
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
${/* 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. */''}
<div class="event-note-stack">
${activeEvents.map((e, i) => {
const dateLine = dateLineFor(e);
const isActive = i === slideIndex && !noteFading;
return html`
<div
key=${e.id || i}
class=${`event-note-slide${isActive ? ' is-active' : ''}`}
aria-hidden=${isActive ? undefined : 'true'}
>
<div class="event-note-header">
<span class="event-note-emoji">${e.emoji}</span>
<span class="event-note-title">${e.title}</span>
</div>
<div class="event-note-body">
<span class="event-note-msg">${e.message}</span>
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
</div>
</div>`;
})}
</div>
${activeEvents.length > 1 && html`
<div class="event-note-dots">
${activeEvents.map((_, i) => html`
<button
key=${i}
type="button"
class=${`event-note-dot${i === slideIndex ? ' active' : ''}`}
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
onClick=${() => noteGoTo(i)}
/>`)}
</div>`}
<button
type="button"
class="event-note-close"
aria-label="Close event banner"
title="Close"
onClick=${noteClose}
>
<span class="event-note-close-label">Close</span>
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
</div>`;
})()}
<main class="utci-shell">
<div class="utci-header">
<div class="header-left">
<div class="utci-loc-search" ref=${searchWrapRef}>
<div class="utci-current-loc">
<button
type="button"
class="utci-loc-name"
aria-label="Search for a town or city"
aria-expanded=${searchOpen}
onClick=${() => (searchOpen ? closeSearch() : openSearch())}
>
<svg class="utci-loc-pin" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
<circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
</svg>
${location.name}
</button>
<button
type="button"
class="utci-search-toggle"
aria-label="Search for a town or city"
aria-expanded=${searchOpen}
onClick=${() => (searchOpen ? closeSearch() : openSearch())}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="utci-loc-tools">
<div class="utci-loc-tool">
<button
type="button"
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
aria-label="Use my current location"
title="Use my current location"
disabled=${locating}
onClick=${useMyLocation}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<span class="utci-loc-tool-label">My spot</span>
</div>
<div class="utci-loc-tool">
<button
type="button"
class="utci-loc-action"
aria-label="Share this forecast"
title="Copy a link to this forecast"
onClick=${shareForecast}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-tool-label">Share</span>
</div>
<div class="utci-loc-tool">
<a
class="utci-loc-action"
href="./dashboard.html"
aria-label="Keep a weather journal"
title="Keep a weather journal — Dashboard"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</a>
<span class="utci-loc-tool-label">Journal</span>
</div>
</div>
${(locateError || shareState) && html`
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
${locateError
? locateError
: shareState === 'copied' ? 'Link copied' : "Couldn't copy the link"}
${locateError && html`
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
onClick=${() => setLocateError(null)}>×</button>`}
</div>`}
${(() => {
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
const recents = (recentLocations || [])
.filter((l) => `${l.lat.toFixed(3)},${l.lon.toFixed(3)}` !== curKey)
.slice(0, 3);
if (!recents.length) return null;
return html`
<div class="utci-recent-locs">
<span class="utci-recent-label">Recent</span>
${recents.map((l) => html`
<button
key=${`${l.lat.toFixed(3)}-${l.lon.toFixed(3)}`}
type="button"
class="utci-recent-chip"
title=${`See the forecast for ${l.name}`}
onClick=${() => {
setLocationAndSave(l);
setSelectedDay(0);
closeSearch();
}}
>
<svg class="utci-recent-pin" viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
<path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
<circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
</svg>
<span class="utci-recent-name">${l.name}</span>
</button>`)}
</div>`;
})()}
${(searchOpen || searchClosing) && html`
<div
class=${'utci-search-wrap utci-search-overlay' + (searchOpen ? '' : ' is-closing')}
onAnimationEnd=${(e) => {
if (e.target === e.currentTarget && !searchOpen) {
setSearchClosing(false);
setSearchQuery('');
}
}}
>
<input
ref=${searchInputRef}
class="utci-search"
type="text"
placeholder="Search any town or city…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
/>
${searchResults.length > 0 && html`
<div class="utci-results">
${searchResults.map((r) => html`
<div
key=${`${r.id}-${r.latitude}`}
class="utci-result"
onClick=${() => {
setLocationAndSave({
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
lat: r.latitude,
lon: r.longitude,
country: r.country_code,
});
setSearchQuery('');
setSearchResults([]);
setSelectedDay(0);
closeSearch();
}}
>
<div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
<div class="utci-result-meta">
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
</div>
</div>`)}
</div>`}
${searching && html`<div class="utci-searching">Searching…</div>`}
</div>`}
</div>
</div>
<div class="scope-col">
<${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`
<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 ?? 'Cycle 24h'}</span>
</button>
<${DayTimeline}
windowStart=${windowStart}
windowEnd=${windowEnd}
simMs=${simMs}
setSimMs=${setSimMs}
hourlyRows=${hourlyRows}
utcOffsetMs=${utcOffsetMs}
/>
</div>
</div>`}
</div>
<div class="header-right">
${forecast && panelRow && whyFeelsLike && html`
<div class="insight-panel insight-panel--why">
<div class="insight-env-footer" data-env=${utciEnv}>
<span class="insight-env-footer-label">Solar model</span>
<${CustomSelect}
value=${utciEnv}
isOn=${true}
noHide=${true}
grpClass="grp-felt"
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
value: k,
label: v.label,
}))}
onChange=${(v) => setUtciEnv(v)}
/>
</div>
<div class="insight-panel-title">
Why It Feels
${panelCat && html`<span class="insight-thermal-cat" style=${{ background: panelCat.bg, color: panelCat.fg }}>${panelCat.label}</span>`}
</div>
${(() => {
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`
<div class=${'insight-row' + (isBase ? ' insight-row--base' : '')} key=${label}>
<span class="insight-icon">${icon}</span>
<span class="insight-label">
${label}
${!isBase && html`<span class="insight-bar-track">
<span class=${'insight-bar ' + (value >= 0 ? 'bar-pos' : 'bar-neg')}
style=${{ width: `${Math.round(Math.abs(value) / _wflMax * 100)}%` }}></span>
</span>`}
</span>
<span class=${`insight-delta ${isBase ? 'delta-base' : value >= 0 ? 'delta-pos' : 'delta-neg'}`}>
${isBase ? '' : value >= 0 ? '+' : ''}${value.toFixed(1)}°
</span>
</div>
`);
})()}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
</div>`}
</div>
</div>
${error && html`
<div class="utci-status" role="alert" aria-live="polite"
style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
<span class="utci-status-msg">⚠ ${friendlyError(error)}</span>
<button type="button" class="utci-status-retry" onClick=${retry} disabled=${loading}>
${loading ? 'Retrying…' : 'Retry'}
</button>
</div>`}
${loading && !error && html`
<div class="acquiring-overlay">
<div class="acquiring-popup">
<div class="acquiring-spinner"></div>
<div class="acquiring-label">Acquiring forecast data…</div>
</div>
</div>`}
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
${weeklyNudgeOpen && !welcomeOpen && html`
<${WeeklyNudge} onClose=${closeWeeklyNudge} openRestore=${openRestore} />`}
${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`
<button
class=${`floating-profile-btn${panelOpen ? ' is-open' : ''}`}
onClick=${() => (panelOpen ? closePanel() : openPanel())}
aria-label="Profile and settings"
aria-expanded=${panelOpen}
title="Profile & settings"
>
<span class="floating-profile-icon" aria-hidden="true">${fabIcon}</span>
<span class="floating-profile-val">${mainLabel}</span>
${fabVal && html`<span class="floating-profile-sub">· ${fabVal}</span>`}
<span class="floating-profile-caret" aria-hidden="true">▾</span>
</button>`;
})()}
<${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}
/>`}
<div class="table-with-rail">
<div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}>
<div class="table-toolbar-row">
<span class="fvt-toolbar-left">
<span class="forecast-view-toggle-label">View:</span>
${/* One three-way control. Quick is the card layout; the other
two are the same hourly table on opposite axes — Detailed
runs hours along the top, Table runs them down the side.
Rotation used to be a separate toggle that only appeared
once you were already in the table, which hid the layout
people wanted behind a mode they had to find first. */''}
<span class="forecast-view-toggle">
<button type="button" title="Quick view visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')}
aria-pressed=${forecastView === 'simple' ? 'true' : 'false'}
onClick=${() => setForecastView('simple')}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
Quick
</button>
<button type="button" title="Detailed view hours along the top, metrics down the side" class=${'fvt-btn' + (forecastView === 'table' && tableRotated ? ' on' : '')}
aria-pressed=${forecastView === 'table' && tableRotated ? 'true' : 'false'}
onClick=${() => { setForecastView('table'); setTableRotated(true); }}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><rect x=".5" y="1.5" width="3" height="10" rx=".6" fill="currentColor"/><rect x="4.5" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".35"/><rect x="8" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".25"/></svg>
Detailed
</button>
<button type="button" title="Table view hours down the side, metrics along the top" class=${'fvt-btn' + (forecastView === 'table' && !tableRotated ? ' on' : '')}
aria-pressed=${forecastView === 'table' && !tableRotated ? 'true' : 'false'}
onClick=${() => { setForecastView('table'); setTableRotated(false); }}>
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
Table
</button>
</span>
<button
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
onClick=${() => {
if (!isPro) { setProPromptSource('columns'); setProPromptDay(0); return; }
setColTogglesOpen(v => !v);
}}>
<span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button>
</span>
${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`
<span class="fvt-thermal-tabs">
${showSolar && html`<button type="button" class=${'fvt-thermal-tab' + (solarOn ? ' on' : '')} onClick=${() => setSimpleTemp('utciAdj')}>SunSoak</button>`}
${showShade && html`<button type="button" class=${'fvt-thermal-tab' + (shadeOn ? ' on' : '')} onClick=${() => setSimpleTemp('shadeT')}>Shade</button>`}
${showVehicle && html`<button type="button" class=${'fvt-thermal-tab' + (vehicleOn ? ' on' : '')} onClick=${() => setSimpleTemp('vehicleT')}>Vehicle</button>`}
${showIndoor && html`<button type="button" class=${'fvt-thermal-tab' + (indoorOn ? ' on' : '')} onClick=${() => setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT')}>Indoors</button>`}
${showFur && html`<button type="button" class=${'fvt-thermal-tab' + (furOn ? ' on' : '')} onClick=${() => setSimpleTemp('furSurfaceT')}>Fur</button>`}
</span>
`;
})()}
</div>
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
<span class="fvt-interval">
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
</span>
<div class="col-toggles-body">
${isPro && html`<${Fragment}>
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-felt${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-pets${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${colOffered('dir') && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${colOffered('aqi') && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
${colOffered('pollen') && html`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
${colOffered('uvA') && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
${colOffered('uvB') && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
${colOffered('sun') && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
${colOffered('direct') && html`<button class=${`col-toggle grp-solar${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
${colOffered('diffuse') && html`<button class=${`col-toggle grp-solar${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
</${Fragment}>`}
<span class="col-toggles-display-opts">
<label class="display-opt">
<input type="checkbox" checked=${showDecimals} onChange=${toggleShowDecimals} />
Decimals
</label>
<label class="display-opt">
<input type="checkbox" checked=${showUnits} onChange=${toggleShowUnits} />
Units
</label>
</span>
</div>
</div>
</div>
<div class=${'forecast-simple-wrap'
+ (fscCanScrollLeft ? ' scroll-fade-left' : '')
+ (fscCanScrollRight ? ' scroll-fade-right' : '')}>
<span class="fsc-scroll-chevron left" aria-hidden="true"></span>
<span class="fsc-scroll-chevron right" aria-hidden="true"></span>
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
<div class="forecast-simple-inner">
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
${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 = (() => {
// Two rain numbers, reconciled the same way the day tabs do it
// (see DayTabs). precipProb answers WHETHER it rains, precip
// answers HOW MUCH — and they come from different model runs,
// so a 97%-certain hour can still carry 0.0mm. Gating the icon
// on the amount alone left those hours wearing a cloud while
// the card underneath read 97%. So: probability decides IF the
// icon shows rain, and the two together decide how hard it looks.
// • >= 50% — rain icon, whatever the amount says
// • > 20% with mm/cm — rain icon (a light but real shower)
// • otherwise — cloud
const pProb = r.precipProb ?? 0;
const wet = r.precip > 0 || r.snow > 0;
if (pProb >= 50 || (pProb > 20 && wet)) {
const c01 = v => Math.max(0, Math.min(1, v));
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
const probT = c01((pProb - 50) / 50);
// Amount half: "heavy" taken as 2mm in a single hour.
const amountT = c01((r.precip || 0) / 2);
// Certainty alone tops out at medium-high; only a genuinely
// wet hour reaches the 3-drop downpour.
const score = probT * 0.55 + amountT * 0.45;
const drops = score < 0.33 ? 1 : score < 0.70 ? 2 : 3;
// PrecipIcon draws its dry dash when precip and snow are both
// 0, so a certain-but-zero-amount hour needs a nominal trace
// to draw drops at all. drops= still sets the real intensity.
const iconPrecip = r.snow > 0 ? (r.precip || 0) : (r.precip > 0 ? r.precip : 0.01);
return html`<${PrecipIcon} precip=${iconPrecip} snow=${r.snow} drops=${drops} 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`
<div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}
style=${{
background: `rgb(${cardRgb[0]},${cardRgb[1]},${cardRgb[2]})`,
...(isNow ? {} : { border: `1px solid rgb(${edgeRgb[0]},${edgeRgb[1]},${edgeRgb[2]})` }),
...(fscPlot ? { gridColumn: fscPlot.cardCol(fscPlot.landing[ri]) } : {}),
}}>
<div class="fsc-time">${localHHMMs}</div>
<div class="fsc-scope-wrap">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
</div>
<div class="fsc-meta">
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
</div>
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
</div>
`;
})}
</div>
${(() => {
if (!fscPlot) return null;
const fscSvgW = 1000, fscSvgH = 80;
// The curve plots the SAME rows the cards do. It used to plot the
// raw hourly series instead, which agrees with the cards at 1h and
// diverges at every longer interval: a card shows its bucket's
// aggregate, so an "8am 24°" card (the 811am mean) sat above a
// curve drawn at 8am's own 14.5°, and its connector pointed at a
// height the card never claimed. One series, one number.
const fscSrc = tableRows;
// 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;
// x of card i — the landing hour it is centred on, via the same
// mapping that placed the card in the grid, so a point and its
// card share an x by construction.
const fscCardX = i => fscPlot.hourAt(fscPlot.landing[i]) * fscSvgW;
const fscStopPct = i => (fscPlot.hourAt(fscPlot.landing[i]) * 100).toFixed(1);
const fscAllPts = fscSrc.map((r, i) => ({ x: fscCardX(i), y: toY(getT(r)) }));
// One point per card now, so the connectors ARE the data points.
const fscPts = fscAllPts;
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`
<svg class="fsc-temp-curve" viewBox="0 0 ${fscSvgW} ${fscSvgH}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fsc-grad" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
${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`<stop key=${j} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
})}
</linearGradient>
<linearGradient id="fsc-grad-strong" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
${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`<stop key=${`s${j}`} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
})}
</linearGradient>
</defs>
${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => {
const gy = toY(t);
return html`
<line key=${'g'+t} x1="0" y1=${gy} x2=${fscSvgW} y2=${gy}
stroke=${color} stroke-width="1" opacity="0.35"
vector-effect="non-scaling-stroke" />
`;
})}
${fscPts.map((pt, i) => {
const isNow = fscNowFlags[i];
return html`
<line key=${'c'+i} x1=${pt.x} y1="0" x2=${pt.x} y2=${pt.y}
stroke=${isNow ? '#c8922a' : '#b09870'}
stroke-width="1"
opacity=${isNow ? '1' : '0.55'}
vector-effect="non-scaling-stroke"
/>
`;
})}
<path d=${fscFill} fill="url(#fsc-grad)" opacity="0.45" />
<path d=${fscFill} fill="none" stroke="url(#fsc-grad-strong)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
</svg>
`;
})()}
</div>
</div>
<div class="fsc-table-btn-wrap">
<button type="button" class="fsc-table-btn" onClick=${() => setForecastView('table')}>
Detailed Hourly Forecast
</button>
</div>
</div>
${(() => {
// ── 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`
<span class="utci-time-inner">
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<span>${localHHMM}</span>
${rowEvents.length > 0 && html`
<span class="utci-time-tags">
${rowEvents.map((ev, idx) => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`)}
</span>`}
</span>`;
};
// ── 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 <div>, 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`
<div key="table-rotated" class=${`utci-table-wrap is-rotated${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
${/* Head split out of the body table, exactly as the normal
orientation does it: the hour row has to pin to the
viewport as the page scrolls, and a <thead> inside the
horizontal scroller can only stick to that scroller.
useTableScroll keeps the two tables' column widths and
horizontal offset in step. */''}
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-rotated utci-table-head" ref=${headTableRef}>
<thead>
<tr>
<th class="rot-corner col-info-th" scope="col"
onClick=${(e) => handleThClick('hour', e)}
onMouseEnter=${(e) => handleThEnter('hour', e)}
onMouseLeave=${handleThLeave}>
<span class="rot-corner-date">${glanceDateShort}</span>
<span class="rot-corner-controls">
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
</span>
</th>
${tableRows.map(r => {
const f = hourFlags(r);
return html`
<th key=${r.iso} scope="col"
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
${timeCell(r)}
</th>`;
})}
</tr>
</thead>
</table>
</div>
</div>
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<tbody>
${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`
<tr class="grp-label-row grp-label-row--rotated">
<th class=${`grp-label rot-group-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup">
${GROUP_LABELS[newGroup]}
</th>
${/* 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`
<td key=${r.iso} class=${`grp-label-fill grp-${newGroup} ${hourFlags(r).isNow ? 'is-now' : ''}`.trim()}></td>`)}
</tr>`}
<tr class="rot-metric-row">
<th scope="row"
class=${`rot-metric-label col-info-th ${groupColor(d.key)}`.trim()}
onClick=${(e) => handleThClick(d.key, e)}
onMouseEnter=${(e) => handleThEnter(d.key, e)}
onMouseLeave=${handleThLeave}>
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
</th>
${tableRows.map((r, hi) => {
const c = d.render(r, tableRows[hi - 1], tableRows[hi + 1], 'to right');
const f = hourFlags(r);
return html`<td key=${r.iso}
class=${`${d.cellClass ?? ''} ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}
title=${c.title ?? null}
style=${c.style ?? null}>${c.content}</td>`;
})}
</tr>
</${Fragment}>`;
})}
</tbody>
</table>
</div>
</div>`;
// ── 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`
<div key="table-normal" class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}>
<thead>
${groupLabelRow()}
<tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
<span>Rows</span>
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
</th>
${visibleColumnDefs.map(d => html`
<th key=${d.key} scope="col"
class=${`col-info-th ${d.headClass ?? ''} ${groupStart(d.key)} ${groupColor(d.key)}`.replace(/\s+/g, ' ').trim()}
onClick=${(e) => handleThClick(d.key, e)}
onMouseEnter=${(e) => handleThEnter(d.key, e)}
onMouseLeave=${handleThLeave}>
${d.label} <span class=${d.unitNone ? 'col-unit col-unit--none' : 'col-unit'}>${d.unit}</span>${d.headExtra ?? null}
</th>`)}
</tr>
</thead>
</table>
</div>
</div>
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody>
${tableRows.map((r, rowIdx) => {
const f = hourFlags(r);
return html`
<tr key=${r.iso}
class=${`${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">${timeCell(r)}</td>
${visibleColumnDefs.map(d => {
const c = d.render(r, tableRows[rowIdx - 1], tableRows[rowIdx + 1], 'to bottom');
return html`<td key=${d.key}
class=${`${d.cellClass ?? ''} ${groupStart(d.key)}`.trim()}
title=${c.title ?? null}
style=${c.style ?? null}>${c.content}</td>`;
})}
</tr>`;
})}
</tbody>
</table>
</div>
</div>`;
})()}
</div>
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
<aside class="glance-rail">
${((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
<div class="insight-panel insight-panel--glance">
<div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div>
${(() => {
const heatEvents = eventGlanceItems.filter(e => /heat caution|heat warning|extreme heat/i.test(e.label)).map(e => ({ ...e, alert: true }));
const otherEvents = eventGlanceItems.filter(e => !/heat caution|heat warning|extreme heat/i.test(e.label));
const renderRow = ({ icon, label, value, sub, alert, grp }, keyPrefix = '') => html`
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}${grp ? ` insight-row--${grp}` : ''}`} key=${`${keyPrefix}${label}`}>
<span class="insight-icon">${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}${sub ? html`<span class="insight-sub">${titleCaseText(sub)}</span>` : ''}</span>
</div>`;
// Only the outdoors profile reorders items around the heat block
// (it has a 'Peak felt temp' anchor). Other profiles render in order.
const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null;
const climateRows = glanceSummary.filter(i => i.label === 'Pre-2020 24h Average');
const restSummary = glanceSummary.filter(i => i.label !== 'Pre-2020 24h Average' && !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time')));
return [
...restSummary.flatMap(item => [
renderRow(item),
// After the peak/heat block, drop in good driving time then the comfortable window
...(item.label === 'Peak felt temp'
? [
...heatEvents.map(e => renderRow(e, 'ev-')),
...(drivingItem ? [renderRow(drivingItem)] : []),
...(comfortItem ? [renderRow(comfortItem)] : []),
]
: []),
]),
...climateRows.map(item => renderRow(item)),
...otherEvents.map(e => renderRow(e, 'ev-')),
];
})()}
</div>`}
${weekAhead.length > 0 && html`
<div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div>
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
${weekAhead.map(({ icon, iconTitle, label, value, score }) => html`
<div class=${`insight-row${score == null ? ' insight-row--week-note' : ''}`} key=${label}>
<span class="insight-icon" title=${iconTitle}>${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span>
${score != null && html`
<span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
<span class="week-score__track">
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
</span>
<span class="week-score__pct">${score}%</span>
</span>`}
</div>`)}
</div>`}
${visible.length > 0 && html`
<div class="export-panel">
${isPro
? html`
<button class="export-day-btn" onClick=${handleExportDay} title="Download full hourly data for this day as a spreadsheet">
<span class="export-day-btn__icon">⬇</span>
Export day data
</button>
<div class="export-day-hint">Excel / LibreOffice · 41 columns · hourly</div>`
: html`
<button class="export-day-btn export-day-btn--locked"
onClick=${() => { setProPromptSource('export'); setProPromptDay(0); }}
title="Export day data is part of SunScope Extra">
<span class="export-day-btn__icon">🔒</span>
Export day data
</button>
<div class="export-day-hint">SunScope Extra feature</div>`}
</div>`}
</aside>`}
</div>
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
<div
ref=${colPopupRef}
class=${`col-info-popup${colPopup.below ? ' col-info-popup--below' : ''}`}
style=${{
position: 'fixed',
left: `${colPopup.x}px`,
top: `${colPopup.y}px`,
transform: colPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${colPopup.arrowLeft}px`,
}}
onMouseEnter=${handlePopupEnter}
onMouseLeave=${handlePopupLeave}
>
<button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}</p>
${COL_DESCRIPTIONS[colPopup.key].link && html`
<a class="col-info-more" href=${COL_DESCRIPTIONS[colPopup.key].link} target="_blank" rel="noopener noreferrer">
More about this column →
</a>`}
</div>`}
${eventTagPopup && (() => {
const evs = eventTagPopup.events;
const ev = evs[evSlideIndex] || evs[0];
return html`
<div
ref=${eventTagPopupRef}
class=${`col-info-popup col-info-popup--ev${eventTagPopup.below ? ' col-info-popup--below' : ''}`}
style=${{
position: 'fixed',
left: `${eventTagPopup.x}px`,
top: `${eventTagPopup.y}px`,
transform: eventTagPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
'--arrow-left': `${eventTagPopup.arrowLeft}px`,
}}
onMouseEnter=${handleEventTagPopupEnter}
onMouseLeave=${handleEventTagPopupLeave}
>
<button class="col-info-close" onClick=${closeEventTagPopup} aria-label="Close">×</button>
<div class=${`ev-popup-stage${evTransition ? ` ev-popup-${evTransition}` : ''}`}>
<strong class="col-info-title">${ev.emoji} ${ev.title}</strong>
<p class="col-info-desc">${ev.message}</p>
</div>
${evs.length > 1 && html`
<div class="ev-popup-dots">
${evs.map((_, i) => html`
<span
key=${i}
class=${`ev-popup-dot${i === evSlideIndex ? ' active' : ''}`}
onClick=${() => evSlideTo(i)}
/>
`)}
</div>
`}
</div>`;
})()}
<div class="utci-legend">
<span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -25, label: 'Extreme cold', value: '< -20°C' },
{ t: -15, label: 'Arctic', value: '-20-10°C' },
{ t: -5, label: 'Freezing', value: '-100°C' },
{ t: 1, label: 'Cold', value: '010°C' },
{ t: 11, label: 'Cool', value: '1019°C' },
{ t: 21, label: 'Comfortable', value: '1924°C', bold: true },
{ t: 25, label: 'Warm', value: '2427°C' },
{ t: 29, label: 'Caution', value: '2732°C' },
{ t: 36, label: 'Extreme', value: '3241°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`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
<span class="utci-legend-item-label">${b.label}</span>
<span class="utci-legend-item-value">${b.value}</span>
</span>`;
})}
</div>
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
<div class="utci-legend-row">
${[
{ t: -35, label: 'Extreme cold', value: '< -28°C' },
{ t: -23, label: 'Arctic', value: '-28-18°C' },
{ t: -13, label: 'Freezing', value: '-18-8°C' },
{ t: 0, label: 'Cold', value: '-82°C' },
{ t: 9, label: 'Cool', value: '211°C' },
{ t: 18, label: 'Comfortable', value: '1125°C', bold: true },
{ t: 28, label: 'Warm', value: '2532°C' },
{ t: 36, label: 'Caution', value: '3240°C' },
{ t: 46, label: 'Extreme', value: '4052°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`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
<span class="utci-legend-item-label">${b.label}</span>
<span class="utci-legend-item-value">${b.value}</span>
</span>`;
})}
</div>`}
</div>
${(() => {
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`
<div class="almanac-panel">
<div class="almanac-header">
<span class="almanac-title">🔭 What's Coming</span>
<span class="almanac-subtitle">Cosmic events · next 4 events · ${location.name}</span>
</div>
<div class="almanac-list">
${upcoming.length === 0
? html`<div class="almanac-empty">No major cosmic events on the horizon — clear skies ahead.</div>`
: upcoming.map(ev => html`
<div key=${ev.id} class="almanac-entry">
<div class="almanac-entry-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
<div class="almanac-entry-icon">${ev.emoji}</div>
<div class="almanac-entry-body">
<div class="almanac-entry-head">
<span class="almanac-entry-title">${ev.title}</span>
<span class="almanac-entry-date">${formatPeak(ev.peak)}</span>
<span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
</div>
<div class="almanac-entry-desc">${ev.desc}</div>
${ev.visibilityNote && html`
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
`}
</div>
</div>
`)
}
</div>
</div>`;
})()}
<div class="utci-bottom-grid">
<div class="utci-about">
<h2 class="utci-about-heading">What is SunScope?</h2>
<p class="utci-about-text">
SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
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 <strong>SunSoak</strong> index layers three extra
dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel;
an <strong>environment modifier</strong> 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: <em>what will my body actually feel out there?</em>
</p>
<p class="utci-about-text">
Beyond SunSoak, SunScope calculates <strong>vehicle cabin heat</strong> (choose your
vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven building
types including office blocks; managed heatwave mode), <strong>urban concrete surface
temperature</strong>, <strong>UV index and sunburn time</strong> by skin type, and
<strong>soil temperature and moisture</strong> 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.
<a href="./about.html" class="utci-about-link">Learn more </a>
</p>
</div>
<div class="utci-reading-box">
<h2 class="utci-about-heading">Reading the table</h2>
<p class="utci-about-text">
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.
${' '}<a onClick=${openWelcome}
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none', cursor: 'pointer' }}>
How it works
</a>
</p>
</div>
</div>
${isPro && html`
<div class="utci-footer">
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0', textAlign: 'center' }}>
SunScope Extra is active.
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
target="_blank" rel="noopener noreferrer"
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none' }}>
Manage or cancel subscription →
</a>
</div>
</div>
`}
<footer class="utci-site-footer">
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a>
· <a href="./index.html">Forecast</a>
· <a href="./about.html">About</a>
· <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a>
· <a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
· Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>
· UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer>
</main>
</div>`;
}