// ════════════════════════════════════════════════════════════════════════ // 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 } 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 [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); // 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 }, }, urban: { label: 'Urban', icon: '🏙️', 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 }, }, 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 }, }, sailing: { label: 'Sailing', icon: '⛵', 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 }, }, 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: true, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, }, alltemps: { label: 'All Temps', icon: '🌡️', 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: '⚙️', 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({ ...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'); // 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 ───────────────────────────────────────────── // 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. Adjusted for your selected vehicle type — albedo, glazing area, and insulation all affect the result. 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, 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 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()} glob=${currentRow?.glob ?? 0} />
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 || 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` `} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html` ${visibleCols.vehicleT && html` `} `} ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html` ${indoorMode === 'on' && html` `} `} ${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); // 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` `} ${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)} ${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 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 →

`; }