diff --git a/assets/js/.fuse_hidden0000000800000001 b/assets/js/.fuse_hidden0000000800000001 deleted file mode 100644 index d38fa2e..0000000 --- a/assets/js/.fuse_hidden0000000800000001 +++ /dev/null @@ -1,1615 +0,0 @@ -// ════════════════════════════════════════════════════════════════════════ -// app.js — Main UTCIForecast component. -// -// This is the top-level Preact component that owns all state, fetches -// the forecast, runs the per-hour computations, and renders the page. -// -// Reading order inside UTCIForecast(): -// 1. STATE (useState calls) — bits that change on interaction -// 2. EFFECTS (useEffect calls) — runs on search / location change -// 3. COMPUTATION (hourlyRows, days, …) — API data → display rows -// 4. JSX RETURN (the big html`...`) — actual page markup -// -// QUICK MAP -// ────────────────────────────────────────────────────────────────────── -// Forecast length .............. fetch URL contains &forecast_days=14 -// Free tier day limit .......... const FREE_DAYS = 3 -// Preview the Pro view ......... useState(false) on isPro → flip to true -// Starting location ............ useState({...}) on `location` near top -// Default columns shown ........ useState({...}) on visibleCols -// Page tagline / about copy .... search "utci-tagline" or "utci-about-text" -// ════════════════════════════════════════════════════════════════════════ - -import { h, render, Fragment } from '../vendor/preact.js'; -import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js'; -import htm from '../vendor/htm.js'; -import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass } from './physics.js'; -import { - utciCategory, precipPenalty, windCompass8, uvSplit, - SKIN_TYPES, sunburnMinutes, burnLabel, - VEHICLE_TYPES, BUILDING_TYPES, - cloudCategory, confidenceBand, moonGlyph, -} from './utils.js'; -import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; - -const html = htm.bind(h); - -export function UTCIForecast() { - - // ── 1. STATE ────────────────────────────────────────────────────────── - // Each useState() pairs a value with a setter. Calling the setter - // re-renders the page with the new value. - - // The location we're forecasting for. 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); - }; - - const [forecast, setForecast] = useState(null); // raw Open-Meteo response - const [loading, setLoading] = useState(false); // true while fetching - const [error, setError] = useState(null); // fetch error message - const [searchQuery, setSearchQuery] = useState(''); // text in the search box - const [searchResults, setSearchResults] = useState([]); // geocoding dropdown - const [searching, setSearching] = useState(false); // search-in-flight flag - const [selectedDay, setSelectedDay] = useState(0); // which day tab is active - const [now, setNow] = useState(new Date()); // ticks every 5 min - const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card - const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom' - - // Day-tabs horizontal scrolling — chevrons show only when there's more - // content to reveal in that direction. Auto-scrolls active tab into view. - const dayTabsRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - // Re-runs whenever the number of day tabs changes (e.g. when the - // forecast finishes loading and the tabs first appear). Also re-measures - // on scroll, on window resize, and via ResizeObserver if the element's - // own width changes (e.g. layout shifts when sidebar opens). - useEffect(() => { - const el = dayTabsRef.current; - if (!el) return; - const update = () => { - setCanScrollLeft(el.scrollLeft > 1); - setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); - }; - update(); - el.addEventListener('scroll', update, { passive: true }); - window.addEventListener('resize', update); - let ro = null; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(update); - ro.observe(el); - } - return () => { - el.removeEventListener('scroll', update); - window.removeEventListener('resize', update); - if (ro) ro.disconnect(); - }; - }, [forecast]); - useEffect(() => { - const el = dayTabsRef.current; - if (!el) return; - const activeTab = el.querySelector('.utci-day-tab.active'); - if (!activeTab) return; - const elRect = el.getBoundingClientRect(); - const tabRect = activeTab.getBoundingClientRect(); - if (tabRect.left < elRect.left + 8) { - el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' }); - } else if (tabRect.right > elRect.right - 8) { - el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' }); - } - }, [selectedDay]); - const scrollDayTabs = (dir) => { - const el = dayTabsRef.current; - if (!el) return; - el.scrollBy({ left: dir * 200, behavior: 'smooth' }); - }; - - // ─── PRO TIER STUB ──────────────────────────────────────────────────── - // FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE. - // When this is wired to real billing/auth, replace `useState(false)` - // with a check against the logged-in user. - //const [isPro, setIsPro] = useState(false); - 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'; - }); - - // How many days the free tier shows. Days beyond this get a 🔒. - // Bump this number if you want to give free users more access. - const FREE_DAYS = 3; - - // Filter profile presets — each preset defines which columns are visible - // when that profile is selected. - const FILTER_PROFILES = { - basic: { - label: 'Basic', - icon: '🌡️', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, - }, - home: { - label: 'Home', - icon: '🏠', - cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true }, - }, - vehicle: { - label: 'Vehicle', - icon: '🚗', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false }, - }, - farming: { - label: 'Farming', - icon: '🌾', - cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, - }, - outdoors: { - label: 'Places', - icon: '🌤️', - // Default cols match the first variant (urban). Switching variant updates visibleCols. - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false }, - }, - alltemps: { - label: 'Temps', - icon: '🌡️', - proOnly: true, - cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true }, - }, - custom: { - label: 'Custom', - icon: '⚙️', - proOnly: true, - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false }, - }, - }; - - // Places sub-variants — each has its own column set. - // Selecting a variant applies its cols to visibleCols. - const OUTDOORS_VARIANTS = { - urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } }, - beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, - events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false } }, - festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, - wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, - naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, - sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false } }, - }; - - // Current active filter profile - const [activeProfile, setActiveProfile] = useState('basic'); - - // Which columns appear in the hourly table by default. - // true = visible on first load (and the only ones free users see) - // false = hidden by default (Pro users can toggle these on) - const [visibleCols, setVisibleCols] = useState({ ...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'); - const searchTimeout = useRef(null); - - // Refs for the two-scroller table layout (sticky-to-viewport header + - // horizontally-scrolling body). The header is clipped (overflow:hidden) - // and its inner "track" gets translateX'd via JS to follow the body's - // scrollLeft. See the useLayoutEffect just below where the JS sync - // happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules. - const headStickyRef = useRef(null); - const headTrackRef = useRef(null); - const headTableRef = useRef(null); - const bodyScrollRef = useRef(null); - const bodyTableRef = useRef(null); - const tableWrapRef = useRef(null); - - // ─── COLUMN HEADER POPUP ───────────────────────────────────────────── - // Popup opens on click (instant) or after hovering a for 3 s. - // Closes on click-toggle, click-outside, or mouseleave from both the - // th and the popup (with a 150 ms grace period so you can move between - // the two without it snapping shut). - // State holds { key, x, y, arrowLeft, below } or null when closed. - const [colPopup, setColPopup] = useState(null); - const colPopupRef = useRef(null); - const colPopupThRef = useRef(null); // that triggered the current popup - const hoverTimerRef = useRef(null); // setTimeout id for the 3 s open delay - const closeTimerRef = useRef(null); // setTimeout id for the 150 ms close grace - - const COL_DESCRIPTIONS = { - hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, - air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, - rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' }, - dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' }, - wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' }, - dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' }, - cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' }, - sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' }, - direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' }, - diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' }, - tmrt: { title: 'Mean Radiant Temp', desc: 'The temperature a person\'s skin "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day — this is why shade feels so much cooler.' }, - delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' }, - utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.' }, - uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.' }, - uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' }, - burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' }, - utciP: { title: 'UTCI+P', desc: 'SunScope\'s adjusted felt temperature: UTCI plus the soak-factor penalty for precipitation. Rain and snow on wet clothing can reduce the felt temperature by up to 8 °C.' }, - precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' }, - soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.' }, - soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, - soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' }, - concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' }, - vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, - indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, - managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, - }; - - // Compute popup position from a th element's current bounding rect. - // Returns { x, y, arrowLeft, below } — "below" true when popup sits - // underneath the th (not enough room above). - const calcPopupPos = (thEl) => { - const rect = thEl.getBoundingClientRect(); - const popupW = 260; - const popupH = 110; // approx — enough to decide above/below - const margin = 8; - const gap = 6; // gap between popup edge and th edge - // Horizontal: centre on the th, clamped to viewport - let x = rect.left + rect.width / 2; - x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); - // Vertical: prefer above; flip below if not enough room - const spaceAbove = rect.top; - const below = spaceAbove < popupH + gap + margin; - const y = below ? rect.bottom + gap : rect.top - gap; - // Arrow offset from popup left edge - const popupLeft = x - popupW / 2; - const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); - return { x, y, arrowLeft, below }; - }; - - // Returns the three event-handler props every needs. - const thPopupProps = (key) => ({ - onClick: (e) => handleThClick(key, e), - onMouseEnter: (e) => handleThEnter(key, e), - onMouseLeave: handleThLeave, - }); - - // Open the popup for a given th element + key. - const openPopup = (key, thEl) => { - colPopupThRef.current = thEl; - const pos = calcPopupPos(thEl); - setColPopup({ key, ...pos }); - }; - - // Close the popup and clear all pending timers. - const closePopup = () => { - setColPopup(null); - colPopupThRef.current = null; - clearTimeout(hoverTimerRef.current); - clearTimeout(closeTimerRef.current); - }; - - // Click: instant toggle. - const handleThClick = (key, e) => { - clearTimeout(hoverTimerRef.current); // cancel any pending hover-open - clearTimeout(closeTimerRef.current); - if (colPopup?.key === key) { closePopup(); return; } - openPopup(key, e.currentTarget); - }; - - // Hover open: start 3 s timer on th enter. - const handleThEnter = (key, e) => { - clearTimeout(closeTimerRef.current); // cancel any pending close - if (colPopup?.key === key) return; // already open for this th - clearTimeout(hoverTimerRef.current); - const thEl = e.currentTarget; - hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 3000); - }; - - // Leave th: cancel hover-open; start close grace if popup is showing. - const handleThLeave = () => { - clearTimeout(hoverTimerRef.current); - closeTimerRef.current = setTimeout(closePopup, 150); - }; - - // Enter popup: cancel any pending close (mouse moved from th into popup). - const handlePopupEnter = () => clearTimeout(closeTimerRef.current); - - // Leave popup: close after grace period. - const handlePopupLeave = () => { - closeTimerRef.current = setTimeout(closePopup, 150); - }; - - // Dismiss popup on click outside; reposition on scroll/resize. - useEffect(() => { - if (!colPopup) return; - const onClickOutside = (e) => { - if (colPopupRef.current && !colPopupRef.current.contains(e.target)) { - closePopup(); - } - }; - const onScrollOrResize = () => { - if (!colPopupThRef.current) return; - const pos = calcPopupPos(colPopupThRef.current); - setColPopup(prev => prev ? { ...prev, ...pos } : null); - }; - document.addEventListener('mousedown', onClickOutside); - window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); - window.addEventListener('resize', onScrollOrResize, { passive: true }); - return () => { - document.removeEventListener('mousedown', onClickOutside); - window.removeEventListener('scroll', onScrollOrResize, { capture: true }); - window.removeEventListener('resize', onScrollOrResize); - }; - }, [colPopup]); - - // ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── - // Track whether the body scroller can scroll left/right so we can show - // fade + chevron indicators on the table edges. - const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); - const [tableCanScrollRight, setTableCanScrollRight] = useState(false); - - const updateTableScrollIndicators = () => { - const el = bodyScrollRef.current; - if (!el) return; - setTableCanScrollLeft(el.scrollLeft > 1); - setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); - }; - - // ─── DRAG-TO-SCROLL ────────────────────────────────────────────────── - // Attach pointer-event drag scrolling to the body scroller so desktop - // users can click-drag the table horizontally. - useEffect(() => { - const el = bodyScrollRef.current; - if (!el) return; - let isDown = false; - let startX = 0; - let startScroll = 0; - - const onMouseDown = (e) => { - // Only act on clicks that land inside the body scroller - if (!el.contains(e.target)) return; - if (e.button !== 0) return; - if (e.target.closest('button, a, input, select')) return; - isDown = true; - startX = e.clientX; - startScroll = el.scrollLeft; - el.style.cursor = 'grabbing'; - document.body.style.userSelect = 'none'; - document.body.style.webkitUserSelect = 'none'; - }; - const onMouseMove = (e) => { - if (!isDown) return; - const dx = e.clientX - startX; - el.scrollLeft = startScroll - dx; - }; - const onMouseUp = () => { - if (!isDown) return; - isDown = false; - el.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.webkitUserSelect = ''; - }; - - // Attach everything to document so Preact's synthetic event system - // cannot intercept or swallow the events before we see them. - document.addEventListener('mousedown', onMouseDown); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - - // Also update indicators on scroll - el.addEventListener('scroll', updateTableScrollIndicators); - - return () => { - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - el.removeEventListener('scroll', updateTableScrollIndicators); - }; - }, [forecast]); - - // Update indicators after layout sync (columns may have changed width) - useEffect(() => { - updateTableScrollIndicators(); - }, [forecast, visibleCols, selectedDay]); - - // ─── TABLE SCROLL SYNC ─────────────────────────────────────────────── - // The hourly table is rendered as two stacked scroll areas: - // • Sticky header strip (locked to viewport top, clipped) - // • Body scroller (overflow-x: auto — owns the horizontal scrollbar) - // We need to (a) keep the header track shifted horizontally to match - // the body's scrollLeft, and (b) keep the header cells the same pixel - // width as the body cells even as columns toggle or the window resizes. - // ───────────────────────────────────────────────────────────────────── - const handleBodyScroll = () => { - const track = headTrackRef.current; - const body = bodyScrollRef.current; - if (!track || !body) return; - track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; - updateTableScrollIndicators(); - }; - - useLayoutEffect(() => { - // Synchronise the head and body table column widths with a - // "shrink-to-fit then distribute" strategy: - // • Measure each column's true natural (content-fit) width by - // temporarily switching both tables to table-layout: auto + - // width: max-content. White-space: nowrap on cells stops content - // from wrapping, so the measurement is the smallest width that - // won't clip the content. - // • If the body scroller has spare horizontal space (natural total - // < container width), scale every column up proportionally to - // fill it — so toggling columns off makes the remaining ones fan - // out instead of leaving an awkward gap. - // • Otherwise apply the natural widths as-is and let the body - // scroller's overflow-x: auto produce a horizontal scrollbar. - const sync = () => { - const headTable = headTableRef.current; - const bodyTable = bodyTableRef.current; - const bodyScroll = bodyScrollRef.current; - if (!headTable || !bodyTable || !bodyScroll) return; - const bodyRow = bodyTable.querySelector('tbody tr'); - const headRow = headTable.querySelector('thead tr'); - if (!bodyRow || !headRow) return; - const headCells = Array.from(headRow.children); - const bodyCells = Array.from(bodyRow.children); - const n = Math.min(headCells.length, bodyCells.length); - if (n === 0) return; - - // Step 1: clear any previously-forced cell widths and switch the - // tables to natural sizing so the measurement reflects the true - // content-fit width — independent of how wide the container is. - headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); - bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); - headTable.style.width = 'max-content'; - bodyTable.style.width = 'max-content'; - headTable.style.tableLayout = 'auto'; - bodyTable.style.tableLayout = 'auto'; - - // Step 2: read each cell's natural width. getBoundingClientRect - // forces synchronous layout — that's what we want. - const naturalW = new Array(n); - let naturalTotal = 0; - for (let i = 0; i < n; i++) { - const headW = headCells[i].getBoundingClientRect().width; - const bodyW = bodyCells[i].getBoundingClientRect().width; - const w = Math.max(Math.ceil(headW), Math.ceil(bodyW)); - naturalW[i] = w; - naturalTotal += w; - } - - // Step 3: decide final widths based on available container width. - const containerW = bodyScroll.clientWidth; - const finalW = new Array(n); - let totalWidth; - if (naturalTotal > 0 && naturalTotal < containerW) { - // Spare space — distribute proportionally across columns so they - // fan out to fill the scroller (no awkward right-hand gap). - const scale = containerW / naturalTotal; - let running = 0; - for (let i = 0; i < n - 1; i++) { - finalW[i] = Math.floor(naturalW[i] * scale); - running += finalW[i]; - } - // Absorb sub-pixel rounding into the last column so the total - // exactly matches the container width. - finalW[n - 1] = containerW - running; - totalWidth = containerW; - } else { - // Naturals don't fit — use them as-is and let the body scroll. - for (let i = 0; i < n; i++) finalW[i] = naturalW[i]; - totalWidth = naturalTotal; - } - - // Step 4: restore the CSS-defined table-layout: fixed so the - // explicit cell widths we apply below are honoured by the browser - // (not redistributed by the auto-layout algorithm). - headTable.style.tableLayout = ''; - bodyTable.style.tableLayout = ''; - - // Step 5: apply the final width to both head and body cells. - for (let i = 0; i < n; i++) { - const px = `${finalW[i]}px`; - headCells[i].style.width = px; - headCells[i].style.minWidth = px; - headCells[i].style.maxWidth = px; - bodyCells[i].style.width = px; - bodyCells[i].style.minWidth = px; - bodyCells[i].style.maxWidth = px; - } - // Make both tables exactly totalWidth wide so they share the same - // horizontal extent — column N in the header sits directly above - // column N in the body, no drift as you scroll right. - headTable.style.width = `${totalWidth}px`; - bodyTable.style.width = `${totalWidth}px`; - // Re-apply current horizontal offset so column alignment survives. - handleBodyScroll(); - }; - // Run once after layout - sync(); - // Re-sync when the scroll container's width changes (window resize, - // sidebar opens, etc). We observe the scroller — not the body table — - // because the body table's width is now driven by sync itself, which - // would otherwise create a feedback loop. - let ro = null; - if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) { - ro = new ResizeObserver(sync); - ro.observe(bodyScrollRef.current); - } - window.addEventListener('resize', sync); - return () => { - if (ro) ro.disconnect(); - window.removeEventListener('resize', sync); - }; - }, [forecast, visibleCols, selectedDay, skinType, vehicleType]); - - // Geocoding search - useEffect(() => { - if (searchQuery.length < 2) { setSearchResults([]); return; } - if (searchTimeout.current) clearTimeout(searchTimeout.current); - searchTimeout.current = setTimeout(async () => { - setSearching(true); - try { - const r = await fetch( - `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json` - ); - const j = await r.json(); - setSearchResults(j.results || []); - } catch { setSearchResults([]); } - finally { setSearching(false); } - }, 300); - }, [searchQuery]); - - // ─── FORECAST FETCH ────────────────────────────────────────────────── - // Runs every time `location` changes (i.e. when a new city is picked). - // Builds the Open-Meteo URL and stores the response in `forecast`. - // Change forecast_days=14 below to fetch a different range (max 16). - // Add or remove fields in the `&hourly=...` list to fetch more data — - // but if you remove one that's used elsewhere, expect errors. - useEffect(() => { - async function load() { - setLoading(true); setError(null); - try { - const url = - `https://api.open-meteo.com/v1/forecast` + - `?latitude=${location.lat}&longitude=${location.lon}` + - `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + - `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + - `direct_radiation,diffuse_radiation,shortwave_radiation,` + - `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + - `uv_index,precipitation,snowfall,` + - `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + - `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; - const r = await fetch(url); - if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); - setForecast(await r.json()); - } catch (e) { setError(e.message); } - finally { setLoading(false); } - } - load(); - }, [location]); - - // ─── AUTO-REFRESH — tick every 5 minutes ───────────────────────────── - // Updates `now` so the scope always shows the current hour's data. - // Also refetches the forecast so fresh API data comes in automatically. - useEffect(() => { - const FIVE_MIN = 5 * 60 * 1000; - const id = setInterval(() => { - setNow(new Date()); - // Trigger a fresh forecast fetch by nudging location identity. - // We do this via a separate load rather than touching location state - // (which would reset other things), so we call load() directly. - async function refresh() { - try { - const url = - `https://api.open-meteo.com/v1/forecast` + - `?latitude=${location.lat}&longitude=${location.lon}` + - `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + - `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + - `direct_radiation,diffuse_radiation,shortwave_radiation,` + - `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + - `uv_index,precipitation,snowfall,` + - `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + - `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; - const r = await fetch(url); - if (r.ok) setForecast(await r.json()); - } catch (_) { /* silently ignore refresh errors */ } - } - refresh(); - }, FIVE_MIN); - return () => clearInterval(id); - }, [location]); - - // ─── COMPUTATION ───────────────────────────────────────────────────── - // Take the raw API arrays and stitch them into one object per hour, - // calculating UTCI + soak-factor for each row. This is what gets - // displayed in the table. - // Open-Meteo with timezone=auto returns local wall-clock strings like - // "2026-05-13T14:00" — no Z, no offset suffix. We need two things: - // 1. The wall-clock hour for display & day grouping (just slice the string) - // 2. The true UTC instant for solarElevationDeg (which uses .getUTC* internally) - // Strategy: treat the ISO string as UTC (append Z), which gives a Date whose - // UTC hours equal the local wall-clock hour. Then ADD the utc_offset_seconds - // to shift it to the real UTC instant. e.g. Brisbane UTC+10: "14:00" local - // → parse as UTC 14:00 → add 10h → UTC 00:00 next day? No — subtract. - // Brisbane local 14:00 = UTC 04:00, offset = +10h, so UTC = local - offset. - // Date.parse("2026-05-13T14:00Z") = ms for UTC 14:00 - // Subtract offset (+10h = 36000000ms) → UTC 04:00. Correct. - const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; - - const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { - const h = forecast.hourly; - const Ta = h.temperature_2m[i]; - const RH = h.relative_humidity_2m[i]; - const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; - const va = h.wind_speed_10m[i]; - const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; - const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; - const dir = h.direct_radiation[i] || 0; - const dif = h.diffuse_radiation[i] || 0; - const glob = h.shortwave_radiation[i] || 0; - const cc = h.cloud_cover[i]; - const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; - const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; - const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; - const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; - const precip = h.precipitation[i] || 0; - const snow = h.snowfall[i] || 0; - const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; - const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; - const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; - const concreteT = calcConcreteTemp(Ta, glob, va); - // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). - // For display we slice the string directly — no Date object needed. - // For solarElevationDeg (which uses .getUTC* internally) we need the - // true UTC instant: treat the local time as UTC then subtract the offset. - // e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓ - const dtUTC = new Date(Date.parse(iso + 'Z') - utcOffsetMs); - // dt kept for SkyScope / backward compat — same as dtUTC. - const dt = dtUTC; - const elev = solarElevationDeg(location.lat, location.lon, dtUTC); - const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); - const eh = vaporPressureHpa(Ta, RH); - const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); - const utci = utciApprox(Ta, Tmrt, va, eh); - const utciAdj = utci + precipPenalty(precip, snow, va); - // Derived - const compass = windCompass8(wd); - const { uvA, uvB } = uvSplit(uv, elev); - const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); - return { - iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, - cc, ccLow, ccMid, ccHigh, cloudCat, - uv, uvA, uvB, - precip, snow, - soilT0, soilT6, soilM, concreteT, vehicleT, - elev, Tmrt, utci, utciAdj, eh, compass, - }; - }) : []; - - // Two-pass indoor temperature: needs the full hourly arrays so thermal - // lag can look back at previous hours. Run after hourlyRows is built, - // then stamp each row with its indoorT value. - if (hourlyRows.length > 0) { - const TaArr = hourlyRows.map(r => r.Ta); - const globArr = hourlyRows.map(r => r.glob); - const elevArr = hourlyRows.map(r => r.elev); - const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); - const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); - hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); - } - - // Group those hourly rows into days for the day tabs. - const days = []; - hourlyRows.forEach(row => { - const key = row.iso.slice(0, 10); - let day = days.find(d => d.key === key); - if (!day) { day = { key, rows: [] }; days.push(day); } - day.rows.push(row); - }); - - const visible = days[selectedDay]?.rows || []; - // nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH" - // Used to match against r.iso (which is already a local wall-clock string). - const nowLocalISO = new Date(now.getTime() + utcOffsetMs) - .toISOString().slice(0, 13); // "YYYY-MM-DDTHH" - const currentRow = hourlyRows.length > 0 - ? (hourlyRows.find(row => row.iso.slice(0, 13) === nowLocalISO) - ?? hourlyRows.reduce((best, row) => - Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best)) - : null; - const currentCat = currentRow - ? utciCategory(currentRow.utciAdj) - : { bg: '#4a4228', fg: '#ede4cc', label: 'No data' }; - - // ─── 4. JSX RETURN ─────────────────────────────────────────────────── - // Everything below is the actual page markup, written as one big HTM - // template. Search tips: - // • "utci-header" — the top section (title + dial + search) - // • "utci-day-tabs" — the 14 day buttons with band colours - // • "col-toggles" — the column-customisation row (Pro only) - // • "utci-table" — the hourly table itself - // • "utci-legend" — the thermal-stress band legend - // • "utci-about" — the explainer paragraphs at the bottom - // • "utci-footer" — the "reading the table" note - return html` -
- -
- -
-
-

- SUNScope -

-
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} - /> -
- -
-
- - 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' }); - 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 = { - 'profile: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: - ${Object.entries(FILTER_PROFILES).map(([key, profile]) => { - const locked = profile.proOnly && !isPro; - - // The 'outdoors' profile becomes a CustomSelect dropdown, - // followed by a separator dividing general-use from technical profiles. - if (key === 'outdoors') { - return html` - <${CustomSelect} - key="outdoors" - value=${activeProfile === 'outdoors' ? outdoorsVariant : 'off'} - isOn=${activeProfile === 'outdoors'} - noHide=${true} - hideLabel=${`${profile.icon} ${profile.label}`} - options=${Object.entries(OUTDOORS_VARIANTS).map(([k, v]) => ({ - value: k, - label: v.name + (v.proOnly && !isPro ? ' 🔒' : ''), - disabled: false, - }))} - onChange=${(v) => { - if (v === 'off') { - setActiveProfile('basic'); - setVisibleCols({ ...FILTER_PROFILES.basic.cols }); - setIndoorMode('off'); - return; - } - if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) { - setProPromptSource(`variant:${v}`); - setProPromptDay(0); - return; - } - setActiveProfile('outdoors'); - setOutdoorsVariant(v); - setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols }); - setIndoorMode('off'); - }} - /> - `; - } - - return html` - `; - })} - -
- - - ${(isPro || activeProfile === 'outdoors' || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html` -
- Columns: - -${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html``} - - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['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 })); - } - }} - />`} - - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['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" - />`} - `} - - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['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' || FILTER_PROFILES[activeProfile].cols['precip']) && html``} - -
`} - - -
- - - -
-
- - - - - ${visibleCols.air && html``} - ${visibleCols.rh && html``} - ${visibleCols.dew && html``} - ${visibleCols.soilT && html``} - ${visibleCols.soilT6 && html``} - ${visibleCols.soilM && html``} - ${visibleCols.concreteT && html``} - ${visibleCols.wind && html``} - ${visibleCols.dir && html``} - ${visibleCols.cloud && html``} - ${visibleCols.sun && html``} - ${visibleCols.direct && html``} - ${visibleCols.diffuse && html``} - ${visibleCols.tmrt && html``} - ${visibleCols.delta && html``} - ${visibleCols.utci && html``} - ${visibleCols.uvA && html``} - ${visibleCols.uvB && html``} - ${visibleCols.burn && html``} - ${visibleCols.vehicleT && html``} - ${indoorMode === 'on' && !indoorManaged && html``} - ${indoorMode === 'on' && indoorManaged && html``} - ${visibleCols.precip && html``} - ${visibleCols.utciP && html``} - - -
handleThClick('hour', e)}>Hour handleThClick('air', e)}>Air °C handleThClick('rh', e)}>RH % handleThClick('dew', e)}>Dew °C handleThClick('soilT', e)}>Soil °C surface handleThClick('soilT6', e)}>Soil 6cm °C root handleThClick('soilM', e)}>Soil moist m³/m³ handleThClick('concreteT', e)}>Concrete °C surface handleThClick('wind', e)}>Wind m/s (gust) handleThClick('dir', e)}>Dir - handleThClick('cloud', e)}>Cloud % handleThClick('sun', e)}>Sun elev° handleThClick('direct', e)}>Direct W/m² handleThClick('diffuse', e)}>Diffuse W/m² handleThClick('tmrt', e)}>Tmrt °C handleThClick('delta', e)}>Δ UTCI−Air handleThClick('utci', e)}>UTCI °C felt handleThClick('uvA', e)}>UV-A est. idx handleThClick('uvB', e)}>UV-B est. idx handleThClick('burn', e)}>Burn to MED handleThClick('vehicleT', e)}>Vehicle °C peak handleThClick('indoorT', e)}>Indoors °C est. handleThClick('managedT', e)}>Managed °C est. handleThClick('precip', e)}>Pcpt mm/h handleThClick('utciP', e)}>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.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} - - ${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)} - - 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}

-
`} - -
- 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} - `)} -
-
- `} - -
-

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 → -

-
- - - -
-
`; -} diff --git a/build.js b/build.js index b843a5c..7085fb4 100644 --- a/build.js +++ b/build.js @@ -66,9 +66,11 @@ function bumpVersion() { return Math.floor(Date.now() / 1000).toString(36); } -// Swap dev asset tags in HTML for bundle tags +// Swap dev asset tags in HTML for bundle tags. +// Strips the main sunscope.css link but preserves standalone CSS files +// like about.css that are not part of the main bundle. function rewriteHTML(html, version) { - html = html.replace(/]*href=["'][^"']*assets\/[^"']*\.css[^"']*["'][^>]*>/gi, ''); + html = html.replace(/]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, ''); html = html.replace(/]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, ''); const css = ' '; const js = ' '; @@ -143,7 +145,16 @@ async function build() { console.log(' -> ' + htmlFile); } - // 5. Copy static files + // 5. Copy standalone CSS files that are not part of the main bundle + for (const f of ['about.css']) { + const src = path.join(ROOT, 'assets', f); + if (fs.existsSync(src)) { + copyFile(src, path.join(ASSETS, f)); + console.log(' -> assets/' + f); + } + } + + // 6. Copy static files console.log(' Copying static files...'); for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png']) { const src = path.join(ROOT, f);