Files
worldmonitor/src/components/CountryDeepDivePanel.ts
Elie Habib cffdcf8052 fix(energy): V6 review findings (7 issues across 5 PRs) (#2861)
* fix(energy): V6 review findings — flow availability, ember proto bool, LNG stale/blocking, fixture accuracy

- Fix 1: Only show "Flow data unavailable" for chokepoints with PortWatch
  flow coverage (hormuz, malacca, suez, bab_el_mandeb), not all corridors
- Fix 2: Correct proto comment on data_available field 9 to document
  gas mode and both mode behavior
- Fix 3: Add ember_available bool field 50 to GetCountryEnergyProfile proto,
  set server-side from spine.electricity or direct Ember key fallback
- Fix 4: Ember fallback reads energy:ember:v1:{code} when spine exists but
  has no electricity block (or fossilShare is absent)
- Fix 6: Add IEA upstream fixture matching actual API response shape,
  with golden test parsing through seeder parseRecord/buildIndex
- Fix 7: Add PortWatch ArcGIS fixture with all attributes.* fields used
  by buildHistory, with golden test validating parsed output

* fix(energy): add emberAvailable to energy gate; use real buildHistory in portwatch test

* fix(energy): add Ember render block to renderEnergyProfile for Ember-only countries

* chore: regenerate OpenAPI specs after proto comment update
2026-04-09 12:40:13 +04:00

1676 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CountryBriefSignals } from '@/types';
import { getSourcePropagandaRisk, getSourceTier } from '@/config/feeds';
import { getCountryCentroid, ME_STRIKE_BOUNDS } from '@/services/country-geometry';
import type { CountryScore } from '@/services/country-instability';
import { t } from '@/services/i18n';
import { getNearbyInfrastructure } from '@/services/related-assets';
import type { PredictionMarket } from '@/services/prediction';
import type { AssetType, NewsItem, RelatedAsset } from '@/types';
import { sanitizeUrl } from '@/utils/sanitize';
import { formatIntelBrief } from '@/utils/format-intel-brief';
import { getCSSColor } from '@/utils';
import { toFlagEmoji } from '@/utils/country-flag';
import { PORTS } from '@/config/ports';
import { haversineDistanceKm } from '@/services/related-assets';
import type {
CountryBriefPanel,
CountryIntelData,
StockIndexData,
CountryDeepDiveSignalDetails,
CountryDeepDiveSignalItem,
CountryDeepDiveMilitarySummary,
CountryDeepDiveEconomicIndicator,
CountryFactsData,
CountryEnergyProfileData,
CountryPortActivityData,
} from './CountryBriefPanel';
import type { MapContainer } from './MapContainer';
import { ResilienceWidget } from './ResilienceWidget';
import { toApiUrl } from '@/services/runtime';
import type { ComputeEnergyShockScenarioResponse, ProductImpact } from '@/generated/client/worldmonitor/intelligence/v1/service_client';
type ThreatLevel = 'critical' | 'high' | 'medium' | 'low' | 'info';
type TrendDirection = 'up' | 'down' | 'flat';
const INFRA_TYPES: AssetType[] = ['pipeline', 'cable', 'datacenter', 'base', 'nuclear'];
const INFRA_ICONS: Record<AssetType, string> = {
pipeline: '🛢️',
cable: '🌐',
datacenter: '🖥️',
base: '🛡️',
nuclear: '☢️',
};
const SEVERITY_ORDER: Record<ThreatLevel, number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
info: 0,
};
export class CountryDeepDivePanel implements CountryBriefPanel {
private panel: HTMLElement;
private content: HTMLElement;
private closeButton: HTMLButtonElement;
private currentCode: string | null = null;
private currentName: string | null = null;
private isMaximizedState = false;
private onCloseCallback?: () => void;
private onStateChangeCallback?: (state: { visible: boolean; maximized: boolean }) => void;
private onShareStory?: (code: string, name: string) => void;
private onExportImage?: (code: string, name: string) => void;
private map: MapContainer | null;
private abortController: AbortController = new AbortController();
private lastFocusedElement: HTMLElement | null = null;
private economicIndicators: CountryDeepDiveEconomicIndicator[] = [];
private infrastructureByType = new Map<AssetType, RelatedAsset[]>();
private maximizeButton: HTMLButtonElement | null = null;
private currentHeadlineCount = 0;
private signalsBody: HTMLElement | null = null;
private signalBreakdownBody: HTMLElement | null = null;
private signalRecentBody: HTMLElement | null = null;
private newsBody: HTMLElement | null = null;
private militaryBody: HTMLElement | null = null;
private infrastructureBody: HTMLElement | null = null;
private economicBody: HTMLElement | null = null;
private marketsBody: HTMLElement | null = null;
private briefBody: HTMLElement | null = null;
private timelineBody: HTMLElement | null = null;
private scoreCard: HTMLElement | null = null;
private factsBody: HTMLElement | null = null;
private resilienceWidget: ResilienceWidget | null = null;
private energyBody: HTMLElement | null = null;
private maritimeBody: HTMLElement | null = null;
private readonly handleGlobalKeydown = (event: KeyboardEvent): void => {
if (!this.panel.classList.contains('active')) return;
if (event.key === 'Escape') {
event.preventDefault();
if (this.isMaximizedState) {
this.minimize();
} else {
this.hide();
}
return;
}
if (event.key !== 'Tab') return;
const focusable = this.getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
const current = document.activeElement as HTMLElement | null;
if (event.shiftKey && current === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
};
constructor(map: MapContainer | null = null) {
this.map = map;
this.panel = this.getOrCreatePanel();
const content = this.panel.querySelector<HTMLElement>('#deep-dive-content');
const closeButton = this.panel.querySelector<HTMLButtonElement>('#deep-dive-close');
if (!content || !closeButton) {
throw new Error('Country deep-dive panel structure is invalid');
}
this.content = content;
this.closeButton = closeButton;
this.closeButton.addEventListener('click', () => this.hide());
this.panel.addEventListener('click', (e) => {
if (this.isMaximizedState && !(e.target as HTMLElement).closest('.panel-content')) {
this.minimize();
}
});
}
public setMap(map: MapContainer | null): void {
this.map = map;
}
public setShareStoryHandler(handler: (code: string, name: string) => void): void {
this.onShareStory = handler;
}
public setExportImageHandler(handler: (code: string, name: string) => void): void {
this.onExportImage = handler;
}
public get signal(): AbortSignal {
return this.abortController.signal;
}
public showLoading(): void {
this.currentCode = '__loading__';
this.currentName = null;
this.renderLoading();
this.open();
}
public showGeoError(onRetry: () => void): void {
this.currentCode = '__error__';
this.currentName = null;
this.resetPanelContent();
const wrapper = this.el('div', 'cdp-geo-error');
wrapper.append(
this.el('div', 'cdp-geo-error-icon', '\u26A0\uFE0F'),
this.el('div', 'cdp-geo-error-msg', t('countryBrief.geocodeFailed')),
);
const actions = this.el('div', 'cdp-geo-error-actions');
const retryBtn = this.el('button', 'cdp-geo-error-retry', t('countryBrief.retryBtn')) as HTMLButtonElement;
retryBtn.type = 'button';
retryBtn.addEventListener('click', () => onRetry(), { once: true });
const closeBtn = this.el('button', 'cdp-geo-error-close', t('countryBrief.closeBtn')) as HTMLButtonElement;
closeBtn.type = 'button';
closeBtn.addEventListener('click', () => this.hide(), { once: true });
actions.append(retryBtn, closeBtn);
wrapper.append(actions);
this.content.append(wrapper);
}
public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void {
this.abortController.abort();
this.abortController = new AbortController();
this.currentCode = code;
this.currentName = country;
this.economicIndicators = [];
this.infrastructureByType.clear();
this.renderSkeleton(country, code, score, signals);
this.open();
}
public hide(): void {
this.destroyResilienceWidget();
if (this.isMaximizedState) {
this.isMaximizedState = false;
this.panel.classList.remove('maximized');
if (this.maximizeButton) this.maximizeButton.textContent = '\u26F6';
}
this.abortController.abort();
this.close();
this.currentCode = null;
this.currentName = null;
this.onCloseCallback?.();
this.onStateChangeCallback?.({ visible: false, maximized: false });
}
public onClose(cb: () => void): void {
this.onCloseCallback = cb;
}
public onStateChange(cb: (state: { visible: boolean; maximized: boolean }) => void): void {
this.onStateChangeCallback = cb;
}
public maximize(): void {
if (this.isMaximizedState) return;
this.isMaximizedState = true;
this.panel.classList.add('maximized');
if (this.maximizeButton) this.maximizeButton.textContent = '\u229F';
this.onStateChangeCallback?.({ visible: true, maximized: true });
}
public minimize(): void {
if (!this.isMaximizedState) return;
this.isMaximizedState = false;
this.panel.classList.remove('maximized');
if (this.maximizeButton) this.maximizeButton.textContent = '\u26F6';
this.onStateChangeCallback?.({ visible: true, maximized: false });
}
public getIsMaximized(): boolean {
return this.isMaximizedState;
}
public isVisible(): boolean {
return this.panel.classList.contains('active');
}
public getCode(): string | null {
return this.currentCode;
}
public getName(): string | null {
return this.currentName;
}
public getTimelineMount(): HTMLElement | null {
return this.timelineBody;
}
public updateSignalDetails(details: CountryDeepDiveSignalDetails): void {
if (!this.signalBreakdownBody || !this.signalRecentBody) return;
this.renderSignalBreakdown(details);
this.renderRecentSignals(details.recentHigh);
}
public updateNews(headlines: NewsItem[]): void {
if (!this.newsBody) return;
this.newsBody.replaceChildren();
const items = [...headlines]
.sort((a, b) => {
const sa = SEVERITY_ORDER[this.toThreatLevel(a.threat?.level)];
const sb = SEVERITY_ORDER[this.toThreatLevel(b.threat?.level)];
if (sb !== sa) return sb - sa;
return this.toTimestamp(b.pubDate) - this.toTimestamp(a.pubDate);
})
.slice(0, 10);
this.currentHeadlineCount = items.length;
if (items.length === 0) {
this.newsBody.append(this.makeEmpty(t('countryBrief.noNews')));
return;
}
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const row = this.el('a', 'cdp-news-item');
row.id = `cdp-news-${i + 1}`;
const href = sanitizeUrl(item.link);
if (href) {
row.setAttribute('href', href);
row.setAttribute('target', '_blank');
row.setAttribute('rel', 'noopener');
} else {
row.removeAttribute('href');
}
const top = this.el('div', 'cdp-news-top');
const tier = item.tier ?? getSourceTier(item.source);
top.append(this.badge(`Tier ${tier}`, `cdp-tier-badge tier-${Math.max(1, Math.min(4, tier))}`));
const severity = this.toThreatLevel(item.threat?.level);
const levelKey = severity === 'info' ? 'low' : severity === 'medium' ? 'moderate' : severity;
const severityLabel = t(`countryBrief.levels.${levelKey}`);
top.append(this.badge(severityLabel.toUpperCase(), `cdp-severity-badge sev-${severity}`));
const risk = getSourcePropagandaRisk(item.source);
if (risk.stateAffiliated) {
top.append(this.badge(`State-affiliated: ${risk.stateAffiliated}`, 'cdp-state-badge'));
}
const title = this.el('div', 'cdp-news-title', this.decodeEntities(item.title));
const meta = this.el('div', 'cdp-news-meta', `${item.source}${this.formatRelativeTime(item.pubDate)}`);
row.append(top, title, meta);
if (i >= 5) {
const wrapper = this.el('div', 'cdp-expanded-only');
wrapper.append(row);
this.newsBody.append(wrapper);
} else {
this.newsBody.append(row);
}
}
}
public updateMilitaryActivity(summary: CountryDeepDiveMilitarySummary): void {
if (!this.militaryBody) return;
this.militaryBody.replaceChildren();
const stats = this.el('div', 'cdp-military-grid');
stats.append(
this.metric(t('countryBrief.ownFlights'), String(summary.ownFlights), 'cdp-chip-neutral'),
this.metric(t('countryBrief.foreignFlights'), String(summary.foreignFlights), summary.foreignFlights > 0 ? 'cdp-chip-danger' : 'cdp-chip-neutral'),
this.metric(t('countryBrief.navalVessels'), String(summary.nearbyVessels), 'cdp-chip-neutral'),
this.metric(t('countryBrief.foreignPresence'), summary.foreignPresence ? t('countryBrief.detected') : t('countryBrief.notDetected'), summary.foreignPresence ? 'cdp-chip-danger' : 'cdp-chip-success'),
);
this.militaryBody.append(stats);
const basesTitle = this.el('div', 'cdp-subtitle', t('countryBrief.nearestBases'));
this.militaryBody.append(basesTitle);
if (summary.nearestBases.length === 0) {
this.militaryBody.append(this.makeEmpty(t('countryBrief.noBasesNearby')));
return;
}
const list = this.el('ul', 'cdp-base-list');
for (const base of summary.nearestBases.slice(0, 3)) {
const item = this.el('li', 'cdp-base-item');
const left = this.el('span', 'cdp-base-name', base.name);
const right = this.el('span', 'cdp-base-distance', `${Math.round(base.distanceKm)} km`);
item.append(left, right);
list.append(item);
}
this.militaryBody.append(list);
}
public updateInfrastructure(countryCode: string): void {
if (!this.infrastructureBody) return;
this.infrastructureBody.replaceChildren();
const centroid = getCountryCentroid(countryCode, ME_STRIKE_BOUNDS);
if (!centroid) {
this.infrastructureBody.append(this.makeEmpty(t('countryBrief.noGeometry')));
return;
}
const assets = getNearbyInfrastructure(centroid.lat, centroid.lon, INFRA_TYPES);
if (assets.length === 0) {
this.infrastructureBody.append(this.makeEmpty(t('countryBrief.noInfrastructure')));
return;
}
this.infrastructureByType.clear();
for (const type of INFRA_TYPES) {
const matches = assets.filter((asset) => asset.type === type);
this.infrastructureByType.set(type, matches);
}
const grid = this.el('div', 'cdp-infra-grid');
for (const type of INFRA_TYPES) {
const list = this.infrastructureByType.get(type) ?? [];
if (list.length === 0) continue;
const card = this.el('button', 'cdp-infra-card');
card.setAttribute('type', 'button');
card.addEventListener('click', () => this.highlightInfrastructure(type));
const icon = this.el('span', 'cdp-infra-icon', INFRA_ICONS[type]);
const label = this.el('span', 'cdp-infra-label', t(`countryBrief.infra.${type}`));
const count = this.el('span', 'cdp-infra-count', String(list.length));
card.append(icon, label, count);
grid.append(card);
}
this.infrastructureBody.append(grid);
const expandedDetails = this.el('div', 'cdp-expanded-only');
for (const type of INFRA_TYPES) {
const list = this.infrastructureByType.get(type) ?? [];
if (list.length === 0) continue;
const typeLabel = this.el('div', 'cdp-subtitle', `${INFRA_ICONS[type]} ${t(`countryBrief.infra.${type}`)}`);
expandedDetails.append(typeLabel);
const ul = this.el('ul', 'cdp-base-list');
for (const asset of list.slice(0, 5)) {
const li = this.el('li', 'cdp-base-item');
li.append(
this.el('span', 'cdp-base-name', asset.name),
this.el('span', 'cdp-base-distance', `${Math.round(asset.distanceKm)} km`),
);
ul.append(li);
}
expandedDetails.append(ul);
}
const nearbyPorts = PORTS
.map((port) => ({
...port,
distanceKm: haversineDistanceKm(centroid.lat, centroid.lon, port.lat, port.lon),
}))
.filter((port) => port.distanceKm <= 1500)
.sort((a, b) => a.distanceKm - b.distanceKm)
.slice(0, 5);
if (nearbyPorts.length > 0) {
const portsTitle = this.el('div', 'cdp-subtitle', `\u2693 ${t('countryBrief.nearbyPorts')}`);
expandedDetails.append(portsTitle);
const portList = this.el('ul', 'cdp-base-list');
for (const port of nearbyPorts) {
const li = this.el('li', 'cdp-base-item');
li.append(
this.el('span', 'cdp-base-name', `${port.name} (${port.type})`),
this.el('span', 'cdp-base-distance', `${Math.round(port.distanceKm)} km`),
);
portList.append(li);
}
expandedDetails.append(portList);
}
this.infrastructureBody.append(expandedDetails);
}
public updateEconomicIndicators(indicators: CountryDeepDiveEconomicIndicator[]): void {
this.economicIndicators = indicators;
this.renderEconomicIndicators();
}
public updateCountryFacts(data: CountryFactsData): void {
if (!this.factsBody) return;
this.factsBody.replaceChildren();
if (!data.headOfState && !data.wikipediaSummary && data.population === 0 && !data.capital) {
this.factsBody.append(this.makeEmpty(t('countryBrief.noFacts')));
return;
}
if (data.wikipediaThumbnailUrl) {
const img = this.el('img', 'cdp-facts-thumbnail');
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.src = sanitizeUrl(data.wikipediaThumbnailUrl);
this.factsBody.append(img);
}
if (data.wikipediaSummary) {
const summaryText = data.wikipediaSummary.length > 300
? data.wikipediaSummary.slice(0, 300) + '...'
: data.wikipediaSummary;
this.factsBody.append(this.el('p', 'cdp-facts-summary', summaryText));
}
const grid = this.el('div', 'cdp-facts-grid');
const popStr = data.population >= 1_000_000_000
? `${(data.population / 1_000_000_000).toFixed(1)}B`
: data.population >= 1_000_000
? `${(data.population / 1_000_000).toFixed(1)}M`
: data.population.toLocaleString();
grid.append(this.factItem(t('countryBrief.facts.population'), popStr));
grid.append(this.factItem(t('countryBrief.facts.capital'), data.capital));
grid.append(this.factItem(t('countryBrief.facts.area'), `${data.areaSqKm.toLocaleString()} km\u00B2`));
const rawTitle = data.headOfStateTitle || '';
const hosLabel = rawTitle.length > 30 ? t('countryBrief.facts.headOfState') : (rawTitle || t('countryBrief.facts.headOfState'));
grid.append(this.factItem(hosLabel, data.headOfState));
grid.append(this.factItem(t('countryBrief.facts.languages'), data.languages.join(', ')));
grid.append(this.factItem(t('countryBrief.facts.currencies'), data.currencies.join(', ')));
this.factsBody.append(grid);
}
public updateEnergyProfile(data: CountryEnergyProfileData): void {
if (!this.energyBody) return;
this.renderEnergyProfile(data);
this.resilienceWidget?.setEnergyMix(data);
}
private renderEnergyProfile(data: CountryEnergyProfileData): void {
if (!this.energyBody) return;
this.energyBody.replaceChildren();
const hasAny = data.mixAvailable || data.jodiOilAvailable || data.ieaStocksAvailable
|| data.jodiGasAvailable || data.gasStorageAvailable || data.electricityAvailable
|| data.emberAvailable;
if (!hasAny) {
this.energyBody.append(this.makeEmpty('Energy data unavailable for this country.'));
return;
}
if (data.mixAvailable) {
const segments: Array<{ label: string; color: string; value: number }> = [
{ label: 'Coal', color: '#6b6b6b', value: data.coalShare },
{ label: 'Oil', color: '#8B4513', value: data.oilShare },
{ label: 'Gas', color: '#D2691E', value: data.gasShare },
{ label: 'Nuclear', color: '#6A0DAD', value: data.nuclearShare },
{ label: 'Hydro', color: '#1E90FF', value: data.hydroShare },
{ label: 'Wind', color: '#87CEEB', value: data.windShare },
{ label: 'Solar', color: '#FFD700', value: data.solarShare },
{ label: 'Other renew', color: '#32CD32', value: Math.max(0, data.renewShare - data.windShare - data.solarShare - data.hydroShare) },
];
const total = segments.reduce((s, seg) => s + seg.value, 0);
const norm = total > 0 ? total : 1;
const bar = this.el('div', '');
bar.style.cssText = 'display:flex;width:100%;height:12px;border-radius:4px;overflow:hidden;margin-bottom:8px';
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const span = this.el('span', '');
span.style.cssText = `width:${pct}%;background:${seg.color}`;
bar.append(span);
}
this.energyBody.append(bar);
const legend = this.el('div', '');
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const row = this.el('div', '');
row.style.cssText = 'font-size:11px;color:#aaa;display:flex;gap:4px;align-items:center';
const dot = this.el('span', '');
dot.textContent = '\u25CF';
dot.style.color = seg.color;
const label = this.el('span', '', `${seg.label} ${Math.round(pct)}%`);
row.append(dot, label);
legend.append(row);
}
this.energyBody.append(legend);
const src = this.el('div', 'cdp-economic-source', `Data: ${data.mixYear} (OWID)`);
this.energyBody.append(src);
}
if (data.mixAvailable) {
const importPct = data.importShare;
const color = importPct > 60 ? '#ef4444'
: importPct >= 30 ? '#f59e0b'
: importPct > 0 ? '#22c55e'
: '#6b7280';
const labelText = importPct <= 0 ? 'Net exporter' : `${Math.round(importPct)}%`;
const row = this.el('div', '');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-top:6px';
const label = this.el('span', 'cdp-economic-source', 'Import dependency:');
const badge = this.el('span', '');
badge.style.cssText = `background:${color};color:#fff;padding:1px 6px;border-radius:3px;font-size:11px`;
badge.textContent = labelText;
row.append(label, badge);
this.energyBody.append(row);
}
if (data.jodiOilAvailable) {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
section.append(this.el('div', 'cdp-subtitle', `Oil Product Supply (${data.jodiOilDataMonth})`));
const table = this.el('table', '');
table.style.cssText = 'width:100%;font-size:11px;border-collapse:collapse';
const thead = this.el('thead', '');
const hr = this.el('tr', '');
for (const h of ['Product', 'Demand', 'Imports']) {
const th = this.el('th', '');
th.textContent = h;
th.style.cssText = 'text-align:left;color:#aaa;padding:2px 4px';
hr.append(th);
}
thead.append(hr);
table.append(thead);
const tbody = this.el('tbody', '');
const rows: Array<{ label: string; demand: number; imports: number }> = [
{ label: 'Gasoline', demand: data.gasolineDemandKbd, imports: data.gasolineImportsKbd },
{ label: 'Diesel', demand: data.dieselDemandKbd, imports: data.dieselImportsKbd },
{ label: 'Jet fuel', demand: data.jetDemandKbd, imports: data.jetImportsKbd },
{ label: 'LPG', demand: data.lpgDemandKbd, imports: data.lpgImportsKbd },
];
for (const r of rows) {
const tr = this.el('tr', '');
const fmtKbd = (v: number) => v > 0 ? `${v} kbd` : '\u2014';
for (const val of [r.label, fmtKbd(r.demand), fmtKbd(r.imports)]) {
const td = this.el('td', '');
td.textContent = val;
td.style.cssText = 'padding:2px 4px';
tr.append(td);
}
tbody.append(tr);
}
if (data.crudeImportsKbd > 0) {
const tr = this.el('tr', '');
for (const val of ['Crude', '\u2014', `${data.crudeImportsKbd} kbd`]) {
const td = this.el('td', '');
td.textContent = val;
td.style.cssText = 'padding:2px 4px';
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
section.append(table);
section.append(this.el('div', 'cdp-economic-source', 'Source: JODI'));
this.energyBody.append(section);
}
if (data.jodiGasAvailable) {
const totalBcm = Math.round(data.gasTotalDemandTj / 36000);
const lngShare = data.gasLngShare;
const pipeShare = Math.max(0, 100 - lngShare);
const lngColor = lngShare > 80 ? '#ef4444' : lngShare >= 40 ? '#f59e0b' : '#22c55e';
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
const row = this.el('div', '');
row.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px';
const gasLabel = this.el('span', '', `Gas demand: ${totalBcm} BCM/yr`);
const lngBadge = this.el('span', '');
lngBadge.style.cssText = `background:${lngColor};color:#fff;padding:1px 5px;border-radius:3px;font-size:11px`;
lngBadge.textContent = `LNG ${lngShare.toFixed(0)}%`;
const pipeBadge = this.el('span', '');
pipeBadge.style.cssText = 'background:#6b7280;color:#fff;padding:1px 5px;border-radius:3px;font-size:11px';
pipeBadge.textContent = `Pipeline ${pipeShare.toFixed(0)}%`;
row.append(gasLabel, lngBadge, pipeBadge);
section.append(row);
this.energyBody.append(section);
}
if (data.ieaStocksAvailable) {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
if (data.ieaNetExporter) {
const msg = this.el('div', '');
msg.style.cssText = 'color:#22c55e;font-size:12px';
msg.textContent = 'IEA oil stocks: Net Exporter';
section.append(msg);
} else {
const coverLabel = this.el('div', '');
coverLabel.style.cssText = 'font-size:12px;margin-bottom:4px;display:flex;align-items:center;gap:6px';
const txt = this.el('span', '', `IEA Oil Stocks: ${data.ieaDaysOfCover} days of cover`);
coverLabel.append(txt);
if (data.ieaBelowObligation) {
const warn = this.el('span', '');
warn.style.cssText = 'background:#ef4444;color:#fff;padding:1px 5px;border-radius:3px;font-size:11px';
warn.textContent = 'Below 90-day obligation';
coverLabel.append(warn);
}
section.append(coverLabel);
const barOuter = this.el('div', '');
barOuter.style.cssText = 'position:relative;width:100%;height:8px;border-radius:4px;background:#374151;overflow:visible';
const fillPct = Math.min(data.ieaDaysOfCover / 180 * 100, 100);
const fill = this.el('div', '');
fill.style.cssText = `width:${fillPct}%;height:100%;background:#3b82f6;border-radius:4px`;
const marker = this.el('div', '');
marker.style.cssText = 'position:absolute;top:-2px;left:50%;width:2px;height:12px;background:#f59e0b;transform:translateX(-50%)';
barOuter.append(fill, marker);
section.append(barOuter);
}
this.energyBody.append(section);
}
const hasLiveSignals = data.gasStorageAvailable || data.electricityAvailable;
if (hasLiveSignals) {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
section.append(this.el('div', 'cdp-subtitle', 'Live Signals'));
if (data.gasStorageAvailable) {
const row = this.el('div', '');
row.style.cssText = 'font-size:12px;margin-bottom:4px';
const deltaSign = data.gasStorageChange1d >= 0 ? '+' : '';
row.textContent = `EU Gas Storage: ${data.gasStorageFillPct.toFixed(1)}% (${deltaSign}${data.gasStorageChange1d.toFixed(1)}% today, ${data.gasStorageTrend}) as of ${data.gasStorageDate}`;
section.append(row);
}
if (data.electricityAvailable) {
const row = this.el('div', '');
row.style.cssText = 'font-size:12px';
row.textContent = `Electricity: \u20AC${data.electricityPriceMwh.toFixed(1)}/MWh as of ${data.electricityDate}`;
section.append(row);
}
this.energyBody.append(section);
}
if (data.emberAvailable) {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
const monthLabel = data.emberDataMonth || 'latest';
section.append(this.el('div', 'cdp-subtitle', `Monthly Generation Mix (${monthLabel})`));
const segments: Array<{ label: string; color: string; value: number }> = [
{ label: 'Fossil', color: '#8B4513', value: data.emberFossilShare },
{ label: 'Renewable', color: '#22c55e', value: data.emberRenewShare },
{ label: 'Nuclear', color: '#6A0DAD', value: data.emberNuclearShare },
];
const total = segments.reduce((acc, seg) => acc + seg.value, 0);
const norm = total > 0 ? total : 1;
const bar = this.el('div', '');
bar.style.cssText = 'display:flex;width:100%;height:10px;border-radius:4px;overflow:hidden;margin-bottom:6px';
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const span = this.el('span', '');
span.style.cssText = `width:${pct}%;background:${seg.color}`;
bar.append(span);
}
section.append(bar);
const legend = this.el('div', '');
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const row = this.el('div', '');
row.style.cssText = 'font-size:11px;color:#aaa;display:flex;gap:4px;align-items:center';
const dot = this.el('span', '');
dot.textContent = '\u25CF';
dot.style.color = seg.color;
const label = this.el('span', '', `${seg.label} ${Math.round(pct)}%`);
row.append(dot, label);
legend.append(row);
}
section.append(legend);
if (data.emberCoalShare > 0 || data.emberGasShare > 0) {
const breakdown = this.el('div', '');
breakdown.style.cssText = 'font-size:11px;color:#aaa;margin-top:4px';
const parts: string[] = [];
if (data.emberCoalShare > 0) parts.push(`Coal ${Math.round(data.emberCoalShare)}%`);
if (data.emberGasShare > 0) parts.push(`Gas ${Math.round(data.emberGasShare)}%`);
breakdown.textContent = `Fossil breakdown: ${parts.join(', ')}`;
section.append(breakdown);
}
if (data.emberDemandTwh > 0) {
const demand = this.el('div', '');
demand.style.cssText = 'font-size:11px;color:#aaa;margin-top:2px';
demand.textContent = `Total demand: ${data.emberDemandTwh.toFixed(1)} TWh`;
section.append(demand);
}
section.append(this.el('div', 'cdp-economic-source', 'Source: Ember Climate (monthly)'));
this.energyBody!.append(section);
}
if (data.jodiOilAvailable || data.jodiGasAvailable) {
this.energyBody.append(this.renderShockScenarioWidget());
}
}
private renderShockScenarioWidget(): HTMLElement {
const wrapper = this.el('div', '');
wrapper.style.cssText = 'margin-top:12px;border-top:1px solid #374151;padding-top:10px';
const title = this.el('div', 'cdp-subtitle', 'Shock Scenario');
wrapper.append(title);
const controls = this.el('div', '');
controls.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:6px';
const chokepointSelect = this.el('select', '') as HTMLSelectElement;
chokepointSelect.style.cssText = 'background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:3px 6px;font-size:11px';
const chopkpts: Array<[string, string]> = [['hormuz', 'Strait of Hormuz'], ['malacca', 'Strait of Malacca'], ['suez', 'Suez Canal'], ['babelm', 'Bab el-Mandeb']];
for (const [cpValue, cpLabel] of chopkpts) {
const opt = this.el('option', '') as HTMLOptionElement;
opt.value = cpValue;
opt.textContent = cpLabel;
chokepointSelect.append(opt);
}
const disruptionSelect = this.el('select', '') as HTMLSelectElement;
disruptionSelect.style.cssText = 'background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:3px 6px;font-size:11px';
for (const pct of [25, 50, 75, 100]) {
const opt = this.el('option', '') as HTMLOptionElement;
opt.value = String(pct);
opt.textContent = `${pct}% disruption`;
disruptionSelect.append(opt);
}
const fuelModeSelect = this.el('select', '') as HTMLSelectElement;
fuelModeSelect.style.cssText = disruptionSelect.style.cssText;
for (const [val, label] of [['oil', 'Oil'], ['gas', 'Gas (LNG)'], ['both', 'Both']] as const) {
const opt = this.el('option', '') as HTMLOptionElement;
opt.value = val;
opt.textContent = label;
fuelModeSelect.append(opt);
}
const computeBtn = this.el('button', 'cdp-action-btn') as HTMLButtonElement;
computeBtn.type = 'button';
computeBtn.textContent = 'Compute';
computeBtn.style.cssText += ';font-size:11px;padding:3px 8px';
const coverageBadge = this.el('span', '');
coverageBadge.style.cssText = 'display:none;font-size:10px;padding:2px 5px;border-radius:3px;font-weight:600';
controls.append(chokepointSelect, disruptionSelect, fuelModeSelect, computeBtn, coverageBadge);
wrapper.append(controls);
const resultArea = this.el('div', '');
resultArea.style.cssText = 'margin-top:8px';
wrapper.append(resultArea);
computeBtn.addEventListener('click', () => {
const code = this.currentCode;
if (!code) return;
const chokepoint = chokepointSelect.value;
const disruption = parseInt(disruptionSelect.value, 10);
resultArea.replaceChildren();
const loading = this.el('div', 'cdp-economic-source', 'Computing\u2026');
resultArea.append(loading);
computeBtn.disabled = true;
coverageBadge.style.display = 'none';
coverageBadge.textContent = '';
const url = toApiUrl(`/api/intelligence/v1/compute-energy-shock?country_code=${encodeURIComponent(code)}&chokepoint_id=${encodeURIComponent(chokepoint)}&disruption_pct=${disruption}&fuel_mode=${encodeURIComponent(fuelModeSelect.value)}`);
globalThis.fetch(url)
.then((r) => r.json() as Promise<ComputeEnergyShockScenarioResponse>)
.then((result) => {
resultArea.replaceChildren();
resultArea.append(this.renderShockResult(result));
const lvl = result.coverageLevel ?? '';
if (lvl) {
const colors: Record<string, string> = {
full: 'background:#15803d;color:#dcfce7',
partial: 'background:#b45309;color:#fef3c7',
unsupported: 'background:#b91c1c;color:#fee2e2',
};
coverageBadge.style.cssText = `display:inline-block;font-size:10px;padding:2px 5px;border-radius:3px;font-weight:600;${colors[lvl] ?? ''}`;
coverageBadge.textContent = lvl;
} else {
coverageBadge.style.display = 'none';
}
})
.catch(() => {
resultArea.replaceChildren();
resultArea.append(this.el('div', 'cdp-economic-source', 'Failed to compute scenario.'));
})
.finally(() => {
computeBtn.disabled = false;
});
});
return wrapper;
}
private renderShockResult(result: ComputeEnergyShockScenarioResponse): HTMLElement {
const container = this.el('div', '');
if (!result.dataAvailable && !(result as any).gasImpact?.dataAvailable) {
container.append(this.el('div', 'cdp-economic-source', result.assessment));
return container;
}
if (result.degraded) {
const warn = this.el('div', '');
warn.style.cssText = 'font-size:10px;color:#f59e0b;margin-bottom:6px;padding:3px 6px;background:#1c1400;border-radius:3px';
warn.textContent = 'Live flow data unavailable — using historical baseline';
container.append(warn);
}
if (result.products.length > 0) {
const table = this.el('table', '');
table.style.cssText = 'width:100%;font-size:11px;border-collapse:collapse;margin-bottom:6px';
const thead = this.el('thead', '');
const hr = this.el('tr', '');
const headers = ['Product', 'Demand', 'Loss', 'Deficit'];
if (result.portwatchCoverage && result.liveFlowRatio != null) headers.push('Flow');
for (const h of headers) {
const th = this.el('th', '');
th.textContent = h;
th.style.cssText = 'text-align:left;color:#aaa;padding:2px 4px';
hr.append(th);
}
thead.append(hr);
table.append(thead);
const tbody = this.el('tbody', '');
for (const p of result.products as ProductImpact[]) {
const tr = this.el('tr', '');
const defColor = p.deficitPct > 30 ? '#ef4444' : p.deficitPct > 10 ? '#f59e0b' : '#22c55e';
const cells = [
p.product,
`${p.demandKbd} kbd`,
`${p.outputLossKbd} kbd`,
`${p.deficitPct.toFixed(1)}%`,
];
if (result.portwatchCoverage && result.liveFlowRatio != null) {
cells.push(`${Math.round(result.liveFlowRatio * 100)}%`);
}
cells.forEach((val, i) => {
const td = this.el('td', '');
td.textContent = val;
td.style.cssText = `padding:2px 4px${i === 3 ? `;color:${defColor}` : ''}`;
tr.append(td);
});
tbody.append(tr);
}
table.append(tbody);
container.append(table);
}
if (result.ieaStocksCoverage) {
const coverRow = this.el('div', 'cdp-economic-source');
coverRow.style.cssText += ';margin-bottom:4px';
let coverText: string;
if (result.effectiveCoverDays < 0) {
coverText = 'Net oil exporter — strategic reserve cover not applicable';
} else if (result.effectiveCoverDays > 0) {
coverText = `IEA cover: ~${result.effectiveCoverDays} days under this scenario`;
} else {
coverText = 'IEA cover: 0 days (reserves exhausted under this scenario)';
}
coverRow.textContent = coverText;
container.append(coverRow);
}
const assessmentEl = this.el('div', '');
assessmentEl.style.cssText = 'font-size:11px;color:#d1d5db;line-height:1.4;margin-top:4px';
assessmentEl.textContent = result.assessment;
container.append(assessmentEl);
if (result.limitations && result.limitations.length > 0) {
const details = this.el('details', '') as HTMLDetailsElement;
details.style.cssText = 'margin-top:6px;font-size:10px;color:#9ca3af';
const summary = this.el('summary', '');
summary.style.cssText = 'cursor:pointer;color:#6b7280';
summary.textContent = 'Model assumptions';
details.append(summary);
const ul = this.el('ul', '');
ul.style.cssText = 'margin:4px 0 0 12px;padding:0;list-style:disc';
for (const lim of result.limitations) {
const li = this.el('li', '');
li.textContent = lim;
ul.append(li);
}
details.append(ul);
container.append(details);
}
if (result.gasImpact?.dataAvailable) {
const gi = result.gasImpact;
const gasSection = this.el('div', '');
gasSection.style.cssText = 'margin-top:10px;border-top:1px solid #374151;padding-top:8px';
const gasTitle = this.el('div', '');
gasTitle.style.cssText = 'font-size:11px;font-weight:600;color:#e5e7eb;margin-bottom:4px';
gasTitle.textContent = 'Gas / LNG Impact';
gasSection.append(gasTitle);
const metrics = this.el('div', 'cdp-economic-source');
metrics.textContent = `LNG share: ${(gi.lngShareOfImports * 100).toFixed(0)}% | Disruption: ${gi.lngDisruptionTj.toFixed(0)} TJ | Deficit: ${gi.deficitPct.toFixed(1)}%`;
gasSection.append(metrics);
if (gi.storage) {
const s = gi.storage;
const storageDiv = this.el('div', 'cdp-economic-source');
storageDiv.style.cssText += ';margin-top:4px';
storageDiv.textContent = `Gas storage: ${s.fillPct.toFixed(1)}% full (${s.gasTwh.toFixed(0)} TWh), buffer ~${s.bufferDays} days, ${s.trend} (${s.scope})`;
gasSection.append(storageDiv);
}
const srcBadge = this.el('div', '');
srcBadge.style.cssText = 'font-size:10px;color:#9ca3af;margin-top:2px';
srcBadge.textContent = `Source: ${gi.dataSource === 'gie_daily' ? 'GIE (daily, Europe)' : 'JODI (monthly, global)'}`;
gasSection.append(srcBadge);
const gasAssess = this.el('div', '');
gasAssess.style.cssText = 'font-size:11px;color:#d1d5db;line-height:1.4;margin-top:4px';
gasAssess.textContent = gi.assessment;
gasSection.append(gasAssess);
container.append(gasSection);
}
return container;
}
public updateMaritimeActivity(data: CountryPortActivityData): void {
if (!this.maritimeBody) return;
if (!data.available || data.ports.length === 0) {
this.maritimeBody.parentElement?.remove();
this.maritimeBody = null;
return;
}
this.maritimeBody.replaceChildren();
const table = this.el('table', 'cdp-maritime-table');
const thead = this.el('thead');
const headerRow = this.el('tr');
for (const col of ['Port', 'Tanker Calls (30d)', 'Trend', 'Import DWT', 'Export DWT']) {
const th = this.el('th', '', col);
headerRow.append(th);
}
thead.append(headerRow);
table.append(thead);
const tbody = this.el('tbody');
for (const port of data.ports) {
const tr = this.el('tr');
const nameCell = this.el('td', 'cdp-maritime-port');
nameCell.textContent = port.portName;
if (port.anomalySignal) {
const badge = this.el('span', 'cdp-maritime-anomaly', '\u26A0');
badge.title = 'Traffic anomaly detected';
nameCell.append(badge);
}
tr.append(nameCell);
const callsCell = this.el('td', '', String(port.tankerCalls30d));
tr.append(callsCell);
const trendCell = this.el('td', 'cdp-maritime-trend');
const pct = port.trendDeltaPct;
if (pct !== 0 || port.tankerCalls30d > 0) {
const sign = pct >= 0 ? '+' : '';
trendCell.textContent = `${sign}${pct.toFixed(1)}%`;
trendCell.classList.add(pct >= 0 ? 'cdp-trend-up' : 'cdp-trend-down');
} else {
trendCell.textContent = 'n/a';
}
tr.append(trendCell);
const fmtDwt = (v: number): string =>
v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1_000 ? `${(v / 1_000).toFixed(0)}K` : String(Math.round(v));
tr.append(this.el('td', '', fmtDwt(port.importTankerDwt)));
tr.append(this.el('td', '', fmtDwt(port.exportTankerDwt)));
tbody.append(tr);
}
table.append(tbody);
this.maritimeBody.append(table);
if (data.fetchedAt) {
const dateStr = data.fetchedAt.split('T')[0] ?? data.fetchedAt;
const footer = this.el('div', 'cdp-section-source', `Source: IMF PortWatch \u00B7 as of ${dateStr}`);
this.maritimeBody.append(footer);
}
}
private factItem(label: string, value: string): HTMLElement {
const wrapper = this.el('div', 'cdp-fact-item');
wrapper.append(this.el('div', 'cdp-fact-label', label));
wrapper.append(this.el('div', '', value));
return wrapper;
}
public updateScore(score: CountryScore | null, _signals: CountryBriefSignals): void {
if (!this.scoreCard) return;
// Partial DOM update: score number, level color, trend, component bars only
const top = this.scoreCard.firstElementChild as HTMLElement | null;
while (this.scoreCard.childElementCount > 1) {
this.scoreCard.lastElementChild?.remove();
}
if (top) {
const updatedEl = top.querySelector('.cdp-updated');
if (updatedEl) updatedEl.textContent = `Updated ${this.shortDate(score?.lastUpdated ?? new Date())}`;
}
if (score) {
const band = this.ciiBand(score.score);
const scoreRow = this.el('div', 'cdp-score-row');
const value = this.el('div', `cdp-score-value cii-${band}`, `${score.score}/100`);
const trend = this.el('div', 'cdp-trend', `${this.trendArrow(score.trend)} ${score.trend}`);
scoreRow.append(value, trend);
this.scoreCard.append(scoreRow);
this.scoreCard.append(this.renderComponentBars(score.components));
} else {
this.scoreCard.append(this.makeEmpty(t('countryBrief.ciiUnavailable')));
}
}
public updateStock(data: StockIndexData): void {
if (!data.available) {
this.renderEconomicIndicators();
return;
}
const delta = Number.parseFloat(data.weekChangePercent);
const trend: TrendDirection = Number.isFinite(delta)
? delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'
: 'flat';
const base = this.economicIndicators.filter((item) => item.label !== 'Stock Index');
base.unshift({
label: 'Stock Index',
value: `${data.indexName}: ${data.price} ${data.currency}`,
trend,
source: 'Market Service',
});
this.economicIndicators = base.slice(0, 3);
this.renderEconomicIndicators();
}
public updateMarkets(markets: PredictionMarket[]): void {
if (!this.marketsBody) return;
this.marketsBody.replaceChildren();
if (markets.length === 0) {
this.marketsBody.append(this.makeEmpty(t('countryBrief.noMarkets')));
return;
}
for (const market of markets.slice(0, 5)) {
const item = this.el('div', 'cdp-market-item');
const top = this.el('div', 'cdp-market-top');
const title = this.el('div', 'cdp-market-title', market.title);
top.append(title);
const link = sanitizeUrl(market.url || '');
if (link) {
const anchor = this.el('a', 'cdp-market-link', 'Open');
anchor.setAttribute('href', link);
anchor.setAttribute('target', '_blank');
anchor.setAttribute('rel', 'noopener');
top.append(anchor);
}
const prob = this.el('div', 'cdp-market-prob', `Probability: ${Math.round(market.yesPrice)}%`);
const meta = this.el('div', 'cdp-market-meta', market.endDate ? `Ends ${this.shortDate(market.endDate)}` : 'Active');
item.append(top, prob, meta);
const expanded = this.el('div', 'cdp-expanded-only');
if (market.volume != null) {
expanded.append(this.el('div', 'cdp-market-volume', `Volume: $${market.volume.toLocaleString()}`));
}
const yesPercent = Math.round(market.yesPrice);
const noPercent = 100 - yesPercent;
const bar = this.el('div', 'cdp-market-bar');
const barYes = this.el('div', 'cdp-market-bar-yes');
barYes.style.width = `${yesPercent}%`;
const barNo = this.el('div', 'cdp-market-bar-no');
barNo.style.width = `${noPercent}%`;
bar.append(barYes, barNo);
expanded.append(bar);
item.append(expanded);
this.marketsBody.append(item);
}
}
public updateBrief(data: CountryIntelData): void {
if (!this.briefBody || data.code !== this.currentCode) return;
this.briefBody.replaceChildren();
if (data.error || data.skipped || !data.brief) {
this.briefBody.append(this.makeEmpty(data.error || data.reason || t('countryBrief.assessmentUnavailable')));
return;
}
const summaryHtml = this.formatBrief(this.summarizeBrief(data.brief), 0);
const text = this.el('div', 'cdp-assessment-text cdp-summary-only');
text.innerHTML = summaryHtml;
const metaTokens: string[] = [];
if (data.cached) metaTokens.push('Cached');
if (data.fallback) metaTokens.push('Fallback');
if (data.generatedAt) metaTokens.push(`Updated ${new Date(data.generatedAt).toLocaleTimeString()}`);
const meta = this.el('div', 'cdp-assessment-meta', metaTokens.join(' • '));
this.briefBody.append(text, meta);
const expandedBrief = this.el('div', 'cdp-expanded-only');
const fullText = this.el('div', 'cdp-assessment-text');
fullText.innerHTML = this.formatBrief(data.brief, this.currentHeadlineCount);
expandedBrief.append(fullText);
this.briefBody.append(expandedBrief);
}
private renderLoading(): void {
this.resetPanelContent();
const loading = this.el('div', 'cdp-loading');
loading.append(
this.el('div', 'cdp-loading-title', t('countryBrief.identifying')),
this.el('div', 'cdp-loading-line'),
this.el('div', 'cdp-loading-line cdp-loading-line-short'),
);
this.content.append(loading);
}
private renderSkeleton(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void {
this.resetPanelContent();
const shell = this.el('div', 'cdp-shell');
const header = this.el('header', 'cdp-header');
const left = this.el('div', 'cdp-header-left');
const flag = this.el('span', 'cdp-flag', CountryDeepDivePanel.toFlagEmoji(code));
const titleWrap = this.el('div', 'cdp-title-wrap');
const name = this.el('h2', 'cdp-country-name', country);
const subtitle = this.el('div', 'cdp-country-subtitle', `${code.toUpperCase()} • Country Intelligence`);
titleWrap.append(name, subtitle);
left.append(flag, titleWrap);
const right = this.el('div', 'cdp-header-right');
const maxBtn = this.el('button', 'cdp-maximize-btn', '\u26F6') as HTMLButtonElement;
maxBtn.setAttribute('type', 'button');
maxBtn.setAttribute('aria-label', 'Toggle maximize');
maxBtn.addEventListener('click', () => {
if (this.isMaximizedState) this.minimize();
else this.maximize();
});
this.maximizeButton = maxBtn;
const shareBtn = this.el('button', 'cdp-action-btn cdp-share-btn') as HTMLButtonElement;
shareBtn.setAttribute('type', 'button');
shareBtn.setAttribute('aria-label', t('components.countryBrief.shareLink'));
shareBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a2 2 0 002 2h12a2 2 0 002-2v-7"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>';
shareBtn.addEventListener('click', () => {
if (!this.currentCode || !this.currentName) return;
const url = `${window.location.origin}/?c=${encodeURIComponent(this.currentCode)}`;
navigator.clipboard.writeText(url).then(() => {
const orig = shareBtn.innerHTML;
shareBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => { shareBtn.innerHTML = orig; }, 1500);
}).catch(() => {});
});
const storyButton = this.el('button', 'cdp-action-btn', 'Story') as HTMLButtonElement;
storyButton.setAttribute('type', 'button');
storyButton.addEventListener('click', () => {
if (this.onShareStory && this.currentCode && this.currentName) {
this.onShareStory(this.currentCode, this.currentName);
}
});
const exportButton = this.el('button', 'cdp-action-btn', 'Export') as HTMLButtonElement;
exportButton.setAttribute('type', 'button');
exportButton.addEventListener('click', () => {
if (this.onExportImage && this.currentCode && this.currentName) {
this.onExportImage(this.currentCode, this.currentName);
}
});
right.append(shareBtn, maxBtn, storyButton, exportButton);
header.append(left, right);
const scoreCard = this.el('section', 'cdp-card cdp-score-card');
this.scoreCard = scoreCard;
const top = this.el('div', 'cdp-score-top');
const label = this.el('span', 'cdp-score-label', t('countryBrief.instabilityIndex'));
const updated = this.el('span', 'cdp-updated', `Updated ${this.shortDate(score?.lastUpdated ?? new Date())}`);
top.append(label, updated);
scoreCard.append(top);
if (score) {
const band = this.ciiBand(score.score);
const scoreRow = this.el('div', 'cdp-score-row');
const value = this.el('div', `cdp-score-value cii-${band}`, `${score.score}/100`);
const trend = this.el('div', 'cdp-trend', `${this.trendArrow(score.trend)} ${score.trend}`);
scoreRow.append(value, trend);
scoreCard.append(scoreRow);
scoreCard.append(this.renderComponentBars(score.components));
} else {
scoreCard.append(this.makeEmpty(t('countryBrief.ciiUnavailable')));
}
this.resilienceWidget = new ResilienceWidget(code);
const summaryGrid = this.el('div', 'cdp-summary-grid');
summaryGrid.append(scoreCard, this.resilienceWidget.getElement());
const bodyGrid = this.el('div', 'cdp-grid');
const [signalsCard, signalBody] = this.sectionCard(t('countryBrief.activeSignals'));
const [timelineCard, timelineBody] = this.sectionCard(t('countryBrief.timeline'));
const [newsCard, newsBody] = this.sectionCard(t('countryBrief.topNews'));
const [militaryCard, militaryBody] = this.sectionCard(t('countryBrief.militaryActivity'));
const [infraCard, infraBody] = this.sectionCard(t('countryBrief.infrastructure'));
const [economicCard, economicBody] = this.sectionCard(t('countryBrief.economicIndicators'));
const [marketsCard, marketsBody] = this.sectionCard(t('countryBrief.predictionMarkets'));
const [briefCard, briefBody] = this.sectionCard(t('countryBrief.intelBrief'));
const [factsCard, factsBody] = this.sectionCard(t('countryBrief.countryFacts'));
this.factsBody = factsBody;
factsBody.append(this.makeLoading(t('countryBrief.loadingFacts')));
const factsExpanded = this.el('div', 'cdp-expanded-only');
factsExpanded.append(factsCard);
const [energyCard, energyBody] = this.sectionCard('Energy Profile', 'Oil import dependency, chokepoint exposure, and energy shock data from JODI, IEA, and PortWatch.');
this.energyBody = energyBody;
energyBody.append(this.makeLoading('Loading energy data\u2026'));
const [maritimeCard, maritimeBody] = this.sectionCard('Maritime Activity', 'Port-level tanker call volume and import/export cargo weight over 30 days. ⚠ badge = port running below 50% of its 30-day baseline. Source: IMF PortWatch.');
this.maritimeBody = maritimeBody;
maritimeBody.append(this.makeLoading('Loading port activity\u2026'));
this.signalsBody = signalBody;
this.timelineBody = timelineBody;
this.timelineBody.classList.add('cdp-timeline-mount');
this.newsBody = newsBody;
this.militaryBody = militaryBody;
this.infrastructureBody = infraBody;
this.economicBody = economicBody;
this.marketsBody = marketsBody;
this.briefBody = briefBody;
this.renderInitialSignals(signals);
newsBody.append(this.makeLoading('Loading country headlines…'));
militaryBody.append(this.makeLoading('Loading flights, vessels, and nearby bases…'));
infraBody.append(this.makeLoading('Computing nearby critical infrastructure…'));
economicBody.append(this.makeLoading('Loading available indicators…'));
marketsBody.append(this.makeLoading(t('countryBrief.loadingMarkets')));
briefBody.append(this.makeLoading(t('countryBrief.generatingBrief')));
bodyGrid.append(briefCard, factsExpanded, energyCard, maritimeCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, marketsCard);
shell.append(header, summaryGrid, bodyGrid);
this.content.append(shell);
}
private destroyResilienceWidget(): void {
this.resilienceWidget?.destroy();
this.resilienceWidget = null;
}
private resetPanelContent(): void {
this.destroyResilienceWidget();
this.scoreCard = null;
this.energyBody = null;
this.maritimeBody = null;
this.content.replaceChildren();
}
private renderInitialSignals(signals: CountryBriefSignals): void {
if (!this.signalsBody) return;
this.signalsBody.replaceChildren();
const chips = this.el('div', 'cdp-signal-chips');
this.addSignalChip(chips, signals.criticalNews, t('countryBrief.chips.criticalNews'), '🚨', 'conflict');
this.addSignalChip(chips, signals.protests, t('countryBrief.chips.protests'), '📢', 'protest');
this.addSignalChip(chips, signals.militaryFlights, t('countryBrief.chips.militaryAir'), '✈️', 'military');
this.addSignalChip(chips, signals.militaryVessels, t('countryBrief.chips.navalVessels'), '⚓', 'military');
this.addSignalChip(chips, signals.outages, t('countryBrief.chips.outages'), '🌐', 'outage');
this.addSignalChip(chips, signals.aisDisruptions, t('countryBrief.chips.aisDisruptions'), '🚢', 'outage');
this.addSignalChip(chips, signals.satelliteFires, t('countryBrief.chips.satelliteFires'), '🔥', 'climate');
this.addSignalChip(chips, signals.radiationAnomalies, 'Radiation anomalies', '☢️', 'outage');
this.addSignalChip(chips, signals.temporalAnomalies, t('countryBrief.chips.temporalAnomalies'), '⏱️', 'outage');
this.addSignalChip(chips, signals.cyberThreats, t('countryBrief.chips.cyberThreats'), '🛡️', 'conflict');
this.addSignalChip(chips, signals.earthquakes, t('countryBrief.chips.earthquakes'), '🌍', 'quake');
if (signals.displacementOutflow > 0) {
const fmt = signals.displacementOutflow >= 1_000_000
? `${(signals.displacementOutflow / 1_000_000).toFixed(1)}M`
: `${(signals.displacementOutflow / 1000).toFixed(0)}K`;
chips.append(this.makeSignalChip(`🌊 ${fmt} ${t('countryBrief.chips.displaced')}`, 'displacement'));
}
this.addSignalChip(chips, signals.climateStress, t('countryBrief.chips.climateStress'), '🌡️', 'climate');
this.addSignalChip(chips, signals.conflictEvents, t('countryBrief.chips.conflictEvents'), '⚔️', 'conflict');
this.addSignalChip(chips, signals.activeStrikes, t('countryBrief.chips.activeStrikes'), '💥', 'conflict');
if (signals.travelAdvisories > 0 && signals.travelAdvisoryMaxLevel) {
const advLabel = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? t('countryBrief.chips.doNotTravel')
: signals.travelAdvisoryMaxLevel === 'reconsider' ? t('countryBrief.chips.reconsiderTravel')
: t('countryBrief.chips.exerciseCaution');
chips.append(this.makeSignalChip(`⚠️ ${signals.travelAdvisories} ${t('countryBrief.chips.advisory')}: ${advLabel}`, 'advisory'));
}
this.addSignalChip(chips, signals.orefSirens, t('countryBrief.chips.activeSirens'), '🚨', 'conflict');
this.addSignalChip(chips, signals.orefHistory24h, t('countryBrief.chips.sirens24h'), '🕓', 'conflict');
this.addSignalChip(chips, signals.aviationDisruptions, t('countryBrief.chips.aviationDisruptions'), '🚫', 'outage');
this.addSignalChip(chips, signals.gpsJammingHexes, t('countryBrief.chips.gpsJammingZones'), '📡', 'outage');
this.signalsBody.append(chips);
this.signalBreakdownBody = this.el('div', 'cdp-signal-breakdown');
this.signalRecentBody = this.el('div', 'cdp-signal-recent');
this.signalsBody.append(this.signalBreakdownBody, this.signalRecentBody);
const seeded: CountryDeepDiveSignalDetails = {
critical: signals.criticalNews + Math.max(0, signals.activeStrikes),
high: signals.militaryFlights + signals.militaryVessels + signals.protests,
medium: signals.outages + signals.cyberThreats + signals.aisDisruptions + signals.radiationAnomalies,
low: signals.earthquakes + signals.temporalAnomalies + signals.satelliteFires,
recentHigh: [],
};
this.renderSignalBreakdown(seeded);
this.signalRecentBody.append(this.makeLoading('Loading top high-severity signals…'));
}
private addSignalChip(container: HTMLElement, count: number, label: string, icon: string, cls: string): void {
if (count <= 0) return;
container.append(this.makeSignalChip(`${icon} ${count} ${label}`, cls));
}
private makeSignalChip(text: string, cls: string): HTMLElement {
return this.el('span', `cdp-signal-chip chip-${cls}`, text);
}
private renderComponentBars(components: CountryScore['components']): HTMLElement {
const wrap = this.el('div', 'cdp-components');
const items = [
{ label: t('countryBrief.components.unrest'), value: components.unrest, icon: '📢' },
{ label: t('countryBrief.components.conflict'), value: components.conflict, icon: '⚔' },
{ label: t('countryBrief.components.security'), value: components.security, icon: '🛡️' },
{ label: t('countryBrief.components.information'), value: components.information, icon: '📡' },
];
for (const item of items) {
const row = this.el('div', 'cdp-score-row');
const icon = this.el('span', 'cdp-comp-icon', item.icon);
const label = this.el('span', 'cdp-comp-label', item.label);
const barOuter = this.el('div', 'cdp-comp-bar');
const pct = Math.min(100, Math.max(0, item.value));
const color = pct >= 70 ? getCSSColor('--semantic-critical')
: pct >= 50 ? getCSSColor('--semantic-high')
: pct >= 30 ? getCSSColor('--semantic-elevated')
: getCSSColor('--semantic-normal');
const barFill = this.el('div', 'cdp-comp-fill');
barFill.style.width = `${pct}%`;
barFill.style.background = color;
barOuter.append(barFill);
const val = this.el('span', 'cdp-comp-val', String(Math.round(item.value)));
row.append(icon, label, barOuter, val);
wrap.append(row);
}
return wrap;
}
private renderSignalBreakdown(details: CountryDeepDiveSignalDetails): void {
if (!this.signalBreakdownBody) return;
this.signalBreakdownBody.replaceChildren();
this.signalBreakdownBody.append(
this.metric(t('countryBrief.levels.critical'), String(details.critical), 'cdp-chip-danger'),
this.metric(t('countryBrief.levels.high'), String(details.high), 'cdp-chip-warn'),
this.metric(t('countryBrief.levels.moderate'), String(details.medium), 'cdp-chip-neutral'),
this.metric(t('countryBrief.levels.low'), String(details.low), 'cdp-chip-success'),
);
}
private renderRecentSignals(items: CountryDeepDiveSignalItem[]): void {
if (!this.signalRecentBody) return;
this.signalRecentBody.replaceChildren();
if (items.length === 0) {
this.signalRecentBody.append(this.makeEmpty(t('countryBrief.noSignals')));
return;
}
for (const item of items.slice(0, 3)) {
const row = this.el('div', 'cdp-signal-item');
const line = this.el('div', 'cdp-signal-line');
line.append(
this.badge(item.type, 'cdp-type-badge'),
this.badge(item.severity.toUpperCase(), `cdp-severity-badge sev-${item.severity}`),
);
const desc = this.el('div', 'cdp-signal-desc', item.description);
const ts = this.el('div', 'cdp-signal-time', this.formatRelativeTime(item.timestamp));
row.append(line, desc, ts);
this.signalRecentBody.append(row);
}
}
private renderEconomicIndicators(): void {
if (!this.economicBody) return;
this.economicBody.replaceChildren();
if (this.economicIndicators.length === 0) {
this.economicBody.append(this.makeEmpty(t('countryBrief.noIndicators')));
return;
}
for (const indicator of this.economicIndicators.slice(0, 3)) {
const row = this.el('div', 'cdp-economic-item');
const top = this.el('div', 'cdp-economic-top');
const isMarketRow = indicator.label === 'Stock Index' || indicator.label === 'Weekly Momentum';
const trendClass = isMarketRow ? `trend-market-${indicator.trend}` : `trend-${indicator.trend}`;
top.append(
this.el('span', 'cdp-economic-label', indicator.label),
this.el('span', `cdp-trend-token ${trendClass}`, this.trendArrowFromDirection(indicator.trend)),
);
const value = this.el('div', 'cdp-economic-value', indicator.value);
row.append(top, value);
if (indicator.source) {
row.append(this.el('div', 'cdp-economic-source', indicator.source));
}
this.economicBody.append(row);
}
}
private highlightInfrastructure(type: AssetType): void {
if (!this.map) return;
const assets = this.infrastructureByType.get(type) ?? [];
if (assets.length === 0) return;
this.map.flashAssets(type, assets.map((asset) => asset.id));
}
private open(): void {
if (this.panel.classList.contains('active')) return;
this.lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
this.panel.classList.add('active');
this.panel.setAttribute('aria-hidden', 'false');
document.addEventListener('keydown', this.handleGlobalKeydown);
requestAnimationFrame(() => this.closeButton.focus());
this.onStateChangeCallback?.({ visible: true, maximized: this.isMaximizedState });
}
private close(): void {
if (!this.panel.classList.contains('active')) return;
this.panel.classList.remove('active');
this.panel.setAttribute('aria-hidden', 'true');
document.removeEventListener('keydown', this.handleGlobalKeydown);
if (this.lastFocusedElement) this.lastFocusedElement.focus();
}
private getFocusableElements(): HTMLElement[] {
const selectors = 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
return Array.from(this.panel.querySelectorAll<HTMLElement>(selectors))
.filter((el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true' && el.offsetParent !== null);
}
private getOrCreatePanel(): HTMLElement {
const existing = document.getElementById('country-deep-dive-panel');
if (existing) return existing;
const panel = this.el('aside', 'country-deep-dive');
panel.id = 'country-deep-dive-panel';
panel.setAttribute('aria-label', 'Country Intelligence');
panel.setAttribute('aria-hidden', 'true');
const shell = this.el('div', 'country-deep-dive-shell');
const close = this.el('button', 'panel-close', '×') as HTMLButtonElement;
close.id = 'deep-dive-close';
close.setAttribute('aria-label', 'Close');
const content = this.el('div', 'panel-content');
content.id = 'deep-dive-content';
shell.append(close, content);
panel.append(shell);
document.body.append(panel);
return panel;
}
private sectionCard(title: string, helpText?: string): [HTMLElement, HTMLElement] {
const card = this.el('section', 'cdp-card');
const heading = this.el('h3', 'cdp-card-title', title);
if (helpText) {
const tip = this.el('button', 'cdp-card-help', '?');
tip.setAttribute('title', helpText);
tip.setAttribute('type', 'button');
heading.append(tip);
}
const body = this.el('div', 'cdp-card-body');
card.append(heading, body);
return [card, body];
}
private metric(label: string, value: string, chipClass: string): HTMLElement {
const box = this.el('div', 'cdp-metric');
box.append(
this.el('span', 'cdp-metric-label', label),
this.badge(value, `cdp-metric-value ${chipClass}`),
);
return box;
}
private makeLoading(text: string): HTMLElement {
const wrap = this.el('div', 'cdp-loading-inline');
wrap.append(
this.el('div', 'cdp-loading-line'),
this.el('div', 'cdp-loading-line cdp-loading-line-short'),
this.el('span', 'cdp-loading-text', text),
);
return wrap;
}
private makeEmpty(text: string): HTMLElement {
return this.el('div', 'cdp-empty', text);
}
private badge(text: string, className: string): HTMLElement {
return this.el('span', className, text);
}
private formatBrief(text: string, headlineCount = 0): string {
return formatIntelBrief(text, headlineCount > 0 ? { count: headlineCount, hrefPrefix: '#cdp-news-' } : undefined);
}
private summarizeBrief(brief: string): string {
const stripped = brief.replace(/\*\*(.*?)\*\*/g, '$1');
const lines = stripped.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length >= 3) {
return lines.slice(0, 3).join('\n');
}
const normalized = stripped.replace(/\s+/g, ' ').trim();
const sentences = normalized.split(/(?<=[.!?])\s+/).filter((part) => part.length > 0);
return sentences.slice(0, 3).join(' ') || normalized;
}
private trendArrow(trend: CountryScore['trend']): string {
if (trend === 'rising') return '↑';
if (trend === 'falling') return '↓';
return '→';
}
private trendArrowFromDirection(trend: TrendDirection): string {
if (trend === 'up') return '↑';
if (trend === 'down') return '↓';
return '→';
}
private ciiBand(score: number): 'stable' | 'elevated' | 'high' | 'critical' {
if (score <= 25) return 'stable';
if (score <= 50) return 'elevated';
if (score <= 75) return 'high';
return 'critical';
}
private decodeEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/');
}
private toThreatLevel(level: string | undefined): ThreatLevel {
if (level === 'critical' || level === 'high' || level === 'medium' || level === 'low' || level === 'info') {
return level;
}
return 'low';
}
private toTimestamp(date: Date | string): number {
const d = date instanceof Date ? date : new Date(date);
return Number.isFinite(d.getTime()) ? d.getTime() : 0;
}
private shortDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return 'Unknown';
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
private formatRelativeTime(value: Date | string): string {
const ms = Date.now() - this.toTimestamp(value);
const mins = Math.floor(ms / 60000);
if (mins < 1) return t('countryBrief.timeAgo.m', { count: 1 });
if (mins < 60) return t('countryBrief.timeAgo.m', { count: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t('countryBrief.timeAgo.h', { count: hours });
const days = Math.floor(hours / 24);
return t('countryBrief.timeAgo.d', { count: days });
}
private el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
public static toFlagEmoji(code: string): string {
return toFlagEmoji(code, '🌍');
}
}