`;
}
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`
${glanceDate}
`];
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
cells.push(html`
${GROUP_LABELS[g]}
`);
}
return html`
${cells}
`;
};
// Continuous temperature colour scale — airTempRgb / petAirTempRgb now
// live in tableColumns.js alongside the cell renderers that use them, and
// are imported at the top of this file for the thermal-stress legend.
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
// whyFeelsLike is derived below, after the playback rows are resolved, so
// the panel can track the simulated instant during play/scrub (see panelRow).
// Interpolated row at the precise current instant — used for the scope
// display even outside playback so there's no jump when play is pressed.
const nowRow = interpolateRowAt(hourlyRows, now.getTime());
// 24-hour window anchored to now — shared by the play effect and the timeline.
const windowStart = now.getTime();
const windowEnd = windowStart + PLAYBACK_WINDOW_MS;
// simMs drives the scope whether set by auto-play or by dragging the timeline.
const simRow = simMs != null ? interpolateRowAt(hourlyRows, simMs) : null;
const scopeRow = simRow || nowRow || currentRow;
// Elevation computed continuously from the simulated instant so the sun
// starts exactly where liveElev left off (same solarElevationDeg call).
const scopeElev = (simMs != null && location?.lat != null)
? solarElevationDeg(location.lat, location.lon, new Date(simMs))
: (liveElev ?? currentRow?.elev ?? 0);
const scopeDt = simRow ? simRow.dt : now;
const scopeCat = scopeRow ? utciCategory(scopeRow.utciAdj) : currentCat;
// "Why It Feels" panel always mirrors the scope dial: the simulated instant
// during play/scrub, and the interpolated "now" row (scopeRow → nowRow) when
// stopped. Sharing scopeRow/scopeCat guarantees the panel's thermal label can
// never disagree with the dial's reticle.
const panelRow = scopeRow;
const panelCat = scopeCat;
const whyFeelsLike = computeWhyFeelsLike(panelRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
// Synthesize a storm overlay (lightning) when the simulated hour is wet;
// outside playback keep the real active event.
const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent;
// Local clock label for the playback button / timeline. The window is a ROLLING
// 24h from now so it may cross midnight — prefix weekday ("Wed 14:30").
const simClock = (simMs != null && scopeDt)
? (() => {
const d = new Date(scopeDt.getTime() + utcOffsetMs);
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
return `${wd} ${d.toISOString().slice(11, 16)}`;
})()
: null;
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
// CAMS air quality runs out well before the 14-day forecast does, so the AQI
// and Pollen columns hit a wall partway along the day tabs. Without saying so
// the empty cells read as a bug, which is worse than the missing data.
const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon);
const aqBeyondNote = aqBeyond
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
: null;
// ─── TABLE COLUMN REGISTRY ───────────────────────────────────────────
// One definition per metric (label, unit, group, colouring, formatting),
// shared by both table orientations — see tableColumns.js. The array
// order is the column order normally, and the row order when rotated.
const columnDefs = buildColumnDefs({
visibleCols, indoorMode, indoorManaged,
showDecimals, showUnits,
skinType, utciEnv, pollenType,
aqBeyond, aqBeyondNote,
});
const visibleColumnDefs = columnDefs.filter(d => d.visible);
const glanceSummary = computeGlanceSummary(
days[selectedDay]?.rows ?? [],
activeProfile,
outdoorsVariant,
skinType,
visibleCols,
vehicleSpeed,
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
activeProfile === 'farming' ? days : null,
location?.lat,
// Climate normals + selected day's date drive the "vs seasonal average" row.
normals,
days[selectedDay]?.key,
);
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
// rendered in its own "The Week Ahead" box beneath it.
// Scored on the profile's own main field, so the panel answers "when should
// I drive / when is the house bearable", not always "when is it nice out".
const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant);
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
const glanceDate = (() => {
const key = days[selectedDay]?.key;
if (!key) return '';
const d = new Date(key + 'T00:00Z');
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const day = d.getUTCDate();
const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' });
const yr = d.getUTCFullYear();
const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day);
return `${wd} ${day}${ord} ${mon} ${yr}`;
})();
// Pro: export the selected day's full hourly data as a styled spreadsheet.
const handleExportDay = () => {
exportDayXls(visible, {
locationName: location?.name ?? 'Unknown',
dateLabel: glanceDate,
skinType,
});
};
// Day's events surfaced in the "Day at a glance" box. Reuses the same
// row shape as glanceSummary items: { icon, label, value, alert }.
const hhmm = (iso) => {
if (!iso) return '';
const h = parseInt(iso.slice(11, 13), 10);
const m = iso.slice(14, 16);
const period = h < 12 ? 'am' : 'pm';
const h12 = h % 12 || 12;
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
};
const hhmmEnd = (iso) => {
if (!iso) return '';
const h = parseInt(iso.slice(11, 13), 10) + 1;
const m = iso.slice(14, 16);
const period = (h % 24) < 12 ? 'am' : 'pm';
const h12 = h % 12 || 12;
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
};
const eventGlanceItems = (selectedDayEvents ?? [])
.filter(ev => ev.type !== 'promo')
.map(ev => ({
icon: ev.emoji,
label: ev.title,
value: ev.isoRange
? (ev.isoRange[0] === ev.isoRange[1]
? hhmm(ev.isoRange[0])
: `${hhmm(ev.isoRange[0])} – ${hhmmEnd(ev.isoRange[1])}`)
: (ev.nightOnly ? 'Overnight' : 'All day'),
alert: false,
}));
// ─── 3b. QUICK-VIEW PLOT GEOMETRY ────────────────────────────────────
// Shared by the simple-view cards and the temperature curve beneath them
// so the two can never drift apart.
//
// The curve is drawn from the full-resolution hourly rows (all 24 hours,
// never resampled), but each card covers a bucket of `tableInterval`
// hours and is LABELLED with that bucket's LANDING hour — a 4h card
// reading "8pm" spans 8–11pm and prints the worst felt temp in that span,
// which on a cooling evening is 8pm's own value. So a card must sit over
// its landing hour, not over the bucket's temporal middle (which is where
// an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card
// landed above 9:30pm, past sunset, and pointed into the cold green tail).
//
// Laying the row out in hour columns fixes that, but a card is `bucket`
// hours wide while its landing hour sits only half an hour in from the
// bucket's start, so the first card would hang off the left edge. Hence
// the inset spacers: a blank half-bucket of track at each end for the
// outer cards to overhang into.
//
// The grid is measured in HALF-hour tracks, because a card centred on its
// landing hour starts on a half-hour boundary. All tracks are identical,
// and each card SPANS 2*bucket of them (rather than sitting in one track
// at width:400%) — spanning divides a card's intrinsic width across the
// tracks it covers, so the grid's max-content width stays close to what
// it was and mobile doesn't gain a load of extra horizontal scroll.
//
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
// hour j -> centre at (bucket + 2j + 1) / total
// card -> spans 2*bucket tracks, starting one half-hour after 2j
// => centre = 2j + 1 + bucket == hour j's centre ✓
const fscPlot = (() => {
if (!tableRows.length) return null;
const cards = tableRows.length;
const hourly = visible.length > cards ? visible : tableRows;
const hours = hourly.length;
const bucket = Math.max(1, tableInterval || 1);
const total = 2 * hours + 2 * bucket; // half-hour tracks
// Landing-hour index of each card, accumulated so partial buckets at a
// day boundary stay correct rather than assuming i * bucket.
const landing = [];
let j = 0;
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
// A card spans 2*bucket tracks, so this keeps its 55px minimum.
const unit = 55 / (2 * bucket);
return {
hourly, hours, bucket, landing,
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
// Fraction of the track width at which hour j's data point sits.
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
// grid-column for the card whose landing hour is j (lines are 1-based).
cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`,
};
})();
// Single colour source for the quick view: the curve's gradient stops AND
// the cards' thermal tags both read from here, so a card's connector line
// can never land on a shade its own tag contradicts. (The tags used to take
// the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting
// over a yellow-green 22°C point on the continuous ramp.)
//
// Pet mode goes through petAirTempRgb, which remaps the pet reading onto
// the human scale first (petEquivHumanTemp), exactly as the pet table
// columns and pet legend do.
const fscRgb = (t, whiteMix) =>
(simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix);
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
// Everything below is the actual page markup, written as one big HTM
// template. Search tips:
// • "utci-header" — the top section (title + dial + search)
// • "utci-day-tabs" — the 14 day buttons with band colours
// • "col-toggles" — the column-customisation row (Pro only)
// • "utci-table" — the hourly table itself
// • "utci-legend" — the thermal-stress band legend
// • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note
return html`
0 && !noteDismissed ? ' has-event-note' : ''}`}>
${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0];
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null;
// Date line for one event — computed per slide because every event is
// rendered (see the stack below), not just the visible one.
const dateLineFor = (e) => {
const startFmt = fmtDate(e.start);
const peakFmt = fmtDate(e.peak);
const endFmt = fmtDate(e.end);
if (!startFmt || !endFmt) return null;
if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt;
return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt)
? `${startFmt} – ${endFmt} · Peak ${peakFmt}`
: `${startFmt} – ${endFmt}`;
};
// Warning banners take their background from the day tabs' weather
// palette (ev.tint, set in events/weather-checks.js) so a heat alert
// reads the same orange as a hot day tab and a rain alert the same
// blue. Pastelised hard — the banner is a wide block of body text, so
// it needs far more headroom than a small tab — and drawn edge-in to
// echo the tabs' radial "colour radiating inward" look.
const noteTintStyle = (isPriority && ev.tint) ? (() => {
const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`;
const edge = pale(0.55), core = pale(0.82);
return {
background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`,
borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`,
};
})() : undefined;
return html`
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
strip is always as tall as the LONGEST event's copy. Rotating to
a two-line message then back no longer resizes the banner and
shunts the page up and down. Only the active slide is visible;
the rest sit at opacity 0 and are hidden from assistive tech. */''}
View:
${/* 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. */''}
${forecastView === 'simple' && (() => {
const showFur = visibleCols.furSurfaceT;
const showSolar = visibleCols.utciP;
const showShade = visibleCols.shadeT;
const showVehicle = visibleCols.vehicleT;
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null;
const furOn = simpleTemp === 'furSurfaceT';
const solarOn = simpleTemp === 'utciAdj';
const shadeOn = simpleTemp === 'shadeT';
const vehicleOn = simpleTemp === 'vehicleT';
const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
// Values (fur colour, vehicle type/speed, building type,
// ventilation) are set in the config strip above the day
// tabs now — this row is just a tab switcher for which
// thermal model drives the quick-view cards below. Sits
// directly above col-toggles in normal flow (touching, zero
// gap) so it reads as a folder tab attached to that box.
return html`
${showSolar && html``}
${showShade && html``}
${showVehicle && html``}
${showIndoor && html``}
${showFur && html``}
`;
})()}
${(() => {
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`
`;
})()}
${(() => {
// ── THE HOURLY TABLE, IN EITHER ORIENTATION ──────────────
// Normally hours run down the page and metrics across it.
// Rotated, the axes swap: hours along the top, metrics down
// the side. Both read the same visibleColumnDefs registry
// (see tableColumns.js), so the cells are identical either
// way — only the axis they are laid out on changes.
// Is this hour "now", and is the sun below the horizon?
const hourFlags = (r) => ({
isNight: r.elev < 0,
isNow: r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO,
});
// The scope + "3pm" + event tags cell. Leads each row
// normally; heads each column when rotated.
const timeCell = (r) => {
// r.iso is the local wall-clock string from the API — slice it directly.
const h24 = parseInt(r.iso.slice(11, 13), 10);
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return html`
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
${localHHMM}
${rowEvents.length > 0 && html`
${rowEvents.map((ev, idx) => html`
handleEventTagClick(rowEvents, idx, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}
`)}
`}
`;
};
// ── ROTATED: hours across the top, metrics down the side ──
// One table, unlike the normal orientation's split pair.
// That split exists purely to let the header stick to the
// viewport while the body scrolls sideways; here the hour
// row deliberately scrolls away with the table, so there is
// nothing to keep in step and no reason to pay for it. The
// metric column still pins on the left — plain CSS sticky,
// which works inside the horizontal scroller.
//
// The key matters: both orientations root at a
, so
// without distinct keys Preact diffs one into the other and
// reuses the DOM nodes — carrying over the inline column
// widths and header transforms that the width-sync leaves
// behind, which is what knocks the columns out of
// alignment when you flip back.
if (tableRotated) return html`
${tableRows.map(r => {
const f = hourFlags(r);
return html`
${timeCell(r)}
`;
})}
${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`
${GROUP_LABELS[newGroup]}
${/* 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`
`;
// ── 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`
SunScope is a free hourly weather forecast built around felt temperature,
not just air temperature. It uses the Universal Thermal Climate Index (UTCI)
— the biometeorological standard used in heat-health warning systems worldwide — as its
foundation, combining air temperature, humidity, wind, and solar radiation into a single
honest number. Then it goes further. Our SunSoak index layers three extra
dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel;
an environment modifier that adjusts the solar load for where you actually
are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat;
and the full radiant heat absorbed from surrounding surfaces. One number that honestly
answers: what will my body actually feel out there?
Beyond SunSoak, SunScope calculates vehicle cabin heat (choose your
vehicle type; toggle windows open), indoor temperature (seven building
types including office blocks; managed heatwave mode), urban concrete surface
temperature, UV index and sunburn time by skin type, and
soil temperature and moisture for farming and motorhome use. Switch
profiles — Places, Activities, Work — to see the data that matters for your situation,
or go Custom and build your own view.
Learn more →
Reading the table
SunSoak is the number to watch — it reflects everything your body
actually experiences, not just what the thermometer says. A large gap between SunSoak and Air
temperature means solar radiation is doing significant work on your body. On clear sunny days
that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the
SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are
for the most accurate reading.
${' '}
How it works →