// ════════════════════════════════════════════════════════════════════════ // 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: '☄️', 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) { 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; }); } // ─── 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. // // 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) .map(ev => Object.assign({}, ev, { message: dynamicCosmicMessage(ev, dateStr) })); // 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 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; }