File size: 4,834 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | /**
* Celebration Service
*
* Wraps canvas-confetti with milestone detection for species recovery
* announcements, renewable energy records, and similar positive breakthroughs.
*
* Design: "Warm, not birthday party" -- moderate particle counts (40-80),
* nature-inspired colors (greens, golds, blues), session-level deduplication
* so celebrations feel special, not repetitive.
*
* Respects prefers-reduced-motion: no animations when that media query matches.
*/
// canvas-confetti (~10KB) is only needed when a positive milestone actually fires —
// never at boot. Lazy-load + cache it on first celebration so it ships off the eager
// main entry. Bursts are fire-and-forget, so a one-tick load delay is imperceptible.
type ConfettiFn = typeof import('canvas-confetti');
type ConfettiOptions = Parameters<ConfettiFn>[0];
let confettiPromise: Promise<ConfettiFn> | null = null;
function loadConfetti(): Promise<ConfettiFn> {
if (!confettiPromise) {
confettiPromise = import('canvas-confetti')
// canvas-confetti is `export =`; the runtime namespace wraps it under .default
// (esbuild/Vite CJS interop), while the type is the function itself.
.then((m) => ((m as { default?: ConfettiFn }).default ?? m) as ConfettiFn)
.catch((err) => { confettiPromise = null; throw err; });
}
return confettiPromise;
}
function fireConfetti(options: ConfettiOptions): void {
void loadConfetti().then((confetti) => { confetti(options); }).catch(() => { /* best-effort */ });
}
// ---- Types ----
export interface MilestoneData {
speciesRecoveries?: Array<{ name: string; status: string }>;
renewablePercent?: number;
newSpeciesCount?: number;
}
// ---- Constants ----
/** Checked once at module load -- if user prefers reduced motion, skip all celebrations. */
const REDUCED_MOTION = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
/** Nature-inspired warm palette matching the happy theme. */
const WARM_COLORS = ['#6B8F5E', '#C4A35A', '#7BA5C4', '#8BAF7A', '#E8B96E', '#7FC4C4'];
/** Session-level dedup set. Stores milestone keys that have already been celebrated this session. */
const celebrated = new Set<string>();
// ---- Public API ----
/**
* Fire a confetti celebration with warm, nature-inspired colors.
*
* @param type - 'milestone' for species recovery (40 particles, single burst),
* 'record' for renewable energy records (80 particles, double burst).
*/
export function celebrate(type: 'milestone' | 'record' = 'milestone'): void {
if (REDUCED_MOTION) return;
if (type === 'milestone') {
fireConfetti({
particleCount: 40,
spread: 60,
origin: { y: 0.7 },
colors: WARM_COLORS,
disableForReducedMotion: true,
});
} else {
// 'record' -- double burst for extra emphasis
fireConfetti({
particleCount: 80,
spread: 90,
origin: { y: 0.6 },
colors: WARM_COLORS,
disableForReducedMotion: true,
});
setTimeout(() => {
fireConfetti({
particleCount: 80,
spread: 90,
origin: { y: 0.6 },
colors: WARM_COLORS,
disableForReducedMotion: true,
});
}, 300);
}
}
/**
* Check data for milestone events and fire a celebration if a new one is found.
*
* Only fires ONE celebration per call (first matching milestone wins) to prevent
* multiple confetti bursts overlapping. Session dedup (Set in memory) ensures
* the same milestone is never celebrated twice in a single browser session.
*/
export function checkMilestones(data: MilestoneData): void {
// --- Species recovery milestone ---
if (data.speciesRecoveries) {
for (const species of data.speciesRecoveries) {
const status = species.status.toLowerCase();
if (status === 'recovered' || status === 'stabilized') {
const key = `species:${species.name}`;
if (!celebrated.has(key)) {
celebrated.add(key);
celebrate('milestone');
return; // one celebration per call
}
}
}
}
// --- Renewable energy record (every 5% threshold) ---
if (data.renewablePercent != null && data.renewablePercent > 0) {
const threshold = Math.floor(data.renewablePercent / 5) * 5;
const key = `renewable:${threshold}`;
if (!celebrated.has(key)) {
celebrated.add(key);
celebrate('record');
return;
}
}
// --- New species count ---
if (data.newSpeciesCount != null && data.newSpeciesCount > 0) {
const key = `species-count:${data.newSpeciesCount}`;
if (!celebrated.has(key)) {
celebrated.add(key);
celebrate('milestone');
return;
}
}
}
/**
* Clear the celebrated set. Exported for testing purposes.
*/
export function resetCelebrations(): void {
celebrated.clear();
}
|