Files
sunscope/assets/js/app.js
T
fraxle 85c415d1f5 1.5.7
Profile memory
Comment cleanup
2026-05-18 23:02:41 +01:00

1671 lines
93 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, useEffect, useRef, useCallback } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
utciCategory,
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 { getActiveEvents, getLensEvent, getCellTagEvents, getUpcomingEvents } from './events.js';
import {
FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, POLLEN_TYPES,
profileButtonOrder, variantIcons,
activityVariantKeys, placeVariantKeys,
COL_DESCRIPTIONS,
} from './config.js';
import { buildHourlyRows } from './compute.js';
import { useForecast } from './hooks/useForecast.js';
import { useColumnPopup } from './hooks/useColumnPopup.js';
import { useTableScroll } from './hooks/useTableScroll.js';
const html = htm.bind(h);
export function UTCIForecast() {
// ── 1. STATE ──────────────────────────────────────────────────────────
// Each useState() pairs a value with a setter. Calling the setter
// re-renders the page with the new value.
// The location we're forecasting for. Restored from localStorage if the
// user has visited before, otherwise defaults to Pangbourne, Berkshire.
const [location, setLocation] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_last_location');
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' };
});
// Wrapper that persists the location before updating state.
const setLocationAndSave = (loc) => {
try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
setLocation(loc);
};
// Forecast data is owned by useForecast — it fetches on location change,
// auto-refreshes every 5 minutes, and exposes the air-quality side data too.
const { forecast, airQuality, loading, error, now } = useForecast(location);
const [searchQuery, setSearchQuery] = useState(''); // text in the search box
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
const [searching, setSearching] = useState(false); // search-in-flight flag
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
// Day-tabs horizontal scrolling — chevrons show only when there's more
// content to reveal in that direction. Auto-scrolls active tab into view.
const dayTabsRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Re-runs whenever the number of day tabs changes (e.g. when the
// forecast finishes loading and the tabs first appear). Also re-measures
// on scroll, on window resize, and via ResizeObserver if the element's
// own width changes (e.g. layout shifts when sidebar opens).
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const update = () => {
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
let ro = null;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(update);
ro.observe(el);
}
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
if (ro) ro.disconnect();
};
}, [forecast]);
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const activeTab = el.querySelector('.utci-day-tab.active');
if (!activeTab) return;
const elRect = el.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.left < elRect.left + 8) {
el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' });
} else if (tabRect.right > elRect.right - 8) {
el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' });
}
}, [selectedDay]);
const scrollDayTabs = (dir) => {
const el = dayTabsRef.current;
if (!el) return;
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
};
// ─── PRO TIER ─────────────────────────────────────────────────────────
// Initialise from URL param (?pro=1, set by Stripe after checkout) or
// from localStorage (returning subscriber). Cleans the URL param
// immediately so it doesn't stay visible in the address bar.
const [isPro, setIsPro] = useState(() => {
// Check URL param first (just returned from Stripe checkout)
const params = new URLSearchParams(window.location.search);
if (params.get('pro') === '1') {
localStorage.setItem('sunscope_pro', '1');
// Clean the URL so the param doesn't stay visible
window.history.replaceState({}, '', window.location.pathname);
return true;
}
// Check localStorage (returning Pro subscriber)
return localStorage.getItem('sunscope_pro') === '1';
});
// ── FUTURE FEATURE v2: Dark Mode + Theme System (JSON-driven) ───────────────
// Add a `theme` state that drives a data-theme attribute on <body> or .utci-app.
// CSS custom properties do all the heavy lifting — no JS colour logic needed.
//
// THE THEME FILE: /assets/themes.json
// All themes live in a single JSON file. Easy to read, easy to edit —
// add a new theme by copying an existing block and changing the values.
// No build step, no JS changes required for new themes.
//
// Each theme has:
// id — unique key used in localStorage and data-theme attribute
// name — human-readable label (shown in theme picker UI)
// description — one-liner explaining the look/feel
// base — which base mode it extends: "light" | "dark"
// trigger — how/when it activates (see trigger types below)
// colors — the CSS custom properties it overrides (only what changes)
//
// Example themes.json:
// [
// {
// "id": "light",
// "name": "Light (Default)",
// "description": "Classic parchment and brass in full daylight",
// "base": "light",
// "trigger": { "type": "manual" },
// "colors": {
// "--bg": "#f5edd6",
// "--surface": "#ede0c4",
// "--brass": "#c8922a",
// "--text": "#2a1a08",
// "--text-muted": "#9a7d5a",
// "--accent": "#c8922a"
// }
// },
// {
// "id": "dark",
// "name": "Dark",
// "description": "Deep walnut and glowing brass — made for night",
// "base": "dark",
// "trigger": { "type": "manual" },
// "colors": {
// "--bg": "#1a1208",
// "--surface": "#261a0a",
// "--brass": "#c8922a",
// "--text": "#f5edd6",
// "--text-muted": "#9a7d5a",
// "--accent": "#d4a030"
// }
// },
// {
// "id": "christmas",
// "name": "Christmas",
// "description": "Holly green with crimson brass — Dec 20 to Jan 2",
// "base": "dark",
// "trigger": { "type": "date", "from": "12-20", "to": "01-02" },
// "colors": {
// "--bg": "#0f1f0f",
// "--surface": "#1a3a1a",
// "--brass": "#b8312f",
// "--text": "#f0ede0",
// "--text-muted": "#7a9a7a",
// "--accent": "#c8922a"
// }
// },
// {
// "id": "halloween",
// "name": "Halloween",
// "description": "Near-black with amber and burnt orange — Oct 2531",
// "base": "dark",
// "trigger": { "type": "date", "from": "10-25", "to": "10-31" },
// "colors": {
// "--bg": "#100a00",
// "--surface": "#1e1000",
// "--brass": "#cc6600",
// "--text": "#f0d080",
// "--text-muted": "#8a6020",
// "--accent": "#e07820"
// }
// },
// {
// "id": "heatwave",
// "name": "Heatwave Alert",
// "description": "Deep red pulse when a dangerous heat day is forecast",
// "base": "light",
// "trigger": { "type": "alert", "alertType": "heat" },
// "colors": {
// "--brass": "#b82020",
// "--accent": "#c83030"
// }
// }
// ]
//
// TRIGGER TYPES:
// "manual" — user picks it from the theme switcher in the nav, persisted
// to localStorage ('sunscope_theme')
// "date" — auto-applies between MM-DD dates (wraps year-end correctly)
// user can still override manually; auto resets when out of range
// "alert" — activates when daySummary (compute.js) fires a matching alert;
// subtle, temporary — reverts when alert clears
// "system" — mirrors the OS dark/light preference (prefers-color-scheme)
//
// HOW IT WORKS IN JS:
// 1. Fetch /assets/themes.json once on load (or bundle it as an import)
// 2. Walk the trigger rules to find the highest-priority active theme
// Priority: alert > manual > date > system
// 3. Apply the winning theme's colors as inline CSS variables on <body>
// Object.entries(theme.colors).forEach(([k,v]) =>
// document.body.style.setProperty(k, v))
// 4. Store active theme id in localStorage for manual overrides
// 5. A small theme-picker icon (🎨 or ☀️/🌙) in the top nav lets the
// user browse and manually select any theme
//
// const [theme, setTheme] = useState(() =>
// localStorage.getItem('sunscope_theme') || 'system');
// ─────────────────────────────────────────────────────────────────────────────
// (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.)
// Current active filter profile - persisted in localStorage
const [activeProfile, setActiveProfile] = useState(() => {
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
});
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
return { ...(FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols) };
} catch (e) { return { ...FILTER_PROFILES.basic.cols }; }
});
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
const [skinType, setSkinType] = useState('II');
// Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car');
// Places profile sub-variant - persisted in localStorage
const [outdoorsVariant, setOutdoorsVariant] = useState(() => {
try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; }
});
const setOutdoorsVariantAndSave = (v) => {
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
setOutdoorsVariant(v);
};
// Vehicle ventilation — true = windows open (high convective loss).
const [vehicleVent, setVehicleVent] = useState(false);
// Indoor: buildingType drives the physics preset; indoorManaged toggles the
// curtains+ventilation model on top. indoorMode gates column visibility:
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
const cols = FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols;
return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off';
} catch (e) { return 'off'; }
});
// Pollen type for the pollen column. Persisted in localStorage.
const [pollenType, setPollenType] = useState(() => {
try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
});
const setPollenTypeAndSave = (v) => {
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
setPollenType(v);
};
// (POLLEN_TYPES now lives in ./config.js.)
const searchTimeout = useRef(null);
const activateProfile = (key) => {
const profile = FILTER_PROFILES[key];
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
setActiveProfile(key);
if (key !== 'custom') {
setVisibleCols({ ...profile.cols });
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off');
setIndoorManaged(false);
}
};
// (profileButtonOrder, variantIcons, activityVariantKeys,
// placeVariantKeys now live in ./config.js.)
const activityOptions = [
{
value: 'farming',
label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`,
},
...activityVariantKeys.map((k) => {
const v = OUTDOORS_VARIANTS[k];
return {
value: k,
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
};
}),
];
const placeOptions = placeVariantKeys.map((k) => {
const v = OUTDOORS_VARIANTS[k];
return {
value: k,
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
};
});
// activeCols: the effective column set for the current profile/variant.
// Use this everywhere instead of FILTER_PROFILES[activeProfile].cols so that
// outdoors variants each get their own cols rather than the generic outdoors cols.
const activeCols = activeProfile === 'outdoors'
? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols)
: (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols);
const activityValue = activeProfile === 'farming'
? 'farming'
: activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
? outdoorsVariant
: 'off';
const activityLabel = activityOptions.find((option) => option.value === activityValue)?.label;
const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
? outdoorsVariant
: 'off';
const placeLabel = placeOptions.find((option) => option.value === placeValue)?.label;
// Refs for the two-scroller table layout (sticky-to-viewport header +
// horizontally-scrolling body). The header is clipped (overflow:hidden)
// and its inner "track" gets translateX'd via JS to follow the body's
// scrollLeft. See the useLayoutEffect just below where the JS sync
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
const headStickyRef = useRef(null);
const headTrackRef = useRef(null);
const headTableRef = useRef(null);
const bodyScrollRef = useRef(null);
const bodyTableRef = useRef(null);
const tableWrapRef = useRef(null);
// ─── COLUMN HEADER & EVENT TAG POPUPS ────────────────────────────────
// All popup state + handlers live in the useColumnPopup hook. The JSX
// wires up the th cells and event-tag spans with the handlers returned
// here, and reads colPopup / eventTagPopup to know when to render the
// floating panel.
const {
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
eventTagPopup, eventTagPopupRef,
evSlideIndex, evTransition, evSlideTo,
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
} = useColumnPopup();
// (COL_DESCRIPTIONS, calcPopupPos and all the popup handlers now live in
// ./config.js and ./hooks/useColumnPopup.js respectively.)
// ─── TABLE SCROLL ────────────────────────────────────────────────────
// useTableScroll owns everything horizontal-scroll-related for the
// hourly table:
// • scroll indicators (drives fade + chevron CSS)
// • drag-to-scroll for desktop users
// • column-width sync between sticky header and body
// It needs the four table refs and the deps that should trigger a
// re-sync (anything that changes the rendered cell content / count).
const {
tableCanScrollLeft,
tableCanScrollRight,
handleBodyScroll,
} = useTableScroll({
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef,
forecast, visibleCols, selectedDay, skinType, vehicleType,
indoorMode, indoorManaged,
});
// Geocoding search
useEffect(() => {
if (searchQuery.length < 2) { setSearchResults([]); return; }
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
setSearching(true);
try {
const r = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json`
);
const j = await r.json();
setSearchResults(j.results || []);
} catch { setSearchResults([]); }
finally { setSearching(false); }
}, 300);
}, [searchQuery]);
// (FORECAST FETCH, AIR QUALITY FETCH and 5-min AUTO-REFRESH now live in
// ./hooks/useForecast.js — called at the top of this component.)
// ─── COMPUTATION ─────────────────────────────────────────────────────
// Stitch the raw API arrays into one object per hour (UTCI + soak factor,
// indoor temps, AQI, pollen, etc) and group into days for the day tabs.
// See ./compute.js for the full algorithm.
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
forecast, airQuality, location, vehicleType, vehicleVent, buildingType,
});
const visible = days[selectedDay]?.rows || [];
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
// Used to match against r.iso (which is already a local wall-clock string).
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
.toISOString().slice(0, 13); // "YYYY-MM-DDTHH"
const currentRow = hourlyRows.length > 0
? (hourlyRows.find(row => row.iso.slice(0, 13) === nowLocalISO)
?? hourlyRows.reduce((best, row) =>
Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best))
: null;
const currentCat = currentRow
? utciCategory(currentRow.utciAdj)
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
// ─── COSMIC/WEATHER EVENTS ────────────────────────────────────────────
// activeEvents — today's events → drives banner & lens overlay.
// selectedDayEvents — viewed day's events → drives cell tags.
const [dismissedEventIds, setDismissedEventIds] = useState([]);
const [bannerIndex, setBannerIndex] = useState(0);
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
// bannerVisible drives the .visible class — kept true until the collapse
// animation finishes so content doesn't vanish before the wrap closes.
const [bannerVisible, setBannerVisible] = useState(false);
const bannerIndexRef = useRef(0);
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
const todayRows = days[0]?.rows || [];
const activeEvents = getActiveEvents(todayRows, location)
.filter(ev => !dismissedEventIds.includes(ev.id));
const lensEvent = getLensEvent(activeEvents);
const selectedDayEvents = getActiveEvents(visible, location);
// Sync visibility: show as soon as events exist, hide after dismiss animation
useEffect(() => {
if (activeEvents.length > 0) setBannerVisible(true);
}, [activeEvents.length]);
// Crossfade to a new banner index: fade out → swap → fade in
const bannerSlideTo = useCallback((next) => {
setBannerTransition('exiting');
setTimeout(() => {
setBannerIndex(next);
setBannerTransition('entering');
setTimeout(() => setBannerTransition(null), 420);
}, 400);
}, []);
// Dismiss with animation: fade stage → collapse wrap → remove event
const dismissBanner = useCallback((evId) => {
// 1. fade the stage out (CSS handles this via :not(.visible))
setBannerVisible(false);
// 2. after wrap has collapsed (grid transition ~450ms), remove the event
setTimeout(() => {
setDismissedEventIds(ids => [...ids, evId]);
setBannerIndex(0);
}, 500);
}, []);
// Auto-advance banner slideshow every 10 seconds when multiple events
useEffect(() => {
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
const id = setInterval(() => {
const next = (bannerIndexRef.current + 1) % activeEvents.length;
bannerSlideTo(next);
}, 10000);
return () => clearInterval(id);
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
// Keep index in bounds if events change
useEffect(() => {
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
}, [activeEvents.length]);
// ─── 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=${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 ev = activeEvents[bannerIndex] || 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);
// Only show date line if the event has start/end (cosmic events do; weather events don't)
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="event-banner-stage">
<div class=${`event-banner${bannerTransition ? ` banner-${bannerTransition}` : ''}`}
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>
<button
class="event-banner-dismiss"
style=${{ color: ev.textColor }}
onClick=${() => dismissBanner(ev.id)}
aria-label="Dismiss"
title="Dismiss this event"
></button>
</div>
</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>
</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`
<${Fragment}>
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(-1)}
aria-label="Scroll days left"
type="button"></button>
<button
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
onClick=${() => scrollDayTabs(1)}
aria-label="Scroll days right"
type="button"></button>
<div class="utci-day-tabs" ref=${dayTabsRef}>
${days.map((d, i) => {
const band = confidenceBand(i);
const locked = !isPro && i >= FREE_DAYS;
const isActive = i === selectedDay;
// d.key is "YYYY-MM-DD" in location-local time — parse as UTC so
// toLocaleDateString with timeZone:'UTC' reads the correct weekday/date.
const dDate = new Date(d.key + 'T00:00Z');
const dayName = i === 0 ? 'Today'
: i === 1 ? 'Tomorrow'
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
// ── Day-tab weather icon ───────────────────────────────────
// Use daytime rows (elev > 0) where available, else all rows.
// Pick the modal cloud category and sum precip/snow to decide
// whether to show a PrecipIcon or a CloudIcon.
const dayRows = d.rows.filter(r => r.elev > 0);
const repRows = dayRows.length > 0 ? dayRows : d.rows;
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
// Modal cloud category (most frequent among daytime hours)
const catCounts = {};
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
// Representative solar elevation: midday row or median of daytime rows
const midRow = repRows[Math.floor(repRows.length / 2)];
const repElev = midRow ? midRow.elev : 45;
const repDt = midRow ? midRow.dt : dDate;
// Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
return html`
<button
key=${d.key}
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
onClick=${() => {
if (locked) {
setProPromptSource('day');
setProPromptDay(i);
} else {
setSelectedDay(i);
setProPromptDay(null); // hide the card on a normal click
}
}}
title=${locked
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
: `${band.label} · day ${i + 1} of 14`}
style=${{
background: band.bg,
color: '#2a1d10',
borderStyle: 'solid',
borderWidth: '0 0 3px 0',
borderBottomColor: isActive ? '#1e1208' : band.edge,
opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative',
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
}}
>
${locked && html`
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
${dayName}
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span>
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
${showPrecip
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
</span>
${dayHi !== null && html`
<span style=${{
display: 'block',
fontFamily: 'Fraunces, serif',
fontStyle: 'italic',
fontSize: '11px',
marginTop: '2px',
letterSpacing: 0,
textTransform: 'none',
}}>
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
<span style=${{ opacity: 0.5 }}> / </span>
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
</span>`}
</button>`;
})}
</div>
</div>
${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
const extraPromptCopy = {
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
},
'profile:alltemps': {
title: 'Temps is part of SunScope Extra',
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
},
'profile:custom': {
title: 'Custom columns are part of SunScope Extra',
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
},
'variant:festival': {
title: 'Festival planning is part of SunScope Extra',
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
},
'variant:wintersports': {
title: 'Winter Sports is part of SunScope Extra',
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
},
'variant:naturist': {
title: 'Naturist is part of SunScope Extra',
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
},
};
const promptCopy = extraPromptCopy[proPromptSource] || {
title: `${dayName}'s forecast is part of SunScope Extra`,
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
};
return html`
<div style=${{
margin: '12px 0',
padding: '18px 22px',
background: '#fdf8ee',
border: '1.5px solid #c9b08a',
borderLeft: '4px solid #c8922a',
borderRadius: '0 4px 4px 0',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '14px',
}}>
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
<div style=${{
fontFamily: 'Fraunces, serif',
fontStyle: 'italic',
fontSize: '19px',
fontWeight: 700,
color: '#1e1208',
marginBottom: '6px',
lineHeight: 1.25,
}}>
🔒 ${promptCopy.title}
</div>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontSize: '13.5px',
color: '#4a3420',
lineHeight: 1.65,
}}>
${promptCopy.detail}
</div>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#c8922a',
marginTop: '10px',
}}>
£2 / month · cancel any time
</div>
</div>
<div style=${{
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-end',
}}>
<a
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
target="_blank"
rel="noopener noreferrer"
style=${{
display: 'inline-block',
padding: '10px 18px',
background: '#c8922a',
color: '#fff',
textDecoration: 'none',
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
borderRadius: '3px',
whiteSpace: 'nowrap',
}}
>
Subscribe — £2/month
</a>
<a
href="/restore.php"
style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#c8922a',
textDecoration: 'none',
borderBottom: '1px solid rgba(200,146,42,0.4)',
paddingBottom: '1px',
}}
>
Already subscribed? Restore access →
</a>
<a
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
target="_blank"
rel="noopener noreferrer"
style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#b09870',
textDecoration: 'none',
borderBottom: '1px solid rgba(176,152,112,0.4)',
paddingBottom: '1px',
}}
>
Manage or cancel subscription →
</a>
<button
onClick=${() => setProPromptDay(null)}
style=${{
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#b09870',
padding: '2px 4px',
}}
>
dismiss
</button>
</div>
</div>`;
})()}
${(() => {
const band = confidenceBand(selectedDay);
const isOutlook = selectedDay >= 7;
return html`
<div style=${{
display: 'flex',
alignItems: 'center',
gap: '10px',
margin: '8px 0 4px',
padding: '6px 10px',
background: band.tint,
borderLeft: `3px solid ${band.edge}`,
borderRadius: '0 4px 4px 0',
fontSize: '12px',
color: '#3a2a18',
fontFamily: 'Manrope, sans-serif',
fontWeight: 600,
flexWrap: 'wrap',
}}>
<span style=${{ letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 700 }}>
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
</span>
${isOutlook && html`
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
forecast skill is reduced — treat hourly detail as trend, not precision
</span>`}
</div>`;
})()}
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
<span class="filter-profiles-label">Profile:</span>
${profileButtonOrder.slice(0, 3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
})}
<span class="profile-divider" aria-hidden="true"></span>
<${CustomSelect}
key="places"
value=${placeValue}
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
noHide=${true}
hideLabel="🌤️ Places"
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`}
options=${placeOptions}
onChange=${(v) => {
if (v === 'off') {
activateProfile('basic');
return;
}
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
<${CustomSelect}
key="activities"
value=${activityValue}
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
noHide=${true}
hideLabel="🎯 Activities"
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`}
options=${activityOptions}
onChange=${(v) => {
if (v === 'off') {
activateProfile('basic');
return;
}
if (v === 'farming') {
activateProfile('farming');
return;
}
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
<span class="profile-divider" aria-hidden="true"></span>
${profileButtonOrder.slice(3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
<button
key=${key}
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
onClick=${() => {
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
})}
</div>
${(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['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['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['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</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['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['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['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>`}
${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
${isPro && (activeProfile === 'custom' || activeCols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
${isPro && (activeProfile === 'custom' || activeCols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
${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['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['vehicleT']) : activeCols['vehicleT']) && html`
<span class="col-toggle-group">
<${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">
<${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['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
</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>
<tr>
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour</th>
${visibleCols.air && html`<th class="utci-tight-head col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
${visibleCols.soilT && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" 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="col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Visibility <span class="col-unit">km</span></th>`}
${visibleCols.aqi && html`<th class="col-info-th" 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" 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.sun && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
${visibleCols.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
${visibleCols.delta && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCIAir</span></th>`}
${visibleCols.utci && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
${visibleCols.uvA && html`<th class="col-info-th" 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" 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.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
${visibleCols.vehicleT && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
${visibleCols.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</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})`;
};
// Returns a very light background tint based on temperature value.
// Cold → faint blue, cool → faint green, warm → faint amber, hot → faint red.
const tempBg = (t) => {
if (t == null) return 'transparent';
if (t < 0) return 'rgba(100,160,230,0.10)';
if (t < 10) return 'rgba(140,200,200,0.10)';
if (t < 18) return 'rgba(140,200,150,0.10)';
if (t < 26) return 'rgba(100,190,100,0.10)';
if (t < 32) return 'rgba(230,200, 80,0.10)';
if (t < 40) return 'rgba(230,140, 50,0.10)';
if (t < 50) return 'rgba(210, 70, 50,0.10)';
return 'rgba(160, 30, 30,0.10)';
};
const tempBgStrong = (t) => {
if (t == null) return 'transparent';
if (t < 0) return 'rgba(100,160,230,0.20)';
if (t < 10) return 'rgba(140,200,200,0.20)';
if (t < 18) return 'rgba(140,200,150,0.20)';
if (t < 26) return 'rgba(100,190,100,0.20)';
if (t < 32) return 'rgba(230,200, 80,0.20)';
if (t < 40) return 'rgba(230,140, 50,0.20)';
if (t < 50) return 'rgba(210, 70, 50,0.20)';
return 'rgba(160, 30, 30,0.20)';
};
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=${30} />
<span>${localHHMM}</span>
${(() => {
const rowEvents = getCellTagEvents(selectedDayEvents, r);
return rowEvents.map(ev => html`
<span key=${ev.id} class="event-cell-tag"
onClick=${(e) => handleEventTagClick(rowEvents, e)}
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, e)}
onMouseLeave=${handleEventTagLeave}
role="button" tabIndex="0" aria-label=${ev.title}
>${ev.emoji}</span>
`);
})()}
</span>
</td>
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
${visibleCols.rh && html`<td style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
${visibleCols.dew && html`<td style=${{ background: tempBg(r.dew) }}>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
${visibleCols.soilT && html`
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
${visibleCols.soilT6 && html`
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT6) }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
${visibleCols.soilM && html`
<td style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
${visibleCols.concreteT && html`
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
</td>`}
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
: ''}
</td>`}
${visibleCols.dir && html`<td class="utci-dir-cell">
<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.cloud && html`<td 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 style=${{ background: bg, color }}>
${v != null ? (v < 10 ? v.toFixed(1) : Math.round(v)) : '—'}
</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 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 style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.sun && html`<td style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
${visibleCols.tmrt && html`<td style=${{ background: tempBg(r.Tmrt) }}>${r.Tmrt.toFixed(1)}</td>`}
${visibleCols.delta && html`
<td style=${{
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
fontWeight: 600,
background: deltaBg(delta),
}}>
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
</td>`}
${visibleCols.utci && html`
<td style=${{ background: hexToRgba(cat.bg, 0.22), color: cat.fg === '#fff' ? cat.fg : '#2a1a08', fontWeight: 600 }}>
${r.utci.toFixed(1)}°
</td>`}
${visibleCols.uvA && html`
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
</td>`}
${visibleCols.uvB && html`
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
</td>`}
${visibleCols.burn && html`
<td style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
${burnLabel(burnMins)}
</td>`}
${visibleCols.vehicleT && html`
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBgStrong(r.vehicleT) }}>
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
</td>`}
${indoorMode === 'on' && !indoorManaged && html`
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.indoorT) }}>
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
</td>`}
${indoorMode === 'on' && indoorManaged && html`
<td style=${{ color: r.managedT != null && r.managedT > 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.managedT) }}>
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
</td>`}
${visibleCols.precip && html`
<td 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 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
</span>
</span>
</td>`}
${visibleCols.utciP && html`
<td style=${{ background: hexToRgba(adjCat.bg, 0.55), color: adjCat.fg === '#fff' ? adjCat.fg : '#2a1a08', fontWeight: 700, fontSize: '16px' }}>
${r.utciAdj.toFixed(1)}°
</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">
${[
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
{ l: '18 to 26 Comfortable', bg: '#4a8a3a', fg: '#fff' },
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
].map((b, i) => html`
<span key=${i} class="utci-legend-item" style=${{ background: b.bg, color: b.fg }}>
${b.l}
</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>
</main>
</div>`;
}