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