UI Overhaul Profiles now in popup Current profile always on screen Hidden columns only when edit neeeded Add pulldowns in to profile config Customised the day tabs for vehicle, indoor and pet
1879 lines
120 KiB
JavaScript
1879 lines
120 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 } from './components.js';
|
||
import { getCellTagEvents, getUpcomingEvents } 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 } 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),
|
||
};
|
||
}
|
||
|
||
// ── 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,
|
||
forecast, airQuality, loading, error, now, fetchedAt, normals,
|
||
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,
|
||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||
bannerVisible, bannerStageRef,
|
||
bannerSlideTo, dismissBanner,
|
||
} = 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);
|
||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||
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', 'vehicleT', 'indoorT', 'managedT', 'petHomeT', 'burn', 'utci', 'delta', 'tmrt'],
|
||
surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'],
|
||
ambient: ['shadeT', 'petShadeT', '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)));
|
||
};
|
||
const airTempRgbStrong = (t) => airTempRgb(t, 0.42);
|
||
const airTempRgbVeryStrong = (t) => airTempRgb(t, 0.25);
|
||
|
||
// 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;
|
||
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,
|
||
);
|
||
|
||
// 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,
|
||
}));
|
||
|
||
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
|
||
// Everything below is the actual page markup, written as one big HTM
|
||
// template. Search tips:
|
||
// • "utci-header" — the top section (title + dial + search)
|
||
// • "utci-day-tabs" — the 14 day buttons with band colours
|
||
// • "col-toggles" — the column-customisation row (Pro only)
|
||
// • "utci-table" — the hourly table itself
|
||
// • "utci-legend" — the thermal-stress band legend
|
||
// • "utci-about" — the explainer paragraphs at the bottom
|
||
// • "utci-footer" — the "reading the table" note
|
||
return html`
|
||
<div class="utci-app">
|
||
<nav class="utci-topnav" id="site-nav">
|
||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||
<div class="nav-logo" aria-hidden="true">
|
||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><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>
|
||
|
||
<!-- Event flyout — a direct child of .utci-app (sibling of the nav) so its
|
||
position:fixed / z-index can sit above the nav. Keeping it inside
|
||
.utci-shell would trap it in the shell's stacking context. -->
|
||
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
||
${activeEvents.length > 0 && (() => {
|
||
const renderSlide = (idx, isOutgoing) => {
|
||
const ev = activeEvents[idx] || activeEvents[0];
|
||
const fmtDate = (iso) => iso
|
||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||
: null;
|
||
const startFmt = fmtDate(ev.start);
|
||
const peakFmt = fmtDate(ev.peak);
|
||
const endFmt = fmtDate(ev.end);
|
||
const showDates = startFmt && endFmt;
|
||
const dateLine = showDates
|
||
? (startFmt === endFmt
|
||
? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}`
|
||
: peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
|
||
? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}`
|
||
: `Active ${startFmt} – ${endFmt}`)
|
||
: null;
|
||
return html`
|
||
<div class=${isOutgoing ? 'event-banner event-banner--outgoing' : 'event-banner'}
|
||
style=${{ background: ev.color, color: ev.textColor }}
|
||
onClick=${isOutgoing ? undefined : () => dismissBanner(ev.id)}
|
||
role=${isOutgoing ? undefined : 'button'}
|
||
title=${isOutgoing ? undefined : 'Click to dismiss'}
|
||
>
|
||
<span class="event-banner-emoji">${ev.emoji}</span>
|
||
<div class="event-banner-body">
|
||
<div class="event-banner-title">${ev.title}</div>
|
||
<div class="event-banner-msg">${ev.message}</div>
|
||
${dateLine && html`
|
||
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'Manrope, sans-serif', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', marginTop: '5px' }}>
|
||
${dateLine}
|
||
</div>
|
||
`}
|
||
${activeEvents.length > 1 && html`
|
||
<div class="event-banner-dots" onClick=${(e) => e.stopPropagation()}>
|
||
${activeEvents.map((_, i) => html`
|
||
<span
|
||
key=${i}
|
||
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
|
||
onClick=${(e) => { e.stopPropagation(); bannerSlideTo(i); }}
|
||
style=${{ background: ev.textColor }}
|
||
/>
|
||
`)}
|
||
</div>
|
||
`}
|
||
</div>
|
||
${!isOutgoing && html`<button
|
||
class="event-banner-dismiss"
|
||
style=${{ color: ev.textColor }}
|
||
onClick=${(e) => { e.stopPropagation(); dismissBanner(ev.id); }}
|
||
aria-label="Dismiss"
|
||
title="Dismiss this event"
|
||
>✕</button>`}
|
||
</div>`;
|
||
};
|
||
return html`
|
||
<div class="event-banner-stage" ref=${bannerStageRef}>
|
||
${bannerTransition === 'crossfading' && bannerPrevIndex !== null
|
||
? renderSlide(bannerPrevIndex, true)
|
||
: null}
|
||
${renderSlide(bannerIndex, false)}
|
||
</div>`;
|
||
})()}
|
||
</div>
|
||
|
||
<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>
|
||
<span class="utci-loc-coords">
|
||
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
|
||
</span>
|
||
</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 ?? 'Day cycle'}</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" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
|
||
⚠ ${error}
|
||
</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' : '')} onClick=${() => setColTogglesOpen(v => !v)}>
|
||
<span class="col-toggles-edit-btn-label">${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 showVehicle = visibleCols.vehicleT;
|
||
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
|
||
if (!showFur && !showSolar && !showVehicle && !showIndoor) return null;
|
||
const furOn = simpleTemp === 'furSurfaceT';
|
||
const solarOn = simpleTemp === 'utciAdj';
|
||
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>`}
|
||
${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 class=${'col-toggles' + (colTogglesOpen ? ' col-toggles--open' : '')}>
|
||
<span class="fvt-interval">
|
||
<span class="fvt-interval-label">Hours:</span>
|
||
${[1, 2, 3, 4].map(n => html`<button key=${n} type="button" class=${'hour-interval-btn' + (tableInterval === n ? ' on' : '')} title=${n === 1 ? 'Every hour' : `Every ${n} hours`} onClick=${() => setTableInterval(n)}>${n}h</button>`)}
|
||
</span>
|
||
<div class="col-toggles-body">
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT']) && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur Colour</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pawT']) : activeCols['pawT']) && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petShadeT']) : activeCols['petShadeT']) && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['petHomeT']) : activeCols['petHomeT']) && html`<button class=${`col-toggle grp-felt${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn']) && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utci']) && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['delta']) && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['tmrt']) && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['concreteT']) && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT']) && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilT6']) && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['soilM']) && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['shadeT']) && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['air']) && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['rh']) && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dew']) && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precip']) && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['precipProb']) && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['lightning']) && html`<button class=${`col-toggle grp-precip${visibleCols.lightning ? ' on' : ''}`} onClick=${() => toggleCol('lightning')}>Lightning</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['cloud']) && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vis']) && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['wind']) && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['dir']) && html`<button class=${`col-toggle grp-wind${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['aqi']) && html`<button class=${`col-toggle grp-airqual${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
|
||
${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen']) && html`<button class=${`col-toggle grp-airqual${visibleCols.pollen ? ' on' : ''}`} onClick=${() => toggleCol('pollen')}>Pollen</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvA']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['uvB']) && html`<button class=${`col-toggle grp-solar${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['sun']) && html`<button class=${`col-toggle grp-solar${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['direct']) && html`<button class=${`col-toggle grp-solar${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['diffuse']) && html`<button class=${`col-toggle grp-solar${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||
|
||
<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 class="forecast-simple-wrap">
|
||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||
<div class="forecast-simple-inner">
|
||
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}>
|
||
${tableRows.map(r => {
|
||
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);
|
||
return html`
|
||
<div key=${r.iso} class=${'fsc-card' + (isNow ? ' fsc-card--now' : '')}>
|
||
<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=${{background: cat.bg, color: cat.fg}}>${cat.label}</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
</div>
|
||
${(() => {
|
||
if (!tableRows.length) return null;
|
||
const fscN = tableRows.length;
|
||
const fscSvgW = 1000, fscSvgH = 80;
|
||
const fscCardW = fscSvgW / fscN;
|
||
// Use full 1-hour data for the smooth curve, tableRows for connectors/gradient
|
||
const fscSrc = visible.length > fscN ? visible : tableRows;
|
||
// 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;
|
||
// 1-hour points for the smooth curve — evenly spread across SVG width
|
||
const fscAllPts = fscSrc.map((r, j) => ({
|
||
x: (j + 0.5) * fscSvgW / fscSrc.length,
|
||
y: toY(getT(r)),
|
||
}));
|
||
// Connector points — x centred under card column, y at the exact point
|
||
// the 1-hour bezier curve passes through at that x.
|
||
// For a bucket of size n starting at visible index jFirst, the card
|
||
// centre x lands at the bezier midpoint between visible[jFirst+(n-1)/2 floor]
|
||
// and visible[jFirst+(n-1)/2 ceil], so y = lerp of those two neighbours.
|
||
let fscVj = 0;
|
||
const fscPts = tableRows.map((r, i) => {
|
||
const bucketLen = r.isoHours ? r.isoHours.length : 1;
|
||
const jFirst = fscVj;
|
||
fscVj += bucketLen;
|
||
const x = (i + 0.5) * fscCardW;
|
||
const ctr = (bucketLen - 1) / 2;
|
||
const jLow = Math.min(jFirst + Math.floor(ctr), fscSrc.length - 1);
|
||
const jHigh = Math.min(jFirst + Math.ceil(ctr), fscSrc.length - 1);
|
||
const frac = ctr - Math.floor(ctr);
|
||
const tCtr = getT(fscSrc[jLow]) * (1 - frac) + getT(fscSrc[jHigh]) * frac;
|
||
return { x, y: toY(tCtr) };
|
||
});
|
||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||
// Extended fill points (edge-anchored) for the gradient area
|
||
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 = airTempRgbStrong(getT(r)) || [200, 200, 200];
|
||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
return html`<stop key=${j} offset=${`${((j + 0.5) / fscSrc.length * 100).toFixed(1)}%`} 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 = airTempRgbVeryStrong(getT(r)) || [200, 200, 200];
|
||
const fc = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
|
||
return html`<stop key=${`s${j}`} offset=${`${((j + 0.5) / fscSrc.length * 100).toFixed(1)}%`} 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>Hour</span>
|
||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||
${[1, 2, 3, 4].map(n => html`
|
||
<button key=${n} type="button"
|
||
class=${`hour-interval-btn${tableInterval === n ? ' on' : ''}`}
|
||
title=${n === 1 ? 'Every hour' : `Every ${n} hours`}
|
||
onClick=${(e) => { e.stopPropagation(); setTableInterval(n); }}
|
||
>${n}h</button>`)}
|
||
</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.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.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.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.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</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.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="col-unit">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="col-unit">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.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.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.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.concreteT)?.label ?? ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.concreteT, rPrev?.concreteT, rNext?.concreteT) }}>
|
||
${fmt(r.concreteT)}${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.shadeT && html`<td class=${groupStart('shadeT')} style=${{ background: airTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT), color: airTempFontColor() }}>${r.shadeT != null ? fmt(r.shadeT) + u('°C') : '—'}</td>`}
|
||
${visibleCols.petShadeT && html`
|
||
<td class=${groupStart('petShadeT')} title=${r.shadeT != null ? (petCategory(r.shadeT)?.label ?? '') : ''} style=${{ color: airTempFontColor(), background: petAirTempBg(r.shadeT, rPrev?.shadeT, rNext?.shadeT) }}>
|
||
${r.shadeT != null ? fmt(r.shadeT) + 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')} 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')} 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>`}
|
||
${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].desc}</p>
|
||
</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>`;
|
||
}
|