diff --git a/assets/css/table.css b/assets/css/table.css index 8a0aaa8..5bb9593 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -575,7 +575,7 @@ padding: 14px 16px 16px; pointer-events: all; } -/* Arrow points DOWN toward the column header */ +/* Arrow points DOWN toward the column header (default — popup is above the th) */ .col-info-popup::after { content: ''; position: absolute; @@ -586,7 +586,17 @@ background: #fffcf2; border-right: 1.5px solid #c9b08a; border-bottom: 1.5px solid #c9b08a; - transform: rotate(45deg); + transform: translateX(-50%) rotate(45deg); +} +/* Arrow points UP toward the column header (popup is below the th) */ +.col-info-popup.col-info-popup--below::after { + bottom: auto; + top: -7px; + border-right: none; + border-bottom: none; + border-left: 1.5px solid #c9b08a; + border-top: 1.5px solid #c9b08a; + transform: translateX(-50%) rotate(45deg); } .col-info-title { display: block; diff --git a/assets/js/.fuse_hidden0000000800000001 b/assets/js/.fuse_hidden0000000800000001 new file mode 100644 index 0000000..d38fa2e --- /dev/null +++ b/assets/js/.fuse_hidden0000000800000001 @@ -0,0 +1,1615 @@ +// ════════════════════════════════════════════════════════════════════════ +// 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, useLayoutEffect, useRef } from '../vendor/preact-hooks.js'; +import htm from '../vendor/htm.js'; +import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass } from './physics.js'; +import { + utciCategory, precipPenalty, windCompass8, uvSplit, + SKIN_TYPES, sunburnMinutes, burnLabel, + VEHICLE_TYPES, BUILDING_TYPES, + cloudCategory, confidenceBand, moonGlyph, +} from './utils.js'; +import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.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); + }; + + const [forecast, setForecast] = useState(null); // raw Open-Meteo response + const [loading, setLoading] = useState(false); // true while fetching + const [error, setError] = useState(null); // fetch error message + 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 [now, setNow] = useState(new Date()); // ticks every 5 min + 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 STUB ──────────────────────────────────────────────────── + // FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. + // When this is wired to real billing/auth, replace `useState(false)` + // with a check against the logged-in user. + //const [isPro, setIsPro] = useState(false); + 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'; + }); + + // How many days the free tier shows. Days beyond this get a 🔒. + // Bump this number if you want to give free users more access. + const FREE_DAYS = 3; + + // Filter profile presets — each preset defines which columns are visible + // when that profile is selected. + const FILTER_PROFILES = { + basic: { + label: 'Basic', + icon: '🌡️', + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, + }, + home: { + label: 'Home', + icon: '🏠', + cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true }, + }, + vehicle: { + label: 'Vehicle', + icon: '🚗', + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false }, + }, + farming: { + label: 'Farming', + icon: '🌾', + cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, + }, + outdoors: { + label: 'Places', + icon: '🌤️', + // Default cols match the first variant (urban). Switching variant updates visibleCols. + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false }, + }, + alltemps: { + label: 'Temps', + icon: '🌡️', + proOnly: true, + cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true }, + }, + custom: { + label: 'Custom', + icon: '⚙️', + proOnly: true, + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, + }, + }; + + // Places sub-variants — each has its own column set. + // Selecting a variant applies its cols to visibleCols. + const OUTDOORS_VARIANTS = { + urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } }, + beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, + events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } }, + festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, + wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, + naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, + sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, + }; + + // Current active filter profile + const [activeProfile, setActiveProfile] = useState('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({ ...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 (urban / beach / events / festival / wintersports / naturist) + const [outdoorsVariant, setOutdoorsVariant] = useState('urban'); + + // 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('off'); + const searchTimeout = useRef(null); + + // 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 POPUP ───────────────────────────────────────────── + // Popup opens on click (instant) or after hovering a for 3 s. + // Closes on click-toggle, click-outside, or mouseleave from both the + // th and the popup (with a 150 ms grace period so you can move between + // the two without it snapping shut). + // State holds { key, x, y, arrowLeft, below } or null when closed. + const [colPopup, setColPopup] = useState(null); + const colPopupRef = useRef(null); + const colPopupThRef = useRef(null); // that triggered the current popup + const hoverTimerRef = useRef(null); // setTimeout id for the 3 s open delay + const closeTimerRef = useRef(null); // setTimeout id for the 150 ms close grace + + const COL_DESCRIPTIONS = { + hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, + air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, + rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' }, + dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' }, + wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' }, + dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' }, + cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' }, + sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' }, + direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' }, + diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' }, + tmrt: { title: 'Mean Radiant Temp', desc: 'The temperature a person\'s skin "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day — this is why shade feels so much cooler.' }, + delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' }, + utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.' }, + uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.' }, + uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' }, + burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' }, + utciP: { title: 'UTCI+P', desc: 'SunScope\'s adjusted felt temperature: UTCI plus the soak-factor penalty for precipitation. Rain and snow on wet clothing can reduce the felt temperature by up to 8 °C.' }, + precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' }, + soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.' }, + soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, + soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' }, + concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' }, + vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, + indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, + managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, + }; + + // Compute popup position from a th element's current bounding rect. + // Returns { x, y, arrowLeft, below } — "below" true when popup sits + // underneath the th (not enough room above). + const calcPopupPos = (thEl) => { + const rect = thEl.getBoundingClientRect(); + const popupW = 260; + const popupH = 110; // approx — enough to decide above/below + const margin = 8; + const gap = 6; // gap between popup edge and th edge + // Horizontal: centre on the th, clamped to viewport + let x = rect.left + rect.width / 2; + x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); + // Vertical: prefer above; flip below if not enough room + const spaceAbove = rect.top; + const below = spaceAbove < popupH + gap + margin; + const y = below ? rect.bottom + gap : rect.top - gap; + // Arrow offset from popup left edge + const popupLeft = x - popupW / 2; + const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); + return { x, y, arrowLeft, below }; + }; + + // Returns the three event-handler props every needs. + const thPopupProps = (key) => ({ + onClick: (e) => handleThClick(key, e), + onMouseEnter: (e) => handleThEnter(key, e), + onMouseLeave: handleThLeave, + }); + + // Open the popup for a given th element + key. + const openPopup = (key, thEl) => { + colPopupThRef.current = thEl; + const pos = calcPopupPos(thEl); + setColPopup({ key, ...pos }); + }; + + // Close the popup and clear all pending timers. + const closePopup = () => { + setColPopup(null); + colPopupThRef.current = null; + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + }; + + // Click: instant toggle. + const handleThClick = (key, e) => { + clearTimeout(hoverTimerRef.current); // cancel any pending hover-open + clearTimeout(closeTimerRef.current); + if (colPopup?.key === key) { closePopup(); return; } + openPopup(key, e.currentTarget); + }; + + // Hover open: start 3 s timer on th enter. + const handleThEnter = (key, e) => { + clearTimeout(closeTimerRef.current); // cancel any pending close + if (colPopup?.key === key) return; // already open for this th + clearTimeout(hoverTimerRef.current); + const thEl = e.currentTarget; + hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 3000); + }; + + // Leave th: cancel hover-open; start close grace if popup is showing. + const handleThLeave = () => { + clearTimeout(hoverTimerRef.current); + closeTimerRef.current = setTimeout(closePopup, 150); + }; + + // Enter popup: cancel any pending close (mouse moved from th into popup). + const handlePopupEnter = () => clearTimeout(closeTimerRef.current); + + // Leave popup: close after grace period. + const handlePopupLeave = () => { + closeTimerRef.current = setTimeout(closePopup, 150); + }; + + // Dismiss popup on click outside; reposition on scroll/resize. + useEffect(() => { + if (!colPopup) return; + const onClickOutside = (e) => { + if (colPopupRef.current && !colPopupRef.current.contains(e.target)) { + closePopup(); + } + }; + const onScrollOrResize = () => { + if (!colPopupThRef.current) return; + const pos = calcPopupPos(colPopupThRef.current); + setColPopup(prev => prev ? { ...prev, ...pos } : null); + }; + document.addEventListener('mousedown', onClickOutside); + window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); + window.addEventListener('resize', onScrollOrResize, { passive: true }); + return () => { + document.removeEventListener('mousedown', onClickOutside); + window.removeEventListener('scroll', onScrollOrResize, { capture: true }); + window.removeEventListener('resize', onScrollOrResize); + }; + }, [colPopup]); + + // ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── + // Track whether the body scroller can scroll left/right so we can show + // fade + chevron indicators on the table edges. + const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); + const [tableCanScrollRight, setTableCanScrollRight] = useState(false); + + const updateTableScrollIndicators = () => { + const el = bodyScrollRef.current; + if (!el) return; + setTableCanScrollLeft(el.scrollLeft > 1); + setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + + // ─── DRAG-TO-SCROLL ────────────────────────────────────────────────── + // Attach pointer-event drag scrolling to the body scroller so desktop + // users can click-drag the table horizontally. + useEffect(() => { + const el = bodyScrollRef.current; + if (!el) return; + let isDown = false; + let startX = 0; + let startScroll = 0; + + const onMouseDown = (e) => { + // Only act on clicks that land inside the body scroller + if (!el.contains(e.target)) return; + if (e.button !== 0) return; + if (e.target.closest('button, a, input, select')) return; + isDown = true; + startX = e.clientX; + startScroll = el.scrollLeft; + el.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + }; + const onMouseMove = (e) => { + if (!isDown) return; + const dx = e.clientX - startX; + el.scrollLeft = startScroll - dx; + }; + const onMouseUp = () => { + if (!isDown) return; + isDown = false; + el.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.webkitUserSelect = ''; + }; + + // Attach everything to document so Preact's synthetic event system + // cannot intercept or swallow the events before we see them. + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + // Also update indicators on scroll + el.addEventListener('scroll', updateTableScrollIndicators); + + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + el.removeEventListener('scroll', updateTableScrollIndicators); + }; + }, [forecast]); + + // Update indicators after layout sync (columns may have changed width) + useEffect(() => { + updateTableScrollIndicators(); + }, [forecast, visibleCols, selectedDay]); + + // ─── TABLE SCROLL SYNC ─────────────────────────────────────────────── + // The hourly table is rendered as two stacked scroll areas: + // • Sticky header strip (locked to viewport top, clipped) + // • Body scroller (overflow-x: auto — owns the horizontal scrollbar) + // We need to (a) keep the header track shifted horizontally to match + // the body's scrollLeft, and (b) keep the header cells the same pixel + // width as the body cells even as columns toggle or the window resizes. + // ───────────────────────────────────────────────────────────────────── + const handleBodyScroll = () => { + const track = headTrackRef.current; + const body = bodyScrollRef.current; + if (!track || !body) return; + track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; + updateTableScrollIndicators(); + }; + + useLayoutEffect(() => { + // Synchronise the head and body table column widths with a + // "shrink-to-fit then distribute" strategy: + // • Measure each column's true natural (content-fit) width by + // temporarily switching both tables to table-layout: auto + + // width: max-content. White-space: nowrap on cells stops content + // from wrapping, so the measurement is the smallest width that + // won't clip the content. + // • If the body scroller has spare horizontal space (natural total + // < container width), scale every column up proportionally to + // fill it — so toggling columns off makes the remaining ones fan + // out instead of leaving an awkward gap. + // • Otherwise apply the natural widths as-is and let the body + // scroller's overflow-x: auto produce a horizontal scrollbar. + const sync = () => { + const headTable = headTableRef.current; + const bodyTable = bodyTableRef.current; + const bodyScroll = bodyScrollRef.current; + if (!headTable || !bodyTable || !bodyScroll) return; + const bodyRow = bodyTable.querySelector('tbody tr'); + const headRow = headTable.querySelector('thead tr'); + if (!bodyRow || !headRow) return; + const headCells = Array.from(headRow.children); + const bodyCells = Array.from(bodyRow.children); + const n = Math.min(headCells.length, bodyCells.length); + if (n === 0) return; + + // Step 1: clear any previously-forced cell widths and switch the + // tables to natural sizing so the measurement reflects the true + // content-fit width — independent of how wide the container is. + headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); + bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); + headTable.style.width = 'max-content'; + bodyTable.style.width = 'max-content'; + headTable.style.tableLayout = 'auto'; + bodyTable.style.tableLayout = 'auto'; + + // Step 2: read each cell's natural width. getBoundingClientRect + // forces synchronous layout — that's what we want. + const naturalW = new Array(n); + let naturalTotal = 0; + for (let i = 0; i < n; i++) { + const headW = headCells[i].getBoundingClientRect().width; + const bodyW = bodyCells[i].getBoundingClientRect().width; + const w = Math.max(Math.ceil(headW), Math.ceil(bodyW)); + naturalW[i] = w; + naturalTotal += w; + } + + // Step 3: decide final widths based on available container width. + const containerW = bodyScroll.clientWidth; + const finalW = new Array(n); + let totalWidth; + if (naturalTotal > 0 && naturalTotal < containerW) { + // Spare space — distribute proportionally across columns so they + // fan out to fill the scroller (no awkward right-hand gap). + const scale = containerW / naturalTotal; + let running = 0; + for (let i = 0; i < n - 1; i++) { + finalW[i] = Math.floor(naturalW[i] * scale); + running += finalW[i]; + } + // Absorb sub-pixel rounding into the last column so the total + // exactly matches the container width. + finalW[n - 1] = containerW - running; + totalWidth = containerW; + } else { + // Naturals don't fit — use them as-is and let the body scroll. + for (let i = 0; i < n; i++) finalW[i] = naturalW[i]; + totalWidth = naturalTotal; + } + + // Step 4: restore the CSS-defined table-layout: fixed so the + // explicit cell widths we apply below are honoured by the browser + // (not redistributed by the auto-layout algorithm). + headTable.style.tableLayout = ''; + bodyTable.style.tableLayout = ''; + + // Step 5: apply the final width to both head and body cells. + for (let i = 0; i < n; i++) { + const px = `${finalW[i]}px`; + headCells[i].style.width = px; + headCells[i].style.minWidth = px; + headCells[i].style.maxWidth = px; + bodyCells[i].style.width = px; + bodyCells[i].style.minWidth = px; + bodyCells[i].style.maxWidth = px; + } + // Make both tables exactly totalWidth wide so they share the same + // horizontal extent — column N in the header sits directly above + // column N in the body, no drift as you scroll right. + headTable.style.width = `${totalWidth}px`; + bodyTable.style.width = `${totalWidth}px`; + // Re-apply current horizontal offset so column alignment survives. + handleBodyScroll(); + }; + // Run once after layout + sync(); + // Re-sync when the scroll container's width changes (window resize, + // sidebar opens, etc). We observe the scroller — not the body table — + // because the body table's width is now driven by sync itself, which + // would otherwise create a feedback loop. + let ro = null; + if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) { + ro = new ResizeObserver(sync); + ro.observe(bodyScrollRef.current); + } + window.addEventListener('resize', sync); + return () => { + if (ro) ro.disconnect(); + window.removeEventListener('resize', sync); + }; + }, [forecast, visibleCols, selectedDay, skinType, vehicleType]); + + // 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 ────────────────────────────────────────────────── + // Runs every time `location` changes (i.e. when a new city is picked). + // Builds the Open-Meteo URL and stores the response in `forecast`. + // Change forecast_days=14 below to fetch a different range (max 16). + // Add or remove fields in the `&hourly=...` list to fetch more data — + // but if you remove one that's used elsewhere, expect errors. + useEffect(() => { + async function load() { + setLoading(true); setError(null); + try { + const url = + `https://api.open-meteo.com/v1/forecast` + + `?latitude=${location.lat}&longitude=${location.lon}` + + `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + + `direct_radiation,diffuse_radiation,shortwave_radiation,` + + `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + + `uv_index,precipitation,snowfall,` + + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + + `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); + setForecast(await r.json()); + } catch (e) { setError(e.message); } + finally { setLoading(false); } + } + load(); + }, [location]); + + // ─── AUTO-REFRESH — tick every 5 minutes ───────────────────────────── + // Updates `now` so the scope always shows the current hour's data. + // Also refetches the forecast so fresh API data comes in automatically. + useEffect(() => { + const FIVE_MIN = 5 * 60 * 1000; + const id = setInterval(() => { + setNow(new Date()); + // Trigger a fresh forecast fetch by nudging location identity. + // We do this via a separate load rather than touching location state + // (which would reset other things), so we call load() directly. + async function refresh() { + try { + const url = + `https://api.open-meteo.com/v1/forecast` + + `?latitude=${location.lat}&longitude=${location.lon}` + + `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + + `direct_radiation,diffuse_radiation,shortwave_radiation,` + + `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + + `uv_index,precipitation,snowfall,` + + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + + `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; + const r = await fetch(url); + if (r.ok) setForecast(await r.json()); + } catch (_) { /* silently ignore refresh errors */ } + } + refresh(); + }, FIVE_MIN); + return () => clearInterval(id); + }, [location]); + + // ─── COMPUTATION ───────────────────────────────────────────────────── + // Take the raw API arrays and stitch them into one object per hour, + // calculating UTCI + soak-factor for each row. This is what gets + // displayed in the table. + // Open-Meteo with timezone=auto returns local wall-clock strings like + // "2026-05-13T14:00" — no Z, no offset suffix. We need two things: + // 1. The wall-clock hour for display & day grouping (just slice the string) + // 2. The true UTC instant for solarElevationDeg (which uses .getUTC* internally) + // Strategy: treat the ISO string as UTC (append Z), which gives a Date whose + // UTC hours equal the local wall-clock hour. Then ADD the utc_offset_seconds + // to shift it to the real UTC instant. e.g. Brisbane UTC+10: "14:00" local + // → parse as UTC 14:00 → add 10h → UTC 00:00 next day? No — subtract. + // Brisbane local 14:00 = UTC 04:00, offset = +10h, so UTC = local - offset. + // Date.parse("2026-05-13T14:00Z") = ms for UTC 14:00 + // Subtract offset (+10h = 36000000ms) → UTC 04:00. Correct. + const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; + + const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { + const h = forecast.hourly; + const Ta = h.temperature_2m[i]; + const RH = h.relative_humidity_2m[i]; + const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; + const va = h.wind_speed_10m[i]; + const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; + const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; + const dir = h.direct_radiation[i] || 0; + const dif = h.diffuse_radiation[i] || 0; + const glob = h.shortwave_radiation[i] || 0; + const cc = h.cloud_cover[i]; + const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; + const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; + const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; + const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; + const precip = h.precipitation[i] || 0; + const snow = h.snowfall[i] || 0; + const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; + const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; + const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; + const concreteT = calcConcreteTemp(Ta, glob, va); + // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). + // For display we slice the string directly — no Date object needed. + // For solarElevationDeg (which uses .getUTC* internally) we need the + // true UTC instant: treat the local time as UTC then subtract the offset. + // e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓ + const dtUTC = new Date(Date.parse(iso + 'Z') - utcOffsetMs); + // dt kept for SkyScope / backward compat — same as dtUTC. + const dt = dtUTC; + const elev = solarElevationDeg(location.lat, location.lon, dtUTC); + const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); + const eh = vaporPressureHpa(Ta, RH); + const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); + const utci = utciApprox(Ta, Tmrt, va, eh); + const utciAdj = utci + precipPenalty(precip, snow, va); + // Derived + const compass = windCompass8(wd); + const { uvA, uvB } = uvSplit(uv, elev); + const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); + return { + iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, + cc, ccLow, ccMid, ccHigh, cloudCat, + uv, uvA, uvB, + precip, snow, + soilT0, soilT6, soilM, concreteT, vehicleT, + elev, Tmrt, utci, utciAdj, eh, compass, + }; + }) : []; + + // Two-pass indoor temperature: needs the full hourly arrays so thermal + // lag can look back at previous hours. Run after hourlyRows is built, + // then stamp each row with its indoorT value. + if (hourlyRows.length > 0) { + const TaArr = hourlyRows.map(r => r.Ta); + const globArr = hourlyRows.map(r => r.glob); + const elevArr = hourlyRows.map(r => r.elev); + const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); + const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); + hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); + } + + // Group those hourly rows into days for the day tabs. + const days = []; + hourlyRows.forEach(row => { + const key = row.iso.slice(0, 10); + let day = days.find(d => d.key === key); + if (!day) { day = { key, rows: [] }; days.push(day); } + day.rows.push(row); + }); + + 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' }; + + // ─── 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` +
+ +
+ +
+
+

+ SUNScope +

+
See the sun the way your body does.
+
+ ↳ ${location.name} + + ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}° + +
+
+ +
+ <${ScopeReticle} + value=${currentRow?.utciAdj ?? null} + cat=${currentCat} + loading=${loading} + elev=${currentRow?.elev ?? 0} + dt=${currentRow?.dt ?? new Date()} + glob=${currentRow?.glob ?? 0} + /> +
+ +
+
+ + setSearchQuery(e.currentTarget.value)} + /> + ${searchResults.length > 0 && html` +
+ ${searchResults.map((r) => html` +
{ + setLocationAndSave({ + name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, + lat: r.latitude, + lon: r.longitude, + country: r.country_code, + }); + setSearchQuery(''); + setSearchResults([]); + setSelectedDay(0); + }} + > +
${r.name}${r.admin1 ? `, ${r.admin1}` : ''}
+
+ ${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}° +
+
`)} +
`} + ${searching && html`
Searching…
`} +
+
+
+ + ${error && html` +
+ ⚠ ${error} +
`} + ${loading && !error && html` +
Acquiring forecast data…
`} + + ${forecast && days.length > 0 && html` + <${Fragment}> + +
+ + +
+ ${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' }); + return html` + `; + })} +
+
+ + + ${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 = { + 'profile: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` +
+
+
+ 🔒 ${promptCopy.title} +
+
+ ${promptCopy.detail} +
+
+ £2 / month · cancel any time +
+
+
+ + Subscribe — £2/month + + + Already subscribed? Restore access → + + + Manage or cancel subscription → + + +
+
`; + })()} + + ${(() => { + const band = confidenceBand(selectedDay); + const isOutlook = selectedDay >= 7; + return html` +
+ + Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label} + + ${isOutlook && html` + + forecast skill is reduced — treat hourly detail as trend, not precision + `} +
`; + })()} + + +
+ Profile: + ${Object.entries(FILTER_PROFILES).map(([key, profile]) => { + const locked = profile.proOnly && !isPro; + + // The 'outdoors' profile becomes a CustomSelect dropdown, + // followed by a separator dividing general-use from technical profiles. + if (key === 'outdoors') { + return html` + <${CustomSelect} + key="outdoors" + value=${activeProfile === 'outdoors' ? outdoorsVariant : 'off'} + isOn=${activeProfile === 'outdoors'} + noHide=${true} + hideLabel=${`${profile.icon} ${profile.label}`} + options=${Object.entries(OUTDOORS_VARIANTS).map(([k, v]) => ({ + value: k, + label: v.name + (v.proOnly && !isPro ? ' 🔒' : ''), + disabled: false, + }))} + onChange=${(v) => { + if (v === 'off') { + setActiveProfile('basic'); + setVisibleCols({ ...FILTER_PROFILES.basic.cols }); + setIndoorMode('off'); + return; + } + if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) { + setProPromptSource(`variant:${v}`); + setProPromptDay(0); + return; + } + setActiveProfile('outdoors'); + setOutdoorsVariant(v); + setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); + setIndoorMode('off'); + }} + /> + `; + } + + return html` + `; + })} + +
+ + + ${(isPro || activeProfile === 'outdoors' || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html` +
+ Columns: + +${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html``} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html``} + + ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['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 })); + } + }} + />`} + + ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html` + + <${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" + />`} + `} + + ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html` + + <${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" + />`} + `} + ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html``} + +
`} + + +
+ + + +
+
+ + + + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html``} + ${visibleCols.soilT6 && html``} + ${visibleCols.soilM && html``} + ${visibleCols.concreteT && html``} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html``} + ${visibleCols.utci && html``} + ${visibleCols.uvA && html``} + ${visibleCols.uvB && html``} + ${visibleCols.burn && html``} + ${visibleCols.vehicleT && html``} + ${indoorMode === 'on' && !indoorManaged && html``} + ${indoorMode === 'on' && indoorManaged && html``} + ${visibleCols.precip && html``} + ${visibleCols.utciP && html``} + + +
handleThClick('hour', e)}>Hour handleThClick('air', e)}>Air °C handleThClick('rh', e)}>RH % handleThClick('dew', e)}>Dew °C handleThClick('soilT', e)}>Soil °C surface handleThClick('soilT6', e)}>Soil 6cm °C root handleThClick('soilM', e)}>Soil moist m³/m³ handleThClick('concreteT', e)}>Concrete °C surface handleThClick('wind', e)}>Wind m/s (gust) handleThClick('dir', e)}>Dir - handleThClick('cloud', e)}>Cloud % handleThClick('sun', e)}>Sun elev° handleThClick('direct', e)}>Direct W/m² handleThClick('diffuse', e)}>Diffuse W/m² handleThClick('tmrt', e)}>Tmrt °C handleThClick('delta', e)}>Δ UTCI−Air handleThClick('utci', e)}>UTCI °C felt handleThClick('uvA', e)}>UV-A est. idx handleThClick('uvB', e)}>UV-B est. idx handleThClick('burn', e)}>Burn to MED handleThClick('vehicleT', e)}>Vehicle °C peak handleThClick('indoorT', e)}>Indoors °C est. handleThClick('managedT', e)}>Managed °C est. handleThClick('precip', e)}>Pcpt mm/h handleThClick('utciP', e)}>UTCI+P °C adj.
+
+
+ +
+ + + ${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` + + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html` + `} + ${visibleCols.soilT6 && html` + `} + ${visibleCols.soilM && html` + `} + ${visibleCols.concreteT && html` + `} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html` + `} + ${visibleCols.utci && html` + `} + ${visibleCols.uvA && html` + `} + ${visibleCols.uvB && html` + `} + ${visibleCols.burn && html` + `} + ${visibleCols.vehicleT && html` + `} + ${indoorMode === 'on' && !indoorManaged && html` + `} + ${indoorMode === 'on' && indoorManaged && html` + `} + ${visibleCols.precip && html` + `} + ${visibleCols.utciP && html` + `} + `; + })} + +
+ + <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} /> + ${localHHMM} + + ${r.Ta.toFixed(1)}${Math.round(r.RH)}${r.dew != null ? r.dew.toFixed(1) : '—'}${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}${r.soilM != null ? r.soilM.toFixed(3) : '—'} 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}> + ${r.concreteT != null ? r.concreteT.toFixed(1) : '—'} + + ${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5 + ? html`(${r.gust.toFixed(1)})` + : ''} + + + <${WindVane} bearing=${r.wd} size=${28} /> + ${r.compass.label} + + + + <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> + ${Math.round(r.cc)} + + 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}${Math.round(r.dir)}${Math.round(r.dif)}${r.Tmrt.toFixed(1)} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218', + fontWeight: 600, + background: deltaBg(delta), + }}> + ${delta > 0 ? '+' : ''}${delta.toFixed(1)} + + ${r.utci.toFixed(1)}° + 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}> + ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} + 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}> + ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} + 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}> + ${burnLabel(burnMins)} + 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBgStrong(r.vehicleT) }}> + ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'} + 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.indoorT) }}> + ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'} + 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.managedT) }}> + ${r.managedT != null ? r.managedT.toFixed(1) : '—'} + 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}> + + <${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} /> + 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}> + ${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} + + + + ${r.utciAdj.toFixed(1)}° +
+
+
+ + ${colPopup && COL_DESCRIPTIONS[colPopup.key] && html` +
+ + ${COL_DESCRIPTIONS[colPopup.key].title} +

${COL_DESCRIPTIONS[colPopup.key].desc}

+
`} + +
+ Thermal stress bands +
+ ${[ + { 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` + + ${b.l} + `)} +
+
+ `} + +
+

What is SunScope?

+

+ SunScope is a free hourly weather forecast built around felt temperature, + not just air temperature. It uses the Universal Thermal Climate Index (UTCI) + — the biometeorological standard used in heat-health warning systems worldwide — to combine + air temperature, humidity, wind, and solar radiation into a single honest number. The + UTCI+P column adds an original rain and snow penalty so wet, windy days + read as cold as they feel. +

+

+ Beyond felt temperature, SunScope calculates vehicle cabin heat (choose + your vehicle type; toggle windows open), indoor temperature (seven + building types; managed heatwave mode), urban concrete surface temperature, + UV index and sunburn time by skin type, and soil temperature + and moisture for farming and motorhome use. Switch profiles to see the data + that matters for your situation — or go Custom and build your own view. + Learn more → +

+
+ + + +
+
`; +} diff --git a/assets/js/app.js b/assets/js/app.js index abe4462..b357ae4 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -238,7 +238,10 @@ export function UTCIForecast() { // Clicking a shows a small description popup below it. // State holds { key, x, y } or null when closed. const [colPopup, setColPopup] = useState(null); - const colPopupRef = useRef(null); + const colPopupRef = useRef(null); + const colPopupThRef = useRef(null); + const hoverTimerRef = useRef(null); + const closeTimerRef = useRef(null); const COL_DESCRIPTIONS = { hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, @@ -268,34 +271,76 @@ export function UTCIForecast() { managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, }; - // Dismiss popup on click outside + const calcPopupPos = (thEl) => { + const rect = thEl.getBoundingClientRect(); + const popupW = 260, popupH = 110, margin = 8, gap = 6; + let x = rect.left + rect.width / 2; + x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); + const below = rect.top < popupH + gap + margin; + const y = below ? rect.bottom + gap : rect.top - gap; + const popupLeft = x - popupW / 2; + const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); + return { x, y, arrowLeft, below }; + }; + + const openPopup = (key, thEl) => { + colPopupThRef.current = thEl; + setColPopup({ key, ...calcPopupPos(thEl) }); + }; + + const closePopup = () => { + setColPopup(null); + colPopupThRef.current = null; + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + }; + + const handleThClick = (key, e) => { + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + if (colPopup?.key === key) { closePopup(); return; } + openPopup(key, e.currentTarget); + }; + + const handleThEnter = (key, e) => { + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + // If a different popup is open, close it immediately and start fresh timer + if (colPopup && colPopup.key !== key) closePopup(); + if (colPopup?.key === key) return; // already showing this one + const thEl = e.currentTarget; + hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000); + }; + + // Leaving a th: just cancel the pending open. Don't auto-close — + // the user might be moving into the popup, or just passing through. + const handleThLeave = () => { + clearTimeout(hoverTimerRef.current); + }; + + // Popup mouse handlers: keep it open while hovering, close on leave. + const handlePopupEnter = () => clearTimeout(closeTimerRef.current); + const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); }; + useEffect(() => { if (!colPopup) return; const onClickOutside = (e) => { - if (colPopupRef.current && !colPopupRef.current.contains(e.target)) { - setColPopup(null); - } + if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup(); + }; + const onScrollOrResize = () => { + if (!colPopupThRef.current) return; + setColPopup(prev => prev ? { ...prev, ...calcPopupPos(colPopupThRef.current) } : null); }; document.addEventListener('mousedown', onClickOutside); - return () => document.removeEventListener('mousedown', onClickOutside); + window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); + window.addEventListener('resize', onScrollOrResize, { passive: true }); + return () => { + document.removeEventListener('mousedown', onClickOutside); + window.removeEventListener('scroll', onScrollOrResize, { capture: true }); + window.removeEventListener('resize', onScrollOrResize); + }; }, [colPopup]); - const handleThClick = (key, e) => { - if (colPopup?.key === key) { setColPopup(null); return; } - const rect = e.currentTarget.getBoundingClientRect(); - const popupW = 260; - const margin = 8; // min gap from screen edge - // Centre on the th, then clamp so popup stays within viewport - let x = rect.left + rect.width / 2; - x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); - // Position popup ABOVE the header; arrow points down toward the th - const y = rect.top - 6; // 6px gap above the th top edge - // Store arrowLeft as offset from popup left edge so arrow stays over the th - const popupLeft = x - popupW / 2; - const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); - setColPopup({ key, x, y, arrowLeft }); - }; - // ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── // Track whether the body scroller can scroll left/right so we can show // fade + chevron indicators on the table edges. @@ -1259,31 +1304,31 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a - - ${visibleCols.air && html``} - ${visibleCols.rh && html``} - ${visibleCols.dew && html``} - ${visibleCols.soilT && html``} - ${visibleCols.soilT6 && html``} - ${visibleCols.soilM && html``} - ${visibleCols.concreteT && html``} - ${visibleCols.wind && html``} - ${visibleCols.dir && html``} - ${visibleCols.cloud && html``} - ${visibleCols.sun && html``} - ${visibleCols.direct && html``} - ${visibleCols.diffuse && html``} - ${visibleCols.tmrt && html``} - ${visibleCols.delta && html``} - ${visibleCols.utci && html``} - ${visibleCols.uvA && html``} - ${visibleCols.uvB && html``} - ${visibleCols.burn && html``} - ${visibleCols.vehicleT && html``} - ${indoorMode === 'on' && !indoorManaged && html``} - ${indoorMode === 'on' && indoorManaged && html``} - ${visibleCols.precip && html``} - ${visibleCols.utciP && html``} + + ${visibleCols.air && html``} + ${visibleCols.rh && html``} + ${visibleCols.dew && html``} + ${visibleCols.soilT && html``} + ${visibleCols.soilT6 && html``} + ${visibleCols.soilM && html``} + ${visibleCols.concreteT && html``} + ${visibleCols.wind && html``} + ${visibleCols.dir && html``} + ${visibleCols.cloud && html``} + ${visibleCols.sun && html``} + ${visibleCols.direct && html``} + ${visibleCols.diffuse && html``} + ${visibleCols.tmrt && html``} + ${visibleCols.delta && html``} + ${visibleCols.utci && html``} + ${visibleCols.uvA && html``} + ${visibleCols.uvB && html``} + ${visibleCols.burn && html``} + ${visibleCols.vehicleT && html``} + ${indoorMode === 'on' && !indoorManaged && html``} + ${indoorMode === 'on' && indoorManaged && html``} + ${visibleCols.precip && html``} + ${visibleCols.utciP && html``}
handleThClick('hour', e)}>Hour handleThClick('air', e)}>Air °C handleThClick('rh', e)}>RH % handleThClick('dew', e)}>Dew °C handleThClick('soilT', e)}>Soil °C surface handleThClick('soilT6', e)}>Soil 6cm °C root handleThClick('soilM', e)}>Soil moist m³/m³ handleThClick('concreteT', e)}>Concrete °C surface handleThClick('wind', e)}>Wind m/s (gust) handleThClick('dir', e)}>Dir - handleThClick('cloud', e)}>Cloud % handleThClick('sun', e)}>Sun elev° handleThClick('direct', e)}>Direct W/m² handleThClick('diffuse', e)}>Diffuse W/m² handleThClick('tmrt', e)}>Tmrt °C handleThClick('delta', e)}>Δ UTCI−Air handleThClick('utci', e)}>UTCI °C felt handleThClick('uvA', e)}>UV-A est. idx handleThClick('uvB', e)}>UV-B est. idx handleThClick('burn', e)}>Burn to MED handleThClick('vehicleT', e)}>Vehicle °C peak handleThClick('indoorT', e)}>Indoors °C est. handleThClick('managedT', e)}>Managed °C est. handleThClick('precip', e)}>Pcpt mm/h handleThClick('utciP', e)}>UTCI+P °C adj. handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air °C handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH % handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew °C handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C surface handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm °C root handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist m³/m³ handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind m/s (gust) handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir - handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud % handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun elev° handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct W/m² handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse W/m² handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ UTCI−Air handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI °C felt handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A est. idx handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B est. idx handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn to MED handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle °C peak handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors °C est. handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed °C est. handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P °C adj.
@@ -1460,16 +1505,18 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a ${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
- + ${COL_DESCRIPTIONS[colPopup.key].title}

${COL_DESCRIPTIONS[colPopup.key].desc}

`}