Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels. Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab. Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest. Anchors on the reference page — 35 row ids in columns.html, one per column key. New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added. Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
2085 lines
129 KiB
JavaScript
2085 lines
129 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, useRef, useEffect } from '../vendor/preact-hooks.js';
|
||
import htm from '../vendor/htm.js';
|
||
import {
|
||
utciCategory, UTCI_BANDS,
|
||
petCategory,
|
||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||
VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES,
|
||
FUR_COLORS,
|
||
confidenceBand, moonGlyph, skyFillForElev,
|
||
} from './utils.js';
|
||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js';
|
||
import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js';
|
||
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS, FILTER_PROFILES, variantIcons, deriveProfileMain } from './config.js';
|
||
import { useAppState } from './hooks/useAppState.js';
|
||
import { DayTabs } from './components/DayTabs.js';
|
||
import { ConfigPanel } from './components/ConfigPanel.js';
|
||
import { WelcomeModal } from './components/WelcomeModal.js';
|
||
import { RestoreModal } from './components/RestoreModal.js';
|
||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
|
||
import { solarElevationDeg } from './physics.js';
|
||
import { exportDayXls } from './export.js';
|
||
|
||
const html = htm.bind(h);
|
||
|
||
// ── Scope time-lapse playback ───────────────────────────────────────────
|
||
// Drives the big scope through the next 24h so you can watch the sky/ground
|
||
// gradients and weather evolve. Speed: 15 simulated minutes every 1 real
|
||
// second (≈ a full day in ~1.6 minutes). UI advances smoothly every 200ms.
|
||
const PLAYBACK_SIM_MS_PER_REAL_MS = 900; // 15 min / 1 s = 900×
|
||
const PLAYBACK_STEP_MS = 200; // UI update cadence
|
||
const PLAYBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||
|
||
// Interpolates the hourly rows to an arbitrary instant (ms). Returns a row
|
||
// with smoothly blended elevation / cloud / precip so the scope animates
|
||
// continuously rather than snapping hour to hour.
|
||
function interpolateRowAt(rows, t) {
|
||
if (!rows || rows.length === 0) return null;
|
||
if (t <= +rows[0].dt) return rows[0];
|
||
if (t >= +rows[rows.length - 1].dt) return rows[rows.length - 1];
|
||
let lo = rows[0], hi = rows[1];
|
||
for (let i = 1; i < rows.length; i++) {
|
||
if (+rows[i].dt >= t) { lo = rows[i - 1]; hi = rows[i]; break; }
|
||
}
|
||
const f = (t - +lo.dt) / (+hi.dt - +lo.dt);
|
||
const L = (a, b) => (a == null || b == null) ? (a ?? b) : a + (b - a) * f;
|
||
return {
|
||
...lo,
|
||
dt: new Date(t),
|
||
elev: L(lo.elev, hi.elev),
|
||
glob: L(lo.glob, hi.glob),
|
||
utciAdj: L(lo.utciAdj, hi.utciAdj),
|
||
// Felt-temp inputs — interpolated so the "Why It Feels" panel breakdown
|
||
// stays smooth and consistent with the (interpolated) utciAdj category.
|
||
Ta: L(lo.Ta, hi.Ta),
|
||
Tmrt: L(lo.Tmrt, hi.Tmrt),
|
||
va: L(lo.va, hi.va),
|
||
eh: L(lo.eh, hi.eh),
|
||
cc: L(lo.cc, hi.cc),
|
||
ccLow: L(lo.ccLow, hi.ccLow),
|
||
ccMid: L(lo.ccMid, hi.ccMid),
|
||
ccHigh: L(lo.ccHigh, hi.ccHigh),
|
||
precip: L(lo.precip, hi.precip),
|
||
snow: L(lo.snow, hi.snow),
|
||
visKm: L(lo.visKm, hi.visKm),
|
||
};
|
||
}
|
||
|
||
// Turn a raw fetch failure into something a person can act on. The banner only
|
||
// ever appears when nothing at all has loaded (see the loadForecast waterfall
|
||
// in hooks/useForecast.js), so "showing your last saved forecast" is never the
|
||
// right thing to say here - there isn't one.
|
||
function friendlyError(msg) {
|
||
const m = String(msg || '');
|
||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||
return "You're offline. Reconnect and try again.";
|
||
}
|
||
if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
|
||
return "Couldn't reach the weather service. Check your connection and try again.";
|
||
}
|
||
const status = m.match(/HTTP (\d{3})/);
|
||
if (status) {
|
||
const code = Number(status[1]);
|
||
if (code === 429) return 'Too many requests just now. Give it a minute and try again.';
|
||
if (code >= 500) return "The weather service isn't responding. Try again in a moment.";
|
||
return `The weather service rejected the request (error ${code}).`;
|
||
}
|
||
return m;
|
||
}
|
||
|
||
// ── Draggable 24-hour timeline ──────────────────────────────────────────
|
||
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
|
||
const trackRef = useRef(null);
|
||
const draggingRef = useRef(false);
|
||
const totalMs = windowEnd - windowStart;
|
||
const toFrac = (ms) => Math.max(0, Math.min(1, (ms - windowStart) / totalMs));
|
||
const fraction = simMs != null ? toFrac(simMs) : null;
|
||
|
||
// Find sunrise/sunset within the window by scanning hourly elevation sign changes
|
||
let sunriseMs = null, sunsetMs = null;
|
||
const winRows = hourlyRows.filter(r => +r.dt >= windowStart - 3600000 && +r.dt <= windowEnd + 3600000);
|
||
for (let i = 1; i < winRows.length; i++) {
|
||
const a = winRows[i - 1], b = winRows[i];
|
||
if (a.elev <= 0 && b.elev > 0 && sunriseMs === null) {
|
||
const f = (-a.elev) / (b.elev - a.elev);
|
||
const t = +a.dt + f * (+b.dt - +a.dt);
|
||
if (t >= windowStart && t <= windowEnd) sunriseMs = t;
|
||
}
|
||
if (a.elev > 0 && b.elev <= 0 && sunsetMs === null) {
|
||
const f = a.elev / (a.elev - b.elev);
|
||
const t = +a.dt + f * (+b.dt - +a.dt);
|
||
if (t >= windowStart && t <= windowEnd) sunsetMs = t;
|
||
}
|
||
}
|
||
const srFrac = sunriseMs != null ? toFrac(sunriseMs) : null;
|
||
const ssFrac = sunsetMs != null ? toFrac(sunsetMs) : null;
|
||
|
||
// Build gradient from actual per-hour sky colours so the bar mirrors what
|
||
// the scope would show at each moment across the window.
|
||
const bgGradient = (() => {
|
||
if (!winRows.length) return '#0a0810';
|
||
const stops = winRows.map(r => {
|
||
const pct = (toFrac(+r.dt) * 100).toFixed(2);
|
||
const col = skyFillForElev(r.elev, r.dt.getUTCHours() < 12);
|
||
return `${col} ${pct}%`;
|
||
});
|
||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||
})();
|
||
|
||
// Tick labels at 0h / 6h / 12h / 18h / 24h of the window (actual local clock times)
|
||
const ticks = [0, 6, 12, 18, 24].map(h => {
|
||
const d = new Date(windowStart + h * 3600000 + utcOffsetMs);
|
||
return d.toISOString().slice(11, 16);
|
||
});
|
||
|
||
const msFromClientX = (clientX) => {
|
||
const rect = trackRef.current.getBoundingClientRect();
|
||
return windowStart + Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * totalMs;
|
||
};
|
||
const onPointerDown = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
try { trackRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||
draggingRef.current = true;
|
||
document.body.classList.add('tl-scrubbing');
|
||
setSimMs(msFromClientX(e.clientX));
|
||
};
|
||
const onPointerMove = (e) => {
|
||
if (!draggingRef.current) return;
|
||
setSimMs(msFromClientX(e.clientX));
|
||
};
|
||
const onPointerUp = () => {
|
||
draggingRef.current = false;
|
||
document.body.classList.remove('tl-scrubbing');
|
||
};
|
||
const onPointerCancel = onPointerUp;
|
||
|
||
return html`
|
||
<div class="day-timeline">
|
||
<div class="day-timeline-track"
|
||
ref=${trackRef}
|
||
style=${{ background: bgGradient }}
|
||
onPointerDown=${onPointerDown}
|
||
onPointerMove=${onPointerMove}
|
||
onPointerUp=${onPointerUp}
|
||
onPointerCancel=${onPointerCancel}>
|
||
${srFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${srFrac * 100}%` }}>↑</span>`}
|
||
${ssFrac != null && html`<span class="day-timeline-sun-marker" style=${{ left: `${ssFrac * 100}%` }}>↓</span>`}
|
||
${fraction != null && html`<div class="day-timeline-thumb" style=${{ left: `${fraction * 100}%` }}></div>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export function UTCIForecast() {
|
||
|
||
// ── STATE + EFFECTS ───────────────────────────────────────────────────
|
||
// All useState, useEffect, useCallback and useRef logic lives in
|
||
// useAppState. See hooks/useAppState.js for the full reading order.
|
||
const {
|
||
location, setLocationAndSave, recentLocations,
|
||
useMyLocation, locating, locateError, setLocateError,
|
||
shareForecast, shareState,
|
||
forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
|
||
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
|
||
selectedDay, setSelectedDay,
|
||
proPromptDay, setProPromptDay,
|
||
proPromptSource, setProPromptSource,
|
||
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
|
||
isPro, setIsPro,
|
||
activeProfile, setActiveProfile, activateProfile, activeCols,
|
||
visibleCols, setVisibleCols, toggleCol,
|
||
showDecimals, toggleShowDecimals,
|
||
welcomeOpen, closeWelcome, openWelcome,
|
||
restoreOpen, openRestore, closeRestore,
|
||
panelOpen, openPanel, closePanel,
|
||
showUnits, toggleShowUnits,
|
||
tableInterval, setTableInterval,
|
||
forecastView, setForecastView,
|
||
activityOptions, placeOptions, workOptions,
|
||
activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel,
|
||
skinType, setSkinType,
|
||
vehicleType, setVehicleType,
|
||
vehicleVent, setVehicleVent,
|
||
vehicleSpeed, setVehicleSpeed,
|
||
outdoorsVariant, setOutdoorsVariantAndSave,
|
||
buildingType, setBuildingType,
|
||
furColor, setFurColor,
|
||
indoorManaged, setIndoorManaged,
|
||
indoorMode, setIndoorMode,
|
||
pollenType, setPollenTypeAndSave,
|
||
utciEnv, setUtciEnv,
|
||
headStickyRef, headTrackRef, headTableRef,
|
||
bodyScrollRef, bodyTableRef, tableWrapRef,
|
||
colPopup, colPopupRef,
|
||
handleThClick, handleThEnter, handleThLeave,
|
||
handlePopupEnter, handlePopupLeave,
|
||
eventTagPopup, eventTagPopupRef,
|
||
evSlideIndex, evTransition, evSlideTo,
|
||
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
|
||
handleEventTagPopupEnter, handleEventTagPopupLeave,
|
||
closePopup, closeEventTagPopup,
|
||
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
||
hourlyRows, days, utcOffsetMs,
|
||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||
liveElev,
|
||
activeEvents, lensEvent, selectedDayEvents,
|
||
} = useAppState();
|
||
|
||
// Search is hidden by default and revealed via the magnifier next to the
|
||
// location. The input then overlays the location line to save space.
|
||
const [searchOpen, setSearchOpen] = useState(false);
|
||
// While closing, keep the overlay mounted so it can fade/close out before
|
||
// unmounting; cleared when the exit animation ends.
|
||
const [searchClosing, setSearchClosing] = useState(false);
|
||
const openSearch = () => { setSearchClosing(false); setSearchOpen(true); };
|
||
const closeSearch = () => { setSearchOpen(false); setSearchClosing(true); };
|
||
const searchWrapRef = useRef(null);
|
||
|
||
// ── Event note ───────────────────────────────────────────────────────
|
||
// A content-width strip above the nav showing one active event at a time
|
||
// with its full message, cycling through them with a fade. In flow, so it
|
||
// pushes the page down rather than covering anything (see .event-note).
|
||
const [noteIndex, setNoteIndex] = useState(0);
|
||
const [noteFading, setNoteFading] = useState(false);
|
||
const NOTE_MS = 8000;
|
||
const NOTE_FADE_MS = 350;
|
||
|
||
const noteGoTo = (next) => {
|
||
if (next === noteIndex) return;
|
||
setNoteFading(true);
|
||
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
|
||
};
|
||
|
||
// Keep the index in range if the event list shrinks between forecasts.
|
||
useEffect(() => {
|
||
if (noteIndex >= activeEvents.length) setNoteIndex(0);
|
||
}, [activeEvents.length]);
|
||
|
||
useEffect(() => {
|
||
if (activeEvents.length <= 1) return;
|
||
const id = setInterval(() => {
|
||
setNoteFading(true);
|
||
setTimeout(() => {
|
||
setNoteIndex(i => (i + 1) % activeEvents.length);
|
||
setNoteFading(false);
|
||
}, NOTE_FADE_MS);
|
||
}, NOTE_MS);
|
||
return () => clearInterval(id);
|
||
}, [activeEvents.length]);
|
||
|
||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||
// Which column pills are offered in the Columns bar. The bar as a whole is
|
||
// Extra-only (see the isPro gate around col-toggles-body), so this just asks
|
||
// "is this column part of the current profile?" — Custom and Show All offer
|
||
// the lot, every other profile offers what it actually uses.
|
||
const colOffered = (key) =>
|
||
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
|
||
const searchInputRef = useRef(null);
|
||
const fscScrollRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
const el = fscScrollRef.current;
|
||
if (!el) return;
|
||
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
|
||
const onMouseDown = (e) => {
|
||
if (!el.contains(e.target) || e.button !== 0) return;
|
||
isDown = true; hasDragged = false;
|
||
startX = e.clientX; startScroll = el.scrollLeft;
|
||
document.body.style.userSelect = 'none';
|
||
document.body.style.webkitUserSelect = 'none';
|
||
};
|
||
const onMouseMove = (e) => {
|
||
if (!isDown) return;
|
||
const dx = e.clientX - startX;
|
||
if (Math.abs(dx) > 5) {
|
||
hasDragged = true;
|
||
el.style.cursor = 'grabbing';
|
||
el.scrollLeft = startScroll - dx;
|
||
}
|
||
};
|
||
const onMouseUp = () => {
|
||
if (!isDown) return;
|
||
isDown = false;
|
||
el.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
document.body.style.webkitUserSelect = '';
|
||
};
|
||
const onClickCapture = (e) => {
|
||
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
|
||
};
|
||
document.addEventListener('mousedown', onMouseDown);
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
el.addEventListener('click', onClickCapture, true);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onMouseDown);
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
el.removeEventListener('click', onClickCapture, true);
|
||
};
|
||
}, []);
|
||
|
||
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
|
||
useEffect(() => {
|
||
if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
|
||
else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
|
||
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
|
||
else setSimpleTemp('utciAdj');
|
||
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
|
||
setForecastView('table');
|
||
}
|
||
}, [activeProfile, outdoorsVariant]);
|
||
|
||
// Focus the input the moment the search opens.
|
||
useEffect(() => {
|
||
if (searchOpen) searchInputRef.current?.focus();
|
||
}, [searchOpen]);
|
||
|
||
// Close the search on outside-click or Escape, clearing any stray query.
|
||
useEffect(() => {
|
||
if (!searchOpen) return;
|
||
const onDown = (e) => {
|
||
if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) {
|
||
closeSearch();
|
||
}
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') { closeSearch(); }
|
||
};
|
||
document.addEventListener('mousedown', onDown);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDown);
|
||
document.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [searchOpen, setSearchQuery]);
|
||
|
||
// ─── SCOPE TIME-LAPSE ──────────────────────────────────────────────────
|
||
// `playing` toggles the time-lapse; `simMs` is the simulated instant shown.
|
||
const [playing, setPlaying] = useState(false);
|
||
const [simMs, setSimMs] = useState(null);
|
||
// Reset scope to live when the user switches days (scope window is always
|
||
// "now → +24h" regardless of selected day, so a stale scrub position is confusing).
|
||
useEffect(() => { if (!playing) setSimMs(null); }, [selectedDay]);
|
||
useEffect(() => {
|
||
if (!playing) return;
|
||
const start = now.getTime();
|
||
const end = start + PLAYBACK_WINDOW_MS;
|
||
setSimMs(prev => (prev == null || prev < start || prev > end) ? start : prev);
|
||
const id = setInterval(() => {
|
||
setSimMs(prev => {
|
||
let next = (prev == null ? start : prev) + PLAYBACK_STEP_MS * PLAYBACK_SIM_MS_PER_REAL_MS;
|
||
if (next > end) next = start; // loop back to "now"
|
||
return next;
|
||
});
|
||
}, PLAYBACK_STEP_MS);
|
||
return () => clearInterval(id);
|
||
}, [playing]);
|
||
|
||
// Columns that mark the start of a logical group - used to draw a faint
|
||
// vertical border separating groups in the forecast table.
|
||
const GROUP_ORDER = {
|
||
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'],
|
||
surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'],
|
||
ambient: ['petShadeT', 'petHomeT', 'air', 'rh', 'dew'],
|
||
precip: ['precip', 'precipProb', 'lightning'],
|
||
sky: ['cloud', 'vis'],
|
||
wind: ['wind', 'dir'],
|
||
airqual: ['aqi', 'pollen'],
|
||
solar: ['uvA', 'uvB', 'sun', 'direct', 'diffuse'],
|
||
};
|
||
const GROUP_OF = Object.fromEntries(
|
||
Object.entries(GROUP_ORDER).flatMap(([g, keys]) => keys.map(k => [k, g]))
|
||
);
|
||
// Returns col-group-start when this column is the leftmost VISIBLE member
|
||
// of its group. If the canonical first member is hidden the border migrates
|
||
// to the next visible column in the same group.
|
||
const isColVisible = (k) => {
|
||
if (k === 'indoorT') return indoorMode === 'on' && !indoorManaged;
|
||
if (k === 'managedT') return indoorMode === 'on' && indoorManaged;
|
||
return !!visibleCols[k];
|
||
};
|
||
const groupStart = (key) => {
|
||
const group = GROUP_OF[key];
|
||
if (!group) return '';
|
||
const first = GROUP_ORDER[group].find(isColVisible);
|
||
return first === key ? 'col-group-start' : '';
|
||
};
|
||
// Returns a CSS class encoding the group name - used to tint header cells
|
||
// and group label spans.
|
||
const groupColor = (key) => {
|
||
const g = GROUP_OF[key];
|
||
return g ? `grp-${g}` : '';
|
||
};
|
||
// Builds the group label row above the column headers.
|
||
// Each visible group gets one spanning cell; groups with no visible
|
||
// columns are skipped entirely. Hour always gets a blank lead cell.
|
||
const groupLabelRow = () => {
|
||
const groups = Object.keys(GROUP_ORDER);
|
||
const cells = [html`<th class="grp-label-hour" scope="col"><span class="grp-label-date">${glanceDate}</span></th>`];
|
||
for (const g of groups) {
|
||
const span = GROUP_ORDER[g].filter(isColVisible).length;
|
||
if (span === 0) continue;
|
||
const labels = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
|
||
cells.push(html`<th class=${`grp-label grp-label-${g} grp-${g}`} colspan=${span} scope="colgroup">${labels[g]}</th>`);
|
||
}
|
||
return html`<tr class="grp-label-row">${cells}</tr>`;
|
||
};
|
||
|
||
// Continuous temperature colour scale - hoisted so both the table columns
|
||
// and the thermal stress legend can share the same function.
|
||
const TEMP_STOPS = [
|
||
[-10, [ 90, 155, 220]],
|
||
[ 0, [140, 195, 235]],
|
||
[ 10, [155, 215, 195]],
|
||
[ 16, [140, 210, 140]],
|
||
[ 20, [195, 225, 110]],
|
||
[ 24, [240, 225, 80]],
|
||
[ 28, [250, 175, 65]],
|
||
[ 32, [240, 120, 55]],
|
||
[ 36, [220, 70, 50]],
|
||
[ 40, [185, 30, 30]],
|
||
[ 50, [130, 0, 20]],
|
||
];
|
||
const airTempRgb = (t, whiteMix = 0.58) => {
|
||
if (t == null) return null;
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
let i = TEMP_STOPS.findIndex(s => t < s[0]);
|
||
if (i === -1) i = TEMP_STOPS.length;
|
||
const raw = i === 0 ? TEMP_STOPS[0][1]
|
||
: i >= TEMP_STOPS.length ? TEMP_STOPS[TEMP_STOPS.length-1][1]
|
||
: (() => {
|
||
const [t0, c0] = TEMP_STOPS[i-1];
|
||
const [t1, c1] = TEMP_STOPS[i];
|
||
const x = clamp((t - t0) / (t1 - t0), 0, 1);
|
||
const lerp = (a, b) => Math.round(a + (b - a) * x);
|
||
return [lerp(c0[0],c1[0]), lerp(c0[1],c1[1]), lerp(c0[2],c1[2])];
|
||
})();
|
||
return raw.map(c => Math.min(255, Math.round(c + (255 - c) * whiteMix)));
|
||
};
|
||
|
||
// Pet columns (Fur Colour, Pet Shade, Pet Home, Paw) reuse airTempRgb
|
||
// exactly as-is - identical stops, identical whiteMix blend, identical
|
||
// per-row top/bottom cell blending (petAirTempBg mirrors airTempBg
|
||
// below). The only thing that differs is which temperature gets handed
|
||
// to it: petEquivHumanTemp() remaps a pet reading to "the human felt-temp
|
||
// this severity is equivalent to" first, using the exact same anchor
|
||
// pairs PET_BANDS was calibrated against (same tier, same ordinal
|
||
// position in UTCI_BANDS vs PET_BANDS - see utils.js). So a -2 -C pet
|
||
// reading (mild "Cold", not "Freezing") gets looked up as if it were a
|
||
// few degrees warmer on the human scale, and a 46 -C paw reading (mid
|
||
// "Extreme", not "Danger") looks up around human "Extreme" too - never a
|
||
// different colour-computation, just a different input to the same one.
|
||
const PET_TO_HUMAN_TEMP = [
|
||
[-28, -20], [-18, -10], [-8, 0], [-3, 5], [2, 10], [7, 15], [11, 19],
|
||
[25, 24], [32, 27], [40, 32], [52, 41],
|
||
];
|
||
const petEquivHumanTemp = (t) => {
|
||
if (t == null) return null;
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const pts = PET_TO_HUMAN_TEMP;
|
||
const slopeBetween = (a, b) => (b[1] - a[1]) / (b[0] - a[0]);
|
||
if (t <= pts[0][0]) {
|
||
const slope = slopeBetween(pts[0], pts[1]);
|
||
return pts[0][1] + (t - pts[0][0]) * slope;
|
||
}
|
||
if (t >= pts[pts.length - 1][0]) {
|
||
const last = pts[pts.length - 1], prev = pts[pts.length - 2];
|
||
const slope = slopeBetween(prev, last);
|
||
return last[1] + (t - last[0]) * slope;
|
||
}
|
||
for (let i = 1; i < pts.length; i++) {
|
||
const [p0, h0] = pts[i - 1], [p1, h1] = pts[i];
|
||
if (t <= p1) {
|
||
const x = clamp((t - p0) / (p1 - p0), 0, 1);
|
||
return h0 + (h1 - h0) * x;
|
||
}
|
||
}
|
||
};
|
||
const petAirTempRgb = (t, whiteMix = 0.58) => airTempRgb(petEquivHumanTemp(t), whiteMix);
|
||
|
||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||
// whyFeelsLike is derived below, after the playback rows are resolved, so
|
||
// the panel can track the simulated instant during play/scrub (see panelRow).
|
||
|
||
// Interpolated row at the precise current instant — used for the scope
|
||
// display even outside playback so there's no jump when play is pressed.
|
||
const nowRow = interpolateRowAt(hourlyRows, now.getTime());
|
||
// 24-hour window anchored to now — shared by the play effect and the timeline.
|
||
const windowStart = now.getTime();
|
||
const windowEnd = windowStart + PLAYBACK_WINDOW_MS;
|
||
// simMs drives the scope whether set by auto-play or by dragging the timeline.
|
||
const simRow = simMs != null ? interpolateRowAt(hourlyRows, simMs) : null;
|
||
const scopeRow = simRow || nowRow || currentRow;
|
||
// Elevation computed continuously from the simulated instant so the sun
|
||
// starts exactly where liveElev left off (same solarElevationDeg call).
|
||
const scopeElev = (simMs != null && location?.lat != null)
|
||
? solarElevationDeg(location.lat, location.lon, new Date(simMs))
|
||
: (liveElev ?? currentRow?.elev ?? 0);
|
||
const scopeDt = simRow ? simRow.dt : now;
|
||
const scopeCat = scopeRow ? utciCategory(scopeRow.utciAdj) : currentCat;
|
||
// "Why It Feels" panel always mirrors the scope dial: the simulated instant
|
||
// during play/scrub, and the interpolated "now" row (scopeRow → nowRow) when
|
||
// stopped. Sharing scopeRow/scopeCat guarantees the panel's thermal label can
|
||
// never disagree with the dial's reticle.
|
||
const panelRow = scopeRow;
|
||
const panelCat = scopeCat;
|
||
const whyFeelsLike = computeWhyFeelsLike(panelRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
|
||
// Synthesize a storm overlay (lightning) when the simulated hour is wet;
|
||
// outside playback keep the real active event.
|
||
const scopeEvent = simRow ? ((simRow.precip ?? 0) >= 4 ? { id: 'storm' } : null) : lensEvent;
|
||
// Local clock label for the playback button / timeline. The window is a ROLLING
|
||
// 24h from now so it may cross midnight — prefix weekday ("Wed 14:30").
|
||
const simClock = (simMs != null && scopeDt)
|
||
? (() => {
|
||
const d = new Date(scopeDt.getTime() + utcOffsetMs);
|
||
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||
return `${wd} ${d.toISOString().slice(11, 16)}`;
|
||
})()
|
||
: null;
|
||
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
|
||
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
|
||
// CAMS air quality runs out well before the 14-day forecast does, so the AQI
|
||
// and Pollen columns hit a wall partway along the day tabs. Without saying so
|
||
// the empty cells read as a bug, which is worse than the missing data.
|
||
const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon);
|
||
const aqBeyondNote = aqBeyond
|
||
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
|
||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
|
||
: null;
|
||
const glanceSummary = computeGlanceSummary(
|
||
days[selectedDay]?.rows ?? [],
|
||
activeProfile,
|
||
outdoorsVariant,
|
||
skinType,
|
||
visibleCols,
|
||
vehicleSpeed,
|
||
// Farming uses these for seasonal sow/harvest advice (week's dates + weather).
|
||
activeProfile === 'farming' ? days : null,
|
||
location?.lat,
|
||
// Climate normals + selected day's date drive the "vs seasonal average" row.
|
||
normals,
|
||
days[selectedDay]?.key,
|
||
);
|
||
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
|
||
// rendered in its own "The Week Ahead" box beneath it.
|
||
const weekAhead = computeBestDay(days, activeProfile, outdoorsVariant, nowLocalISO);
|
||
|
||
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||
const glanceDate = (() => {
|
||
const key = days[selectedDay]?.key;
|
||
if (!key) return '';
|
||
const d = new Date(key + 'T00:00Z');
|
||
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||
const day = d.getUTCDate();
|
||
const mon = d.toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' });
|
||
const yr = d.getUTCFullYear();
|
||
const ord = (n => { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; })(day);
|
||
return `${wd} ${day}${ord} ${mon} ${yr}`;
|
||
})();
|
||
|
||
// Pro: export the selected day's full hourly data as a styled spreadsheet.
|
||
const handleExportDay = () => {
|
||
exportDayXls(visible, {
|
||
locationName: location?.name ?? 'Unknown',
|
||
dateLabel: glanceDate,
|
||
skinType,
|
||
});
|
||
};
|
||
|
||
// Day's events surfaced in the "Day at a glance" box. Reuses the same
|
||
// row shape as glanceSummary items: { icon, label, value, alert }.
|
||
const hhmm = (iso) => {
|
||
if (!iso) return '';
|
||
const h = parseInt(iso.slice(11, 13), 10);
|
||
const m = iso.slice(14, 16);
|
||
const period = h < 12 ? 'am' : 'pm';
|
||
const h12 = h % 12 || 12;
|
||
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
|
||
};
|
||
const hhmmEnd = (iso) => {
|
||
if (!iso) return '';
|
||
const h = parseInt(iso.slice(11, 13), 10) + 1;
|
||
const m = iso.slice(14, 16);
|
||
const period = (h % 24) < 12 ? 'am' : 'pm';
|
||
const h12 = h % 12 || 12;
|
||
return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`;
|
||
};
|
||
const eventGlanceItems = (selectedDayEvents ?? [])
|
||
.filter(ev => ev.type !== 'promo')
|
||
.map(ev => ({
|
||
icon: ev.emoji,
|
||
label: ev.title,
|
||
value: ev.isoRange
|
||
? (ev.isoRange[0] === ev.isoRange[1]
|
||
? hhmm(ev.isoRange[0])
|
||
: `${hhmm(ev.isoRange[0])} – ${hhmmEnd(ev.isoRange[1])}`)
|
||
: (ev.nightOnly ? 'Overnight' : 'All day'),
|
||
alert: false,
|
||
}));
|
||
|
||
// ─── 3b. QUICK-VIEW PLOT GEOMETRY ────────────────────────────────────
|
||
// Shared by the simple-view cards and the temperature curve beneath them
|
||
// so the two can never drift apart.
|
||
//
|
||
// The curve is drawn from the full-resolution hourly rows (all 24 hours,
|
||
// never resampled), but each card covers a bucket of `tableInterval`
|
||
// hours and is LABELLED with that bucket's LANDING hour — a 4h card
|
||
// reading "8pm" spans 8–11pm and prints the worst felt temp in that span,
|
||
// which on a cooling evening is 8pm's own value. So a card must sit over
|
||
// its landing hour, not over the bucket's temporal middle (which is where
|
||
// an evenly-divided row of cards puts it: at 4h the "8pm / 28°" card
|
||
// landed above 9:30pm, past sunset, and pointed into the cold green tail).
|
||
//
|
||
// Laying the row out in hour columns fixes that, but a card is `bucket`
|
||
// hours wide while its landing hour sits only half an hour in from the
|
||
// bucket's start, so the first card would hang off the left edge. Hence
|
||
// the inset spacers: a blank half-bucket of track at each end for the
|
||
// outer cards to overhang into.
|
||
//
|
||
// The grid is measured in HALF-hour tracks, because a card centred on its
|
||
// landing hour starts on a half-hour boundary. All tracks are identical,
|
||
// and each card SPANS 2*bucket of them (rather than sitting in one track
|
||
// at width:400%) — spanning divides a card's intrinsic width across the
|
||
// tracks it covers, so the grid's max-content width stays close to what
|
||
// it was and mobile doesn't gain a load of extra horizontal scroll.
|
||
//
|
||
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
|
||
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
|
||
// hour j -> centre at (bucket + 2j + 1) / total
|
||
// card -> spans 2*bucket tracks, starting one half-hour after 2j
|
||
// => centre = 2j + 1 + bucket == hour j's centre ✓
|
||
const fscPlot = (() => {
|
||
if (!tableRows.length) return null;
|
||
const cards = tableRows.length;
|
||
const hourly = visible.length > cards ? visible : tableRows;
|
||
const hours = hourly.length;
|
||
const bucket = Math.max(1, tableInterval || 1);
|
||
const total = 2 * hours + 2 * bucket; // half-hour tracks
|
||
// Landing-hour index of each card, accumulated so partial buckets at a
|
||
// day boundary stay correct rather than assuming i * bucket.
|
||
const landing = [];
|
||
let j = 0;
|
||
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
|
||
// A card spans 2*bucket tracks, so this keeps its 55px minimum.
|
||
const unit = 55 / (2 * bucket);
|
||
return {
|
||
hourly, hours, bucket, landing,
|
||
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
|
||
// Fraction of the track width at which hour j's data point sits.
|
||
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
|
||
// grid-column for the card whose landing hour is j (lines are 1-based).
|
||
cardCol: j2 => `${2 * j2 + 2} / span ${2 * bucket}`,
|
||
};
|
||
})();
|
||
|
||
// Single colour source for the quick view: the curve's gradient stops AND
|
||
// the cards' thermal tags both read from here, so a card's connector line
|
||
// can never land on a shade its own tag contradicts. (The tags used to take
|
||
// the discrete UTCI_BANDS hex — a flat #90d090 "Comfortable" chip sitting
|
||
// over a yellow-green 22°C point on the continuous ramp.)
|
||
//
|
||
// Pet mode goes through petAirTempRgb, which remaps the pet reading onto
|
||
// the human scale first (petEquivHumanTemp), exactly as the pet table
|
||
// columns and pet legend do.
|
||
const fscRgb = (t, whiteMix) =>
|
||
(simpleTemp === 'furSurfaceT' ? petAirTempRgb : airTempRgb)(t, whiteMix);
|
||
|
||
// ─── 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${activeEvents.length > 0 ? ' has-event-note' : ''}`}>
|
||
${activeEvents.length > 0 && (() => {
|
||
const ev = activeEvents[noteIndex] || activeEvents[0];
|
||
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
|
||
const fmtDate = (iso) => iso
|
||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||
: null;
|
||
// Date line for one event — computed per slide because every event is
|
||
// rendered (see the stack below), not just the visible one.
|
||
const dateLineFor = (e) => {
|
||
const startFmt = fmtDate(e.start);
|
||
const peakFmt = fmtDate(e.peak);
|
||
const endFmt = fmtDate(e.end);
|
||
if (!startFmt || !endFmt) return null;
|
||
if (startFmt === endFmt) return peakFmt ? `Peak ${peakFmt}` : startFmt;
|
||
return (peakFmt && peakFmt !== startFmt && peakFmt !== endFmt)
|
||
? `${startFmt} – ${endFmt} · Peak ${peakFmt}`
|
||
: `${startFmt} – ${endFmt}`;
|
||
};
|
||
// Warning banners take their background from the day tabs' weather
|
||
// palette (ev.tint, set in events/weather-checks.js) so a heat alert
|
||
// reads the same orange as a hot day tab and a rain alert the same
|
||
// blue. Pastelised hard — the banner is a wide block of body text, so
|
||
// it needs far more headroom than a small tab — and drawn edge-in to
|
||
// echo the tabs' radial "colour radiating inward" look.
|
||
const noteTintStyle = (isPriority && ev.tint) ? (() => {
|
||
const pale = (amt) => `rgb(${ev.tint.map(c => Math.round(c + (255 - c) * amt)).join(',')})`;
|
||
const edge = pale(0.55), core = pale(0.82);
|
||
return {
|
||
background: `linear-gradient(90deg, ${edge} 0%, ${core} 35%, ${core} 65%, ${edge} 100%)`,
|
||
borderColor: `rgb(${ev.tint.map(c => Math.round(c * 0.72)).join(',')})`,
|
||
};
|
||
})() : undefined;
|
||
return html`
|
||
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}`} style=${noteTintStyle}>
|
||
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
|
||
strip is always as tall as the LONGEST event's copy. Rotating to
|
||
a two-line message then back no longer resizes the banner and
|
||
shunts the page up and down. Only the active slide is visible;
|
||
the rest sit at opacity 0 and are hidden from assistive tech. */''}
|
||
<div class="event-note-stack">
|
||
${activeEvents.map((e, i) => {
|
||
const dateLine = dateLineFor(e);
|
||
const isActive = i === noteIndex && !noteFading;
|
||
return html`
|
||
<div
|
||
key=${e.id || i}
|
||
class=${`event-note-slide${isActive ? ' is-active' : ''}`}
|
||
aria-hidden=${isActive ? undefined : 'true'}
|
||
>
|
||
<span class="event-note-emoji">${e.emoji}</span>
|
||
<span class="event-note-title">${e.title}</span>
|
||
<span class="event-note-msg">${e.message}</span>
|
||
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
|
||
</div>`;
|
||
})}
|
||
</div>
|
||
${activeEvents.length > 1 && html`
|
||
<div class="event-note-dots">
|
||
${activeEvents.map((_, i) => html`
|
||
<button
|
||
key=${i}
|
||
type="button"
|
||
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
|
||
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
|
||
onClick=${() => noteGoTo(i)}
|
||
/>`)}
|
||
</div>`}
|
||
</div>`;
|
||
})()}
|
||
<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><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||
</div>
|
||
<div class="nav-links" id="nav-drawer">
|
||
<a href="./index.html">Forecast</a>
|
||
<div class="nav-dropdown">
|
||
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
|
||
<div class="nav-dropdown-menu">
|
||
<a href="./about.html">Overview</a>
|
||
<a href="./sunsoak.html">SunSoak & the science</a>
|
||
<a href="./profiles.html">Forecast profiles</a>
|
||
<a href="./columns.html">Column reference</a>
|
||
<a href="./temperatures.html">Derived temperatures</a>
|
||
<a href="./features.html">On-screen features</a>
|
||
<a href="./stress-bands.html">Stress bands</a>
|
||
</div>
|
||
</div>
|
||
<a href="./faq.html">FAQ</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 class="header-left">
|
||
<h1 class="utci-title">
|
||
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
|
||
</h1>
|
||
<div class="utci-tagline">See the world the way your skin does.</div>
|
||
<div class="utci-loc-search" ref=${searchWrapRef}>
|
||
<div class="utci-current-loc">
|
||
<button
|
||
type="button"
|
||
class="utci-loc-name"
|
||
aria-label="Search for a town or city"
|
||
aria-expanded=${searchOpen}
|
||
onClick=${() => (searchOpen ? closeSearch() : openSearch())}
|
||
>
|
||
<svg class="utci-loc-pin" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
||
<path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
|
||
<circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
|
||
</svg>
|
||
${location.name}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="utci-search-toggle"
|
||
aria-label="Search for a town or city"
|
||
aria-expanded=${searchOpen}
|
||
onClick=${() => (searchOpen ? closeSearch() : openSearch())}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
|
||
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
|
||
aria-label="Use my current location"
|
||
title="Use my current location"
|
||
disabled=${locating}
|
||
onClick=${useMyLocation}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
|
||
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
|
||
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="utci-loc-action"
|
||
aria-label="Share this forecast"
|
||
title="Copy a link to this forecast"
|
||
onClick=${shareForecast}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
|
||
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
|
||
</svg>
|
||
</button>
|
||
<span class="utci-loc-coords">
|
||
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
|
||
</span>
|
||
</div>
|
||
${(locateError || shareState) && html`
|
||
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
|
||
${locateError
|
||
? locateError
|
||
: shareState === 'copied' ? 'Link copied' : "Couldn't copy the link"}
|
||
${locateError && html`
|
||
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
|
||
onClick=${() => setLocateError(null)}>×</button>`}
|
||
</div>`}
|
||
${fetchedAt && !loading && html`
|
||
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
|
||
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||
</div>`}
|
||
${(() => {
|
||
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
|
||
const recents = (recentLocations || [])
|
||
.filter((l) => `${l.lat.toFixed(3)},${l.lon.toFixed(3)}` !== curKey)
|
||
.slice(0, 3);
|
||
if (!recents.length) return null;
|
||
return html`
|
||
<div class="utci-recent-locs">
|
||
<span class="utci-recent-label">Recent</span>
|
||
${recents.map((l) => html`
|
||
<button
|
||
key=${`${l.lat.toFixed(3)}-${l.lon.toFixed(3)}`}
|
||
type="button"
|
||
class="utci-recent-chip"
|
||
title=${`See the forecast for ${l.name}`}
|
||
onClick=${() => {
|
||
setLocationAndSave(l);
|
||
setSelectedDay(0);
|
||
closeSearch();
|
||
}}
|
||
>
|
||
<svg class="utci-recent-pin" viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
|
||
<path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
|
||
<circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
|
||
</svg>
|
||
<span class="utci-recent-name">${l.name}</span>
|
||
</button>`)}
|
||
</div>`;
|
||
})()}
|
||
${(searchOpen || searchClosing) && html`
|
||
<div
|
||
class=${'utci-search-wrap utci-search-overlay' + (searchOpen ? '' : ' is-closing')}
|
||
onAnimationEnd=${(e) => {
|
||
if (e.target === e.currentTarget && !searchOpen) {
|
||
setSearchClosing(false);
|
||
setSearchQuery('');
|
||
}
|
||
}}
|
||
>
|
||
<input
|
||
ref=${searchInputRef}
|
||
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=${() => {
|
||
setLocationAndSave({
|
||
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
|
||
lat: r.latitude,
|
||
lon: r.longitude,
|
||
country: r.country_code,
|
||
});
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
setSelectedDay(0);
|
||
closeSearch();
|
||
}}
|
||
>
|
||
<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>
|
||
|
||
<div class="scope-col">
|
||
<${ScopeReticle}
|
||
value=${scopeRow?.utciAdj ?? null}
|
||
cat=${scopeCat}
|
||
loading=${loading}
|
||
elev=${scopeElev}
|
||
dt=${scopeDt}
|
||
glob=${scopeRow?.glob ?? 0}
|
||
activeEvent=${scopeEvent}
|
||
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
|
||
cc=${scopeRow?.cc ?? null}
|
||
ccLow=${scopeRow?.ccLow ?? null}
|
||
ccMid=${scopeRow?.ccMid ?? null}
|
||
ccHigh=${scopeRow?.ccHigh ?? null}
|
||
precip=${scopeRow?.precip ?? 0}
|
||
snow=${scopeRow?.snow ?? 0}
|
||
visKm=${scopeRow?.visKm ?? null}
|
||
env=${utciEnv}
|
||
wind=${scopeRow?.va ?? 0}
|
||
gust=${scopeRow?.gust ?? 0}
|
||
wd=${scopeRow?.wd ?? null}
|
||
/>
|
||
${hourlyRows.length > 0 && html`
|
||
<div class="scope-daycycle-wrap">
|
||
<div class="scope-daycycle-pill">
|
||
<button type="button"
|
||
class=${`scope-play ${(playing || simMs != null) ? 'is-playing' : ''}`}
|
||
onClick=${() => { if (playing || simMs != null) { setPlaying(false); setSimMs(null); } else setPlaying(true); }}
|
||
title=${playing ? 'Stop time-lapse' : 'Play a 24-hour time-lapse on the scope'}>
|
||
<span class="scope-play-icon">${(playing || simMs != null) ? '◼' : '▶'}</span><span class="scope-play-label">${simClock ?? 'Cycle 24h'}</span>
|
||
</button>
|
||
<${DayTimeline}
|
||
windowStart=${windowStart}
|
||
windowEnd=${windowEnd}
|
||
simMs=${simMs}
|
||
setSimMs=${setSimMs}
|
||
hourlyRows=${hourlyRows}
|
||
utcOffsetMs=${utcOffsetMs}
|
||
/>
|
||
</div>
|
||
</div>`}
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
${forecast && panelRow && whyFeelsLike && html`
|
||
<div class="insight-panel insight-panel--why">
|
||
<div class="insight-env-footer" data-env=${utciEnv}>
|
||
<span class="insight-env-footer-label">Solar model</span>
|
||
<${CustomSelect}
|
||
value=${utciEnv}
|
||
isOn=${true}
|
||
noHide=${true}
|
||
grpClass="grp-felt"
|
||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.label,
|
||
}))}
|
||
onChange=${(v) => setUtciEnv(v)}
|
||
/>
|
||
</div>
|
||
<div class="insight-panel-title">
|
||
Why It Feels
|
||
${panelCat && html`<span class="insight-thermal-cat" style=${{ background: panelCat.bg, color: panelCat.fg }}>${panelCat.label}</span>`}
|
||
</div>
|
||
${(() => {
|
||
const _wflItems = [
|
||
{ label: 'Air temperature', icon: '🌡', value: panelRow.Ta, isBase: true },
|
||
{ label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky },
|
||
{ label: 'Wind', icon: '🌬', value: whyFeelsLike.wind },
|
||
{ label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity },
|
||
...(whyFeelsLike.environment !== 0 ? [
|
||
{ label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment }
|
||
] : []),
|
||
...(whyFeelsLike.precipitation !== 0 ? [
|
||
{ label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation }
|
||
] : []),
|
||
];
|
||
const _wflDeltaItems = _wflItems.filter(i => !i.isBase);
|
||
const _wflMax = Math.max(..._wflDeltaItems.map(i => Math.abs(i.value)), 0.1);
|
||
return _wflItems.map(({ label, icon, value, isBase }) => html`
|
||
<div class=${'insight-row' + (isBase ? ' insight-row--base' : '')} key=${label}>
|
||
<span class="insight-icon">${icon}</span>
|
||
<span class="insight-label">
|
||
${label}
|
||
${!isBase && html`<span class="insight-bar-track">
|
||
<span class=${'insight-bar ' + (value >= 0 ? 'bar-pos' : 'bar-neg')}
|
||
style=${{ width: `${Math.round(Math.abs(value) / _wflMax * 100)}%` }}></span>
|
||
</span>`}
|
||
</span>
|
||
<span class=${`insight-delta ${isBase ? 'delta-base' : value >= 0 ? 'delta-pos' : 'delta-neg'}`}>
|
||
${isBase ? '' : value >= 0 ? '+' : ''}${value.toFixed(1)}°
|
||
</span>
|
||
</div>
|
||
`);
|
||
})()}
|
||
</div>`}
|
||
</div>
|
||
</div>
|
||
|
||
${error && html`
|
||
<div class="utci-status" role="alert" aria-live="polite"
|
||
style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
|
||
<span class="utci-status-msg">⚠ ${friendlyError(error)}</span>
|
||
<button type="button" class="utci-status-retry" onClick=${retry} disabled=${loading}>
|
||
${loading ? 'Retrying…' : 'Retry'}
|
||
</button>
|
||
</div>`}
|
||
${loading && !error && html`
|
||
<div class="acquiring-overlay">
|
||
<div class="acquiring-popup">
|
||
<div class="acquiring-spinner"></div>
|
||
<div class="acquiring-label">Acquiring forecast data…</div>
|
||
</div>
|
||
</div>`}
|
||
|
||
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
|
||
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
|
||
|
||
${forecast && days.length > 0 && (() => {
|
||
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
|
||
const fabIcon = activeProfile === 'outdoors'
|
||
? (variantIcons[outdoorsVariant] || '🎯')
|
||
: (FILTER_PROFILES[activeProfile]?.icon || '🎯');
|
||
const fabVal = mainConfigKey === 'vehicle'
|
||
? (VEHICLE_TYPES[vehicleType]?.name || '')
|
||
: mainConfigKey === 'indoor'
|
||
? (BUILDING_TYPES[buildingType]?.name || '')
|
||
: mainConfigKey === 'fur'
|
||
? (FUR_COLORS[furColor]?.name || '')
|
||
: (UTCI_ENVIRONMENTS[utciEnv]?.label || '');
|
||
return html`
|
||
<button
|
||
class=${`floating-profile-btn${panelOpen ? ' is-open' : ''}`}
|
||
onClick=${() => (panelOpen ? closePanel() : openPanel())}
|
||
aria-label="Profile and settings"
|
||
aria-expanded=${panelOpen}
|
||
title="Profile & settings"
|
||
>
|
||
<span class="floating-profile-icon" aria-hidden="true">${fabIcon}</span>
|
||
<span class="floating-profile-val">${mainLabel}</span>
|
||
${fabVal && html`<span class="floating-profile-sub">· ${fabVal}</span>`}
|
||
<span class="floating-profile-caret" aria-hidden="true">▾</span>
|
||
</button>`;
|
||
})()}
|
||
|
||
<${ConfigPanel}
|
||
open=${panelOpen}
|
||
onClose=${closePanel}
|
||
isPro=${isPro}
|
||
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
|
||
activityOptions=${activityOptions} placeOptions=${placeOptions} workOptions=${workOptions}
|
||
activityValue=${activityValue} placeValue=${placeValue} workValue=${workValue}
|
||
outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave}
|
||
setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} setIndoorMode=${setIndoorMode}
|
||
setProPromptSource=${setProPromptSource} setProPromptDay=${setProPromptDay}
|
||
indoorManaged=${indoorManaged} setIndoorManaged=${setIndoorManaged}
|
||
buildingType=${buildingType} setBuildingType=${setBuildingType}
|
||
utciEnv=${utciEnv} setUtciEnv=${setUtciEnv}
|
||
vehicleType=${vehicleType} setVehicleType=${setVehicleType}
|
||
vehicleSpeed=${vehicleSpeed} setVehicleSpeed=${setVehicleSpeed}
|
||
vehicleVent=${vehicleVent} setVehicleVent=${setVehicleVent}
|
||
furColor=${furColor} setFurColor=${setFurColor}
|
||
skinType=${skinType} setSkinType=${setSkinType}
|
||
pollenType=${pollenType} setPollenTypeAndSave=${setPollenTypeAndSave}
|
||
/>
|
||
|
||
${forecast && days.length > 0 && html`<${DayTabs}
|
||
days=${days}
|
||
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
|
||
isPro=${isPro}
|
||
openRestore=${openRestore}
|
||
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
|
||
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
|
||
activeProfile=${activeProfile} outdoorsVariant=${outdoorsVariant}
|
||
openPanel=${openPanel}
|
||
vehicleType=${vehicleType} vehicleSpeed=${vehicleSpeed}
|
||
buildingType=${buildingType} indoorManaged=${indoorManaged} utciEnv=${utciEnv} furColor=${furColor}
|
||
dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight}
|
||
scrollDayTabs=${scrollDayTabs}
|
||
/>`}
|
||
|
||
|
||
<div class="table-with-rail">
|
||
<div class=${'table-main' + (forecastView === 'simple' ? ' table-main--simple' : '')}>
|
||
<div class="table-toolbar-row">
|
||
<span class="fvt-toolbar-left">
|
||
<span class="forecast-view-toggle-label">View:</span>
|
||
<span class="forecast-view-toggle">
|
||
<button type="button" title="Quick view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')} onClick=${() => setForecastView('simple')}>
|
||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
|
||
Quick
|
||
</button>
|
||
<button type="button" title="Detailed view – full hourly table" class=${'fvt-btn' + (forecastView === 'table' ? ' on' : '')} onClick=${() => setForecastView('table')}>
|
||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
|
||
Detailed
|
||
</button>
|
||
</span>
|
||
<button
|
||
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
|
||
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
|
||
onClick=${() => {
|
||
if (!isPro) { setProPromptSource('columns'); setProPromptDay(0); return; }
|
||
setColTogglesOpen(v => !v);
|
||
}}>
|
||
<span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
|
||
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
||
</button>
|
||
</span>
|
||
${forecastView === 'simple' && (() => {
|
||
const showFur = visibleCols.furSurfaceT;
|
||
const showSolar = visibleCols.utciP;
|
||
const showShade = visibleCols.shadeT;
|
||
const showVehicle = visibleCols.vehicleT;
|
||
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
|
||
if (!showFur && !showSolar && !showShade && !showVehicle && !showIndoor) return null;
|
||
const furOn = simpleTemp === 'furSurfaceT';
|
||
const solarOn = simpleTemp === 'utciAdj';
|
||
const shadeOn = simpleTemp === 'shadeT';
|
||
const vehicleOn = simpleTemp === 'vehicleT';
|
||
const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
|
||
// Values (fur colour, vehicle type/speed, building type,
|
||
// ventilation) are set in the config strip above the day
|
||
// tabs now — this row is just a tab switcher for which
|
||
// thermal model drives the quick-view cards below. Sits
|
||
// directly above col-toggles in normal flow (touching, zero
|
||
// gap) so it reads as a folder tab attached to that box.
|
||
return html`
|
||
<span class="fvt-thermal-tabs">
|
||
${showSolar && html`<button type="button" class=${'fvt-thermal-tab' + (solarOn ? ' on' : '')} onClick=${() => setSimpleTemp('utciAdj')}>SunSoak</button>`}
|
||
${showShade && html`<button type="button" class=${'fvt-thermal-tab' + (shadeOn ? ' on' : '')} onClick=${() => setSimpleTemp('shadeT')}>Shade</button>`}
|
||
${showVehicle && html`<button type="button" class=${'fvt-thermal-tab' + (vehicleOn ? ' on' : '')} onClick=${() => setSimpleTemp('vehicleT')}>Vehicle</button>`}
|
||
${showIndoor && html`<button type="button" class=${'fvt-thermal-tab' + (indoorOn ? ' on' : '')} onClick=${() => setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT')}>Indoors</button>`}
|
||
${showFur && html`<button type="button" class=${'fvt-thermal-tab' + (furOn ? ' on' : '')} onClick=${() => setSimpleTemp('furSurfaceT')}>Fur Colour</button>`}
|
||
</span>
|
||
`;
|
||
})()}
|
||
</div>
|
||
<div key=${forecastView} class=${'col-toggles-wrap' + (colTogglesOpen ? ' col-toggles-wrap--open' : '')}>
|
||
<div class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
||
<span class="fvt-interval">
|
||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} label="Hours" />
|
||
</span>
|
||
<div class="col-toggles-body">
|
||
${isPro && html`<${Fragment}>
|
||
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
|
||
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
|
||
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
|
||
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur Colour</button>`}
|
||
${colOffered('pawT') && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
|
||
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
|
||
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
|
||
${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
|
||
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||
${colOffered('delta') && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
||
${colOffered('tmrt') && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
||
${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||
${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||
${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||
${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
|
||
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||
${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||
${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
|
||
${colOffered('lightning') && html`<button class=${`col-toggle grp-precip${visibleCols.lightning ? ' on' : ''}`} onClick=${() => toggleCol('lightning')}>Lightning</button>`}
|
||
${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||
${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
|
||
${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||
${colOffered('dir') && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||
${colOffered('aqi') && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
|
||
${colOffered('pollen') && html`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
|
||
${colOffered('uvA') && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
|
||
${colOffered('uvB') && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
|
||
${colOffered('sun') && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||
${colOffered('direct') && html`<button class=${`col-toggle grp-solar${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||
${colOffered('diffuse') && html`<button class=${`col-toggle grp-solar${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||
</${Fragment}>`}
|
||
|
||
<span class="col-toggles-display-opts">
|
||
<label class="display-opt">
|
||
<input type="checkbox" checked=${showDecimals} onChange=${toggleShowDecimals} />
|
||
Decimals
|
||
</label>
|
||
<label class="display-opt">
|
||
<input type="checkbox" checked=${showUnits} onChange=${toggleShowUnits} />
|
||
Units
|
||
</label>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="forecast-simple-wrap">
|
||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||
<div class="forecast-simple-inner">
|
||
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
|
||
${tableRows.map((r, ri) => {
|
||
const dispTemp = r[simpleTemp] ?? r.utciAdj;
|
||
const cat = simpleTemp === 'furSurfaceT' ? petCategory(dispTemp) : utciCategory(dispTemp);
|
||
const h24s = parseInt(r.iso.slice(11, 13), 10);
|
||
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
|
||
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
|
||
const domIcon = (() => {
|
||
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
|
||
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
|
||
if (r.cloudCat && r.cloudCat !== 'clear')
|
||
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
|
||
return null;
|
||
})();
|
||
const windMph = Math.round((r.gust ?? r.va) * 2.237);
|
||
// Tag shade = the exact gradient stop the curve is painted
|
||
// with at this card's landing hour (fsc-grad, whiteMix 0.42),
|
||
// so the connector line lands on the colour the tag is
|
||
// wearing. Band label and weight still come from the band.
|
||
const feelRgb = fscRgb(dispTemp, 0.42) || [200, 200, 200];
|
||
const feelLum = (feelRgb[0] * 299 + feelRgb[1] * 587 + feelRgb[2] * 114) / 1000;
|
||
const feelStyle = {
|
||
background: `rgb(${feelRgb[0]},${feelRgb[1]},${feelRgb[2]})`,
|
||
color: feelLum > 165 ? '#1a1a1a' : '#ffffff',
|
||
...(cat.fontWeight ? { fontWeight: cat.fontWeight } : {}),
|
||
};
|
||
return html`
|
||
<div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}
|
||
style=${fscPlot && { gridColumn: fscPlot.cardCol(fscPlot.landing[ri]) }}>
|
||
<div class="fsc-time">${localHHMMs}</div>
|
||
<div class="fsc-scope-wrap">
|
||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${42} />
|
||
${domIcon && html`<div class="fsc-wx-overlay">${domIcon}</div>`}
|
||
</div>
|
||
<div class="fsc-meta">
|
||
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
|
||
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span>
|
||
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
|
||
</div>
|
||
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
|
||
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
${(() => {
|
||
if (!fscPlot) return null;
|
||
const fscSvgW = 1000, fscSvgH = 80;
|
||
const fscSrc = fscPlot.hourly;
|
||
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
||
const fscMin = -15, fscMax = 45;
|
||
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
||
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
||
// Same hour->x mapping the cards grid uses (see fscPlot above), so a
|
||
// card's centre and its hour's data point are the same x by
|
||
// construction — every hour of the day stays on screen.
|
||
const fscHourX = j => fscPlot.hourAt(j) * fscSvgW;
|
||
const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1);
|
||
const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) }));
|
||
// Connector points — one per card, planted on its landing hour's data
|
||
// point, which is exactly where that card is centred.
|
||
const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
|
||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||
// Flat runs from each edge into the first/last hour, so the fill still
|
||
// covers the inset spacer strips at both ends.
|
||
const fscFillPts = [
|
||
{ x: 0, y: fscAllPts[0].y },
|
||
...fscAllPts,
|
||
{ x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y },
|
||
];
|
||
let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`;
|
||
for (let i = 1; i < fscFillPts.length; i++) {
|
||
const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
|
||
const cpx = (p0.x + p1.x) / 2;
|
||
fscFillLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`;
|
||
}
|
||
const fscFill = fscFillLine + ` L ${fscSvgW},${fscSvgH} L 0,${fscSvgH} Z`;
|
||
return html`
|
||
<svg class="fsc-temp-curve" viewBox="0 0 ${fscSvgW} ${fscSvgH}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="fsc-grad" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||
${fscSrc.map((r, j) => {
|
||
const rgb = fscRgb(getT(r), 0.42) || [200, 200, 200];
|
||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
return html`<stop key=${j} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
|
||
})}
|
||
</linearGradient>
|
||
<linearGradient id="fsc-grad-strong" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||
${fscSrc.map((r, j) => {
|
||
const rgb = fscRgb(getT(r), 0.25) || [200, 200, 200];
|
||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
return html`<stop key=${`s${j}`} offset=${`${fscStopPct(j)}%`} stop-color=${fc} />`;
|
||
})}
|
||
</linearGradient>
|
||
</defs>
|
||
${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => {
|
||
const gy = toY(t);
|
||
return html`
|
||
<line key=${'g'+t} x1="0" y1=${gy} x2=${fscSvgW} y2=${gy}
|
||
stroke=${color} stroke-width="1" opacity="0.35"
|
||
vector-effect="non-scaling-stroke" />
|
||
`;
|
||
})}
|
||
${fscPts.map((pt, i) => {
|
||
const isNow = fscNowFlags[i];
|
||
return html`
|
||
<line key=${'c'+i} x1=${pt.x} y1="0" x2=${pt.x} y2=${pt.y}
|
||
stroke=${isNow ? '#c8922a' : '#b09870'}
|
||
stroke-width="1"
|
||
opacity=${isNow ? '1' : '0.55'}
|
||
vector-effect="non-scaling-stroke"
|
||
/>
|
||
`;
|
||
})}
|
||
<path d=${fscFill} fill="url(#fsc-grad)" opacity="0.45" />
|
||
<path d=${fscFill} fill="none" stroke="url(#fsc-grad-strong)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" />
|
||
</svg>
|
||
`;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div class="fsc-table-btn-wrap">
|
||
<button type="button" class="fsc-table-btn" onClick=${() => setForecastView('table')}>
|
||
Detailed Hourly Forecast →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
|
||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||
|
||
<div class="utci-thead-sticky" ref=${headStickyRef}>
|
||
<div class="utci-thead-track" ref=${headTrackRef}>
|
||
<table class="utci-table utci-table-head" ref=${headTableRef}>
|
||
<thead>
|
||
${groupLabelRow()}
|
||
<tr>
|
||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>
|
||
<span>Rows</span>
|
||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||
</span>
|
||
</th>
|
||
${visibleCols.utciP && html`<th class=${`col-info-th ${groupStart('utciP')} ${groupColor('utciP')}`} scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>SunSoak <span class="col-unit">°C felt</span>${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ? html`<span class="col-env-badge">${UTCI_ENVIRONMENTS[utciEnv].shortLabel}</span>` : null}</th>`}
|
||
${visibleCols.shadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('shadeT')} ${groupColor('shadeT')}`} scope="col" onClick=${(e) => handleThClick('shadeT', e)} onMouseEnter=${(e) => handleThEnter('shadeT', e)} onMouseLeave=${handleThLeave}>Shade <span class="col-unit">°C felt</span></th>`}
|
||
${visibleCols.vehicleT && html`<th class=${`col-info-th ${groupStart('vehicleT')} ${groupColor('vehicleT')}`} scope="col" onClick=${(e) => handleThClick('vehicleT', e)} onMouseEnter=${(e) => handleThEnter('vehicleT', e)} onMouseLeave=${handleThLeave}>Vehicle <span class="col-unit">°C peak</span></th>`}
|
||
${indoorMode === 'on' && !indoorManaged && html`<th class=${`col-info-th ${groupStart('indoorT')} ${groupColor('indoorT')}`} scope="col" onClick=${(e) => handleThClick('indoorT', e)} onMouseEnter=${(e) => handleThEnter('indoorT', e)} onMouseLeave=${handleThLeave}>Indoors <span class="col-unit">°C est.</span></th>`}
|
||
${indoorMode === 'on' && indoorManaged && html`<th class=${`col-info-th ${groupStart('managedT')} ${groupColor('managedT')}`} scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
|
||
${visibleCols.burn && html`<th class=${`col-info-th ${groupStart('burn')} ${groupColor('burn')}`} scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
|
||
${visibleCols.utci && html`<th class=${`col-info-th ${groupStart('utci')} ${groupColor('utci')}`} scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
|
||
${visibleCols.delta && html`<th class=${`col-info-th ${groupStart('delta')} ${groupColor('delta')}`} scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCI−Air</span></th>`}
|
||
${visibleCols.tmrt && html`<th class=${`col-info-th ${groupStart('tmrt')} ${groupColor('tmrt')}`} scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.concreteT && html`<th class=${`col-info-th ${groupStart('concreteT')} ${groupColor('concreteT')}`} scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
|
||
${visibleCols.furSurfaceT && html`<th class=${`col-info-th ${groupStart('furSurfaceT')} ${groupColor('furSurfaceT')}`} scope="col" onClick=${(e) => handleThClick('furSurfaceT', e)} onMouseEnter=${(e) => handleThEnter('furSurfaceT', e)} onMouseLeave=${handleThLeave}>Fur <span class="col-unit">°C surface</span></th>`}
|
||
${visibleCols.pawT && html`<th class=${`col-info-th ${groupStart('pawT')} ${groupColor('pawT')}`} scope="col" onClick=${(e) => handleThClick('pawT', e)} onMouseEnter=${(e) => handleThEnter('pawT', e)} onMouseLeave=${handleThLeave}>Paw <span class="col-unit">°C surface</span></th>`}
|
||
${visibleCols.soilT && html`<th class=${`col-info-th ${groupStart('soilT')} ${groupColor('soilT')}`} scope="col" onClick=${(e) => handleThClick('soilT', e)} onMouseEnter=${(e) => handleThEnter('soilT', e)} onMouseLeave=${handleThLeave}>Soil °C <span class="col-unit">surface</span></th>`}
|
||
${visibleCols.soilT6 && html`<th class=${`col-info-th ${groupStart('soilT6')} ${groupColor('soilT6')}`} scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
|
||
${visibleCols.soilM && html`<th class=${`col-info-th ${groupStart('soilM')} ${groupColor('soilM')}`} scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">%</span></th>`}
|
||
${visibleCols.petShadeT && html`<th class=${`utci-tight-head col-info-th ${groupStart('petShadeT')} ${groupColor('petShadeT')}`} scope="col" onClick=${(e) => handleThClick('petShadeT', e)} onMouseEnter=${(e) => handleThEnter('petShadeT', e)} onMouseLeave=${handleThLeave}>Pet Shade <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.petHomeT && html`<th class=${`col-info-th ${groupStart('petHomeT')} ${groupColor('petHomeT')}`} scope="col" onClick=${(e) => handleThClick('petHomeT', e)} onMouseEnter=${(e) => handleThEnter('petHomeT', e)} onMouseLeave=${handleThLeave}>Pet Home <span class="col-unit">°C est.</span></th>`}
|
||
${visibleCols.air && html`<th class=${`utci-tight-head col-info-th ${groupStart('air')} ${groupColor('air')}`} scope="col" onClick=${(e) => handleThClick('air', e)} onMouseEnter=${(e) => handleThEnter('air', e)} onMouseLeave=${handleThLeave}>Air <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.rh && html`<th class=${`col-info-th ${groupStart('rh')} ${groupColor('rh')}`} scope="col" onClick=${(e) => handleThClick('rh', e)} onMouseEnter=${(e) => handleThEnter('rh', e)} onMouseLeave=${handleThLeave}>RH <span class="col-unit">%</span></th>`}
|
||
${visibleCols.dew && html`<th class=${`col-info-th ${groupStart('dew')} ${groupColor('dew')}`} scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.precip && html`<th class=${`utci-tight-head col-info-th ${groupStart('precip')} ${groupColor('precip')}`} scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
|
||
${visibleCols.precipProb && html`<th class=${`utci-tight-head col-info-th ${groupStart('precipProb')} ${groupColor('precipProb')}`} scope="col" onClick=${(e) => handleThClick('precipProb', e)} onMouseEnter=${(e) => handleThEnter('precipProb', e)} onMouseLeave=${handleThLeave}>Rain <span class="col-unit">%</span></th>`}
|
||
${visibleCols.lightning && html`<th class=${`utci-tight-head col-info-th ${groupStart('lightning')} ${groupColor('lightning')}`} scope="col" onClick=${(e) => handleThClick('lightning', e)} onMouseEnter=${(e) => handleThEnter('lightning', e)} onMouseLeave=${handleThLeave}>⚡ <span class="col-unit">LPI</span></th>`}
|
||
${visibleCols.cloud && html`<th class=${`col-info-th ${groupStart('cloud')} ${groupColor('cloud')}`} scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
|
||
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
|
||
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
|
||
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
|
||
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'EU idx'}</span></th>`}
|
||
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'grains/m³'}</span></th>`}
|
||
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
|
||
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
|
||
${visibleCols.direct && html`<th class=${`col-info-th ${groupStart('direct')} ${groupColor('direct')}`} scope="col" onClick=${(e) => handleThClick('direct', e)} onMouseEnter=${(e) => handleThEnter('direct', e)} onMouseLeave=${handleThLeave}>Direct <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.diffuse && html`<th class=${`col-info-th ${groupStart('diffuse')} ${groupColor('diffuse')}`} scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
|
||
</tr>
|
||
</thead>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||
<tbody>
|
||
${tableRows.map((r, rowIdx) => {
|
||
const rPrev = tableRows[rowIdx - 1];
|
||
const rNext = tableRows[rowIdx + 1];
|
||
const cat = utciCategory(r.utci);
|
||
const isNight = r.elev < 0;
|
||
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : 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})`;
|
||
};
|
||
const fmt = (v, dp = 1) => v == null ? '—' : (showDecimals ? v.toFixed(dp) : String(Math.round(v)));
|
||
const u = (unit) => showUnits ? unit : '';
|
||
// Returns a background tint based on temperature value - all share the same
|
||
// Continuous temperature colour scale — used for all temp columns.
|
||
// airTempRgb(t) returns the whiteblended [r,g,b] for a temperature.
|
||
// airTempBg(t, tPrev, tNext) returns a top→bottom gradient that
|
||
// flows from the previous row's colour through this row's colour to
|
||
// the next row's colour, making the whole column one seamless heatmap.
|
||
// airTempRgb and TEMP_STOPS hoisted to component scope above.
|
||
const toRgb = (rgb) => rgb ? `rgb(${rgb[0]},${rgb[1]},${rgb[2]})` : 'transparent';
|
||
const airTempBg = (t, tPrev, tNext) => {
|
||
if (t == null) return 'transparent';
|
||
// Top colour = midpoint temp between prev row and this row.
|
||
// Bottom colour = midpoint temp between this row and next row.
|
||
// This guarantees the boundary colour is identical on both sides
|
||
// of every cell edge, giving a perfectly seamless column gradient.
|
||
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
|
||
const tBot = tNext != null ? (t + tNext) / 2 : t;
|
||
return `linear-gradient(to bottom, ${toRgb(airTempRgb(tTop))} 0%, ${toRgb(airTempRgb(tBot))} 100%)`;
|
||
};
|
||
const airTempFontColor = () => '#1a1a1a';
|
||
// Returns any extra band styles (fontWeight, textShadow) for a given temp.
|
||
const bandExtras = (t) => {
|
||
const band = utciCategory(t);
|
||
if (!band) return {};
|
||
return {
|
||
...(band.fontWeight ? { fontWeight: band.fontWeight } : {}),
|
||
...(band.textShadow ? { textShadow: band.textShadow } : {}),
|
||
};
|
||
};
|
||
// Pet-specific columns (Fur Colour, Pet Shade, Pet Home, Paw) use
|
||
// petAirTempRgb instead of airTempRgb - identical gradient/blend
|
||
// mechanics to every other temp column, just a pet-calibrated
|
||
// scale so the same degree reading lands on a different shade.
|
||
const petAirTempBg = (t, tPrev, tNext) => {
|
||
if (t == null) return 'transparent';
|
||
const tTop = tPrev != null ? (tPrev + t) / 2 : t;
|
||
const tBot = tNext != null ? (t + tNext) / 2 : t;
|
||
return `linear-gradient(to bottom, ${toRgb(petAirTempRgb(tTop))} 0%, ${toRgb(petAirTempRgb(tBot))} 100%)`;
|
||
};
|
||
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 soilMoistureBg = (v) => {
|
||
if (v == null || isNaN(v)) return { bg: 'transparent', fg: '#555' };
|
||
const pct = v * 100;
|
||
// stops: [pct, r, g, b]
|
||
const stops = [
|
||
[0, 212, 180, 131], // sandy tan – bone dry
|
||
[10, 160, 120, 64], // mid brown – dry
|
||
[22, 138, 122, 80], // olive-brown – damp/firm
|
||
[32, 200, 160, 64], // amber – soft, caution
|
||
[38, 58, 138, 92], // green – wet
|
||
[50, 42, 106, 144], // blue – saturated
|
||
];
|
||
let s0 = stops[0], s1 = stops[stops.length - 1];
|
||
for (let i = 0; i < stops.length - 1; i++) {
|
||
if (pct >= stops[i][0] && pct <= stops[i+1][0]) { s0 = stops[i]; s1 = stops[i+1]; break; }
|
||
}
|
||
const t = s1[0] === s0[0] ? 1 : Math.max(0, Math.min(1, (pct - s0[0]) / (s1[0] - s0[0])));
|
||
const r = Math.round(s0[1] + t * (s1[1] - s0[1]));
|
||
const g = Math.round(s0[2] + t * (s1[2] - s0[2]));
|
||
const b = Math.round(s0[3] + t * (s1[3] - s0[3]));
|
||
const lum = 0.299*r + 0.587*g + 0.114*b;
|
||
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
|
||
};
|
||
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=${38} />
|
||
<span>${localHHMM}</span>
|
||
${(() => {
|
||
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
||
return rowEvents.map((ev, idx) => html`
|
||
<span key=${ev.id} class="event-cell-tag"
|
||
onClick=${(e) => handleEventTagClick(rowEvents, idx, e)}
|
||
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, idx, e)}
|
||
onMouseLeave=${handleEventTagLeave}
|
||
role="button" tabIndex="0" aria-label=${ev.title}
|
||
>${ev.emoji}</span>
|
||
`);
|
||
})()}
|
||
</span>
|
||
</td>
|
||
${visibleCols.utciP && html`
|
||
<td class=${groupStart('utciP')} style=${{ background: airTempBg(r.utciAdj, rPrev?.utciAdj, rNext?.utciAdj), color: airTempFontColor(), fontSize: '13px' }}>
|
||
${fmt(r.utciAdj)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.shadeT && html`
|
||
<td class=${groupStart('shadeT')} style=${{ color: airTempFontColor(), background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
|
||
${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}
|
||
</td>`}
|
||
${visibleCols.vehicleT && html`
|
||
<td class=${groupStart('vehicleT')} style=${{ color: airTempFontColor(), background: airTempBg(r.vehicleT, rPrev?.vehicleT, rNext?.vehicleT) }}>
|
||
${fmt(r.vehicleT)}${u('°C')}
|
||
</td>`}
|
||
${indoorMode === 'on' && !indoorManaged && html`
|
||
<td class=${groupStart('indoorT')} style=${{ color: airTempFontColor(), background: airTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
|
||
${fmt(r.indoorT)}${u('°C')}
|
||
</td>`}
|
||
${indoorMode === 'on' && indoorManaged && html`
|
||
<td class=${groupStart('managedT')} style=${{ color: airTempFontColor(), background: airTempBg(r.managedT, rPrev?.managedT, rNext?.managedT) }}>
|
||
${fmt(r.managedT)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.burn && html`
|
||
<td class=${groupStart('burn')} style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
|
||
${burnLabel(burnMins)}
|
||
</td>`}
|
||
${visibleCols.utci && html`
|
||
<td class=${groupStart('utci')} style=${{ background: airTempBg(r.utci, rPrev?.utci, rNext?.utci), color: airTempFontColor() }}>
|
||
${fmt(r.utci)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.delta && html`
|
||
<td class=${groupStart('delta')} style=${{
|
||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||
background: deltaBg(delta),
|
||
}}>
|
||
${delta > 0 ? '+' : ''}${fmt(delta)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.tmrt && html`<td class=${groupStart('tmrt')} style=${{ background: airTempBg(r.Tmrt, rPrev?.Tmrt, rNext?.Tmrt), color: airTempFontColor() }}>${fmt(r.Tmrt)}${u('°C')}</td>`}
|
||
${visibleCols.concreteT && html`
|
||
<td class=${groupStart('concreteT')} style=${{ color: airTempFontColor(), background: airTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
|
||
${fmt(r.concreteT)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.furSurfaceT && html`
|
||
<td class=${groupStart('furSurfaceT')} title=${petCategory(r.furSurfaceT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.furSurfaceT, rPrev?.furSurfaceT, rNext?.furSurfaceT) }}>
|
||
${fmt(r.furSurfaceT)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.pawT && html`
|
||
<td class=${groupStart('pawT')} title=${petCategory(r.pawT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.pawT, rPrev?.pawT, rNext?.pawT) }}>
|
||
${fmt(r.pawT)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.soilT && html`
|
||
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
|
||
${visibleCols.soilT6 && html`
|
||
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}</td>`}
|
||
${visibleCols.soilM && html`
|
||
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
|
||
${visibleCols.petShadeT && html`
|
||
<td class=${groupStart('petShadeT')} title=${r.petShadeT != null ? (petCategory(r.petShadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.petShadeT, rPrev?.petShadeT, rNext?.petShadeT) }}>
|
||
${r.petShadeT != null ? fmt(r.petShadeT) + u('°C') : '—'}
|
||
</td>`}
|
||
${visibleCols.petHomeT && html`
|
||
<td class=${groupStart('petHomeT')} title=${petCategory(r.indoorT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.indoorT, rPrev?.indoorT, rNext?.indoorT) }}>
|
||
${fmt(r.indoorT)}${u('°C')}
|
||
</td>`}
|
||
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
|
||
${visibleCols.rh && html`<td class=${groupStart('rh')} style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}${u('%')}</td>`}
|
||
${visibleCols.dew && html`<td class=${groupStart('dew')} style=${{ background: airTempBg(r.dew, rPrev?.dew, rNext?.dew), color: airTempFontColor() }}>${fmt(r.dew)}${u('°C')}</td>`}
|
||
${visibleCols.precip && html`
|
||
<td class=${groupStart('precip')} 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.precipProb > 0 ? r.precip : 0} snow=${r.precipProb > 0 ? r.snow : 0} size=${28} />
|
||
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
|
||
${r.precipProb > 0
|
||
? (r.snow > 0 ? fmt(r.snow) + u('cm') : r.precip > 0 ? fmt(r.precip) + u('mm') : '—')
|
||
: '—'}
|
||
</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.precipProb && html`
|
||
<td class=${groupStart('precipProb')} style=${{ background: scaleBg(r.precipProb, 0, 100, '70,145,200'), color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90' }}>
|
||
${r.precipProb}${u('%')}
|
||
</td>`}
|
||
${visibleCols.lightning && (() => {
|
||
const lpi = r.lightning ?? 0;
|
||
const bg = lpi >= 25 ? 'rgba(255,180,0,0.25)'
|
||
: lpi >= 5 ? 'rgba(255,210,0,0.15)'
|
||
: 'transparent';
|
||
const color = lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90';
|
||
return html`<td class=${groupStart('lightning')} style=${{ background: bg, color, textAlign: 'center' }}>
|
||
${lpi > 0 ? `${Math.round(lpi)}` : ''}
|
||
</td>`;
|
||
})()}
|
||
${visibleCols.cloud && html`<td class=${groupStart('cloud')} style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07), verticalAlign: 'middle' }}>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<span style=${{ position: 'relative', top: '6px' }}>
|
||
<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} />
|
||
</span>
|
||
<span>${Math.round(r.cc)}${u('%')}</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.vis && (() => {
|
||
const v = r.visKm;
|
||
const bg = v == null ? 'transparent'
|
||
: v < 1 ? 'rgba(180,80,80,0.18)'
|
||
: v < 4 ? 'rgba(210,140,50,0.15)'
|
||
: v < 10 ? 'rgba(200,190,80,0.12)'
|
||
: 'transparent';
|
||
const color = v != null && v < 4 ? '#8a3a1a' : '#4a3218';
|
||
return html`<td class=${groupStart('vis')} style=${{ background: bg, color }}>
|
||
${v != null ? fmt(v) + u('km') : '—'}
|
||
</td>`;
|
||
})()}
|
||
${visibleCols.wind && html`<td class=${groupStart('wind')} style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
|
||
${Math.round(r.va * 2.237)}${u('mph')}${r.gust != null && r.gust > r.va + 0.5
|
||
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)}${u('mph')})</span>`)(r.gust * 2.237)
|
||
: ''}
|
||
</td>`}
|
||
${visibleCols.dir && html`<td class=${`utci-dir-cell ${groupStart('dir')}`}>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<${WindVane} bearing=${r.wd} size=${28} />
|
||
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.aqi && (() => {
|
||
const v = r.aqi == null ? null : Math.round(r.aqi);
|
||
const bg = v == null ? 'transparent'
|
||
: v < 20 ? 'rgba(80,180,100,0.15)'
|
||
: v < 40 ? 'rgba(140,200,100,0.13)'
|
||
: v < 60 ? 'rgba(220,200,60,0.15)'
|
||
: v < 80 ? 'rgba(220,130,50,0.18)'
|
||
: v < 100 ? 'rgba(200,70,50,0.18)'
|
||
: 'rgba(160,30,100,0.20)';
|
||
const color = v != null && v >= 60 ? '#7a2010' : v != null && v >= 40 ? '#7a4a10' : '#2a4a20';
|
||
const label = v == null ? '—'
|
||
: v < 20 ? `${v} Good`
|
||
: v < 40 ? `${v} Fair`
|
||
: v < 60 ? `${v} Mod`
|
||
: v < 80 ? `${v} Poor`
|
||
: v < 100 ? `${v} V.Poor`
|
||
: `${v} Hazard`;
|
||
return html`<td class=${groupStart('aqi')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
|
||
})()}
|
||
${visibleCols.pollen && (() => {
|
||
const pollenMap = {
|
||
all_pollen: [r.grassPollen, r.birchPollen, r.alderPollen, r.mugwortPollen, r.olivePollen, r.ragweedPollen].reduce((s, x) => x != null ? s + x : s, null),
|
||
grass_pollen: r.grassPollen,
|
||
birch_pollen: r.birchPollen,
|
||
alder_pollen: r.alderPollen,
|
||
mugwort_pollen: r.mugwortPollen,
|
||
olive_pollen: r.olivePollen,
|
||
ragweed_pollen: r.ragweedPollen,
|
||
};
|
||
const v = pollenMap[pollenType] ?? null;
|
||
const bg = v == null ? 'transparent'
|
||
: v < 10 ? 'transparent'
|
||
: v < 50 ? 'rgba(180,200,80,0.13)'
|
||
: v < 200 ? 'rgba(210,150,50,0.16)'
|
||
: 'rgba(200,70,50,0.18)';
|
||
const color = v != null && v >= 200 ? '#8a2010' : v != null && v >= 50 ? '#7a4a10' : '#4a3218';
|
||
const label = v == null ? '—'
|
||
: v < 10 ? `${Math.round(v)} Low`
|
||
: v < 50 ? `${Math.round(v)} Mod`
|
||
: v < 200 ? `${Math.round(v)} High`
|
||
: `${Math.round(v)} V.High`;
|
||
return html`<td class=${groupStart('pollen')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
|
||
})()}
|
||
${visibleCols.uvA && html`
|
||
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
|
||
${r.uvA > 0 ? fmt(r.uvA) + u(' idx') : '—'}
|
||
</td>`}
|
||
${visibleCols.uvB && html`
|
||
<td class=${groupStart('uvB')} style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
|
||
${r.uvB > 0 ? fmt(r.uvB, 2) + u(' idx') : '—'}
|
||
</td>`}
|
||
${visibleCols.sun && html`<td class=${groupStart('sun')} style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? fmt(r.elev) + u('°') : '—'}</td>`}
|
||
${visibleCols.direct && html`<td class=${groupStart('direct')} style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}${u('W/m²')}</td>`}
|
||
${visibleCols.diffuse && html`<td class=${groupStart('diffuse')} style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}${u('W/m²')}</td>`}
|
||
</tr>`;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
|
||
<aside class="glance-rail">
|
||
${((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
|
||
<div class="insight-panel insight-panel--glance">
|
||
<div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div>
|
||
${(() => {
|
||
const heatEvents = eventGlanceItems.filter(e => /heat caution|heat warning|extreme heat/i.test(e.label)).map(e => ({ ...e, alert: true }));
|
||
const otherEvents = eventGlanceItems.filter(e => !/heat caution|heat warning|extreme heat/i.test(e.label));
|
||
const renderRow = ({ icon, label, value, alert, grp }, keyPrefix = '') => html`
|
||
<div class=${`insight-row${alert ? ' insight-row--alert' : ''}${grp ? ` insight-row--${grp}` : ''}`} key=${`${keyPrefix}${label}`}>
|
||
<span class="insight-icon">${icon}</span>
|
||
<span class="insight-label">${label}</span>
|
||
<span class="insight-value">${value}</span>
|
||
</div>`;
|
||
// Only the outdoors profile reorders items around the heat block
|
||
// (it has a 'Peak felt temp' anchor). Other profiles render in order.
|
||
const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
|
||
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
|
||
const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null;
|
||
const climateRows = glanceSummary.filter(i => i.label === '1991-2020 High' || i.label === '1991-2020 Average');
|
||
const restSummary = glanceSummary.filter(i => i.label !== '1991-2020 High' && i.label !== '1991-2020 Average' && !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time')));
|
||
return [
|
||
...restSummary.flatMap(item => [
|
||
renderRow(item),
|
||
// After the peak/heat block, drop in good driving time then the comfortable window
|
||
...(item.label === 'Peak felt temp'
|
||
? [
|
||
...heatEvents.map(e => renderRow(e, 'ev-')),
|
||
...(drivingItem ? [renderRow(drivingItem)] : []),
|
||
...(comfortItem ? [renderRow(comfortItem)] : []),
|
||
]
|
||
: []),
|
||
]),
|
||
...climateRows.map(item => renderRow(item)),
|
||
...otherEvents.map(e => renderRow(e, 'ev-')),
|
||
];
|
||
})()}
|
||
</div>`}
|
||
${weekAhead.length > 0 && html`
|
||
<div class="insight-panel insight-panel--week">
|
||
<div class="insight-panel-title">The Week Ahead</div>
|
||
${weekAhead.map(({ icon, label, value, grp }) => html`
|
||
<div class=${`insight-row${grp ? ` insight-row--${grp}` : ''}`} key=${label}>
|
||
<span class="insight-icon">${icon}</span>
|
||
<span class="insight-label">${label}</span>
|
||
<span class="insight-value">${value}</span>
|
||
</div>`)}
|
||
</div>`}
|
||
${visible.length > 0 && html`
|
||
<div class="export-panel">
|
||
${isPro
|
||
? html`
|
||
<button class="export-day-btn" onClick=${handleExportDay} title="Download full hourly data for this day as a spreadsheet">
|
||
<span class="export-day-btn__icon">⬇</span>
|
||
Export day data
|
||
</button>
|
||
<div class="export-day-hint">Excel / LibreOffice · 41 columns · hourly</div>`
|
||
: html`
|
||
<button class="export-day-btn export-day-btn--locked"
|
||
onClick=${() => { setProPromptSource('export'); setProPromptDay(0); }}
|
||
title="Export day data is part of SunScope Extra">
|
||
<span class="export-day-btn__icon">🔒</span>
|
||
Export day data
|
||
</button>
|
||
<div class="export-day-hint">SunScope Extra feature</div>`}
|
||
</div>`}
|
||
</aside>`}
|
||
</div>
|
||
|
||
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
|
||
<div
|
||
ref=${colPopupRef}
|
||
class=${`col-info-popup${colPopup.below ? ' col-info-popup--below' : ''}`}
|
||
style=${{
|
||
position: 'fixed',
|
||
left: `${colPopup.x}px`,
|
||
top: `${colPopup.y}px`,
|
||
transform: colPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||
'--arrow-left': `${colPopup.arrowLeft}px`,
|
||
}}
|
||
onMouseEnter=${handlePopupEnter}
|
||
onMouseLeave=${handlePopupLeave}
|
||
>
|
||
<button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
|
||
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
|
||
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}</p>
|
||
${COL_DESCRIPTIONS[colPopup.key].link && html`
|
||
<a class="col-info-more" href=${COL_DESCRIPTIONS[colPopup.key].link} target="_blank" rel="noopener noreferrer">
|
||
More about this column →
|
||
</a>`}
|
||
</div>`}
|
||
|
||
${eventTagPopup && (() => {
|
||
const evs = eventTagPopup.events;
|
||
const ev = evs[evSlideIndex] || evs[0];
|
||
return html`
|
||
<div
|
||
ref=${eventTagPopupRef}
|
||
class=${`col-info-popup col-info-popup--ev${eventTagPopup.below ? ' col-info-popup--below' : ''}`}
|
||
style=${{
|
||
position: 'fixed',
|
||
left: `${eventTagPopup.x}px`,
|
||
top: `${eventTagPopup.y}px`,
|
||
transform: eventTagPopup.below ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||
'--arrow-left': `${eventTagPopup.arrowLeft}px`,
|
||
}}
|
||
onMouseEnter=${handleEventTagPopupEnter}
|
||
onMouseLeave=${handleEventTagPopupLeave}
|
||
>
|
||
<button class="col-info-close" onClick=${closeEventTagPopup} aria-label="Close">×</button>
|
||
<div class=${`ev-popup-stage${evTransition ? ` ev-popup-${evTransition}` : ''}`}>
|
||
<strong class="col-info-title">${ev.emoji} ${ev.title}</strong>
|
||
<p class="col-info-desc">${ev.message}</p>
|
||
</div>
|
||
${evs.length > 1 && html`
|
||
<div class="ev-popup-dots">
|
||
${evs.map((_, i) => html`
|
||
<span
|
||
key=${i}
|
||
class=${`ev-popup-dot${i === evSlideIndex ? ' active' : ''}`}
|
||
onClick=${() => evSlideTo(i)}
|
||
/>
|
||
`)}
|
||
</div>
|
||
`}
|
||
</div>`;
|
||
})()}
|
||
|
||
<div class="utci-legend">
|
||
<span class="utci-legend-label">Thermal stress bands</span>
|
||
<div class="utci-legend-row">
|
||
${[
|
||
{ t: -9, label: 'Freezing', value: '< 0°C' },
|
||
{ t: 1, label: 'Cold', value: '0–10°C' },
|
||
{ t: 11, label: 'Cool', value: '10–19°C' },
|
||
{ t: 21, label: 'Comfortable', value: '19–24°C', bold: true },
|
||
{ t: 25, label: 'Warm', value: '24–27°C' },
|
||
{ t: 29, label: 'Caution', value: '27–32°C' },
|
||
{ t: 36, label: 'Extreme', value: '32–41°C' },
|
||
{ t: 47, label: 'Danger', value: '41°C+' },
|
||
].map((b, i) => {
|
||
const rgb = airTempRgb(b.t) || [200, 200, 200];
|
||
const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
|
||
const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
|
||
const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
|
||
const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
|
||
const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
|
||
return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
|
||
<span class="utci-legend-item-label">${b.label}</span>
|
||
<span class="utci-legend-item-value">${b.value}</span>
|
||
</span>`;
|
||
})}
|
||
</div>
|
||
|
||
${(visibleCols.furSurfaceT || visibleCols.petHomeT || visibleCols.petShadeT || visibleCols.pawT) && html`
|
||
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
|
||
<div class="utci-legend-row">
|
||
${[
|
||
{ t: -15, label: 'Freezing', value: '< -8°C' },
|
||
{ t: 0, label: 'Cold', value: '-8–2°C' },
|
||
{ t: 9, label: 'Cool', value: '2–11°C' },
|
||
{ t: 18, label: 'Comfortable', value: '11–25°C', bold: true },
|
||
{ t: 28, label: 'Warm', value: '25–32°C' },
|
||
{ t: 36, label: 'Caution', value: '32–40°C' },
|
||
{ t: 46, label: 'Extreme', value: '40–52°C' },
|
||
{ t: 60, label: 'Danger', value: '52°C+' },
|
||
].map((b, i) => {
|
||
// Exact same recipe as the human legend above (same 135deg
|
||
// light/mid/dark sweep, same luminance-based font colour) -
|
||
// just reading from petAirTempRgb instead of airTempRgb.
|
||
const rgb = petAirTempRgb(b.t) || [200, 200, 200];
|
||
const mid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
const light = `rgb(${rgb.map(c => Math.min(255, Math.round(c + (255-c)*0.30))).join(',')})`;
|
||
const dark = `rgb(${rgb.map(c => Math.round(c * 0.96)).join(',')})`;
|
||
const bg = `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
|
||
const lum = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
|
||
const fg = lum > 165 ? '#1a1a1a' : '#ffffff';
|
||
return html`<span key=${i} class="utci-legend-item" style=${{ background: bg, color: fg, ...(b.bold ? { fontWeight: 700 } : {}) }}>
|
||
<span class="utci-legend-item-label">${b.label}</span>
|
||
<span class="utci-legend-item-value">${b.value}</span>
|
||
</span>`;
|
||
})}
|
||
</div>`}
|
||
</div>
|
||
|
||
|
||
${(() => {
|
||
const upcoming = getUpcomingEvents(location, 3650).slice(0, 4);
|
||
const formatPeak = (iso) => {
|
||
const d = new Date(iso + 'T00:00Z');
|
||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||
};
|
||
const countdownLabel = (days) => {
|
||
if (days === 0) return 'Tonight!';
|
||
if (days === 1) return 'Tomorrow';
|
||
if (days < 7) return `In ${days} days`;
|
||
if (days < 14) return 'Next week';
|
||
if (days < 60) return `In ${Math.round(days / 7)} weeks`;
|
||
return `In ${Math.round(days / 30)} months`;
|
||
};
|
||
return html`
|
||
<div class="almanac-panel">
|
||
<div class="almanac-header">
|
||
<span class="almanac-title">🔭 What's Coming</span>
|
||
<span class="almanac-subtitle">Cosmic events · next 4 events · ${location.name}</span>
|
||
</div>
|
||
<div class="almanac-list">
|
||
${upcoming.length === 0
|
||
? html`<div class="almanac-empty">No major cosmic events on the horizon — clear skies ahead.</div>`
|
||
: upcoming.map(ev => html`
|
||
<div key=${ev.id} class="almanac-entry">
|
||
<div class="almanac-entry-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||
<div class="almanac-entry-icon">${ev.emoji}</div>
|
||
<div class="almanac-entry-body">
|
||
<div class="almanac-entry-head">
|
||
<span class="almanac-entry-title">${ev.title}</span>
|
||
<span class="almanac-entry-date">${formatPeak(ev.peak)}</span>
|
||
<span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
|
||
</div>
|
||
<div class="almanac-entry-desc">${ev.desc}</div>
|
||
${ev.visibilityNote && html`
|
||
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`)
|
||
}
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
|
||
<div class="utci-bottom-grid">
|
||
<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 — as its
|
||
foundation, combining air temperature, humidity, wind, and solar radiation into a single
|
||
honest number. Then it goes further. Our <strong>SunSoak</strong> index layers three extra
|
||
dimensions on top: a rain and snow penalty so wet, windy days read as cold as they feel;
|
||
an <strong>environment modifier</strong> that adjusts the solar load for where you actually
|
||
are — forest canopy, alpine altitude, lakeside glare, shaded riverbank, desert ground heat;
|
||
and the full radiant heat absorbed from surrounding surfaces. One number that honestly
|
||
answers: <em>what will my body actually feel out there?</em>
|
||
</p>
|
||
<p class="utci-about-text">
|
||
Beyond SunSoak, SunScope calculates <strong>vehicle cabin heat</strong> (choose your
|
||
vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven building
|
||
types including office blocks; 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 — Places, Activities, Work — 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-reading-box">
|
||
<h2 class="utci-about-heading">Reading the table</h2>
|
||
<p class="utci-about-text">
|
||
SunSoak is the number to watch — it reflects everything your body
|
||
actually experiences, not just what the thermometer says. A large gap between SunSoak and Air
|
||
temperature means solar radiation is doing significant work on your body. On clear sunny days
|
||
that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the
|
||
SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are
|
||
for the most accurate reading.
|
||
${' '}<a onClick=${openWelcome}
|
||
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none', cursor: 'pointer' }}>
|
||
How it works →
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
${isPro && html`
|
||
<div class="utci-footer">
|
||
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0', textAlign: 'center' }}>
|
||
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>
|
||
`}
|
||
|
||
<footer class="utci-site-footer">
|
||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a>
|
||
· <a href="./index.html">Forecast</a>
|
||
· <a href="./about.html">About</a>
|
||
· <a href="./faq.html">FAQ</a>
|
||
· <a href=${isPro
|
||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||
target="_blank" rel="noopener noreferrer">Account</a>
|
||
· Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>
|
||
· UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||
</footer>
|
||
</main>
|
||
</div>`;
|
||
}
|