Skip to content

Commit 5ada764

Browse files
authored
feat(ui): add Thermal Escalation panel (koala73#1786)
* feat(ui): add Thermal Escalation panel The thermal-escalation seed, RPC handler, service client, data-loader, and bootstrap key all existed but the panel component was never created. This adds ThermalEscalationPanel with summary cards, cluster table, and map click-to-center, plus wires it into panels.ts and panel-layout.ts. * fix(data): use shouldLoad() for sanctions/radiation data loading The sanctions-pressure and radiation-watch panels used this.ctx.panels[key] to decide whether to load data. For lazy-loaded panels (radiation-watch), the panel isn't in ctx.panels yet when loadData() runs, so data loading was skipped entirely and the panel stayed at "Loading..." forever. Switch to shouldLoad() which checks viewport proximity via DEFAULT_PANELS config, matching the pattern used by thermal-escalation and other priority-2 panels. * fix(ui): display thermal countDelta as raw count, not percent countDelta is an absolute observation-count difference, not a ratio. Multiplying by 100 and appending % inflated every value by 100x.
1 parent b585dea commit 5ada764

5 files changed

Lines changed: 166 additions & 2 deletions

File tree

src/app/data-loader.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,10 +479,10 @@ export class DataLoaderManager implements AppModule {
479479
if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
480480
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) });
481481
if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) });
482-
if (SITE_VARIANT !== 'happy' && (this.ctx.panels['sanctions-pressure'] || this.ctx.mapLayers.sanctions)) {
482+
if (SITE_VARIANT !== 'happy' && (shouldLoad('sanctions-pressure') || this.ctx.mapLayers.sanctions)) {
483483
tasks.push({ name: 'sanctions', task: runGuarded('sanctions', () => this.loadSanctionsPressure()) });
484484
}
485-
if (SITE_VARIANT !== 'happy' && (this.ctx.panels['radiation-watch'] || this.ctx.mapLayers.radiationWatch)) {
485+
if (SITE_VARIANT !== 'happy' && (shouldLoad('radiation-watch') || this.ctx.mapLayers.radiationWatch)) {
486486
tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) });
487487
}
488488

src/app/panel-layout.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,14 @@ export class PanelLayoutManager implements AppModule {
707707
}),
708708
);
709709

710+
this.lazyPanel('thermal-escalation', () =>
711+
import('@/components/ThermalEscalationPanel').then(m => {
712+
const p = new m.ThermalEscalationPanel();
713+
p.setLocationClickHandler((lat: number, lon: number) => { this.ctx.map?.setCenter(lat, lon, 4); });
714+
return p;
715+
}),
716+
);
717+
710718
const _wmKeyPresent = getSecretState('WORLDMONITOR_API_KEY').present;
711719
const _lockPanels = this.ctx.isDesktopApp && !_wmKeyPresent;
712720

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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+
}

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export * from './SupplyChainPanel';
5050
export * from './SecurityAdvisoriesPanel';
5151
export * from './SanctionsPressurePanel';
5252
export * from './RadiationWatchPanel';
53+
export * from './ThermalEscalationPanel';
5354
export * from './OrefSirensPanel';
5455
export * from './TelegramIntelPanel';
5556
export * from './BreakingNewsBanner';

src/config/panels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
6464
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
6565
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 2 },
6666
'radiation-watch': { name: 'Radiation Watch', enabled: true, priority: 2 },
67+
'thermal-escalation': { name: 'Thermal Escalation', enabled: true, priority: 2 },
6768
'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
6869
'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, ...(_desktop && { premium: 'locked' as const }) },
6970
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },

0 commit comments

Comments
 (0)