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
+343 -113
View File
@@ -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 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.
@@ -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 [
'<g opacity="0.55" clip-path="url(#scope-lens-clip)">',
`<line x1="${cx-60}" y1="${cy-50}" x2="${cx-20}" y2="${cy-20}" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>`,
`<line x1="${cx-40}" y1="${cy-70}" x2="${cx+5}" y2="${cy-30}" stroke="#fff0c0" stroke-width="1" stroke-linecap="round" opacity="0.7"/>`,
`<line x1="${cx+30}" y1="${cy-60}" x2="${cx+65}" y2="${cy-25}" stroke="#ffe8a0" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>`,
`<line x1="${cx-10}" y1="${cy-40}" x2="${cx+30}" y2="${cy-5}" stroke="#fff0c0" stroke-width="0.9" stroke-linecap="round" opacity="0.65"/>`,
`<line x1="${cx-70}" y1="${cy-20}" x2="${cx-35}" y2="${cy+10}" stroke="#ffe8a0" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>`,
'<line x1="' + (cx-60) + '" y1="' + (cy-50) + '" x2="' + (cx-20) + '" y2="' + (cy-20) + '" stroke="#ffe8a0" stroke-width="1.5" stroke-linecap="round" opacity="0.9"/>',
'<line x1="' + (cx-40) + '" y1="' + (cy-70) + '" x2="' + (cx+5) + '" y2="' + (cy-30) + '" stroke="#fff0c0" stroke-width="1" stroke-linecap="round" opacity="0.7"/>',
'<line x1="' + (cx+30) + '" y1="' + (cy-60) + '" x2="' + (cx+65) + '" y2="' + (cy-25) + '" stroke="#ffe8a0" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>',
'<line x1="' + (cx-10) + '" y1="' + (cy-40) + '" x2="' + (cx+30) + '" y2="' + (cy-5) + '" stroke="#fff0c0" stroke-width="0.9" stroke-linecap="round" opacity="0.65"/>',
'<line x1="' + (cx-70) + '" y1="' + (cy-20) + '" x2="' + (cx-35) + '" y2="' + (cy+10) + '" stroke="#ffe8a0" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>',
'</g>',
].join('\n');
].join('');
}
if (id.includes('solar-eclipse')) {
return [
'<g clip-path="url(#scope-lens-clip)">',
`<radialGradient id="corona-glow" cx="${cx}" cy="${cy-20}" r="38" gradientUnits="userSpaceOnUse">`,
'<stop offset="0%" stop-color="rgba(255,220,100,0)" />',
'<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />',
'<stop offset="100%" stop-color="rgba(255,180,40,0)" />',
'</radialGradient>',
`<circle cx="${cx}" cy="${cy-20}" r="38" fill="url(#corona-glow)" opacity="0.8" />`,
`<circle cx="${cx}" cy="${cy-20}" r="18" fill="rgba(5,2,15,0.88)" />`,
'</g>',
].join('\n');
const cx_ = cx, cy_ = cy - 20;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="corona-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="38" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,220,100,0)" />'
+ '<stop offset="55%" stop-color="rgba(255,220,100,0.55)" />'
+ '<stop offset="100%" stop-color="rgba(255,180,40,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="38" fill="url(#corona-glow)" opacity="0.8" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="18" fill="rgba(5,2,15,0.88)" /></g>';
}
if (id.includes('lunar-eclipse')) {
return [
'<g clip-path="url(#scope-lens-clip)">',
`<radialGradient id="blood-moon-glow" cx="${cx}" cy="${cy-30}" r="30" gradientUnits="userSpaceOnUse">`,
'<stop offset="0%" stop-color="rgba(180,30,10,0.7)" />',
'<stop offset="100%" stop-color="rgba(120,10,5,0)" />',
'</radialGradient>',
`<circle cx="${cx}" cy="${cy-30}" r="30" fill="url(#blood-moon-glow)" />`,
`<circle cx="${cx}" cy="${cy-30}" r="16" fill="rgba(160,25,10,0.75)" />`,
`<circle cx="${cx}" cy="${cy-30}" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" />`,
'</g>',
].join('\n');
const cx_ = cx, cy_ = cy - 30;
return '<g clip-path="url(#scope-lens-clip)">'
+ '<radialGradient id="blood-moon-glow" cx="' + cx_ + '" cy="' + cy_ + '" r="30" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(180,30,10,0.7)" />'
+ '<stop offset="100%" stop-color="rgba(120,10,5,0)" /></radialGradient>'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="30" fill="url(#blood-moon-glow)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="rgba(160,25,10,0.75)" />'
+ '<circle cx="' + cx_ + '" cy="' + cy_ + '" r="16" fill="none" stroke="rgba(220,60,20,0.5)" stroke-width="1.5" /></g>';
}
if (event.emoji === '🪐') {
return [
'<g clip-path="url(#scope-lens-clip)" opacity="0.65">',
`<circle cx="${cx+20}" cy="${cy-35}" r="10" fill="rgba(210,185,130,0.7)" />`,
`<ellipse cx="${cx+20}" cy="${cy-35}" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" />`,
'</g>',
].join('\n');
if (event.emoji === String.fromCodePoint(0x1FA90)) {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.65">'
+ '<circle cx="' + (cx+20) + '" cy="' + (cy-35) + '" r="10" fill="rgba(210,185,130,0.7)" />'
+ '<ellipse cx="' + (cx+20) + '" cy="' + (cy-35) + '" rx="18" ry="4.5" fill="none" stroke="rgba(200,170,100,0.65)" stroke-width="2.5" /></g>';
}
if (event.emoji === '🔴') {
return [
'<g clip-path="url(#scope-lens-clip)" opacity="0.6">',
`<circle cx="${cx+30}" cy="${cy-30}" r="7" fill="rgba(200,80,40,0.75)" />`,
`<circle cx="${cx-20}" cy="${cy-45}" r="5" fill="rgba(220,160,80,0.7)" />`,
'</g>',
].join('\n');
if (event.emoji === String.fromCodePoint(0x1F534)) {
return '<g clip-path="url(#scope-lens-clip)" opacity="0.6">'
+ '<circle cx="' + (cx+30) + '" cy="' + (cy-30) + '" r="7" fill="rgba(200,80,40,0.75)" />'
+ '<circle cx="' + (cx-20) + '" cy="' + (cy-45) + '" r="5" fill="rgba(220,160,80,0.7)" /></g>';
}
if (id === 'stargazing') {
const 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]) => `<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>`;
const pts = [[cx-50,cy-55,1.8],[cx+40,cy-65,1.4],[cx-20,cy-70,1.0],[cx+65,cy-40,1.6],[cx-60,cy-30,1.2],[cx+50,cy-55,1.0],[cx-35,cy-45,1.4],[cx+20,cy-50,1.8],[cx-75,cy-50,1.0]];
const dots = pts.map(function(s){return '<circle cx="'+s[0]+'" cy="'+s[1]+'" r="'+s[2]+'" fill="rgba(255,255,255,0.9)" />';}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.7">' + dots + '</g>';
}
if (id === 'perfect-sunset') {
return [
'<g clip-path="url(#scope-lens-clip)" opacity="0.5">',
`<radialGradient id="sunset-lens-glow" cx="${cx}" cy="${cy+20}" r="${lensR}" gradientUnits="userSpaceOnUse">`,
'<stop offset="0%" stop-color="rgba(255,120,20,0.7)" />',
'<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />',
'<stop offset="100%" stop-color="rgba(200,20,0,0)" />',
'</radialGradient>',
`<circle cx="${cx}" cy="${cy}" r="${lensR}" fill="url(#sunset-lens-glow)" />`,
'</g>',
].join('\n');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">'
+ '<radialGradient id="sunset-lens-glow" cx="' + cx + '" cy="' + (cy+20) + '" r="' + lensR + '" gradientUnits="userSpaceOnUse">'
+ '<stop offset="0%" stop-color="rgba(255,120,20,0.7)" />'
+ '<stop offset="50%" stop-color="rgba(255,60,10,0.25)" />'
+ '<stop offset="100%" stop-color="rgba(200,20,0,0)" /></radialGradient>'
+ '<circle cx="' + cx + '" cy="' + cy + '" r="' + lensR + '" fill="url(#sunset-lens-glow)" /></g>';
}
if (id === 'heat-spike') {
return [
'<g clip-path="url(#scope-lens-clip)" opacity="0.35">',
`<path d="M ${cx-lensR+10} ${cy+30} Q ${cx-20} ${cy+15} ${cx+20} ${cy+30} Q ${cx+60} ${cy+45} ${cx+lensR-10} ${cy+30}" fill="none" stroke="rgba(255,120,20,0.6)" stroke-width="2" />`,
`<path d="M ${cx-lensR+10} ${cy+45} Q ${cx-10} ${cy+30} ${cx+30} ${cy+45} Q ${cx+65} ${cy+60} ${cx+lensR-10} ${cy+45}" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" />`,
`<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');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.35">'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+30) + ' Q ' + (cx-20) + ' ' + (cy+15) + ' ' + (cx+20) + ' ' + (cy+30) + ' Q ' + (cx+60) + ' ' + (cy+45) + ' ' + (cx+lensR-10) + ' ' + (cy+30) + '" fill="none" stroke="rgba(255,120,20,0.6)" stroke-width="2" />'
+ '<path d="M ' + (cx-lensR+10) + ' ' + (cy+45) + ' Q ' + (cx-10) + ' ' + (cy+30) + ' ' + (cx+30) + ' ' + (cy+45) + ' Q ' + (cx+65) + ' ' + (cy+60) + ' ' + (cx+lensR-10) + ' ' + (cy+45) + '" fill="none" stroke="rgba(255,100,10,0.5)" stroke-width="2" /></g>';
}
if (id === 'storm') {
const lines = [-55,-30,-5,20,45,65].map(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" />`
).join('');
return `<g clip-path="url(#scope-lens-clip)" opacity="0.45">${lines}</g>`;
const sl = [-55,-30,-5,20,45,65].map(function(x){
return '<line x1="' + (cx+x) + '" y1="' + (cy-lensR+10) + '" x2="' + (cx+x-15) + '" y2="' + (cy+lensR-10) + '" stroke="rgba(140,180,220,0.6)" stroke-width="1" stroke-linecap="round" />';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.45">' + sl + '</g>';
}
if (id === 'frost') {
const crystals = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(([dx,dy]) =>
`<g transform="translate(${cx+dx},${cy+dy})">` +
'<line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="1" />' +
'<line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" />' +
'<line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" />' +
'<line x1="4" y1="-4" x2="-4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" />' +
'</g>'
).join('');
return `<g clip-path="url(#scope-lens-clip)" opacity="0.5">${crystals}</g>`;
const fc = [[-50,40],[0,55],[50,40],[-30,65],[30,65]].map(function(p){
var dx=p[0], dy=p[1];
return '<g transform="translate(' + (cx+dx) + ',' + (cy+dy) + ')"><line x1="-6" y1="0" x2="6" y2="0" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="0" y1="-6" x2="0" y2="6" stroke="rgba(180,220,255,0.8)" stroke-width="1" /><line x1="-4" y1="-4" x2="4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /><line x1="4" y1="-4" x2="-4" y2="4" stroke="rgba(180,220,255,0.7)" stroke-width="0.8" /></g>';
}).join('');
return '<g clip-path="url(#scope-lens-clip)" opacity="0.5">' + fc + '</g>';
}
return null;