1520 lines
91 KiB
JavaScript
1520 lines
91 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════
|
||
// 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. Change the values below to set
|
||
// a different starting location for new visitors.
|
||
const [location, setLocation] = useState({
|
||
name: 'Pangbourne, Berkshire',
|
||
lat: 51.4839,
|
||
lon: -1.0725,
|
||
country: 'GB',
|
||
});
|
||
|
||
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: true, uvA: false, uvB: false, burn: false, utciP: false, 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 ─────────────────────────────────────────────
|
||
// Clicking a <th> 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 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.' },
|
||
};
|
||
|
||
// Dismiss popup on click outside
|
||
useEffect(() => {
|
||
if (!colPopup) return;
|
||
const onClickOutside = (e) => {
|
||
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) {
|
||
setColPopup(null);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', onClickOutside);
|
||
return () => document.removeEventListener('mousedown', onClickOutside);
|
||
}, [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.
|
||
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`
|
||
<div class="utci-app">
|
||
<nav class="utci-topnav" id="site-nav">
|
||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||
<div class="nav-logo" aria-hidden="true">
|
||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">SUN</span><span class="nav-logo-scope">Scope</span></span>
|
||
<span class="nav-logo-tag">See the sun the way your body does.</span>
|
||
</div>
|
||
<div class="nav-links" id="nav-drawer">
|
||
<a href="./index.html">Forecast</a>
|
||
<a href="./about.html">About</a>
|
||
<a href=${isPro
|
||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||
target="_blank" rel="noopener noreferrer">Account</a>
|
||
</div>
|
||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
|
||
onClick=${() => {
|
||
const nav = document.getElementById('site-nav');
|
||
const drawer = document.getElementById('nav-drawer');
|
||
const btn = document.getElementById('burger-btn');
|
||
const open = nav.classList.toggle('nav-open');
|
||
drawer.classList.toggle('is-open', open);
|
||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}}>
|
||
<span></span><span></span><span></span>
|
||
</button>
|
||
</nav>
|
||
<main class="utci-shell">
|
||
|
||
<div class="utci-header">
|
||
<div>
|
||
<h1 class="utci-title">
|
||
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
|
||
</h1>
|
||
<div class="utci-tagline">See the sun the way your body does.</div>
|
||
<div class="utci-current-loc">
|
||
↳ ${location.name}
|
||
<span class="utci-loc-coords">
|
||
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<${ScopeReticle}
|
||
value=${currentRow?.utciAdj ?? null}
|
||
cat=${currentCat}
|
||
loading=${loading}
|
||
elev=${currentRow?.elev ?? 0}
|
||
dt=${currentRow?.dt ?? new Date()}
|
||
glob=${currentRow?.glob ?? 0}
|
||
/>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<div class="utci-search-wrap">
|
||
<label class="utci-search-label">Change location</label>
|
||
<input
|
||
class="utci-search"
|
||
type="text"
|
||
placeholder="Search any town or city…"
|
||
value=${searchQuery}
|
||
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
|
||
/>
|
||
${searchResults.length > 0 && html`
|
||
<div class="utci-results">
|
||
${searchResults.map((r) => html`
|
||
<div
|
||
key=${`${r.id}-${r.latitude}`}
|
||
class="utci-result"
|
||
onClick=${() => {
|
||
setLocation({
|
||
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
|
||
lat: r.latitude,
|
||
lon: r.longitude,
|
||
country: r.country_code,
|
||
});
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
setSelectedDay(0);
|
||
}}
|
||
>
|
||
<div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
|
||
<div class="utci-result-meta">
|
||
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
|
||
</div>
|
||
</div>`)}
|
||
</div>`}
|
||
${searching && html`<div class="utci-searching">Searching…</div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${error && html`
|
||
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
|
||
⚠ ${error}
|
||
</div>`}
|
||
${loading && !error && html`
|
||
<div class="utci-status">Acquiring forecast data…</div>`}
|
||
|
||
${forecast && days.length > 0 && html`
|
||
<${Fragment}>
|
||
<!--
|
||
DAY TABS — one button per day, coloured by confidence band.
|
||
Days 4+ get 🔒'd when isPro is false. To change the lock
|
||
behaviour (e.g. open a paywall modal instead of doing
|
||
nothing), edit the onClick handler below.
|
||
-->
|
||
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
|
||
<button
|
||
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(-1)}
|
||
aria-label="Scroll days left"
|
||
type="button">‹</button>
|
||
<button
|
||
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(1)}
|
||
aria-label="Scroll days right"
|
||
type="button">›</button>
|
||
<div class="utci-day-tabs" ref=${dayTabsRef}>
|
||
${days.map((d, i) => {
|
||
const band = confidenceBand(i);
|
||
const locked = !isPro && i >= FREE_DAYS;
|
||
const isActive = i === selectedDay;
|
||
// d.key is "YYYY-MM-DD" in location-local time — parse as UTC so
|
||
// toLocaleDateString with timeZone:'UTC' reads the correct weekday/date.
|
||
const dDate = new Date(d.key + 'T00:00Z');
|
||
const dayName = i === 0 ? 'Today'
|
||
: i === 1 ? 'Tomorrow'
|
||
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||
return html`
|
||
<button
|
||
key=${d.key}
|
||
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource('day');
|
||
setProPromptDay(i);
|
||
} else {
|
||
setSelectedDay(i);
|
||
setProPromptDay(null); // hide the card on a normal click
|
||
}
|
||
}}
|
||
title=${locked
|
||
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||
: `${band.label} · day ${i + 1} of 14`}
|
||
style=${{
|
||
background: band.bg,
|
||
color: '#2a1d10',
|
||
borderStyle: 'solid',
|
||
borderWidth: '0 0 3px 0',
|
||
borderBottomColor: isActive ? '#1e1208' : band.edge,
|
||
opacity: locked ? 0.5 : 1,
|
||
cursor: locked ? 'not-allowed' : 'pointer',
|
||
position: 'relative',
|
||
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
|
||
}}
|
||
>
|
||
${locked && html`
|
||
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
|
||
${dayName}
|
||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||
</span>
|
||
</button>`;
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
PRO UPSELL CARD — shown when a locked day is clicked.
|
||
Visible only while proPromptDay !== null. To change the
|
||
copy or pricing, edit the strings below. The "Notify me"
|
||
button is a mailto: link — replace with a real signup
|
||
form when you have one.
|
||
-->
|
||
${proPromptDay !== null && days[proPromptDay] && (() => {
|
||
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
|
||
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
|
||
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||
const extraPromptCopy = {
|
||
'profile:sailing': {
|
||
title: 'Sailing is part of SunScope Extra',
|
||
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
|
||
},
|
||
'profile:alltemps': {
|
||
title: 'Temps is part of SunScope Extra',
|
||
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
|
||
},
|
||
'profile:custom': {
|
||
title: 'Custom columns are part of SunScope Extra',
|
||
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
|
||
},
|
||
'variant:festival': {
|
||
title: 'Festival planning is part of SunScope Extra',
|
||
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
|
||
},
|
||
'variant:wintersports': {
|
||
title: 'Winter Sports is part of SunScope Extra',
|
||
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
|
||
},
|
||
'variant:naturist': {
|
||
title: 'Naturist is part of SunScope Extra',
|
||
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
|
||
},
|
||
};
|
||
const promptCopy = extraPromptCopy[proPromptSource] || {
|
||
title: `${dayName}'s forecast is part of SunScope Extra`,
|
||
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
|
||
};
|
||
return html`
|
||
<div style=${{
|
||
margin: '12px 0',
|
||
padding: '18px 22px',
|
||
background: '#fdf8ee',
|
||
border: '1.5px solid #c9b08a',
|
||
borderLeft: '4px solid #c8922a',
|
||
borderRadius: '0 4px 4px 0',
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '14px',
|
||
}}>
|
||
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
|
||
<div style=${{
|
||
fontFamily: 'Fraunces, serif',
|
||
fontStyle: 'italic',
|
||
fontSize: '19px',
|
||
fontWeight: 700,
|
||
color: '#1e1208',
|
||
marginBottom: '6px',
|
||
lineHeight: 1.25,
|
||
}}>
|
||
🔒 ${promptCopy.title}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontSize: '13.5px',
|
||
color: '#4a3420',
|
||
lineHeight: 1.65,
|
||
}}>
|
||
${promptCopy.detail}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
color: '#c8922a',
|
||
marginTop: '10px',
|
||
}}>
|
||
£2 / month · cancel any time
|
||
</div>
|
||
</div>
|
||
<div style=${{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<a
|
||
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
display: 'inline-block',
|
||
padding: '10px 18px',
|
||
background: '#c8922a',
|
||
color: '#fff',
|
||
textDecoration: 'none',
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
borderRadius: '3px',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
Subscribe — £2/month
|
||
</a>
|
||
<a
|
||
href="/restore.php"
|
||
style=${{
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
color: '#c8922a',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Already subscribed? Restore access →
|
||
</a>
|
||
<a
|
||
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
color: '#b09870',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(176,152,112,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Manage or cancel subscription →
|
||
</a>
|
||
<button
|
||
onClick=${() => setProPromptDay(null)}
|
||
style=${{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
color: '#b09870',
|
||
padding: '2px 4px',
|
||
}}
|
||
>
|
||
dismiss
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
|
||
${(() => {
|
||
const band = confidenceBand(selectedDay);
|
||
const isOutlook = selectedDay >= 7;
|
||
return html`
|
||
<div style=${{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
margin: '8px 0 4px',
|
||
padding: '6px 10px',
|
||
background: band.tint,
|
||
borderLeft: `3px solid ${band.edge}`,
|
||
borderRadius: '0 4px 4px 0',
|
||
fontSize: '12px',
|
||
color: '#3a2a18',
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
<span style=${{ letterSpacing: '1px', textTransform: 'uppercase', fontWeight: 700 }}>
|
||
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
|
||
</span>
|
||
${isOutlook && html`
|
||
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
|
||
forecast skill is reduced — treat hourly detail as trend, not precision
|
||
</span>`}
|
||
</div>`;
|
||
})()}
|
||
|
||
<!--
|
||
FILTER PROFILE SELECTOR — presets shown to all users.
|
||
Extra profiles are shown in place with a gentle 🔒 and clicking
|
||
them triggers the same upsell prompt as locked days.
|
||
The bottom border is removed only when the col-toggles bar
|
||
follows (Pro users), so the two bars merge into one panel.
|
||
-->
|
||
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
|
||
<span class="filter-profiles-label">Profile:</span>
|
||
${Object.entries(FILTER_PROFILES).map(([key, profile]) => {
|
||
const locked = profile.proOnly && !isPro;
|
||
|
||
// The 'outdoors' profile becomes a CustomSelect dropdown,
|
||
// followed by a separator dividing general-use from technical profiles.
|
||
if (key === 'outdoors') {
|
||
return html`
|
||
<${CustomSelect}
|
||
key="outdoors"
|
||
value=${activeProfile === 'outdoors' ? outdoorsVariant : 'off'}
|
||
isOn=${activeProfile === 'outdoors'}
|
||
noHide=${true}
|
||
hideLabel=${`${profile.icon} ${profile.label}`}
|
||
options=${Object.entries(OUTDOORS_VARIANTS).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name + (v.proOnly && !isPro ? ' 🔒' : ''),
|
||
disabled: false,
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setActiveProfile('basic');
|
||
setVisibleCols({ ...FILTER_PROFILES.basic.cols });
|
||
setIndoorMode('off');
|
||
return;
|
||
}
|
||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||
setProPromptSource(`variant:${v}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariant(v);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||
setIndoorMode('off');
|
||
}}
|
||
/>
|
||
<span class="profile-divider" aria-hidden="true"></span>`;
|
||
}
|
||
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource(`profile:${key}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
setActiveProfile(key);
|
||
if (key !== 'custom') {
|
||
setVisibleCols({ ...profile.cols });
|
||
}
|
||
// Auto-enable indoor column for profiles that include it;
|
||
// reset to off for profiles that don't.
|
||
if (key !== 'custom') {
|
||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||
setIndoorManaged(false);
|
||
}
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
|
||
</div>
|
||
|
||
<!--
|
||
COLUMN TOGGLES — in exact table column order.
|
||
Buttons visible to all users when profile includes that col.
|
||
Burn + Vehicle dropdowns always shown (free + pro).
|
||
Pro users see all toggles; free users see profile-filtered subset.
|
||
-->
|
||
${(isPro || activeProfile === 'outdoors' || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html`
|
||
<div class="col-toggles">
|
||
<span class="col-toggles-label">Columns:</span>
|
||
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html`<button class=${`col-toggle${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html`<button class=${`col-toggle${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html`<button class=${`col-toggle${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html`<button class=${`col-toggle${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html`<button class=${`col-toggle${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html`<button class=${`col-toggle${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html`<button class=${`col-toggle${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html`<button class=${`col-toggle${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
|
||
|
||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['burn']) && html`
|
||
<${CustomSelect}
|
||
value=${skinType}
|
||
isOn=${visibleCols.burn}
|
||
hideLabel="Burn"
|
||
hidingLabel="Hide Burn"
|
||
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name.split(' · ')[1] + ' skin',
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, burn: false }));
|
||
} else {
|
||
setSkinType(v);
|
||
setVisibleCols(prev => ({ ...prev, burn: true }));
|
||
}
|
||
}}
|
||
/>`}
|
||
|
||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html`
|
||
<span class="col-toggle-group">
|
||
<${CustomSelect}
|
||
value=${vehicleType}
|
||
isOn=${visibleCols.vehicleT}
|
||
hideLabel="Vehicle"
|
||
hidingLabel="Hide Vehicle"
|
||
groupedLeft=${true}
|
||
isLastChild=${!visibleCols.vehicleT}
|
||
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name,
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
|
||
setVehicleVent(false);
|
||
} else {
|
||
setVehicleType(v);
|
||
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
|
||
}
|
||
}}
|
||
/>
|
||
${visibleCols.vehicleT && html`
|
||
<${VentPill}
|
||
checked=${vehicleVent}
|
||
onChange=${() => setVehicleVent(v => !v)}
|
||
label="Ventilation"
|
||
title="Ventilation — open windows significantly reduce cabin heat build-up"
|
||
/>`}
|
||
</span>`}
|
||
|
||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html`
|
||
<span class="col-toggle-group">
|
||
<${CustomSelect}
|
||
value=${buildingType}
|
||
isOn=${indoorMode === 'on'}
|
||
hideLabel="Indoors"
|
||
hidingLabel="Hide Indoors"
|
||
groupedLeft=${true}
|
||
isLastChild=${indoorMode !== 'on'}
|
||
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name,
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
} else {
|
||
setBuildingType(v);
|
||
setIndoorMode('on');
|
||
}
|
||
}}
|
||
/>
|
||
${indoorMode === 'on' && html`
|
||
<${VentPill}
|
||
checked=${indoorManaged}
|
||
onChange=${() => setIndoorManaged(v => !v)}
|
||
label="Managed"
|
||
title="Managed: curtains closed by day, windows open when cooler outside"
|
||
/>`}
|
||
</span>`}
|
||
${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||
|
||
</div>`}
|
||
|
||
<!--
|
||
HOURLY TABLE — each row is one hour from the selected day.
|
||
Each column is wrapped in a visibleCols.X check, so it only
|
||
shows when its toggle is on. To force a column to always
|
||
show, remove the visibleCols check around it. To rename a
|
||
heading, edit the text inside the matching <th>.
|
||
-->
|
||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}>
|
||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||
<!-- Sticky header strip — locks to viewport top. Clipped
|
||
horizontally; the inner .utci-thead-track is shifted
|
||
via translateX from JS to follow the body's scrollLeft.
|
||
See handleBodyScroll + useLayoutEffect above. -->
|
||
<div class="utci-thead-sticky" ref=${headStickyRef}>
|
||
<div class="utci-thead-track" ref=${headTrackRef}>
|
||
<table class="utci-table utci-table-head" ref=${headTableRef}>
|
||
<thead>
|
||
<tr>
|
||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)}>Hour</th>
|
||
${visibleCols.air && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('air', e)}>Air <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.rh && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('rh', e)}>RH <span class="col-unit">%</span></th>`}
|
||
${visibleCols.dew && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('dew', e)}>Dew <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.soilT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT', e)}>Soil °C <span class="col-unit">surface</span></th>`}
|
||
${visibleCols.soilT6 && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT6', e)}>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
||
${visibleCols.soilM && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilM', e)}>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)}>Concrete <span class="col-unit">°C surface</span></th>`}
|
||
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)}>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)}>Dir <span class="col-unit">-</span></th>`}
|
||
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)}>Cloud <span class="col-unit">%</span></th>`}
|
||
${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)}>Sun <span class="col-unit">elev°</span></th>`}
|
||
${visibleCols.direct && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('direct', e)}>Direct <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.diffuse && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('diffuse', e)}>Diffuse <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)}>Tmrt <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.delta && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('delta', e)}>Δ <span class="col-unit">UTCI−Air</span></th>`}
|
||
${visibleCols.utci && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utci', e)}>UTCI <span class="col-unit">°C felt</span></th>`}
|
||
${visibleCols.uvA && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvA', e)}>UV-A <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.uvB && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('uvB', e)}>UV-B <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)}>Burn <span class="col-unit">to MED</span></th>`}
|
||
${visibleCols.vehicleT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vehicleT', e)}>Vehicle <span class="col-unit">°C peak</span></th>`}
|
||
${indoorMode === 'on' && !indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('indoorT', e)}>Indoors <span class="col-unit">°C est.</span></th>`}
|
||
${indoorMode === 'on' && indoorManaged && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('managedT', e)}>Managed <span class="col-unit">°C est.</span></th>`}
|
||
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('precip', e)}>Pcpt <span class="col-unit">mm/h</span></th>`}
|
||
${visibleCols.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||
</tr>
|
||
</thead>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<!-- Body scroller — owns the horizontal scrollbar. The
|
||
onScroll handler translates the header track to keep
|
||
columns aligned with the visible body columns. -->
|
||
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||
<tbody>
|
||
${visible.map((r) => {
|
||
const cat = utciCategory(r.utci);
|
||
const isNight = r.elev < 0;
|
||
const isNow = r.iso.slice(0, 13) === nowLocalISO;
|
||
const delta = r.utci - r.Ta;
|
||
const adjCat = utciCategory(r.utciAdj);
|
||
// Converts a band hex colour to rgba at the given alpha — used to
|
||
// tint the UTCI and UTCI+P cells directly from the band colour.
|
||
const hexToRgba = (hex, a) => {
|
||
const h = hex.replace('#', '');
|
||
const r = parseInt(h.slice(0,2),16);
|
||
const g = parseInt(h.slice(2,4),16);
|
||
const b = parseInt(h.slice(4,6),16);
|
||
return `rgba(${r},${g},${b},${a})`;
|
||
};
|
||
// Returns a very light background tint based on temperature value.
|
||
// Cold → faint blue, cool → faint green, warm → faint amber, hot → faint red.
|
||
const tempBg = (t) => {
|
||
if (t == null) return 'transparent';
|
||
if (t < 0) return 'rgba(100,160,230,0.10)';
|
||
if (t < 10) return 'rgba(140,200,200,0.10)';
|
||
if (t < 18) return 'rgba(140,200,150,0.10)';
|
||
if (t < 26) return 'rgba(100,190,100,0.10)';
|
||
if (t < 32) return 'rgba(230,200, 80,0.10)';
|
||
if (t < 40) return 'rgba(230,140, 50,0.10)';
|
||
if (t < 50) return 'rgba(210, 70, 50,0.10)';
|
||
return 'rgba(160, 30, 30,0.10)';
|
||
};
|
||
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
|
||
if (v == null || isNaN(v)) return 'transparent';
|
||
if (v <= min) return 'transparent';
|
||
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
||
const a = 0.035 + x * maxAlpha;
|
||
return `rgba(${rgb},${a.toFixed(3)})`;
|
||
};
|
||
const deltaBg = (v) => {
|
||
if (v == null || isNaN(v)) return 'transparent';
|
||
if (Math.abs(v) < 1) return 'transparent';
|
||
const x = Math.min(1, Math.abs(v) / 12);
|
||
const a = 0.035 + x * 0.13;
|
||
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
||
};
|
||
const burnBg = (mins, uv) => {
|
||
if (!isFinite(mins) || uv <= 0) return 'transparent';
|
||
if (mins >= 240) return 'transparent';
|
||
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
|
||
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
|
||
};
|
||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||
const burnMins = sunburnMinutes(r.uv, skinType);
|
||
return html`
|
||
<tr key=${r.iso}
|
||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||
<td class="utci-time">
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
|
||
<span>${localHHMM}</span>
|
||
</span>
|
||
</td>
|
||
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
|
||
${visibleCols.rh && html`<td style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
|
||
${visibleCols.dew && html`<td style=${{ background: tempBg(r.dew) }}>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT && html`
|
||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT6 && html`
|
||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT6) }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilM && html`
|
||
<td style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||
${visibleCols.concreteT && html`
|
||
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
|
||
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
|
||
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
|
||
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
|
||
: ''}
|
||
</td>`}
|
||
${visibleCols.dir && html`<td class="utci-dir-cell">
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<${WindVane} bearing=${r.wd} size=${28} />
|
||
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.cloud && html`<td style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07) }}>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
|
||
<span>${Math.round(r.cc)}</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.sun && html`<td style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
|
||
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
|
||
${visibleCols.tmrt && html`<td style=${{ background: tempBg(r.Tmrt) }}>${r.Tmrt.toFixed(1)}</td>`}
|
||
${visibleCols.delta && html`
|
||
<td style=${{
|
||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||
fontWeight: 600,
|
||
background: deltaBg(delta),
|
||
}}>
|
||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||
</td>`}
|
||
${visibleCols.utci && html`
|
||
<td style=${{ background: hexToRgba(cat.bg, 0.22), color: cat.fg === '#fff' ? cat.fg : '#2a1a08', fontWeight: 600 }}>
|
||
${r.utci.toFixed(1)}°
|
||
</td>`}
|
||
${visibleCols.uvA && html`
|
||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
|
||
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.uvB && html`
|
||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
|
||
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||
</td>`}
|
||
${visibleCols.burn && html`
|
||
<td style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
|
||
${burnLabel(burnMins)}
|
||
</td>`}
|
||
${visibleCols.vehicleT && html`
|
||
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.vehicleT) }}>
|
||
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${indoorMode === 'on' && !indoorManaged && html`
|
||
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.indoorT) }}>
|
||
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${indoorMode === 'on' && indoorManaged && html`
|
||
<td style=${{ color: r.managedT != null && r.managedT > 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBg(r.managedT) }}>
|
||
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.precip && html`
|
||
<td style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
||
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
|
||
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
|
||
${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||
</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.utciP && html`
|
||
<td style=${{ background: hexToRgba(adjCat.bg, 0.55), color: adjCat.fg === '#fff' ? adjCat.fg : '#2a1a08', fontWeight: 700, fontSize: '16px' }}>
|
||
${r.utciAdj.toFixed(1)}°
|
||
</td>`}
|
||
</tr>`;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
|
||
<div
|
||
ref=${colPopupRef}
|
||
class="col-info-popup"
|
||
style=${{
|
||
position: 'fixed',
|
||
left: `${colPopup.x}px`,
|
||
top: `${colPopup.y}px`,
|
||
transform: 'translateX(-50%) translateY(-100%)',
|
||
'--arrow-left': `${colPopup.arrowLeft}px`,
|
||
}}
|
||
>
|
||
<button class="col-info-close" onClick=${() => setColPopup(null)} aria-label="Close">×</button>
|
||
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
|
||
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
|
||
</div>`}
|
||
|
||
<div class="utci-legend">
|
||
<span class="utci-legend-label">Thermal stress bands</span>
|
||
<div class="utci-legend-row">
|
||
${[
|
||
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
|
||
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
|
||
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
|
||
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
|
||
{ l: '18 to 26 Comfortable', bg: '#4a8a3a', fg: '#fff' },
|
||
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
|
||
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
|
||
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
|
||
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
|
||
].map((b, i) => html`
|
||
<span key=${i} class="utci-legend-item" style=${{ background: b.bg, color: b.fg }}>
|
||
${b.l}
|
||
</span>`)}
|
||
</div>
|
||
</div>
|
||
</>`}
|
||
|
||
<div class="utci-about">
|
||
<h2 class="utci-about-heading">What is SunScope?</h2>
|
||
<p class="utci-about-text">
|
||
SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
|
||
not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
|
||
— 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
|
||
<strong>UTCI+P</strong> column adds an original rain and snow penalty so wet, windy days
|
||
read as cold as they feel.
|
||
</p>
|
||
<p class="utci-about-text">
|
||
Beyond felt temperature, SunScope calculates <strong>vehicle cabin heat</strong> (choose
|
||
your vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven
|
||
building types; managed heatwave mode), <strong>urban concrete surface temperature</strong>,
|
||
<strong>UV index and sunburn time</strong> by skin type, and <strong>soil temperature
|
||
and moisture</strong> for farming and motorhome use. Switch profiles to see the data
|
||
that matters for your situation — or go Custom and build your own view.
|
||
<a href="./about.html" class="utci-about-link">Learn more →</a>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="utci-footer">
|
||
<em>Reading the table.</em> A large positive Δ means your body is absorbing
|
||
far more heat than the air temperature alone suggests — typically due to direct solar radiation.
|
||
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
|
||
${isPro && html`
|
||
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
|
||
SunScope Extra is active.
|
||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank" rel="noopener noreferrer"
|
||
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none' }}>
|
||
Manage or cancel subscription →
|
||
</a>
|
||
</div>
|
||
`}
|
||
</div>
|
||
|
||
</main>
|
||
</div>`;
|
||
}
|