898 lines
38 KiB
JavaScript
898 lines
38 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════
|
||
// events.js — Cosmic & weather event engine for SunScope.
|
||
//
|
||
// 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.
|
||
// Set it back to null when done.
|
||
//
|
||
// Each event object:
|
||
// { id, emoji, title, message, color, textColor, type }
|
||
//
|
||
// type: 'cosmic' | 'weather' | 'promo'
|
||
// ════════════════════════════════════════════════════════════════════════
|
||
|
||
// ─── PROMO OVERRIDE ──────────────────────────────────────────────────────
|
||
// Set this to show a custom banner regardless of weather or cosmic events.
|
||
// Leave as null for automatic event detection.
|
||
//
|
||
// Example:
|
||
// export const PROMO_OVERRIDE = {
|
||
// id: 'promo-summer',
|
||
// emoji: '☀️',
|
||
// title: 'Summer Sale',
|
||
// message: 'SunScope Extra — 20% off this weekend only.',
|
||
// color: '#c8922a',
|
||
// textColor: '#fff',
|
||
// type: 'promo',
|
||
// };
|
||
//
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x2604, 0xFE0F),
|
||
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: String.fromCodePoint(0x1F311),
|
||
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: String.fromCodePoint(0x1F311),
|
||
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: String.fromCodePoint(0x1F534),
|
||
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: String.fromCodePoint(0x1FA90),
|
||
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 ───────────────────────────────────────────────
|
||
// These are computed from the actual forecast data.
|
||
// Each checker receives (rows, location) and returns an event object or null.
|
||
// rows = today's hourlyRows array. location = { lat, lon, name, country }.
|
||
|
||
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) {
|
||
if (!events || events.length === 0) return [];
|
||
return events.filter(ev => {
|
||
if (ev.type === 'promo') return false;
|
||
if (ev.nightOnly && row.elev >= -5) return false;
|
||
if (ev.id === 'heat-spike' && row.elev < 0) return false;
|
||
// Sunset/sunrise events: only show icon during the actual twilight window
|
||
if (ev.isoRange) {
|
||
if (row.iso < ev.isoRange[0] || row.iso > ev.isoRange[1]) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// ─── MAIN EXPORT ─────────────────────────────────────────────────────────
|
||
// Returns ALL active events for the given rows/date as an array.
|
||
// Empty array = nothing active.
|
||
//
|
||
// Order within array:
|
||
// 1. PROMO_OVERRIDE (if set, returned alone)
|
||
// 2. All matching cosmic calendar events for this date
|
||
// 3. All matching weather-derived events
|
||
|
||
export function getActiveEvents(rows, location) {
|
||
// 1. Manual promo override — shown alone, no mixing with other events
|
||
if (PROMO_OVERRIDE) return [PROMO_OVERRIDE];
|
||
|
||
// 2. Cosmic calendar — use the date of the rows being viewed, not today.
|
||
const dateStr = (rows && rows.length > 0)
|
||
? rows[0].iso.slice(0, 10)
|
||
: new Date().toISOString().slice(0, 10);
|
||
const cosmicHits = COSMIC_CALENDAR.filter(ev => dateStr >= ev.start && dateStr <= ev.end);
|
||
|
||
// 3. Weather-derived events (all that match, not just first)
|
||
const weatherEvents = [];
|
||
if (rows && rows.length > 0) {
|
||
const checks = [
|
||
checkHeatSpike(rows),
|
||
checkStorm(rows),
|
||
checkFrost(rows),
|
||
checkSunset(rows, location),
|
||
checkStargazing(rows),
|
||
];
|
||
checks.forEach(ev => { if (ev) weatherEvents.push(ev); });
|
||
}
|
||
|
||
return [...cosmicHits, ...weatherEvents];
|
||
}
|
||
|
||
// ─── LENS EVENT PICKER ───────────────────────────────────────────────────
|
||
// From an array of active events, returns the single one closest to its peak
|
||
// (most "intense"). Weather events without a peak use today's date.
|
||
// Returns null if events array is empty.
|
||
|
||
export function getLensEvent(events) {
|
||
if (!events || events.length === 0) return null;
|
||
if (events.length === 1) return events[0];
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
return events.reduce((best, ev) => {
|
||
const peakB = best.peak || today;
|
||
const peakE = ev.peak || today;
|
||
const distB = Math.abs(new Date(today) - new Date(peakB));
|
||
const distE = Math.abs(new Date(today) - new Date(peakE));
|
||
return distE < distB ? ev : best;
|
||
});
|
||
}
|
||
|
||
// ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
|
||
// Returns SVG group content for the event overlay in the big ScopeReticle.
|
||
// Rendered INSIDE the lens clip and BELOW the glass shine/shade layers.
|
||
// cx, cy = lens centre; lensR = lens radius.
|
||
|
||
|
||
export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||
if (!event) return null;
|
||
const id = event.id;
|
||
|
||
if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) {
|
||
return [
|
||
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
|
||
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
|
||
'<line x1="' + (cx-40) + '" y1="' + (cy-70) + '" x2="' + (cx+5) + '" y2="' + (cy-30) + '" stroke="#fff0c0" stroke-width="1" stroke-linecap="round" opacity="0.7"/>',
|
||
'<line x1="' + (cx+30) + '" y1="' + (cy-60) + '" x2="' + (cx+65) + '" y2="' + (cy-25) + '" stroke="#ffe8a0" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>',
|
||
'<line x1="' + (cx-10) + '" y1="' + (cy-40) + '" x2="' + (cx+30) + '" y2="' + (cy-5) + '" stroke="#fff0c0" stroke-width="0.9" stroke-linecap="round" opacity="0.65"/>',
|
||
'<line x1="' + (cx-70) + '" y1="' + (cy-20) + '" x2="' + (cx-35) + '" y2="' + (cy+10) + '" stroke="#ffe8a0" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>',
|
||
'</g>',
|
||
].join('');
|
||
}
|
||
|
||
if (id.includes('solar-eclipse')) {
|
||
const cx_ = cx, cy_ = cy - 20;
|
||
return '<g clip-path="url(#scope-lens-clip)">'
|
||
+ '<radialGradient id="corona-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="38" gradientUnits="userSpaceOnUse">'
|
||
+ '<stop offset="0%" stop-color="rgba(255,220,100,0)" />'
|
||
+ '<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />'
|
||
+ '<stop offset="100%" stop-color="rgba(255,180,40,0)" /></radialGradient>'
|
||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="38" fill="url(#corona-glow)" opacity="0.8" />'
|
||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="18" fill="rgba(5,2,15,0.88)" /></g>';
|
||
}
|
||
|
||
if (id.includes('lunar-eclipse')) {
|
||
const cx_ = cx, cy_ = cy - 30;
|
||
return '<g clip-path="url(#scope-lens-clip)">'
|
||
+ '<radialGradient id="blood-moon-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="30" gradientUnits="userSpaceOnUse">'
|
||
+ '<stop offset="0%" stop-color="rgba(180,30,10,0.7)" />'
|
||
+ '<stop offset="100%" stop-color="rgba(120,10,5,0)" /></radialGradient>'
|
||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="30" fill="url(#blood-moon-glow)" />'
|
||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="rgba(160,25,10,0.75)" />'
|
||
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
|
||
}
|
||
|
||
if (event.emoji === String.fromCodePoint(0x1FA90)) {
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
|
||
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
|
||
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
|
||
}
|
||
|
||
if (event.emoji === String.fromCodePoint(0x1F534)) {
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
|
||
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
|
||
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
|
||
}
|
||
|
||
if (id === 'stargazing') {
|
||
const pts = [[cx-50,cy-55,1.8],[cx+40,cy-65,1.4],[cx-20,cy-70,1.0],[cx+65,cy-40,1.6],[cx-60,cy-30,1.2],[cx+50,cy-55,1.0],[cx-35,cy-45,1.4],[cx+20,cy-50,1.8],[cx-75,cy-50,1.0]];
|
||
const dots = pts.map(function(s){return '<circle cx="'+s[0]+'" cy="'+s[1]+'" r="'+s[2]+'" fill="rgba(255,255,255,0.9)" />';}).join('');
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.7">' + dots + '</g>';
|
||
}
|
||
|
||
if (id === 'perfect-sunset') {
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">'
|
||
+ '<radialGradient id="sunset-lens-glow" cx="' + cx + '" cy="' + (cy+20) + '" r="' + lensR + '" gradientUnits="userSpaceOnUse">'
|
||
+ '<stop offset="0%" stop-color="rgba(255,120,20,0.7)" />'
|
||
+ '<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />'
|
||
+ '<stop offset="100%" stop-color="rgba(200,20,0,0)" /></radialGradient>'
|
||
+ '<circle cx="' + cx + '" cy="' + cy + '" r="' + lensR + '" fill="url(#sunset-lens-glow)" /></g>';
|
||
}
|
||
|
||
if (id === 'heat-spike') {
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.35">'
|
||
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+30) + ' Q ' + (cx-20) + ' ' + (cy+15) + ' ' + (cx+20) + ' ' + (cy+30) + ' Q ' + (cx+60) + ' ' + (cy+45) + ' ' + (cx+lensR-10) + ' ' + (cy+30) + '" fill="none" stroke="rgba(255,120,20,0.6)" stroke-width="2" />'
|
||
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+45) + ' Q ' + (cx-10) + ' ' + (cy+30) + ' ' + (cx+30) + ' ' + (cy+45) + ' Q ' + (cx+65) + ' ' + (cy+60) + ' ' + (cx+lensR-10) + ' ' + (cy+45) + '" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" /></g>';
|
||
}
|
||
|
||
if (id === 'storm') {
|
||
const sl = [-55,-30,-5,20,45,65].map(function(x){
|
||
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
|
||
}).join('');
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
|
||
}
|
||
|
||
if (id === 'frost') {
|
||
const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){
|
||
var dx=p[0], dy=p[1];
|
||
return '<g transform="translate(' + (cx+dx) + ',' + (cy+dy) + ')"><line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /><line x1="4" y1="-4" x2="-4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /></g>';
|
||
}).join('');
|
||
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + fc + '</g>';
|
||
}
|
||
|
||
return null;
|
||
}
|