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 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(/';
@@ -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);