1031 lines
53 KiB
JavaScript
1031 lines
53 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 } from './physics.js';
|
||
import {
|
||
utciCategory, precipPenalty, windCompass8, uvSplit,
|
||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||
cloudCategory, confidenceBand, moonGlyph,
|
||
} from './utils.js';
|
||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon } 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 [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);
|
||
|
||
// 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 },
|
||
},
|
||
urban: {
|
||
label: 'Urban',
|
||
icon: '🏙️',
|
||
cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: true, delta: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false },
|
||
},
|
||
farming: {
|
||
label: 'Farming',
|
||
icon: '🌾',
|
||
cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true },
|
||
},
|
||
sailing: {
|
||
label: 'Sailing',
|
||
icon: '⛵',
|
||
cols: { hour: true, air: false, 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 },
|
||
},
|
||
custom: {
|
||
label: 'Custom',
|
||
icon: '⚙️',
|
||
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 },
|
||
},
|
||
};
|
||
|
||
// 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({
|
||
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,
|
||
});
|
||
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');
|
||
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);
|
||
|
||
// ─── 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)`;
|
||
};
|
||
|
||
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]);
|
||
|
||
// 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]);
|
||
|
||
// ─── 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;
|
||
// 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 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,
|
||
elev, Tmrt, utci, utciAdj, eh, compass,
|
||
};
|
||
}) : [];
|
||
|
||
// 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 || [];
|
||
const now = new Date();
|
||
// 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">
|
||
<a href="./index.html">Home</a>
|
||
<a href="./about.html">About</a>
|
||
</nav>
|
||
<div class="lens-bloom-a"></div>
|
||
<div class="lens-bloom-b"></div>
|
||
|
||
<div class="utci-shell">
|
||
|
||
<div class="utci-header">
|
||
<div>
|
||
<h1 class="utci-title">
|
||
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
|
||
<sub class="title-beta">beta</sub>
|
||
</h1>
|
||
<div class="utci-tagline">See the sun the way your body does.</div>
|
||
<div class="utci-subtitle">
|
||
<a href="https://utci.org/" target="_blank" rel="noopener noreferrer" class="utci-subtitle-link">Universal Thermal Climate Index</a>
|
||
· Bröde 2012 · Open-Meteo · SunScope soak-factor
|
||
</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()}
|
||
/>
|
||
</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' });
|
||
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,
|
||
}}>
|
||
🔒 ${proPromptSource === 'custom'
|
||
? 'Custom columns are part of SunScope Extra'
|
||
: `${dayName}'s forecast is part of SunScope Extra`}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontSize: '13.5px',
|
||
color: '#4a3420',
|
||
lineHeight: 1.65,
|
||
}}>
|
||
${proPromptSource === 'custom'
|
||
? 'Pro lets you choose exactly which columns appear — mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.'
|
||
: 'Pro unlocks the full 14-day forecast, customisable columns, and an ad-free view.'}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.14em',
|
||
color: '#c8922a',
|
||
marginTop: '10px',
|
||
}}>
|
||
£2 / month · launching soon
|
||
</div>
|
||
</div>
|
||
<div style=${{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<a
|
||
href=${`mailto:fraxle@yahoo.co.uk?subject=${encodeURIComponent('SunScope Extra — notify me at launch')}&body=${encodeURIComponent('Hi — please let me know when SunScope Extra launches. (Triggered by ' + dayLong + ')')}`}
|
||
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',
|
||
}}
|
||
>
|
||
Notify me at launch
|
||
</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.
|
||
Custom is Pro-only: free users see it locked with a 🔒
|
||
and clicking it 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 isCustom = key === 'custom';
|
||
const locked = isCustom && !isPro;
|
||
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 ? '🔒 Custom columns are part of SunScope Extra' : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource('custom');
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
setActiveProfile(key);
|
||
if (key !== 'custom') setVisibleCols({ ...profile.cols });
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
</div>
|
||
|
||
<!--
|
||
COLUMN TOGGLES — Pro only. Hidden entirely for free users
|
||
(the profile selector above provides enough control for them).
|
||
-->
|
||
${isPro && html`
|
||
<div class="col-toggles">
|
||
<span class="col-toggles-label">Columns:</span>
|
||
${[
|
||
// Hour and UTCI+P are always-on — no toggle button for them.
|
||
{ key: 'air', label: 'Air' },
|
||
{ key: 'rh', label: 'RH' },
|
||
{ key: 'dew', label: 'Dew' },
|
||
{ key: 'soilT', label: 'Soil °C' },
|
||
{ key: 'soilT6', label: 'Soil 6cm' },
|
||
{ key: 'soilM', label: 'Soil moist' },
|
||
{ key: 'wind', label: 'Wind' },
|
||
{ key: 'dir', label: 'Dir' },
|
||
{ key: 'cloud', label: 'Cloud' },
|
||
{ key: 'sun', label: 'Sun' },
|
||
{ key: 'direct', label: 'Direct' },
|
||
{ key: 'diffuse', label: 'Diffuse' },
|
||
{ key: 'tmrt', label: 'Tmrt' },
|
||
{ key: 'delta', label: 'Δ' },
|
||
{ key: 'utci', label: 'UTCI' },
|
||
{ key: 'uvA', label: 'UV-A' },
|
||
{ key: 'uvB', label: 'UV-B' },
|
||
].filter(c =>
|
||
activeProfile === 'custom' || !!FILTER_PROFILES[activeProfile].cols[c.key]
|
||
).map(c => html`
|
||
<button
|
||
key=${c.key}
|
||
class=${`col-toggle${visibleCols[c.key] ? ' on' : ''}`}
|
||
onClick=${() => toggleCol(c.key)}
|
||
>${c.label}</button>`)}
|
||
|
||
${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['burn']) && html`
|
||
<select
|
||
class=${`col-toggle burn-select${visibleCols.burn ? ' on' : ''}`}
|
||
value=${visibleCols.burn ? skinType : 'off'}
|
||
onChange=${(e) => {
|
||
if (e.target.value === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, burn: false }));
|
||
} else {
|
||
setSkinType(e.target.value);
|
||
setVisibleCols(prev => ({ ...prev, burn: true }));
|
||
}
|
||
}}>
|
||
<option value="off">${visibleCols.burn ? 'Hide Burn' : 'Burn'}</option>
|
||
${Object.entries(SKIN_TYPES).map(([k, v]) => html`
|
||
<option key=${k} value=${k}>${v.name.split(' · ')[1]} skin</option>`)}
|
||
</select>`}
|
||
|
||
${(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">
|
||
<!-- 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>Hour</th>
|
||
${visibleCols.air && html`<th class="utci-tight-head">Air <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.rh && html`<th>RH <span class="col-unit">%</span></th>`}
|
||
${visibleCols.dew && html`<th>Dew <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.soilT && html`<th>Soil °C <span class="col-unit">surface</span></th>`}
|
||
${visibleCols.soilT6 && html`<th>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
||
${visibleCols.soilM && html`<th>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||
${visibleCols.wind && html`<th>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||
${visibleCols.dir && html`<th class="utci-dir-cell">Dir <span class="col-unit">-</span></th>`}
|
||
${visibleCols.cloud && html`<th>Cloud <span class="col-unit">%</span></th>`}
|
||
${visibleCols.sun && html`<th>Sun <span class="col-unit">elev°</span></th>`}
|
||
${visibleCols.direct && html`<th>Direct <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.diffuse && html`<th>Diffuse <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.tmrt && html`<th>Tmrt <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.delta && html`<th>Δ <span class="col-unit">UTCI−Air</span></th>`}
|
||
${visibleCols.utci && html`<th>UTCI <span class="col-unit">°C felt</span></th>`}
|
||
${visibleCols.uvA && html`<th>UV-A <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.uvB && html`<th>UV-B <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.burn && html`<th>Burn <span class="col-unit">to MED</span></th>`}
|
||
${visibleCols.precip && html`<th class="utci-tight-head">Pcpt <span class="col-unit">mm/h</span></th>`}
|
||
<th>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);
|
||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||
const localHHMM = r.iso.slice(11, 16);
|
||
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} size=${30} />
|
||
<span>${localHHMM}</span>
|
||
</span>
|
||
</td>
|
||
${visibleCols.air && html`<td>${r.Ta.toFixed(1)}</td>`}
|
||
${visibleCols.rh && html`<td>${Math.round(r.RH)}</td>`}
|
||
${visibleCols.dew && html`<td>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT && html`
|
||
<td style=${{ color: '#8a6a3a' }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT6 && html`
|
||
<td style=${{ color: '#8a6a3a' }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilM && html`
|
||
<td style=${{ color: '#5090b0' }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||
${visibleCols.wind && html`<td>
|
||
${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>
|
||
<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>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.direct && html`<td>${Math.round(r.dir)}</td>`}
|
||
${visibleCols.diffuse && html`<td>${Math.round(r.dif)}</td>`}
|
||
${visibleCols.tmrt && html`<td>${r.Tmrt.toFixed(1)}</td>`}
|
||
${visibleCols.delta && html`
|
||
<td style=${{
|
||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#9a7d5a',
|
||
fontWeight: 600,
|
||
}}>
|
||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||
</td>`}
|
||
${visibleCols.utci && html`
|
||
<td>
|
||
<span class="utci-cell" style=${{ background: cat.bg, color: cat.fg }}>
|
||
${r.utci.toFixed(1)}
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.uvA && html`
|
||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#5a4228' }}>
|
||
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.uvB && html`
|
||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#5a4228', fontWeight: 600 }}>
|
||
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||
</td>`}
|
||
${visibleCols.burn && html`
|
||
<td style=${{ color: r.uv > 0 ? (sunburnMinutes(r.uv, skinType) < 30 ? '#c44a3a' : '#c8601a') : '#5a4228' }}>
|
||
${burnLabel(sunburnMinutes(r.uv, skinType))}
|
||
</td>`}
|
||
${visibleCols.precip && html`
|
||
<td>
|
||
<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 ? '#6090c8' : r.precip > 0 ? '#5090b0' : '#c0a880' }}>
|
||
${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||
</span>
|
||
</span>
|
||
</td>`}
|
||
<td style=${{ background: 'rgba(180,215,250,0.10)' }}>
|
||
<span class="utci-cell utci-cell-hero" style=${{ background: adjCat.bg, color: adjCat.fg }}>
|
||
${r.utciAdj.toFixed(1)}
|
||
</span>
|
||
</td>
|
||
</tr>`;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</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: '#6ab05a', 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 shows how the weather will actually <em>feel</em> on your body — not just the air
|
||
temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>, a
|
||
peer-reviewed biometeorological standard developed by Bröde et al. (2012) that combines
|
||
air temperature, humidity, wind speed, and solar radiation into a single <em>felt
|
||
temperature</em>. On a calm, sunny winter day UTCI can read several degrees warmer than
|
||
the thermometer; on a grey, blustery day it can read far colder. Forecast data is
|
||
sourced in real time from <strong>Open-Meteo</strong>, a free and open-source weather API,
|
||
and solar radiation is used to calculate Mean Radiant Temperature — the heat your skin
|
||
absorbs from the sun — making SunScope one of the most complete outdoor comfort forecasts
|
||
available for free.
|
||
</p>
|
||
<p class="utci-about-text">
|
||
The <strong>UTCI+P</strong> column adds the <strong>SunScope soak-factor</strong>: an
|
||
original precipitation penalty that accounts for the extra chill of rain and snow on
|
||
exposed skin and wet clothing. Light drizzle reduces the felt temperature by around
|
||
1–2 °C; heavy rain combined with wind can push it down by 7–8 °C. Snow carries an
|
||
additional penalty on top. The result is an honest, real-world comfort score for any
|
||
location worldwide — simply search for your town or city and compare the hourly
|
||
forecast across the next 3 days (and up to 14 days with SunScope Extra).
|
||
</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.
|
||
</div>
|
||
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|