Files
sunscope/assets/js/app.js
T
fraxle df3ecdae35 5.1
Combine and relabel table view switch.
Remove and re-arrange some table columns
Group Pets together
2026-08-16 09:13:15 +01:00

1929 lines
112 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 "utci-tagline" or "utci-about-text"
// ════════════════════════════════════════════════════════════════════════
import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useRef, useEffect } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
utciCategory, UTCI_BANDS,
petCategory,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
FUR_COLORS,
confidenceBand, moonGlyph, skyFillForElev, titleCaseText, scoreFillColor,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';
const html = htm.bind(h);
// ── Scope time-lapse playback ───────────────────────────────────────────
// Drives the big scope through the next 24h so you can watch the sky/ground
// gradients and weather evolve. Speed: 15 simulated minutes every 1 real
// second (≈ a full day in ~1.6 minutes). UI advances smoothly every 200ms.
const PLAYBACK_SIM_MS_PER_REAL_MS = 900; // 15 min / 1 s = 900×
const PLAYBACK_STEP_MS = 200; // UI update cadence
const PLAYBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
// Interpolates the hourly rows to an arbitrary instant (ms). Returns a row
// with smoothly blended elevation / cloud / precip so the scope animates
// continuously rather than snapping hour to hour.
function interpolateRowAt(rows, t) {
if (!rows || rows.length === 0) return null;
if (t <= +rows[0].dt) return rows[0];
if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1];
let lo = rows[0], hi = rows[1];
for (let i = 1; i < rows.length; i++) {
if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; }
}
const f = (t - +lo.dt) / (+hi.dt - +lo.dt);
const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f;
return {
...lo,
dt: new Date(t),
elev: L(lo.elev, hi.elev),
glob: L(lo.glob, hi.glob),
utciAdj: L(lo.utciAdj, hi.utciAdj),
// Felt-temp inputs — interpolated so the "Why It Feels" panel breakdown
// stays smooth and consistent with the (interpolated) utciAdj category.
Ta: L(lo.Ta, hi.Ta),
Tmrt: L(lo.Tmrt, hi.Tmrt),
va: L(lo.va, hi.va),
eh: L(lo.eh, hi.eh),
cc: L(lo.cc, hi.cc),
ccLow: L(lo.ccLow, hi.ccLow),
ccMid: L(lo.ccMid, hi.ccMid),
ccHigh: L(lo.ccHigh, hi.ccHigh),
precip: L(lo.precip, hi.precip),
snow: L(lo.snow, hi.snow),
visKm: L(lo.visKm, hi.visKm),
};
}
// Turn a raw fetch failure into something a person can act on. The banner only
// ever appears when nothing at all has loaded (see the loadForecast waterfall
// in hooks/useForecast.js), so "showing your last saved forecast" is never the
// right thing to say here - there isn't one.
function friendlyError(msg) {
const m = String(msg || '');
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return "You're offline. Reconnect and try again.";
}
if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
return "Couldn't reach the weather service. Check your connection and try again.";
}
const status = m.match(/HTTP (\d{3})/);
if (status) {
const code = Number(status[1]);
if (code === 429) return 'Too many requests just now. Give it a minute and try again.';
if (code >= 500) return "The weather service isn't responding. Try again in a moment.";
return `The weather service rejected the request (error ${code}).`;
}
return m;
}
// ── Draggable 24-hour timeline ──────────────────────────────────────────
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
const trackRef = useRef(null);
const draggingRef = useRef(false);
const totalMs = windowEnd - windowStart;
const toFrac = (ms) => Math.max(0, Math.min(1, (ms - windowStart) / totalMs));
const fraction = simMs != null ? toFrac(simMs) : null;
// Find sunrise/sunset within the window by scanning hourly elevation sign changes
let sunriseMs = null, sunsetMs = null;
const winRows = hourlyRows.filter(r => +r.dt >= windowStart - 3600000 && +r.dt <= windowEnd + 3600000);
for (let i = 1; i < winRows.length; i++) {
const a = winRows[i - 1], b = winRows[i];
if (a.elev <= 0 && b.elev > 0 && sunriseMs === null) {
const f = (-a.elev) / (b.elev - a.elev);
const t = +a.dt + f * (+b.dt - +a.dt);
if (t >= windowStart && t <= windowEnd) sunriseMs = t;
}
if (a.elev > 0 && b.elev <= 0 && sunsetMs === null) {
const f = a.elev / (a.elev - b.elev);
const t = +a.dt + f * (+b.dt - +a.dt);
if (t >= windowStart && t <= windowEnd) sunsetMs = t;
}
}
const srFrac = sunriseMs != null ? toFrac(sunriseMs) : null;
const ssFrac = sunsetMs != null ? toFrac(sunsetMs) : null;
// Build gradient from actual per-hour sky colours so the bar mirrors what
// the scope would show at each moment across the window.
const bgGradient = (() => {
if (!winRows.length) return '#0a0810';
const stops = winRows.map(r => {
const pct = (toFrac(+r.dt) * 100).toFixed(2);
const col = skyFillForElev(r.elev, r.dt.getUTCHours() < 12);
return `${col} ${pct}%`;
});
return `linear-gradient(to right, ${stops.join(', ')})`;
})();
// Tick labels at 0h / 6h / 12h / 18h / 24h of the window (actual local clock times)
const ticks = [0, 6, 12, 18, 24].map(h => {
const d = new Date(windowStart + h * 3600000 + utcOffsetMs);
return d.toISOString().slice(11, 16);
});
const msFromClientX = (clientX) => {
const rect = trackRef.current.getBoundingClientRect();
return windowStart + Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * totalMs;
};
const onPointerDown = (e) => {
if (e.button !== 0) return;
e.preventDefault();
try { trackRef.current.setPointerCapture(e.pointerId); } catch (_) {}
draggingRef.current = true;
document.body.classList.add('tl-scrubbing');
setSimMs(msFromClientX(e.clientX));
};
const onPointerMove = (e) => {
if (!draggingRef.current) return;
setSimMs(msFromClientX(e.clientX));
};
const onPointerUp = () => {
draggingRef.current = false;
document.body.classList.remove('tl-scrubbing');
};
const onPointerCancel = onPointerUp;
return html`
<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,
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.
const [searchOpen, setSearchOpen] = useState(false);
// While closing, keep the overlay mounted so it can fade/close out before
// unmounting; cleared when the exit animation ends.
const [searchClosing, setSearchClosing] = useState(false);
const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
const searchWrapRef = useRef(null);
// ── Event note ───────────────────────────────────────────────────────
// A content-width strip above the nav showing one active event at a time
// with its full message, cycling through them with a fade. In flow, so it
// pushes the page down rather than covering anything (see .event-note).
const [noteIndex, setNoteIndex] = useState(0);
const [noteFading, setNoteFading] = useState(false);
const NOTE_MS = 8000;
const NOTE_FADE_MS = 350;
const noteGoTo = (next) => {
if (next === noteIndex) return;
setNoteFading(true);
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
};
// ── Dismissal ────────────────────────────────────────────────────────
// Closing the strip hides it for the rest of the browser session, but only
// for the events that were showing at the time: the dismissal is stored
// against a signature of the active ids, so a new event (or a fresh weather
// warning) brings the strip back rather than staying silently suppressed.
const NOTE_CLOSE_KEY = 'sunscope_event_note_closed';
const NOTE_CLOSE_MS = 260;
const noteSig = activeEvents.map(e => e.id).sort().join('|');
const [noteClosedSig, setNoteClosedSig] = useState(() => {
try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; }
});
// Kept mounted for the collapse animation, then dropped.
const [noteClosing, setNoteClosing] = useState(false);
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
const noteClose = () => {
if (noteClosing) return;
setNoteClosing(true);
setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig);
setNoteClosing(false);
}, NOTE_CLOSE_MS);
};
// Keep the index in range if the event list shrinks between forecasts.
useEffect(() => {
if (noteIndex >= activeEvents.length) setNoteIndex(0);
}, [activeEvents.length]);
useEffect(() => {
if (activeEvents.length <= 1) return;
const id = setInterval(() => {
setNoteFading(true);
setTimeout(() => {
setNoteIndex(i => (i + 1) % activeEvents.length);
setNoteFading(false);
}, NOTE_FADE_MS);
}, NOTE_MS);
return () => clearInterval(id);
}, [activeEvents.length]);
const [colTogglesOpen, setColTogglesOpen] = useState(false);
// Which column pills are offered in the Columns bar. The bar as a whole is
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
// "is this column part of the current profile?" — Custom and Show All offer
// the lot, every other profile offers what it actually uses.
const colOffered = (key) =>
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
const searchInputRef = useRef(null);
const fscScrollRef = useRef(null);
useEffect(() => {
const el = fscScrollRef.current;
if (!el) return;
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
const onMouseDown = (e) => {
if (!el.contains(e.target) || e.button !== 0) return;
isDown = true; hasDragged = false;
startX = e.clientX; startScroll = el.scrollLeft;
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 5) {
hasDragged = true;
el.style.cursor = 'grabbing';
el.scrollLeft = startScroll - dx;
}
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
};
const onClickCapture = (e) => {
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
};
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
el.addEventListener('click', onClickCapture, true);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', onClickCapture, true);
};
}, []);
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
useEffect(() => {
if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
else setSimpleTemp('utciAdj');
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
setForecastView('table');
}
}, [activeProfile, outdoorsVariant]);
// Focus the input the moment the search opens.
useEffect(() => {
if (searchOpen) searchInputRef.current?.focus();
}, [searchOpen]);
// Close the search on outside-click or Escape, clearing any stray query.
useEffect(() => {
if (!searchOpen) return;
const onDown = (e) => {
if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) {
closeSearch();
}
};
const onKey = (e) => {
if (e.key === 'Escape') { closeSearch(); }
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [searchOpen, setSearchQuery]);
// ─── SCOPE TIME-LAPSE ──────────────────────────────────────────────────
// `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
const [playing, setPlaying] = useState(false);
const [simMs, setSimMs] = useState(null);
// Reset scope to live when the user switches days (scope window is always
// "now → +24h" regardless of selected day, so a stale scrub position is confusing).
useEffect(() => { if (!playing) setSimMs(null); }, [selectedDay]);
useEffect(() => {
if (!playing) return;
const start = now.getTime();
const end = start + PLAYBACK_WINDOW_MS;
setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev);
const id = setInterval(() => {
setSimMs(prev => {
let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS;
if (next > end) next = start; // loop back to "now"
return next;
});
}, PLAYBACK_STEP_MS);
return () => clearInterval(id);
}, [playing]);
// Columns that mark the start of a logical group - used to draw a faint
// vertical border separating groups in the forecast table.
const GROUP_ORDER = {
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', '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}`;
})();
// 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%) — spanning divides a card's intrinsic width across the
// tracks it covers, so the grid's max-content width stays close to what
// it was and mobile doesn't gain a load of extra horizontal scroll.
//
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
// hour j -> centre at (bucket + 2j + 1) / total
// card -> spans 2*bucket tracks, starting one half-hour after 2j
// => centre = 2j + 1 + bucket == hour j's centre ✓
const fscPlot = (() => {
if (!tableRows.length) return null;
const cards = tableRows.length;
const hourly = visible.length > cards ? visible : tableRows;
const hours = hourly.length;
const bucket = Math.max(1, tableInterval || 1);
const total = 2 * hours + 2 * bucket; // half-hour tracks
// Landing-hour index of each card, accumulated so partial buckets at a
// day boundary stay correct rather than assuming i * bucket.
const landing = [];
let j = 0;
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
// A card spans 2*bucket tracks, so this keeps its 55px minimum.
const unit = 55 / (2 * bucket);
return {
hourly, hours, bucket, landing,
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
// Fraction of the track width at which hour j's data point sits.
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
// grid-column for the card whose landing hour is j (lines are 1-based).
cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`,
};
})();
// Single colour source for the quick view: the curve's gradient stops AND
// the cards' thermal tags both read from here, so a card's connector line
// can never land on a shade its own tag contradicts. (The tags used to take
// the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting
// over a yellow-green 22°C point on the continuous ramp.)
//
// Pet mode goes through petAirTempRgb, which remaps the pet reading onto
// the human scale first (petEquivHumanTemp), exactly as the pet table
// columns and pet legend do.
const fscRgb = (t, whiteMix) =>
(simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix);
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
// Everything below is the actual page markup, written as one big HTM
// template. Search tips:
// • "utci-header" — the top section (title + dial + search)
// • "utci-day-tabs" — the 14 day buttons with band colours
// • "col-toggles" — the column-customisation row (Pro only)
// • "utci-table" — the hourly table itself
// • "utci-legend" — the thermal-stress band legend
// • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note
return html`
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0];
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null;
// Date line for one event — computed per slide because every event is
// rendered (see the stack below), not just the visible one.
const dateLineFor = (e) => {
const startFmt = fmtDate(e.start);
const peakFmt = fmtDate(e.peak);
const endFmt = fmtDate(e.end);
if (!startFmt || !endFmt) return null;
if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt;
return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt)
? `${startFmt} ${endFmt} · Peak ${peakFmt}`
: `${startFmt} ${endFmt}`;
};
// Warning banners take their background from the day tabs' weather
// palette (ev.tint, set in events/weather-checks.js) so a heat alert
// reads the same orange as a hot day tab and a rain alert the same
// blue. Pastelised hard — the banner is a wide block of body text, so
// it needs far more headroom than a small tab — and drawn edge-in to
// echo the tabs' radial "colour radiating inward" look.
const noteTintStyle = (isPriority && ev.tint) ? (() => {
const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`;
const edge = pale(0.55), core = pale(0.82);
return {
background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`,
borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`,
};
})() : undefined;
return html`
<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 === noteIndex && !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 === noteIndex ? ' 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>`;
})()}
<nav 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" aria-hidden="true">
<span 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></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</div>
<div class="nav-links" id="nav-drawer">
<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>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
</div>
<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>
</nav>
<main class="utci-shell">
<div class="utci-header">
<div class="header-left">
<h1 class="utci-title">
<span class="title-sun">Sun</span><svg class="title-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-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><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="title-scope">Scope</span>
</h1>
<div class="utci-tagline">See the world the way your skin does.</div>
<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>
<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>
<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-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</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>`}
${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>`}
${(() => {
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>
`);
})()}
</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} />`}
${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('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('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</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">
<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 = (() => {
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
if (r.cloudCat && r.cloudCat !== 'clear')
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
return null;
})();
const windMph = Math.round((r.gust ?? r.va) * 2.237);
// Tag shade = the exact gradient stop the curve is painted
// with at this card's landing hour (fsc-grad, whiteMix 0.42),
// so the connector line lands on the colour the tag is
// wearing. Band label and weight still come from the band.
const feelRgb = fscRgb(dispTemp, 0.42) || [200, 200, 200];
const feelLum = (feelRgb[0] * 299 + feelRgb[1] * 587 + feelRgb[2] * 114) / 1000;
const feelStyle = {
background: `rgb(${feelRgb[0]},${feelRgb[1]},${feelRgb[2]})`,
color: feelLum > 165 ? '#1a1a1a' : '#ffffff',
...(cat.fontWeight ? { fontWeight: cat.fontWeight } : {}),
};
// Card wears the same hue as its feel tag, washed right back so
// the tag still reads as the strong swatch on top of it.
const cardRgb = fscRgb(dispTemp, 0.70) || [245, 245, 245];
// Border is the same hue a few shades down, so it edges the card
// without introducing a colour that isn't already on it.
const edgeRgb = fscRgb(dispTemp, 0.40) || [200, 200, 200];
return html`
<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</span>
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</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;
const fscSrc = fscPlot.hourly;
// Fixed scale: bottom = Freezing band bottom (10) 10, top = Danger start (44) + 10
const fscMin = -15, fscMax = 45;
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
const getT = r => r[simpleTemp] ?? r.utciAdj;
// Same hour->x mapping the cards grid uses (see fscPlot above), so a
// card's centre and its hour's data point are the same x by
// construction — every hour of the day stays on screen.
const fscHourX = j => fscPlot.hourAt(j) * fscSvgW;
const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1);
const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) }));
// Connector points — one per card, planted on its landing hour's data
// point, which is exactly where that card is centred.
const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
// Flat runs from each edge into the first/last hour, so the fill still
// covers the inset spacer strips at both ends.
const fscFillPts = [
{ x: 0, y: fscAllPts[0].y },
...fscAllPts,
{ x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y },
];
let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`;
for (let i = 1; i < fscFillPts.length; i++) {
const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
const cpx = (p0.x + p1.x) / 2;
fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`;
}
const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`;
return html`
<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>
<div class="utci-tbody-scroll" ref=${bodyScrollRef}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<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">${glanceDate}</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>
<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, label, value, score }) => html`
<div class="insight-row" key=${label}>
<span class="insight-icon">${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: -9, label: 'Freezing', value: '< 0°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: -15, label: 'Freezing', value: '< -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=${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>`;
}