v1.41 - Add 2027 Dates and Fix Cosmic Events

This commit is contained in:
fraxle
2026-05-16 21:51:49 +01:00
parent 214b8987cc
commit b4d2a1427a
3 changed files with 450 additions and 144 deletions
+24
View File
@@ -62,6 +62,30 @@
opacity: 1; 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 */ /* Cell tag — tiny event icon in the hour column */
.event-cell-tag { .event-cell-tag {
font-size: 11px; font-size: 11px;
+77 -25
View File
@@ -31,7 +31,7 @@ import {
cloudCategory, confidenceBand, moonGlyph, cloudCategory, confidenceBand, moonGlyph,
} from './utils.js'; } from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.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); const html = htm.bind(h);
@@ -719,12 +719,30 @@ export function UTCIForecast() {
? utciCategory(currentRow.utciAdj) ? utciCategory(currentRow.utciAdj)
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' }; : { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
// ─── COSMIC/WEATHER EVENT ──────────────────────────────────────────── // ─── COSMIC/WEATHER EVENTS ────────────────────────────────────────────
// Compute once per render. todayRows = the rows for today (day 0). // activeEvents — today's events → drives banner & lens overlay.
const [dismissedEventId, setDismissedEventId] = useState(null); // selectedDayEvents — viewed day's events → drives cell tags.
const [dismissedEventIds, setDismissedEventIds] = useState([]);
const [bannerIndex, setBannerIndex] = useState(0);
const todayRows = days[0]?.rows || []; const todayRows = days[0]?.rows || [];
const activeEvent = getActiveEvent(todayRows, location); const activeEvents = getActiveEvents(todayRows, location)
const showBanner = activeEvent && activeEvent.id !== dismissedEventId; .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 ─────────────────────────────────────────────────── // ─── 4. JSX RETURN ───────────────────────────────────────────────────
// Everything below is the actual page markup, written as one big HTM // Everything below is the actual page markup, written as one big HTM
@@ -766,27 +784,61 @@ export function UTCIForecast() {
</nav> </nav>
<main class="utci-shell"> <main class="utci-shell">
<!-- ── EVENT BANNER ── slides down when an event is active ── --> <!-- ── EVENT BANNER ── slideshow when multiple events active ── -->
<div class=${`event-banner-wrap${showBanner ? ' visible' : ''}`}> <div class=${`event-banner-wrap${activeEvents.length > 0 ? ' visible' : ''}`}>
${showBanner && html` ${activeEvents.length > 0 && (() => {
<div class="event-banner" style=${{ const ev = activeEvents[bannerIndex] || activeEvents[0];
background: activeEvent.color, const fmtDate = (iso) => iso
color: activeEvent.textColor, ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
}}> : null;
<span class="event-banner-emoji">${activeEvent.emoji}</span> 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`
<div class="event-banner"
style=${{ background: ev.color, color: ev.textColor }}
onMouseEnter=${() => {}}
>
<span class="event-banner-emoji">${ev.emoji}</span>
<div class="event-banner-body"> <div class="event-banner-body">
<div class="event-banner-title">${activeEvent.title}</div> <div class="event-banner-title">${ev.title}</div>
<div class="event-banner-msg">${activeEvent.message}</div> <div class="event-banner-msg">${ev.message}</div>
${dateLine && html`
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'JetBrains Mono, monospace', letterSpacing: '0.08em', textTransform: 'uppercase', marginTop: '5px' }}>
${dateLine}
</div>
`}
${activeEvents.length > 1 && html`
<div class="event-banner-dots">
${activeEvents.map((_, i) => html`
<span
key=${i}
class=${`event-banner-dot${i === bannerIndex ? ' active' : ''}`}
onClick=${() => setBannerIndex(i)}
style=${{ background: ev.textColor }}
/>
`)}
</div>
`}
</div> </div>
<button <button
class="event-banner-dismiss" class="event-banner-dismiss"
style=${{ color: activeEvent.textColor }} style=${{ color: ev.textColor }}
onClick=${() => setDismissedEventId(activeEvent.id)} onClick=${() => setDismissedEventIds(ids => [...ids, ev.id])}
aria-label="Dismiss" aria-label="Dismiss"
title="Dismiss" title="Dismiss this event"
></button> ></button>
</div> </div>`;
`} })()}
</div> </div>
<div class="utci-header"> <div class="utci-header">
@@ -811,7 +863,7 @@ export function UTCIForecast() {
elev=${currentRow?.elev ?? 0} elev=${currentRow?.elev ?? 0}
dt=${currentRow?.dt ?? new Date()} dt=${currentRow?.dt ?? new Date()}
glob=${currentRow?.glob ?? 0} glob=${currentRow?.glob ?? 0}
activeEvent=${activeEvent} activeEvent=${lensEvent}
/> />
</div> </div>
@@ -1457,9 +1509,9 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}> <span style=${{ display: 'inline-flex', alignItems: 'center', gap: '7px', verticalAlign: 'middle' }}>
<${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} /> <${SkyScope} elev=${r.elev} dt=${r.dt} glob=${r.glob} size=${30} />
<span>${localHHMM}</span> <span>${localHHMM}</span>
${shouldShowCellTag(activeEvent, r) && html` ${getCellTagEvents(selectedDayEvents, r).map(ev => html`
<span class="event-cell-tag" title=${activeEvent.title}>${activeEvent.emoji}</span> <span key=${ev.id} class="event-cell-tag" title=${ev.title}>${ev.emoji}</span>
`} `)}
</span> </span>
</td> </td>
${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`} ${visibleCols.air && html`<td style=${{ background: tempBg(r.Ta) }}>${r.Ta.toFixed(1)}</td>`}
+342 -112
View File
@@ -190,6 +190,121 @@ const COSMIC_CALENDAR = [
start: '2026-06-30', end: '2026-07-02', 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 ─────────────────────────────────────────────── // ─── WEATHER-DERIVED EVENTS ───────────────────────────────────────────────
@@ -421,6 +536,128 @@ const ALMANAC_CALENDAR = [
latHint: 'north', latHint: 'north',
type: 'meteor', 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 1625.',
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 630.',
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 420.',
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 1726.',
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. // Returns a location-aware visibility note for an almanac entry.
@@ -465,47 +702,68 @@ export function getUpcomingEvents(location, days = 90) {
} }
// ─── CELL TAG LOGIC ────────────────────────────────────────────────────── // ─── CELL TAG LOGIC ──────────────────────────────────────────────────────
// For timetable cell icons: given an active event and a row, // Returns the subset of active events that should show an icon for this row.
// should this hour show the event icon? export function getCellTagEvents(events, row) {
export function shouldShowCellTag(event, row) { if (!events || events.length === 0) return [];
if (!event) return false; return events.filter(ev => {
if (event.type === 'promo') return false; if (ev.type === 'promo') return false;
// Night-only events only badge night rows if (ev.nightOnly && row.elev >= -5) return false;
if (event.nightOnly && row.elev >= -5) return false; if (ev.id === 'heat-spike' && row.elev < 0) 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; return true;
});
} }
// ─── MAIN EXPORT ───────────────────────────────────────────────────────── // ─── MAIN EXPORT ─────────────────────────────────────────────────────────
// Call this once per render with today's rows and location. // Returns ALL active events for the given rows/date as an array.
// Returns the single highest-priority active event, or null. // Empty array = nothing active.
// //
// Priority order: // Order within array:
// 1. PROMO_OVERRIDE (manual) // 1. PROMO_OVERRIDE (if set, returned alone)
// 2. Cosmic calendar events (sorted by priority: eclipse > meteor > planetary) // 2. All matching cosmic calendar events for this date
// 3. Weather events (heat > storm > frost > sunset > stargazing) // 3. All matching weather-derived events
export function getActiveEvent(todayRows, location) { export function getActiveEvents(rows, location) {
// 1. Manual promo override // 1. Manual promo override — shown alone, no mixing with other events
if (PROMO_OVERRIDE) return PROMO_OVERRIDE; if (PROMO_OVERRIDE) return [PROMO_OVERRIDE];
// 2. Cosmic calendar // 2. Cosmic calendar — use the date of the rows being viewed, not today.
const today = new Date(); const dateStr = (rows && rows.length > 0)
const todayStr = today.toISOString().slice(0, 10); ? rows[0].iso.slice(0, 10)
const cosmicHit = COSMIC_CALENDAR.find(ev => todayStr >= ev.start && todayStr <= ev.end); : new Date().toISOString().slice(0, 10);
if (cosmicHit) return cosmicHit; const cosmicHits = COSMIC_CALENDAR.filter(ev => dateStr >= ev.start && dateStr <= ev.end);
// 3. Weather-derived (needs forecast data) // 3. Weather-derived events (all that match, not just first)
if (!todayRows || todayRows.length === 0) return null; 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 ( return [...cosmicHits, ...weatherEvents];
checkHeatSpike(todayRows) || }
checkStorm(todayRows) ||
checkFrost(todayRows) || // ─── LENS EVENT PICKER ───────────────────────────────────────────────────
checkSunset(todayRows, location) || // From an array of active events, returns the single one closest to its peak
checkStargazing(todayRows) // (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 ─────────────────────────────────────────────── // ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
@@ -518,114 +776,86 @@ export function getLensOverlaySVG(event, cx, cy, lensR) {
if (!event) return null; if (!event) return null;
const id = event.id; const id = event.id;
if (event.emoji === '☄️') { if (event.emoji === String.fromCodePoint(0x2604, 0xFE0F)) {
return [ return [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">', '<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-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-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+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-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"/>`, '<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>', '</g>',
].join('\n'); ].join('');
} }
if (id.includes('solar-eclipse')) { if (id.includes('solar-eclipse')) {
return [ const cx_ = cx, cy_ = cy - 20;
'<g clip-path="url(#scope-lens-clip)">', return '<g clip-path="url(#scope-lens-clip)">'
`<radialGradient id="corona-glow" cx="${cx}" cy="${cy-20}" r="38" gradientUnits="userSpaceOnUse">`, + '<radialGradient id="corona-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="38" gradientUnits="userSpaceOnUse">'
'<stop offset="0%" stop-color="rgba(255,220,100,0)" />', + '<stop offset="0%" stop-color="rgba(255,220,100,0)" />'
'<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />', + '<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />'
'<stop offset="100%" stop-color="rgba(255,180,40,0)" />', + '<stop offset="100%" stop-color="rgba(255,180,40,0)" /></radialGradient>'
'</radialGradient>', + '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="38" fill="url(#corona-glow)" opacity="0.8" />'
`<circle cx="${cx}" cy="${cy-20}" r="38" fill="url(#corona-glow)" opacity="0.8" />`, + '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="18" fill="rgba(5,2,15,0.88)" /></g>';
`<circle cx="${cx}" cy="${cy-20}" r="18" fill="rgba(5,2,15,0.88)" />`,
'</g>',
].join('\n');
} }
if (id.includes('lunar-eclipse')) { if (id.includes('lunar-eclipse')) {
return [ const cx_ = cx, cy_ = cy - 30;
'<g clip-path="url(#scope-lens-clip)">', return '<g clip-path="url(#scope-lens-clip)">'
`<radialGradient id="blood-moon-glow" cx="${cx}" cy="${cy-30}" r="30" gradientUnits="userSpaceOnUse">`, + '<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="0%" stop-color="rgba(180,30,10,0.7)" />'
'<stop offset="100%" stop-color="rgba(120,10,5,0)" />', + '<stop offset="100%" stop-color="rgba(120,10,5,0)" /></radialGradient>'
'</radialGradient>', + '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="30" fill="url(#blood-moon-glow)" />'
`<circle cx="${cx}" cy="${cy-30}" 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-30}" 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>';
`<circle cx="${cx}" cy="${cy-30}" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" />`,
'</g>',
].join('\n');
} }
if (event.emoji === '🪐') { if (event.emoji === String.fromCodePoint(0x1FA90)) {
return [ return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
'<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)" />'
`<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>';
`<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>',
].join('\n');
} }
if (event.emoji === '🔴') { if (event.emoji === String.fromCodePoint(0x1F534)) {
return [ return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
'<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+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>';
`<circle cx="${cx-20}" cy="${cy-45}" r="5" fill="rgba(220,160,80,0.7)" />`,
'</g>',
].join('\n');
} }
if (id === 'stargazing') { if (id === 'stargazing') {
const stars = [ 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]];
[cx-50,cy-55,1.8],[cx+40,cy-65,1.4],[cx-20,cy-70,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('');
[cx+65,cy-40,1.6],[cx-60,cy-30,1.2],[cx+50,cy-55,1.0], return '<g clip-path="url(#scope-lens-clip)" opacity="0.7">' + dots + '</g>';
[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]) => `<circle cx="${x}" cy="${y}" r="${r}" 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') { if (id === 'perfect-sunset') {
return [ return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">'
'<g clip-path="url(#scope-lens-clip)" opacity="0.5">', + '<radialGradient id="sunset-lens-glow" cx="' + cx + '" cy="' + (cy+20) + '" r="' + lensR + '" gradientUnits="userSpaceOnUse">'
`<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="0%" stop-color="rgba(255,120,20,0.7)" />', + '<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />'
'<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />', + '<stop offset="100%" stop-color="rgba(200,20,0,0)" /></radialGradient>'
'<stop offset="100%" stop-color="rgba(200,20,0,0)" />', + '<circle cx="' + cx + '" cy="' + cy + '" r="' + lensR + '" fill="url(#sunset-lens-glow)" /></g>';
'</radialGradient>',
`<circle cx="${cx}" cy="${cy}" r="${lensR}" fill="url(#sunset-lens-glow)" />`,
'</g>',
].join('\n');
} }
if (id === 'heat-spike') { if (id === 'heat-spike') {
return [ return '<g clip-path="url(#scope-lens-clip)" opacity="0.35">'
'<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+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>';
`<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" />`,
`<path d="M ${cx-lensR+20} ${cy+60} Q ${cx+10} ${cy+45} ${cx+40} ${cy+60} Q ${cx+70} ${cy+75} ${cx+lensR-20} ${cy+60}" fill="none" stroke="rgba(255,80,0,0.4)" stroke-width="1.5" />`,
'</g>',
].join('\n');
} }
if (id === 'storm') { if (id === 'storm') {
const lines = [-55,-30,-5,20,45,65].map(x => const sl = [-55,-30,-5,20,45,65].map(function(x){
`<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" />` 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(''); }).join('');
return `<g clip-path="url(#scope-lens-clip)" opacity="0.45">${lines}</g>`; return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
} }
if (id === 'frost') { if (id === 'frost') {
const crystals = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(([dx,dy]) => const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){
`<g transform="translate(${cx+dx},${cy+dy})">` + var dx=p[0], dy=p[1];
'<line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="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>';
'<line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" />' + }).join('');
'<line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" />' + return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + fc + '</g>';
'<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">${crystals}</g>`;
} }
return null; return null;