1565 lines
89 KiB
JavaScript
1565 lines
89 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════
|
||
// app.js — Main UTCIForecast component.
|
||
//
|
||
// This is the top-level Preact component that owns all state, fetches
|
||
// the forecast, runs the per-hour computations, and renders the page.
|
||
//
|
||
// Reading order inside UTCIForecast():
|
||
// 1. STATE (useState calls) — bits that change on interaction
|
||
// 2. EFFECTS (useEffect calls) — runs on search / location change
|
||
// 3. COMPUTATION (hourlyRows, days, …) — API data → display rows
|
||
// 4. JSX RETURN (the big html`...`) — actual page markup
|
||
//
|
||
// QUICK MAP
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Forecast length .............. fetch URL contains &forecast_days=14
|
||
// Free tier day limit .......... const FREE_DAYS = 3
|
||
// Preview the Pro view ......... useState(false) on isPro → flip to true
|
||
// Starting location ............ useState({...}) on `location` near top
|
||
// Default columns shown ........ useState({...}) on visibleCols
|
||
// Page tagline / about copy .... search "utci-tagline" or "utci-about-text"
|
||
// ════════════════════════════════════════════════════════════════════════
|
||
|
||
import { h, render, Fragment } from '../vendor/preact.js';
|
||
import { useState, useEffect, useRef, useCallback } from '../vendor/preact-hooks.js';
|
||
import htm from '../vendor/htm.js';
|
||
import {
|
||
utciCategory,
|
||
SKIN_TYPES, sunburnMinutes, burnLabel,
|
||
VEHICLE_TYPES, BUILDING_TYPES,
|
||
confidenceBand, moonGlyph,
|
||
} from './utils.js';
|
||
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
|
||
import { getActiveEvents, getLensEvent, getCellTagEvents, getUpcomingEvents } from './events.js';
|
||
import {
|
||
FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, POLLEN_TYPES,
|
||
profileButtonOrder, variantIcons,
|
||
activityVariantKeys, placeVariantKeys,
|
||
COL_DESCRIPTIONS,
|
||
} from './config.js';
|
||
import { buildHourlyRows } from './compute.js';
|
||
import { useForecast } from './hooks/useForecast.js';
|
||
import { useColumnPopup } from './hooks/useColumnPopup.js';
|
||
import { useTableScroll } from './hooks/useTableScroll.js';
|
||
|
||
const html = htm.bind(h);
|
||
|
||
export function UTCIForecast() {
|
||
|
||
// ── 1. STATE ──────────────────────────────────────────────────────────
|
||
// Each useState() pairs a value with a setter. Calling the setter
|
||
// re-renders the page with the new value.
|
||
|
||
// The location we're forecasting for. Restored from localStorage if the
|
||
// user has visited before, otherwise defaults to Pangbourne, Berkshire.
|
||
const [location, setLocation] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem('sunscope_last_location');
|
||
if (saved) return JSON.parse(saved);
|
||
} catch (e) { /* ignore */ }
|
||
return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' };
|
||
});
|
||
|
||
// Wrapper that persists the location before updating state.
|
||
const setLocationAndSave = (loc) => {
|
||
try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
|
||
setLocation(loc);
|
||
};
|
||
|
||
// Forecast data is owned by useForecast — it fetches on location change,
|
||
// auto-refreshes every 5 minutes, and exposes the air-quality side data too.
|
||
const { forecast, airQuality, loading, error, now } = useForecast(location);
|
||
|
||
const [searchQuery, setSearchQuery] = useState(''); // text in the search box
|
||
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
|
||
const [searching, setSearching] = useState(false); // search-in-flight flag
|
||
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
|
||
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
|
||
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
|
||
|
||
// Day-tabs horizontal scrolling — chevrons show only when there's more
|
||
// content to reveal in that direction. Auto-scrolls active tab into view.
|
||
const dayTabsRef = useRef(null);
|
||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||
// Re-runs whenever the number of day tabs changes (e.g. when the
|
||
// forecast finishes loading and the tabs first appear). Also re-measures
|
||
// on scroll, on window resize, and via ResizeObserver if the element's
|
||
// own width changes (e.g. layout shifts when sidebar opens).
|
||
useEffect(() => {
|
||
const el = dayTabsRef.current;
|
||
if (!el) return;
|
||
const update = () => {
|
||
setCanScrollLeft(el.scrollLeft > 1);
|
||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||
};
|
||
update();
|
||
el.addEventListener('scroll', update, { passive: true });
|
||
window.addEventListener('resize', update);
|
||
let ro = null;
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
}
|
||
return () => {
|
||
el.removeEventListener('scroll', update);
|
||
window.removeEventListener('resize', update);
|
||
if (ro) ro.disconnect();
|
||
};
|
||
}, [forecast]);
|
||
useEffect(() => {
|
||
const el = dayTabsRef.current;
|
||
if (!el) return;
|
||
const activeTab = el.querySelector('.utci-day-tab.active');
|
||
if (!activeTab) return;
|
||
const elRect = el.getBoundingClientRect();
|
||
const tabRect = activeTab.getBoundingClientRect();
|
||
if (tabRect.left < elRect.left + 8) {
|
||
el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' });
|
||
} else if (tabRect.right > elRect.right - 8) {
|
||
el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' });
|
||
}
|
||
}, [selectedDay]);
|
||
const scrollDayTabs = (dir) => {
|
||
const el = dayTabsRef.current;
|
||
if (!el) return;
|
||
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
|
||
};
|
||
|
||
// ─── PRO TIER ─────────────────────────────────────────────────────────
|
||
// Initialise from URL param (?pro=1, set by Stripe after checkout) or
|
||
// from localStorage (returning subscriber). Cleans the URL param
|
||
// immediately so it doesn't stay visible in the address bar.
|
||
const [isPro, setIsPro] = useState(() => {
|
||
// Check URL param first (just returned from Stripe checkout)
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('pro') === '1') {
|
||
localStorage.setItem('sunscope_pro', '1');
|
||
// Clean the URL so the param doesn't stay visible
|
||
window.history.replaceState({}, '', window.location.pathname);
|
||
return true;
|
||
}
|
||
// Check localStorage (returning Pro subscriber)
|
||
return localStorage.getItem('sunscope_pro') === '1';
|
||
});
|
||
|
||
// (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.)
|
||
|
||
// Current active filter profile
|
||
const [activeProfile, setActiveProfile] = useState('basic');
|
||
|
||
// Which columns appear in the hourly table by default.
|
||
// true = visible on first load (and the only ones free users see)
|
||
// false = hidden by default (Pro users can toggle these on)
|
||
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols, vis: false, aqi: false, pollen: false });
|
||
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
||
|
||
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
|
||
const [skinType, setSkinType] = useState('II');
|
||
|
||
// Vehicle type for the cabin heat column. 'car' is the default preset.
|
||
const [vehicleType, setVehicleType] = useState('car');
|
||
|
||
// Places profile sub-variant (urban / beach / events / festival / wintersports / naturist)
|
||
const [outdoorsVariant, setOutdoorsVariant] = useState('urban');
|
||
|
||
// Vehicle ventilation — true = windows open (high convective loss).
|
||
const [vehicleVent, setVehicleVent] = useState(false);
|
||
|
||
// Indoor: buildingType drives the physics preset; indoorManaged toggles the
|
||
// curtains+ventilation model on top. indoorMode gates column visibility:
|
||
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
|
||
const [buildingType, setBuildingType] = useState('brick');
|
||
const [indoorManaged, setIndoorManaged] = useState(false);
|
||
const [indoorMode, setIndoorMode] = useState('off');
|
||
|
||
// Pollen type for the pollen column. Persisted in localStorage.
|
||
const [pollenType, setPollenType] = useState(() => {
|
||
try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
|
||
});
|
||
const setPollenTypeAndSave = (v) => {
|
||
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
|
||
setPollenType(v);
|
||
};
|
||
|
||
// (POLLEN_TYPES now lives in ./config.js.)
|
||
|
||
const searchTimeout = useRef(null);
|
||
|
||
const activateProfile = (key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
setActiveProfile(key);
|
||
if (key !== 'custom') {
|
||
setVisibleCols({ ...profile.cols });
|
||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||
setIndoorManaged(false);
|
||
}
|
||
};
|
||
|
||
// (profileButtonOrder, variantIcons, activityVariantKeys,
|
||
// placeVariantKeys now live in ./config.js.)
|
||
|
||
const activityOptions = [
|
||
{
|
||
value: 'farming',
|
||
label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`,
|
||
},
|
||
...activityVariantKeys.map((k) => {
|
||
const v = OUTDOORS_VARIANTS[k];
|
||
return {
|
||
value: k,
|
||
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||
};
|
||
}),
|
||
];
|
||
const placeOptions = placeVariantKeys.map((k) => {
|
||
const v = OUTDOORS_VARIANTS[k];
|
||
return {
|
||
value: k,
|
||
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||
};
|
||
});
|
||
// activeCols: the effective column set for the current profile/variant.
|
||
// Use this everywhere instead of FILTER_PROFILES[activeProfile].cols so that
|
||
// outdoors variants each get their own cols rather than the generic outdoors cols.
|
||
const activeCols = activeProfile === 'outdoors'
|
||
? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols)
|
||
: (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols);
|
||
|
||
const activityValue = activeProfile === 'farming'
|
||
? 'farming'
|
||
: activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
|
||
? outdoorsVariant
|
||
: 'off';
|
||
const activityLabel = activityOptions.find((option) => option.value === activityValue)?.label;
|
||
const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
|
||
? outdoorsVariant
|
||
: 'off';
|
||
const placeLabel = placeOptions.find((option) => option.value === placeValue)?.label;
|
||
|
||
// Refs for the two-scroller table layout (sticky-to-viewport header +
|
||
// horizontally-scrolling body). The header is clipped (overflow:hidden)
|
||
// and its inner "track" gets translateX'd via JS to follow the body's
|
||
// scrollLeft. See the useLayoutEffect just below where the JS sync
|
||
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
|
||
const headStickyRef = useRef(null);
|
||
const headTrackRef = useRef(null);
|
||
const headTableRef = useRef(null);
|
||
const bodyScrollRef = useRef(null);
|
||
const bodyTableRef = useRef(null);
|
||
const tableWrapRef = useRef(null);
|
||
|
||
// ─── COLUMN HEADER & EVENT TAG POPUPS ────────────────────────────────
|
||
// All popup state + handlers live in the useColumnPopup hook. The JSX
|
||
// wires up the th cells and event-tag spans with the handlers returned
|
||
// here, and reads colPopup / eventTagPopup to know when to render the
|
||
// floating panel.
|
||
const {
|
||
colPopup, colPopupRef,
|
||
handleThClick, handleThEnter, handleThLeave,
|
||
handlePopupEnter, handlePopupLeave,
|
||
eventTagPopup, eventTagPopupRef,
|
||
evSlideIndex, evTransition, evSlideTo,
|
||
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
|
||
handleEventTagPopupEnter, handleEventTagPopupLeave,
|
||
closePopup, closeEventTagPopup,
|
||
} = useColumnPopup();
|
||
|
||
// (COL_DESCRIPTIONS, calcPopupPos and all the popup handlers now live in
|
||
// ./config.js and ./hooks/useColumnPopup.js respectively.)
|
||
|
||
// ─── TABLE SCROLL ────────────────────────────────────────────────────
|
||
// useTableScroll owns everything horizontal-scroll-related for the
|
||
// hourly table:
|
||
// • scroll indicators (drives fade + chevron CSS)
|
||
// • drag-to-scroll for desktop users
|
||
// • column-width sync between sticky header and body
|
||
// It needs the four table refs and the deps that should trigger a
|
||
// re-sync (anything that changes the rendered cell content / count).
|
||
const {
|
||
tableCanScrollLeft,
|
||
tableCanScrollRight,
|
||
handleBodyScroll,
|
||
} = useTableScroll({
|
||
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef,
|
||
forecast, visibleCols, selectedDay, skinType, vehicleType,
|
||
});
|
||
|
||
// Geocoding search
|
||
useEffect(() => {
|
||
if (searchQuery.length < 2) { setSearchResults([]); return; }
|
||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||
searchTimeout.current = setTimeout(async () => {
|
||
setSearching(true);
|
||
try {
|
||
const r = await fetch(
|
||
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json`
|
||
);
|
||
const j = await r.json();
|
||
setSearchResults(j.results || []);
|
||
} catch { setSearchResults([]); }
|
||
finally { setSearching(false); }
|
||
}, 300);
|
||
}, [searchQuery]);
|
||
|
||
// (FORECAST FETCH, AIR QUALITY FETCH and 5-min AUTO-REFRESH now live in
|
||
// ./hooks/useForecast.js — called at the top of this component.)
|
||
|
||
// ─── COMPUTATION ─────────────────────────────────────────────────────
|
||
// Stitch the raw API arrays into one object per hour (UTCI + soak factor,
|
||
// indoor temps, AQI, pollen, etc) and group into days for the day tabs.
|
||
// See ./compute.js for the full algorithm.
|
||
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
|
||
forecast, airQuality, location, vehicleType, vehicleVent, buildingType,
|
||
});
|
||
|
||
const visible = days[selectedDay]?.rows || [];
|
||
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
|
||
// Used to match against r.iso (which is already a local wall-clock string).
|
||
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
|
||
.toISOString().slice(0, 13); // "YYYY-MM-DDTHH"
|
||
const currentRow = hourlyRows.length > 0
|
||
? (hourlyRows.find(row => row.iso.slice(0, 13) === nowLocalISO)
|
||
?? hourlyRows.reduce((best, row) =>
|
||
Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best))
|
||
: null;
|
||
const currentCat = currentRow
|
||
? utciCategory(currentRow.utciAdj)
|
||
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
|
||
|
||
// ─── COSMIC/WEATHER EVENTS ────────────────────────────────────────────
|
||
// activeEvents — today's events → drives banner & lens overlay.
|
||
// selectedDayEvents — viewed day's events → drives cell tags.
|
||
const [dismissedEventIds, setDismissedEventIds] = useState([]);
|
||
const [bannerIndex, setBannerIndex] = useState(0);
|
||
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
|
||
// bannerVisible drives the .visible class — kept true until the collapse
|
||
// animation finishes so content doesn't vanish before the wrap closes.
|
||
const [bannerVisible, setBannerVisible] = useState(false);
|
||
const bannerIndexRef = useRef(0);
|
||
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
||
|
||
const todayRows = days[0]?.rows || [];
|
||
const activeEvents = getActiveEvents(todayRows, location)
|
||
.filter(ev => !dismissedEventIds.includes(ev.id));
|
||
const lensEvent = getLensEvent(activeEvents);
|
||
const selectedDayEvents = getActiveEvents(visible, location);
|
||
|
||
// Sync visibility: show as soon as events exist, hide after dismiss animation
|
||
useEffect(() => {
|
||
if (activeEvents.length > 0) setBannerVisible(true);
|
||
}, [activeEvents.length]);
|
||
|
||
// Crossfade to a new banner index: fade out → swap → fade in
|
||
const bannerSlideTo = useCallback((next) => {
|
||
setBannerTransition('exiting');
|
||
setTimeout(() => {
|
||
setBannerIndex(next);
|
||
setBannerTransition('entering');
|
||
setTimeout(() => setBannerTransition(null), 420);
|
||
}, 400);
|
||
}, []);
|
||
|
||
// Dismiss with animation: fade stage → collapse wrap → remove event
|
||
const dismissBanner = useCallback((evId) => {
|
||
// 1. fade the stage out (CSS handles this via :not(.visible))
|
||
setBannerVisible(false);
|
||
// 2. after wrap has collapsed (grid transition ~450ms), remove the event
|
||
setTimeout(() => {
|
||
setDismissedEventIds(ids => [...ids, evId]);
|
||
setBannerIndex(0);
|
||
}, 500);
|
||
}, []);
|
||
|
||
// Auto-advance banner slideshow every 10 seconds when multiple events
|
||
useEffect(() => {
|
||
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
|
||
const id = setInterval(() => {
|
||
const next = (bannerIndexRef.current + 1) % activeEvents.length;
|
||
bannerSlideTo(next);
|
||
}, 10000);
|
||
return () => clearInterval(id);
|
||
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
|
||
|
||
// Keep index in bounds if events change
|
||
useEffect(() => {
|
||
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
|
||
}, [activeEvents.length]);
|
||
|
||
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
|
||
// Everything below is the actual page markup, written as one big HTM
|
||
// template. Search tips:
|
||
// • "utci-header" — the top section (title + dial + search)
|
||
// • "utci-day-tabs" — the 14 day buttons with band colours
|
||
// • "col-toggles" — the column-customisation row (Pro only)
|
||
// • "utci-table" — the hourly table itself
|
||
// • "utci-legend" — the thermal-stress band legend
|
||
// • "utci-about" — the explainer paragraphs at the bottom
|
||
// • "utci-footer" — the "reading the table" note
|
||
return html`
|
||
<div class="utci-app">
|
||
<nav class="utci-topnav" id="site-nav">
|
||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||
<div class="nav-logo" aria-hidden="true">
|
||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">SUN</span><span class="nav-logo-scope">Scope</span></span>
|
||
<span class="nav-logo-tag">See the sun the way your body does.</span>
|
||
</div>
|
||
<div class="nav-links" id="nav-drawer">
|
||
<a href="./index.html">Forecast</a>
|
||
<a href="./about.html">About</a>
|
||
<a href=${isPro
|
||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||
target="_blank" rel="noopener noreferrer">Account</a>
|
||
</div>
|
||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
|
||
onClick=${() => {
|
||
const nav = document.getElementById('site-nav');
|
||
const drawer = document.getElementById('nav-drawer');
|
||
const btn = document.getElementById('burger-btn');
|
||
const open = nav.classList.toggle('nav-open');
|
||
drawer.classList.toggle('is-open', open);
|
||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}}>
|
||
<span></span><span></span><span></span>
|
||
</button>
|
||
</nav>
|
||
<main class="utci-shell">
|
||
|
||
<!-- ── EVENT BANNER ── slideshow when multiple events active ── -->
|
||
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
|
||
${activeEvents.length > 0 && (() => {
|
||
const ev = activeEvents[bannerIndex] || 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);
|
||
// Only show date line if the event has start/end (cosmic events do; weather events don't)
|
||
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="event-banner-stage">
|
||
<div class=${`event-banner${bannerTransition ? ` banner-${bannerTransition}` : ''}`}
|
||
style=${{ background: ev.color, color: ev.textColor }}
|
||
>
|
||
<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">
|
||
${activeEvents.map((_, i) => html`
|
||
<span
|
||
key=${i}
|
||
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
|
||
onClick=${() => bannerSlideTo(i)}
|
||
style=${{ background: ev.textColor }}
|
||
/>
|
||
`)}
|
||
</div>
|
||
`}
|
||
</div>
|
||
<button
|
||
class="event-banner-dismiss"
|
||
style=${{ color: ev.textColor }}
|
||
onClick=${() => dismissBanner(ev.id)}
|
||
aria-label="Dismiss"
|
||
title="Dismiss this event"
|
||
>✕</button>
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
</div>
|
||
|
||
<div class="utci-header">
|
||
<div>
|
||
<h1 class="utci-title">
|
||
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
|
||
</h1>
|
||
<div class="utci-tagline">See the sun the way your body does.</div>
|
||
<div class="utci-current-loc">
|
||
↳ ${location.name}
|
||
<span class="utci-loc-coords">
|
||
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<${ScopeReticle}
|
||
value=${currentRow?.utciAdj ?? null}
|
||
cat=${currentCat}
|
||
loading=${loading}
|
||
elev=${currentRow?.elev ?? 0}
|
||
dt=${currentRow?.dt ?? new Date()}
|
||
glob=${currentRow?.glob ?? 0}
|
||
activeEvent=${lensEvent}
|
||
/>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<div class="utci-search-wrap">
|
||
<label class="utci-search-label">Change location</label>
|
||
<input
|
||
class="utci-search"
|
||
type="text"
|
||
placeholder="Search any town or city…"
|
||
value=${searchQuery}
|
||
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
|
||
/>
|
||
${searchResults.length > 0 && html`
|
||
<div class="utci-results">
|
||
${searchResults.map((r) => html`
|
||
<div
|
||
key=${`${r.id}-${r.latitude}`}
|
||
class="utci-result"
|
||
onClick=${() => {
|
||
setLocationAndSave({
|
||
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
|
||
lat: r.latitude,
|
||
lon: r.longitude,
|
||
country: r.country_code,
|
||
});
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
setSelectedDay(0);
|
||
}}
|
||
>
|
||
<div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
|
||
<div class="utci-result-meta">
|
||
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
|
||
</div>
|
||
</div>`)}
|
||
</div>`}
|
||
${searching && html`<div class="utci-searching">Searching…</div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${error && html`
|
||
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
|
||
⚠ ${error}
|
||
</div>`}
|
||
${loading && !error && html`
|
||
<div class="utci-status">Acquiring forecast data…</div>`}
|
||
|
||
${forecast && days.length > 0 && html`
|
||
<${Fragment}>
|
||
<!--
|
||
DAY TABS — one button per day, coloured by confidence band.
|
||
Days 4+ get 🔒'd when isPro is false. To change the lock
|
||
behaviour (e.g. open a paywall modal instead of doing
|
||
nothing), edit the onClick handler below.
|
||
-->
|
||
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
|
||
<button
|
||
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(-1)}
|
||
aria-label="Scroll days left"
|
||
type="button">‹</button>
|
||
<button
|
||
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(1)}
|
||
aria-label="Scroll days right"
|
||
type="button">›</button>
|
||
<div class="utci-day-tabs" ref=${dayTabsRef}>
|
||
${days.map((d, i) => {
|
||
const band = confidenceBand(i);
|
||
const locked = !isPro && i >= FREE_DAYS;
|
||
const isActive = i === selectedDay;
|
||
// d.key is "YYYY-MM-DD" in location-local time — parse as UTC so
|
||
// toLocaleDateString with timeZone:'UTC' reads the correct weekday/date.
|
||
const dDate = new Date(d.key + 'T00:00Z');
|
||
const dayName = i === 0 ? 'Today'
|
||
: i === 1 ? 'Tomorrow'
|
||
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
|
||
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
|
||
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
|
||
|
||
// ── Day-tab weather icon ───────────────────────────────────
|
||
// Use daytime rows (elev > 0) where available, else all rows.
|
||
// Pick the modal cloud category and sum precip/snow to decide
|
||
// whether to show a PrecipIcon or a CloudIcon.
|
||
const dayRows = d.rows.filter(r => r.elev > 0);
|
||
const repRows = dayRows.length > 0 ? dayRows : d.rows;
|
||
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||
// Modal cloud category (most frequent among daytime hours)
|
||
const catCounts = {};
|
||
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
|
||
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
|
||
// Representative solar elevation: midday row or median of daytime rows
|
||
const midRow = repRows[Math.floor(repRows.length / 2)];
|
||
const repElev = midRow ? midRow.elev : 45;
|
||
const repDt = midRow ? midRow.dt : dDate;
|
||
// Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm
|
||
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
||
|
||
return html`
|
||
<button
|
||
key=${d.key}
|
||
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource('day');
|
||
setProPromptDay(i);
|
||
} else {
|
||
setSelectedDay(i);
|
||
setProPromptDay(null); // hide the card on a normal click
|
||
}
|
||
}}
|
||
title=${locked
|
||
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||
: `${band.label} · day ${i + 1} of 14`}
|
||
style=${{
|
||
background: band.bg,
|
||
color: '#2a1d10',
|
||
borderStyle: 'solid',
|
||
borderWidth: '0 0 3px 0',
|
||
borderBottomColor: isActive ? '#1e1208' : band.edge,
|
||
opacity: locked ? 0.5 : 1,
|
||
cursor: locked ? 'not-allowed' : 'pointer',
|
||
position: 'relative',
|
||
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
|
||
}}
|
||
>
|
||
${locked && html`
|
||
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
|
||
${dayName}
|
||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||
</span>
|
||
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
|
||
${showPrecip
|
||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
|
||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
|
||
</span>
|
||
${dayHi !== null && html`
|
||
<span style=${{
|
||
display: 'block',
|
||
fontFamily: 'Fraunces, serif',
|
||
fontStyle: 'italic',
|
||
fontSize: '11px',
|
||
marginTop: '2px',
|
||
letterSpacing: 0,
|
||
textTransform: 'none',
|
||
}}>
|
||
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
|
||
<span style=${{ opacity: 0.5 }}> / </span>
|
||
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
|
||
</span>`}
|
||
</button>`;
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
PRO UPSELL CARD — shown when a locked day is clicked.
|
||
Visible only while proPromptDay !== null. To change the
|
||
copy or pricing, edit the strings below. The "Notify me"
|
||
button is a mailto: link — replace with a real signup
|
||
form when you have one.
|
||
-->
|
||
${proPromptDay !== null && days[proPromptDay] && (() => {
|
||
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
|
||
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
|
||
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||
const extraPromptCopy = {
|
||
'variant:sailing': {
|
||
title: 'Sailing is part of SunScope Extra',
|
||
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
|
||
},
|
||
'profile:alltemps': {
|
||
title: 'Temps is part of SunScope Extra',
|
||
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
|
||
},
|
||
'profile:custom': {
|
||
title: 'Custom columns are part of SunScope Extra',
|
||
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
|
||
},
|
||
'variant:festival': {
|
||
title: 'Festival planning is part of SunScope Extra',
|
||
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
|
||
},
|
||
'variant:wintersports': {
|
||
title: 'Winter Sports is part of SunScope Extra',
|
||
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
|
||
},
|
||
'variant:naturist': {
|
||
title: 'Naturist is part of SunScope Extra',
|
||
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
|
||
},
|
||
};
|
||
const promptCopy = extraPromptCopy[proPromptSource] || {
|
||
title: `${dayName}'s forecast is part of SunScope Extra`,
|
||
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
|
||
};
|
||
return html`
|
||
<div style=${{
|
||
margin: '12px 0',
|
||
padding: '18px 22px',
|
||
background: '#fdf8ee',
|
||
border: '1.5px solid #c9b08a',
|
||
borderLeft: '4px solid #c8922a',
|
||
borderRadius: '0 4px 4px 0',
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '14px',
|
||
}}>
|
||
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
|
||
<div style=${{
|
||
fontFamily: 'Fraunces, serif',
|
||
fontStyle: 'italic',
|
||
fontSize: '19px',
|
||
fontWeight: 700,
|
||
color: '#1e1208',
|
||
marginBottom: '6px',
|
||
lineHeight: 1.25,
|
||
}}>
|
||
🔒 ${promptCopy.title}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontSize: '13.5px',
|
||
color: '#4a3420',
|
||
lineHeight: 1.65,
|
||
}}>
|
||
${promptCopy.detail}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#c8922a',
|
||
marginTop: '10px',
|
||
}}>
|
||
£2 / month · cancel any time
|
||
</div>
|
||
</div>
|
||
<div style=${{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<a
|
||
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
display: 'inline-block',
|
||
padding: '10px 18px',
|
||
background: '#c8922a',
|
||
color: '#fff',
|
||
textDecoration: 'none',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
borderRadius: '3px',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
Subscribe — £2/month
|
||
</a>
|
||
<a
|
||
href="/restore.php"
|
||
style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#c8922a',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Already subscribed? Restore access →
|
||
</a>
|
||
<a
|
||
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#b09870',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(176,152,112,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Manage or cancel subscription →
|
||
</a>
|
||
<button
|
||
onClick=${() => setProPromptDay(null)}
|
||
style=${{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#b09870',
|
||
padding: '2px 4px',
|
||
}}
|
||
>
|
||
dismiss
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
|
||
${(() => {
|
||
const band = confidenceBand(selectedDay);
|
||
const isOutlook = selectedDay >= 7;
|
||
return html`
|
||
<div style=${{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
margin: '8px 0 4px',
|
||
padding: '6px 10px',
|
||
background: band.tint,
|
||
borderLeft: `3px solid ${band.edge}`,
|
||
borderRadius: '0 4px 4px 0',
|
||
fontSize: '12px',
|
||
color: '#3a2a18',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 600,
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
<span style=${{ letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
|
||
</span>
|
||
${isOutlook && html`
|
||
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
|
||
forecast skill is reduced — treat hourly detail as trend, not precision
|
||
</span>`}
|
||
</div>`;
|
||
})()}
|
||
|
||
<!--
|
||
FILTER PROFILE SELECTOR — presets shown to all users.
|
||
Extra profiles are shown in place with a gentle 🔒 and clicking
|
||
them triggers the same upsell prompt as locked days.
|
||
The bottom border is removed only when the col-toggles bar
|
||
follows (Pro users), so the two bars merge into one panel.
|
||
-->
|
||
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
|
||
<span class="filter-profiles-label">Profile:</span>
|
||
${profileButtonOrder.slice(0, 3).map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource(`profile:${key}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
activateProfile(key);
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
<span class="profile-divider" aria-hidden="true"></span>
|
||
<${CustomSelect}
|
||
key="places"
|
||
value=${placeValue}
|
||
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
|
||
noHide=${true}
|
||
hideLabel="🌤️ Places"
|
||
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`}
|
||
options=${placeOptions}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
activateProfile('basic');
|
||
return;
|
||
}
|
||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||
setProPromptSource(`variant:${v}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariant(v);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
/>
|
||
<${CustomSelect}
|
||
key="activities"
|
||
value=${activityValue}
|
||
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
|
||
noHide=${true}
|
||
hideLabel="🎯 Activities"
|
||
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`}
|
||
options=${activityOptions}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
activateProfile('basic');
|
||
return;
|
||
}
|
||
if (v === 'farming') {
|
||
activateProfile('farming');
|
||
return;
|
||
}
|
||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||
setProPromptSource(`variant:${v}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariant(v);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
/>
|
||
<span class="profile-divider" aria-hidden="true"></span>
|
||
${profileButtonOrder.slice(3).map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource(`profile:${key}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
activateProfile(key);
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
|
||
</div>
|
||
|
||
<!--
|
||
COLUMN TOGGLES — in exact table column order.
|
||
Buttons visible to all users when profile includes that col.
|
||
Burn + Vehicle dropdowns always shown (free + pro).
|
||
Pro users see all toggles; free users see profile-filtered subset.
|
||
-->
|
||
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
|
||
<div class="col-toggles">
|
||
<span class="col-toggles-label">Columns:</span>
|
||
|
||
${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button class=${`col-toggle${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['rh']) && html`<button class=${`col-toggle${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['dew']) && html`<button class=${`col-toggle${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['soilT']) && html`<button class=${`col-toggle${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['soilT6']) && html`<button class=${`col-toggle${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['soilM']) && html`<button class=${`col-toggle${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['concreteT']) && html`<button class=${`col-toggle${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['wind']) && html`<button class=${`col-toggle${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['dir']) && html`<button class=${`col-toggle${visibleCols.dir ? ' on' : ''}`} onClick=${() => toggleCol('dir')}>Dir</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['cloud']) && html`<button class=${`col-toggle${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['vis']) && html`<button class=${`col-toggle${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['aqi']) && html`<button class=${`col-toggle${visibleCols.aqi ? ' on' : ''}`} onClick=${() => toggleCol('aqi')}>Air Quality</button>`}
|
||
${(isPro || activeCols['pollen']) && html`<${CustomSelect}
|
||
value=${pollenType}
|
||
isOn=${visibleCols.pollen}
|
||
hideLabel="Pollen"
|
||
hidingLabel="Hide Pollen"
|
||
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
|
||
{ value: k, label: v.name },
|
||
...(i === 0 ? [{ value: '_div', divider: true }] : []),
|
||
])}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, pollen: false }));
|
||
} else {
|
||
setPollenTypeAndSave(v);
|
||
setVisibleCols(prev => ({ ...prev, pollen: true }));
|
||
}
|
||
}}
|
||
/>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['sun']) && html`<button class=${`col-toggle${visibleCols.sun ? ' on' : ''}`} onClick=${() => toggleCol('sun')}>Sun</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['direct']) && html`<button class=${`col-toggle${visibleCols.direct ? ' on' : ''}`} onClick=${() => toggleCol('direct')}>Direct</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['diffuse']) && html`<button class=${`col-toggle${visibleCols.diffuse ? ' on' : ''}`} onClick=${() => toggleCol('diffuse')}>Diffuse</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html`<button class=${`col-toggle${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['delta']) && html`<button class=${`col-toggle${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['utci']) && html`<button class=${`col-toggle${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['uvA']) && html`<button class=${`col-toggle${visibleCols.uvA ? ' on' : ''}`} onClick=${() => toggleCol('uvA')}>UV-A</button>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['uvB']) && html`<button class=${`col-toggle${visibleCols.uvB ? ' on' : ''}`} onClick=${() => toggleCol('uvB')}>UV-B</button>`}
|
||
|
||
${(isPro ? (activeProfile === 'custom' || activeCols['burn']) : activeCols['burn']) && html`
|
||
<${CustomSelect}
|
||
value=${skinType}
|
||
isOn=${visibleCols.burn}
|
||
hideLabel="Burn"
|
||
hidingLabel="Hide Burn"
|
||
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name.split(' · ')[1] + ' skin',
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, burn: false }));
|
||
} else {
|
||
setSkinType(v);
|
||
setVisibleCols(prev => ({ ...prev, burn: true }));
|
||
}
|
||
}}
|
||
/>`}
|
||
|
||
${(isPro ? (activeProfile === 'custom' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
|
||
<span class="col-toggle-group">
|
||
<${CustomSelect}
|
||
value=${vehicleType}
|
||
isOn=${visibleCols.vehicleT}
|
||
hideLabel="Vehicle"
|
||
hidingLabel="Hide Vehicle"
|
||
groupedLeft=${true}
|
||
isLastChild=${!visibleCols.vehicleT}
|
||
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name,
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
|
||
setVehicleVent(false);
|
||
} else {
|
||
setVehicleType(v);
|
||
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
|
||
}
|
||
}}
|
||
/>
|
||
${visibleCols.vehicleT && html`
|
||
<${VentPill}
|
||
checked=${vehicleVent}
|
||
onChange=${() => setVehicleVent(v => !v)}
|
||
label="Ventilation"
|
||
title="Ventilation — open windows significantly reduce cabin heat build-up"
|
||
/>`}
|
||
</span>`}
|
||
|
||
${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
|
||
<span class="col-toggle-group">
|
||
<${CustomSelect}
|
||
value=${buildingType}
|
||
isOn=${indoorMode === 'on'}
|
||
hideLabel="Indoors"
|
||
hidingLabel="Hide Indoors"
|
||
groupedLeft=${true}
|
||
isLastChild=${indoorMode !== 'on'}
|
||
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
|
||
value: k,
|
||
label: v.name,
|
||
}))}
|
||
onChange=${(v) => {
|
||
if (v === 'off') {
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
} else {
|
||
setBuildingType(v);
|
||
setIndoorMode('on');
|
||
}
|
||
}}
|
||
/>
|
||
${indoorMode === 'on' && html`
|
||
<${VentPill}
|
||
checked=${indoorManaged}
|
||
onChange=${() => setIndoorManaged(v => !v)}
|
||
label="Managed"
|
||
title="Managed: curtains closed by day, windows open when cooler outside"
|
||
/>`}
|
||
</span>`}
|
||
${isPro && (activeProfile === 'custom' || activeCols['precip']) && html`<button class=${`col-toggle${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||
|
||
</div>`}
|
||
|
||
<!--
|
||
HOURLY TABLE — each row is one hour from the selected day.
|
||
Each column is wrapped in a visibleCols.X check, so it only
|
||
shows when its toggle is on. To force a column to always
|
||
show, remove the visibleCols check around it. To rename a
|
||
heading, edit the text inside the matching <th>.
|
||
-->
|
||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}>
|
||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||
<!-- Sticky header strip — locks to viewport top. Clipped
|
||
horizontally; the inner .utci-thead-track is shifted
|
||
via translateX from JS to follow the body's scrollLeft.
|
||
See handleBodyScroll + useLayoutEffect above. -->
|
||
<div class="utci-thead-sticky" ref=${headStickyRef}>
|
||
<div class="utci-thead-track" ref=${headTrackRef}>
|
||
<table class="utci-table utci-table-head" ref=${headTableRef}>
|
||
<thead>
|
||
<tr>
|
||
<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('hour', e)} onMouseEnter=${(e) => handleThEnter('hour', e)} onMouseLeave=${handleThLeave}>Hour</th>
|
||
${visibleCols.air && html`<th class="utci-tight-head col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('dew', e)} onMouseEnter=${(e) => handleThEnter('dew', e)} onMouseLeave=${handleThLeave}>Dew <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.soilT && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">m³/m³</span></th>`}
|
||
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
|
||
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
|
||
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
|
||
${visibleCols.cloud && html`<th class="col-info-th" 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="col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Visibility <span class="col-unit">km</span></th>`}
|
||
${visibleCols.aqi && html`<th class="col-info-th" 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" 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.sun && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('diffuse', e)} onMouseEnter=${(e) => handleThEnter('diffuse', e)} onMouseLeave=${handleThLeave}>Diffuse <span class="col-unit">W/m²</span></th>`}
|
||
${visibleCols.tmrt && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('tmrt', e)} onMouseEnter=${(e) => handleThEnter('tmrt', e)} onMouseLeave=${handleThLeave}>Tmrt <span class="col-unit">°C</span></th>`}
|
||
${visibleCols.delta && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('delta', e)} onMouseEnter=${(e) => handleThEnter('delta', e)} onMouseLeave=${handleThLeave}>Δ <span class="col-unit">UTCI−Air</span></th>`}
|
||
${visibleCols.utci && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utci', e)} onMouseEnter=${(e) => handleThEnter('utci', e)} onMouseLeave=${handleThLeave}>UTCI <span class="col-unit">°C felt</span></th>`}
|
||
${visibleCols.uvA && html`<th class="col-info-th" 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" 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.burn && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('burn', e)} onMouseEnter=${(e) => handleThEnter('burn', e)} onMouseLeave=${handleThLeave}>Burn <span class="col-unit">to MED</span></th>`}
|
||
${visibleCols.vehicleT && html`<th class="col-info-th" 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" 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" scope="col" onClick=${(e) => handleThClick('managedT', e)} onMouseEnter=${(e) => handleThEnter('managedT', e)} onMouseLeave=${handleThLeave}>Managed <span class="col-unit">°C est.</span></th>`}
|
||
${visibleCols.precip && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('precip', e)} onMouseEnter=${(e) => handleThEnter('precip', e)} onMouseLeave=${handleThLeave}>Pcpt <span class="col-unit">mm/h</span></th>`}
|
||
${visibleCols.utciP && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('utciP', e)} onMouseEnter=${(e) => handleThEnter('utciP', e)} onMouseLeave=${handleThLeave}>UTCI+P <span class="col-unit">°C adj.</span></th>`}
|
||
</tr>
|
||
</thead>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<!-- Body scroller — owns the horizontal scrollbar. The
|
||
onScroll handler translates the header track to keep
|
||
columns aligned with the visible body columns. -->
|
||
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
|
||
<tbody>
|
||
${visible.map((r) => {
|
||
const cat = utciCategory(r.utci);
|
||
const isNight = r.elev < 0;
|
||
const isNow = r.iso.slice(0, 13) === nowLocalISO;
|
||
const delta = r.utci - r.Ta;
|
||
const adjCat = utciCategory(r.utciAdj);
|
||
// Converts a band hex colour to rgba at the given alpha — used to
|
||
// tint the UTCI and UTCI+P cells directly from the band colour.
|
||
const hexToRgba = (hex, a) => {
|
||
const h = hex.replace('#', '');
|
||
const r = parseInt(h.slice(0,2),16);
|
||
const g = parseInt(h.slice(2,4),16);
|
||
const b = parseInt(h.slice(4,6),16);
|
||
return `rgba(${r},${g},${b},${a})`;
|
||
};
|
||
// Returns a very light background tint based on temperature value.
|
||
// Cold → faint blue, cool → faint green, warm → faint amber, hot → faint red.
|
||
const tempBg = (t) => {
|
||
if (t == null) return 'transparent';
|
||
if (t < 0) return 'rgba(100,160,230,0.10)';
|
||
if (t < 10) return 'rgba(140,200,200,0.10)';
|
||
if (t < 18) return 'rgba(140,200,150,0.10)';
|
||
if (t < 26) return 'rgba(100,190,100,0.10)';
|
||
if (t < 32) return 'rgba(230,200, 80,0.10)';
|
||
if (t < 40) return 'rgba(230,140, 50,0.10)';
|
||
if (t < 50) return 'rgba(210, 70, 50,0.10)';
|
||
return 'rgba(160, 30, 30,0.10)';
|
||
};
|
||
const tempBgStrong = (t) => {
|
||
if (t == null) return 'transparent';
|
||
if (t < 0) return 'rgba(100,160,230,0.20)';
|
||
if (t < 10) return 'rgba(140,200,200,0.20)';
|
||
if (t < 18) return 'rgba(140,200,150,0.20)';
|
||
if (t < 26) return 'rgba(100,190,100,0.20)';
|
||
if (t < 32) return 'rgba(230,200, 80,0.20)';
|
||
if (t < 40) return 'rgba(230,140, 50,0.20)';
|
||
if (t < 50) return 'rgba(210, 70, 50,0.20)';
|
||
return 'rgba(160, 30, 30,0.20)';
|
||
};
|
||
const scaleBg = (v, min, max, rgb, maxAlpha = 0.14) => {
|
||
if (v == null || isNaN(v)) return 'transparent';
|
||
if (v <= min) return 'transparent';
|
||
const x = Math.max(0, Math.min(1, (v - min) / (max - min)));
|
||
const a = 0.035 + x * maxAlpha;
|
||
return `rgba(${rgb},${a.toFixed(3)})`;
|
||
};
|
||
const deltaBg = (v) => {
|
||
if (v == null || isNaN(v)) return 'transparent';
|
||
if (Math.abs(v) < 1) return 'transparent';
|
||
const x = Math.min(1, Math.abs(v) / 12);
|
||
const a = 0.035 + x * 0.13;
|
||
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
||
};
|
||
const burnBg = (mins, uv) => {
|
||
if (!isFinite(mins) || uv <= 0) return 'transparent';
|
||
if (mins >= 240) return 'transparent';
|
||
const x = Math.max(0, Math.min(1, (240 - mins) / 220));
|
||
return `rgba(210,70,50,${(0.04 + x * 0.15).toFixed(3)})`;
|
||
};
|
||
// r.iso is the local wall-clock string from the API — slice it directly.
|
||
const h24 = parseInt(r.iso.slice(11, 13), 10);
|
||
const localHHMM = h24 === 0 ? '12am' : h24 < 12 ? `${h24}am` : h24 === 12 ? '12pm' : `${h24 - 12}pm`;
|
||
const burnMins = sunburnMinutes(r.uv, skinType);
|
||
return html`
|
||
<tr key=${r.iso}
|
||
class=${`${isNight ? 'is-night' : ''} ${isNow ? 'is-now' : ''}`.trim()}>
|
||
<td class="utci-time">
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
|
||
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
|
||
<span>${localHHMM}</span>
|
||
${(() => {
|
||
const rowEvents = getCellTagEvents(selectedDayEvents, r);
|
||
return rowEvents.map(ev => html`
|
||
<span key=${ev.id} class="event-cell-tag"
|
||
onClick=${(e) => handleEventTagClick(rowEvents, e)}
|
||
onMouseEnter=${(e) => handleEventTagEnter(rowEvents, e)}
|
||
onMouseLeave=${handleEventTagLeave}
|
||
role="button" tabIndex="0" aria-label=${ev.title}
|
||
>${ev.emoji}</span>
|
||
`);
|
||
})()}
|
||
</span>
|
||
</td>
|
||
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
|
||
${visibleCols.rh && html`<td style=${{ background: scaleBg(r.RH, 30, 100, '70,145,200') }}>${Math.round(r.RH)}</td>`}
|
||
${visibleCols.dew && html`<td style=${{ background: tempBg(r.dew) }}>${r.dew != null ? r.dew.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT && html`
|
||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT0) }}>${r.soilT0 != null ? r.soilT0.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilT6 && html`
|
||
<td style=${{ color: '#6b4a1c', background: tempBg(r.soilT6) }}>${r.soilT6 != null ? r.soilT6.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.soilM && html`
|
||
<td style=${{ color: '#2a6a90', background: scaleBg(r.soilM, 0.12, 0.45, '50,125,190') }}>${r.soilM != null ? r.soilM.toFixed(3) : '—'}</td>`}
|
||
${visibleCols.concreteT && html`
|
||
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
|
||
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
|
||
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
|
||
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
|
||
: ''}
|
||
</td>`}
|
||
${visibleCols.dir && html`<td class="utci-dir-cell">
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||
<${WindVane} bearing=${r.wd} size=${28} />
|
||
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.cloud && html`<td 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)}</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 style=${{ background: bg, color }}>
|
||
${v != null ? (v < 10 ? v.toFixed(1) : Math.round(v)) : '—'}
|
||
</td>`;
|
||
})()}
|
||
${visibleCols.aqi && (() => {
|
||
const v = 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 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 style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
|
||
})()}
|
||
${visibleCols.sun && html`<td style=${{ background: scaleBg(r.elev > 0 ? r.elev : null, 0, 70, '225,160,45') }}>${r.elev > 0 ? r.elev.toFixed(1) : '—'}</td>`}
|
||
${visibleCols.direct && html`<td style=${{ background: scaleBg(r.dir, 0, 850, '230,155,35') }}>${Math.round(r.dir)}</td>`}
|
||
${visibleCols.diffuse && html`<td style=${{ background: scaleBg(r.dif, 0, 450, '230,190,70') }}>${Math.round(r.dif)}</td>`}
|
||
${visibleCols.tmrt && html`<td style=${{ background: tempBg(r.Tmrt) }}>${r.Tmrt.toFixed(1)}</td>`}
|
||
${visibleCols.delta && html`
|
||
<td style=${{
|
||
color: delta > 3 ? '#c8601a' : delta < -3 ? '#3f73c4' : '#4a3218',
|
||
fontWeight: 600,
|
||
background: deltaBg(delta),
|
||
}}>
|
||
${delta > 0 ? '+' : ''}${delta.toFixed(1)}
|
||
</td>`}
|
||
${visibleCols.utci && html`
|
||
<td style=${{ background: hexToRgba(cat.bg, 0.22), color: cat.fg === '#fff' ? cat.fg : '#2a1a08', fontWeight: 600 }}>
|
||
${r.utci.toFixed(1)}°
|
||
</td>`}
|
||
${visibleCols.uvA && html`
|
||
<td style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
|
||
${r.uvA > 0 ? r.uvA.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.uvB && html`
|
||
<td style=${{ color: r.uvB > 0 ? '#c44a3a' : '#4a3218', background: scaleBg(r.uvB, 0, 1.2, '210,70,50') }}>
|
||
${r.uvB > 0 ? r.uvB.toFixed(2) : '—'}
|
||
</td>`}
|
||
${visibleCols.burn && html`
|
||
<td style=${{ color: r.uv > 0 ? (burnMins < 30 ? '#c44a3a' : '#c8601a') : '#4a3218', background: burnBg(burnMins, r.uv) }}>
|
||
${burnLabel(burnMins)}
|
||
</td>`}
|
||
${visibleCols.vehicleT && html`
|
||
<td style=${{ color: r.vehicleT != null && r.vehicleT > 45 ? '#c0392b' : r.vehicleT != null && r.vehicleT > 35 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBgStrong(r.vehicleT) }}>
|
||
${r.vehicleT != null ? r.vehicleT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${indoorMode === 'on' && !indoorManaged && html`
|
||
<td style=${{ color: r.indoorT != null && r.indoorT > 32 ? '#c0392b' : r.indoorT != null && r.indoorT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.indoorT) }}>
|
||
${r.indoorT != null ? r.indoorT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${indoorMode === 'on' && indoorManaged && html`
|
||
<td style=${{ color: r.managedT != null && r.managedT > 32 ? '#c0392b' : r.managedT != null && r.managedT > 26 ? '#e67e22' : '#4a7a4a', fontWeight: 'bold', background: tempBgStrong(r.managedT) }}>
|
||
${r.managedT != null ? r.managedT.toFixed(1) : '—'}
|
||
</td>`}
|
||
${visibleCols.precip && html`
|
||
<td style=${{ background: r.snow > 0 ? scaleBg(r.snow, 0, 4, '90,140,210') : scaleBg(r.precip, 0, 8, '70,145,200') }}>
|
||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '5px', verticalAlign: 'middle' }}>
|
||
<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${28} />
|
||
<span style=${{ color: r.snow > 0 ? '#2a5fa8' : r.precip > 0 ? '#2a6a90' : '#7a5c30' }}>
|
||
${r.snow > 0 ? r.snow.toFixed(1) + 'cm' : r.precip > 0 ? r.precip.toFixed(1) : '—'}
|
||
</span>
|
||
</span>
|
||
</td>`}
|
||
${visibleCols.utciP && html`
|
||
<td style=${{ background: hexToRgba(adjCat.bg, 0.55), color: adjCat.fg === '#fff' ? adjCat.fg : '#2a1a08', fontWeight: 700, fontSize: '16px' }}>
|
||
${r.utciAdj.toFixed(1)}°
|
||
</td>`}
|
||
</tr>`;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
|
||
<div
|
||
ref=${colPopupRef}
|
||
class=${`col-info-popup${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">
|
||
${[
|
||
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
|
||
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
|
||
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
|
||
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
|
||
{ l: '18 to 26 Comfortable', bg: '#4a8a3a', fg: '#fff' },
|
||
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
|
||
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
|
||
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
|
||
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
|
||
].map((b, i) => html`
|
||
<span key=${i} class="utci-legend-item" style=${{ background: b.bg, color: b.fg }}>
|
||
${b.l}
|
||
</span>`)}
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
ALMANAC — upcoming cosmic events in the next 3 months.
|
||
Location-aware: visibility notes adjust by latitude.
|
||
-->
|
||
${(() => {
|
||
const upcoming = getUpcomingEvents(location, 90);
|
||
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 90 days · ${location.name}</span>
|
||
</div>
|
||
<div class="almanac-list">
|
||
${upcoming.length === 0
|
||
? html`<div class="almanac-empty">No major cosmic events in the next 90 days — 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-about">
|
||
<h2 class="utci-about-heading">What is SunScope?</h2>
|
||
<p class="utci-about-text">
|
||
SunScope is a free hourly weather forecast built around <strong>felt temperature</strong>,
|
||
not just air temperature. It uses the <strong>Universal Thermal Climate Index (UTCI)</strong>
|
||
— the biometeorological standard used in heat-health warning systems worldwide — to combine
|
||
air temperature, humidity, wind, and solar radiation into a single honest number. The
|
||
<strong>UTCI+P</strong> column adds an original rain and snow penalty so wet, windy days
|
||
read as cold as they feel.
|
||
</p>
|
||
<p class="utci-about-text">
|
||
Beyond felt temperature, SunScope calculates <strong>vehicle cabin heat</strong> (choose
|
||
your vehicle type; toggle windows open), <strong>indoor temperature</strong> (seven
|
||
building types; managed heatwave mode), <strong>urban concrete surface temperature</strong>,
|
||
<strong>UV index and sunburn time</strong> by skin type, and <strong>soil temperature
|
||
and moisture</strong> for farming and motorhome use. Switch profiles to see the data
|
||
that matters for your situation — or go Custom and build your own view.
|
||
<a href="./about.html" class="utci-about-link">Learn more →</a>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="utci-footer">
|
||
<em>Reading the table.</em> A large positive Δ means your body is absorbing
|
||
far more heat than the air temperature alone suggests — typically due to direct solar radiation.
|
||
On clear sunny days this gap can exceed 10 °C even at modest air temperatures.
|
||
${isPro && html`
|
||
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
|
||
SunScope Extra is active.
|
||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank" rel="noopener noreferrer"
|
||
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none' }}>
|
||
Manage or cancel subscription →
|
||
</a>
|
||
</div>
|
||
`}
|
||
</div>
|
||
|
||
</main>
|
||
</div>`;
|
||
}
|