// ════════════════════════════════════════════════════════════════════════ // 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. // 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; // 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 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, 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: '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.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.precip && html``}
HourAir °CRH %Dew °CSoil °C surfaceSoil 6cm °C rootSoil moist m³/m³Wind m/s (gust)Dir -Cloud %Sun elev°Direct W/m²Diffuse W/m²Tmrt °CΔ UTCI−AirUTCI °C feltUV-A est. idxUV-B est. idxBurn to MEDPcpt mm/hUTCI+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); // r.iso is the local wall-clock string from the API — slice it directly. const localHHMM = r.iso.slice(11, 16); return html` ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.soilT && html` `} ${visibleCols.soilT6 && html` `} ${visibleCols.soilM && 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.precip && 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) : '—'} ${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' : '#9a7d5a', fontWeight: 600, }}> ${delta > 0 ? '+' : ''}${delta.toFixed(1)} ${r.utci.toFixed(1)} 0 ? '#c8922a' : '#5a4228' }}> ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}> ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}> ${burnLabel(sunburnMinutes(r.uv, skinType))} <${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)}
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).

`; }