From 55ddcd617a1eb7ab0c6b7e86ba4d57bec696c0bb Mon Sep 17 00:00:00 2001 From: fraxle Date: Mon, 18 May 2026 15:47:23 +0100 Subject: [PATCH] 1.5 Code refactoring Profile fixes Table fixes Animation additions --- .gitignore | 3 + assets/css/table.css | 65 ++ assets/css/ui.css | 56 +- assets/js/app.js | 1005 ++++++-------------------- assets/js/compute.js | 118 +++ assets/js/config.js | 162 +++++ assets/js/events.js | 849 +--------------------- assets/js/events/almanac-calendar.js | 298 ++++++++ assets/js/events/cosmic-calendar.js | 276 +++++++ assets/js/events/dynamic-message.js | 29 + assets/js/events/lens-overlay.js | 100 +++ assets/js/events/weather-checks.js | 139 ++++ assets/js/hooks/useColumnPopup.js | 221 ++++++ assets/js/hooks/useForecast.js | 124 ++++ assets/js/hooks/useTableScroll.js | 222 ++++++ 15 files changed, 2062 insertions(+), 1605 deletions(-) create mode 100644 assets/js/compute.js create mode 100644 assets/js/config.js create mode 100644 assets/js/events/almanac-calendar.js create mode 100644 assets/js/events/cosmic-calendar.js create mode 100644 assets/js/events/dynamic-message.js create mode 100644 assets/js/events/lens-overlay.js create mode 100644 assets/js/events/weather-checks.js create mode 100644 assets/js/hooks/useColumnPopup.js create mode 100644 assets/js/hooks/useForecast.js create mode 100644 assets/js/hooks/useTableScroll.js diff --git a/.gitignore b/.gitignore index 5dbc419..c2b63a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Cowork session backups backup/ +# Cowork agent instructions +AGENTS.md + # OS / editor cruft .DS_Store Thumbs.db diff --git a/assets/css/table.css b/assets/css/table.css index c868abf..e4ccb30 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -554,6 +554,71 @@ color: #4a3420; } +/* ── POPUP OPEN/CLOSE ANIMATION ───────────────────────────────────────── + The popup mounts/unmounts instantly in JS, so we animate it via a + scale + fade keyframe anchored to the arrow tip (transform-origin). + Above-the-target: origin is bottom-center. Below: top-center. */ +@keyframes popup-open { + from { opacity: 0; transform: translateX(-50%) translateY(-100%) scaleY(0.6); } + to { opacity: 1; transform: translateX(-50%) translateY(-100%) scaleY(1); } +} +@keyframes popup-open-below { + from { opacity: 0; transform: translateX(-50%) scaleY(0.6); } + to { opacity: 1; transform: translateX(-50%) scaleY(1); } +} + +.col-info-popup:not(.col-info-popup--below) { + transform-origin: bottom center; + animation: popup-open 0.22s cubic-bezier(0.34, 1.4, 0.64, 1) both; +} +.col-info-popup.col-info-popup--below { + transform-origin: top center; + animation: popup-open-below 0.22s cubic-bezier(0.34, 1.4, 0.64, 1) both; +} + +/* ── EVENT TAG POPUP — SLIDESHOW ──────────────────────────────────────── + The stage holds the current slide. On transition, evTransition is set + to 'exiting' (fade out) then 'entering' (fade in) via the hook. */ +@keyframes ev-fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes ev-fade-out { from { opacity: 1; } to { opacity: 0; } } + +.ev-popup-stage { + transition: opacity 0.38s ease; +} +.ev-popup-entering { + animation: ev-fade-in 0.38s ease both; +} +.ev-popup-exiting { + animation: ev-fade-out 0.38s ease both; +} + +/* Slideshow navigation dots */ +.ev-popup-dots { + display: flex; + gap: 5px; + margin-top: 8px; + justify-content: center; + align-items: center; +} +.ev-popup-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #c9b08a; + opacity: 0.4; + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; + flex-shrink: 0; +} +.ev-popup-dot.active { + opacity: 1; + transform: scale(1.35); + background: #7a5530; +} +.ev-popup-dot:hover { + opacity: 0.75; +} + /* ── 8. NOW-ROW & NIGHT-ROW DECORATIONS ─────────────────────────────── */ diff --git a/assets/css/ui.css b/assets/css/ui.css index c479062..9a82f8c 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -5,24 +5,62 @@ /* ── EVENT BANNER ───────────────────────────────────────────────────── */ /* Animated slide-down banner for cosmic/weather/promo events. */ +/* Open/close: grow vertically using grid-template-rows (silky smooth, + no max-height hack — it animates from 0fr → 1fr and back). */ .event-banner-wrap { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.45s cubic-bezier(0.4, 0, 0.2, 1); +} +.event-banner-wrap > * { overflow: hidden; - max-height: 0; - transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1); } .event-banner-wrap.visible { - max-height: 120px; + grid-template-rows: 1fr; } +/* When closing, fade the content out before the row collapses */ +.event-banner-wrap:not(.visible) .event-banner-stage { + opacity: 0; +} + +/* Slide crossfade: both banners rendered simultaneously, fading into each other */ +@keyframes banner-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes banner-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +/* Container that holds both slides during a crossfade */ +/* Also fades in/out with the wrap open/close */ +.event-banner-stage { + opacity: 1; + transition: opacity 0.35s ease; + position: relative; + margin-bottom: 12px; +} + .event-banner { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-radius: 4px; - margin-bottom: 12px; position: relative; border-left: 4px solid rgba(255,255,255,0.25); } + +/* Single-element crossfade — content swaps during the transition gap */ +.event-banner.banner-exiting { + animation: banner-fade-out 0.4s ease both; + pointer-events: none; +} + +.event-banner.banner-entering { + animation: banner-fade-in 0.4s ease both; +} .event-banner-emoji { font-size: 24px; flex-shrink: 0; @@ -85,12 +123,12 @@ opacity: 0.75; } -/* Cell tag — tiny event icon in the hour column */ +/* Cell tag — event icon in the hour column */ .event-cell-tag { - font-size: 11px; + font-size: 15px; line-height: 1; vertical-align: middle; - margin-left: 3px; + margin-left: 1px; display: inline-block; cursor: pointer; border-radius: 3px; @@ -236,10 +274,6 @@ /* ── EVENT BANNER — mobile fixes ────────────────────────────────────── */ @media (max-width: 640px) { - .event-banner-wrap.visible { - max-height: 260px; /* generous — banner can be tall with dates line */ - } - .event-banner-wrap { margin-top: 8px; /* push banner clear of the fixed nav bar */ margin-bottom: 18px; /* breathing room between banner and header */ diff --git a/assets/js/app.js b/assets/js/app.js index 1e9c370..31db63e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,17 +21,26 @@ // ════════════════════════════════════════════════════════════════════════ import { h, render, Fragment } from '../vendor/preact.js'; -import { useState, useEffect, useLayoutEffect, useRef } from '../vendor/preact-hooks.js'; +import { useState, useEffect, useRef, useCallback } from '../vendor/preact-hooks.js'; import htm from '../vendor/htm.js'; -import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTemp, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass } from './physics.js'; import { - utciCategory, precipPenalty, windCompass8, uvSplit, + utciCategory, SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, BUILDING_TYPES, - cloudCategory, confidenceBand, moonGlyph, + 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); @@ -57,15 +66,14 @@ export function UTCIForecast() { setLocation(loc); }; - const [forecast, setForecast] = useState(null); // raw Open-Meteo response - const [airQuality, setAirQuality] = useState(null); // raw Open-Meteo air quality response - const [loading, setLoading] = useState(false); // true while fetching - const [error, setError] = useState(null); // fetch error message + // 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 [now, setNow] = useState(new Date()); // ticks every 5 min const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom' @@ -135,64 +143,7 @@ export function UTCIForecast() { return localStorage.getItem('sunscope_pro') === '1'; }); - // How many days the free tier shows. Days beyond this get a 🔒. - // Bump this number if you want to give free users more access. - const FREE_DAYS = 3; - - // Filter profile presets — each preset defines which columns are visible - // when that profile is selected. - const FILTER_PROFILES = { - basic: { - label: 'Basic', - icon: '🌡️', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false }, - }, - home: { - label: 'Home', - icon: '🏠', - cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true }, - }, - vehicle: { - label: 'Vehicle', - icon: '🚗', - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false }, - }, - farming: { - label: 'Farming', - icon: '🌾', - cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true }, - }, - outdoors: { - label: 'Places', - icon: '🌤️', - // Default cols match the first variant (urban). Switching variant updates visibleCols. - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false }, - }, - alltemps: { - label: 'Temps', - icon: '🌡️', - proOnly: true, - cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false }, - }, - custom: { - label: 'Custom', - icon: '⚙️', - proOnly: true, - cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false }, - }, - }; - - // Places sub-variants — each has its own column set. - // Selecting a variant applies its cols to visibleCols. - const OUTDOORS_VARIANTS = { - urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } }, - beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, - events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, - festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, - wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, - naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, - sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, - }; + // (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.) // Current active filter profile const [activeProfile, setActiveProfile] = useState('basic'); @@ -231,16 +182,8 @@ export function UTCIForecast() { setPollenType(v); }; - // Pollen type options shown in the pulldown selector. - const POLLEN_TYPES = { - all_pollen: { name: 'All pollen', unit: 'grains/m³' }, - grass_pollen: { name: 'Grass pollen', unit: 'grains/m³' }, - birch_pollen: { name: 'Birch pollen', unit: 'grains/m³' }, - alder_pollen: { name: 'Alder pollen', unit: 'grains/m³' }, - mugwort_pollen: { name: 'Mugwort pollen', unit: 'grains/m³' }, - olive_pollen: { name: 'Olive pollen', unit: 'grains/m³' }, - ragweed_pollen: { name: 'Ragweed pollen', unit: 'grains/m³' }, - }; + // (POLLEN_TYPES now lives in ./config.js.) + const searchTimeout = useRef(null); const activateProfile = (key) => { @@ -254,18 +197,9 @@ export function UTCIForecast() { } }; - const profileButtonOrder = ['basic', 'home', 'vehicle', 'alltemps', 'custom']; - const variantIcons = { - urban: '🏙️', - beach: '🏖️', - events: '🎪', - festival: '⛺', - sailing: '⛵', - wintersports: '🎿', - naturist: '☀️', - }; - const activityVariantKeys = ['sailing', 'wintersports', 'naturist']; - const placeVariantKeys = ['urban', 'beach', 'events', 'festival']; + // (profileButtonOrder, variantIcons, activityVariantKeys, + // placeVariantKeys now live in ./config.js.) + const activityOptions = [ { value: 'farming', @@ -286,6 +220,13 @@ export function UTCIForecast() { 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) @@ -309,374 +250,41 @@ export function UTCIForecast() { const bodyTableRef = useRef(null); const tableWrapRef = useRef(null); - // ─── COLUMN HEADER POPUP ───────────────────────────────────────────── - // Clicking a shows a small description popup below it. - // State holds { key, x, y } or null when closed. - const [colPopup, setColPopup] = useState(null); - const colPopupRef = useRef(null); - const colPopupThRef = useRef(null); - const hoverTimerRef = useRef(null); - const closeTimerRef = useRef(null); + // ─── 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(); - // Event tag popup — same behaviour as column popups but for cell emoji icons. - const [eventTagPopup, setEventTagPopup] = useState(null); // { ev, x, y, arrowLeft, below } - const eventTagPopupRef = useRef(null); - const evHoverTimerRef = useRef(null); - const evCloseTimerRef = useRef(null); + // (COL_DESCRIPTIONS, calcPopupPos and all the popup handlers now live in + // ./config.js and ./hooks/useColumnPopup.js respectively.) - const openEventTagPopup = (ev, spanEl) => { - const rect = spanEl.getBoundingClientRect(); - const popupW = 260, popupH = 80, margin = 8, gap = 6; - let x = rect.left + rect.width / 2; - x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); - const below = rect.top < popupH + gap + margin; - const y = below ? rect.bottom + gap : rect.top - gap; - const popupLeft = x - popupW / 2; - const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); - setEventTagPopup({ ev, x, y, arrowLeft, below }); - }; - - const closeEventTagPopup = () => { - setEventTagPopup(null); - clearTimeout(evHoverTimerRef.current); - clearTimeout(evCloseTimerRef.current); - }; - - const handleEventTagClick = (ev, e) => { - e.stopPropagation(); - clearTimeout(evHoverTimerRef.current); - clearTimeout(evCloseTimerRef.current); - if (eventTagPopup?.ev?.id === ev.id) { closeEventTagPopup(); return; } - openEventTagPopup(ev, e.currentTarget); - }; - - const handleEventTagEnter = (ev, e) => { - clearTimeout(evHoverTimerRef.current); - clearTimeout(evCloseTimerRef.current); - if (eventTagPopup?.ev?.id === ev.id) return; - const spanEl = e.currentTarget; - evHoverTimerRef.current = setTimeout(() => openEventTagPopup(ev, spanEl), 700); - }; - - const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current); - - const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current); - const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); }; - - useEffect(() => { - if (!eventTagPopup) return; - const onClickOutside = (e) => { - if (eventTagPopupRef.current && !eventTagPopupRef.current.contains(e.target)) closeEventTagPopup(); - }; - const onScrollOrResize = () => closeEventTagPopup(); - document.addEventListener('mousedown', onClickOutside); - window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); - window.addEventListener('resize', onScrollOrResize, { passive: true }); - return () => { - document.removeEventListener('mousedown', onClickOutside); - window.removeEventListener('scroll', onScrollOrResize, { capture: true }); - window.removeEventListener('resize', onScrollOrResize); - }; - }, [eventTagPopup]); - - const COL_DESCRIPTIONS = { - hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, - air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, - rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' }, - dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' }, - wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' }, - dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' }, - cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' }, - sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' }, - direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' }, - diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' }, - tmrt: { title: 'Mean Radiant Temp', desc: 'The temperature a person\'s skin "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day — this is why shade feels so much cooler.' }, - delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' }, - utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.' }, - uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.' }, - uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' }, - burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' }, - utciP: { title: 'UTCI+P', desc: 'SunScope\'s adjusted felt temperature: UTCI plus the soak-factor penalty for precipitation. Rain and snow on wet clothing can reduce the felt temperature by up to 8 °C.' }, - precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' }, - soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.' }, - soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, - soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' }, - concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' }, - vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, - indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, - managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, - vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' }, - aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' }, - pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.' }, - }; - - const calcPopupPos = (thEl) => { - const rect = thEl.getBoundingClientRect(); - const popupW = 260, popupH = 110, margin = 8, gap = 6; - let x = rect.left + rect.width / 2; - x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); - const below = rect.top < popupH + gap + margin; - const y = below ? rect.bottom + gap : rect.top - gap; - const popupLeft = x - popupW / 2; - const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); - return { x, y, arrowLeft, below }; - }; - - const openPopup = (key, thEl) => { - colPopupThRef.current = thEl; - setColPopup({ key, ...calcPopupPos(thEl) }); - }; - - const closePopup = () => { - setColPopup(null); - colPopupThRef.current = null; - clearTimeout(hoverTimerRef.current); - clearTimeout(closeTimerRef.current); - }; - - const handleThClick = (key, e) => { - clearTimeout(hoverTimerRef.current); - clearTimeout(closeTimerRef.current); - if (colPopup?.key === key) { closePopup(); return; } - openPopup(key, e.currentTarget); - }; - - const handleThEnter = (key, e) => { - clearTimeout(hoverTimerRef.current); - clearTimeout(closeTimerRef.current); - // If a different popup is open, close it immediately and start fresh timer - if (colPopup && colPopup.key !== key) closePopup(); - if (colPopup?.key === key) return; // already showing this one - const thEl = e.currentTarget; - hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000); - }; - - // Leaving a th: just cancel the pending open. Don't auto-close — - // the user might be moving into the popup, or just passing through. - const handleThLeave = () => { - clearTimeout(hoverTimerRef.current); - }; - - // Popup mouse handlers: keep it open while hovering, close on leave. - const handlePopupEnter = () => clearTimeout(closeTimerRef.current); - const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); }; - - useEffect(() => { - if (!colPopup) return; - const onClickOutside = (e) => { - if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup(); - }; - // Close on scroll (avoids scroll-linked jank) or resize - const onScrollOrResize = () => closePopup(); - document.addEventListener('mousedown', onClickOutside); - window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); - window.addEventListener('resize', onScrollOrResize, { passive: true }); - return () => { - document.removeEventListener('mousedown', onClickOutside); - window.removeEventListener('scroll', onScrollOrResize, { capture: true }); - window.removeEventListener('resize', onScrollOrResize); - }; - }, [colPopup]); - - // ─── TABLE SCROLL INDICATORS ───────────────────────────────────────── - // Track whether the body scroller can scroll left/right so we can show - // fade + chevron indicators on the table edges. - const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); - const [tableCanScrollRight, setTableCanScrollRight] = useState(false); - - const updateTableScrollIndicators = () => { - const el = bodyScrollRef.current; - if (!el) return; - setTableCanScrollLeft(el.scrollLeft > 1); - setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); - }; - - // ─── DRAG-TO-SCROLL ────────────────────────────────────────────────── - // Attach pointer-event drag scrolling to the body scroller so desktop - // users can click-drag the table horizontally. - useEffect(() => { - const el = bodyScrollRef.current; - if (!el) return; - let isDown = false; - let startX = 0; - let startScroll = 0; - - const onMouseDown = (e) => { - // Only act on clicks that land inside the body scroller - if (!el.contains(e.target)) return; - if (e.button !== 0) return; - if (e.target.closest('button, a, input, select')) return; - isDown = true; - startX = e.clientX; - startScroll = el.scrollLeft; - el.style.cursor = 'grabbing'; - document.body.style.userSelect = 'none'; - document.body.style.webkitUserSelect = 'none'; - }; - const onMouseMove = (e) => { - if (!isDown) return; - const dx = e.clientX - startX; - el.scrollLeft = startScroll - dx; - }; - const onMouseUp = () => { - if (!isDown) return; - isDown = false; - el.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.webkitUserSelect = ''; - }; - - // Attach everything to document so Preact's synthetic event system - // cannot intercept or swallow the events before we see them. - document.addEventListener('mousedown', onMouseDown); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - - // Also update indicators on scroll - el.addEventListener('scroll', updateTableScrollIndicators); - - return () => { - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - el.removeEventListener('scroll', updateTableScrollIndicators); - }; - }, [forecast]); - - // Update indicators after layout sync (columns may have changed width) - useEffect(() => { - updateTableScrollIndicators(); - }, [forecast, visibleCols, selectedDay]); - - // ─── TABLE SCROLL SYNC ─────────────────────────────────────────────── - // The hourly table is rendered as two stacked scroll areas: - // • Sticky header strip (locked to viewport top, clipped) - // • Body scroller (overflow-x: auto — owns the horizontal scrollbar) - // We need to (a) keep the header track shifted horizontally to match - // the body's scrollLeft, and (b) keep the header cells the same pixel - // width as the body cells even as columns toggle or the window resizes. - // ───────────────────────────────────────────────────────────────────── - const handleBodyScroll = () => { - const track = headTrackRef.current; - const body = bodyScrollRef.current; - if (!track || !body) return; - track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; - updateTableScrollIndicators(); - }; - - useLayoutEffect(() => { - // Synchronise the head and body table column widths with a - // "shrink-to-fit then distribute" strategy: - // • Measure each column's true natural (content-fit) width by - // temporarily switching both tables to table-layout: auto + - // width: max-content. White-space: nowrap on cells stops content - // from wrapping, so the measurement is the smallest width that - // won't clip the content. - // • If the body scroller has spare horizontal space (natural total - // < container width), scale every column up proportionally to - // fill it — so toggling columns off makes the remaining ones fan - // out instead of leaving an awkward gap. - // • Otherwise apply the natural widths as-is and let the body - // scroller's overflow-x: auto produce a horizontal scrollbar. - const sync = () => { - const headTable = headTableRef.current; - const bodyTable = bodyTableRef.current; - const bodyScroll = bodyScrollRef.current; - if (!headTable || !bodyTable || !bodyScroll) return; - const bodyRow = bodyTable.querySelector('tbody tr'); - const headRow = headTable.querySelector('thead tr'); - if (!bodyRow || !headRow) return; - const headCells = Array.from(headRow.children); - const bodyCells = Array.from(bodyRow.children); - const n = Math.min(headCells.length, bodyCells.length); - if (n === 0) return; - - // Step 1: clear any previously-forced cell widths and switch the - // tables to natural sizing so the measurement reflects the true - // content-fit width — independent of how wide the container is. - headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); - bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); - headTable.style.width = 'max-content'; - bodyTable.style.width = 'max-content'; - headTable.style.tableLayout = 'auto'; - bodyTable.style.tableLayout = 'auto'; - - // Step 2: read each cell's natural width. getBoundingClientRect - // forces synchronous layout — that's what we want. - const naturalW = new Array(n); - let naturalTotal = 0; - for (let i = 0; i < n; i++) { - const headW = headCells[i].getBoundingClientRect().width; - const bodyW = bodyCells[i].getBoundingClientRect().width; - const w = Math.max(Math.ceil(headW), Math.ceil(bodyW)); - naturalW[i] = w; - naturalTotal += w; - } - - // Step 3: decide final widths based on available container width. - const containerW = bodyScroll.clientWidth; - const finalW = new Array(n); - let totalWidth; - if (naturalTotal > 0 && naturalTotal < containerW) { - // Spare space — distribute proportionally across columns so they - // fan out to fill the scroller (no awkward right-hand gap). - const scale = containerW / naturalTotal; - let running = 0; - for (let i = 0; i < n - 1; i++) { - finalW[i] = Math.floor(naturalW[i] * scale); - running += finalW[i]; - } - // Absorb sub-pixel rounding into the last column so the total - // exactly matches the container width. - finalW[n - 1] = containerW - running; - totalWidth = containerW; - } else { - // Naturals don't fit — use them as-is and let the body scroll. - for (let i = 0; i < n; i++) finalW[i] = naturalW[i]; - totalWidth = naturalTotal; - } - - // Step 4: restore the CSS-defined table-layout: fixed so the - // explicit cell widths we apply below are honoured by the browser - // (not redistributed by the auto-layout algorithm). - headTable.style.tableLayout = ''; - bodyTable.style.tableLayout = ''; - - // Step 5: apply the final width to both head and body cells. - for (let i = 0; i < n; i++) { - const px = `${finalW[i]}px`; - headCells[i].style.width = px; - headCells[i].style.minWidth = px; - headCells[i].style.maxWidth = px; - bodyCells[i].style.width = px; - bodyCells[i].style.minWidth = px; - bodyCells[i].style.maxWidth = px; - } - // Make both tables exactly totalWidth wide so they share the same - // horizontal extent — column N in the header sits directly above - // column N in the body, no drift as you scroll right. - headTable.style.width = `${totalWidth}px`; - bodyTable.style.width = `${totalWidth}px`; - // Re-apply current horizontal offset so column alignment survives. - handleBodyScroll(); - }; - // Run once after layout - sync(); - // Re-sync when the scroll container's width changes (window resize, - // sidebar opens, etc). We observe the scroller — not the body table — - // because the body table's width is now driven by sync itself, which - // would otherwise create a feedback loop. - let ro = null; - if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) { - ro = new ResizeObserver(sync); - ro.observe(bodyScrollRef.current); - } - window.addEventListener('resize', sync); - return () => { - if (ro) ro.disconnect(); - window.removeEventListener('resize', sync); - }; - }, [forecast, visibleCols, selectedDay, skinType, vehicleType]); + // ─── 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(() => { @@ -695,218 +303,15 @@ export function UTCIForecast() { }, 300); }, [searchQuery]); - // ─── FORECAST FETCH ────────────────────────────────────────────────── - // Runs every time `location` changes (i.e. when a new city is picked). - // Builds the Open-Meteo URL and stores the response in `forecast`. - // Change forecast_days=14 below to fetch a different range (max 16). - // Add or remove fields in the `&hourly=...` list to fetch more data — - // but if you remove one that's used elsewhere, expect errors. - useEffect(() => { - async function load() { - setLoading(true); setError(null); - try { - const url = - `https://api.open-meteo.com/v1/forecast` + - `?latitude=${location.lat}&longitude=${location.lon}` + - `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + - `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + - `direct_radiation,diffuse_radiation,shortwave_radiation,` + - `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + - `uv_index,precipitation,snowfall,visibility,` + - `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + - `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; - const r = await fetch(url); - if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); - setForecast(await r.json()); - } catch (e) { setError(e.message); } - finally { setLoading(false); } - } - load(); - loadAirQuality(location); - }, [location]); - - // ─── AIR QUALITY FETCH (with 6-hour localStorage cache) ────────────── - // Fetches visibility, European AQI, and pollen from the Open-Meteo - // Air Quality API. Cached per location for 6 hours — pollen and AQI - // data updates at most a couple of times per day so there's no need - // to hit the API every 5 minutes with the weather refresh. - const SIX_HOURS_MS = 6 * 60 * 60 * 1000; - async function loadAirQuality(loc) { - const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; - try { - const cached = localStorage.getItem(cacheKey); - if (cached) { - const { ts, data } = JSON.parse(cached); - if (Date.now() - ts < SIX_HOURS_MS) { - setAirQuality(data); - return; - } - } - } catch (e) { /* ignore bad cache */ } - try { - const url = - `https://air-quality-api.open-meteo.com/v1/air-quality` + - `?latitude=${loc.lat}&longitude=${loc.lon}` + - `&hourly=european_aqi,` + - `grass_pollen,birch_pollen,alder_pollen,` + - `mugwort_pollen,olive_pollen,ragweed_pollen` + - `&timezone=auto&forecast_days=5`; - const r = await fetch(url); - if (!r.ok) return; // silently fail — these columns just show '—' - const data = await r.json(); - setAirQuality(data); - try { - localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data })); - } catch (e) { /* ignore storage errors */ } - } catch (_) { /* silently ignore */ } - } - - // ─── AUTO-REFRESH — tick every 5 minutes ───────────────────────────── - // Updates `now` so the scope always shows the current hour's data. - // Also refetches the forecast so fresh API data comes in automatically. - useEffect(() => { - const FIVE_MIN = 5 * 60 * 1000; - const id = setInterval(() => { - setNow(new Date()); - // Trigger a fresh forecast fetch by nudging location identity. - // We do this via a separate load rather than touching location state - // (which would reset other things), so we call load() directly. - async function refresh() { - try { - const url = - `https://api.open-meteo.com/v1/forecast` + - `?latitude=${location.lat}&longitude=${location.lon}` + - `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + - `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + - `direct_radiation,diffuse_radiation,shortwave_radiation,` + - `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + - `uv_index,precipitation,snowfall,visibility,` + - `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + - `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; - const r = await fetch(url); - if (r.ok) setForecast(await r.json()); - } catch (_) { /* silently ignore refresh errors */ } - // Air quality: only refetch if cache has expired (6-hour TTL) - const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`; - try { - const cached = localStorage.getItem(cacheKey); - if (cached) { - const { ts } = JSON.parse(cached); - if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh - } - } catch (e) { /* ignore */ } - loadAirQuality(location); - } - refresh(); - }, FIVE_MIN); - return () => clearInterval(id); - }, [location]); + // (FORECAST FETCH, AIR QUALITY FETCH and 5-min AUTO-REFRESH now live in + // ./hooks/useForecast.js — called at the top of this component.) // ─── COMPUTATION ───────────────────────────────────────────────────── - // Take the raw API arrays and stitch them into one object per hour, - // calculating UTCI + soak-factor for each row. This is what gets - // displayed in the table. - // - // Open-Meteo with timezone=auto returns local wall-clock strings like - // "2026-05-13T14:00" — no Z suffix. We use two forms: - // • String slices (iso.slice(...)) for display & day grouping - // • A true UTC Date for solarElevationDeg (see per-row comment below) - const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; - - // Build a fast lookup map from the air quality hourly data: ISO string → index. - // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like - // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14". - const aqTimeMap = {}; - if (airQuality?.hourly?.time) { - airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; }); - } - const getAq = (field, iso) => { - if (!airQuality?.hourly?.[field]) return null; - const i = aqTimeMap[iso.slice(0, 13)]; - if (i === undefined) return null; - return airQuality.hourly[field][i] ?? null; - }; - - const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { - const h = forecast.hourly; - const Ta = h.temperature_2m[i]; - const RH = h.relative_humidity_2m[i]; - const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; - const va = h.wind_speed_10m[i]; - const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; - const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; - const dir = h.direct_radiation[i] || 0; - const dif = h.diffuse_radiation[i] || 0; - const glob = h.shortwave_radiation[i] || 0; - const cc = h.cloud_cover[i]; - const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; - const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; - const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; - const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; - const precip = h.precipitation[i] || 0; - const snow = h.snowfall[i] || 0; - const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; - const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; - const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; - const concreteT = calcConcreteTemp(Ta, glob, va); - // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). - // For display we slice the string directly — no Date object needed. - // For solarElevationDeg (which uses .getUTC* internally) we need the - // true UTC instant: treat the local time as UTC then subtract the offset. - // e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓ - // True UTC instant: treat the local wall-clock string as UTC, then - // subtract the offset. e.g. Brisbane UTC+10, local 14:00 → UTC 04:00. - const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); - const elev = solarElevationDeg(location.lat, location.lon, dt); - const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); - const eh = vaporPressureHpa(Ta, RH); - const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); - const utci = utciApprox(Ta, Tmrt, va, eh); - const utciAdj = utci + precipPenalty(precip, snow, va); - // Derived - const compass = windCompass8(wd); - const { uvA, uvB } = uvSplit(uv, elev); - const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); - // Visibility from the main forecast API (metres → km). - const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })(); - const aqi = getAq('european_aqi', iso); - const grassPollen = getAq('grass_pollen', iso); - const birchPollen = getAq('birch_pollen', iso); - const alderPollen = getAq('alder_pollen', iso); - const mugwortPollen= getAq('mugwort_pollen', iso); - const olivePollen = getAq('olive_pollen', iso); - const ragweedPollen= getAq('ragweed_pollen', iso); - return { - iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, - cc, ccLow, ccMid, ccHigh, cloudCat, - uv, uvA, uvB, - precip, snow, - soilT0, soilT6, soilM, concreteT, vehicleT, - elev, Tmrt, utci, utciAdj, eh, compass, - visKm, aqi, - grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, - }; - }) : []; - - // Two-pass indoor temperature: needs the full hourly arrays so thermal - // lag can look back at previous hours. Run after hourlyRows is built, - // then stamp each row with its indoorT value. - if (hourlyRows.length > 0) { - const TaArr = hourlyRows.map(r => r.Ta); - const globArr = hourlyRows.map(r => r.glob); - const elevArr = hourlyRows.map(r => r.elev); - const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); - const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); - hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); - } - - // Group those hourly rows into days for the day tabs. - const days = []; - hourlyRows.forEach(row => { - const key = row.iso.slice(0, 10); - let day = days.find(d => d.key === key); - if (!day) { day = { key, rows: [] }; days.push(day); } - day.rows.push(row); + // 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 || []; @@ -927,21 +332,55 @@ export function UTCIForecast() { // 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 [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(() => { - setBannerIndex(i => (i + 1) % activeEvents.length); + const next = (bannerIndexRef.current + 1) % activeEvents.length; + bannerSlideTo(next); }, 10000); return () => clearInterval(id); - }, [activeEvents.length, activeEvents.map(e => e.id).join(',')]); + }, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]); // Keep index in bounds if events change useEffect(() => { @@ -989,7 +428,7 @@ export function UTCIForecast() {
-
0 ? ' visible' : ''}`}> +
${activeEvents.length > 0 && (() => { const ev = activeEvents[bannerIndex] || activeEvents[0]; const fmtDate = (iso) => iso @@ -1008,38 +447,40 @@ export function UTCIForecast() { : `Active ${startFmt} – ${endFmt}`) : null; return html` -
- ${ev.emoji} -
-
${ev.title}
-
${ev.message}
- ${dateLine && html` -
- ${dateLine} -
- `} - ${activeEvents.length > 1 && html` -
- ${activeEvents.map((_, i) => html` - setBannerIndex(i)} - style=${{ background: ev.textColor }} - /> - `)} -
- `} +
+
+ ${ev.emoji} +
+
${ev.title}
+
${ev.message}
+ ${dateLine && html` +
+ ${dateLine} +
+ `} + ${activeEvents.length > 1 && html` +
+ ${activeEvents.map((_, i) => html` + bannerSlideTo(i)} + style=${{ background: ev.textColor }} + /> + `)} +
+ `} +
+
-
`; })()}
@@ -1455,22 +896,18 @@ export function UTCIForecast() { })} <${CustomSelect} - key="activities" - value=${activityValue} - isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))} + key="places" + value=${placeValue} + isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)} noHide=${true} - hideLabel="🎯 Activities" - buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`} - options=${activityOptions} + hideLabel="🌤️ Places" + buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`} + options=${placeOptions} 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); @@ -1484,18 +921,22 @@ export function UTCIForecast() { }} /> <${CustomSelect} - key="places" - value=${placeValue} - isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)} + key="activities" + value=${activityValue} + isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))} noHide=${true} - hideLabel="🌤️ Places" - buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`} - options=${placeOptions} + 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); @@ -1538,23 +979,23 @@ export function UTCIForecast() { Burn + Vehicle dropdowns always shown (free + pro). Pro users see all toggles; free users see profile-filtered subset. --> - ${(isPro || activeProfile === 'outdoors' || FILTER_PROFILES[activeProfile]?.cols['burn'] || FILTER_PROFILES[activeProfile]?.cols['vehicleT'] || FILTER_PROFILES[activeProfile]?.cols['indoorT'] || FILTER_PROFILES[activeProfile]?.cols['managedT']) && html` + ${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
Columns: -${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['air']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['rh']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dew']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilT6']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['soilM']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['concreteT']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['wind']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['dir']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['cloud']) && html``} - ${isPro && html``} - ${isPro && html``} - <${CustomSelect} +${isPro && (activeProfile === 'custom' || activeCols['air']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['rh']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['dew']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['soilT']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['soilT6']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['soilM']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['concreteT']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['wind']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['dir']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['cloud']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['vis']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['aqi']) && html``} + ${(isPro || activeCols['pollen']) && html`<${CustomSelect} value=${pollenType} isOn=${visibleCols.pollen} hideLabel="Pollen" @@ -1571,17 +1012,17 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a setVisibleCols(prev => ({ ...prev, pollen: true })); } }} - /> - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['sun']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['direct']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['diffuse']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['tmrt']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['delta']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['utci']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvA']) && html``} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['uvB']) && html``} + />`} + ${isPro && (activeProfile === 'custom' || activeCols['sun']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['direct']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['diffuse']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['delta']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['utci']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['uvA']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['uvB']) && html``} - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['burn']) && html` + ${(isPro ? (activeProfile === 'custom' || activeCols['burn']) : activeCols['burn']) && html` <${CustomSelect} value=${skinType} isOn=${visibleCols.burn} @@ -1601,7 +1042,7 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a }} />`} - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile]?.cols['vehicleT']) && html` + ${(isPro ? (activeProfile === 'custom' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` <${CustomSelect} value=${vehicleType} @@ -1633,7 +1074,7 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a />`} `} - ${(activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['indoorT'] || FILTER_PROFILES[activeProfile].cols['managedT']) && html` + ${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html` <${CustomSelect} value=${buildingType} @@ -1664,7 +1105,7 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a title="Managed: curtains closed by day, windows open when cooler outside" />`} `} - ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['precip']) && html``} + ${isPro && (activeProfile === 'custom' || activeCols['precip']) && html``}
`} @@ -1796,14 +1237,17 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} /> ${localHHMM} - ${getCellTagEvents(selectedDayEvents, r).map(ev => html` - handleEventTagClick(ev, e)} - onMouseEnter=${(e) => handleEventTagEnter(ev, e)} - onMouseLeave=${handleEventTagLeave} - role="button" tabIndex="0" aria-label=${ev.title} - >${ev.emoji} - `)} + ${(() => { + const rowEvents = getCellTagEvents(selectedDayEvents, r); + return rowEvents.map(ev => html` + handleEventTagClick(rowEvents, e)} + onMouseEnter=${(e) => handleEventTagEnter(rowEvents, e)} + onMouseLeave=${handleEventTagLeave} + role="button" tabIndex="0" aria-label=${ev.title} + >${ev.emoji} + `); + })()} ${visibleCols.air && html`${r.Ta.toFixed(1)}`} @@ -1830,9 +1274,11 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a ${r.compass.label} `} - ${visibleCols.cloud && html` + ${visibleCols.cloud && html` - <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> + + <${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${28} /> + ${Math.round(r.cc)} `} @@ -1970,24 +1416,41 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a

${COL_DESCRIPTIONS[colPopup.key].desc}

`} - ${eventTagPopup && html` -
- - ${eventTagPopup.ev.emoji} ${eventTagPopup.ev.title} -

${eventTagPopup.ev.message}

-
`} + ${eventTagPopup && (() => { + const evs = eventTagPopup.events; + const ev = evs[evSlideIndex] || evs[0]; + return html` +
+ +
+ ${ev.emoji} ${ev.title} +

${ev.message}

+
+ ${evs.length > 1 && html` +
+ ${evs.map((_, i) => html` + evSlideTo(i)} + /> + `)} +
+ `} +
`; + })()}
Thermal stress bands diff --git a/assets/js/compute.js b/assets/js/compute.js new file mode 100644 index 0000000..b74e36f --- /dev/null +++ b/assets/js/compute.js @@ -0,0 +1,118 @@ +// ════════════════════════════════════════════════════════════════════════ +// compute.js — Build the per-hour display rows from the raw API data. +// +// Pure-ish function: feed in (forecast, airQuality, location, vehicleType, +// vehicleVent, buildingType) and get back { hourlyRows, days, utcOffsetMs }. +// +// Open-Meteo with timezone=auto returns local wall-clock strings like +// "2026-05-13T14:00" — no Z suffix. Two forms are used in each row: +// • String slices (iso.slice(...)) for display & day grouping +// • A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*). +// ════════════════════════════════════════════════════════════════════════ + +import { + vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, + calcConcreteTemp, calcVehicleInteriorTemp, + calcIndoorTempPass, calcManagedIndoorTempPass, +} from './physics.js'; +import { windCompass8, uvSplit, cloudCategory, precipPenalty } from './utils.js'; + +export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType }) { + const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; + + // Build a fast lookup map from the air quality hourly data: ISO string → index. + // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like + // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14". + const aqTimeMap = {}; + if (airQuality?.hourly?.time) { + airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; }); + } + const getAq = (field, iso) => { + if (!airQuality?.hourly?.[field]) return null; + const i = aqTimeMap[iso.slice(0, 13)]; + if (i === undefined) return null; + return airQuality.hourly[field][i] ?? null; + }; + + const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { + const h = forecast.hourly; + const Ta = h.temperature_2m[i]; + const RH = h.relative_humidity_2m[i]; + const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; + const va = h.wind_speed_10m[i]; + const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; + const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; + const dir = h.direct_radiation[i] || 0; + const dif = h.diffuse_radiation[i] || 0; + const glob = h.shortwave_radiation[i] || 0; + const cc = h.cloud_cover[i]; + const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; + const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; + const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; + const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; + const precip = h.precipitation[i] || 0; + const snow = h.snowfall[i] || 0; + const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; + const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; + const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; + const concreteT = calcConcreteTemp(Ta, glob, va); + // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). + // For display we slice the string directly — no Date object needed. + // For solarElevationDeg (which uses .getUTC* internally) we need the + // true UTC instant: treat the local time as UTC then subtract the offset. + // e.g. Brisbane UTC+10: local 14:00 → parse as UTC 14:00 → subtract 10h → UTC 04:00 ✓ + const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); + const elev = solarElevationDeg(location.lat, location.lon, dt); + const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); + const eh = vaporPressureHpa(Ta, RH); + const Tmrt = calcTmrt(Ta, dir, dif, glob, elev); + const utci = utciApprox(Ta, Tmrt, va, eh); + const utciAdj = utci + precipPenalty(precip, snow, va); + // Derived + const compass = windCompass8(wd); + const { uvA, uvB } = uvSplit(uv, elev); + const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); + // Visibility from the main forecast API (metres → km). + const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })(); + const aqi = getAq('european_aqi', iso); + const grassPollen = getAq('grass_pollen', iso); + const birchPollen = getAq('birch_pollen', iso); + const alderPollen = getAq('alder_pollen', iso); + const mugwortPollen= getAq('mugwort_pollen', iso); + const olivePollen = getAq('olive_pollen', iso); + const ragweedPollen= getAq('ragweed_pollen', iso); + return { + iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, + cc, ccLow, ccMid, ccHigh, cloudCat, + uv, uvA, uvB, + precip, snow, + soilT0, soilT6, soilM, concreteT, vehicleT, + elev, Tmrt, utci, utciAdj, eh, compass, + visKm, aqi, + grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, + }; + }) : []; + + // Two-pass indoor temperature: needs the full hourly arrays so thermal + // lag can look back at previous hours. Run after hourlyRows is built, + // then stamp each row with its indoorT value. + if (hourlyRows.length > 0) { + const TaArr = hourlyRows.map(r => r.Ta); + const globArr = hourlyRows.map(r => r.glob); + const elevArr = hourlyRows.map(r => r.elev); + const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); + const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); + hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); + } + + // Group those hourly rows into days for the day tabs. + const days = []; + hourlyRows.forEach(row => { + const key = row.iso.slice(0, 10); + let day = days.find(d => d.key === key); + if (!day) { day = { key, rows: [] }; days.push(day); } + day.rows.push(row); + }); + + return { hourlyRows, days, utcOffsetMs }; +} diff --git a/assets/js/config.js b/assets/js/config.js new file mode 100644 index 0000000..5002bfd --- /dev/null +++ b/assets/js/config.js @@ -0,0 +1,162 @@ +// ════════════════════════════════════════════════════════════════════════ +// config.js — Pure-data configuration constants for the UTCIForecast app. +// +// Moved out of app.js so the main component is easier to read and edit. +// Nothing here has state or side effects — just constants and lookup +// tables imported by app.js (and anywhere else that needs them). +// +// Where to find things: +// FREE_DAYS .................. how many days the free tier shows +// FILTER_PROFILES ............ preset column sets (Basic, Home, etc.) +// OUTDOORS_VARIANTS .......... Places & Activities sub-variants +// POLLEN_TYPES ............... pollen-column pulldown options +// variantIcons ............... emoji glyph per place/activity variant +// profileButtonOrder ......... left-to-right order of the profile buttons +// activityVariantKeys ........ which variants appear in the Activities menu +// placeVariantKeys ........... which variants appear in the Places menu +// COL_DESCRIPTIONS ........... tooltip text for each table column header +// ════════════════════════════════════════════════════════════════════════ + +// How many days the free tier shows. Days beyond this get a 🔒. +// Bump this number if you want to give free users more access. +export const FREE_DAYS = 3; + +// Filter profile presets — each preset defines which columns are visible +// when that profile is selected. +export const FILTER_PROFILES = { + basic: { + label: 'Basic', + icon: '🌡️', + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false }, + }, + home: { + label: 'Home', + icon: '🏠', + cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true }, + }, + vehicle: { + label: 'Vehicle', + icon: '🚗', + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false }, + }, + farming: { + label: 'Farming', + icon: '🌾', + cols: { hour: true, air: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true }, + }, + outdoors: { + label: 'Places', + icon: '🌤️', + // Default cols match the first variant (urban). Switching variant updates visibleCols. + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false }, + }, + alltemps: { + label: 'Temps', + icon: '🌡️', + proOnly: true, + cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false }, + }, + custom: { + label: 'Custom', + icon: '⚙️', + proOnly: true, + cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false }, + }, +}; + +// Places sub-variants — each has its own column set. +// Selecting a variant applies its cols to visibleCols. +export const OUTDOORS_VARIANTS = { + urban: { name: 'Urban', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } }, + beach: { name: 'Beach', cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + events: { name: 'Events', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, + festival: { name: 'Festival', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, + wintersports: { name: 'Winter Sports', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + naturist: { name: 'Naturist', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } }, + sailing: { name: 'Sailing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + // Places — free + park: { name: 'Park / Picnic', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, + airport: { name: 'Airport / Travel', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + // Places — Pro + construction: { name: 'Construction', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false } }, + // Activities — free + cycling: { name: 'Cycling', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + running: { name: 'Running', cols: { hour: true, air: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, + dogwalk: { name: 'Dog Walking', cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } }, + // Activities — Pro + hiking: { name: 'Hiking', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + photography: { name: 'Photography', proOnly: true, cols: { hour: true, air: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, + fishing: { name: 'Fishing', proOnly: true, cols: { hour: true, air: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false } }, +}; + +// Pollen type options shown in the pulldown selector. +export const POLLEN_TYPES = { + all_pollen: { name: 'All pollen', unit: 'grains/m³' }, + grass_pollen: { name: 'Grass pollen', unit: 'grains/m³' }, + birch_pollen: { name: 'Birch pollen', unit: 'grains/m³' }, + alder_pollen: { name: 'Alder pollen', unit: 'grains/m³' }, + mugwort_pollen: { name: 'Mugwort pollen', unit: 'grains/m³' }, + olive_pollen: { name: 'Olive pollen', unit: 'grains/m³' }, + ragweed_pollen: { name: 'Ragweed pollen', unit: 'grains/m³' }, +}; + +// Left-to-right order of the profile buttons in the top filter bar. +export const profileButtonOrder = ['basic', 'home', 'vehicle', 'alltemps', 'custom']; + +// Emoji glyph per place/activity variant. +export const variantIcons = { + urban: '🏙️', + beach: '🏖️', + events: '🎪', + park: '🌳', + airport: '✈️', + festival: '⛺', + construction: '🏗️', + cycling: '🚴', + running: '🏃', + dogwalk: '🐾', + sailing: '⛵', + wintersports: '🎿', + naturist: '☀️', + hiking: '🏔️', + photography: '📸', + fishing: '🎣', +}; + +// Which variant keys appear in the Activities dropdown. +export const activityVariantKeys = ['cycling', 'running', 'dogwalk', 'sailing', 'wintersports', 'naturist', 'hiking', 'photography', 'fishing']; + +// Which variant keys appear in the Places dropdown. +export const placeVariantKeys = ['urban', 'beach', 'events', 'park', 'airport', 'festival', 'construction']; + +// Tooltip text shown when the user clicks/hovers a table column header. +export const COL_DESCRIPTIONS = { + hour: { title: 'Hour', desc: 'Local wall-clock time for this forecast row. Each row covers one hour.' }, + air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' }, + rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' }, + dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' }, + wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m, with peak gust in brackets where significantly higher. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' }, + dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' }, + cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' }, + sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' }, + direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' }, + diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' }, + tmrt: { title: 'Mean Radiant Temp', desc: 'The temperature a person\'s skin "sees" from all surrounding surfaces and the sun combined. Can exceed air temperature by 20–30 °C on a sunny day — this is why shade feels so much cooler.' }, + delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' }, + utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the raw felt temperature combining air temp, humidity, wind, and solar radiation. Does not include precipitation effects.' }, + uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.' }, + uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' }, + burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' }, + utciP: { title: 'UTCI+P', desc: 'SunScope\'s adjusted felt temperature: UTCI plus the soak-factor penalty for precipitation. Rain and snow on wet clothing can reduce the felt temperature by up to 8 °C.' }, + precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' }, + soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.' }, + soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, + soilM: { title: 'Soil Moisture', desc: 'Volumetric water content of the top 1 cm of soil (m³/m³). Values above 0.4 suggest saturated ground; below 0.2 indicates dry conditions.' }, + concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 15–25 °C above air temperature on sunny days.' }, + vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces with retained warmth, lower glass gain, and slower heat response. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, + indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, + managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, + vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' }, + aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0–100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 0–20 = Good; 20–40 = Fair; 40–60 = Moderate; 60–80 = Poor; 80–100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' }, + pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 10–50; High = 50–200; Very High = 200+. Values vary by species and season.' }, +}; diff --git a/assets/js/events.js b/assets/js/events.js index 9d55511..469e6e3 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -1,20 +1,45 @@ // ════════════════════════════════════════════════════════════════════════ // events.js — Cosmic & weather event engine for SunScope. // +// This is the public-facing barrel module. It composes the smaller files +// under ./events/ and exposes the same API as before, so callers +// (app.js, components.js) keep working without changes. +// // Returns the "active event" (or null) based on: // 1. PROMO_OVERRIDE — manually set a message for promotions etc. // 2. Hardcoded cosmic calendar (eclipses, meteor showers, alignments) // 3. Weather-derived events (stargazing, sunset, heat spike, storm) // -// To set a promo banner, edit PROMO_OVERRIDE near the top of this file. +// To set a promo banner, edit PROMO_OVERRIDE below. // Set it back to null when done. // // Each event object: // { id, emoji, title, message, color, textColor, type } // // type: 'cosmic' | 'weather' | 'promo' +// +// Where to find things: +// Cosmic calendar data ........ ./events/cosmic-calendar.js +// Almanac calendar data ....... ./events/almanac-calendar.js +// Weather check functions ..... ./events/weather-checks.js +// Dynamic message helper ...... ./events/dynamic-message.js +// Lens overlay SVG renderer ... ./events/lens-overlay.js // ════════════════════════════════════════════════════════════════════════ +import { COSMIC_CALENDAR } from './events/cosmic-calendar.js'; +import { + checkStargazing, + checkSunset, + checkHeatSpike, + checkStorm, + checkFrost, +} from './events/weather-checks.js'; +import { dynamicCosmicMessage } from './events/dynamic-message.js'; + +// Re-export so callers that imported these from events.js keep working. +export { getUpcomingEvents } from './events/almanac-calendar.js'; +export { getLensOverlaySVG } from './events/lens-overlay.js'; + // ─── PROMO OVERRIDE ────────────────────────────────────────────────────── // Set this to show a custom banner regardless of weather or cosmic events. // Leave as null for automatic event detection. @@ -32,708 +57,6 @@ // export const PROMO_OVERRIDE = null; -// ─── COSMIC CALENDAR ───────────────────────────────────────────────────── -// Hardcoded events — these are known years in advance. -// Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD', peak: 'YYYY-MM-DD' (optional), ...eventProps } -// Active if today falls within [start, end] (inclusive). -// Night-only events (meteor showers, eclipses) are marked nightOnly: true -// so the cell icons only appear in night-time rows. - -const COSMIC_CALENDAR = [ - - // ── METEOR SHOWERS ────────────────────────────────────────────────── - { - id: 'quadrantids-2026', - emoji: '☄️', - title: 'Quadrantid Meteor Shower', - message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best after midnight in a dark sky.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-01-01', end: '2026-01-05', peak: '2026-01-03', - }, - { - id: 'lyrids-2026', - emoji: '☄️', - title: 'Lyrid Meteor Shower', - message: 'The Lyrid meteor shower peaks tonight — up to 20 meteors/hour from a dark sky after midnight.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-04-16', end: '2026-04-25', peak: '2026-04-22', - }, - { - id: 'eta-aquariids-2026', - emoji: '☄️', - title: 'Eta Aquariid Meteor Shower', - message: 'The Eta Aquariid shower peaks tonight — fragments of Halley\'s Comet, up to 50/hour before dawn.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-04-19', end: '2026-05-28', peak: '2026-05-06', - }, - { - id: 'perseids-2026', - emoji: '☄️', - title: 'Perseid Meteor Shower', - message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak.', - color: '#3a1a00', - textColor: '#ffe8c0', - type: 'cosmic', - nightOnly: true, - start: '2026-07-17', end: '2026-08-24', peak: '2026-08-12', - }, - { - id: 'orionids-2026', - emoji: '☄️', - title: 'Orionid Meteor Shower', - message: 'The Orionid shower peaks tonight — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-10-02', end: '2026-11-07', peak: '2026-10-21', - }, - { - id: 'leonids-2026', - emoji: '☄️', - title: 'Leonid Meteor Shower', - message: 'The Leonid shower is active tonight — fast, bright meteors from comet Tempel-Tuttle.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-11-06', end: '2026-11-30', peak: '2026-11-17', - }, - { - id: 'geminids-2026', - emoji: '☄️', - title: 'Geminid Meteor Shower', - message: 'The Geminids peak tonight — the best shower of the year, up to 150 meteors/hour. No moon interference.', - color: '#3a1a00', - textColor: '#ffe8c0', - type: 'cosmic', - nightOnly: true, - start: '2026-12-04', end: '2026-12-20', peak: '2026-12-13', - }, - { - id: 'ursids-2026', - emoji: '☄️', - title: 'Ursid Meteor Shower', - message: 'The Ursid shower peaks tonight — a quieter festive shower near the winter solstice.', - color: '#2a1a5a', - textColor: '#e8d8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22', - }, - - // ── ECLIPSES ──────────────────────────────────────────────────────── - { - id: 'solar-eclipse-2026-aug', - emoji: '🌑', - title: 'Total Solar Eclipse', - message: 'A total solar eclipse crosses Europe and North Africa today — look for rapid temperature drops and unusual animal behaviour even in partial zones.', - color: '#1a0a2a', - textColor: '#d8c8f8', - type: 'cosmic', - nightOnly: false, - start: '2026-08-12', end: '2026-08-12', - }, - { - id: 'lunar-eclipse-2026-mar', - emoji: '🌕', - title: 'Total Lunar Eclipse', - message: 'A total lunar eclipse is visible tonight — the Moon turns deep red (a "Blood Moon") as it passes through Earth\'s shadow.', - color: '#3a0a0a', - textColor: '#ffd8d8', - type: 'cosmic', - nightOnly: true, - start: '2026-03-03', end: '2026-03-03', - }, - - // ── PLANETARY EVENTS ──────────────────────────────────────────────── - { - id: 'saturn-opposition-2026', - emoji: '🪐', - title: 'Saturn at Opposition', - message: 'Saturn is at its closest and brightest tonight — visible all night long, rings tilted beautifully toward Earth.', - color: '#1a2a3a', - textColor: '#c8e8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-09-23', end: '2026-09-23', - }, - { - id: 'jupiter-opposition-2026', - emoji: '🪐', - title: 'Jupiter at Opposition', - message: 'Jupiter is at its closest and brightest tonight — you can see its cloud bands with binoculars.', - color: '#1a2a3a', - textColor: '#c8e8ff', - type: 'cosmic', - nightOnly: true, - start: '2026-10-08', end: '2026-10-08', - }, - { - id: 'mars-conjunction-2026', - emoji: '🔴', - title: 'Mars & Venus Conjunction', - message: 'Mars and Venus are remarkably close in the evening sky tonight — a striking pair visible to the naked eye.', - color: '#2a1520', - textColor: '#ffc8d8', - type: 'cosmic', - nightOnly: true, - start: '2026-06-30', end: '2026-07-02', - }, - - // ── 2027 METEOR SHOWERS ───────────────────────────────────────────── - { - id: 'quadrantids-2027', - emoji: '☄️', - title: 'Quadrantid Meteor Shower', - message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best in the hours before dawn on 4 Jan from a dark site.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-01-01', end: '2027-01-05', peak: '2027-01-04', - }, - { - id: 'lyrids-2027', - emoji: '☄️', - title: 'Lyrid Meteor Shower', - message: 'The Lyrid meteor shower peaks — up to 20 meteors/hour after midnight. Note: bright waning gibbous moon may reduce visibility this year.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-04-16', end: '2027-04-25', peak: '2027-04-23', - }, - { - id: 'eta-aquariids-2027', - emoji: '☄️', - title: 'Eta Aquariid Meteor Shower', - message: 'The Eta Aquariids peak — fragments of Halley\'s Comet, up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-04-19', end: '2027-05-28', peak: '2027-05-05', - }, - { - id: 'perseids-2027', - emoji: '☄️', - title: 'Perseid Meteor Shower', - message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak around 13 Aug.', - color: '#3a1a00', textColor: '#ffe8c0', - type: 'cosmic', nightOnly: true, - start: '2027-07-17', end: '2027-08-24', peak: '2027-08-13', - }, - { - id: 'orionids-2027', - emoji: '☄️', - title: 'Orionid Meteor Shower', - message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-10-02', end: '2027-11-07', peak: '2027-10-21', - }, - { - id: 'leonids-2027', - emoji: '☄️', - title: 'Leonid Meteor Shower', - message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-11-06', end: '2027-11-30', peak: '2027-11-17', - }, - { - id: 'geminids-2027', - emoji: '☄️', - title: 'Geminid Meteor Shower', - message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.', - color: '#3a1a00', textColor: '#ffe8c0', - type: 'cosmic', nightOnly: true, - start: '2027-12-04', end: '2027-12-20', peak: '2027-12-14', - }, - { - id: 'ursids-2027', - emoji: '☄️', - title: 'Ursid Meteor Shower', - message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.', - color: '#2a1a5a', textColor: '#e8d8ff', - type: 'cosmic', nightOnly: true, - start: '2027-12-17', end: '2027-12-26', peak: '2027-12-22', - }, - - // ── 2027 ECLIPSES ─────────────────────────────────────────────────── - { - id: 'annular-solar-eclipse-2027-feb', - emoji: '🌑', - title: 'Annular Solar Eclipse', - message: 'An annular solar eclipse creates a "ring of fire" effect — visible across parts of South America, Africa and the Indian Ocean.', - color: '#1a0a2a', textColor: '#d8c8f8', - type: 'cosmic', nightOnly: false, - start: '2027-02-06', end: '2027-02-06', peak: '2027-02-06', - }, - { - id: 'total-solar-eclipse-2027-aug', - emoji: '🌑', - title: 'Total Solar Eclipse', - message: 'A spectacular total solar eclipse — the path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia. One of the longest totalities of the century.', - color: '#1a0a2a', textColor: '#d8c8f8', - type: 'cosmic', nightOnly: false, - start: '2027-08-02', end: '2027-08-02', peak: '2027-08-02', - }, - - // ── 2027 PLANETARY EVENTS ─────────────────────────────────────────── - { - id: 'mars-opposition-2027', - emoji: '🔴', - title: 'Mars at Opposition', - message: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars will reveal its surface colour.', - color: '#2a1520', textColor: '#ffc8d8', - type: 'cosmic', nightOnly: true, - start: '2027-02-19', end: '2027-02-19', peak: '2027-02-19', - }, - { - id: 'venus-jupiter-conjunction-2027', - emoji: '🪐', - title: 'Venus & Jupiter Conjunction', - message: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.', - color: '#1a2a3a', textColor: '#c8e8ff', - type: 'cosmic', nightOnly: true, - start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25', - }, -]; - -// ─── WEATHER-DERIVED EVENTS ─────────────────────────────────────────────── -// Computed in real-time from the forecast data. -// Each checker function receives (rows [, location]) and returns an event -// object or null. rows = today's hourlyRows array. -// location = { lat, lon, name, country }. -// -// To add a new weather event: write a checkXxx(rows, location) function -// below and add it to the checks[] array inside getActiveEvents(). - -function checkStargazing(rows) { - // Great stargazing: mostly clear night hours with low cloud - const nightRows = rows.filter(r => r.elev < -5); - if (nightRows.length < 3) return null; - const avgCloud = nightRows.reduce((s, r) => s + r.cc, 0) / nightRows.length; - if (avgCloud > 30) return null; - return { - id: 'stargazing', - emoji: '⭐', - title: 'Great Stargazing Tonight', - message: `Clear skies expected overnight at ${nightRows.length} hours with average ${Math.round(avgCloud)}% cloud — ideal conditions for stargazing.`, - color: '#080e1a', - textColor: '#c8dcff', - type: 'weather', - nightOnly: true, - }; -} - -function checkSunset(rows, location) { - // Find the actual sunset window: the LAST contiguous run of rows where - // solar elevation is in the golden/civil-twilight band (-3° to 8°). - // This distinguishes sunset from sunrise (which is the first such run). - const twilightIndices = rows - .map((r, i) => ({ r, i })) - .filter(({ r }) => r.elev > -3 && r.elev < 8); - if (twilightIndices.length === 0) return null; - - // Split into runs separated by gaps (midday gap separates sunrise from sunset) - const runs = []; - let run = [twilightIndices[0]]; - for (let k = 1; k < twilightIndices.length; k++) { - if (twilightIndices[k].i === twilightIndices[k - 1].i + 1) { - run.push(twilightIndices[k]); - } else { - runs.push(run); - run = [twilightIndices[k]]; - } - } - runs.push(run); - - // Use the LAST run (sunset). If only one run exists it's either sunrise-only - // or a single dusk window — use it but we'll label generically. - const sunsetRun = runs[runs.length - 1]; - const sunsetRows = sunsetRun.map(({ r }) => r); - const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev; - // If sun is rising through the band this is a sunrise window, not sunset — skip. - if (isSunrise) return null; - - const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; - const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length; - if (avgLowCloud > 25) return null; - if (avgCloud < 5 || avgCloud > 75) return null; - - // Store the ISO time range so getCellTagEvents can limit the icon to those hours - const firstISO = sunsetRun[0].r.iso; - const lastISO = sunsetRun[sunsetRun.length - 1].r.iso; - - return { - id: 'perfect-sunset', - emoji: '🌅', - title: 'Spectacular Sunset Conditions', - message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions look ideal for a vivid sunset near ${location.name}.`, - color: '#3a1a00', - textColor: '#ffe0b0', - type: 'weather', - nightOnly: false, - isoRange: [firstISO, lastISO], // only show cell icon during these hours - }; -} - -function checkHeatSpike(rows) { - const maxTemp = Math.max(...rows.map(r => r.Ta).filter(isFinite)); - if (maxTemp < 30) return null; - const severity = maxTemp >= 36 ? 'extreme' : maxTemp >= 33 ? 'severe' : 'notable'; - const msgs = { - notable: `Temperatures reaching ${maxTemp.toFixed(1)}°C — above seasonal norms. Stay hydrated and avoid prolonged sun exposure.`, - severe: `Heat warning: ${maxTemp.toFixed(1)}°C expected today. Risk of heat exhaustion for vulnerable people — keep cool and hydrated.`, - extreme: `Extreme heat alert: ${maxTemp.toFixed(1)}°C forecast. Risk of heat stroke — avoid outdoor activity during peak hours.`, - }; - return { - id: 'heat-spike', - emoji: '🔥', - title: severity === 'extreme' ? 'Extreme Heat Alert' : severity === 'severe' ? 'Heat Warning' : 'Heat Spike Today', - message: msgs[severity], - color: severity === 'extreme' ? '#3a0000' : severity === 'severe' ? '#4a1000' : '#5a2000', - textColor: '#ffd0b0', - type: 'weather', - nightOnly: false, - }; -} - -function checkStorm(rows) { - const maxGust = Math.max(...rows.map(r => r.gust ?? r.va ?? 0).filter(isFinite)); - const maxPrecip = Math.max(...rows.map(r => r.precip ?? 0).filter(isFinite)); - if (maxGust < 15 && maxPrecip < 5) return null; - const isStorm = maxGust >= 20 || maxPrecip >= 10; - return { - id: 'storm', - emoji: '🌩️', - title: isStorm ? 'Storm Conditions Forecast' : 'Blustery & Wet Today', - message: isStorm - ? `Storm-level conditions expected — gusts to ${maxGust.toFixed(0)} m/s with heavy precipitation. Take care outdoors.` - : `Unsettled day ahead — windy with gusts to ${maxGust.toFixed(0)} m/s and ${maxPrecip.toFixed(1)} mm/h rain at peak.`, - color: '#1a2030', - textColor: '#c0d8f0', - type: 'weather', - nightOnly: false, - }; -} - -function checkFrost(rows) { - const minTemp = Math.min(...rows.map(r => r.Ta).filter(isFinite)); - if (minTemp > 2) return null; - return { - id: 'frost', - emoji: '❄️', - title: minTemp <= 0 ? 'Freezing Conditions' : 'Frost Risk Tonight', - message: minTemp <= 0 - ? `Temperatures dropping to ${minTemp.toFixed(1)}°C — ice on roads and surfaces is likely. Allow extra travel time.` - : `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`, - color: '#0a1a2a', - textColor: '#c8e8ff', - type: 'weather', - nightOnly: false, - }; -} - -// ─── ALMANAC CALENDAR ──────────────────────────────────────────────────── -// Extended calendar used for the "What's Coming" panel. -// Includes all events from COSMIC_CALENDAR plus multi-year entries. -// Each entry has a latHint: null = global, 'north' = better from northern -// latitudes, 'south' = southern, 'path:...' = specific eclipse path note. - -const ALMANAC_CALENDAR = [ - // 2026 events (covers rest of year from today) - { - id: 'lunar-eclipse-2026-mar', - emoji: '🌕', - title: 'Total Lunar Eclipse', - desc: 'The Moon turns deep red as it passes through Earth\'s shadow. Visible from Europe, Africa, and the Americas.', - start: '2026-03-03', peak: '2026-03-03', - color: '#3a0a0a', textColor: '#ffd8d8', - latHint: null, - type: 'eclipse', - }, - { - id: 'lyrids-2026', - emoji: '☄️', - title: 'Lyrid Meteor Shower', - desc: 'Up to 20 meteors/hour at peak. Active Apr 16–25, best after midnight from a dark site.', - start: '2026-04-16', peak: '2026-04-22', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'north', - type: 'meteor', - }, - { - id: 'eta-aquariids-2026', - emoji: '☄️', - title: 'Eta Aquariid Meteor Shower', - desc: 'Debris from Halley\'s Comet — up to 50/hour before dawn. Best from southern latitudes but visible worldwide.', - start: '2026-04-19', peak: '2026-05-06', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'south', - type: 'meteor', - }, - { - id: 'mars-conjunction-2026', - emoji: '🔴', - title: 'Mars & Venus Conjunction', - desc: 'Mars and Venus appear strikingly close in the evening sky — a beautiful naked-eye pairing.', - start: '2026-06-30', peak: '2026-07-01', - color: '#2a1520', textColor: '#ffc8d8', - latHint: null, - type: 'planetary', - }, - { - id: 'perseids-2026', - emoji: '☄️', - title: 'Perseid Meteor Shower', - desc: 'One of the best showers of the year — up to 100/hour at peak, no need for a telescope.', - start: '2026-07-17', peak: '2026-08-12', - color: '#3a1a00', textColor: '#ffe8c0', - latHint: 'north', - type: 'meteor', - }, - { - id: 'solar-eclipse-2026-aug', - emoji: '🌑', - title: 'Total Solar Eclipse', - desc: 'The path of totality crosses Spain, Iceland, and Greenland. Partial eclipse visible across most of Europe.', - start: '2026-08-12', peak: '2026-08-12', - color: '#1a0a2a', textColor: '#d8c8f8', - latHint: 'path:Spain, Iceland, Greenland — partial eclipse across UK & Europe', - type: 'eclipse', - }, - { - id: 'saturn-opposition-2026', - emoji: '🪐', - title: 'Saturn at Opposition', - desc: 'Saturn is at its closest and brightest — rings tilted beautifully toward Earth. Visible all night.', - start: '2026-09-23', peak: '2026-09-23', - color: '#1a2a3a', textColor: '#c8e8ff', - latHint: null, - type: 'planetary', - }, - { - id: 'orionids-2026', - emoji: '☄️', - title: 'Orionid Meteor Shower', - desc: 'Fast, bright meteors from Halley\'s Comet — up to 25/hour. Active Oct 2 – Nov 7.', - start: '2026-10-02', peak: '2026-10-21', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: null, - type: 'meteor', - }, - { - id: 'jupiter-opposition-2026', - emoji: '🪐', - title: 'Jupiter at Opposition', - desc: 'Jupiter at its closest — you can see its cloud bands and four Galilean moons with binoculars.', - start: '2026-10-08', peak: '2026-10-08', - color: '#1a2a3a', textColor: '#c8e8ff', - latHint: null, - type: 'planetary', - }, - { - id: 'leonids-2026', - emoji: '☄️', - title: 'Leonid Meteor Shower', - desc: 'Fast, bright meteors from comet Tempel-Tuttle. Up to 15/hour at peak.', - start: '2026-11-06', peak: '2026-11-17', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: null, - type: 'meteor', - }, - { - id: 'geminids-2026', - emoji: '☄️', - title: 'Geminid Meteor Shower', - desc: 'The finest shower of the year — up to 150 meteors/hour, visible even before midnight.', - start: '2026-12-04', peak: '2026-12-13', - color: '#3a1a00', textColor: '#ffe8c0', - latHint: 'north', - type: 'meteor', - }, - { - id: 'ursids-2026', - emoji: '☄️', - title: 'Ursid Meteor Shower', - desc: 'A quieter shower near the winter solstice — circumpolar, so best from northern latitudes.', - start: '2026-12-17', peak: '2026-12-22', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'north', - type: 'meteor', - }, - - // ── 2027 ───────────────────────────────────────────────────────────── - { - id: 'quadrantids-2027', - emoji: '☄️', - title: 'Quadrantid Meteor Shower', - desc: 'Up to 120 meteors/hour at peak — one of the strongest showers but with a very sharp peak. Best in the hours before dawn on 4 Jan.', - start: '2027-01-01', peak: '2027-01-04', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'north', - type: 'meteor', - }, - { - id: 'annular-solar-eclipse-2027-feb', - emoji: '🌑', - title: 'Annular Solar Eclipse', - desc: 'A "ring of fire" eclipse visible from parts of South America, Africa and the Indian Ocean. Partial eclipse across much of the southern hemisphere.', - start: '2027-02-06', peak: '2027-02-06', - color: '#1a0a2a', textColor: '#d8c8f8', - latHint: 'path:South America, southern Africa, Indian Ocean', - type: 'eclipse', - }, - { - id: 'mars-opposition-2027', - emoji: '🔴', - title: 'Mars at Opposition', - desc: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars reveal its distinct colour.', - start: '2027-02-19', peak: '2027-02-19', - color: '#2a1520', textColor: '#ffc8d8', - latHint: null, - type: 'planetary', - }, - { - id: 'lyrids-2027', - emoji: '☄️', - title: 'Lyrid Meteor Shower', - desc: 'Up to 20 meteors/hour at peak. Note: bright waning gibbous moon may reduce visibility this year. Active Apr 16–25.', - start: '2027-04-16', peak: '2027-04-23', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'north', - type: 'meteor', - }, - { - id: 'eta-aquariids-2027', - emoji: '☄️', - title: 'Eta Aquariid Meteor Shower', - desc: 'Debris from Halley\'s Comet — up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.', - start: '2027-04-19', peak: '2027-05-05', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'south', - type: 'meteor', - }, - { - id: 'perseids-2027', - emoji: '☄️', - title: 'Perseid Meteor Shower', - desc: 'One of the best showers of the year — up to 100/hour at peak, no telescope needed. Active Jul 17 – Aug 24.', - start: '2027-07-17', peak: '2027-08-13', - color: '#3a1a00', textColor: '#ffe8c0', - latHint: 'north', - type: 'meteor', - }, - { - id: 'total-solar-eclipse-2027-aug', - emoji: '🌑', - title: 'Total Solar Eclipse', - desc: 'One of the longest total solar eclipses of the century. Path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia.', - start: '2027-08-02', peak: '2027-08-02', - color: '#1a0a2a', textColor: '#d8c8f8', - latHint: 'path:Morocco, Spain, Algeria, Libya, Egypt, Saudi Arabia', - type: 'eclipse', - }, - { - id: 'venus-jupiter-conjunction-2027', - emoji: '🪐', - title: 'Venus & Jupiter Conjunction', - desc: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.', - start: '2027-08-23', peak: '2027-08-25', - color: '#1a2a3a', textColor: '#c8e8ff', - latHint: null, - type: 'planetary', - }, - { - id: 'orionids-2027', - emoji: '☄️', - title: 'Orionid Meteor Shower', - desc: 'Fast, bright meteors from Halley\'s Comet debris — up to 25/hour. Active Oct 2 – Nov 7.', - start: '2027-10-02', peak: '2027-10-21', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: null, - type: 'meteor', - }, - { - id: 'leonids-2027', - emoji: '☄️', - title: 'Leonid Meteor Shower', - desc: 'Fast meteors from comet Tempel-Tuttle. Up to 15/hour at peak. Active Nov 6–30.', - start: '2027-11-06', peak: '2027-11-17', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: null, - type: 'meteor', - }, - { - id: 'geminids-2027', - emoji: '☄️', - title: 'Geminid Meteor Shower', - desc: 'The finest shower of the year — up to 150 meteors/hour, visible even before midnight. Active Dec 4–20.', - start: '2027-12-04', peak: '2027-12-14', - color: '#3a1a00', textColor: '#ffe8c0', - latHint: 'north', - type: 'meteor', - }, - { - id: 'ursids-2027', - emoji: '☄️', - title: 'Ursid Meteor Shower', - desc: 'A quieter shower near the winter solstice — circumpolar, best from northern latitudes. Active Dec 17–26.', - start: '2027-12-17', peak: '2027-12-22', - color: '#2a1a5a', textColor: '#e8d8ff', - latHint: 'north', - type: 'meteor', - }, -]; - -// Returns a location-aware visibility note for an almanac entry. -// lat = user's latitude. -function getVisibilityNote(entry, lat) { - if (!entry.latHint) return null; - if (entry.latHint.startsWith('path:')) { - return entry.latHint.replace('path:', '').trim(); - } - if (entry.latHint === 'north') { - if (lat >= 45) return 'Well placed for your latitude'; - if (lat >= 20) return 'Visible from your location'; - return 'Better from northern latitudes'; - } - if (entry.latHint === 'south') { - if (lat <= 10) return 'Well placed for your latitude'; - if (lat <= 40) return 'Visible from your location'; - return 'Better from southern latitudes'; - } - return null; -} - -// ─── MAIN ALMANAC EXPORT ───────────────────────────────────────────────── -// Returns events with peak dates in the next `days` days (default 90), -// sorted by peak date, with a visibility note added. -export function getUpcomingEvents(location, days = 90) { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const cutoff = new Date(today); - cutoff.setDate(cutoff.getDate() + days); - const todayStr = today.toISOString().slice(0, 10); - const cutoffStr = cutoff.toISOString().slice(0, 10); - - return ALMANAC_CALENDAR - .filter(ev => ev.peak >= todayStr && ev.peak <= cutoffStr) - .sort((a, b) => a.peak.localeCompare(b.peak)) - .map(ev => ({ - ...ev, - visibilityNote: getVisibilityNote(ev, location?.lat ?? 51), - daysUntil: Math.round((new Date(ev.peak + 'T00:00Z') - today) / 86400000), - })); -} - // ─── CELL TAG LOGIC ────────────────────────────────────────────────────── // Returns the subset of active events that should show an icon for this row. export function getCellTagEvents(events, row) { @@ -750,33 +73,6 @@ export function getCellTagEvents(events, row) { }); } - -// ─── DYNAMIC MESSAGE GENERATOR ─────────────────────────────────────────── -// Rewrites a cosmic event's message based on where today sits vs. the peak. -// Before peak : "is active and building — peak on . " -// On peak ±1d : "peaks tonight — " -// After peak : "is past its peak () but still possibly visible — " -// Single-day events (start === end) keep their static message unchanged. - -function dynamicCosmicMessage(ev, dateStr) { - if (!ev.peak || ev.start === ev.end) return ev.message; - var today = new Date(dateStr + 'T00:00Z'); - var peak = new Date(ev.peak + 'T00:00Z'); - var diffDays = Math.round((today - peak) / 86400000); - var peakFmt = peak.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }); - var base = ev.message - .replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '') - .trim(); - var baseCapd = base.charAt(0).toUpperCase() + base.slice(1); - if (diffDays < -1) { - return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd; - } else if (diffDays <= 1) { - return ev.title + ' peaks tonight — ' + base; - } else { - return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base; - } -} - // ─── MAIN EXPORT ───────────────────────────────────────────────────────── // Returns ALL active events for the given rows/date as an array. // Empty array = nothing active. @@ -831,96 +127,3 @@ export function getLensEvent(events) { return distE < distB ? ev : best; }); } - -// ─── LENS OVERLAY RENDERER ─────────────────────────────────────────────── -// Returns an SVG string for the event overlay inside the big ScopeReticle. -// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers. -// cx, cy = lens centre coords; lensR = lens radius. -export function getLensOverlaySVG(event, cx, cy, lensR) { - if (!event) return null; - const id = event.id; - - if (event.emoji === '☄️') { - return [ - '', - '', - '', - '', - '', - '', - '', - ].join(''); - } - - if (id.includes('solar-eclipse')) { - const cx_ = cx, cy_ = cy - 20; - return '' - + '' - + '' - + '' - + '' - + '' - + ''; - } - - if (id.includes('lunar-eclipse')) { - const cx_ = cx, cy_ = cy - 30; - return '' - + '' - + '' - + '' - + '' - + '' - + ''; - } - - if (event.emoji === '🪐' && event.id.includes('conjunction')) { - return '' - + '' - + ''; - } - - if (event.emoji === '🔴') { - return '' - + '' - + ''; - } - - if (id === 'stargazing') { - const pts = [[cx-50,cy-55,1.8],[cx+40,cy-65,1.4],[cx-20,cy-70,1.0],[cx+65,cy-40,1.6],[cx-60,cy-30,1.2],[cx+50,cy-55,1.0],[cx-35,cy-45,1.4],[cx+20,cy-50,1.8],[cx-75,cy-50,1.0]]; - const dots = pts.map(function(s){return '';}).join(''); - return '' + dots + ''; - } - - if (id === 'perfect-sunset') { - return '' - + '' - + '' - + '' - + '' - + ''; - } - - if (id === 'heat-spike') { - return '' - + '' - + ''; - } - - if (id === 'storm') { - const sl = [-55,-30,-5,20,45,65].map(function(x){ - return ''; - }).join(''); - return '' + sl + ''; - } - - if (id === 'frost') { - const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){ - var dx=p[0], dy=p[1]; - return ''; - }).join(''); - return '' + fc + ''; - } - - return null; -} diff --git a/assets/js/events/almanac-calendar.js b/assets/js/events/almanac-calendar.js new file mode 100644 index 0000000..f3c999e --- /dev/null +++ b/assets/js/events/almanac-calendar.js @@ -0,0 +1,298 @@ +// ════════════════════════════════════════════════════════════════════════ +// almanac-calendar.js — Extended event calendar for the "What's Coming" +// panel and getUpcomingEvents() export. +// +// Includes all events from COSMIC_CALENDAR plus multi-year entries. +// Each entry has a `latHint`: +// null = global / no hint +// 'north' = better from northern latitudes +// 'south' = better from southern latitudes +// 'path:...' = specific eclipse path note +// ════════════════════════════════════════════════════════════════════════ + +export const ALMANAC_CALENDAR = [ + // 2026 events (covers rest of year from today) + { + id: 'lunar-eclipse-2026-mar', + emoji: '🌕', + title: 'Total Lunar Eclipse', + desc: 'The Moon turns deep red as it passes through Earth\'s shadow. Visible from Europe, Africa, and the Americas.', + start: '2026-03-03', peak: '2026-03-03', + color: '#3a0a0a', textColor: '#ffd8d8', + latHint: null, + type: 'eclipse', + }, + { + id: 'lyrids-2026', + emoji: '☄️', + title: 'Lyrid Meteor Shower', + desc: 'Up to 20 meteors/hour at peak. Active Apr 16–25, best after midnight from a dark site.', + start: '2026-04-16', peak: '2026-04-22', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'north', + type: 'meteor', + }, + { + id: 'eta-aquariids-2026', + emoji: '☄️', + title: 'Eta Aquariid Meteor Shower', + desc: 'Debris from Halley\'s Comet — up to 50/hour before dawn. Best from southern latitudes but visible worldwide.', + start: '2026-04-19', peak: '2026-05-06', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'south', + type: 'meteor', + }, + { + id: 'mars-conjunction-2026', + emoji: '🔴', + title: 'Mars & Venus Conjunction', + desc: 'Mars and Venus appear strikingly close in the evening sky — a beautiful naked-eye pairing.', + start: '2026-06-30', peak: '2026-07-01', + color: '#2a1520', textColor: '#ffc8d8', + latHint: null, + type: 'planetary', + }, + { + id: 'perseids-2026', + emoji: '☄️', + title: 'Perseid Meteor Shower', + desc: 'One of the best showers of the year — up to 100/hour at peak, no need for a telescope.', + start: '2026-07-17', peak: '2026-08-12', + color: '#3a1a00', textColor: '#ffe8c0', + latHint: 'north', + type: 'meteor', + }, + { + id: 'solar-eclipse-2026-aug', + emoji: '🌑', + title: 'Total Solar Eclipse', + desc: 'The path of totality crosses Spain, Iceland, and Greenland. Partial eclipse visible across most of Europe.', + start: '2026-08-12', peak: '2026-08-12', + color: '#1a0a2a', textColor: '#d8c8f8', + latHint: 'path:Spain, Iceland, Greenland — partial eclipse across UK & Europe', + type: 'eclipse', + }, + { + id: 'saturn-opposition-2026', + emoji: '🪐', + title: 'Saturn at Opposition', + desc: 'Saturn is at its closest and brightest — rings tilted beautifully toward Earth. Visible all night.', + start: '2026-09-23', peak: '2026-09-23', + color: '#1a2a3a', textColor: '#c8e8ff', + latHint: null, + type: 'planetary', + }, + { + id: 'orionids-2026', + emoji: '☄️', + title: 'Orionid Meteor Shower', + desc: 'Fast, bright meteors from Halley\'s Comet — up to 25/hour. Active Oct 2 – Nov 7.', + start: '2026-10-02', peak: '2026-10-21', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: null, + type: 'meteor', + }, + { + id: 'jupiter-opposition-2026', + emoji: '🪐', + title: 'Jupiter at Opposition', + desc: 'Jupiter at its closest — you can see its cloud bands and four Galilean moons with binoculars.', + start: '2026-10-08', peak: '2026-10-08', + color: '#1a2a3a', textColor: '#c8e8ff', + latHint: null, + type: 'planetary', + }, + { + id: 'leonids-2026', + emoji: '☄️', + title: 'Leonid Meteor Shower', + desc: 'Fast, bright meteors from comet Tempel-Tuttle. Up to 15/hour at peak.', + start: '2026-11-06', peak: '2026-11-17', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: null, + type: 'meteor', + }, + { + id: 'geminids-2026', + emoji: '☄️', + title: 'Geminid Meteor Shower', + desc: 'The finest shower of the year — up to 150 meteors/hour, visible even before midnight.', + start: '2026-12-04', peak: '2026-12-13', + color: '#3a1a00', textColor: '#ffe8c0', + latHint: 'north', + type: 'meteor', + }, + { + id: 'ursids-2026', + emoji: '☄️', + title: 'Ursid Meteor Shower', + desc: 'A quieter shower near the winter solstice — circumpolar, so best from northern latitudes.', + start: '2026-12-17', peak: '2026-12-22', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'north', + type: 'meteor', + }, + + // ── 2027 ───────────────────────────────────────────────────────────── + { + id: 'quadrantids-2027', + emoji: '☄️', + title: 'Quadrantid Meteor Shower', + desc: 'Up to 120 meteors/hour at peak — one of the strongest showers but with a very sharp peak. Best in the hours before dawn on 4 Jan.', + start: '2027-01-01', peak: '2027-01-04', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'north', + type: 'meteor', + }, + { + id: 'annular-solar-eclipse-2027-feb', + emoji: '🌑', + title: 'Annular Solar Eclipse', + desc: 'A "ring of fire" eclipse visible from parts of South America, Africa and the Indian Ocean. Partial eclipse across much of the southern hemisphere.', + start: '2027-02-06', peak: '2027-02-06', + color: '#1a0a2a', textColor: '#d8c8f8', + latHint: 'path:South America, southern Africa, Indian Ocean', + type: 'eclipse', + }, + { + id: 'mars-opposition-2027', + emoji: '🔴', + title: 'Mars at Opposition', + desc: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars reveal its distinct colour.', + start: '2027-02-19', peak: '2027-02-19', + color: '#2a1520', textColor: '#ffc8d8', + latHint: null, + type: 'planetary', + }, + { + id: 'lyrids-2027', + emoji: '☄️', + title: 'Lyrid Meteor Shower', + desc: 'Up to 20 meteors/hour at peak. Note: bright waning gibbous moon may reduce visibility this year. Active Apr 16–25.', + start: '2027-04-16', peak: '2027-04-23', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'north', + type: 'meteor', + }, + { + id: 'eta-aquariids-2027', + emoji: '☄️', + title: 'Eta Aquariid Meteor Shower', + desc: 'Debris from Halley\'s Comet — up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.', + start: '2027-04-19', peak: '2027-05-05', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'south', + type: 'meteor', + }, + { + id: 'perseids-2027', + emoji: '☄️', + title: 'Perseid Meteor Shower', + desc: 'One of the best showers of the year — up to 100/hour at peak, no telescope needed. Active Jul 17 – Aug 24.', + start: '2027-07-17', peak: '2027-08-13', + color: '#3a1a00', textColor: '#ffe8c0', + latHint: 'north', + type: 'meteor', + }, + { + id: 'total-solar-eclipse-2027-aug', + emoji: '🌑', + title: 'Total Solar Eclipse', + desc: 'One of the longest total solar eclipses of the century. Path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia.', + start: '2027-08-02', peak: '2027-08-02', + color: '#1a0a2a', textColor: '#d8c8f8', + latHint: 'path:Morocco, Spain, Algeria, Libya, Egypt, Saudi Arabia', + type: 'eclipse', + }, + { + id: 'venus-jupiter-conjunction-2027', + emoji: '🪐', + title: 'Venus & Jupiter Conjunction', + desc: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.', + start: '2027-08-23', peak: '2027-08-25', + color: '#1a2a3a', textColor: '#c8e8ff', + latHint: null, + type: 'planetary', + }, + { + id: 'orionids-2027', + emoji: '☄️', + title: 'Orionid Meteor Shower', + desc: 'Fast, bright meteors from Halley\'s Comet debris — up to 25/hour. Active Oct 2 – Nov 7.', + start: '2027-10-02', peak: '2027-10-21', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: null, + type: 'meteor', + }, + { + id: 'leonids-2027', + emoji: '☄️', + title: 'Leonid Meteor Shower', + desc: 'Fast meteors from comet Tempel-Tuttle. Up to 15/hour at peak. Active Nov 6–30.', + start: '2027-11-06', peak: '2027-11-17', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: null, + type: 'meteor', + }, + { + id: 'geminids-2027', + emoji: '☄️', + title: 'Geminid Meteor Shower', + desc: 'The finest shower of the year — up to 150 meteors/hour, visible even before midnight. Active Dec 4–20.', + start: '2027-12-04', peak: '2027-12-14', + color: '#3a1a00', textColor: '#ffe8c0', + latHint: 'north', + type: 'meteor', + }, + { + id: 'ursids-2027', + emoji: '☄️', + title: 'Ursid Meteor Shower', + desc: 'A quieter shower near the winter solstice — circumpolar, best from northern latitudes. Active Dec 17–26.', + start: '2027-12-17', peak: '2027-12-22', + color: '#2a1a5a', textColor: '#e8d8ff', + latHint: 'north', + type: 'meteor', + }, +]; + +// Returns a location-aware visibility note for an almanac entry. +// lat = user's latitude. +export function getVisibilityNote(entry, lat) { + if (!entry.latHint) return null; + if (entry.latHint.startsWith('path:')) { + return entry.latHint.replace('path:', '').trim(); + } + if (entry.latHint === 'north') { + if (lat >= 45) return 'Well placed for your latitude'; + if (lat >= 20) return 'Visible from your location'; + return 'Better from northern latitudes'; + } + if (entry.latHint === 'south') { + if (lat <= 10) return 'Well placed for your latitude'; + if (lat <= 40) return 'Visible from your location'; + return 'Better from southern latitudes'; + } + return null; +} + +// ─── MAIN ALMANAC EXPORT ───────────────────────────────────────────────── +// Returns events with peak dates in the next `days` days (default 90), +// sorted by peak date, with a visibility note added. +export function getUpcomingEvents(location, days = 90) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const cutoff = new Date(today); + cutoff.setDate(cutoff.getDate() + days); + const todayStr = today.toISOString().slice(0, 10); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + return ALMANAC_CALENDAR + .filter(ev => ev.peak >= todayStr && ev.peak <= cutoffStr) + .sort((a, b) => a.peak.localeCompare(b.peak)) + .map(ev => ({ + ...ev, + visibilityNote: getVisibilityNote(ev, location?.lat ?? 51), + daysUntil: Math.round((new Date(ev.peak + 'T00:00Z') - today) / 86400000), + })); +} diff --git a/assets/js/events/cosmic-calendar.js b/assets/js/events/cosmic-calendar.js new file mode 100644 index 0000000..8fd428e --- /dev/null +++ b/assets/js/events/cosmic-calendar.js @@ -0,0 +1,276 @@ +// ════════════════════════════════════════════════════════════════════════ +// cosmic-calendar.js — Hardcoded cosmic events (meteor showers, eclipses, +// planetary oppositions/conjunctions) used by getActiveEvents. +// +// Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD', +// peak: 'YYYY-MM-DD' (optional), ...eventProps } +// Active if today falls within [start, end] (inclusive). +// Night-only events (meteor showers, eclipses) are marked nightOnly: true +// so the cell icons only appear in night-time rows. +// ════════════════════════════════════════════════════════════════════════ + +export const COSMIC_CALENDAR = [ + + // ── METEOR SHOWERS ────────────────────────────────────────────────── + { + id: 'quadrantids-2026', + emoji: '☄️', + title: 'Quadrantid Meteor Shower', + message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best after midnight in a dark sky.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-01-01', end: '2026-01-05', peak: '2026-01-03', + }, + { + id: 'lyrids-2026', + emoji: '☄️', + title: 'Lyrid Meteor Shower', + message: 'The Lyrid meteor shower peaks tonight — up to 20 meteors/hour from a dark sky after midnight.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-04-16', end: '2026-04-25', peak: '2026-04-22', + }, + { + id: 'eta-aquariids-2026', + emoji: '☄️', + title: 'Eta Aquariid Meteor Shower', + message: 'The Eta Aquariid shower peaks tonight — fragments of Halley\'s Comet, up to 50/hour before dawn.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-04-19', end: '2026-05-28', peak: '2026-05-06', + }, + { + id: 'perseids-2026', + emoji: '☄️', + title: 'Perseid Meteor Shower', + message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak.', + color: '#3a1a00', + textColor: '#ffe8c0', + type: 'cosmic', + nightOnly: true, + start: '2026-07-17', end: '2026-08-24', peak: '2026-08-12', + }, + { + id: 'orionids-2026', + emoji: '☄️', + title: 'Orionid Meteor Shower', + message: 'The Orionid shower peaks tonight — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-10-02', end: '2026-11-07', peak: '2026-10-21', + }, + { + id: 'leonids-2026', + emoji: '☄️', + title: 'Leonid Meteor Shower', + message: 'The Leonid shower is active tonight — fast, bright meteors from comet Tempel-Tuttle.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-11-06', end: '2026-11-30', peak: '2026-11-17', + }, + { + id: 'geminids-2026', + emoji: '☄️', + title: 'Geminid Meteor Shower', + message: 'The Geminids peak tonight — the best shower of the year, up to 150 meteors/hour. No moon interference.', + color: '#3a1a00', + textColor: '#ffe8c0', + type: 'cosmic', + nightOnly: true, + start: '2026-12-04', end: '2026-12-20', peak: '2026-12-13', + }, + { + id: 'ursids-2026', + emoji: '☄️', + title: 'Ursid Meteor Shower', + message: 'The Ursid shower peaks tonight — a quieter festive shower near the winter solstice.', + color: '#2a1a5a', + textColor: '#e8d8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22', + }, + + // ── ECLIPSES ──────────────────────────────────────────────────────── + { + id: 'solar-eclipse-2026-aug', + emoji: '🌑', + title: 'Total Solar Eclipse', + message: 'A total solar eclipse crosses Europe and North Africa today — look for rapid temperature drops and unusual animal behaviour even in partial zones.', + color: '#1a0a2a', + textColor: '#d8c8f8', + type: 'cosmic', + nightOnly: false, + start: '2026-08-12', end: '2026-08-12', + }, + { + id: 'lunar-eclipse-2026-mar', + emoji: '🌕', + title: 'Total Lunar Eclipse', + message: 'A total lunar eclipse is visible tonight — the Moon turns deep red (a "Blood Moon") as it passes through Earth\'s shadow.', + color: '#3a0a0a', + textColor: '#ffd8d8', + type: 'cosmic', + nightOnly: true, + start: '2026-03-03', end: '2026-03-03', + }, + + // ── PLANETARY EVENTS ──────────────────────────────────────────────── + { + id: 'saturn-opposition-2026', + emoji: '🪐', + title: 'Saturn at Opposition', + message: 'Saturn is at its closest and brightest tonight — visible all night long, rings tilted beautifully toward Earth.', + color: '#1a2a3a', + textColor: '#c8e8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-09-23', end: '2026-09-23', + }, + { + id: 'jupiter-opposition-2026', + emoji: '🪐', + title: 'Jupiter at Opposition', + message: 'Jupiter is at its closest and brightest tonight — you can see its cloud bands with binoculars.', + color: '#1a2a3a', + textColor: '#c8e8ff', + type: 'cosmic', + nightOnly: true, + start: '2026-10-08', end: '2026-10-08', + }, + { + id: 'mars-conjunction-2026', + emoji: '🔴', + title: 'Mars & Venus Conjunction', + message: 'Mars and Venus are remarkably close in the evening sky tonight — a striking pair visible to the naked eye.', + color: '#2a1520', + textColor: '#ffc8d8', + type: 'cosmic', + nightOnly: true, + start: '2026-06-30', end: '2026-07-02', + }, + + // ── 2027 METEOR SHOWERS ───────────────────────────────────────────── + { + id: 'quadrantids-2027', + emoji: '☄️', + title: 'Quadrantid Meteor Shower', + message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best in the hours before dawn on 4 Jan from a dark site.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-01-01', end: '2027-01-05', peak: '2027-01-04', + }, + { + id: 'lyrids-2027', + emoji: '☄️', + title: 'Lyrid Meteor Shower', + message: 'The Lyrid meteor shower peaks — up to 20 meteors/hour after midnight. Note: bright waning gibbous moon may reduce visibility this year.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-04-16', end: '2027-04-25', peak: '2027-04-23', + }, + { + id: 'eta-aquariids-2027', + emoji: '☄️', + title: 'Eta Aquariid Meteor Shower', + message: 'The Eta Aquariids peak — fragments of Halley\'s Comet, up to 50/hour before dawn. New moon on 6 May means excellent dark skies this year.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-04-19', end: '2027-05-28', peak: '2027-05-05', + }, + { + id: 'perseids-2027', + emoji: '☄️', + title: 'Perseid Meteor Shower', + message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak around 13 Aug.', + color: '#3a1a00', textColor: '#ffe8c0', + type: 'cosmic', nightOnly: true, + start: '2027-07-17', end: '2027-08-24', peak: '2027-08-13', + }, + { + id: 'orionids-2027', + emoji: '☄️', + title: 'Orionid Meteor Shower', + message: 'The Orionid shower peaks — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-10-02', end: '2027-11-07', peak: '2027-10-21', + }, + { + id: 'leonids-2027', + emoji: '☄️', + title: 'Leonid Meteor Shower', + message: 'The Leonid shower peaks — fast meteors from comet Tempel-Tuttle, up to 15/hour.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-11-06', end: '2027-11-30', peak: '2027-11-17', + }, + { + id: 'geminids-2027', + emoji: '☄️', + title: 'Geminid Meteor Shower', + message: 'The Geminids peak — the finest shower of the year, up to 150 meteors/hour, visible even before midnight.', + color: '#3a1a00', textColor: '#ffe8c0', + type: 'cosmic', nightOnly: true, + start: '2027-12-04', end: '2027-12-20', peak: '2027-12-14', + }, + { + id: 'ursids-2027', + emoji: '☄️', + title: 'Ursid Meteor Shower', + message: 'The Ursid shower peaks near the winter solstice — circumpolar, best from northern latitudes.', + color: '#2a1a5a', textColor: '#e8d8ff', + type: 'cosmic', nightOnly: true, + start: '2027-12-17', end: '2027-12-26', peak: '2027-12-22', + }, + + // ── 2027 ECLIPSES ─────────────────────────────────────────────────── + { + id: 'annular-solar-eclipse-2027-feb', + emoji: '🌑', + title: 'Annular Solar Eclipse', + message: 'An annular solar eclipse creates a "ring of fire" effect — visible across parts of South America, Africa and the Indian Ocean.', + color: '#1a0a2a', textColor: '#d8c8f8', + type: 'cosmic', nightOnly: false, + start: '2027-02-06', end: '2027-02-06', peak: '2027-02-06', + }, + { + id: 'total-solar-eclipse-2027-aug', + emoji: '🌑', + title: 'Total Solar Eclipse', + message: 'A spectacular total solar eclipse — the path of totality crosses Morocco, Spain, Algeria, Libya, Egypt and Saudi Arabia. One of the longest totalities of the century.', + color: '#1a0a2a', textColor: '#d8c8f8', + type: 'cosmic', nightOnly: false, + start: '2027-08-02', end: '2027-08-02', peak: '2027-08-02', + }, + + // ── 2027 PLANETARY EVENTS ─────────────────────────────────────────── + { + id: 'mars-opposition-2027', + emoji: '🔴', + title: 'Mars at Opposition', + message: 'Mars is at its closest and brightest — a vivid red beacon visible all night long. Good binoculars will reveal its surface colour.', + color: '#2a1520', textColor: '#ffc8d8', + type: 'cosmic', nightOnly: true, + start: '2027-02-19', end: '2027-02-19', peak: '2027-02-19', + }, + { + id: 'venus-jupiter-conjunction-2027', + emoji: '🪐', + title: 'Venus & Jupiter Conjunction', + message: 'Venus and Jupiter appear strikingly close together in the evening sky — a dazzling naked-eye pairing of the two brightest planets.', + color: '#1a2a3a', textColor: '#c8e8ff', + type: 'cosmic', nightOnly: true, + start: '2027-08-23', end: '2027-08-27', peak: '2027-08-25', + }, +]; diff --git a/assets/js/events/dynamic-message.js b/assets/js/events/dynamic-message.js new file mode 100644 index 0000000..1ef5ad4 --- /dev/null +++ b/assets/js/events/dynamic-message.js @@ -0,0 +1,29 @@ +// ════════════════════════════════════════════════════════════════════════ +// dynamic-message.js — Rewrites a cosmic event's message based on where +// today sits vs. the peak. +// +// Before peak : "is active and building — peak on . " +// On peak ±1d : "peaks tonight — " +// After peak : "is past its peak () but still possibly visible — " +// +// Single-day events (start === end) keep their static message unchanged. +// ════════════════════════════════════════════════════════════════════════ + +export function dynamicCosmicMessage(ev, dateStr) { + if (!ev.peak || ev.start === ev.end) return ev.message; + var today = new Date(dateStr + 'T00:00Z'); + var peak = new Date(ev.peak + 'T00:00Z'); + var diffDays = Math.round((today - peak) / 86400000); + var peakFmt = peak.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }); + var base = ev.message + .replace(/^.*?(?:peaks tonight|peak tonight|is active tonight|is active|peaks)\s*—\s*/i, '') + .trim(); + var baseCapd = base.charAt(0).toUpperCase() + base.slice(1); + if (diffDays < -1) { + return ev.title + ' is active and building — peak on ' + peakFmt + '. ' + baseCapd; + } else if (diffDays <= 1) { + return ev.title + ' peaks tonight — ' + base; + } else { + return ev.title + ' is past its peak (' + peakFmt + ') but still possibly visible — ' + base; + } +} diff --git a/assets/js/events/lens-overlay.js b/assets/js/events/lens-overlay.js new file mode 100644 index 0000000..8b6f9bc --- /dev/null +++ b/assets/js/events/lens-overlay.js @@ -0,0 +1,100 @@ +// ════════════════════════════════════════════════════════════════════════ +// lens-overlay.js — Returns an SVG string for the event overlay inside +// the big ScopeReticle. +// +// Rendered INSIDE the lens clip path, BELOW the glass shine/shade layers. +// cx, cy = lens centre coords; lensR = lens radius. +// +// To add a new overlay for an event: add another `if (...)` block below +// that matches by event.emoji or event.id and returns an SVG fragment +// string. Return null when no overlay applies. +// ════════════════════════════════════════════════════════════════════════ + +export function getLensOverlaySVG(event, cx, cy, lensR) { + if (!event) return null; + const id = event.id; + + if (event.emoji === '☄️') { + return [ + '', + '', + '', + '', + '', + '', + '', + ].join(''); + } + + if (id.includes('solar-eclipse')) { + const cx_ = cx, cy_ = cy - 20; + return '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + if (id.includes('lunar-eclipse')) { + const cx_ = cx, cy_ = cy - 30; + return '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + if (event.emoji === '🪐' && event.id.includes('conjunction')) { + return '' + + '' + + ''; + } + + if (event.emoji === '🔴') { + return '' + + '' + + ''; + } + + if (id === 'stargazing') { + const pts = [[cx-50,cy-55,1.8],[cx+40,cy-65,1.4],[cx-20,cy-70,1.0],[cx+65,cy-40,1.6],[cx-60,cy-30,1.2],[cx+50,cy-55,1.0],[cx-35,cy-45,1.4],[cx+20,cy-50,1.8],[cx-75,cy-50,1.0]]; + const dots = pts.map(function(s){return '';}).join(''); + return '' + dots + ''; + } + + if (id === 'perfect-sunset') { + return '' + + '' + + '' + + '' + + '' + + ''; + } + + if (id === 'heat-spike') { + return '' + + '' + + ''; + } + + if (id === 'storm') { + const sl = [-55,-30,-5,20,45,65].map(function(x){ + return ''; + }).join(''); + return '' + sl + ''; + } + + if (id === 'frost') { + const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){ + var dx=p[0], dy=p[1]; + return ''; + }).join(''); + return '' + fc + ''; + } + + return null; +} diff --git a/assets/js/events/weather-checks.js b/assets/js/events/weather-checks.js new file mode 100644 index 0000000..36f31ac --- /dev/null +++ b/assets/js/events/weather-checks.js @@ -0,0 +1,139 @@ +// ════════════════════════════════════════════════════════════════════════ +// weather-checks.js — Weather-derived event detectors. +// +// Computed in real-time from the forecast data. +// Each checker function receives (rows [, location]) and returns an event +// object or null. rows = today's hourlyRows array. +// location = { lat, lon, name, country }. +// +// To add a new weather event: write a checkXxx(rows, location) function +// below, export it, and add it to the checks[] array inside +// getActiveEvents() (in events.js). +// ════════════════════════════════════════════════════════════════════════ + +export function checkStargazing(rows) { + // Great stargazing: mostly clear night hours with low cloud + const nightRows = rows.filter(r => r.elev < -5); + if (nightRows.length < 3) return null; + const avgCloud = nightRows.reduce((s, r) => s + r.cc, 0) / nightRows.length; + if (avgCloud > 30) return null; + return { + id: 'stargazing', + emoji: '⭐', + title: 'Great Stargazing Tonight', + message: `Clear skies expected overnight at ${nightRows.length} hours with average ${Math.round(avgCloud)}% cloud — ideal conditions for stargazing.`, + color: '#080e1a', + textColor: '#c8dcff', + type: 'weather', + nightOnly: true, + }; +} + +export function checkSunset(rows, location) { + // Find the actual sunset window: the LAST contiguous run of rows where + // solar elevation is in the golden/civil-twilight band (-3° to 8°). + // This distinguishes sunset from sunrise (which is the first such run). + const twilightIndices = rows + .map((r, i) => ({ r, i })) + .filter(({ r }) => r.elev > -3 && r.elev < 8); + if (twilightIndices.length === 0) return null; + + // Split into runs separated by gaps (midday gap separates sunrise from sunset) + const runs = []; + let run = [twilightIndices[0]]; + for (let k = 1; k < twilightIndices.length; k++) { + if (twilightIndices[k].i === twilightIndices[k - 1].i + 1) { + run.push(twilightIndices[k]); + } else { + runs.push(run); + run = [twilightIndices[k]]; + } + } + runs.push(run); + + // Use the LAST run (sunset). If only one run exists it's either sunrise-only + // or a single dusk window — use it but we'll label generically. + const sunsetRun = runs[runs.length - 1]; + const sunsetRows = sunsetRun.map(({ r }) => r); + const isSunrise = runs.length === 1 && sunsetRows[0].elev < sunsetRows[sunsetRows.length - 1].elev; + // If sun is rising through the band this is a sunrise window, not sunset — skip. + if (isSunrise) return null; + + const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; + const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length; + if (avgLowCloud > 25) return null; + if (avgCloud < 5 || avgCloud > 75) return null; + + // Store the ISO time range so getCellTagEvents can limit the icon to those hours + const firstISO = sunsetRun[0].r.iso; + const lastISO = sunsetRun[sunsetRun.length - 1].r.iso; + + return { + id: 'perfect-sunset', + emoji: '🌅', + title: 'Spectacular Sunset Conditions', + message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions look ideal for a vivid sunset near ${location.name}.`, + color: '#3a1a00', + textColor: '#ffe0b0', + type: 'weather', + nightOnly: false, + isoRange: [firstISO, lastISO], // only show cell icon during these hours + }; +} + +export function checkHeatSpike(rows) { + const maxTemp = Math.max(...rows.map(r => r.Ta).filter(isFinite)); + if (maxTemp < 30) return null; + const severity = maxTemp >= 36 ? 'extreme' : maxTemp >= 33 ? 'severe' : 'notable'; + const msgs = { + notable: `Temperatures reaching ${maxTemp.toFixed(1)}°C — above seasonal norms. Stay hydrated and avoid prolonged sun exposure.`, + severe: `Heat warning: ${maxTemp.toFixed(1)}°C expected today. Risk of heat exhaustion for vulnerable people — keep cool and hydrated.`, + extreme: `Extreme heat alert: ${maxTemp.toFixed(1)}°C forecast. Risk of heat stroke — avoid outdoor activity during peak hours.`, + }; + return { + id: 'heat-spike', + emoji: '🔥', + title: severity === 'extreme' ? 'Extreme Heat Alert' : severity === 'severe' ? 'Heat Warning' : 'Heat Spike Today', + message: msgs[severity], + color: severity === 'extreme' ? '#3a0000' : severity === 'severe' ? '#4a1000' : '#5a2000', + textColor: '#ffd0b0', + type: 'weather', + nightOnly: false, + }; +} + +export function checkStorm(rows) { + const maxGust = Math.max(...rows.map(r => r.gust ?? r.va ?? 0).filter(isFinite)); + const maxPrecip = Math.max(...rows.map(r => r.precip ?? 0).filter(isFinite)); + if (maxGust < 15 && maxPrecip < 5) return null; + const isStorm = maxGust >= 20 || maxPrecip >= 10; + return { + id: 'storm', + emoji: '🌩️', + title: isStorm ? 'Storm Conditions Forecast' : 'Blustery & Wet Today', + message: isStorm + ? `Storm-level conditions expected — gusts to ${maxGust.toFixed(0)} m/s with heavy precipitation. Take care outdoors.` + : `Unsettled day ahead — windy with gusts to ${maxGust.toFixed(0)} m/s and ${maxPrecip.toFixed(1)} mm/h rain at peak.`, + color: '#1a2030', + textColor: '#c0d8f0', + type: 'weather', + nightOnly: false, + }; +} + +export function checkFrost(rows) { + const minTemp = Math.min(...rows.map(r => r.Ta).filter(isFinite)); + if (minTemp > 2) return null; + return { + id: 'frost', + emoji: '❄️', + title: minTemp <= 0 ? 'Freezing Conditions' : 'Frost Risk Tonight', + message: minTemp <= 0 + ? `Temperatures dropping to ${minTemp.toFixed(1)}°C — ice on roads and surfaces is likely. Allow extra travel time.` + : `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`, + color: '#0a1a2a', + textColor: '#c8e8ff', + type: 'weather', + nightOnly: false, + }; +} diff --git a/assets/js/hooks/useColumnPopup.js b/assets/js/hooks/useColumnPopup.js new file mode 100644 index 0000000..6a9d1cd --- /dev/null +++ b/assets/js/hooks/useColumnPopup.js @@ -0,0 +1,221 @@ +// ════════════════════════════════════════════════════════════════════════ +// useColumnPopup — owns the column-header popup AND the event-tag popup. +// +// Both popups behave identically: +// • click an anchor → toggle the popup open +// • hover an anchor for N ms → open +// • move into the popup → keep it open +// • leave the popup → close after 200 ms +// • click outside / scroll / resize → close immediately +// +// Returns everything app.js needs to wire up the th cells and event-tag +// spans, plus the popup state objects for rendering the floating panels. +// ════════════════════════════════════════════════════════════════════════ + +import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js'; + +export function useColumnPopup() { + // ─── Column header popup ──────────────────────────────────────────── + const [colPopup, setColPopup] = useState(null); + const colPopupRef = useRef(null); + const colPopupThRef = useRef(null); + const hoverTimerRef = useRef(null); + const closeTimerRef = useRef(null); + + // ─── Event tag popup ──────────────────────────────────────────────── + // Holds { events[], slideIndex, x, y, arrowLeft, below } + // When multiple events are in the popup they auto-cycle with a crossfade. + const [eventTagPopup, setEventTagPopup] = useState(null); + const [evSlideIndex, setEvSlideIndex] = useState(0); + // 'entering' | 'exiting' | null — drives CSS crossfade classes + const [evTransition, setEvTransition] = useState(null); + const prevSlideIndexRef = useRef(0); + const eventTagPopupRef = useRef(null); + const evHoverTimerRef = useRef(null); + const evCloseTimerRef = useRef(null); + const evSlideTimerRef = useRef(null); + + const calcPopupPos = (thEl) => { + const rect = thEl.getBoundingClientRect(); + const popupW = 260, popupH = 110, margin = 8, gap = 6; + let x = rect.left + rect.width / 2; + x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); + const below = rect.top < popupH + gap + margin; + const y = below ? rect.bottom + gap : rect.top - gap; + const popupLeft = x - popupW / 2; + const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); + return { x, y, arrowLeft, below }; + }; + + const openPopup = (key, thEl) => { + colPopupThRef.current = thEl; + setColPopup({ key, ...calcPopupPos(thEl) }); + }; + + const closePopup = () => { + setColPopup(null); + colPopupThRef.current = null; + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + }; + + const handleThClick = (key, e) => { + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + if (colPopup?.key === key) { closePopup(); return; } + openPopup(key, e.currentTarget); + }; + + const handleThEnter = (key, e) => { + clearTimeout(hoverTimerRef.current); + clearTimeout(closeTimerRef.current); + // If a different popup is open, close it immediately and start fresh timer + if (colPopup && colPopup.key !== key) closePopup(); + if (colPopup?.key === key) return; // already showing this one + const thEl = e.currentTarget; + hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000); + }; + + // Leaving a th: just cancel the pending open. Don't auto-close — + // the user might be moving into the popup, or just passing through. + const handleThLeave = () => { + clearTimeout(hoverTimerRef.current); + }; + + // Popup mouse handlers: keep it open while hovering, close on leave. + const handlePopupEnter = () => clearTimeout(closeTimerRef.current); + const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); }; + + useEffect(() => { + if (!colPopup) return; + const onClickOutside = (e) => { + if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup(); + }; + // Close on scroll (avoids scroll-linked jank) or resize + const onScrollOrResize = () => closePopup(); + document.addEventListener('mousedown', onClickOutside); + window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); + window.addEventListener('resize', onScrollOrResize, { passive: true }); + return () => { + document.removeEventListener('mousedown', onClickOutside); + window.removeEventListener('scroll', onScrollOrResize, { capture: true }); + window.removeEventListener('resize', onScrollOrResize); + }; + }, [colPopup]); + + // ─── Event tag popup helpers ───────────────────────────────────────── + const calcEventPopupPos = (spanEl) => { + const rect = spanEl.getBoundingClientRect(); + const popupW = 260, popupH = 80, margin = 8, gap = 6; + let x = rect.left + rect.width / 2; + x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin)); + const below = rect.top < popupH + gap + margin; + const y = below ? rect.bottom + gap : rect.top - gap; + const popupLeft = x - popupW / 2; + const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16)); + return { x, y, arrowLeft, below }; + }; + + // ─── Slideshow advance ─────────────────────────────────────────────── + // evSlideTo triggers a crossfade to a new slide index. + const evSlideTo = useCallback((nextIndex) => { + setEvTransition('exiting'); + // After the exit animation (~400 ms) swap content and fade in + setTimeout(() => { + prevSlideIndexRef.current = nextIndex; + setEvSlideIndex(nextIndex); + setEvTransition('entering'); + // Clear the entering class once the animation finishes + setTimeout(() => setEvTransition(null), 420); + }, 400); + }, []); + + // Auto-advance slideshow when popup is open with multiple events. + // We store a ref to evSlideIndex so the interval closure always reads + // the latest value without needing to be recreated on every slide change. + const evSlideIndexRef = useRef(0); + useEffect(() => { evSlideIndexRef.current = evSlideIndex; }, [evSlideIndex]); + + useEffect(() => { + if (!eventTagPopup || eventTagPopup.events.length <= 1) { + clearInterval(evSlideTimerRef.current); + return; + } + evSlideTimerRef.current = setInterval(() => { + const next = (evSlideIndexRef.current + 1) % eventTagPopup.events.length; + evSlideTo(next); + }, 4000); + return () => clearInterval(evSlideTimerRef.current); + }, [eventTagPopup, evSlideTo]); + + // ─── Event tag popup handlers ──────────────────────────────────────── + const openEventTagPopup = (events, spanEl) => { + clearInterval(evSlideTimerRef.current); + setEvSlideIndex(0); + setEvTransition(null); + setEventTagPopup({ events, ...calcEventPopupPos(spanEl) }); + }; + + const closeEventTagPopup = () => { + setEventTagPopup(null); + setEvSlideIndex(0); + setEvTransition(null); + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + clearInterval(evSlideTimerRef.current); + }; + + // rowEvents = all events for that row (passed in from app.js) + const handleEventTagClick = (rowEvents, e) => { + e.stopPropagation(); + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + // Toggle off if same set already open + if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) { + closeEventTagPopup(); return; + } + openEventTagPopup(rowEvents, e.currentTarget); + }; + + const handleEventTagEnter = (rowEvents, e) => { + clearTimeout(evHoverTimerRef.current); + clearTimeout(evCloseTimerRef.current); + const spanEl = e.currentTarget; + evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl), 700); + }; + + const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current); + + const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current); + const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); }; + + useEffect(() => { + if (!eventTagPopup) return; + const onClickOutside = (e) => { + if (eventTagPopupRef.current && !eventTagPopupRef.current.contains(e.target)) closeEventTagPopup(); + }; + const onScrollOrResize = () => closeEventTagPopup(); + document.addEventListener('mousedown', onClickOutside); + window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true }); + window.addEventListener('resize', onScrollOrResize, { passive: true }); + return () => { + document.removeEventListener('mousedown', onClickOutside); + window.removeEventListener('scroll', onScrollOrResize, { capture: true }); + window.removeEventListener('resize', onScrollOrResize); + }; + }, [eventTagPopup]); + + return { + // column-header popup + colPopup, colPopupRef, + handleThClick, handleThEnter, handleThLeave, + handlePopupEnter, handlePopupLeave, + closePopup, + // event-tag popup + eventTagPopup, eventTagPopupRef, + evSlideIndex, evTransition, evSlideTo, + handleEventTagClick, handleEventTagEnter, handleEventTagLeave, + handleEventTagPopupEnter, handleEventTagPopupLeave, + closeEventTagPopup, + }; +} diff --git a/assets/js/hooks/useForecast.js b/assets/js/hooks/useForecast.js new file mode 100644 index 0000000..77a333c --- /dev/null +++ b/assets/js/hooks/useForecast.js @@ -0,0 +1,124 @@ +// ════════════════════════════════════════════════════════════════════════ +// useForecast — fetches the weather forecast and air quality for the +// given location and keeps them fresh. +// +// Responsibilities: +// 1. Fetch /v1/forecast on location change. +// 2. Fetch /v1/air-quality on location change, with a 6-hour +// localStorage cache (AQI / pollen update slowly). +// 3. Auto-refresh both every 5 minutes so the displayed data stays +// current as time passes. Air quality only refetches if cache is +// stale. +// +// Inputs: +// location — { lat, lon, name, country } +// +// Outputs: +// forecast — raw /v1/forecast response, or null +// airQuality — raw /v1/air-quality response, or null +// loading — true while the forecast fetch is in flight +// error — fetch error message, or null +// now — Date that ticks every 5 minutes (drives the "current row") +// ════════════════════════════════════════════════════════════════════════ + +import { useState, useEffect } from '../../vendor/preact-hooks.js'; + +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; +const FIVE_MIN = 5 * 60 * 1000; + +function buildForecastUrl(loc) { + return `https://api.open-meteo.com/v1/forecast` + + `?latitude=${loc.lat}&longitude=${loc.lon}` + + `&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` + + `wind_speed_10m,wind_direction_10m,wind_gusts_10m,` + + `direct_radiation,diffuse_radiation,shortwave_radiation,` + + `cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` + + `uv_index,precipitation,snowfall,visibility,` + + `soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + + `&wind_speed_unit=ms&timezone=auto&forecast_days=14`; +} + +function buildAirQualityUrl(loc) { + return `https://air-quality-api.open-meteo.com/v1/air-quality` + + `?latitude=${loc.lat}&longitude=${loc.lon}` + + `&hourly=european_aqi,` + + `grass_pollen,birch_pollen,alder_pollen,` + + `mugwort_pollen,olive_pollen,ragweed_pollen` + + `&timezone=auto&forecast_days=5`; +} + +export function useForecast(location) { + const [forecast, setForecast] = useState(null); + const [airQuality, setAirQuality] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [now, setNow] = useState(new Date()); + + // Air quality loader — also used by the 5-minute refresh below. + async function loadAirQuality(loc) { + const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const { ts, data } = JSON.parse(cached); + if (Date.now() - ts < SIX_HOURS_MS) { + setAirQuality(data); + return; + } + } + } catch (e) { /* ignore bad cache */ } + try { + const r = await fetch(buildAirQualityUrl(loc)); + if (!r.ok) return; // silently fail — these columns just show '—' + const data = await r.json(); + setAirQuality(data); + try { + localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data })); + } catch (e) { /* ignore storage errors */ } + } catch (_) { /* silently ignore */ } + } + + // ─── INITIAL FETCH on location change ───────────────────────────── + useEffect(() => { + async function load() { + setLoading(true); setError(null); + try { + const r = await fetch(buildForecastUrl(location)); + if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); + setForecast(await r.json()); + } catch (e) { setError(e.message); } + finally { setLoading(false); } + } + load(); + loadAirQuality(location); + }, [location]); + + // ─── AUTO-REFRESH every 5 minutes ───────────────────────────────── + // Updates `now` (drives the "current hour" highlight) and refetches + // the forecast so fresh API data comes in automatically. Air quality + // only refetches if its 6-hour cache has expired. + useEffect(() => { + const id = setInterval(() => { + setNow(new Date()); + async function refresh() { + try { + const r = await fetch(buildForecastUrl(location)); + if (r.ok) setForecast(await r.json()); + } catch (_) { /* silently ignore refresh errors */ } + const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`; + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const { ts } = JSON.parse(cached); + if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh + } + } catch (e) { /* ignore */ } + loadAirQuality(location); + } + refresh(); + }, FIVE_MIN); + return () => clearInterval(id); + }, [location]); + + return { forecast, airQuality, loading, error, now }; +} diff --git a/assets/js/hooks/useTableScroll.js b/assets/js/hooks/useTableScroll.js new file mode 100644 index 0000000..b99cf59 --- /dev/null +++ b/assets/js/hooks/useTableScroll.js @@ -0,0 +1,222 @@ +// ════════════════════════════════════════════════════════════════════════ +// useTableScroll — owns the horizontal scroll behaviour of the hourly +// table (sticky header + body scroller layout). +// +// Responsibilities: +// 1. SCROLL INDICATORS — track whether the body can scroll left/right so +// the table edges can show fade + chevron indicators. +// 2. DRAG-TO-SCROLL — pointer-event drag on the body scroller for +// desktop users. +// 3. SCROLL SYNC — keep the sticky header track shifted horizontally to +// match the body's scrollLeft, and keep header cell widths in lock- +// step with body cell widths even as columns toggle / window resizes. +// +// Inputs (passed by app.js): +// refs: { headTableRef, bodyTableRef, bodyScrollRef, headTrackRef } +// deps: { forecast, visibleCols, selectedDay, skinType, vehicleType } +// — anything that should cause a re-sync when it changes. +// +// Outputs: +// tableCanScrollLeft, tableCanScrollRight → drive the fade/chevron CSS +// handleBodyScroll → attach to body onScroll +// ════════════════════════════════════════════════════════════════════════ + +import { useState, useEffect, useLayoutEffect } from '../../vendor/preact-hooks.js'; + +export function useTableScroll({ + headTableRef, + bodyTableRef, + bodyScrollRef, + headTrackRef, + forecast, + visibleCols, + selectedDay, + skinType, + vehicleType, +}) { + // ─── SCROLL INDICATORS ───────────────────────────────────────────── + const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false); + const [tableCanScrollRight, setTableCanScrollRight] = useState(false); + + const updateTableScrollIndicators = () => { + const el = bodyScrollRef.current; + if (!el) return; + setTableCanScrollLeft(el.scrollLeft > 1); + setTableCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + + // ─── DRAG-TO-SCROLL ──────────────────────────────────────────────── + useEffect(() => { + const el = bodyScrollRef.current; + if (!el) return; + let isDown = false; + let startX = 0; + let startScroll = 0; + + const onMouseDown = (e) => { + // Only act on clicks that land inside the body scroller + if (!el.contains(e.target)) return; + if (e.button !== 0) return; + if (e.target.closest('button, a, input, select')) return; + isDown = true; + startX = e.clientX; + startScroll = el.scrollLeft; + el.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + }; + const onMouseMove = (e) => { + if (!isDown) return; + const dx = e.clientX - startX; + el.scrollLeft = startScroll - dx; + }; + const onMouseUp = () => { + if (!isDown) return; + isDown = false; + el.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.webkitUserSelect = ''; + }; + + // Attach everything to document so Preact's synthetic event system + // cannot intercept or swallow the events before we see them. + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + // Also update indicators on scroll + el.addEventListener('scroll', updateTableScrollIndicators); + + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + el.removeEventListener('scroll', updateTableScrollIndicators); + }; + }, [forecast]); + + // Update indicators after layout sync (columns may have changed width) + useEffect(() => { + updateTableScrollIndicators(); + }, [forecast, visibleCols, selectedDay]); + + // ─── BODY SCROLL HANDLER (called from JSX onScroll) ─────────────── + const handleBodyScroll = () => { + const track = headTrackRef.current; + const body = bodyScrollRef.current; + if (!track || !body) return; + track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; + updateTableScrollIndicators(); + }; + + // ─── COLUMN-WIDTH SCROLL SYNC (layout effect) ───────────────────── + // Synchronise the head and body table column widths with a + // "shrink-to-fit then distribute" strategy. See the comments inside + // sync() for the algorithm. + useLayoutEffect(() => { + const sync = () => { + const headTable = headTableRef.current; + const bodyTable = bodyTableRef.current; + const bodyScroll = bodyScrollRef.current; + if (!headTable || !bodyTable || !bodyScroll) return; + const bodyRow = bodyTable.querySelector('tbody tr'); + const headRow = headTable.querySelector('thead tr'); + if (!bodyRow || !headRow) return; + const headCells = Array.from(headRow.children); + const bodyCells = Array.from(bodyRow.children); + const n = Math.min(headCells.length, bodyCells.length); + if (n === 0) return; + + // Step 1: clear any previously-forced cell widths and switch the + // tables to natural sizing so the measurement reflects the true + // content-fit width — independent of how wide the container is. + headCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); + bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; }); + headTable.style.width = 'max-content'; + bodyTable.style.width = 'max-content'; + headTable.style.tableLayout = 'auto'; + bodyTable.style.tableLayout = 'auto'; + + // Step 2: read each cell's natural width. getBoundingClientRect + // forces synchronous layout — that's what we want. + const naturalW = new Array(n); + let naturalTotal = 0; + for (let i = 0; i < n; i++) { + const headW = headCells[i].getBoundingClientRect().width; + const bodyW = bodyCells[i].getBoundingClientRect().width; + const w = Math.max(Math.ceil(headW), Math.ceil(bodyW)); + naturalW[i] = w; + naturalTotal += w; + } + + // Step 3: decide final widths based on available container width. + const containerW = bodyScroll.clientWidth; + const finalW = new Array(n); + let totalWidth; + if (naturalTotal > 0 && naturalTotal < containerW) { + // Spare space — distribute proportionally across columns so they + // fan out to fill the scroller (no awkward right-hand gap). + const scale = containerW / naturalTotal; + let running = 0; + for (let i = 0; i < n - 1; i++) { + finalW[i] = Math.floor(naturalW[i] * scale); + running += finalW[i]; + } + // Absorb sub-pixel rounding into the last column so the total + // exactly matches the container width. + finalW[n - 1] = containerW - running; + totalWidth = containerW; + } else { + // Naturals don't fit — use them as-is and let the body scroll. + for (let i = 0; i < n; i++) finalW[i] = naturalW[i]; + totalWidth = naturalTotal; + } + + // Step 4: restore the CSS-defined table-layout: fixed so the + // explicit cell widths we apply below are honoured by the browser + // (not redistributed by the auto-layout algorithm). + headTable.style.tableLayout = ''; + bodyTable.style.tableLayout = ''; + + // Step 5: apply the final width to both head and body cells. + for (let i = 0; i < n; i++) { + const px = `${finalW[i]}px`; + headCells[i].style.width = px; + headCells[i].style.minWidth = px; + headCells[i].style.maxWidth = px; + bodyCells[i].style.width = px; + bodyCells[i].style.minWidth = px; + bodyCells[i].style.maxWidth = px; + } + // Make both tables exactly totalWidth wide so they share the same + // horizontal extent — column N in the header sits directly above + // column N in the body, no drift as you scroll right. + headTable.style.width = `${totalWidth}px`; + bodyTable.style.width = `${totalWidth}px`; + // Re-apply current horizontal offset so column alignment survives. + handleBodyScroll(); + }; + // Run once after layout + sync(); + // Re-sync when the scroll container's width changes (window resize, + // sidebar opens, etc). We observe the scroller — not the body table — + // because the body table's width is now driven by sync itself, which + // would otherwise create a feedback loop. + let ro = null; + if (typeof ResizeObserver !== 'undefined' && bodyScrollRef.current) { + ro = new ResizeObserver(sync); + ro.observe(bodyScrollRef.current); + } + window.addEventListener('resize', sync); + return () => { + if (ro) ro.disconnect(); + window.removeEventListener('resize', sync); + }; + }, [forecast, visibleCols, selectedDay, skinType, vehicleType]); + + return { + tableCanScrollLeft, + tableCanScrollRight, + handleBodyScroll, + }; +}