import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { COUNTER_METRICS, getCounterValue, formatCounterValue, type CounterMetric, } from '@/services/humanity-counters'; import { isDesktopRuntime } from '@/services/runtime'; /** * CountersPanel -- Worldometer-style ticking counters showing positive global metrics. * * Displays 6 metrics (births, trees, vaccines, graduates, books, renewable MW) * with values ticking via requestAnimationFrame. Values are calculated * from absolute time (seconds since midnight UTC * per-second rate) to avoid * drift across tabs, throttling, or background suspension. * * No API calls needed -- all data derived from hardcoded annual rates. */ export class CountersPanel extends Panel { private animFrameId: number | null = null; private valueElements: Map = new Map(); private readonly desktopMode = isDesktopRuntime(); private visibilityHandler: (() => void) | null = null; private lastDesktopUpdateAt = 0; private readonly desktopUpdateIntervalMs = 250; constructor() { super({ id: 'counters', title: 'Live Counters', trackActivity: false, infoTooltip: t('components.counters.infoTooltip') }); this.createCounterGrid(); if (this.desktopMode) { this.visibilityHandler = () => { if (document.hidden) { this.stopTicking(); return; } this.lastDesktopUpdateAt = 0; this.startTicking(); }; document.addEventListener('visibilitychange', this.visibilityHandler); } this.startTicking(); } /** * Build the 6 counter cards and insert them into the panel content area. */ private createCounterGrid(): void { const grid = document.createElement('div'); grid.className = 'counters-grid'; for (const metric of COUNTER_METRICS) { const card = this.createCounterCard(metric); grid.appendChild(card); } // Clear loading state and append the grid this.content.innerHTML = ''; this.content.appendChild(grid); } /** * Create a single counter card with icon, value, label, and source. */ private createCounterCard(metric: CounterMetric): HTMLElement { const card = document.createElement('div'); card.className = 'counter-card'; const icon = document.createElement('div'); icon.className = 'counter-icon'; icon.textContent = metric.icon; const value = document.createElement('div'); value.className = 'counter-value'; value.dataset.counter = metric.id; // Set initial value from absolute time value.textContent = formatCounterValue( getCounterValue(metric), metric.formatPrecision, ); const label = document.createElement('div'); label.className = 'counter-label'; label.textContent = metric.label; const source = document.createElement('div'); source.className = 'counter-source'; source.textContent = metric.source; card.appendChild(icon); card.appendChild(value); card.appendChild(label); card.appendChild(source); // Store reference for fast 60fps updates this.valueElements.set(metric.id, value); return card; } /** * Start the requestAnimationFrame animation loop. * Each tick recalculates all counter values from absolute time. */ public startTicking(): void { if (this.animFrameId !== null) return; // Already ticking if (this.desktopMode && document.hidden) return; this.animFrameId = requestAnimationFrame(this.tick); } private stopTicking(): void { if (this.animFrameId !== null) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; } } /** * Animation tick -- arrow function for correct `this` binding. * Updates all 6 counter values using textContent (not innerHTML). * Desktop runtime is throttled to reduce background CPU usage. */ private tick = (): void => { if (this.desktopMode) { const now = performance.now(); if ((now - this.lastDesktopUpdateAt) < this.desktopUpdateIntervalMs) { this.animFrameId = requestAnimationFrame(this.tick); return; } this.lastDesktopUpdateAt = now; } for (const metric of COUNTER_METRICS) { const el = this.valueElements.get(metric.id); if (el) { const value = getCounterValue(metric); el.textContent = formatCounterValue(value, metric.formatPrecision); } } this.animFrameId = requestAnimationFrame(this.tick); }; /** * Clean up animation frame and call parent destroy. */ public destroy(): void { this.stopTicking(); if (this.visibilityHandler) { document.removeEventListener('visibilitychange', this.visibilityHandler); this.visibilityHandler = null; } this.valueElements.clear(); super.destroy(); } }