diff --git a/assets/css/table.css b/assets/css/table.css
index 8a0aaa8..5bb9593 100644
--- a/assets/css/table.css
+++ b/assets/css/table.css
@@ -575,7 +575,7 @@
padding: 14px 16px 16px;
pointer-events: all;
}
-/* Arrow points DOWN toward the column header */
+/* Arrow points DOWN toward the column header (default — popup is above the th) */
.col-info-popup::after {
content: '';
position: absolute;
@@ -586,7 +586,17 @@
background: #fffcf2;
border-right: 1.5px solid #c9b08a;
border-bottom: 1.5px solid #c9b08a;
- transform: rotate(45deg);
+ transform: translateX(-50%) rotate(45deg);
+}
+/* Arrow points UP toward the column header (popup is below the th) */
+.col-info-popup.col-info-popup--below::after {
+ bottom: auto;
+ top: -7px;
+ border-right: none;
+ border-bottom: none;
+ border-left: 1.5px solid #c9b08a;
+ border-top: 1.5px solid #c9b08a;
+ transform: translateX(-50%) rotate(45deg);
}
.col-info-title {
display: block;
diff --git a/assets/js/.fuse_hidden0000000800000001 b/assets/js/.fuse_hidden0000000800000001
new file mode 100644
index 0000000..d38fa2e
--- /dev/null
+++ b/assets/js/.fuse_hidden0000000800000001
@@ -0,0 +1,1615 @@
+// ════════════════════════════════════════════════════════════════════════
+// 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/assets/js/app.js b/assets/js/app.js
index abe4462..b357ae4 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -238,7 +238,10 @@ export function UTCIForecast() {
// Clicking a
shows a small description popup below it.
// State holds { key, x, y } or null when closed.
const [colPopup, setColPopup] = useState(null);
- const colPopupRef = useRef(null);
+ const colPopupRef = useRef(null);
+ const colPopupThRef = useRef(null);
+ const hoverTimerRef = useRef(null);
+ const closeTimerRef = useRef(null);
const COL_DESCRIPTIONS = {
hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' },
@@ -268,34 +271,76 @@ export function UTCIForecast() {
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.' },
};
- // Dismiss popup on click outside
+ const calcPopupPos = (thEl) => {
+ const rect = thEl.getBoundingClientRect();
+ const popupW = 260, popupH = 110, margin = 8, gap = 6;
+ let x = rect.left + rect.width / 2;
+ x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
+ const below = rect.top < popupH + gap + margin;
+ const y = below ? rect.bottom + gap : rect.top - gap;
+ 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 };
+ };
+
+ const openPopup = (key, thEl) => {
+ colPopupThRef.current = thEl;
+ setColPopup({ key, ...calcPopupPos(thEl) });
+ };
+
+ const closePopup = () => {
+ setColPopup(null);
+ colPopupThRef.current = null;
+ clearTimeout(hoverTimerRef.current);
+ clearTimeout(closeTimerRef.current);
+ };
+
+ const handleThClick = (key, e) => {
+ clearTimeout(hoverTimerRef.current);
+ clearTimeout(closeTimerRef.current);
+ if (colPopup?.key === key) { closePopup(); return; }
+ openPopup(key, e.currentTarget);
+ };
+
+ const handleThEnter = (key, e) => {
+ clearTimeout(hoverTimerRef.current);
+ clearTimeout(closeTimerRef.current);
+ // If a different popup is open, close it immediately and start fresh timer
+ if (colPopup && colPopup.key !== key) closePopup();
+ if (colPopup?.key === key) return; // already showing this one
+ const thEl = e.currentTarget;
+ hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000);
+ };
+
+ // Leaving a th: just cancel the pending open. Don't auto-close —
+ // the user might be moving into the popup, or just passing through.
+ const handleThLeave = () => {
+ clearTimeout(hoverTimerRef.current);
+ };
+
+ // Popup mouse handlers: keep it open while hovering, close on leave.
+ const handlePopupEnter = () => clearTimeout(closeTimerRef.current);
+ const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); };
+
useEffect(() => {
if (!colPopup) return;
const onClickOutside = (e) => {
- if (colPopupRef.current && !colPopupRef.current.contains(e.target)) {
- setColPopup(null);
- }
+ if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup();
+ };
+ const onScrollOrResize = () => {
+ if (!colPopupThRef.current) return;
+ setColPopup(prev => prev ? { ...prev, ...calcPopupPos(colPopupThRef.current) } : null);
};
document.addEventListener('mousedown', onClickOutside);
- return () => document.removeEventListener('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]);
- const handleThClick = (key, e) => {
- if (colPopup?.key === key) { setColPopup(null); return; }
- const rect = e.currentTarget.getBoundingClientRect();
- const popupW = 260;
- const margin = 8; // min gap from screen edge
- // Centre on the th, then clamp so popup stays within viewport
- let x = rect.left + rect.width / 2;
- x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
- // Position popup ABOVE the header; arrow points down toward the th
- const y = rect.top - 6; // 6px gap above the th top edge
- // Store arrowLeft as offset from popup left edge so arrow stays over the th
- const popupLeft = x - popupW / 2;
- const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
- setColPopup({ key, x, y, arrowLeft });
- };
-
// ─── TABLE SCROLL INDICATORS ─────────────────────────────────────────
// Track whether the body scroller can scroll left/right so we can show
// fade + chevron indicators on the table edges.
@@ -1259,31 +1304,31 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
-
handleThClick('hour', e)}>Hour
- ${visibleCols.air && html`
handleThClick('air', e)}>Air °C
`}
- ${visibleCols.rh && html`
handleThClick('rh', e)}>RH %
`}
- ${visibleCols.dew && html`
handleThClick('dew', e)}>Dew °C
`}
- ${visibleCols.soilT && html`
handleThClick('soilT', e)}>Soil °C surface
`}
- ${visibleCols.soilT6 && html`
handleThClick('soilT6', e)}>Soil 6cm °C root
`}
- ${visibleCols.soilM && html`
handleThClick('soilM', e)}>Soil moist m³/m³
`}
- ${visibleCols.concreteT && html`
handleThClick('concreteT', e)}>Concrete °C surface