Code refactoring
Profile fixes
Table fixes
Animation additions
This commit is contained in:
fraxle
2026-05-18 15:47:23 +01:00
parent 0ff82961e2
commit 55ddcd617a
15 changed files with 2062 additions and 1605 deletions
+3
View File
@@ -1,6 +1,9 @@
# Cowork session backups
backup/
# Cowork agent instructions
AGENTS.md
# OS / editor cruft
.DS_Store
Thumbs.db
+65
View File
@@ -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 ─────────────────────────────── */
+45 -11
View File
@@ -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 */
+234 -771
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -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 };
}
+162
View File
@@ -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 2030 °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 710 °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 1525 °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 (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = 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 = 1050; High = 50200; Very High = 200+. Values vary by species and season.' },
};
+26 -823
View File
@@ -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 1625, 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 1625.',
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 630.',
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 420.',
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 1726.',
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 <date>. <detail>"
// On peak ±1d : "peaks tonight — <detail>"
// After peak : "is past its peak (<date>) but still possibly visible — <detail>"
// 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 [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
'<line x1="' + (cx-40) + '" y1="' + (cy-70) + '" x2="' + (cx+5) + '" y2="' + (cy-30) + '" stroke="#fff0c0" stroke-width="1" stroke-linecap="round" opacity="0.7"/>',
'<line x1="' + (cx+30) + '" y1="' + (cy-60) + '" x2="' + (cx+65) + '" y2="' + (cy-25) + '" stroke="#ffe8a0" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>',
'<line x1="' + (cx-10) + '" y1="' + (cy-40) + '" x2="' + (cx+30) + '" y2="' + (cy-5) + '" stroke="#fff0c0" stroke-width="0.9" stroke-linecap="round" opacity="0.65"/>',
'<line x1="' + (cx-70) + '" y1="' + (cy-20) + '" x2="' + (cx-35) + '" y2="' + (cy+10) + '" stroke="#ffe8a0" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>',
'</g>',
].join('');
}
if (id.includes('solar-eclipse')) {
const cx_ = cx, cy_ = cy - 20;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="corona-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="38" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,220,100,0)" />'
+ '<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />'
+ '<stop offset="100%" stop-color="rgba(255,180,40,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="38" fill="url(#corona-glow)" opacity="0.8" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="18" fill="rgba(5,2,15,0.88)" /></g>';
}
if (id.includes('lunar-eclipse')) {
const cx_ = cx, cy_ = cy - 30;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="blood-moon-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="30" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(180,30,10,0.7)" />'
+ '<stop offset="100%" stop-color="rgba(120,10,5,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="30" fill="url(#blood-moon-glow)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="rgba(160,25,10,0.75)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
}
if (event.emoji === '🪐' && event.id.includes('conjunction')) {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
}
if (event.emoji === '🔴') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
}
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 '<circle cx="'+s[0]+'" cy="'+s[1]+'" r="'+s[2]+'" fill="rgba(255,255,255,0.9)" />';}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.7">' + dots + '</g>';
}
if (id === 'perfect-sunset') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">'
+ '<radialGradient id="sunset-lens-glow" cx="' + cx + '" cy="' + (cy+20) + '" r="' + lensR + '" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,120,20,0.7)" />'
+ '<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />'
+ '<stop offset="100%" stop-color="rgba(200,20,0,0)" /></radialGradient>'
+ '<circle cx="' + cx + '" cy="' + cy + '" r="' + lensR + '" fill="url(#sunset-lens-glow)" /></g>';
}
if (id === 'heat-spike') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.35">'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+30) + ' Q ' + (cx-20) + ' ' + (cy+15) + ' ' + (cx+20) + ' ' + (cy+30) + ' Q ' + (cx+60) + ' ' + (cy+45) + ' ' + (cx+lensR-10) + ' ' + (cy+30) + '" fill="none" stroke="rgba(255,120,20,0.6)" stroke-width="2" />'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+45) + ' Q ' + (cx-10) + ' ' + (cy+30) + ' ' + (cx+30) + ' ' + (cy+45) + ' Q ' + (cx+65) + ' ' + (cy+60) + ' ' + (cx+lensR-10) + ' ' + (cy+45) + '" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" /></g>';
}
if (id === 'storm') {
const sl = [-55,-30,-5,20,45,65].map(function(x){
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
}
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 '<g transform="translate(' + (cx+dx) + ',' + (cy+dy) + ')"><line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /><line x1="4" y1="-4" x2="-4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /></g>';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + fc + '</g>';
}
return null;
}
+298
View File
@@ -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 1625, 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 1625.',
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 630.',
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 420.',
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 1726.',
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),
}));
}
+276
View File
@@ -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',
},
];
+29
View File
@@ -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 <date>. <detail>"
// On peak ±1d : "peaks tonight — <detail>"
// After peak : "is past its peak (<date>) but still possibly visible — <detail>"
//
// 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;
}
}
+100
View File
@@ -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 [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
'<line x1="' + (cx-40) + '" y1="' + (cy-70) + '" x2="' + (cx+5) + '" y2="' + (cy-30) + '" stroke="#fff0c0" stroke-width="1" stroke-linecap="round" opacity="0.7"/>',
'<line x1="' + (cx+30) + '" y1="' + (cy-60) + '" x2="' + (cx+65) + '" y2="' + (cy-25) + '" stroke="#ffe8a0" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>',
'<line x1="' + (cx-10) + '" y1="' + (cy-40) + '" x2="' + (cx+30) + '" y2="' + (cy-5) + '" stroke="#fff0c0" stroke-width="0.9" stroke-linecap="round" opacity="0.65"/>',
'<line x1="' + (cx-70) + '" y1="' + (cy-20) + '" x2="' + (cx-35) + '" y2="' + (cy+10) + '" stroke="#ffe8a0" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>',
'</g>',
].join('');
}
if (id.includes('solar-eclipse')) {
const cx_ = cx, cy_ = cy - 20;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="corona-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="38" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,220,100,0)" />'
+ '<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />'
+ '<stop offset="100%" stop-color="rgba(255,180,40,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="38" fill="url(#corona-glow)" opacity="0.8" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="18" fill="rgba(5,2,15,0.88)" /></g>';
}
if (id.includes('lunar-eclipse')) {
const cx_ = cx, cy_ = cy - 30;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="blood-moon-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="30" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(180,30,10,0.7)" />'
+ '<stop offset="100%" stop-color="rgba(120,10,5,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="30" fill="url(#blood-moon-glow)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="rgba(160,25,10,0.75)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
}
if (event.emoji === '🪐' && event.id.includes('conjunction')) {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
}
if (event.emoji === '🔴') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
}
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 '<circle cx="'+s[0]+'" cy="'+s[1]+'" r="'+s[2]+'" fill="rgba(255,255,255,0.9)" />';}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.7">' + dots + '</g>';
}
if (id === 'perfect-sunset') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">'
+ '<radialGradient id="sunset-lens-glow" cx="' + cx + '" cy="' + (cy+20) + '" r="' + lensR + '" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,120,20,0.7)" />'
+ '<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />'
+ '<stop offset="100%" stop-color="rgba(200,20,0,0)" /></radialGradient>'
+ '<circle cx="' + cx + '" cy="' + cy + '" r="' + lensR + '" fill="url(#sunset-lens-glow)" /></g>';
}
if (id === 'heat-spike') {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.35">'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+30) + ' Q ' + (cx-20) + ' ' + (cy+15) + ' ' + (cx+20) + ' ' + (cy+30) + ' Q ' + (cx+60) + ' ' + (cy+45) + ' ' + (cx+lensR-10) + ' ' + (cy+30) + '" fill="none" stroke="rgba(255,120,20,0.6)" stroke-width="2" />'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+45) + ' Q ' + (cx-10) + ' ' + (cy+30) + ' ' + (cx+30) + ' ' + (cy+45) + ' Q ' + (cx+65) + ' ' + (cy+60) + ' ' + (cx+lensR-10) + ' ' + (cy+45) + '" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" /></g>';
}
if (id === 'storm') {
const sl = [-55,-30,-5,20,45,65].map(function(x){
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
}
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 '<g transform="translate(' + (cx+dx) + ',' + (cy+dy) + ')"><line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /><line x1="4" y1="-4" x2="-4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /></g>';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + fc + '</g>';
}
return null;
}
+139
View File
@@ -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,
};
}
+221
View File
@@ -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,
};
}
+124
View File
@@ -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 };
}
+222
View File
@@ -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,
};
}