// ════════════════════════════════════════════════════════════════════════ // 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, 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 } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.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, activityOptions, placeOptions, activityValue, activityLabel, placeValue, placeLabel, skinType, setSkinType, vehicleType, setVehicleType, vehicleVent, setVehicleVent, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType, indoorManaged, setIndoorManaged, indoorMode, setIndoorMode, pollenType, setPollenTypeAndSave, 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(); // ─── 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 body does.
↳ ${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} />
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…
`}
${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} activityValue=${activityValue} activityLabel=${activityLabel} placeValue=${placeValue} placeLabel=${placeLabel} outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged} dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} scrollDayTabs=${scrollDayTabs} />`} ${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
Columns: ${isPro && (activeProfile === 'custom' || activeCols['air']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['rh']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['dew']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['soilT']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['soilT6']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['soilM']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['concreteT']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['wind']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['dir']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['cloud']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['vis']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['aqi']) && html``} ${(isPro ? (activeProfile === 'custom' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect} value=${pollenType} isOn=${visibleCols.pollen} 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' || activeCols['sun']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['direct']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['diffuse']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['delta']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['utci']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['uvA']) && html``} ${isPro && (activeProfile === 'custom' || activeCols['uvB']) && html``} ${(isPro ? (activeProfile === 'custom' || activeCols['burn']) : activeCols['burn']) && html` <${CustomSelect} value=${skinType} isOn=${visibleCols.burn} 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' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` <${CustomSelect} value=${vehicleType} isOn=${visibleCols.vehicleT} 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)} label="Ventilation" title="Ventilation — open windows significantly reduce cabin heat build-up" />`} `} ${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html` <${CustomSelect} value=${buildingType} isOn=${indoorMode === 'on'} 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)} label="Managed" title="Managed: curtains closed by day, windows open when cooler outside" />`} `} ${isPro && (activeProfile === 'custom' || activeCols['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.vis && html``} ${visibleCols.aqi && html``} ${visibleCols.pollen && 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)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour 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('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 m³/m³ handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface 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('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud % handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis km 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('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² handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ UTCI−Air handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI °C felt 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('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn to MED 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('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>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); // 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})`; }; // 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)'; }; const tempBgStrong = (t) => { if (t == null) return 'transparent'; if (t < 0) return 'rgba(100,160,230,0.20)'; if (t < 10) return 'rgba(140,200,200,0.20)'; if (t < 18) return 'rgba(140,200,150,0.20)'; if (t < 26) return 'rgba(100,190,100,0.20)'; if (t < 32) return 'rgba(230,200, 80,0.20)'; if (t < 40) return 'rgba(230,140, 50,0.20)'; if (t < 50) return 'rgba(210, 70, 50,0.20)'; return 'rgba(160, 30, 30,0.20)'; }; 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 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.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.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.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.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} ${(() => { 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} `); })()} ${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) : '—'} ${Math.round(r.va * 2.237)}${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)})`)(r.gust * 2.237) : ''} <${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)} ${v != null ? (v < 10 ? v.toFixed(1) : Math.round(v)) : '—'} = 60 ? 600 : 400 }}>${label}= 50 ? 600 : 400 }}>${label} 0 ? r.elev : null, 0, 70, '225,160,45') }}>${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, background: deltaBg(delta), }}> ${delta > 0 ? '+' : ''}${delta.toFixed(1)} ${r.utci.toFixed(1)}° 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}> ${r.uvA > 0 ? r.uvA.toFixed(1) : '—'} 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}> ${r.uvB > 0 ? r.uvB.toFixed(2) : '—'} 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}> ${burnLabel(burnMins)} 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBgStrong(r.vehicleT) }}> ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'} 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.indoorT) }}> ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'} 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.managedT) }}> ${r.managedT != null ? r.managedT.toFixed(1) : '—'} 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 ? 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}

`} ${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
${[ { 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: '#4a8a3a', 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} `)}
${(() => { 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 — 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 →

`; }