Files
sunscope/assets/js/app.js
T
Fraxle 5574690887 1.8.2
New table temperature colour gradients
2026-05-24 19:19:15 +01:00

920 lines
58 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 htm from '../vendor/htm.js';
import {
utciCategory, UTCI_BANDS, bandGradient,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, BUILDING_TYPES,
confidenceBand, moonGlyph,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getCellTagEvents, getUpcomingEvents } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS } from './config.js';
import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js';
const html = htm.bind(h);
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,
forecast, airQuality, loading, error, now, fetchedAt,
searchQuery, setSearchQuery, searchResults, searching,
selectedDay, setSelectedDay,
proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource,
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
isPro, setIsPro,
activeProfile, setActiveProfile, activateProfile, activeCols,
visibleCols, setVisibleCols, toggleCol,
showDecimals, toggleShowDecimals,
showDegree, toggleShowDegree,
activityOptions, placeOptions,
activityValue, activityLabel, placeValue, placeLabel,
skinType, setSkinType,
vehicleType, setVehicleType,
vehicleVent, setVehicleVent,
outdoorsVariant, setOutdoorsVariantAndSave,
buildingType, setBuildingType,
indoorManaged, setIndoorManaged,
indoorMode, setIndoorMode,
pollenType, setPollenTypeAndSave,
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, nowLocalISO, currentRow, currentCat,
activeEvents, lensEvent, selectedDayEvents,
bannerIndex, bannerTransition, bannerPrevIndex,
bannerVisible, bannerStageRef,
bannerSlideTo, dismissBanner,
} = useAppState();
// 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', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'],
surface: ['concreteT', 'soilT', 'soilT6', 'soilM'],
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}` : '';
};
// 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"></th>`];
for (const g of groups) {
const span = GROUP_ORDER[g].filter(isColVisible).length;
if (span === 0) continue;
const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${labels[g]}</th>`);
}
return html`<tr class="grp-label-row">${cells}</tr>`;
};
// ─── 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">
<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><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the sun the way your body does.</span>
</div>
<div class="nav-links" id="nav-drawer">
<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>
</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=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
${activeEvents.length > 0 && (() => {
const renderSlide = (idx, isOutgoing) => {
const ev = activeEvents[idx] || activeEvents[0];
const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null;
const startFmt = fmtDate(ev.start);
const peakFmt = fmtDate(ev.peak);
const endFmt = fmtDate(ev.end);
const showDates = startFmt && endFmt;
const dateLine = showDates
? (startFmt === endFmt
? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}`
: peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
? `Active ${startFmt} ${endFmt} · Peak: ${peakFmt}`
: `Active ${startFmt} ${endFmt}`)
: null;
return html`
<div class=${isOutgoing ? 'event-banner event-banner--outgoing' : 'event-banner'}
style=${{ background: ev.color, color: ev.textColor }}
>
<span class="event-banner-emoji">${ev.emoji}</span>
<div class="event-banner-body">
<div class="event-banner-title">${ev.title}</div>
<div class="event-banner-msg">${ev.message}</div>
${dateLine && html`
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'Manrope, sans-serif', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', marginTop: '5px' }}>
${dateLine}
</div>
`}
${activeEvents.length > 1 && html`
<div class="event-banner-dots">
${activeEvents.map((_, i) => html`
<span
key=${i}
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
onClick=${() => bannerSlideTo(i)}
style=${{ background: ev.textColor }}
/>
`)}
</div>
`}
</div>
${!isOutgoing && html`<button
class="event-banner-dismiss"
style=${{ color: ev.textColor }}
onClick=${() => dismissBanner(ev.id)}
aria-label="Dismiss"
title="Dismiss this event"
>✕</button>`}
</div>`;
};
return html`
<div class="event-banner-stage" ref=${bannerStageRef}>
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
? renderSlide(bannerPrevIndex, true)
: null}
${renderSlide(bannerIndex, false)}
</div>`;
})()}
</div>
<div class="utci-header">
<div>
<h1 class="utci-title">
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
</h1>
<div class="utci-tagline">See the sun the way your body does.</div>
<div class="utci-current-loc">
${location.name}
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
${fetchedAt && !loading && html`
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
</div>
<div>
<${ScopeReticle}
value=${currentRow?.utciAdj ?? null}
cat=${currentCat}
loading=${loading}
elev=${currentRow?.elev ?? 0}
dt=${currentRow?.dt ?? new Date()}
glob=${currentRow?.glob ?? 0}
activeEvent=${lensEvent}
/>
</div>
<div class="header-right">
<div class="utci-search-wrap">
<label class="utci-search-label">Change location</label>
<input
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);
}}
>
<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>
${error && html`
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
${error}
</div>`}
${loading && !error && html`
<div class="utci-status">Acquiring forecast data…</div>`}
${forecast && days.length > 0 && html`<${DayTabs}
days=${days}
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
isPro=${isPro}
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
visibleCols=${visibleCols}
activityOptions=${activityOptions} placeOptions=${placeOptions}
activityValue=${activityValue} activityLabel=${activityLabel}
placeValue=${placeValue} placeLabel=${placeLabel}
outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols}
setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged}
dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight}
scrollDayTabs=${scrollDayTabs}
/>`}
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
${isPro && (activeProfile === 'custom' || activeCols['utciP']) && html`<button class=${`col-toggle${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>UTCI+P</button>`}
${(isPro ? (activeProfile === 'custom' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${vehicleType}
isOn=${visibleCols.vehicleT}
hideLabel="Vehicle"
hidingLabel="Hide Vehicle"
groupedLeft=${true}
isLastChild=${!visibleCols.vehicleT}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
setVehicleVent(false);
} else {
setVehicleType(v);
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
}
}}
/>
${visibleCols.vehicleT && html`
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up"
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
<span class=${'col-toggle-group' + (indoorMode === 'on' ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType}
isOn=${indoorMode === 'on'}
hideLabel="Indoors"
hidingLabel="Hide Indoors"
groupedLeft=${true}
isLastChild=${indoorMode !== 'on'}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setIndoorMode('off');
setIndoorManaged(false);
} else {
setBuildingType(v);
setIndoorMode('on');
}
}}
/>
${indoorMode === 'on' && html`
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>`}
</span>`}
${(isPro ? (activeProfile === 'custom' || activeCols['burn']) : activeCols['burn']) && html`
<${CustomSelect}
value=${skinType}
isOn=${visibleCols.burn}
hideLabel="Burn"
hidingLabel="Hide Burn"
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({
value: k,
label: v.name.split(' · ')[1] + ' skin',
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, burn: false }));
} else {
setSkinType(v);
setVisibleCols(prev => ({ ...prev, burn: true }));
}
}}
/>`}
${isPro && (activeProfile === 'custom' || activeCols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${isPro && (activeProfile === 'custom' || activeCols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
${isPro && (activeProfile === 'custom' || activeCols['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
${isPro && (activeProfile === 'custom' || activeCols['soilT']) && html`<button class=${`col-toggle${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
${isPro && (activeProfile === 'custom' || activeCols['soilT6']) && html`<button class=${`col-toggle${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
${isPro && (activeProfile === 'custom' || activeCols['soilM']) && html`<button class=${`col-toggle${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button class=${`col-toggle${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${isPro && (activeProfile === 'custom' || activeCols['rh']) && html`<button class=${`col-toggle${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${isPro && (activeProfile === 'custom' || activeCols['dew']) && html`<button class=${`col-toggle${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
${isPro && (activeProfile === 'custom' || activeCols['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
${isPro && (activeProfile === 'custom' || activeCols['precipProb']) && html`<button class=${`col-toggle${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
${isPro && (activeProfile === 'custom' || activeCols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
${isPro && (activeProfile === 'custom' || activeCols['vis']) && html`<button class=${`col-toggle${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
${isPro && (activeProfile === 'custom' || activeCols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
${isPro && (activeProfile === 'custom' || activeCols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
${isPro && (activeProfile === 'custom' || activeCols['aqi']) && html`<button class=${`col-toggle${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
${(isPro ? (activeProfile === 'custom' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect}
value=${pollenType}
isOn=${visibleCols.pollen}
hideLabel="Pollen"
hidingLabel="Hide Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, pollen: false }));
} else {
setPollenTypeAndSave(v);
setVisibleCols(prev => ({ ...prev, pollen: true }));
}
}}
/>`}
${isPro && (activeProfile === 'custom' || activeCols['uvA']) && html`<button class=${`col-toggle${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
${isPro && (activeProfile === 'custom' || activeCols['uvB']) && html`<button class=${`col-toggle${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
${isPro && (activeProfile === 'custom' || activeCols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
${isPro && (activeProfile === 'custom' || activeCols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
${isPro && (activeProfile === 'custom' || activeCols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
</div>`}
<div class="display-options">
<label class="display-opt">
<input type="checkbox" checked=${showDecimals} onChange=${toggleShowDecimals} />
Decimals
</label>
<label class="display-opt">
<input type="checkbox" checked=${showDegree} onChange=${toggleShowDegree} />
° symbol
</label>
</div>
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} 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}>Hour</th>
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
${visibleCols.burn && html`<th class=${`col-info-th ${groupStart('burn')} ${groupColor('burn')}`} scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.utci && html`<th class=${`col-info-th ${groupStart('utci')} ${groupColor('utci')}`} scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.delta && html`<th class=${`col-info-th ${groupStart('delta')} ${groupColor('delta')}`} scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.tmrt && html`<th class=${`col-info-th ${groupStart('tmrt')} ${groupColor('tmrt')}`} scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.concreteT && html`<th class=${`col-info-th ${groupStart('concreteT')} ${groupColor('concreteT')}`} scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.soilT && html`<th class=${`col-info-th ${groupStart('soilT')} ${groupColor('soilT')}`} scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
${visibleCols.soilT6 && html`<th class=${`col-info-th ${groupStart('soilT6')} ${groupColor('soilT6')}`} scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class=${`col-info-th ${groupStart('soilM')} ${groupColor('soilM')}`} scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">%</span></th>`}
${visibleCols.air && html`<th class=${`utci-tight-head col-info-th ${groupStart('air')} ${groupColor('air')}`} scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
${visibleCols.rh && html`<th class=${`col-info-th ${groupStart('rh')} ${groupColor('rh')}`} scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
${visibleCols.dew && html`<th class=${`col-info-th ${groupStart('dew')} ${groupColor('dew')}`} scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.precip && html`<th class=${`utci-tight-head col-info-th ${groupStart('precip')} ${groupColor('precip')}`} scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
${visibleCols.precipProb && html`<th class=${`utci-tight-head col-info-th ${groupStart('precipProb')} ${groupColor('precipProb')}`} scope="col" onClick=${(e) => handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain <span class="col-unit">%</span></th>`}
${visibleCols.cloud && html`<th class=${`col-info-th ${groupStart('cloud')} ${groupColor('cloud')}`} scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class="col-unit">EU idx</span></th>`}
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class="col-unit">grains/m³</span></th>`}
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
${visibleCols.direct && html`<th class=${`col-info-th ${groupStart('direct')} ${groupColor('direct')}`} scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
${visibleCols.diffuse && html`<th class=${`col-info-th ${groupStart('diffuse')} ${groupColor('diffuse')}`} scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></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>
${visible.map((r) => {
const cat = utciCategory(r.utci);
const isNight = r.elev < 0;
const isNow = r.iso.slice(0, 13) === nowLocalISO;
const delta = r.utci - r.Ta;
const adjCat = utciCategory(r.utciAdj);
// Converts a band hex colour to rgba at the given alpha — used to
// tint the UTCI and UTCI+P cells directly from the band colour.
const hexToRgba = (hex, a) => {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0,2),16);
const g = parseInt(h.slice(2,4),16);
const b = parseInt(h.slice(4,6),16);
return `rgba(${r},${g},${b},${a})`;
};
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
const deg = showDegree ? '°' : '';
// Returns a background tint based on temperature value - all share the same
// Thermal Stress Bands - solid colours matching the legend exactly.
// All band-coloured cells use the full bg/fg from UTCI_BANDS.
const tempBg = (t) => {
if (t == null) return 'transparent';
const band = utciCategory(t);
return band ? bandGradient(band.bg, band.darkenAmt) : 'transparent';
};
const tempBgStrong = tempBg;
const tempFontColor = (t) => {
if (t == null) return '#1a1200';
const band = utciCategory(t);
return band ? band.fg : '#1a1200';
};
const tempFontColorStrong = tempFontColor;
// Returns any extra band styles (fontWeight, textShadow) for a given temp.
const bandExtras = (t) => {
const band = utciCategory(t);
if (!band) return {};
return {
...(band.fontWeight ? { fontWeight: band.fontWeight } : {}),
...(band.textShadow ? { textShadow: band.textShadow } : {}),
};
};
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
if (v == null || isNaN(v)) return 'transparent';
if (v <= min) return 'transparent';
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
const a = 0.035 + x * maxAlpha;
return `rgba(${rgb},${a.toFixed(3)})`;
};
const deltaBg = (v) => {
if (v == null || isNaN(v)) return 'transparent';
if (Math.abs(v) < 1) return 'transparent';
const x = Math.min(1, Math.abs(v) / 12);
const a = 0.035 + x * 0.13;
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
};
const burnBg = (mins, uv) => {
if (!isFinite(mins) || uv <= 0) return 'transparent';
if (mins >= 240) return 'transparent';
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
};
// 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 burnMins = sunburnMinutes(r.uv, skinType);
return html`
<tr key=${r.iso}
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
<td class="utci-time">
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} />
<span>${localHHMM}</span>
${(() => {
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return 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>
</td>
${visibleCols.utciP && html`
<td class=${groupStart('utciP')} style=${{ background: bandGradient(adjCat.bg, adjCat.darkenAmt), color: adjCat.fg, fontSize: '13px', ...bandExtras(r.utciAdj) }}>
${fmt(r.utciAdj)}${deg}
</td>`}
${visibleCols.vehicleT && html`
<td class=${groupStart('vehicleT')} style=${{ color: tempFontColorStrong(r.vehicleT), background: tempBgStrong(r.vehicleT), ...bandExtras(r.vehicleT) }}>
${fmt(r.vehicleT)}${deg}
</td>`}
${indoorMode === 'on' && !indoorManaged && html`
<td class=${groupStart('indoorT')} style=${{ color: tempFontColorStrong(r.indoorT), background: tempBgStrong(r.indoorT), ...bandExtras(r.indoorT) }}>
${fmt(r.indoorT)}${deg}
</td>`}
${indoorMode === 'on' && indoorManaged && html`
<td class=${groupStart('managedT')} style=${{ color: tempFontColorStrong(r.managedT), background: tempBgStrong(r.managedT), ...bandExtras(r.managedT) }}>
${fmt(r.managedT)}${deg}
</td>`}
${visibleCols.burn && html`
<td class=${groupStart('burn')} style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
${burnLabel(burnMins)}
</td>`}
${visibleCols.utci && html`
<td class=${groupStart('utci')} style=${{ background: bandGradient(cat.bg, cat.darkenAmt), color: cat.fg, ...bandExtras(r.utci) }}>
${fmt(r.utci)}${deg}
</td>`}
${visibleCols.delta && html`
<td class=${groupStart('delta')} style=${{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
background: deltaBg(delta),
}}>
${delta > 0 ? '+' : ''}${fmt(delta)}
</td>`}
${visibleCols.tmrt && html`<td class=${groupStart('tmrt')} style=${{ background: tempBgStrong(r.Tmrt), color: tempFontColorStrong(r.Tmrt), ...bandExtras(r.Tmrt) }}>${fmt(r.Tmrt)}${deg}</td>`}
${visibleCols.concreteT && html`
<td class=${groupStart('concreteT')} style=${{ color: tempFontColorStrong(r.concreteT), background: tempBgStrong(r.concreteT), ...bandExtras(r.concreteT) }}>
${fmt(r.concreteT)}${deg}
</td>`}
${visibleCols.soilT && html`
<td class=${groupStart('soilT')} style=${{ color: tempFontColorStrong(r.soilT0), background: tempBgStrong(r.soilT0), ...bandExtras(r.soilT0) }}>${fmt(r.soilT0)}${deg}</td>`}
${visibleCols.soilT6 && html`
<td class=${groupStart('soilT6')} style=${{ color: tempFontColorStrong(r.soilT6), background: tempBgStrong(r.soilT6), ...bandExtras(r.soilT6) }}>${fmt(r.soilT6)}${deg}</td>`}
${visibleCols.soilM && html`
<td class=${groupStart('soilM')} style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${r.soilM != null ? fmt(r.soilM * 100, 1) + '%' : '—'}</td>`}
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: tempBg(r.Ta), color: tempFontColorStrong(r.Ta), ...bandExtras(r.Ta) }}>${fmt(r.Ta)}${deg}</td>`}
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td class=${groupStart('dew')} style=${{ background: tempBgStrong(r.dew), color: tempFontColorStrong(r.dew), ...bandExtras(r.dew) }}>${fmt(r.dew)}${deg}</td>`}
${visibleCols.precip && html`
<td class=${groupStart('precip')} style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
${r.snow > 0 ? fmt(r.snow) + 'cm' : r.precip > 0 ? fmt(r.precip) : '—'}
</span>
</span>
</td>`}
${visibleCols.precipProb && html`
<td class=${groupStart('precipProb')} style=${{ background: scaleBg(r.precipProb, 0, 100, '70,145,200'), color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}>
${r.precipProb}%
</td>`}
${visibleCols.cloud && html`<td class=${groupStart('cloud')} style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07), verticalAlign: 'middle' }}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<span style=${{ position: 'relative', top: '6px' }}>
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
</span>
<span>${Math.round(r.cc)}</span>
</span>
</td>`}
${visibleCols.vis && (() => {
const v = r.visKm;
const bg = v == null ? 'transparent'
: v < 1 ? 'rgba(180,80,80,0.18)'
: v < 4 ? 'rgba(210,140,50,0.15)'
: v < 10 ? 'rgba(200,190,80,0.12)'
: 'transparent';
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
return html`<td class=${groupStart('vis')} style=${{ background: bg, color }}>
${v != null ? fmt(v) : '—'}
</td>`;
})()}
${visibleCols.wind && html`<td class=${groupStart('wind')} style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
${Math.round(r.va * 2.237)}${r.gust != null && r.gust > r.va + 0.5
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)})</span>`)(r.gust * 2.237)
: ''}
</td>`}
${visibleCols.dir && html`<td class=${`utci-dir-cell ${groupStart('dir')}`}>
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
<${WindVane} bearing=${r.wd} size=${28} />
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
</span>
</td>`}
${visibleCols.aqi && (() => {
const v = r.aqi;
const bg = v == null ? 'transparent'
: v < 20 ? 'rgba(80,180,100,0.15)'
: v < 40 ? 'rgba(140,200,100,0.13)'
: v < 60 ? 'rgba(220,200,60,0.15)'
: v < 80 ? 'rgba(220,130,50,0.18)'
: v < 100 ? 'rgba(200,70,50,0.18)'
: 'rgba(160,30,100,0.20)';
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
const label = v == null ? '—'
: v < 20 ? `${v} Good`
: v < 40 ? `${v} Fair`
: v < 60 ? `${v} Mod`
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`;
return html`<td class=${groupStart('aqi')} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.pollen && (() => {
const pollenMap = {
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
grass_pollen: r.grassPollen,
birch_pollen: r.birchPollen,
alder_pollen: r.alderPollen,
mugwort_pollen: r.mugwortPollen,
olive_pollen: r.olivePollen,
ragweed_pollen: r.ragweedPollen,
};
const v = pollenMap[pollenType] ?? null;
const bg = v == null ? 'transparent'
: v < 10 ? 'transparent'
: v < 50 ? 'rgba(180,200,80,0.13)'
: v < 200 ? 'rgba(210,150,50,0.16)'
: 'rgba(200,70,50,0.18)';
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
const label = v == null ? '—'
: v < 10 ? `${Math.round(v)} Low`
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`;
return html`<td class=${groupStart('pollen')} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.uvA && html`
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
${r.uvA > 0 ? fmt(r.uvA) : '—'}
</td>`}
${visibleCols.uvB && html`
<td class=${groupStart('uvB')} style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
${r.uvB > 0 ? fmt(r.uvB, 2) : '—'}
</td>`}
${visibleCols.sun && html`<td class=${groupStart('sun')} style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + deg : '—'}</td>`}
${visibleCols.direct && html`<td class=${groupStart('direct')} style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td class=${groupStart('diffuse')} style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
</tr>`;
})}
</tbody>
</table>
</div>
</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].desc}</p>
</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">
${UTCI_BANDS.map((b, i) => html`
<span key=${i} class="utci-legend-item" style=${{ background: bandGradient(b.bg), color: b.fg, ...(b.fontWeight ? { fontWeight: b.fontWeight } : {}), ...(b.textShadow ? { textShadow: b.textShadow } : {}) }}>
${b.label}
</span>`)}
</div>
</div>
${(() => {
const upcoming = getUpcomingEvents(location, 90);
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 90 days · ${location.name}</span>
</div>
<div class="almanac-list">
${upcoming.length === 0
? html`<div class="almanac-empty">No major cosmic events in the next 90 days — 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-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 to combine
air temperature, humidity, wind, and solar radiation into a single honest number. The
<strong>UTCI+P</strong> column adds an original rain and snow penalty so wet, windy days
read as cold as they feel.
</p>
<p class="utci-about-text">
Beyond felt temperature, SunScope calculates <strong>vehicle cabin heat</strong> (choose
your vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven
building types; 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 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-footer">
<em>Reading the table.</em> A large positive Δ means your body is absorbing
far more heat than the air temperature alone suggests typically due to direct solar radiation.
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
${isPro && html`
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
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">
&copy; 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>`;
}