diff --git a/assets/css/ui.css b/assets/css/ui.css index d080515..cf494a4 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -62,6 +62,30 @@ opacity: 1; } +/* Slideshow dots */ +.event-banner-dots { + display: flex; + gap: 5px; + margin-top: 7px; + align-items: center; +} +.event-banner-dot { + width: 6px; + height: 6px; + border-radius: 50%; + opacity: 0.35; + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; + flex-shrink: 0; +} +.event-banner-dot.active { + opacity: 1; + transform: scale(1.3); +} +.event-banner-dot:hover { + opacity: 0.75; +} + /* Cell tag — tiny event icon in the hour column */ .event-cell-tag { font-size: 11px; diff --git a/assets/js/app.js b/assets/js/app.js index 8b996e3..f3e89c9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -31,7 +31,7 @@ import { cloudCategory, confidenceBand, moonGlyph, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js'; -import { getActiveEvent, shouldShowCellTag, getUpcomingEvents } from './events.js'; +import { getActiveEvents, getLensEvent, getCellTagEvents, getUpcomingEvents } from './events.js'; const html = htm.bind(h); @@ -719,12 +719,30 @@ export function UTCIForecast() { ? utciCategory(currentRow.utciAdj) : { bg: '#4a4228', fg: '#ede4cc', label: 'No data' }; - // ─── COSMIC/WEATHER EVENT ───────────────────────────────────────────── - // Compute once per render. todayRows = the rows for today (day 0). - const [dismissedEventId, setDismissedEventId] = useState(null); + // ─── COSMIC/WEATHER EVENTS ──────────────────────────────────────────── + // activeEvents — today's events → drives banner & lens overlay. + // selectedDayEvents — viewed day's events → drives cell tags. + const [dismissedEventIds, setDismissedEventIds] = useState([]); + const [bannerIndex, setBannerIndex] = useState(0); const todayRows = days[0]?.rows || []; - const activeEvent = getActiveEvent(todayRows, location); - const showBanner = activeEvent && activeEvent.id !== dismissedEventId; + const activeEvents = getActiveEvents(todayRows, location) + .filter(ev => !dismissedEventIds.includes(ev.id)); + const lensEvent = getLensEvent(activeEvents); + const selectedDayEvents = getActiveEvents(visible, location); + + // Auto-advance banner slideshow every 10 seconds when multiple events + useEffect(() => { + if (activeEvents.length <= 1) { setBannerIndex(0); return; } + const id = setInterval(() => { + setBannerIndex(i => (i + 1) % activeEvents.length); + }, 10000); + return () => clearInterval(id); + }, [activeEvents.length, activeEvents.map(e => e.id).join(',')]); + + // Keep index in bounds if events change + useEffect(() => { + if (bannerIndex >= activeEvents.length) setBannerIndex(0); + }, [activeEvents.length]); // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM @@ -766,27 +784,61 @@ export function UTCIForecast() {
- -
- ${showBanner && html` -
- ${activeEvent.emoji} -
-
${activeEvent.title}
-
${activeEvent.message}
-
- -
- `} + +
0 ? ' visible' : ''}`}> + ${activeEvents.length > 0 && (() => { + const ev = activeEvents[bannerIndex] || activeEvents[0]; + const fmtDate = (iso) => iso + ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) + : null; + const startFmt = fmtDate(ev.start); + const peakFmt = fmtDate(ev.peak); + const endFmt = fmtDate(ev.end); + // Only show date line if the event has start/end (cosmic events do; weather events don't) + const showDates = startFmt && endFmt; + const dateLine = showDates + ? (startFmt === endFmt + ? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}` + : peakFmt && peakFmt !== startFmt && peakFmt !== endFmt + ? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}` + : `Active ${startFmt} – ${endFmt}`) + : null; + return html` +
{}} + > + ${ev.emoji} +
+
${ev.title}
+
${ev.message}
+ ${dateLine && html` +
+ ${dateLine} +
+ `} + ${activeEvents.length > 1 && html` +
+ ${activeEvents.map((_, i) => html` + setBannerIndex(i)} + style=${{ background: ev.textColor }} + /> + `)} +
+ `} +
+ +
`; + })()}
@@ -811,7 +863,7 @@ export function UTCIForecast() { elev=${currentRow?.elev ?? 0} dt=${currentRow?.dt ?? new Date()} glob=${currentRow?.glob ?? 0} - activeEvent=${activeEvent} + activeEvent=${lensEvent} />
@@ -1457,9 +1509,9 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} /> ${localHHMM} - ${shouldShowCellTag(activeEvent, r) && html` - ${activeEvent.emoji} - `} + ${getCellTagEvents(selectedDayEvents, r).map(ev => html` + ${ev.emoji} + `)} ${visibleCols.air && html`${r.Ta.toFixed(1)}`} diff --git a/assets/js/events.js b/assets/js/events.js index 381c1b1..1b8daf6 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -190,6 +190,121 @@ const COSMIC_CALENDAR = [ 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 ─────────────────────────────────────────────── @@ -421,6 +536,128 @@ const ALMANAC_CALENDAR = [ 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. @@ -465,47 +702,68 @@ export function getUpcomingEvents(location, days = 90) { } // ─── 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; +// 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; + return true; + }); } // ─── MAIN EXPORT ───────────────────────────────────────────────────────── -// Call this once per render with today's rows and location. -// Returns the single highest-priority active event, or null. +// Returns ALL active events for the given rows/date as an array. +// Empty array = nothing active. // -// Priority order: -// 1. PROMO_OVERRIDE (manual) -// 2. Cosmic calendar events (sorted by priority: eclipse > meteor > planetary) -// 3. Weather events (heat > storm > frost > sunset > stargazing) +// 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 getActiveEvent(todayRows, location) { - // 1. Manual promo override - if (PROMO_OVERRIDE) return PROMO_OVERRIDE; +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 - 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; + // 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 (needs forecast data) - if (!todayRows || todayRows.length === 0) return null; + // 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 ( - checkHeatSpike(todayRows) || - checkStorm(todayRows) || - checkFrost(todayRows) || - checkSunset(todayRows, location) || - checkStargazing(todayRows) - ); + 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 ─────────────────────────────────────────────── @@ -518,114 +776,86 @@ export function getLensOverlaySVG(event, cx, cy, lensR) { if (!event) return null; const id = event.id; - if (event.emoji === '☄️') { + if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) { return [ '', - ``, - ``, - ``, - ``, - ``, + '', + '', + '', + '', + '', '', - ].join('\n'); + ].join(''); } if (id.includes('solar-eclipse')) { - return [ - '', - ``, - '', - '', - '', - '', - ``, - ``, - '', - ].join('\n'); + const cx_ = cx, cy_ = cy - 20; + return '' + + '' + + '' + + '' + + '' + + '' + + ''; } if (id.includes('lunar-eclipse')) { - return [ - '', - ``, - '', - '', - '', - ``, - ``, - ``, - '', - ].join('\n'); + const cx_ = cx, cy_ = cy - 30; + return '' + + '' + + '' + + '' + + '' + + '' + + ''; } - if (event.emoji === '🪐') { - return [ - '', - ``, - ``, - '', - ].join('\n'); + if (event.emoji === String.fromCodePoint(0x1FA90)) { + return '' + + '' + + ''; } - if (event.emoji === '🔴') { - return [ - '', - ``, - ``, - '', - ].join('\n'); + if (event.emoji === String.fromCodePoint(0x1F534)) { + return '' + + '' + + ''; } 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}`; + 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 [ - '', - ``, - '', - '', - '', - '', - ``, - '', - ].join('\n'); + return '' + + '' + + '' + + '' + + '' + + ''; } if (id === 'heat-spike') { - return [ - '', - ``, - ``, - ``, - '', - ].join('\n'); + return '' + + '' + + ''; } if (id === 'storm') { - const lines = [-55,-30,-5,20,45,65].map(x => - `` - ).join(''); - return `${lines}`; + const sl = [-55,-30,-5,20,45,65].map(function(x){ + return ''; + }).join(''); + return '' + sl + ''; } if (id === 'frost') { - const crystals = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(([dx,dy]) => - `` + - '' + - '' + - '' + - '' + - '' - ).join(''); - return `${crystals}`; + 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;