// ════════════════════════════════════════════════════════════════════════ // 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 } 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(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 }, }, urban: { label: 'Urban', icon: '🏙️', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: true, delta: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: 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 }, }, 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 }, }, 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 }, }, }; // 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, }); 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); // ─── 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)`; }; 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. 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 dt = new Date(iso); const elev = solarElevationDeg(location.lat, location.lon, dt); 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, elev, Tmrt, utci, utciAdj, eh, compass, }; }) : []; // 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, date: new Date(row.iso), rows: [] }; days.push(day); } day.rows.push(row); }); const visible = days[selectedDay]?.rows || []; const now = new Date(); const currentRow = hourlyRows.length > 0 ? (hourlyRows.find(row => now.toDateString() === row.dt.toDateString() && now.getHours() === row.dt.getHours() ) ?? 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`
| Hour | ${visibleCols.air && html`Air °C | `} ${visibleCols.rh && html`RH % | `} ${visibleCols.dew && html`Dew °C | `} ${visibleCols.soilT && html`Soil °C surface | `} ${visibleCols.soilT6 && html`Soil 6cm °C root | `} ${visibleCols.soilM && html`Soil moist m³/m³ | `} ${visibleCols.wind && html`Wind m/s (gust) | `} ${visibleCols.dir && html`Dir - | `} ${visibleCols.cloud && html`Cloud % | `} ${visibleCols.sun && html`Sun elev° | `} ${visibleCols.direct && html`Direct W/m² | `} ${visibleCols.diffuse && html`Diffuse W/m² | `} ${visibleCols.tmrt && html`Tmrt °C | `} ${visibleCols.delta && html`Δ UTCI−Air | `} ${visibleCols.utci && html`UTCI °C felt | `} ${visibleCols.uvA && html`UV-A est. idx | `} ${visibleCols.uvB && html`UV-B est. idx | `} ${visibleCols.burn && html`Burn to MED | `} ${visibleCols.precip && html`Pcpt mm/h | `}UTCI+P °C adj. |
|---|
| <${SkyScope} elev=${r.elev} dt=${r.dt} size=${30} /> ${r.dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} | ${visibleCols.air && html`${r.Ta.toFixed(1)} | `} ${visibleCols.rh && html`${Math.round(r.RH)} | `} ${visibleCols.dew && html`${r.dew != null ? r.dew.toFixed(1) : '—'} | `} ${visibleCols.soilT && html`${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'} | `} ${visibleCols.soilT6 && html`${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'} | `} ${visibleCols.soilM && html`${r.soilM != null ? r.soilM.toFixed(3) : '—'} | `} ${visibleCols.wind && html`${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5 ? html`(${r.gust.toFixed(1)})` : ''} | `} ${visibleCols.dir && html`<${WindVane} bearing=${r.wd} size=${28} /> ${r.compass.label} | `} ${visibleCols.cloud && html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> ${Math.round(r.cc)} | `} ${visibleCols.sun && html`${r.elev > 0 ? r.elev.toFixed(1) : '—'} | `} ${visibleCols.direct && html`${Math.round(r.dir)} | `} ${visibleCols.diffuse && html`${Math.round(r.dif)} | `} ${visibleCols.tmrt && html`${r.Tmrt.toFixed(1)} | `} ${visibleCols.delta && html`3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a', fontWeight: 600, }}> ${delta > 0 ? '+' : ''}${delta.toFixed(1)} | `} ${visibleCols.utci && html`${r.utci.toFixed(1)} | `} ${visibleCols.uvA && html`0 ? '#c8922a' : '#5a4228' }}> ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} | `} ${visibleCols.uvB && html`0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}> ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} | `} ${visibleCols.burn && html`0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}> ${burnLabel(sunburnMinutes(r.uv, skinType))} | `} ${visibleCols.precip && html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} /> 0 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}> ${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'} | `}${r.utciAdj.toFixed(1)} |
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).