// ════════════════════════════════════════════════════════════════════════ // 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, 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(); // 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 = { solar: ['sun', 'direct', 'diffuse', 'uvA', 'uvB', 'burn'], sky: ['cloud', 'vis', 'aqi', 'pollen'], wind: ['wind', 'dir'], ambient: ['air', 'rh', 'dew'], felt: ['tmrt', 'utci', 'delta', 'utciP'], surface: ['concreteT', 'vehicleT', 'indoorT', 'managedT', 'soilT', 'soilT6', 'soilM'], precip: ['precip'], }; 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' : ''; }; // ─── 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.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``} ${visibleCols.uvA && html``} ${visibleCols.uvB && html``} ${visibleCols.burn && html``} ${visibleCols.cloud && html``} ${visibleCols.vis && html``} ${visibleCols.aqi && html``} ${visibleCols.pollen && html``} ${visibleCols.wind && html``} ${visibleCols.dir && html``} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.tmrt && html``} ${visibleCols.utci && html``} ${visibleCols.delta && html``} ${visibleCols.utciP && html``} ${visibleCols.concreteT && html``} ${visibleCols.vehicleT && html``} ${indoorMode === 'on' && !indoorManaged && html``} ${indoorMode === 'on' && indoorManaged && html``} ${visibleCols.soilT && html``} ${visibleCols.soilT6 && html``} ${visibleCols.soilM && html``} ${visibleCols.precip && html``}
handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour 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('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('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('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind mph (gust) handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir - 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('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt °C 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('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P °C adj. handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete °C surface 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('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('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt mm/h
${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 background tint based on temperature value - all share the same // Thermal Stress Bands colors with 50% alpha for consistency. Uses exact legend ranges. // Converts a hex colour - r,g,b components for rgba(). const hexToRgb = (hex) => { const n = parseInt(hex.replace('#',''), 16); return [(n>>16)&255, (n>>8)&255, n&255]; }; // Air + UTCI columns - 35% alpha light tint. const tempBg = (t) => { if (t == null) return 'transparent'; const band = utciCategory(t); if (!band) return 'transparent'; const [r,g,b] = hexToRgb(band.bg); return `rgba(${r},${g},${b},0.35)`; }; // All other coloured columns - 50% alpha. const tempBgStrong = (t) => { if (t == null) return 'transparent'; const band = utciCategory(t); if (!band) return 'transparent'; const [r,g,b] = hexToRgb(band.bg); return `rgba(${r},${g},${b},0.5)`; }; // Returns perceived luminance 0..1 from a hex colour. const luminance = (hex) => { const [r,g,b] = hexToRgb(hex); return (0.299*r + 0.587*g + 0.114*b) / 255; }; // Light tint (35% alpha) - always dark text, background is never dark enough. const tempFontColor = (t) => '#4a3218'; // Medium tint (50% alpha) - use light text on dark bands, dark text on light bands. const tempFontColorStrong = (t) => { if (t == null) return '#4a3218'; const band = utciCategory(t); if (!band) return '#4a3218'; return luminance(band.bg) < 0.35 ? '#ffffff' : '#4a3218'; }; 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.sun && html``} ${visibleCols.direct && html``} ${visibleCols.diffuse && html``} ${visibleCols.uvA && html` `} ${visibleCols.uvB && html` `} ${visibleCols.burn && 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.wind && html``} ${visibleCols.dir && html``} ${visibleCols.air && html``} ${visibleCols.rh && html``} ${visibleCols.dew && html``} ${visibleCols.tmrt && html` `} ${visibleCols.utci && html` `} ${visibleCols.delta && html` `} ${visibleCols.utciP && html` `} ${visibleCols.concreteT && html` `} ${visibleCols.vehicleT && html` `} ${indoorMode === 'on' && !indoorManaged && html` `} ${indoorMode === 'on' && indoorManaged && html` `} ${visibleCols.soilT && html` `} ${visibleCols.soilM && html` `} ${visibleCols.precip && 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} `); })()} 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}${Math.round(r.dir)}${Math.round(r.dif)} 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)} <${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} ${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} ${r.Ta.toFixed(1)}${Math.round(r.RH)}${r.dew != null ? r.dew.toFixed(1) : '—'}${r.Tmrt.toFixed(1)} ${r.utci.toFixed(1)}° 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218', fontWeight: 600, background: deltaBg(delta), }}> ${delta > 0 ? '+' : ''}${delta.toFixed(1)} ${r.utciAdj.toFixed(1)}° ${r.concreteT != null ? r.concreteT.toFixed(1) : '—'} ${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'} ${r.indoorT != null ? r.indoorT.toFixed(1) : '—'} ${r.managedT != null ? r.managedT.toFixed(1) : '—'} ${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}${r.soilM != null ? r.soilM.toFixed(3) : '—'} 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) : '—'}
${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)} /> `)}
`}
`; })()}
${UTCI_BANDS.map((b, i) => 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 — 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 →

`; }