v1.4 - Cosmic Events
This commit is contained in:
@@ -3,6 +3,205 @@
|
|||||||
════════════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── EVENT BANNER ───────────────────────────────────────────────────── */
|
||||||
|
/* Animated slide-down banner for cosmic/weather/promo events. */
|
||||||
|
|
||||||
|
.event-banner-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.event-banner-wrap.visible {
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
.event-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
border-left: 4px solid rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.event-banner-emoji {
|
||||||
|
font-size: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.event-banner-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.event-banner-title {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.event-banner-msg {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
.event-banner-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.55;
|
||||||
|
padding: 4px 6px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.event-banner-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cell tag — tiny event icon in the hour column */
|
||||||
|
.event-cell-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ALMANAC PANEL ──────────────────────────────────────────────────── */
|
||||||
|
/* "What's Coming" upcoming cosmic events box below the forecast table. */
|
||||||
|
|
||||||
|
.almanac-panel {
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fdf8ee;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.almanac-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px 10px;
|
||||||
|
border-bottom: 1px solid #e8d8b8;
|
||||||
|
background: #faf2e2;
|
||||||
|
}
|
||||||
|
.almanac-title {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #2a1a08;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.almanac-subtitle {
|
||||||
|
font-family: JetBrains Mono, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #9a7d5a;
|
||||||
|
}
|
||||||
|
.almanac-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.almanac-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 13px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-bottom: 1px solid #f0e4cc;
|
||||||
|
border-right: 1px solid #f0e4cc;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.almanac-entry:hover {
|
||||||
|
background: #fef8e8;
|
||||||
|
}
|
||||||
|
.almanac-entry-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.almanac-entry-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.almanac-entry-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.almanac-entry-title {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e1208;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.almanac-entry-date {
|
||||||
|
font-family: JetBrains Mono, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #c8922a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.almanac-entry-countdown {
|
||||||
|
font-family: JetBrains Mono, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9a7d5a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.almanac-entry-desc {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: '#4a3420';
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #4a3420;
|
||||||
|
}
|
||||||
|
.almanac-entry-visibility {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-family: JetBrains Mono, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #7a9a6a;
|
||||||
|
background: #eef4e8;
|
||||||
|
border: 1px solid #c0d8b0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
.almanac-entry-accent {
|
||||||
|
width: 3px;
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.almanac-empty {
|
||||||
|
padding: 22px 18px;
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9a7d5a;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.almanac-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
|
/* ── CUSTOM SELECT DROPDOWN ─────────────────────────────────────────── */
|
||||||
/* Cross-platform replacement for native <select> on the col-toggles bar */
|
/* Cross-platform replacement for native <select> on the col-toggles bar */
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +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';
|
||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
@@ -718,6 +719,13 @@ 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 ─────────────────────────────────────────────
|
||||||
|
// Compute once per render. todayRows = the rows for today (day 0).
|
||||||
|
const [dismissedEventId, setDismissedEventId] = useState(null);
|
||||||
|
const todayRows = days[0]?.rows || [];
|
||||||
|
const activeEvent = getActiveEvent(todayRows, location);
|
||||||
|
const showBanner = activeEvent && activeEvent.id !== dismissedEventId;
|
||||||
|
|
||||||
// ─── 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
|
||||||
// template. Search tips:
|
// template. Search tips:
|
||||||
@@ -758,6 +766,29 @@ export function UTCIForecast() {
|
|||||||
</nav>
|
</nav>
|
||||||
<main class="utci-shell">
|
<main class="utci-shell">
|
||||||
|
|
||||||
|
<!-- ── EVENT BANNER ── slides down when an event is active ── -->
|
||||||
|
<div class=${`event-banner-wrap${showBanner ? ' visible' : ''}`}>
|
||||||
|
${showBanner && html`
|
||||||
|
<div class="event-banner" style=${{
|
||||||
|
background: activeEvent.color,
|
||||||
|
color: activeEvent.textColor,
|
||||||
|
}}>
|
||||||
|
<span class="event-banner-emoji">${activeEvent.emoji}</span>
|
||||||
|
<div class="event-banner-body">
|
||||||
|
<div class="event-banner-title">${activeEvent.title}</div>
|
||||||
|
<div class="event-banner-msg">${activeEvent.message}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="event-banner-dismiss"
|
||||||
|
style=${{ color: activeEvent.textColor }}
|
||||||
|
onClick=${() => setDismissedEventId(activeEvent.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
title="Dismiss"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="utci-header">
|
<div class="utci-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="utci-title">
|
<h1 class="utci-title">
|
||||||
@@ -780,6 +811,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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -859,6 +891,9 @@ export function UTCIForecast() {
|
|||||||
const dayName = i === 0 ? 'Today'
|
const dayName = i === 0 ? 'Today'
|
||||||
: i === 1 ? 'Tomorrow'
|
: i === 1 ? 'Tomorrow'
|
||||||
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||||||
|
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
|
||||||
|
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
|
||||||
|
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
key=${d.key}
|
key=${d.key}
|
||||||
@@ -893,6 +928,20 @@ export function UTCIForecast() {
|
|||||||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||||||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||||
</span>
|
</span>
|
||||||
|
${dayHi !== null && html`
|
||||||
|
<span style=${{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'Fraunces, serif',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginTop: '3px',
|
||||||
|
letterSpacing: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}>
|
||||||
|
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
|
||||||
|
<span style=${{ opacity: 0.5 }}> / </span>
|
||||||
|
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
|
||||||
|
</span>`}
|
||||||
</button>`;
|
</button>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -1408,6 +1457,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`
|
||||||
|
<span class="event-cell-tag" title=${activeEvent.title}>${activeEvent.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>`}
|
||||||
@@ -1538,6 +1590,55 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
|||||||
</span>`)}
|
</span>`)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ALMANAC — upcoming cosmic events in the next 3 months.
|
||||||
|
Location-aware: visibility notes adjust by latitude.
|
||||||
|
-->
|
||||||
|
${(() => {
|
||||||
|
const upcoming = getUpcomingEvents(location, 90);
|
||||||
|
const formatPeak = (iso) => {
|
||||||
|
const d = new Date(iso + 'T00:00Z');
|
||||||
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
const countdownLabel = (days) => {
|
||||||
|
if (days === 0) return 'Tonight!';
|
||||||
|
if (days === 1) return 'Tomorrow';
|
||||||
|
if (days < 7) return `In ${days} days`;
|
||||||
|
if (days < 14) return 'Next week';
|
||||||
|
if (days < 60) return `In ${Math.round(days / 7)} weeks`;
|
||||||
|
return `In ${Math.round(days / 30)} months`;
|
||||||
|
};
|
||||||
|
return html`
|
||||||
|
<div class="almanac-panel">
|
||||||
|
<div class="almanac-header">
|
||||||
|
<span class="almanac-title">🔭 What's Coming</span>
|
||||||
|
<span class="almanac-subtitle">Cosmic events · next 90 days · ${location.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="almanac-list">
|
||||||
|
${upcoming.length === 0
|
||||||
|
? html`<div class="almanac-empty">No major cosmic events in the next 90 days — clear skies ahead.</div>`
|
||||||
|
: upcoming.map(ev => html`
|
||||||
|
<div key=${ev.id} class="almanac-entry">
|
||||||
|
<div class="almanac-entry-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||||||
|
<div class="almanac-entry-icon">${ev.emoji}</div>
|
||||||
|
<div class="almanac-entry-body">
|
||||||
|
<div class="almanac-entry-head">
|
||||||
|
<span class="almanac-entry-title">${ev.title}</span>
|
||||||
|
<span class="almanac-entry-date">${formatPeak(ev.peak)}</span>
|
||||||
|
<span class="almanac-entry-countdown">${countdownLabel(ev.daysUntil)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="almanac-entry-desc">${ev.desc}</div>
|
||||||
|
${ev.visibilityNote && html`
|
||||||
|
<span class="almanac-entry-visibility">📍 ${ev.visibilityNote}</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})()}
|
||||||
</>`}
|
</>`}
|
||||||
|
|
||||||
<div class="utci-about">
|
<div class="utci-about">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
skyGradientForElev, skyFillForElev, grassFillForElev,
|
skyGradientForElev, skyFillForElev, grassFillForElev,
|
||||||
moonPhaseFraction,
|
moonPhaseFraction,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import { getLensOverlaySVG } from './events.js';
|
||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
@@ -411,7 +412,7 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
|
|||||||
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
|
// • (value + 10) / 60 maps UTCI -10..50 onto the 270° sweep —
|
||||||
// widen the dial range by changing those numbers
|
// widen the dial range by changing those numbers
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0 }) {
|
export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), glob = 0, activeEvent = null }) {
|
||||||
// ── Day/night scene behind the readout ─────────────────────────────
|
// ── Day/night scene behind the readout ─────────────────────────────
|
||||||
// The whole back of the lens is a SkyScope-style scene: sky on top,
|
// The whole back of the lens is a SkyScope-style scene: sky on top,
|
||||||
// grass on the bottom, sun positioned by elevation (or moon at night
|
// grass on the bottom, sun positioned by elevation (or moon at night
|
||||||
@@ -566,6 +567,11 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
|
|||||||
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.7" />
|
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.7" />
|
||||||
</>`}
|
</>`}
|
||||||
</g>
|
</g>
|
||||||
|
${(() => {
|
||||||
|
const overlaySVG = getLensOverlaySVG(activeEvent, cx, cy, lensR);
|
||||||
|
if (!overlaySVG) return null;
|
||||||
|
return html`<g dangerouslySetInnerHTML=${{ __html: overlaySVG }} />`;
|
||||||
|
})()}
|
||||||
${stressBands.map((b, i) => html`
|
${stressBands.map((b, i) => html`
|
||||||
<path key=${i} d=${bandArcPath(b)} fill="none"
|
<path key=${i} d=${bandArcPath(b)} fill="none"
|
||||||
stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)}
|
stroke=${b.color} stroke-width="4" stroke-linecap="butt" />`)}
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// events.js — Cosmic & weather event engine for SunScope.
|
||||||
|
//
|
||||||
|
// Returns the "active event" (or null) based on:
|
||||||
|
// 1. PROMO_OVERRIDE — manually set a message for promotions etc.
|
||||||
|
// 2. Hardcoded cosmic calendar (eclipses, meteor showers, alignments)
|
||||||
|
// 3. Weather-derived events (stargazing, sunset, heat spike, storm)
|
||||||
|
//
|
||||||
|
// To set a promo banner, edit PROMO_OVERRIDE near the top of this file.
|
||||||
|
// Set it back to null when done.
|
||||||
|
//
|
||||||
|
// Each event object:
|
||||||
|
// { id, emoji, title, message, color, textColor, type }
|
||||||
|
//
|
||||||
|
// type: 'cosmic' | 'weather' | 'promo'
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─── PROMO OVERRIDE ──────────────────────────────────────────────────────
|
||||||
|
// Set this to show a custom banner regardless of weather or cosmic events.
|
||||||
|
// Leave as null for automatic event detection.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// export const PROMO_OVERRIDE = {
|
||||||
|
// id: 'promo-summer',
|
||||||
|
// emoji: '☀️',
|
||||||
|
// title: 'Summer Sale',
|
||||||
|
// message: 'SunScope Extra — 20% off this weekend only.',
|
||||||
|
// color: '#c8922a',
|
||||||
|
// textColor: '#fff',
|
||||||
|
// type: 'promo',
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
export const PROMO_OVERRIDE = null;
|
||||||
|
|
||||||
|
// ─── COSMIC CALENDAR ─────────────────────────────────────────────────────
|
||||||
|
// Hardcoded events — these are known years in advance.
|
||||||
|
// Each entry: { id, start: 'YYYY-MM-DD', end: 'YYYY-MM-DD', peak: 'YYYY-MM-DD' (optional), ...eventProps }
|
||||||
|
// Active if today falls within [start, end] (inclusive).
|
||||||
|
// Night-only events (meteor showers, eclipses) are marked nightOnly: true
|
||||||
|
// so the cell icons only appear in night-time rows.
|
||||||
|
|
||||||
|
const COSMIC_CALENDAR = [
|
||||||
|
|
||||||
|
// ── METEOR SHOWERS ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'quadrantids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Quadrantid Meteor Shower',
|
||||||
|
message: 'The Quadrantid meteor shower is active — up to 120 meteors/hour at peak. Best after midnight in a dark sky.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-01-01', end: '2026-01-05', peak: '2026-01-03',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lyrids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Lyrid Meteor Shower',
|
||||||
|
message: 'The Lyrid meteor shower peaks tonight — up to 20 meteors/hour from a dark sky after midnight.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-04-16', end: '2026-04-25', peak: '2026-04-22',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eta-aquariids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Eta Aquariid Meteor Shower',
|
||||||
|
message: 'The Eta Aquariid shower peaks tonight — fragments of Halley\'s Comet, up to 50/hour before dawn.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-04-19', end: '2026-05-28', peak: '2026-05-06',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'perseids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Perseid Meteor Shower',
|
||||||
|
message: 'The Perseids are active — one of the best showers of the year, up to 100 meteors/hour at peak.',
|
||||||
|
color: '#3a1a00',
|
||||||
|
textColor: '#ffe8c0',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-07-17', end: '2026-08-24', peak: '2026-08-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orionids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Orionid Meteor Shower',
|
||||||
|
message: 'The Orionid shower peaks tonight — fast, bright meteors from Halley\'s Comet debris. Up to 25/hour.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-10-02', end: '2026-11-07', peak: '2026-10-21',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leonids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Leonid Meteor Shower',
|
||||||
|
message: 'The Leonid shower is active tonight — fast, bright meteors from comet Tempel-Tuttle.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-11-06', end: '2026-11-30', peak: '2026-11-17',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'geminids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Geminid Meteor Shower',
|
||||||
|
message: 'The Geminids peak tonight — the best shower of the year, up to 150 meteors/hour. No moon interference.',
|
||||||
|
color: '#3a1a00',
|
||||||
|
textColor: '#ffe8c0',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-12-04', end: '2026-12-20', peak: '2026-12-13',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ursids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Ursid Meteor Shower',
|
||||||
|
message: 'The Ursid shower peaks tonight — a quieter festive shower near the winter solstice.',
|
||||||
|
color: '#2a1a5a',
|
||||||
|
textColor: '#e8d8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-12-17', end: '2026-12-26', peak: '2026-12-22',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ECLIPSES ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'solar-eclipse-2026-aug',
|
||||||
|
emoji: '🌑',
|
||||||
|
title: 'Total Solar Eclipse',
|
||||||
|
message: 'A total solar eclipse crosses Europe and North Africa today — look for rapid temperature drops and unusual animal behaviour even in partial zones.',
|
||||||
|
color: '#1a0a2a',
|
||||||
|
textColor: '#d8c8f8',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: false,
|
||||||
|
start: '2026-08-12', end: '2026-08-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lunar-eclipse-2026-mar',
|
||||||
|
emoji: '🌕',
|
||||||
|
title: 'Total Lunar Eclipse',
|
||||||
|
message: 'A total lunar eclipse is visible tonight — the Moon turns deep red (a "Blood Moon") as it passes through Earth\'s shadow.',
|
||||||
|
color: '#3a0a0a',
|
||||||
|
textColor: '#ffd8d8',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-03-03', end: '2026-03-03',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── PLANETARY EVENTS ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'saturn-opposition-2026',
|
||||||
|
emoji: '🪐',
|
||||||
|
title: 'Saturn at Opposition',
|
||||||
|
message: 'Saturn is at its closest and brightest tonight — visible all night long, rings tilted beautifully toward Earth.',
|
||||||
|
color: '#1a2a3a',
|
||||||
|
textColor: '#c8e8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-09-23', end: '2026-09-23',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jupiter-opposition-2026',
|
||||||
|
emoji: '🪐',
|
||||||
|
title: 'Jupiter at Opposition',
|
||||||
|
message: 'Jupiter is at its closest and brightest tonight — you can see its cloud bands with binoculars.',
|
||||||
|
color: '#1a2a3a',
|
||||||
|
textColor: '#c8e8ff',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-10-08', end: '2026-10-08',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mars-conjunction-2026',
|
||||||
|
emoji: '🔴',
|
||||||
|
title: 'Mars & Venus Conjunction',
|
||||||
|
message: 'Mars and Venus are remarkably close in the evening sky tonight — a striking pair visible to the naked eye.',
|
||||||
|
color: '#2a1520',
|
||||||
|
textColor: '#ffc8d8',
|
||||||
|
type: 'cosmic',
|
||||||
|
nightOnly: true,
|
||||||
|
start: '2026-06-30', end: '2026-07-02',
|
||||||
|
},
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── WEATHER-DERIVED EVENTS ───────────────────────────────────────────────
|
||||||
|
// These are computed from the actual forecast data.
|
||||||
|
// Each checker receives (rows, location) and returns an event object or null.
|
||||||
|
// rows = today's hourlyRows array. location = { lat, lon, name, country }.
|
||||||
|
|
||||||
|
function checkStargazing(rows) {
|
||||||
|
// Great stargazing: mostly clear night hours with low cloud
|
||||||
|
const nightRows = rows.filter(r => r.elev < -5);
|
||||||
|
if (nightRows.length < 3) return null;
|
||||||
|
const avgCloud = nightRows.reduce((s, r) => s + r.cc, 0) / nightRows.length;
|
||||||
|
if (avgCloud > 30) return null;
|
||||||
|
return {
|
||||||
|
id: 'stargazing',
|
||||||
|
emoji: '⭐',
|
||||||
|
title: 'Great Stargazing Tonight',
|
||||||
|
message: `Clear skies expected overnight at ${nightRows.length} hours with average ${Math.round(avgCloud)}% cloud — ideal conditions for stargazing.`,
|
||||||
|
color: '#080e1a',
|
||||||
|
textColor: '#c8dcff',
|
||||||
|
type: 'weather',
|
||||||
|
nightOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSunset(rows, location) {
|
||||||
|
// Perfect sunset: clear low cloud near sunset hour, good visibility
|
||||||
|
const sunsetRows = rows.filter(r => r.elev > -3 && r.elev < 8);
|
||||||
|
if (sunsetRows.length === 0) return null;
|
||||||
|
const avgCloud = sunsetRows.reduce((s, r) => s + r.cc, 0) / sunsetRows.length;
|
||||||
|
// We want some high cloud (colour) but not low cloud (obstruction)
|
||||||
|
const avgLowCloud = sunsetRows.reduce((s, r) => s + (r.ccLow || 0), 0) / sunsetRows.length;
|
||||||
|
if (avgLowCloud > 25) return null;
|
||||||
|
if (avgCloud < 5 || avgCloud > 75) return null; // pure clear = less dramatic
|
||||||
|
return {
|
||||||
|
id: 'perfect-sunset',
|
||||||
|
emoji: '🌅',
|
||||||
|
title: 'Spectacular Sunset Conditions',
|
||||||
|
message: `Low cloud is clear near the horizon but high cloud will scatter the light — conditions are ideal for a vivid sunset near ${location.name}.`,
|
||||||
|
color: '#3a1a00',
|
||||||
|
textColor: '#ffe0b0',
|
||||||
|
type: 'weather',
|
||||||
|
nightOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkHeatSpike(rows) {
|
||||||
|
const maxTemp = Math.max(...rows.map(r => r.Ta).filter(isFinite));
|
||||||
|
if (maxTemp < 30) return null;
|
||||||
|
const severity = maxTemp >= 36 ? 'extreme' : maxTemp >= 33 ? 'severe' : 'notable';
|
||||||
|
const msgs = {
|
||||||
|
notable: `Temperatures reaching ${maxTemp.toFixed(1)}°C — above seasonal norms. Stay hydrated and avoid prolonged sun exposure.`,
|
||||||
|
severe: `Heat warning: ${maxTemp.toFixed(1)}°C expected today. Risk of heat exhaustion for vulnerable people — keep cool and hydrated.`,
|
||||||
|
extreme: `Extreme heat alert: ${maxTemp.toFixed(1)}°C forecast. Risk of heat stroke — avoid outdoor activity during peak hours.`,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: 'heat-spike',
|
||||||
|
emoji: '🔥',
|
||||||
|
title: severity === 'extreme' ? 'Extreme Heat Alert' : severity === 'severe' ? 'Heat Warning' : 'Heat Spike Today',
|
||||||
|
message: msgs[severity],
|
||||||
|
color: severity === 'extreme' ? '#3a0000' : severity === 'severe' ? '#4a1000' : '#5a2000',
|
||||||
|
textColor: '#ffd0b0',
|
||||||
|
type: 'weather',
|
||||||
|
nightOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStorm(rows) {
|
||||||
|
const maxGust = Math.max(...rows.map(r => r.gust ?? r.va ?? 0).filter(isFinite));
|
||||||
|
const maxPrecip = Math.max(...rows.map(r => r.precip ?? 0).filter(isFinite));
|
||||||
|
if (maxGust < 15 && maxPrecip < 5) return null;
|
||||||
|
const isStorm = maxGust >= 20 || maxPrecip >= 10;
|
||||||
|
return {
|
||||||
|
id: 'storm',
|
||||||
|
emoji: '🌩️',
|
||||||
|
title: isStorm ? 'Storm Conditions Forecast' : 'Blustery & Wet Today',
|
||||||
|
message: isStorm
|
||||||
|
? `Storm-level conditions expected — gusts to ${maxGust.toFixed(0)} m/s with heavy precipitation. Take care outdoors.`
|
||||||
|
: `Unsettled day ahead — windy with gusts to ${maxGust.toFixed(0)} m/s and ${maxPrecip.toFixed(1)} mm/h rain at peak.`,
|
||||||
|
color: '#1a2030',
|
||||||
|
textColor: '#c0d8f0',
|
||||||
|
type: 'weather',
|
||||||
|
nightOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFrost(rows) {
|
||||||
|
const minTemp = Math.min(...rows.map(r => r.Ta).filter(isFinite));
|
||||||
|
if (minTemp > 2) return null;
|
||||||
|
return {
|
||||||
|
id: 'frost',
|
||||||
|
emoji: '❄️',
|
||||||
|
title: minTemp <= 0 ? 'Freezing Conditions' : 'Frost Risk Tonight',
|
||||||
|
message: minTemp <= 0
|
||||||
|
? `Temperatures dropping to ${minTemp.toFixed(1)}°C — ice on roads and surfaces is likely. Allow extra travel time.`
|
||||||
|
: `Temperatures near freezing tonight (${minTemp.toFixed(1)}°C) — frost possible on exposed surfaces and vehicles.`,
|
||||||
|
color: '#0a1a2a',
|
||||||
|
textColor: '#c8e8ff',
|
||||||
|
type: 'weather',
|
||||||
|
nightOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ALMANAC CALENDAR ────────────────────────────────────────────────────
|
||||||
|
// Extended calendar used for the "What's Coming" panel.
|
||||||
|
// Includes all events from COSMIC_CALENDAR plus multi-year entries.
|
||||||
|
// Each entry has a latHint: null = global, 'north' = better from northern
|
||||||
|
// latitudes, 'south' = southern, 'path:...' = specific eclipse path note.
|
||||||
|
|
||||||
|
const ALMANAC_CALENDAR = [
|
||||||
|
// 2026 events (covers rest of year from today)
|
||||||
|
{
|
||||||
|
id: 'lunar-eclipse-2026-mar',
|
||||||
|
emoji: '🌕',
|
||||||
|
title: 'Total Lunar Eclipse',
|
||||||
|
desc: 'The Moon turns deep red as it passes through Earth\'s shadow. Visible from Europe, Africa, and the Americas.',
|
||||||
|
start: '2026-03-03', peak: '2026-03-03',
|
||||||
|
color: '#3a0a0a', textColor: '#ffd8d8',
|
||||||
|
latHint: null,
|
||||||
|
type: 'eclipse',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lyrids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Lyrid Meteor Shower',
|
||||||
|
desc: 'Up to 20 meteors/hour at peak. Active Apr 16–25, best after midnight from a dark site.',
|
||||||
|
start: '2026-04-16', peak: '2026-04-22',
|
||||||
|
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||||
|
latHint: 'north',
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eta-aquariids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Eta Aquariid Meteor Shower',
|
||||||
|
desc: 'Debris from Halley\'s Comet — up to 50/hour before dawn. Best from southern latitudes but visible worldwide.',
|
||||||
|
start: '2026-04-19', peak: '2026-05-06',
|
||||||
|
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||||
|
latHint: 'south',
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mars-conjunction-2026',
|
||||||
|
emoji: '🔴',
|
||||||
|
title: 'Mars & Venus Conjunction',
|
||||||
|
desc: 'Mars and Venus appear strikingly close in the evening sky — a beautiful naked-eye pairing.',
|
||||||
|
start: '2026-06-30', peak: '2026-07-01',
|
||||||
|
color: '#2a1520', textColor: '#ffc8d8',
|
||||||
|
latHint: null,
|
||||||
|
type: 'planetary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'perseids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Perseid Meteor Shower',
|
||||||
|
desc: 'One of the best showers of the year — up to 100/hour at peak, no need for a telescope.',
|
||||||
|
start: '2026-07-17', peak: '2026-08-12',
|
||||||
|
color: '#3a1a00', textColor: '#ffe8c0',
|
||||||
|
latHint: 'north',
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'solar-eclipse-2026-aug',
|
||||||
|
emoji: '🌑',
|
||||||
|
title: 'Total Solar Eclipse',
|
||||||
|
desc: 'The path of totality crosses Spain, Iceland, and Greenland. Partial eclipse visible across most of Europe.',
|
||||||
|
start: '2026-08-12', peak: '2026-08-12',
|
||||||
|
color: '#1a0a2a', textColor: '#d8c8f8',
|
||||||
|
latHint: 'path:Spain, Iceland, Greenland — partial eclipse across UK & Europe',
|
||||||
|
type: 'eclipse',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'saturn-opposition-2026',
|
||||||
|
emoji: '🪐',
|
||||||
|
title: 'Saturn at Opposition',
|
||||||
|
desc: 'Saturn is at its closest and brightest — rings tilted beautifully toward Earth. Visible all night.',
|
||||||
|
start: '2026-09-23', peak: '2026-09-23',
|
||||||
|
color: '#1a2a3a', textColor: '#c8e8ff',
|
||||||
|
latHint: null,
|
||||||
|
type: 'planetary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orionids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Orionid Meteor Shower',
|
||||||
|
desc: 'Fast, bright meteors from Halley\'s Comet — up to 25/hour. Active Oct 2 – Nov 7.',
|
||||||
|
start: '2026-10-02', peak: '2026-10-21',
|
||||||
|
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||||
|
latHint: null,
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jupiter-opposition-2026',
|
||||||
|
emoji: '🪐',
|
||||||
|
title: 'Jupiter at Opposition',
|
||||||
|
desc: 'Jupiter at its closest — you can see its cloud bands and four Galilean moons with binoculars.',
|
||||||
|
start: '2026-10-08', peak: '2026-10-08',
|
||||||
|
color: '#1a2a3a', textColor: '#c8e8ff',
|
||||||
|
latHint: null,
|
||||||
|
type: 'planetary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leonids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Leonid Meteor Shower',
|
||||||
|
desc: 'Fast, bright meteors from comet Tempel-Tuttle. Up to 15/hour at peak.',
|
||||||
|
start: '2026-11-06', peak: '2026-11-17',
|
||||||
|
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||||
|
latHint: null,
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'geminids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Geminid Meteor Shower',
|
||||||
|
desc: 'The finest shower of the year — up to 150 meteors/hour, visible even before midnight.',
|
||||||
|
start: '2026-12-04', peak: '2026-12-13',
|
||||||
|
color: '#3a1a00', textColor: '#ffe8c0',
|
||||||
|
latHint: 'north',
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ursids-2026',
|
||||||
|
emoji: '☄️',
|
||||||
|
title: 'Ursid Meteor Shower',
|
||||||
|
desc: 'A quieter shower near the winter solstice — circumpolar, so best from northern latitudes.',
|
||||||
|
start: '2026-12-17', peak: '2026-12-22',
|
||||||
|
color: '#2a1a5a', textColor: '#e8d8ff',
|
||||||
|
latHint: 'north',
|
||||||
|
type: 'meteor',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Returns a location-aware visibility note for an almanac entry.
|
||||||
|
// lat = user's latitude.
|
||||||
|
function getVisibilityNote(entry, lat) {
|
||||||
|
if (!entry.latHint) return null;
|
||||||
|
if (entry.latHint.startsWith('path:')) {
|
||||||
|
return entry.latHint.replace('path:', '').trim();
|
||||||
|
}
|
||||||
|
if (entry.latHint === 'north') {
|
||||||
|
if (lat >= 45) return 'Well placed for your latitude';
|
||||||
|
if (lat >= 20) return 'Visible from your location';
|
||||||
|
return 'Better from northern latitudes';
|
||||||
|
}
|
||||||
|
if (entry.latHint === 'south') {
|
||||||
|
if (lat <= 10) return 'Well placed for your latitude';
|
||||||
|
if (lat <= 40) return 'Visible from your location';
|
||||||
|
return 'Better from southern latitudes';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAIN ALMANAC EXPORT ─────────────────────────────────────────────────
|
||||||
|
// Returns events with peak dates in the next `days` days (default 90),
|
||||||
|
// sorted by peak date, with a visibility note added.
|
||||||
|
export function getUpcomingEvents(location, days = 90) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const cutoff = new Date(today);
|
||||||
|
cutoff.setDate(cutoff.getDate() + days);
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return ALMANAC_CALENDAR
|
||||||
|
.filter(ev => ev.peak >= todayStr && ev.peak <= cutoffStr)
|
||||||
|
.sort((a, b) => a.peak.localeCompare(b.peak))
|
||||||
|
.map(ev => ({
|
||||||
|
...ev,
|
||||||
|
visibilityNote: getVisibilityNote(ev, location?.lat ?? 51),
|
||||||
|
daysUntil: Math.round((new Date(ev.peak + 'T00:00Z') - today) / 86400000),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CELL TAG LOGIC ──────────────────────────────────────────────────────
|
||||||
|
// For timetable cell icons: given an active event and a row,
|
||||||
|
// should this hour show the event icon?
|
||||||
|
export function shouldShowCellTag(event, row) {
|
||||||
|
if (!event) return false;
|
||||||
|
if (event.type === 'promo') return false;
|
||||||
|
// Night-only events only badge night rows
|
||||||
|
if (event.nightOnly && row.elev >= -5) return false;
|
||||||
|
// Weather events: storm/frost badge all hours; heat badges daytime only
|
||||||
|
if (event.id === 'heat-spike' && row.elev < 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAIN EXPORT ─────────────────────────────────────────────────────────
|
||||||
|
// Call this once per render with today's rows and location.
|
||||||
|
// Returns the single highest-priority active event, or null.
|
||||||
|
//
|
||||||
|
// Priority order:
|
||||||
|
// 1. PROMO_OVERRIDE (manual)
|
||||||
|
// 2. Cosmic calendar events (sorted by priority: eclipse > meteor > planetary)
|
||||||
|
// 3. Weather events (heat > storm > frost > sunset > stargazing)
|
||||||
|
|
||||||
|
export function getActiveEvent(todayRows, location) {
|
||||||
|
// 1. Manual promo override
|
||||||
|
if (PROMO_OVERRIDE) return PROMO_OVERRIDE;
|
||||||
|
|
||||||
|
// 2. Cosmic calendar
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
const cosmicHit = COSMIC_CALENDAR.find(ev => todayStr >= ev.start && todayStr <= ev.end);
|
||||||
|
if (cosmicHit) return cosmicHit;
|
||||||
|
|
||||||
|
// 3. Weather-derived (needs forecast data)
|
||||||
|
if (!todayRows || todayRows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
checkHeatSpike(todayRows) ||
|
||||||
|
checkStorm(todayRows) ||
|
||||||
|
checkFrost(todayRows) ||
|
||||||
|
checkSunset(todayRows, location) ||
|
||||||
|
checkStargazing(todayRows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LENS OVERLAY RENDERER ───────────────────────────────────────────────
|
||||||
|
// Returns SVG group content for the event overlay in the big ScopeReticle.
|
||||||
|
// Rendered INSIDE the lens clip and BELOW the glass shine/shade layers.
|
||||||
|
// cx, cy = lens centre; lensR = lens radius.
|
||||||
|
|
||||||
|
|
||||||
|
export function getLensOverlaySVG(event, cx, cy, lensR) {
|
||||||
|
if (!event) return null;
|
||||||
|
const id = event.id;
|
||||||
|
|
||||||
|
if (event.emoji === '☄️') {
|
||||||
|
return [
|
||||||
|
'<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"/>`,
|
||||||
|
'</g>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === '🔴') {
|
||||||
|
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 (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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user