|
| 1 | +import { Panel } from './Panel'; |
| 2 | +import type { ThermalEscalationCluster, ThermalEscalationWatch } from '@/services/thermal-escalation'; |
| 3 | +import { escapeHtml } from '@/utils/sanitize'; |
| 4 | + |
| 5 | +export class ThermalEscalationPanel extends Panel { |
| 6 | + private clusters: ThermalEscalationCluster[] = []; |
| 7 | + private fetchedAt: Date | null = null; |
| 8 | + private summary: ThermalEscalationWatch['summary'] = { |
| 9 | + clusterCount: 0, |
| 10 | + elevatedCount: 0, |
| 11 | + spikeCount: 0, |
| 12 | + persistentCount: 0, |
| 13 | + conflictAdjacentCount: 0, |
| 14 | + highRelevanceCount: 0, |
| 15 | + }; |
| 16 | + private onLocationClick?: (lat: number, lon: number) => void; |
| 17 | + |
| 18 | + constructor() { |
| 19 | + super({ |
| 20 | + id: 'thermal-escalation', |
| 21 | + title: 'Thermal Escalation', |
| 22 | + showCount: true, |
| 23 | + trackActivity: true, |
| 24 | + infoTooltip: 'Seeded FIRMS/VIIRS thermal anomaly clusters with baseline comparison, persistence tracking, and strategic context. This panel answers where thermal activity is abnormal and which clusters may signal conflict, industrial disruption, or escalation.', |
| 25 | + }); |
| 26 | + this.showLoading('Loading thermal data...'); |
| 27 | + |
| 28 | + this.content.addEventListener('click', (e) => { |
| 29 | + const row = (e.target as HTMLElement).closest<HTMLElement>('.thermal-row'); |
| 30 | + if (!row) return; |
| 31 | + const lat = Number(row.dataset.lat); |
| 32 | + const lon = Number(row.dataset.lon); |
| 33 | + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onLocationClick?.(lat, lon); |
| 34 | + }); |
| 35 | + } |
| 36 | + |
| 37 | + public setLocationClickHandler(handler: (lat: number, lon: number) => void): void { |
| 38 | + this.onLocationClick = handler; |
| 39 | + } |
| 40 | + |
| 41 | + public setData(data: ThermalEscalationWatch): void { |
| 42 | + this.clusters = data.clusters; |
| 43 | + this.fetchedAt = data.fetchedAt; |
| 44 | + this.summary = data.summary; |
| 45 | + this.setCount(data.clusters.length); |
| 46 | + this.render(); |
| 47 | + } |
| 48 | + |
| 49 | + private render(): void { |
| 50 | + if (this.clusters.length === 0) { |
| 51 | + this.setContent('<div class="panel-empty">No thermal escalation clusters detected.</div>'); |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + const rows = this.clusters.map((c) => { |
| 56 | + const age = formatAge(c.lastDetectedAt); |
| 57 | + const persistence = c.persistenceHours >= 24 ? `${Math.round(c.persistenceHours / 24)}d` : `${Math.round(c.persistenceHours)}h`; |
| 58 | + const frpDisplay = c.totalFrp >= 1000 ? `${(c.totalFrp / 1000).toFixed(1)}k` : c.totalFrp.toFixed(0); |
| 59 | + const deltaSign = c.countDelta > 0 ? '+' : ''; |
| 60 | + const flags = [ |
| 61 | + `<span class="thermal-badge thermal-status thermal-status-${c.status}">${escapeHtml(c.status)}</span>`, |
| 62 | + `<span class="thermal-badge thermal-confidence thermal-confidence-${c.confidence}">${escapeHtml(c.confidence)}</span>`, |
| 63 | + c.strategicRelevance === 'high' ? '<span class="thermal-badge thermal-flag-strategic">strategic</span>' : '', |
| 64 | + c.context === 'conflict_adjacent' ? '<span class="thermal-badge thermal-flag-conflict">conflict-adjacent</span>' : '', |
| 65 | + c.context === 'energy_adjacent' ? '<span class="thermal-badge thermal-flag-energy">energy-adjacent</span>' : '', |
| 66 | + c.context === 'industrial' ? '<span class="thermal-badge thermal-flag-industrial">industrial</span>' : '', |
| 67 | + ].filter(Boolean).join(''); |
| 68 | + const assets = c.nearbyAssets.length > 0 |
| 69 | + ? `<div class="thermal-assets">${c.nearbyAssets.slice(0, 3).map(a => escapeHtml(a)).join(' · ')}</div>` |
| 70 | + : ''; |
| 71 | + return ` |
| 72 | + <tr class="thermal-row" data-lat="${c.lat}" data-lon="${c.lon}"> |
| 73 | + <td class="thermal-location"> |
| 74 | + <div class="thermal-location-name">${escapeHtml(c.regionLabel)}</div> |
| 75 | + <div class="thermal-location-meta">${escapeHtml(c.countryName)} · ${c.observationCount} obs · ${c.uniqueSourceCount} src</div> |
| 76 | + <div class="thermal-location-flags">${flags}</div> |
| 77 | + ${assets} |
| 78 | + </td> |
| 79 | + <td class="thermal-frp">${escapeHtml(frpDisplay)} MW</td> |
| 80 | + <td class="thermal-delta">${escapeHtml(`${deltaSign}${Math.round(c.countDelta)}`)} · z${c.zScore.toFixed(1)}</td> |
| 81 | + <td class="thermal-persistence">${escapeHtml(persistence)}</td> |
| 82 | + <td class="thermal-observed">${escapeHtml(age)}</td> |
| 83 | + </tr> |
| 84 | + `; |
| 85 | + }).join(''); |
| 86 | + |
| 87 | + const summary = ` |
| 88 | + <div class="thermal-summary"> |
| 89 | + <div class="thermal-summary-card"> |
| 90 | + <span class="thermal-summary-label">Clusters</span> |
| 91 | + <span class="thermal-summary-value">${this.summary.clusterCount}</span> |
| 92 | + </div> |
| 93 | + <div class="thermal-summary-card thermal-summary-card-elevated"> |
| 94 | + <span class="thermal-summary-label">Elevated</span> |
| 95 | + <span class="thermal-summary-value">${this.summary.elevatedCount}</span> |
| 96 | + </div> |
| 97 | + <div class="thermal-summary-card thermal-summary-card-spike"> |
| 98 | + <span class="thermal-summary-label">Spikes</span> |
| 99 | + <span class="thermal-summary-value">${this.summary.spikeCount}</span> |
| 100 | + </div> |
| 101 | + <div class="thermal-summary-card thermal-summary-card-persistent"> |
| 102 | + <span class="thermal-summary-label">Persistent</span> |
| 103 | + <span class="thermal-summary-value">${this.summary.persistentCount}</span> |
| 104 | + </div> |
| 105 | + <div class="thermal-summary-card thermal-summary-card-conflict"> |
| 106 | + <span class="thermal-summary-label">Conflict-Adj</span> |
| 107 | + <span class="thermal-summary-value">${this.summary.conflictAdjacentCount}</span> |
| 108 | + </div> |
| 109 | + <div class="thermal-summary-card thermal-summary-card-strategic"> |
| 110 | + <span class="thermal-summary-label">High Relevance</span> |
| 111 | + <span class="thermal-summary-value">${this.summary.highRelevanceCount}</span> |
| 112 | + </div> |
| 113 | + </div> |
| 114 | + `; |
| 115 | + |
| 116 | + const footer = this.fetchedAt && this.fetchedAt.getTime() > 0 |
| 117 | + ? `Updated ${this.fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` |
| 118 | + : ''; |
| 119 | + |
| 120 | + this.setContent(` |
| 121 | + <div class="thermal-panel-content"> |
| 122 | + ${summary} |
| 123 | + <table class="thermal-table"> |
| 124 | + <thead> |
| 125 | + <tr> |
| 126 | + <th>Cluster</th> |
| 127 | + <th>FRP</th> |
| 128 | + <th>Delta</th> |
| 129 | + <th>Duration</th> |
| 130 | + <th>Last Seen</th> |
| 131 | + </tr> |
| 132 | + </thead> |
| 133 | + <tbody>${rows}</tbody> |
| 134 | + </table> |
| 135 | + <div class="thermal-footer">${escapeHtml(footer)}</div> |
| 136 | + </div> |
| 137 | + `); |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +function formatAge(date: Date): string { |
| 142 | + const ageMs = Date.now() - date.getTime(); |
| 143 | + if (ageMs < 60 * 60 * 1000) { |
| 144 | + const mins = Math.max(1, Math.floor(ageMs / (60 * 1000))); |
| 145 | + return `${mins}m ago`; |
| 146 | + } |
| 147 | + if (ageMs < 24 * 60 * 60 * 1000) { |
| 148 | + const hours = Math.max(1, Math.floor(ageMs / (60 * 60 * 1000))); |
| 149 | + return `${hours}h ago`; |
| 150 | + } |
| 151 | + const days = Math.floor(ageMs / (24 * 60 * 60 * 1000)); |
| 152 | + if (days < 30) return `${days}d ago`; |
| 153 | + return date.toISOString().slice(0, 10); |
| 154 | +} |
0 commit comments