// ════════════════════════════════════════════════════════════════════════ // 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', }, ]; // ─── 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) { // Perfect sunset: clear low cloud near sunset hour, good visibility const sunsetRows = rows.filter(r => r.elev > -3 && r.elev < 8); if (sunsetRows.length === 0) return null; const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length; // We want some high cloud (colour) but not low cloud (obstruction) 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; // pure clear = less dramatic 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 are ideal for a vivid sunset near ${location.name}.`, color: '#3a1a00', textColor: '#ffe0b0', type: 'weather', nightOnly: false, }; } 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', }, ]; // 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 ────────────────────────────────────────────────────── // For timetable cell icons: given an active event and a row, // should this hour show the event icon? export function shouldShowCellTag(event, row) { if (!event) return false; if (event.type === 'promo') return false; // Night-only events only badge night rows if (event.nightOnly && row.elev >= -5) return false; // Weather events: storm/frost badge all hours; heat badges daytime only if (event.id === 'heat-spike' && row.elev < 0) return false; return true; } // ─── MAIN EXPORT ───────────────────────────────────────────────────────── // Call this once per render with today's rows and location. // Returns the single highest-priority active event, or null. // // Priority order: // 1. PROMO_OVERRIDE (manual) // 2. Cosmic calendar events (sorted by priority: eclipse > meteor > planetary) // 3. Weather events (heat > storm > frost > sunset > stargazing) export function getActiveEvent(todayRows, location) { // 1. Manual promo override if (PROMO_OVERRIDE) return PROMO_OVERRIDE; // 2. Cosmic calendar const today = new Date(); const todayStr = today.toISOString().slice(0, 10); const cosmicHit = COSMIC_CALENDAR.find(ev => todayStr >= ev.start && todayStr <= ev.end); if (cosmicHit) return cosmicHit; // 3. Weather-derived (needs forecast data) if (!todayRows || todayRows.length === 0) return null; return ( checkHeatSpike(todayRows) || checkStorm(todayRows) || checkFrost(todayRows) || checkSunset(todayRows, location) || checkStargazing(todayRows) ); } // ─── 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 === '☄️') { return [ '', ``, ``, ``, ``, ``, '', ].join('\n'); } if (id.includes('solar-eclipse')) { return [ '', ``, '', '', '', '', ``, ``, '', ].join('\n'); } if (id.includes('lunar-eclipse')) { return [ '', ``, '', '', '', ``, ``, ``, '', ].join('\n'); } if (event.emoji === '🪐') { return [ '', ``, ``, '', ].join('\n'); } if (event.emoji === '🔴') { return [ '', ``, ``, '', ].join('\n'); } if (id === 'stargazing') { const stars = [ [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 = stars.map(([x,y,r]) => ``).join(''); return `${dots}`; } if (id === 'perfect-sunset') { return [ '', ``, '', '', '', '', ``, '', ].join('\n'); } if (id === 'heat-spike') { return [ '', ``, ``, ``, '', ].join('\n'); } if (id === 'storm') { const lines = [-55,-30,-5,20,45,65].map(x => `` ).join(''); return `${lines}`; } if (id === 'frost') { const crystals = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(([dx,dy]) => `` + '' + '' + '' + '' + '' ).join(''); return `${crystals}`; } return null; }