// ════════════════════════════════════════════════════════════════════════ // 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, useRef, useCallback } from '../vendor/preact-hooks.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 { getActiveEvents, getLensEvent, getCellTagEvents, getUpcomingEvents } from './events.js'; import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, POLLEN_TYPES, profileButtonOrder, variantIcons, activityVariantKeys, placeVariantKeys, COL_DESCRIPTIONS, } from './config.js'; import { buildHourlyRows } from './compute.js'; import { useForecast } from './hooks/useForecast.js'; import { useColumnPopup } from './hooks/useColumnPopup.js'; import { useTableScroll } from './hooks/useTableScroll.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. Restored from localStorage if the // user has visited before, otherwise defaults to Pangbourne, Berkshire. const [location, setLocation] = useState(() => { try { const saved = localStorage.getItem('sunscope_last_location'); if (saved) return JSON.parse(saved); } catch (e) { /* ignore */ } return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' }; }); // Wrapper that persists the location before updating state. const setLocationAndSave = (loc) => { try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ } setLocation(loc); }; // Forecast data is owned by useForecast — it fetches on location change, // auto-refreshes every 5 minutes, and exposes the air-quality side data too. const { forecast, airQuality, loading, error, now } = useForecast(location); 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 ───────────────────────────────────────────────────────── // Initialise from URL param (?pro=1, set by Stripe after checkout) or // from localStorage (returning subscriber). Cleans the URL param // immediately so it doesn't stay visible in the address bar. const [isPro, setIsPro] = useState(() => { // Check URL param first (just returned from Stripe checkout) const params = new URLSearchParams(window.location.search); if (params.get('pro') === '1') { localStorage.setItem('sunscope_pro', '1'); // Clean the URL so the param doesn't stay visible window.history.replaceState({}, '', window.location.pathname); return true; } // Check localStorage (returning Pro subscriber) return localStorage.getItem('sunscope_pro') === '1'; }); // (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.) // 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({ ...FILTER_PROFILES.basic.cols }); 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'); // Vehicle type for the cabin heat column. 'car' is the default preset. const [vehicleType, setVehicleType] = useState('car'); // Places profile sub-variant (urban / beach / events / festival / wintersports / naturist) const [outdoorsVariant, setOutdoorsVariant] = useState('urban'); // Vehicle ventilation — true = windows open (high convective loss). const [vehicleVent, setVehicleVent] = useState(false); // Indoor: buildingType drives the physics preset; indoorManaged toggles the // curtains+ventilation model on top. indoorMode gates column visibility: // 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged). const [buildingType, setBuildingType] = useState('brick'); const [indoorManaged, setIndoorManaged] = useState(false); const [indoorMode, setIndoorMode] = useState('off'); // Pollen type for the pollen column. Persisted in localStorage. const [pollenType, setPollenType] = useState(() => { try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; } }); const setPollenTypeAndSave = (v) => { try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ } setPollenType(v); }; // (POLLEN_TYPES now lives in ./config.js.) const searchTimeout = useRef(null); const activateProfile = (key) => { const profile = FILTER_PROFILES[key]; setActiveProfile(key); if (key !== 'custom') { setVisibleCols({ ...profile.cols }); const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT']; setIndoorMode(hasIndoor ? 'on' : 'off'); setIndoorManaged(false); } }; // (profileButtonOrder, variantIcons, activityVariantKeys, // placeVariantKeys now live in ./config.js.) const activityOptions = [ { value: 'farming', label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`, }, ...activityVariantKeys.map((k) => { const v = OUTDOORS_VARIANTS[k]; return { value: k, label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`, }; }), ]; const placeOptions = placeVariantKeys.map((k) => { const v = OUTDOORS_VARIANTS[k]; return { value: k, label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`, }; }); // activeCols: the effective column set for the current profile/variant. // Use this everywhere instead of FILTER_PROFILES[activeProfile].cols so that // outdoors variants each get their own cols rather than the generic outdoors cols. const activeCols = activeProfile === 'outdoors' ? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols) : (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols); const activityValue = activeProfile === 'farming' ? 'farming' : activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant) ? outdoorsVariant : 'off'; const activityLabel = activityOptions.find((option) => option.value === activityValue)?.label; const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant) ? outdoorsVariant : 'off'; const placeLabel = placeOptions.find((option) => option.value === placeValue)?.label; // 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); const tableWrapRef = useRef(null); // ─── COLUMN HEADER & EVENT TAG POPUPS ──────────────────────────────── // All popup state + handlers live in the useColumnPopup hook. The JSX // wires up the th cells and event-tag spans with the handlers returned // here, and reads colPopup / eventTagPopup to know when to render the // floating panel. const { colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, eventTagPopup, eventTagPopupRef, evSlideIndex, evTransition, evSlideTo, handleEventTagClick, handleEventTagEnter, handleEventTagLeave, handleEventTagPopupEnter, handleEventTagPopupLeave, closePopup, closeEventTagPopup, } = useColumnPopup(); // (COL_DESCRIPTIONS, calcPopupPos and all the popup handlers now live in // ./config.js and ./hooks/useColumnPopup.js respectively.) // ─── TABLE SCROLL ──────────────────────────────────────────────────── // useTableScroll owns everything horizontal-scroll-related for the // hourly table: // • scroll indicators (drives fade + chevron CSS) // • drag-to-scroll for desktop users // • column-width sync between sticky header and body // It needs the four table refs and the deps that should trigger a // re-sync (anything that changes the rendered cell content / count). const { tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, } = useTableScroll({ headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, }); // 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, AIR QUALITY FETCH and 5-min AUTO-REFRESH now live in // ./hooks/useForecast.js — called at the top of this component.) // ─── COMPUTATION ───────────────────────────────────────────────────── // Stitch the raw API arrays into one object per hour (UTCI + soak factor, // indoor temps, AQI, pollen, etc) and group into days for the day tabs. // See ./compute.js for the full algorithm. const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, }); const visible = days[selectedDay]?.rows || []; // 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' }; // ─── COSMIC/WEATHER EVENTS ──────────────────────────────────────────── // activeEvents — today's events → drives banner & lens overlay. // selectedDayEvents — viewed day's events → drives cell tags. const [dismissedEventIds, setDismissedEventIds] = useState([]); const [bannerIndex, setBannerIndex] = useState(0); const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null // bannerVisible drives the .visible class — kept true until the collapse // animation finishes so content doesn't vanish before the wrap closes. const [bannerVisible, setBannerVisible] = useState(false); const bannerIndexRef = useRef(0); useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]); const todayRows = days[0]?.rows || []; const activeEvents = getActiveEvents(todayRows, location) .filter(ev => !dismissedEventIds.includes(ev.id)); const lensEvent = getLensEvent(activeEvents); const selectedDayEvents = getActiveEvents(visible, location); // Sync visibility: show as soon as events exist, hide after dismiss animation useEffect(() => { if (activeEvents.length > 0) setBannerVisible(true); }, [activeEvents.length]); // Crossfade to a new banner index: fade out → swap → fade in const bannerSlideTo = useCallback((next) => { setBannerTransition('exiting'); setTimeout(() => { setBannerIndex(next); setBannerTransition('entering'); setTimeout(() => setBannerTransition(null), 420); }, 400); }, []); // Dismiss with animation: fade stage → collapse wrap → remove event const dismissBanner = useCallback((evId) => { // 1. fade the stage out (CSS handles this via :not(.visible)) setBannerVisible(false); // 2. after wrap has collapsed (grid transition ~450ms), remove the event setTimeout(() => { setDismissedEventIds(ids => [...ids, evId]); setBannerIndex(0); }, 500); }, []); // Auto-advance banner slideshow every 10 seconds when multiple events useEffect(() => { if (activeEvents.length <= 1) { setBannerIndex(0); return; } const id = setInterval(() => { const next = (bannerIndexRef.current + 1) % activeEvents.length; bannerSlideTo(next); }, 10000); return () => clearInterval(id); }, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]); // Keep index in bounds if events change useEffect(() => { if (bannerIndex >= activeEvents.length) setBannerIndex(0); }, [activeEvents.length]); // ─── 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 ev = activeEvents[bannerIndex] || 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); // Only show date line if the event has start/end (cosmic events do; weather events don't) 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 }} /> `)}
`}
`; })()}

SUNScope

See the sun the way your body does.
↳ ${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()} 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` <${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' }); const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v)); const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null; const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null; // ── Day-tab weather icon ─────────────────────────────────── // Use daytime rows (elev > 0) where available, else all rows. // Pick the modal cloud category and sum precip/snow to decide // whether to show a PrecipIcon or a CloudIcon. const dayRows = d.rows.filter(r => r.elev > 0); const repRows = dayRows.length > 0 ? dayRows : d.rows; const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); // Modal cloud category (most frequent among daytime hours) const catCounts = {}; repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; }); const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear'; // Representative solar elevation: midday row or median of daytime rows const midRow = repRows[Math.floor(repRows.length / 2)]; const repElev = midRow ? midRow.elev : 45; const repDt = midRow ? midRow.dt : dDate; // Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; 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' }); const extraPromptCopy = { 'variant:sailing': { title: 'Sailing is part of SunScope Extra', detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.', }, 'profile:alltemps': { title: 'Temps is part of SunScope Extra', detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.', }, 'profile:custom': { title: 'Custom columns are part of SunScope Extra', detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.', }, 'variant:festival': { title: 'Festival planning is part of SunScope Extra', detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.', }, 'variant:wintersports': { title: 'Winter Sports is part of SunScope Extra', detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.', }, 'variant:naturist': { title: 'Naturist is part of SunScope Extra', detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.', }, }; const promptCopy = extraPromptCopy[proPromptSource] || { title: `${dayName}'s forecast is part of SunScope Extra`, detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.', }; return html`
🔒 ${promptCopy.title}
${promptCopy.detail}
£2 / month · cancel any time
Subscribe — £2/month Already subscribed? Restore access → Manage or cancel subscription →
`; })()} ${(() => { 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: ${profileButtonOrder.slice(0, 3).map((key) => { const profile = FILTER_PROFILES[key]; const locked = profile.proOnly && !isPro; return html` `; })} <${CustomSelect} key="places" value=${placeValue} isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)} noHide=${true} hideLabel="🌤️ Places" buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`} options=${placeOptions} onChange=${(v) => { if (v === 'off') { activateProfile('basic'); return; } if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) { setProPromptSource(`variant:${v}`); setProPromptDay(0); return; } setActiveProfile('outdoors'); setOutdoorsVariant(v); setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); setIndoorMode('off'); setIndoorManaged(false); }} /> <${CustomSelect} key="activities" value=${activityValue} isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))} noHide=${true} hideLabel="🎯 Activities" buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`} options=${activityOptions} onChange=${(v) => { if (v === 'off') { activateProfile('basic'); return; } if (v === 'farming') { activateProfile('farming'); return; } if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) { setProPromptSource(`variant:${v}`); setProPromptDay(0); return; } setActiveProfile('outdoors'); setOutdoorsVariant(v); setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); setIndoorMode('off'); setIndoorManaged(false); }} /> ${profileButtonOrder.slice(3).map((key) => { const profile = FILTER_PROFILES[key]; const locked = profile.proOnly && !isPro; return html` `; })}
${(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 m/s (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}>Visibility 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 => html` handleEventTagClick(rowEvents, e)} onMouseEnter=${(e) => handleEventTagEnter(rowEvents, 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) : '—'} ${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)} ${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 →

`; }