// ════════════════════════════════════════════════════════════════════════ // 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, cloudCategory, confidenceBand, moonGlyph, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } 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. Change the values below to set // a different starting location for new visitors. const [location, setLocation] = useState({ name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB', }); 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 [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(true); // 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 }, }, urban: { label: 'Urban', 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: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false }, }, farming: { label: 'Farming', icon: '🌾', cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, 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 }, }, sailing: { label: 'Sailing', icon: '⛵', cols: { hour: true, air: false, 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 }, }, vehicle: { label: 'Vehicle', icon: '🚗', cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: false, precip: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, 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: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true }, }, custom: { label: 'Custom', icon: '⚙️', 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 }, }, }; // 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({ 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, }); 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'); 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 ───────────────────────────────────────────── // 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 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 peak ambient cabin temperature inside a sealed, parked vehicle. Modelled from solar gain through body panels and side windows. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a typical UK brick house with windows closed and no air conditioning. Accounts for wall conduction, window solar gain, and thermal mass — indoor temperatures typically peak 2–4 hours after the outdoor peak.' }, 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 useEffect(() => { if (!colPopup) return; const onClickOutside = (e) => { if (colPopupRef.current && !colPopupRef.current.contains(e.target)) { setColPopup(null); } }; document.addEventListener('mousedown', onClickOutside); return () => document.removeEventListener('mousedown', onClickOutside); }, [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. 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]); // 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]); // ─── 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); 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); const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr); 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 || []; const now = new Date(); // 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 beta

See the sun the way your body does.
Universal Thermal Climate Index · Bröde 2012 · Open-Meteo · SunScope soak-factor
↳ ${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()} />
setSearchQuery(e.currentTarget.value)} /> ${searchResults.length > 0 && html`
${searchResults.map((r) => html`
{ setLocation({ 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' }); return html`
🔒 ${proPromptSource === 'custom' ? 'Custom columns are part of SunScope Extra' : `${dayName}'s forecast is part of SunScope Extra`}
${proPromptSource === 'custom' ? 'Pro lets you choose exactly which columns appear — mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.' : 'Pro unlocks the full 14-day forecast, customisable columns, and an ad-free view.'}
£2 / month · launching soon
Notify me at launch
`; })()} ${(() => { 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 isCustom = key === 'custom'; const locked = isCustom && !isPro; return html` `; })}
${isPro && html`
Columns: ${[ // Hour and UTCI+P are always-on — no toggle button for them. { key: 'air', label: 'Air' }, { key: 'rh', label: 'RH' }, { key: 'dew', label: 'Dew' }, { key: 'soilT', label: 'Soil °C' }, { key: 'soilT6', label: 'Soil 6cm' }, { key: 'soilM', label: 'Soil moist' }, { key: 'concreteT', label: 'Concrete' }, { key: 'vehicleT', label: 'Vehicle' }, { key: 'indoorT', label: 'Indoors' }, { key: 'managedT', label: 'Managed' }, { key: 'wind', label: 'Wind' }, { key: 'dir', label: 'Dir' }, { key: 'cloud', label: 'Cloud' }, { key: 'sun', label: 'Sun' }, { key: 'direct', label: 'Direct' }, { key: 'diffuse', label: 'Diffuse' }, { key: 'tmrt', label: 'Tmrt' }, { key: 'delta', label: 'Δ' }, { key: 'utci', label: 'UTCI' }, { key: 'uvA', label: 'UV-A' }, { key: 'uvB', label: 'UV-B' }, ].filter(c => activeProfile === 'custom' || !!FILTER_PROFILES[activeProfile].cols[c.key] ).map(c => html` `)} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['burn']) && html` `} ${(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``} ${visibleCols.indoorT && html``} ${visibleCols.managedT && 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); // 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)'; }; // 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`; 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` `} ${visibleCols.indoorT && html` `} ${visibleCols.managedT && html` `} ${visibleCols.precip && html` `} ${visibleCols.utciP && html` `} `; })}
<${SkyScope} elev=${r.elev} dt=${r.dt} 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)} ${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, }}> ${delta > 0 ? '+' : ''}${delta.toFixed(1)} ${r.utci.toFixed(1)} 0 ? '#c8922a' : '#4a3218' }}> ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} 0 ? '#c44a3a' : '#4a3218' }}> ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#4a3218' }}> ${burnLabel(sunburnMinutes(r.uv, skinType))} 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}> ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'} 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.indoorT) }}> ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'} 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.managedT) }}> ${r.managedT != null ? r.managedT.toFixed(1) : '—'} <${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: '#6ab05a', 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 shows how the weather will actually feel on your body — not just the air temperature. It uses the Universal Thermal Climate Index (UTCI), a peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines air temperature, humidity, wind speed, and solar radiation into a single felt temperature. On a calm, sunny winter day UTCI can read several degrees warmer than the thermometer; on a grey, blustery day it can read far colder. Forecast data is sourced in real time from Open-Meteo, a free and open-source weather API, and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts available for free.

The UTCI+P column adds the SunScope soak-factor: an original precipitation penalty that accounts for the extra chill of rain and snow on exposed skin and wet clothing. Light drizzle reduces the felt temperature by around 1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an additional penalty on top. The result is an honest, real-world comfort score for any location worldwide — simply search for your town or city and compare the hourly forecast across the next 3 days (and up to 14 days with SunScope Extra).

`; }