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 colPopupThRef = useRef(null);
const hoverTimerRef = useRef(null);
const closeTimerRef = useRef(null);
// Event tag popup — same behaviour as column popups but for cell emoji icons.
const [eventTagPopup, setEventTagPopup] = useState(null); // { ev, x, y, arrowLeft, below }
const eventTagPopupRef = useRef(null);
const evHoverTimerRef = useRef(null);
const evCloseTimerRef = useRef(null);
const openEventTagPopup = (ev, spanEl) => {
const rect = spanEl.getBoundingClientRect();
const popupW = 260, popupH = 80, 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));
setEventTagPopup({ ev, x, y, arrowLeft, below });
};
const closeEventTagPopup = () => {
setEventTagPopup(null);
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
};
const handleEventTagClick = (ev, e) => {
e.stopPropagation();
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
if (eventTagPopup?.ev?.id === ev.id) { closeEventTagPopup(); return; }
openEventTagPopup(ev, e.currentTarget);
};
const handleEventTagEnter = (ev, e) => {
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
if (eventTagPopup?.ev?.id === ev.id) return;
const spanEl = e.currentTarget;
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(ev, spanEl), 700);
};
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
useEffect(() => {
if (!eventTagPopup) return;
const onClickOutside = (e) => {
if (eventTagPopupRef.current && !eventTagPopupRef.current.contains(e.target)) closeEventTagPopup();
};
const onScrollOrResize = () => closeEventTagPopup();
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);
};
}, [eventTagPopup]);
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.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.' },
};
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)) closePopup();
};
// Close on scroll (avoids scroll-linked jank) or resize
const onScrollOrResize = () => closePopup();
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,visibility,` +
`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();
loadAirQuality(location);
}, [location]);
// ─── AIR QUALITY FETCH (with 6-hour localStorage cache) ──────────────
// Fetches visibility, European AQI, and pollen from the Open-Meteo
// Air Quality API. Cached per location for 6 hours — pollen and AQI
// data updates at most a couple of times per day so there's no need
// to hit the API every 5 minutes with the weather refresh.
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
async function loadAirQuality(loc) {
const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) {
setAirQuality(data);
return;
}
}
} catch (e) { /* ignore bad cache */ }
try {
const url =
`https://air-quality-api.open-meteo.com/v1/air-quality` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=european_aqi,` +
`grass_pollen,birch_pollen,alder_pollen,` +
`mugwort_pollen,olive_pollen,ragweed_pollen` +
`&timezone=auto&forecast_days=5`;
const r = await fetch(url);
if (!r.ok) return; // silently fail — these columns just show '—'
const data = await r.json();
setAirQuality(data);
try {
localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data }));
} catch (e) { /* ignore storage errors */ }
} catch (_) { /* silently ignore */ }
}
// ─── 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,visibility,` +
`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 */ }
// Air quality: only refetch if cache has expired (6-hour TTL)
const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`;
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { ts } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh
}
} catch (e) { /* ignore */ }
loadAirQuality(location);
}
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 suffix. We use two forms:
// • String slices (iso.slice(...)) for display & day grouping
// • A true UTC Date for solarElevationDeg (see per-row comment below)
const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000;
// Build a fast lookup map from the air quality hourly data: ISO string → index.
// Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like
// "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14".
const aqTimeMap = {};
if (airQuality?.hourly?.time) {
airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; });
}
const getAq = (field, iso) => {
if (!airQuality?.hourly?.[field]) return null;
const i = aqTimeMap[iso.slice(0, 13)];
if (i === undefined) return null;
return airQuality.hourly[field][i] ?? null;
};
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 ✓
// True UTC instant: treat the local wall-clock string as UTC, then
// subtract the offset. e.g. Brisbane UTC+10, local 14:00 → UTC 04:00.
const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs);
const elev = solarElevationDeg(location.lat, location.lon, dt);
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);
// Visibility from the main forecast API (metres → km).
const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })();
const aqi = getAq('european_aqi', iso);
const grassPollen = getAq('grass_pollen', iso);
const birchPollen = getAq('birch_pollen', iso);
const alderPollen = getAq('alder_pollen', iso);
const mugwortPollen= getAq('mugwort_pollen', iso);
const olivePollen = getAq('olive_pollen', iso);
const ragweedPollen= getAq('ragweed_pollen', iso);
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,
visKm, aqi,
grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen,
};
}) : [];
// 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' };
// ─── COSMIC/WEATHER EVENTS ────────────────────────────────────────────
// activeEvents — today's events → drives banner & lens overlay.
// selectedDayEvents — viewed day's events → drives cell tags.
const [dismissedEventIds, setDismissedEventIds] = useState([]);
const [bannerIndex, setBannerIndex] = useState(0);
const todayRows = days[0]?.rows || [];
const activeEvents = getActiveEvents(todayRows, location)
.filter(ev => !dismissedEventIds.includes(ev.id));
const lensEvent = getLensEvent(activeEvents);
const selectedDayEvents = getActiveEvents(visible, location);
// Auto-advance banner slideshow every 10 seconds when multiple events
useEffect(() => {
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
const id = setInterval(() => {
setBannerIndex(i => (i + 1) % activeEvents.length);
}, 10000);
return () => clearInterval(id);
}, [activeEvents.length, activeEvents.map(e => e.id).join(',')]);
// Keep index in bounds if events change
useEffect(() => {
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
}, [activeEvents.length]);
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
// Everything below is the actual page markup, written as one big HTM
// template. Search tips:
// • "utci-header" — the top section (title + dial + search)
// • "utci-day-tabs" — the 14 day buttons with band colours
// • "col-toggles" — the column-customisation row (Pro only)
// • "utci-table" — the hourly table itself
// • "utci-legend" — the thermal-stress band legend
// • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note
return html`
${days.map((d, i) => {
const band = confidenceBand(i);
const locked = !isPro && i >= FREE_DAYS;
const isActive = i === selectedDay;
// d.key is "YYYY-MM-DD" in location-local time — parse as UTC so
// toLocaleDateString with timeZone:'UTC' reads the correct weekday/date.
const dDate = new Date(d.key + 'T00:00Z');
const dayName = i === 0 ? 'Today'
: i === 1 ? 'Tomorrow'
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
// ── Day-tab weather icon ───────────────────────────────────
// Use daytime rows (elev > 0) where available, else all rows.
// Pick the modal cloud category and sum precip/snow to decide
// whether to show a PrecipIcon or a CloudIcon.
const dayRows = d.rows.filter(r => r.elev > 0);
const repRows = dayRows.length > 0 ? dayRows : d.rows;
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
// Modal cloud category (most frequent among daytime hours)
const catCounts = {};
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
// Representative solar elevation: midday row or median of daytime rows
const midRow = repRows[Math.floor(repRows.length / 2)];
const repElev = midRow ? midRow.elev : 45;
const repDt = midRow ? midRow.dt : dDate;
// Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
return html`
`;
})}
${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
const extraPromptCopy = {
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
},
'profile:alltemps': {
title: 'Temps is part of SunScope Extra',
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
},
'profile:custom': {
title: 'Custom columns are part of SunScope Extra',
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
},
'variant:festival': {
title: 'Festival planning is part of SunScope Extra',
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
},
'variant:wintersports': {
title: 'Winter Sports is part of SunScope Extra',
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
},
'variant:naturist': {
title: 'Naturist is part of SunScope Extra',
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
},
};
const promptCopy = extraPromptCopy[proPromptSource] || {
title: `${dayName}'s forecast is part of SunScope Extra`,
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
};
return html`
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 →