// ════════════════════════════════════════════════════════════════════════ // 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 htm from '../vendor/htm.js'; import { utciCategory, UTCI_BANDS, bandGradient, SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, BUILDING_TYPES, confidenceBand, moonGlyph, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; import { getCellTagEvents, getUpcomingEvents } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; const html = htm.bind(h); export function UTCIForecast() { // ── STATE + EFFECTS ─────────────────────────────────────────────────── // All useState, useEffect, useCallback and useRef logic lives in // useAppState. See hooks/useAppState.js for the full reading order. const { location, setLocationAndSave, forecast, airQuality, loading, error, now, fetchedAt, searchQuery, setSearchQuery, searchResults, searching, selectedDay, setSelectedDay, proPromptDay, setProPromptDay, proPromptSource, setProPromptSource, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, isPro, setIsPro, activeProfile, setActiveProfile, activateProfile, activeCols, visibleCols, setVisibleCols, toggleCol, showDecimals, toggleShowDecimals, showUnits, toggleShowUnits, activityOptions, placeOptions, workOptions, activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel, skinType, setSkinType, vehicleType, setVehicleType, vehicleVent, setVehicleVent, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType, indoorManaged, setIndoorManaged, indoorMode, setIndoorMode, pollenType, setPollenTypeAndSave, utciEnv, setUtciEnv, headStickyRef, headTrackRef, headTableRef, bodyScrollRef, bodyTableRef, tableWrapRef, colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, eventTagPopup, eventTagPopupRef, evSlideIndex, evTransition, evSlideTo, handleEventTagClick, handleEventTagEnter, handleEventTagLeave, handleEventTagPopupEnter, handleEventTagPopupLeave, closePopup, closeEventTagPopup, tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, hourlyRows, days, utcOffsetMs, visible, nowLocalISO, currentRow, currentCat, activeEvents, lensEvent, selectedDayEvents, bannerIndex, bannerTransition, bannerPrevIndex, bannerVisible, bannerStageRef, bannerSlideTo, dismissBanner, } = useAppState(); // Columns that mark the start of a logical group - used to draw a faint // vertical border separating groups in the forecast table. const GROUP_ORDER = { felt: ['utciP', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'], surface: ['concreteT', 'soilT', 'soilT6', 'soilM'], ambient: ['air', 'rh', 'dew'], precip: ['precip', 'precipProb'], sky: ['cloud', 'vis'], wind: ['wind', 'dir'], airqual: ['aqi', 'pollen'], solar: ['uvA', 'uvB', 'sun', 'direct', 'diffuse'], }; const GROUP_OF = Object.fromEntries( Object.entries(GROUP_ORDER).flatMap(([g, keys]) => keys.map(k => [k, g])) ); // Returns col-group-start when this column is the leftmost VISIBLE member // of its group. If the canonical first member is hidden the border migrates // to the next visible column in the same group. const isColVisible = (k) => { if (k === 'indoorT') return indoorMode === 'on' && !indoorManaged; if (k === 'managedT') return indoorMode === 'on' && indoorManaged; return !!visibleCols[k]; }; const groupStart = (key) => { const group = GROUP_OF[key]; if (!group) return ''; const first = GROUP_ORDER[group].find(isColVisible); return first === key ? 'col-group-start' : ''; }; // Returns a CSS class encoding the group name - used to tint header cells // and group label spans. const groupColor = (key) => { const g = GROUP_OF[key]; return g ? `grp-${g}` : ''; }; // Builds the group label row above the column headers. // Each visible group gets one spanning cell; groups with no visible // columns are skipped entirely. Hour always gets a blank lead cell. const groupLabelRow = () => { const groups = Object.keys(GROUP_ORDER); const cells = [html`${glanceDate}`]; for (const g of groups) { const span = GROUP_ORDER[g].filter(isColVisible).length; if (span === 0) continue; const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' }; cells.push(html`${labels[g]}`); } return html`${cells}`; }; // Continuous temperature colour scale - hoisted so both the table columns // and the thermal stress legend can share the same function. const TEMP_STOPS = [ [-10, [ 90, 155, 220]], [ 0, [140, 195, 235]], [ 10, [155, 215, 195]], [ 16, [140, 210, 140]], [ 20, [195, 225, 110]], [ 24, [240, 225, 80]], [ 28, [250, 175, 65]], [ 32, [240, 120, 55]], [ 36, [220, 70, 50]], [ 40, [185, 30, 30]], [ 50, [130, 0, 20]], ]; const airTempRgb = (t) => { if (t == null) return null; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); let i = TEMP_STOPS.findIndex(s => t < s[0]); if (i === -1) i = TEMP_STOPS.length; const raw = i === 0 ? TEMP_STOPS[0][1] : i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1] : (() => { const [t0, c0] = TEMP_STOPS[i-1]; const [t1, c1] = TEMP_STOPS[i]; const x = clamp((t - t0) / (t1 - t0), 0, 1); const lerp = (a, b) => Math.round(a + (b - a) * x); return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])]; })(); return raw.map(c => Math.min(255, Math.round(c + (255 - c) * 0.66))); }; // ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── // Derived from currentRow and the active day - no side effects. const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]); const glanceSummary = computeGlanceSummary( days[selectedDay]?.rows ?? [], activeProfile, outdoorsVariant, skinType, ); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". const glanceDate = (() => { const key = days[selectedDay]?.key; if (!key) return ''; const d = new Date(key + 'T00:00Z'); const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const day = d.getUTCDate(); const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' }); const yr = d.getUTCFullYear(); const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day); return `${wd} ${day}${ord} ${mon} ${yr}`; })(); // Day's events surfaced in the "Day at a glance" box. Reuses the same // row shape as glanceSummary items: { icon, label, value, alert }. const hhmm = (iso) => (iso ? iso.slice(11, 16) : ''); const eventGlanceItems = (selectedDayEvents ?? []) .filter(ev => ev.type !== 'promo') .map(ev => ({ icon: ev.emoji, label: ev.title, value: ev.isoRange ? (ev.isoRange[0] === ev.isoRange[1] ? hhmm(ev.isoRange[0]) : `${hhmm(ev.isoRange[0])} – ${hhmm(ev.isoRange[1])}`) : (ev.nightOnly ? 'Overnight' : 'All day'), alert: false, })); // ─── 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`
${activeEvents.length > 0 && (() => { const renderSlide = (idx, isOutgoing) => { const ev = activeEvents[idx] || activeEvents[0]; const fmtDate = (iso) => iso ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : null; const startFmt = fmtDate(ev.start); const peakFmt = fmtDate(ev.peak); const endFmt = fmtDate(ev.end); const showDates = startFmt && endFmt; const dateLine = showDates ? (startFmt === endFmt ? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}` : peakFmt && peakFmt !== startFmt && peakFmt !== endFmt ? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}` : `Active ${startFmt} – ${endFmt}`) : null; return html`
${ev.emoji}
${ev.title}
${ev.message}
${dateLine && html`
${dateLine}
`} ${activeEvents.length > 1 && html`
${activeEvents.map((_, i) => html` bannerSlideTo(i)} style=${{ background: ev.textColor }} /> `)}
`}
${!isOutgoing && html``}
`; }; return html`
${bannerTransition === 'crossfading' && bannerPrevIndex !== null ? renderSlide(bannerPrevIndex, true) : null} ${renderSlide(bannerIndex, false)}
`; })()}

SUNScope

See the sun the way your skin does.
setSearchQuery(e.currentTarget.value)} /> ${searchResults.length > 0 && html`
${searchResults.map((r) => html`
{ setLocationAndSave({ name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, lat: r.latitude, lon: r.longitude, country: r.country_code, }); setSearchQuery(''); setSearchResults([]); setSelectedDay(0); }} >
${r.name}${r.admin1 ? `, ${r.admin1}` : ''}
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
`)}
`} ${searching && html`
Searching…
`}
↳ ${location.name} ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
${fetchedAt && !loading && html`
Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`}
<${ScopeReticle} value=${currentRow?.utciAdj ?? null} cat=${currentCat} loading=${loading} elev=${currentRow?.elev ?? 0} dt=${currentRow?.dt ?? new Date()} glob=${currentRow?.glob ?? 0} activeEvent=${lensEvent} utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} />
Environment <${CustomSelect} value=${utciEnv} isOn=${true} noHide=${true} grpClass="grp-felt" options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label, }))} onChange=${(v) => setUtciEnv(v)} />
${forecast && currentRow && whyFeelsLike && html`
Why it feels like this
${[ { label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky }, { label: 'Wind', icon: '🌬', value: whyFeelsLike.wind }, { label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity }, ...(whyFeelsLike.environment !== 0 ? [ { label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment } ] : []), ...(whyFeelsLike.precipitation !== 0 ? [ { label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation } ] : []), ].map(({ label, icon, value }) => html`
${icon} ${label} = 0 ? 'delta-pos' : 'delta-neg'}`}> ${value >= 0 ? '+' : ''}${value.toFixed(1)}°
`)}
`}
${error && html`
⚠ ${error}
`} ${loading && !error && html`
Acquiring forecast data…
`} ${forecast && days.length > 0 && html`<${DayTabs} days=${days} selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} isPro=${isPro} proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} visibleCols=${visibleCols} activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions} activityValue=${activityValue} activityLabel=${activityLabel} placeValue=${placeValue} placeLabel=${placeLabel} workValue=${workValue} workLabel=${workLabel} outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged} setBuildingType=${setBuildingType} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} scrollDayTabs=${scrollDayTabs} />`}
Columns: ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html` <${CustomSelect} value=${utciEnv} isOn=${visibleCols.utciP} grpClass="grp-felt" hideLabel="SunSoak" hidingLabel="Hide SunSoak" groupedLeft=${true} isLastChild=${true} options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label, }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, utciP: false })); } else { setUtciEnv(v); setVisibleCols(prev => ({ ...prev, utciP: true })); } }} /> `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` <${CustomSelect} value=${vehicleType} isOn=${visibleCols.vehicleT} grpClass="grp-felt" hideLabel="Vehicle" hidingLabel="Hide Vehicle" groupedLeft=${true} isLastChild=${!visibleCols.vehicleT} options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name, }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, vehicleT: false })); setVehicleVent(false); } else { setVehicleType(v); setVisibleCols(prev => ({ ...prev, vehicleT: true })); } }} /> ${visibleCols.vehicleT && html` <${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)} grpClass="grp-felt" label="Ventilation" title="Ventilation — open windows significantly reduce cabin heat build-up" />`} `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html` <${CustomSelect} value=${buildingType} isOn=${indoorMode === 'on'} grpClass="grp-felt" hideLabel="Indoors" hidingLabel="Hide Indoors" groupedLeft=${true} isLastChild=${indoorMode !== 'on'} options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name, }))} onChange=${(v) => { if (v === 'off') { setIndoorMode('off'); setIndoorManaged(false); } else { setBuildingType(v); setIndoorMode('on'); } }} /> ${indoorMode === 'on' && html` <${VentPill} checked=${indoorManaged} onChange=${() => setIndoorManaged(v => !v)} grpClass="grp-felt" label="Managed" title="Managed: curtains closed by day, windows open when cooler outside" />`} `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html` <${CustomSelect} value=${skinType} isOn=${visibleCols.burn} grpClass="grp-felt" hideLabel="Burn" hidingLabel="Hide Burn" options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin', }))} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, burn: false })); } else { setSkinType(v); setVisibleCols(prev => ({ ...prev, burn: true })); } }} />`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['concreteT']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT6']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilM']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['air']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['rh']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dew']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precip']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precipProb']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['cloud']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vis']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html``} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect} value=${pollenType} isOn=${visibleCols.pollen} grpClass="grp-airqual" hideLabel="Pollen" hidingLabel="Hide Pollen" options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [ { value: k, label: v.name }, ...(i === 0 ? [{ value: '_div', divider: true }] : []), ])} onChange=${(v) => { if (v === 'off') { setVisibleCols(prev => ({ ...prev, pollen: false })); } else { setPollenTypeAndSave(v); setVisibleCols(prev => ({ ...prev, pollen: true })); } }} />`} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['direct']) && html``} ${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['diffuse']) && html``}
${groupLabelRow()} ${visibleCols.utciP && html``} ${visibleCols.vehicleT && html``} ${indoorMode === 'on' && !indoorManaged && html``} ${indoorMode === 'on' && indoorManaged && html``} ${visibleCols.burn && html``} ${visibleCols.utci && html``} ${visibleCols.delta && html``} ${visibleCols.tmrt && html``} ${visibleCols.concreteT && html``} ${visibleCols.soilT && html``} ${visibleCols.soilT6 && html``} ${visibleCols.soilM && html``} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.precip && html``} ${visibleCols.precipProb && html``} ${visibleCols.cloud && html``} ${visibleCols.vis && html``} ${visibleCols.wind && html``} ${visibleCols.dir && html``} ${visibleCols.aqi && html``} ${visibleCols.pollen && html``} ${visibleCols.uvA && html``} ${visibleCols.uvB && html``} ${visibleCols.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak °C felt${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`${UTCI_ENVIRONMENTS[utciEnv].shortLabel}` : null} handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle °C peak handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors °C est. handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed °C est. handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn to MED handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI °C felt handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ UTCI−Air handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C surface handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm °C root handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist % handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air °C handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH % handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew °C handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain % handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud % handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis km handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind mph (gust) handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir - handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI EU idx handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} grains/m³ handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A est. idx handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B est. idx handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun elev° handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct W/m² handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse W/m²
${visible.map((r, rowIdx) => { const rPrev = visible[rowIdx - 1]; const rNext = visible[rowIdx + 1]; const cat = utciCategory(r.utci); const isNight = r.elev < 0; const isNow = r.iso.slice(0, 13) === nowLocalISO; const delta = r.utci - r.Ta; const adjCat = utciCategory(r.utciAdj); // Converts a band hex colour to rgba at the given alpha — used to // tint the UTCI and UTCI+P cells directly from the band colour. const hexToRgba = (hex, a) => { const h = hex.replace('#', ''); const r = parseInt(h.slice(0,2),16); const g = parseInt(h.slice(2,4),16); const b = parseInt(h.slice(4,6),16); return `rgba(${r},${g},${b},${a})`; }; const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v))); const u = (unit) => showUnits ? unit : ''; // Returns a background tint based on temperature value - all share the same // Continuous temperature colour scale — used for all temp columns. // airTempRgb(t) returns the whiteblended [r,g,b] for a temperature. // airTempBg(t, tPrev, tNext) returns a top→bottom gradient that // flows from the previous row's colour through this row's colour to // the next row's colour, making the whole column one seamless heatmap. // airTempRgb and TEMP_STOPS hoisted to component scope above. const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent'; const airTempBg = (t, tPrev, tNext) => { if (t == null) return 'transparent'; // Top colour = midpoint temp between prev row and this row. // Bottom colour = midpoint temp between this row and next row. // This guarantees the boundary colour is identical on both sides // of every cell edge, giving a perfectly seamless column gradient. const tTop = tPrev != null ? (tPrev + t) / 2 : t; const tBot = tNext != null ? (t + tNext) / 2 : t; return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`; }; const airTempFontColor = () => '#1a1a1a'; // Returns any extra band styles (fontWeight, textShadow) for a given temp. const bandExtras = (t) => { const band = utciCategory(t); if (!band) return {}; return { ...(band.fontWeight ? { fontWeight: band.fontWeight } : {}), ...(band.textShadow ? { textShadow: band.textShadow } : {}), }; }; const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => { if (v == null || isNaN(v)) return 'transparent'; if (v <= min) return 'transparent'; const x = Math.max(0, Math.min(1, (v - min) / (max - min))); const a = 0.035 + x * maxAlpha; return `rgba(${rgb},${a.toFixed(3)})`; }; const soilMoistureBg = (v) => { if (v == null || isNaN(v)) return { bg: 'transparent', fg: '#555' }; const pct = v * 100; // stops: [pct, r, g, b] const stops = [ [0, 212, 180, 131], // sandy tan – bone dry [10, 160, 120, 64], // mid brown – dry [22, 138, 122, 80], // olive-brown – damp/firm [32, 200, 160, 64], // amber – soft, caution [38, 58, 138, 92], // green – wet [50, 42, 106, 144], // blue – saturated ]; let s0 = stops[0], s1 = stops[stops.length - 1]; for (let i = 0; i < stops.length - 1; i++) { if (pct >= stops[i][0] && pct <= stops[i+1][0]) { s0 = stops[i]; s1 = stops[i+1]; break; } } const t = s1[0] === s0[0] ? 1 : Math.max(0, Math.min(1, (pct - s0[0]) / (s1[0] - s0[0]))); const r = Math.round(s0[1] + t * (s1[1] - s0[1])); const g = Math.round(s0[2] + t * (s1[2] - s0[2])); const b = Math.round(s0[3] + t * (s1[3] - s0[3])); const lum = 0.299*r + 0.587*g + 0.114*b; return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' }; }; const deltaBg = (v) => { if (v == null || isNaN(v)) return 'transparent'; if (Math.abs(v) < 1) return 'transparent'; const x = Math.min(1, Math.abs(v) / 12); const a = 0.035 + x * 0.13; return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`; }; const burnBg = (mins, uv) => { if (!isFinite(mins) || uv <= 0) return 'transparent'; if (mins >= 240) return 'transparent'; const x = Math.max(0, Math.min(1, (240 - mins) / 220)); return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`; }; // r.iso is the local wall-clock string from the API — slice it directly. const h24 = parseInt(r.iso.slice(11, 13), 10); const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`; const burnMins = sunburnMinutes(r.uv, skinType); return html` ${visibleCols.utciP && html` `} ${visibleCols.vehicleT && html` `} ${indoorMode === 'on' && !indoorManaged && html` `} ${indoorMode === 'on' && indoorManaged && html` `} ${visibleCols.burn && html` `} ${visibleCols.utci && html` `} ${visibleCols.delta && html` `} ${visibleCols.tmrt && html``} ${visibleCols.concreteT && html` `} ${visibleCols.soilT && html` `} ${visibleCols.soilT6 && html` `} ${visibleCols.soilM && html` `} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.precip && html` `} ${visibleCols.precipProb && html` `} ${visibleCols.cloud && html``} ${visibleCols.vis && (() => { const v = r.visKm; const bg = v == null ? 'transparent' : v < 1 ? 'rgba(180,80,80,0.18)' : v < 4 ? 'rgba(210,140,50,0.15)' : v < 10 ? 'rgba(200,190,80,0.12)' : 'transparent'; const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218'; return html``; })()} ${visibleCols.wind && html``} ${visibleCols.dir && html``} ${visibleCols.aqi && (() => { const v = r.aqi; const bg = v == null ? 'transparent' : v < 20 ? 'rgba(80,180,100,0.15)' : v < 40 ? 'rgba(140,200,100,0.13)' : v < 60 ? 'rgba(220,200,60,0.15)' : v < 80 ? 'rgba(220,130,50,0.18)' : v < 100 ? 'rgba(200,70,50,0.18)' : 'rgba(160,30,100,0.20)'; const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20'; const label = v == null ? '—' : v < 20 ? `${v} Good` : v < 40 ? `${v} Fair` : v < 60 ? `${v} Mod` : v < 80 ? `${v} Poor` : v < 100 ? `${v} V.Poor` : `${v} Hazard`; return html``; })()} ${visibleCols.pollen && (() => { const pollenMap = { all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null), grass_pollen: r.grassPollen, birch_pollen: r.birchPollen, alder_pollen: r.alderPollen, mugwort_pollen: r.mugwortPollen, olive_pollen: r.olivePollen, ragweed_pollen: r.ragweedPollen, }; const v = pollenMap[pollenType] ?? null; const bg = v == null ? 'transparent' : v < 10 ? 'transparent' : v < 50 ? 'rgba(180,200,80,0.13)' : v < 200 ? 'rgba(210,150,50,0.16)' : 'rgba(200,70,50,0.18)'; const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218'; const label = v == null ? '—' : v < 10 ? `${Math.round(v)} Low` : v < 50 ? `${Math.round(v)} Mod` : v < 200 ? `${Math.round(v)} High` : `${Math.round(v)} V.High`; return html``; })()} ${visibleCols.uvA && html` `} ${visibleCols.uvB && html` `} ${visibleCols.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``} `; })}
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${38} /> ${localHHMM} ${(() => { const rowEvents = getCellTagEvents(selectedDayEvents, r); return rowEvents.map((ev, idx) => html` handleEventTagClick(rowEvents, idx, e)} onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)} onMouseLeave=${handleEventTagLeave} role="button" tabIndex="0" aria-label=${ev.title} >${ev.emoji} `); })()} ${fmt(r.utciAdj)}${u('°C')} ${fmt(r.vehicleT)}${u('°C')} ${fmt(r.indoorT)}${u('°C')} ${fmt(r.managedT)}${u('°C')} 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}> ${burnLabel(burnMins)} ${fmt(r.utci)}${u('°C')} 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218', background: deltaBg(delta), }}> ${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')} ${fmt(r.Tmrt)}${u('°C')} ${fmt(r.concreteT)}${u('°C')} ${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'} { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}${fmt(r.Ta)}${u('°C')}${Math.round(r.RH)}${u('%')}${fmt(r.dew)}${u('°C')} 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}> <${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} /> 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}> ${r.snow > 0 ? fmt(r.snow) + u('cm') : r.precip > 0 ? fmt(r.precip) + u('mm') : '—'} = 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}> ${r.precipProb}${u('%')} <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> ${Math.round(r.cc)}${u('%')} ${v != null ? fmt(v) + u('km') : '—'} ${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5 ? (gm => html`= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})`)(r.gust * 2.237) : ''} <${WindVane} bearing=${r.wd} size=${28} /> ${r.compass.label} = 60 ? 600 : 400 }}>${label}= 50 ? 600 : 400 }}>${label} 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}> ${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'} 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}> ${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'} 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}${Math.round(r.dir)}${u('W/m²')}${Math.round(r.dif)}${u('W/m²')}
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html` `}
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
${COL_DESCRIPTIONS[colPopup.key].title}

${COL_DESCRIPTIONS[colPopup.key].desc}

`} ${eventTagPopup && (() => { const evs = eventTagPopup.events; const ev = evs[evSlideIndex] || evs[0]; return html`
${ev.emoji} ${ev.title}

${ev.message}

${evs.length > 1 && html`
${evs.map((_, i) => html` evSlideTo(i)} /> `)}
`}
`; })()}
Thermal stress bands
${[ { t: -9, label: 'Freezing' }, { t: 1, label: 'Cold' }, { t: 11, label: 'Cool' }, { t: 21, label: 'Comfortable', bold: true }, { t: 31, label: 'Hot' }, { t: 41, label: 'Very hot' }, { t: 51, label: 'Danger' }, ].map((b, i) => { const rgb = airTempRgb(b.t) || [200, 200, 200]; const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`; const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`; const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`; const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000; const fg = lum > 165 ? '#1a1a1a' : '#ffffff'; return html`${b.label}`; })}
${(() => { const upcoming = getUpcomingEvents(location, 90); const formatPeak = (iso) => { const d = new Date(iso + 'T00:00Z'); return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); }; const countdownLabel = (days) => { if (days === 0) return 'Tonight!'; if (days === 1) return 'Tomorrow'; if (days < 7) return `In ${days} days`; if (days < 14) return 'Next week'; if (days < 60) return `In ${Math.round(days / 7)} weeks`; return `In ${Math.round(days / 30)} months`; }; return html`
🔭 What's Coming Cosmic events · next 90 days · ${location.name}
${upcoming.length === 0 ? html`
No major cosmic events in the next 90 days — clear skies ahead.
` : upcoming.map(ev => html`
${ev.emoji}
${ev.title} ${countdownLabel(ev.daysUntil)}
${ev.desc}
${ev.visibilityNote && html` 📍 ${ev.visibilityNote} `}
`) }
`; })()}

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 — as its foundation, combining air temperature, humidity, wind, and solar radiation into a single honest number. Then it goes further. Our SunSoak index layers three extra dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel; an environment modifier that adjusts the solar load for where you actually are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat; and the full radiant heat absorbed from surrounding surfaces. One number that honestly answers: what will my body actually feel out there?

Beyond SunSoak, SunScope calculates vehicle cabin heat (choose your vehicle type; toggle windows open), indoor temperature (seven building types including office blocks; 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 — Places, Activities, Work — to see the data that matters for your situation, or go Custom and build your own view. Learn more →

`; }