GenerAI / worldmonitor /src /components /CountryDeepDivePanel.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 7)
9d2d895 verified
Raw
History Blame Contribute Delete
144 kB
import type { CountryBriefSignals } from '@/types';
import {
describePropagandaBadge,
getSourcePropagandaRisk,
getSourceTier,
getSourceTierBadgeTitle,
getSourceType,
} 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 { getCountryInfrastructure } from '@/services/related-assets';
import type { PredictionMarket } from '@/services/prediction';
import type { AssetType, NewsItem, RelatedAsset } from '@/types';
import { sanitizeUrl, escapeHtml } from '@/utils/sanitize';
import { computeAlternativeSuppliers, type ChokepointScoreMap, type EnrichedExporter } from '@/utils/supplier-route-risk';
import { formatIntelBrief } from '@/utils/format-intel-brief';
import { collectBriefSources, renderBriefSourcesFooter, type BriefSource } from '@/utils/brief-sources';
import { getCSSColor, showToast } from '@/utils';
import { toFlagEmoji } from '@/utils/country-flag';
import { PORTS } from '@/config/ports';
import { getChokepointRoutes } from '@/config/trade-routes';
import { STRATEGIC_WATERWAYS } from '@/config/geo';
import { hasPremiumAccess } from '@/services/panel-gating';
import { getAuthState } from '@/services/auth-state';
import { trackGateHit } from '@/services/analytics';
import { fetchBypassOptions, fetchChokepointStatus } from '@/services/supply-chain';
import { haversineDistanceKm } from '@/services/related-assets';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import type {
CountryBriefPanel,
CountryIntelData,
StockIndexData,
CountryDeepDiveSignalDetails,
CountryDeepDiveSignalItem,
CountryDeepDiveMilitarySummary,
CountryDeepDiveEconomicIndicator,
ChinaCountrySummaryData,
ChinaCountrySummaryGroup,
ChinaCountrySummaryGroupId,
CountryFactsData,
CountryEnergyProfileData,
CountryPortActivityData,
} from './CountryBriefPanel';
import type {
GetCountryChokepointIndexResponse,
SectorExposureSummary,
CountryProductsResponse,
CountryProduct,
MultiSectorShockResponse,
MultiSectorShock,
} from '@/services/supply-chain';
import { CHINA_DECISION_SIGNAL_GROUP_IDS } from '../../shared/china-decision-signals';
import { fetchMultiSectorCostShock, HS2_SHORT_LABELS } from '@/services/supply-chain';
import type { MapContainer } from './MapContainer';
import { dedupeHeadlines } from './CountryDeepDivePanel-news-utils';
import { decodeHtmlEntities } from '@/utils/html-entities';
import { renderFollowButton } from '@/utils/follow-button';
import { renderNotifyCountryLink } from '@/utils/notify-country-link';
import { exportCountryEvidenceMarkdown } from '@/utils/export';
import type { CountryEvidenceBundleInput } from '@/utils/export';
import { ciiBandForLevel } from './CountryDeepDivePanel-cii';
const DEPENDENCY_FLAG_LABELS: Record<string, { text: string; cls: string }> = {
DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL: { text: 'Single Source', cls: 'cdp-dep-critical' },
DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL: { text: 'Single Corridor', cls: 'cdp-dep-critical' },
DEPENDENCY_FLAG_COMPOUND_RISK: { text: 'Compound Risk', cls: 'cdp-dep-compound' },
DEPENDENCY_FLAG_DIVERSIFIABLE: { text: 'Diversifiable', cls: 'cdp-dep-ok' },
};
import { toApiUrl } from '@/services/runtime';
import type { ComputeEnergyShockScenarioResponse, ProductImpact } from '@/generated/client/worldmonitor/intelligence/v1/service_client';
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
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,
};
// Clamp long disruption shortDescriptions when rendered in the compact
// CountryDeepDive Atlas row. Some registry entries (OFAC designations,
// multi-clause sanctions summaries) run 100–200 chars; without a clamp
// they overflow the row. 80 chars is a balance between scannability and
// information density; full detail stays accessible by clicking through
// to the asset drawer.
const DISRUPTION_LABEL_MAX_LEN = 80;
function truncateDisruptionLabel(eventType: string, shortDescription: string): string {
const base = `${eventType}${shortDescription}`;
if (base.length <= DISRUPTION_LABEL_MAX_LEN) return base;
return base.slice(0, DISRUPTION_LABEL_MAX_LEN - 1) + '…';
}
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 currentScore: CountryScore | null = null;
private currentSignals: CountryBriefSignals | null = null;
private currentBrief: string | null = null;
private currentBriefGeneratedAt: string | number | null = null;
private currentBriefCached: boolean | null = null;
private currentHeadlines: NewsItem[] = [];
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 chinaSummaryBody: HTMLElement | null = null;
private housingBody: 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: import('@/components/ResilienceWidget').ResilienceWidget | null = null;
private pendingResilienceEnergyMix: CountryEnergyProfileData | null = null;
private resilienceWidgetRequestId = 0;
private energyBody: HTMLElement | null = null;
private maritimeBody: HTMLElement | null = null;
private tradeExposureBody: HTMLElement | null = null;
private selectedSectorHs2: string | null = null;
private sectorBypassAbort: AbortController | null = null;
private cachedTradeExposureData: GetCountryChokepointIndexResponse | null = null;
private cachedSectors: SectorExposureSummary[] = [];
private productImportsBody: HTMLElement | null = null;
private debtBody: HTMLElement | null = null;
private sanctionsBody: HTMLElement | null = null;
private comtradeBody: HTMLElement | null = null;
private tariffBody: HTMLElement | null = null;
// ── Phase 5: Multi-sector Cost Shock Calculator ─────────────────────────
private costShockCalcBody: HTMLElement | null = null;
private costShockCalcTable: HTMLElement | null = null;
private costShockCalcDurationLabel: HTMLElement | null = null;
private costShockCalcTotalLabel: HTMLElement | null = null;
private costShockCalcPrimaryChokepoint: string | null = null;
private costShockCalcClosureDays = 30;
private costShockCalcAbort: AbortController | null = null;
private costShockCalcDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Holds the teardown returned by the FollowButton's `attach()` mounted
// in the title row. The skeleton is rebuilt every `show()` call (via
// `resetPanelContent` → `renderSkeleton`), and the panel itself is
// long-lived (singleton on `document.body`), so this teardown must
// fire BEFORE the skeleton is wiped and on `hide()`.
private followButtonTeardown: (() => void) | null = null;
// Sibling teardown for the U8 "Notify me about this country" sub-action
// mounted alongside the FollowButton. Same lifecycle constraints.
private notifyLinkTeardown: (() => void) | 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.currentScore = score;
this.currentSignals = signals;
this.currentBrief = null;
this.currentBriefGeneratedAt = null;
this.currentBriefCached = null;
this.currentHeadlines = [];
this.currentHeadlineCount = 0;
this.economicIndicators = [];
this.infrastructureByType.clear();
this.renderSkeleton(country, code, score, signals);
this.content.scrollTop = 0;
this.open();
}
public hide(): void {
this.destroyResilienceWidget();
this.tearDownFollowButton();
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.currentScore = null;
this.currentSignals = null;
this.currentBrief = null;
this.currentBriefGeneratedAt = null;
this.currentBriefCached = null;
this.currentHeadlines = [];
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();
this.currentHeadlines = [];
const compare = (a: NewsItem, b: NewsItem) => {
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);
};
const sorted = [...headlines].sort(compare);
const deduped = dedupeHeadlines(sorted, (it) => it.tier ?? getSourceTier(it.source))
.sort((a, b) => compare(a.item, b.item))
.slice(0, 10);
this.currentHeadlineCount = deduped.length;
this.currentHeadlines = deduped.map(({ item }) => item);
if (deduped.length === 0) {
this.newsBody.append(this.makeEmpty(t('countryBrief.noNews')));
return;
}
for (let i = 0; i < deduped.length; i++) {
const { item, extraSources } = deduped[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);
const sourceType = getSourceType(item.source);
const clampedTier = Math.max(1, Math.min(4, tier));
const tierBadge = this.badge(`T${clampedTier} SRC`, `cdp-tier-badge tier-${clampedTier}`);
tierBadge.setAttribute(
'title',
`${getSourceTierBadgeTitle(sourceType)}. Source tier ${clampedTier}; independent of article severity.`,
);
top.append(tierBadge);
const severity = this.toThreatLevel(item.threat?.level);
const levelKey = severity === 'info' ? 'low' : severity === 'medium' ? 'moderate' : severity;
const severityLabel = t(`countryBrief.levels.${levelKey}`);
const sevBadge = this.badge(severityLabel.toUpperCase(), `cdp-severity-badge sev-${severity}`);
sevBadge.setAttribute('title', 'Article severity: how serious the event is. Independent of source tier.');
top.append(sevBadge);
const risk = getSourcePropagandaRisk(item.source);
const riskDescription = describePropagandaBadge(risk, sourceType);
if (riskDescription) {
const riskLabel = risk.stateAffiliated
? `${riskDescription.label}: ${risk.stateAffiliated}`
: riskDescription.label;
const riskBadge = this.badge(
riskLabel,
`cdp-state-badge propaganda-badge ${riskDescription.risk}`,
);
riskBadge.setAttribute(
'title',
risk.stateAffiliated
? `${sourceType === 'gov' ? 'Official government source' : 'State-affiliated'}: ${risk.stateAffiliated}. ${riskDescription.title}`
: riskDescription.title,
);
top.append(riskBadge);
}
const title = this.el('div', 'cdp-news-title', decodeHtmlEntities(item.title));
const metaText = extraSources.length > 0
? `${item.source} +${extraSources.length} ${extraSources.length === 1 ? 'source' : 'sources'}${this.formatRelativeTime(item.pubDate)}`
: `${item.source}${this.formatRelativeTime(item.pubDate)}`;
const meta = this.el('div', 'cdp-news-meta', metaText);
if (extraSources.length > 0) {
meta.setAttribute('title', `Also reported by: ${extraSources.join(', ')}`);
}
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 = getCountryInfrastructure(centroid.lat, centroid.lon, countryCode, 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 updateChinaCountrySummary(data: ChinaCountrySummaryData): void {
if (this.currentCode?.toUpperCase() !== 'CN' || !this.chinaSummaryBody) return;
this.renderChinaCountrySummary(data.groups);
}
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 updateHousingCycle(data: {
residential?: { indexValue: number; qoqChange: number | null; yoyChange: number | null; period: string } | null;
commercial?: { indexValue: number; qoqChange: number | null; yoyChange: number | null; period: string } | null;
dsr?: { dsrPct: number; change: number | null; period: string } | null;
} | null): void {
if (!this.housingBody) return;
this.housingBody.replaceChildren();
if (!data || (!data.residential && !data.commercial && !data.dsr)) {
this.housingBody.append(this.makeEmpty('No BIS housing cycle data for this country'));
return;
}
const grid = this.el('div', 'cdp-pro-metric-grid');
if (data.residential) {
grid.append(
this.proMetricBox('Residential (real)', `${data.residential.indexValue.toFixed(1)}`),
this.proMetricBox('Residential YoY', this.formatPctTrend(data.residential.yoyChange)),
);
}
if (data.commercial) {
grid.append(
this.proMetricBox('Commercial (real)', `${data.commercial.indexValue.toFixed(1)}`),
this.proMetricBox('Commercial YoY', this.formatPctTrend(data.commercial.yoyChange)),
);
}
if (data.dsr) {
grid.append(
this.proMetricBox('Household DSR', `${data.dsr.dsrPct.toFixed(1)}%`),
this.proMetricBox('DSR QoQ', this.formatPctTrend(data.dsr.change)),
);
}
this.housingBody.append(grid);
const src = data.residential?.period || data.commercial?.period || data.dsr?.period || '';
if (src) {
const note = this.el('div', 'cdp-economic-source', `Source: BIS SDMX · latest ${src}`);
this.housingBody.append(note);
}
}
public updateNationalDebt(entry: { debtToGdp: number; debtUsd: number; annualGrowth: number; source: string } | null): void {
if (!this.debtBody) return;
this.debtBody.replaceChildren();
if (!entry) {
this.debtBody.append(this.makeEmpty('No national debt data available'));
return;
}
const grid = this.el('div', 'cdp-pro-metric-grid');
grid.append(
this.proMetricBox('Debt-to-GDP', `${entry.debtToGdp.toFixed(1)}%`),
this.proMetricBox('Total Debt', this.formatMoney(entry.debtUsd)),
this.proMetricBox('YoY Growth', this.formatPctTrend(entry.annualGrowth)),
this.proMetricBox('Source', entry.source),
);
this.debtBody.append(grid);
}
public updateSanctionsPressure(data: { entryCount: number; sanctionsActive?: boolean } | null): void {
if (!this.sanctionsBody) return;
this.sanctionsBody.replaceChildren();
if (!data) {
this.sanctionsBody.append(this.makeEmpty('No sanctions data available'));
return;
}
const grid = this.el('div', 'cdp-pro-metric-grid');
grid.append(
this.proMetricBox('Sanctioned Entities', String(data.entryCount)),
this.proMetricBox('Status', data.sanctionsActive ? 'Active' : 'None'),
);
this.sanctionsBody.append(grid);
}
public updateComtradeFlows(flows: Array<{ partnerName: string; cmdDesc: string; tradeValueUsd: number; yoyChange: number }> | null): void {
if (!this.comtradeBody) return;
this.comtradeBody.replaceChildren();
if (!flows || flows.length === 0) {
this.comtradeBody.append(this.makeEmpty('No data available'));
return;
}
const table = this.el('table', 'cdp-pro-flow-table');
const thead = this.el('thead');
const hr = this.el('tr');
for (const col of ['Partner', 'Commodity', 'Value', 'YoY']) {
hr.append(this.el('th', '', col));
}
thead.append(hr);
table.append(thead);
const tbody = this.el('tbody');
for (const f of flows.slice(0, 5)) {
const tr = this.el('tr');
tr.append(this.el('td', '', f.partnerName));
const cmdTd = this.el('td', '');
cmdTd.textContent = f.cmdDesc.length > 25 ? f.cmdDesc.slice(0, 22) + '...' : f.cmdDesc;
cmdTd.title = f.cmdDesc;
tr.append(cmdTd);
tr.append(this.el('td', '', this.formatMoney(f.tradeValueUsd)));
const yoyTd = this.el('td', f.yoyChange >= 0 ? 'cdp-pro-trend-up' : 'cdp-pro-trend-down');
yoyTd.textContent = this.formatPctTrend(f.yoyChange);
tr.append(yoyTd);
tbody.append(tr);
}
table.append(tbody);
this.comtradeBody.append(table);
}
public updateTariffTrends(data: { currentRate: number; trend: string; datapoints: Array<{ year: number; tariffRate: number }> } | null): void {
if (!this.tariffBody) return;
this.tariffBody.replaceChildren();
if (!data) {
this.tariffBody.append(this.makeEmpty('No tariff data available'));
return;
}
const grid = this.el('div', 'cdp-pro-metric-grid');
grid.append(
this.proMetricBox('Effective Rate', `${data.currentRate.toFixed(2)}%`),
this.proMetricBox('Trend', data.trend === 'rising' ? '\u2191 Rising' : '\u2193 Falling'),
);
this.tariffBody.append(grid);
}
/**
* Mount the Cost Shock Calculator with its initial data and slider.
* Called once per country load with the first (default 30-day) response.
*/
public updateMultiSectorCostShock(data: MultiSectorShockResponse | null): void {
if (!this.costShockCalcBody) return;
this.costShockCalcBody.replaceChildren();
if (!data || (!data.sectors.length && !data.unavailableReason)) {
// Remove the card entirely to avoid showing an empty "Cost Shock" widget
// alongside the Trade Exposure sector table (issue #2973 bug 1).
// sectionCard() creates a .cdp-card (not .cdp-section-card); parentElement
// is the card wrapper. Matches the updateTradeExposure cleanup pattern.
this.costShockCalcBody.parentElement?.remove();
this.costShockCalcBody = null;
return;
}
this.costShockCalcPrimaryChokepoint = data.chokepointId;
this.costShockCalcClosureDays = Number.isFinite(data.closureDays) && data.closureDays > 0 ? data.closureDays : 30;
// ── Header line: chokepoint + war risk tier badge ────────────────────
const header = this.el('div', 'cdp-cost-shock-calc-header');
const cpName = STRATEGIC_WATERWAYS.find(w => w.id === data.chokepointId)?.name
?? data.chokepointId.replace(/_/g, ' ');
header.append(this.el('span', 'cdp-cost-shock-calc-cp', `Primary: ${cpName}`));
const tierShort = data.warRiskTier.replace('WAR_RISK_TIER_', '').replace(/_/g, ' ');
header.append(this.el('span', 'cdp-cost-shock-calc-tier', `War risk: ${tierShort || 'NORMAL'}`));
this.costShockCalcBody.append(header);
// ── Slider ──────────────────────────────────────────────────────────
const sliderWrap = this.el('div', 'cdp-cost-shock-calc-slider-wrap');
const sliderLabel = this.el('label', 'cdp-cost-shock-calc-slider-label');
sliderLabel.append(document.createTextNode('Closure duration: '));
this.costShockCalcDurationLabel = this.el('strong', 'cdp-cost-shock-calc-duration-value', `${this.costShockCalcClosureDays} days`);
sliderLabel.append(this.costShockCalcDurationLabel);
sliderWrap.append(sliderLabel);
const slider = this.el('input', 'cdp-cost-shock-calc-slider');
slider.type = 'range';
slider.min = '1';
slider.max = '90';
slider.step = '1';
slider.value = String(this.costShockCalcClosureDays);
slider.setAttribute('aria-label', 'Chokepoint closure duration in days');
slider.addEventListener('input', this.handleCostShockSliderInput);
sliderWrap.append(slider);
const ticks = this.el('div', 'cdp-cost-shock-calc-ticks');
for (const label of ['1d', '30d', '60d', '90d']) {
ticks.append(this.el('span', 'cdp-cost-shock-calc-tick', label));
}
sliderWrap.append(ticks);
this.costShockCalcBody.append(sliderWrap);
// ── Table ───────────────────────────────────────────────────────────
const table = this.el('table', 'cdp-cost-shock-calc-table');
const thead = this.el('thead');
const headerRow = this.el('tr');
headerRow.append(this.el('th', '', 'Sector'));
headerRow.append(this.el('th', 'cdp-cost-shock-calc-cost-col', 'Added Cost'));
thead.append(headerRow);
table.append(thead);
const tbody = this.el('tbody');
table.append(tbody);
this.costShockCalcTable = tbody;
this.costShockCalcBody.append(table);
// ── Total row ───────────────────────────────────────────────────────
const totalRow = this.el('div', 'cdp-cost-shock-calc-total-row');
totalRow.append(this.el('span', 'cdp-cost-shock-calc-total-label', 'Total'));
this.costShockCalcTotalLabel = this.el('span', 'cdp-cost-shock-calc-total-value', '$0');
totalRow.append(this.costShockCalcTotalLabel);
this.costShockCalcBody.append(totalRow);
if (data.unavailableReason) {
this.costShockCalcBody.append(this.el('div', 'cdp-card-footer', data.unavailableReason));
} else {
this.costShockCalcBody.append(
this.el('div', 'cdp-card-footer', 'Added cost = annual imports × (bypass freight uplift + war risk bps) × closure days / 365'),
);
}
this.renderMultiSectorShockRows(data.sectors);
}
/** Render (or re-render) just the cost-shock table rows + total. */
private renderMultiSectorShockRows(sectors: MultiSectorShock[]): void {
if (!this.costShockCalcTable || !this.costShockCalcTotalLabel) return;
const tbody = this.costShockCalcTable;
tbody.replaceChildren();
const sorted = [...sectors].sort((a, b) => b.totalCostShock - a.totalCostShock);
let total = 0;
for (const s of sorted) {
const tr = this.el('tr', 'cdp-cost-shock-calc-row');
const labelCell = this.el('td', 'cdp-cost-shock-calc-sector', s.hs2Label || HS2_SHORT_LABELS[s.hs2] || `HS${s.hs2}`);
const costCell = this.el('td', 'cdp-cost-shock-calc-cost', this.formatMoney(s.totalCostShock));
if (s.totalCostShock === 0) costCell.classList.add('cdp-cost-shock-calc-cost--zero');
tr.append(labelCell, costCell);
tbody.append(tr);
total += s.totalCostShock;
}
this.costShockCalcTotalLabel.textContent = this.formatMoney(total);
}
private readonly handleCostShockSliderInput = (ev: Event): void => {
const target = ev.target as HTMLInputElement | null;
if (!target) return;
const days = Math.max(1, Math.min(90, Number(target.value) || 30));
this.costShockCalcClosureDays = days;
if (this.costShockCalcDurationLabel) {
this.costShockCalcDurationLabel.textContent = `${days} day${days === 1 ? '' : 's'}`;
}
this.scheduleCostShockRefetch(days);
};
/** Debounce re-fetch by 300ms so rapid slider drags don't spam the API. */
private scheduleCostShockRefetch(days: number): void {
if (this.costShockCalcDebounceTimer) clearTimeout(this.costShockCalcDebounceTimer);
this.costShockCalcDebounceTimer = setTimeout(() => {
this.costShockCalcDebounceTimer = null;
void this.refetchMultiSectorShock(days);
}, 300);
}
private async refetchMultiSectorShock(days: number): Promise<void> {
const iso2 = this.currentCode;
const cp = this.costShockCalcPrimaryChokepoint;
if (!iso2 || !cp) return;
// Abort any in-flight fetch before starting a new one.
this.costShockCalcAbort?.abort();
this.costShockCalcAbort = new AbortController();
try {
const resp = await fetchMultiSectorCostShock(iso2, cp, days, { signal: this.costShockCalcAbort.signal });
if (this.currentCode !== iso2) return;
if (this.costShockCalcClosureDays !== days) return; // a newer slider move superseded this
this.renderMultiSectorShockRows(resp.sectors);
} catch {
// Ignore — either aborted or transient network; leave prior values visible.
}
}
private makeProLocked(text: string): HTMLElement {
const wrap = this.el('div', 'cdp-pro-locked');
wrap.append(
this.el('span', 'cdp-pro-lock-icon', '\uD83D\uDD12'),
this.el('span', 'cdp-pro-lock-text', text),
);
return wrap;
}
private proMetricBox(label: string, value: string): HTMLElement {
const box = this.el('div', 'cdp-pro-metric-box');
box.append(
this.el('div', 'cdp-pro-metric-label', label),
this.el('div', 'cdp-pro-metric-value', value),
);
return box;
}
private formatMoney(usd: number): string {
if (usd >= 1e12) return `$${(usd / 1e12).toFixed(1)}T`;
if (usd >= 1e9) return `$${(usd / 1e9).toFixed(1)}B`;
if (usd >= 1e6) return `$${(usd / 1e6).toFixed(1)}M`;
if (usd >= 1e3) return `$${(usd / 1e3).toFixed(1)}K`;
return `$${Math.round(usd).toLocaleString()}`;
}
/**
* Format a USD value using the same scale as a reference value so row totals
* and supplier rows share a unit suffix (issue #2973 bug 5).
*/
private formatMoneyAtScale(usd: number, referenceUsd: number): string {
if (referenceUsd >= 1e12) return `$${(usd / 1e12).toFixed(2)}T`;
if (referenceUsd >= 1e9) return `$${(usd / 1e9).toFixed(2)}B`;
if (referenceUsd >= 1e6) return `$${(usd / 1e6).toFixed(2)}M`;
if (referenceUsd >= 1e3) return `$${(usd / 1e3).toFixed(2)}K`;
return `$${Math.round(usd).toLocaleString()}`;
}
/**
* Shared exposure-score color scale used by vuln header and row scores
* (issue #2973 bug 4).
*/
private static exposureScoreColor(score: number): string {
if (score >= 70) return 'var(--danger, #ef4444)';
if (score > 30) return 'var(--warning, #f59e0b)';
return 'var(--text-muted, #64748b)';
}
private formatPctTrend(pct: number | null | undefined): string {
if (pct == null || !Number.isFinite(pct)) return '\u2014';
const sign = pct >= 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}%`;
}
public updateEnergyProfile(data: CountryEnergyProfileData): void {
if (!this.energyBody) return;
this.renderEnergyProfile(data);
this.pendingResilienceEnergyMix = 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 || data.sprAvailable;
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 wrap = this.el('div', 'cdp-energy-donut-wrap');
wrap.append(this.buildDonutSvg(segments, norm, 'Primary\nEnergy'));
const legend = this.el('div', 'cdp-energy-legend');
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const row = this.el('div', 'cdp-energy-legend-row');
const dot = this.el('span', 'cdp-energy-legend-dot');
dot.style.background = seg.color;
const label = this.el('span', '', `${seg.label} ${Math.round(pct)}%`);
row.append(dot, label);
legend.append(row);
}
wrap.append(legend);
this.energyBody.append(wrap);
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);
}
if (data.sprAvailable && data.sprRegime === 'government_spr' && !data.sprIeaMember) {
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 badge = this.el('span', '');
badge.style.cssText = 'background:#3b82f6;color:#fff;padding:1px 6px;border-radius:3px;font-size:11px';
const capText = data.sprCapacityMb > 0 ? ` (${data.sprCapacityMb}Mb)` : '';
badge.textContent = `Strategic Reserve: ${data.sprOperator || 'Government SPR'}${capText}`;
row.append(badge);
section.append(row);
this.energyBody.append(section);
} else if (data.sprAvailable && data.sprRegime === 'spare_capacity') {
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px';
const muted = this.el('div', '');
muted.style.cssText = 'color:#6b7280;font-size:11px';
muted.textContent = 'Spare capacity producer (no formal SPR)';
section.append(muted);
this.energyBody.append(section);
} else if (data.sprAvailable && data.sprRegime === 'none') {
const note = this.el('div', 'cdp-economic-source');
note.style.cssText += ';color:#ef4444;opacity:0.7';
note.textContent = 'No known strategic petroleum reserve program';
this.energyBody.append(note);
}
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 wrap = this.el('div', 'cdp-energy-donut-wrap');
wrap.append(this.buildDonutSvg(segments, norm, 'Monthly\nMix'));
const legend = this.el('div', 'cdp-energy-legend');
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const row = this.el('div', 'cdp-energy-legend-row');
const dot = this.el('span', 'cdp-energy-legend-dot');
dot.style.background = seg.color;
const label = this.el('span', '', `${seg.label} ${Math.round(pct)}%`);
row.append(dot, label);
legend.append(row);
}
wrap.append(legend);
section.append(wrap);
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[] = [];
const fossilR = Math.round(data.emberFossilShare);
let coalR = Math.round(data.emberCoalShare);
let gasR = Math.round(data.emberGasShare);
// Fossil may include oil-burn and other minor categories not surfaced as separate shares;
// allocate the residual to "Other" so the breakdown sums to the Fossil legend value (see #2971).
// If independent rounding pushes coal+gas above fossilR, trim the larger of the two so
// the breakdown never sums above the Fossil legend.
const overshoot = (coalR + gasR) - fossilR;
if (overshoot > 0) {
if (coalR >= gasR) coalR -= overshoot;
else gasR -= overshoot;
}
const otherR = fossilR - coalR - gasR;
if (coalR > 0) parts.push(`Coal ${coalR}%`);
if (gasR > 0) parts.push(`Gas ${gasR}%`);
if (otherR > 0) parts.push(`Other ${otherR}%`);
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());
}
// Atlas exposure: pipelines, storage, shortages, disruptions filtered
// to this country. Reads from the same bootstrap-hydrated stores as
// the Energy Atlas variant, so the count is free when data is warm
// and silently absent when a user is on a variant that doesn't
// pre-hydrate those keys.
this.renderAtlasExposure();
}
private renderAtlasExposure(): void {
if (!this.energyBody) return;
const iso2 = this.currentCode;
if (!iso2 || iso2.length !== 2) return;
// Late-import so non-energy variants can tree-shake these modules at
// build time if the Atlas panels aren't bundled. Static imports are
// safe here because all four stores are pure client caches.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import('@/shared/pipeline-registry-store').then(({ getCachedPipelineRegistries }) => {
const { gas, oil } = getCachedPipelineRegistries() as {
gas: { pipelines?: Record<string, { fromCountry?: string; toCountry?: string; transitCountries?: string[]; name?: string; id?: string }> } | undefined;
oil: { pipelines?: Record<string, { fromCountry?: string; toCountry?: string; transitCountries?: string[]; name?: string; id?: string }> } | undefined;
};
const touches = (p: { fromCountry?: string; toCountry?: string; transitCountries?: string[] }): boolean =>
p.fromCountry === iso2 || p.toCountry === iso2 ||
(Array.isArray(p.transitCountries) && p.transitCountries.includes(iso2));
const pipes = [
...Object.values(gas?.pipelines ?? {}).filter(touches),
...Object.values(oil?.pipelines ?? {}).filter(touches),
];
if (pipes.length > 0) {
this.appendAtlasRow(
`Pipelines touching ${iso2}`,
`${pipes.length} pipeline${pipes.length === 1 ? '' : 's'}`,
pipes.map(p => ({
id: p.id || '',
label: p.name || p.id || '',
event: 'energy:open-pipeline-detail',
detail: { pipelineId: p.id },
})),
);
}
}).catch(() => {});
import('@/shared/storage-facility-registry-store').then(({ getCachedStorageFacilityRegistry }) => {
const { registry } = getCachedStorageFacilityRegistry() as {
registry: { facilities?: Record<string, { country?: string; name?: string; id?: string }> } | undefined;
};
const facilities = Object.values(registry?.facilities ?? {}).filter(f => f.country === iso2);
if (facilities.length > 0) {
this.appendAtlasRow(
`Storage in ${iso2}`,
`${facilities.length} facilit${facilities.length === 1 ? 'y' : 'ies'}`,
facilities.map(f => ({
id: f.id || '',
label: f.name || f.id || '',
event: 'energy:open-storage-facility-detail',
detail: { facilityId: f.id },
})),
);
}
}).catch(() => {});
import('@/shared/fuel-shortage-registry-store').then(({ getCachedFuelShortageRegistry }) => {
const { registry } = getCachedFuelShortageRegistry() as {
registry: { shortages?: Record<string, { country?: string; product?: string; severity?: string; id?: string; shortDescription?: string; resolvedAt?: string | null }> } | undefined;
};
// Exclude resolved shortages — the drill-down counts ACTIVE crises
// per country, and rendering resolved rows as active inflates the
// confirmed/watch severity line. Classifier writes resolvedAt on
// resolution; raw seed uses null.
const shortages = Object.values(registry?.shortages ?? {})
.filter(s => s.country === iso2 && !s.resolvedAt);
if (shortages.length > 0) {
const confirmedCount = shortages.filter(s => s.severity === 'confirmed').length;
const severityLine = confirmedCount > 0
? `${confirmedCount} confirmed · ${shortages.length - confirmedCount} watch`
: `${shortages.length} watch`;
this.appendAtlasRow(
`Fuel shortages in ${iso2}`,
severityLine,
shortages.map(s => ({
id: s.id || '',
label: `${s.product || ''}${s.shortDescription || ''}`.trim(),
event: 'energy:open-fuel-shortage-detail',
detail: { shortageId: s.id },
})),
);
}
}).catch(() => {});
// Disruptions filter (plan §R/#5 decision B). The seeded registry carries
// denormalised `countries[]` on every event, populated from the referenced
// pipeline or storage facility. We fetch the full list once (no asset
// filter) and narrow client-side; the bootstrap payload already contains
// the registry so this is usually cache-hot. If the RPC round-trip returns
// nothing, we silently skip — CountryDeepDive is not the primary
// disruption surface (EnergyDisruptionsPanel is), so an empty row is
// preferable to a spurious error.
this.loadDisruptionsForCountry(iso2);
}
private async loadDisruptionsForCountry(iso2: string): Promise<void> {
try {
const { SupplyChainServiceClient } = await import(
'@/generated/client/worldmonitor/supply_chain/v1/service_client'
);
const { getRpcBaseUrl } = await import('@/services/rpc-client');
// Thread the panel's `signal` into the fetch shim so a country
// switch or panel close cancels the in-flight request, not just
// discards the result via the `this.currentCode !== iso2` guard
// below. Codex P2 on PR #3377.
const abortSignal = this.signal;
const client = new SupplyChainServiceClient(getRpcBaseUrl(), {
fetch: (input, init) => globalThis.fetch(input, { ...(init ?? {}), signal: abortSignal }),
});
const res = await client.listEnergyDisruptions({
assetId: '',
assetType: '',
ongoingOnly: false,
});
if (!res || !Array.isArray(res.events) || this.currentCode !== iso2) return;
const events = res.events.filter(e =>
Array.isArray(e.countries) && e.countries.includes(iso2),
);
if (events.length === 0) return;
const ongoing = events.filter(e => !e.endAt).length;
const summary = ongoing > 0
? `${ongoing} ongoing · ${events.length - ongoing} resolved`
: `${events.length} resolved`;
this.appendAtlasRow(
`Energy disruptions in ${iso2}`,
summary,
events.map(e => ({
id: e.id,
// Clamp long descriptions (some registry entries run 100-200
// chars, e.g. OFAC designation paragraphs) so the row layout
// stays compact. 80-char limit + ellipsis. Codex P2 on PR #3377.
label: truncateDisruptionLabel(e.eventType, e.shortDescription),
// Event type mirrors the existing asset-detail events (pipeline /
// storage) because disruptions reference the underlying asset; the
// panel-layout listener routes to the matching asset panel.
event: e.assetType === 'storage'
? 'energy:open-storage-facility-detail'
: 'energy:open-pipeline-detail',
// Emit ONLY the {pipelineId, facilityId} the drawers consume today
// (see PipelineStatusPanel + StorageFacilityMapPanel
// openDetailHandler). Previously this detail included a
// `highlightEventId` that no receiver read — Codex P2 flagged the
// misleading API surface. Clicking a row jumps to the asset
// drawer; the user sees the full per-asset timeline and locates
// the event visually. Re-add `highlightEventId` here and in
// EnergyDisruptionsPanel's dispatchOpenAsset only when the
// drawer panels ship matching consumer code.
detail: e.assetType === 'storage'
? { facilityId: e.assetId }
: { pipelineId: e.assetId },
})),
);
} catch {
// Silent — disruptions row is supplementary; failures elsewhere
// surface via the dedicated EnergyDisruptionsPanel. Abort errors
// from signal cancellation are also swallowed here intentionally.
}
}
private appendAtlasRow(
title: string,
summary: string,
items: Array<{ id: string; label: string; event: string; detail: Record<string, string | undefined> }>,
): void {
if (!this.energyBody || items.length === 0) return;
const section = this.el('div', '');
section.style.cssText = 'margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06)';
const header = this.el('div', '');
header.style.cssText = 'display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px';
header.append(this.el('div', 'cdp-subtitle', title));
header.append(this.el('div', 'cdp-economic-source', summary));
section.append(header);
for (const it of items.slice(0, 5)) {
const row = this.el('div', '');
row.style.cssText = 'font-size:11px;color:#ddd;padding:2px 0;cursor:pointer';
row.textContent = it.label || it.id;
row.addEventListener('click', () => {
if (!it.id) return;
try {
window.dispatchEvent(new CustomEvent(it.event, { detail: it.detail }));
} catch { /* Non-browser runtime no-op */ }
});
section.append(row);
}
if (items.length > 5) {
const more = this.el('div', 'cdp-economic-source', `+${items.length - 5} more`);
section.append(more);
}
this.energyBody.append(section);
}
private buildDonutSvg(
segments: Array<{ label: string; color: string; value: number }>,
norm: number,
centerText: string,
): HTMLElement {
const size = 120;
const r = 46;
const stroke = 18;
const cx = size / 2;
const cy = size / 2;
const circ = 2 * Math.PI * r;
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
let offset = 0;
for (const seg of segments) {
const pct = (seg.value / norm) * 100;
if (pct <= 0.5) continue;
const dash = (pct / 100) * circ;
const gap = circ - dash;
const circle = document.createElementNS(ns, 'circle');
circle.setAttribute('cx', String(cx));
circle.setAttribute('cy', String(cy));
circle.setAttribute('r', String(r));
circle.setAttribute('fill', 'none');
circle.setAttribute('stroke', seg.color);
circle.setAttribute('stroke-width', String(stroke));
circle.setAttribute('stroke-dasharray', `${dash} ${gap}`);
circle.setAttribute('stroke-dashoffset', String(-offset));
svg.append(circle);
offset += dash;
}
const wrap = this.el('div', 'cdp-energy-donut');
wrap.append(svg);
const label = this.el('div', 'cdp-energy-donut-label');
label.textContent = centerText;
wrap.append(label);
return wrap;
}
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', 'Strait of Hormuz'], ['malacca_strait', 'Strait of Malacca'], ['suez', 'Suez Canal'], ['bab_el_mandeb', '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) {
// Live flow ratio is a chokepoint-level figure, not a per-product one. Surface it once
// as a note instead of repeating the same value across every row (see #2971).
if (result.portwatchCoverage && result.liveFlowRatio != null) {
const note = this.el('div', '');
note.style.cssText = 'font-size:10px;color:#aaa;margin-bottom:4px';
note.textContent = `Current transit flow vs baseline: ${Math.round(result.liveFlowRatio * 100)}%`;
container.append(note);
}
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'];
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)}%`,
];
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)}%`;
if (pct > 0) trendCell.classList.add('cdp-trend-up');
else if (pct < 0) trendCell.classList.add('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);
const scrollWrap = this.el('div', 'cdp-maritime-scroll');
scrollWrap.append(table);
this.maritimeBody.append(scrollWrap);
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);
}
}
public updateTradeExposure(data: GetCountryChokepointIndexResponse | null, sectors?: SectorExposureSummary[]): void {
if (!this.tradeExposureBody) return;
if (data == null || data.exposures.length === 0) {
this.tradeExposureBody.parentElement?.remove();
this.tradeExposureBody = null;
return;
}
this.cachedTradeExposureData = data;
this.cachedSectors = sectors ?? [];
this.renderTradeExposureContent();
}
private renderTradeExposureContent(): void {
if (!this.tradeExposureBody || !this.cachedTradeExposureData) return;
const data = this.cachedTradeExposureData;
const sectors = this.cachedSectors;
this.tradeExposureBody.replaceChildren();
const vulnScore = Math.round(data.vulnerabilityIndex);
const vulnDiv = this.el('div', 'cdp-vuln-index', `Vulnerability: ${vulnScore}/100`);
vulnDiv.style.color = CountryDeepDivePanel.exposureScoreColor(vulnScore);
this.tradeExposureBody.append(vulnDiv);
if (sectors && sectors.length > 0) {
const sectorLabel = this.el('div', 'cdp-section-sublabel', 'Sector exposure by primary chokepoint');
this.tradeExposureBody.append(sectorLabel);
const table = this.el('table', 'cdp-trade-exposure-table');
const thead = this.el('thead');
const headerRow = this.el('tr');
headerRow.append(this.el('th', '', 'Sector'));
headerRow.append(this.el('th', '', 'Chokepoint'));
headerRow.append(this.el('th', 'cdp-exposure-score-header', 'Risk'));
thead.append(headerRow);
table.append(thead);
const tbody = this.el('tbody');
for (const s of sectors.slice(0, 10)) {
const isSelected = this.selectedSectorHs2 === s.hs2;
const tr = this.el('tr');
tr.className = `cdp-sector-row${isSelected ? ' cdp-sector-row--selected' : ''}`;
tr.dataset.hs2 = s.hs2;
const sectorCell = this.el('td', 'cdp-sector-label');
sectorCell.textContent = s.label;
const flag = DEPENDENCY_FLAG_LABELS[s.dependencyFlag];
if (flag) {
const badge = this.el('span', `cdp-dep-badge ${flag.cls}`, flag.text);
sectorCell.append(document.createTextNode(' '), badge);
}
const cpCell = this.el('td', 'cdp-chokepoint-name');
cpCell.textContent = s.primaryChokepointName;
const scoreCell = this.el('td', 'cdp-exposure-score');
scoreCell.textContent = `${s.exposureScore.toFixed(0)}`;
scoreCell.style.color = CountryDeepDivePanel.exposureScoreColor(s.exposureScore);
tr.append(sectorCell, cpCell, scoreCell);
tbody.append(tr);
if (isSelected) {
const detailRow = this.el('tr');
detailRow.className = 'cdp-sector-detail-row';
const detailCell = this.el('td');
detailCell.setAttribute('colspan', '3');
detailCell.append(this.buildRouteDetail(s));
detailRow.append(detailCell);
tbody.append(detailRow);
}
}
table.append(tbody);
tbody.addEventListener('click', (e) => {
const row = (e.target as HTMLElement).closest<HTMLElement>('tr.cdp-sector-row');
if (!row?.dataset.hs2) return;
this.handleSectorRowClick(row.dataset.hs2);
});
this.tradeExposureBody.append(table);
} else {
const sorted = [...data.exposures].sort((a, b) => b.exposureScore - a.exposureScore).slice(0, 3);
const table = this.el('table', 'cdp-trade-exposure-table');
const tbody = this.el('tbody');
for (const entry of sorted) {
const tr = this.el('tr');
const nameCell = this.el('td', 'cdp-chokepoint-name');
nameCell.textContent = entry.chokepointName || entry.chokepointId.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const barWrap = this.el('td', 'cdp-exposure-bar-wrap');
const bar = this.el('div', 'cdp-exposure-bar');
bar.style.width = `${Math.min(entry.exposureScore, 100)}%`;
barWrap.append(bar);
const pctCell = this.el('td', 'cdp-exposure-pct', `${entry.exposureScore.toFixed(1)}`);
pctCell.style.color = CountryDeepDivePanel.exposureScoreColor(entry.exposureScore);
tr.append(nameCell, barWrap, pctCell);
tbody.append(tr);
}
table.append(tbody);
this.tradeExposureBody.append(table);
}
const footer = this.el('div', 'cdp-card-footer', 'Source: Comtrade \u00B7 HS2 sectors \u00B7 Scores indicate route overlap, not share');
this.tradeExposureBody.append(footer);
}
private handleSectorRowClick(hs2: string): void {
this.sectorBypassAbort?.abort();
this.sectorBypassAbort = null;
this.map?.clearHighlightedRoute();
if (this.selectedSectorHs2 === hs2) {
this.selectedSectorHs2 = null;
this.renderTradeExposureContent();
return;
}
if (this.isMaximizedState) this.minimize();
this.selectedSectorHs2 = hs2;
this.renderTradeExposureContent();
this.costShockCalcBody?.closest('.cdp-section-card')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const sector = this.cachedSectors.find(s => s.hs2 === hs2);
if (!sector) return;
const matchingRoutes = getChokepointRoutes(sector.primaryChokepointId);
const matchingRouteIds = matchingRoutes.map(r => r.id);
if (matchingRouteIds.length > 0) {
this.map?.highlightRoute(matchingRouteIds);
this.map?.zoomToRoutes(matchingRouteIds);
}
}
private buildRouteDetail(sector: SectorExposureSummary): HTMLElement {
const wrap = this.el('div', 'cdp-route-detail');
const matchingRoutes = getChokepointRoutes(sector.primaryChokepointId);
if (matchingRoutes.length === 0) {
wrap.append(this.el('div', 'cdp-route-path', 'No maritime route data'));
return wrap;
}
const portMap = new Map(PORTS.map(p => [p.id, p.name]));
const waterwayMap = new Map(STRATEGIC_WATERWAYS.map(w => [w.id, w.name]));
const cpName = waterwayMap.get(sector.primaryChokepointId) ?? sector.primaryChokepointName;
const routesLabel = this.el('div', 'cdp-bypass-heading', `Routes via ${cpName}:`);
wrap.append(routesLabel);
for (const route of matchingRoutes) {
const pathParts: string[] = [];
pathParts.push(portMap.get(route.from) ?? route.from);
for (const wp of route.waypoints) {
pathParts.push(waterwayMap.get(wp) ?? wp);
}
pathParts.push(portMap.get(route.to) ?? route.to);
const pathStr = pathParts.map(p => escapeHtml(p)).join(' \u2192 ');
const pathEl = this.el('div', 'cdp-route-path');
setTrustedHtml(pathEl, trustedHtml(`${escapeHtml(route.name)}: ${pathStr}`, "legacy direct innerHTML migration"));
wrap.append(pathEl);
}
const statsEl = this.el('div', 'cdp-route-stats');
const distEl = this.el('div');
setTrustedHtml(distEl, trustedHtml(`Distance: <span>\u2014</span>`, "legacy direct innerHTML migration"));
const transitEl = this.el('div');
setTrustedHtml(transitEl, trustedHtml(`Transit: <span>\u2014</span>`, "legacy direct innerHTML migration"));
const riskEl = this.el('div');
const riskScore = sector.exposureScore;
const riskColor = riskScore >= 70 ? '#ef4444' : riskScore > 30 ? '#f59e0b' : '#94a3b8';
setTrustedHtml(riskEl, trustedHtml(`Chokepoint Risk: <span style="color:${riskColor}">${riskScore.toFixed(0)}/100</span>`, "legacy direct innerHTML migration"));
const routeCountEl = this.el('div');
setTrustedHtml(routeCountEl, trustedHtml(`Routes via chokepoint: <span>${matchingRoutes.length}</span>`, "legacy direct innerHTML migration"));
statsEl.append(distEl, transitEl, riskEl, routeCountEl);
wrap.append(statsEl);
const bypassSection = this.el('div', 'cdp-bypass-section');
const bypassHeading = this.el('div', 'cdp-bypass-heading', 'Bypass Options');
bypassSection.append(bypassHeading);
const bypassContent = this.el('div');
const isPro = hasPremiumAccess(getAuthState());
if (!isPro) {
const gateEl = this.makeProLocked('Bypass corridors available with PRO');
gateEl.addEventListener('click', () => trackGateHit('sector-bypass-corridors'), { once: true });
bypassContent.append(gateEl);
} else {
bypassContent.append(this.makeLoading('Loading bypass options\u2026'));
this.sectorBypassAbort = new AbortController();
const signal = this.sectorBypassAbort.signal;
void fetchBypassOptions(sector.primaryChokepointId, 'container', 100).then(resp => {
if (signal.aborted) return;
bypassContent.replaceChildren();
const top3 = resp.options.slice(0, 3);
if (top3.length === 0) {
bypassContent.append(this.el('div', 'cdp-route-path', 'No bypass options available'));
return;
}
const tbl = this.el('table', 'cdp-trade-exposure-table');
const tHead = this.el('thead');
const hRow = this.el('tr');
hRow.append(this.el('th', '', 'Corridor'), this.el('th', '', '+Days'), this.el('th', '', '+Cost'), this.el('th', '', 'Risk'));
tHead.append(hRow);
tbl.append(tHead);
const tBody = this.el('tbody');
const riskTierMap: Record<string, string> = {
WAR_RISK_TIER_UNSPECIFIED: 'Normal',
WAR_RISK_TIER_WAR_ZONE: 'War Zone',
WAR_RISK_TIER_CRITICAL: 'Critical',
WAR_RISK_TIER_HIGH: 'High',
WAR_RISK_TIER_ELEVATED: 'Elevated',
WAR_RISK_TIER_NORMAL: 'Normal',
};
for (const opt of top3) {
const r = this.el('tr');
r.append(
this.el('td', '', opt.name),
this.el('td', '', opt.addedTransitDays > 0 ? `+${opt.addedTransitDays}d` : '\u2014'),
this.el('td', '', opt.addedCostMultiplier > 1 ? `+${((opt.addedCostMultiplier - 1) * 100).toFixed(0)}%` : '\u2014'),
this.el('td', '', riskTierMap[opt.bypassWarRiskTier] ?? opt.bypassWarRiskTier),
);
tBody.append(r);
}
tbl.append(tBody);
bypassContent.append(tbl);
}).catch(() => {
if (signal.aborted) return;
bypassContent.replaceChildren();
bypassContent.append(this.el('div', 'cdp-route-path', 'Bypass data unavailable'));
});
}
bypassSection.append(bypassContent);
wrap.append(bypassSection);
return wrap;
}
public updateProductImports(data: CountryProductsResponse | null): void {
if (!this.productImportsBody) return;
this.productImportsBody.replaceChildren();
if (!data || data.products.length === 0) {
this.productImportsBody.append(this.makeEmpty('No data available'));
return;
}
this.renderProductSelector(data.products);
}
private renderProductSelector(products: CountryProduct[]): void {
if (!this.productImportsBody) return;
const wrap = this.el('div', 'cdp-product-selector');
const input = this.el('input', 'cdp-product-search');
input.type = 'text';
input.placeholder = 'Search products...';
input.setAttribute('autocomplete', 'off');
const list = this.el('div', 'cdp-product-list');
const detailMount = this.el('div', 'cdp-product-detail');
const renderList = (filter: string) => {
list.replaceChildren();
const lower = filter.toLowerCase();
const filtered = lower
? products.filter(p => p.description.toLowerCase().includes(lower) || p.hs4.includes(lower))
: products;
for (const p of filtered.slice(0, 12)) {
const item = this.el('button', 'cdp-product-item');
item.type = 'button';
item.textContent = `${p.description} (HS ${p.hs4})`;
item.addEventListener('click', () => {
input.value = p.description;
list.replaceChildren();
this.renderProductDetail(detailMount, p);
});
list.append(item);
}
};
input.addEventListener('input', () => renderList(input.value));
input.addEventListener('focus', () => {
if (list.children.length === 0) renderList(input.value);
});
this.productImportsBody.addEventListener('click', (e) => {
if (!(e.target instanceof HTMLElement) || e.target.closest('.cdp-product-selector')) return;
list.replaceChildren();
});
wrap.append(input, list);
this.productImportsBody.append(wrap, detailMount);
const first = products[0];
if (first) {
input.value = first.description;
this.renderProductDetail(detailMount, first);
}
}
private renderProductDetail(mount: HTMLElement, product: CountryProduct): void {
mount.replaceChildren();
const header = this.el('div', 'cdp-product-header');
header.append(
this.el('span', 'cdp-product-name', `${product.description} (HS ${product.hs4})`),
this.el('span', 'cdp-product-value', this.formatMoney(product.totalValue)),
);
mount.append(header);
if (product.topExporters.length === 0) {
mount.append(this.makeEmpty('No exporter data'));
return;
}
const table = this.el('table', 'cdp-product-suppliers-table');
const thead = this.el('thead');
const hr = this.el('tr');
hr.append(this.el('th', '', 'Supplier'));
hr.append(this.el('th', '', 'Share'));
hr.append(this.el('th', '', 'Value'));
hr.append(this.el('th', '', 'Route Risk'));
thead.append(hr);
table.append(thead);
const tbody = this.el('tbody');
const recsMount = this.el('div', 'cdp-recommendations');
type ExporterRow = { partnerIso2: string; share: number; value: number; risk: EnrichedExporter['risk'] | null };
const renderRows = (enriched: EnrichedExporter[] | null) => {
tbody.replaceChildren();
recsMount.replaceChildren();
const importerCode = this.currentCode;
const rawRows: ExporterRow[] = enriched ?? product.topExporters.map(exp => ({
partnerIso2: exp.partnerIso2,
share: exp.share,
value: exp.value,
risk: null,
}));
// Drop self-imports (receiver = supplier) and rows with unresolved partner ISO2 codes;
// the seeder emits partnerIso2='' when a UN code can't be mapped, which surfaced as "N/A" rows.
const isVisible = (iso2: string) => Boolean(iso2) && iso2 !== importerCode;
const rows = rawRows.filter(r => isVisible(r.partnerIso2));
const visibleEnriched = enriched ? enriched.filter(e => isVisible(e.partnerIso2)) : null;
if (rows.length === 0) {
const empty = this.el('div', 'cdp-recommendation-item');
empty.textContent = '\u2139 No external suppliers in available trade data.';
recsMount.append(empty);
return;
}
for (const exp of rows) {
const tr = this.el('tr');
const supplierTd = this.el('td', 'cdp-product-supplier');
const flag = exp.partnerIso2 ? CountryDeepDivePanel.toFlagEmoji(exp.partnerIso2) : '';
supplierTd.textContent = `${flag} ${exp.partnerIso2}`;
tr.append(supplierTd);
const shareTd = this.el('td', 'cdp-product-share');
const pct = Math.round(exp.share * 100);
shareTd.textContent = `${pct}%`;
const barWrap = this.el('div', 'cdp-product-share-bar-wrap');
const bar = this.el('div', 'cdp-product-share-bar');
bar.style.width = `${Math.min(pct, 100)}%`;
if (pct >= 50) bar.classList.add('cdp-product-share-high');
barWrap.append(bar);
shareTd.append(barWrap);
tr.append(shareTd);
tr.append(this.el('td', 'cdp-product-val', this.formatMoneyAtScale(exp.value, product.totalValue)));
const riskTd = this.el('td', 'cdp-product-risk');
if (exp.risk) {
const badgeCls = `cdp-risk-badge cdp-risk-${exp.risk.riskLevel.replace('_', '-')}`;
const badgeLabels: Record<string, string> = { safe: 'Safe', at_risk: 'At Risk', critical: 'Critical', unknown: 'Unknown' };
const badge = this.el('span', badgeCls, badgeLabels[exp.risk.riskLevel] ?? exp.risk.riskLevel);
riskTd.append(badge);
if (exp.risk.transitChokepoints.length > 0) {
const cpNames = exp.risk.transitChokepoints
.map(cp => cp.chokepointName)
.join(', ');
const cpInfo = this.el('div', 'cdp-risk-chokepoints');
cpInfo.textContent = cpNames;
riskTd.append(cpInfo);
}
} else {
riskTd.textContent = '\u2014';
}
tr.append(riskTd);
tbody.append(tr);
}
if (visibleEnriched) {
const hasCritical = visibleEnriched.some(e => e.risk.riskLevel === 'critical');
const hasAtRisk = visibleEnriched.some(e => e.risk.riskLevel === 'at_risk');
const hasUnknown = visibleEnriched.some(e => e.risk.riskLevel === 'unknown');
const hasSafe = visibleEnriched.some(e => e.risk.riskLevel === 'safe');
if (hasCritical || hasAtRisk) {
for (const exp of visibleEnriched) {
if (exp.risk.riskLevel === 'safe' || exp.risk.riskLevel === 'unknown') continue;
const recCls = exp.risk.riskLevel === 'critical' ? 'cdp-recommendation-critical' : 'cdp-recommendation-warn';
const item = this.el('div', `cdp-recommendation-item ${recCls}`);
const expPct = Math.round(exp.share * 100);
let text = `\u26A0 ${product.description} imports from ${exp.partnerIso2} (${expPct}%) transit`;
if (exp.risk.transitChokepoints.length === 0) continue;
const worstCp = exp.risk.transitChokepoints.reduce((a, b) => a.disruptionScore > b.disruptionScore ? a : b);
text += ` ${worstCp.chokepointName} (disruption ${worstCp.disruptionScore}/100).`;
if (exp.safeAlternative && isVisible(exp.safeAlternative)) {
const alt = visibleEnriched.find(e => e.partnerIso2 === exp.safeAlternative);
const altPct = alt ? Math.round(alt.share * 100) : 0;
const altFlag = CountryDeepDivePanel.toFlagEmoji(exp.safeAlternative);
text += ` ${altFlag} ${exp.safeAlternative} supplies ${altPct}% via routes avoiding this chokepoint.`;
}
item.textContent = text;
recsMount.append(item);
}
} else if (hasUnknown && !hasSafe) {
const item = this.el('div', 'cdp-recommendation-item');
item.textContent = '\u2139 No modeled maritime route data available for these suppliers. Risk cannot be assessed.';
recsMount.append(item);
} else if (hasUnknown && hasSafe) {
const safeCount = visibleEnriched.filter(e => e.risk.riskLevel === 'safe').length;
const unknownCount = visibleEnriched.filter(e => e.risk.riskLevel === 'unknown').length;
const item = this.el('div', 'cdp-recommendation-item');
item.textContent = `\u2139 ${safeCount} supplier(s) verified safe. ${unknownCount} supplier(s) have no modeled route data.`;
recsMount.append(item);
} else {
const safeItem = this.el('div', 'cdp-recommendation-item cdp-recommendation-safe');
safeItem.textContent = '\u2713 All current suppliers use routes that avoid disrupted chokepoints.';
recsMount.append(safeItem);
}
}
};
renderRows(null);
table.append(tbody);
mount.append(table, recsMount);
const importerIso2 = this.currentCode;
const capturedCode = this.getCode();
if (importerIso2) {
fetchChokepointStatus().then(resp => {
if (this.getCode() !== capturedCode) return;
if (!resp.chokepoints.length) return;
const scores: ChokepointScoreMap = new Map();
for (const cp of resp.chokepoints) {
scores.set(cp.id, cp.disruptionScore);
}
const enriched = computeAlternativeSuppliers(product.topExporters, importerIso2, scores);
renderRows(enriched);
}).catch(() => {
console.warn('[deep-dive] Chokepoint status unavailable for route risk enrichment');
});
}
const source = this.el('div', 'cdp-card-footer', `Source: UN Comtrade HS4 bilateral \u00B7 ${product.year}`);
mount.append(source);
}
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 ${score?.lastUpdated ? this.shortDate(score.lastUpdated) : '—'}`;
}
if (score) {
const band = ciiBandForLevel(score.level);
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, 6);
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.currentBrief = null;
this.currentBriefGeneratedAt = null;
this.currentBriefCached = null;
this.briefBody.append(this.makeEmpty(data.error || data.reason || t('countryBrief.assessmentUnavailable')));
return;
}
this.currentBrief = data.brief;
this.currentBriefGeneratedAt = data.generatedAt ?? null;
this.currentBriefCached = data.cached === true;
const briefSources = collectBriefSources(data.sources ?? [], 6);
const summaryHtml = this.formatBrief(this.summarizeBrief(data.brief), briefSources, 0);
const text = this.el('div', 'cdp-assessment-text cdp-summary-only');
setTrustedHtml(text, trustedHtml(summaryHtml, "legacy direct innerHTML migration"));
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 sourcesFooter = renderBriefSourcesFooter(briefSources, { className: 'cdp-brief-sources' });
if (sourcesFooter) {
const summarySources = this.el('div', 'cdp-summary-only');
setTrustedHtml(summarySources, trustedHtml(sourcesFooter, "legacy direct innerHTML migration"));
this.briefBody.append(summarySources);
}
const expandedBrief = this.el('div', 'cdp-expanded-only');
const fullText = this.el('div', 'cdp-assessment-text');
setTrustedHtml(fullText, trustedHtml(this.formatBrief(data.brief, briefSources, this.currentHeadlineCount), "legacy direct innerHTML migration"));
expandedBrief.append(fullText);
if (sourcesFooter) {
const sources = this.el('div', 'cdp-expanded-only');
setTrustedHtml(sources, trustedHtml(sourcesFooter, "legacy direct innerHTML migration"));
expandedBrief.append(sources);
}
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);
// `resetPanelContent` (called at the top of renderSkeleton) already
// tore down the prior FollowButton's subscriptions; here we just
// mount a fresh one for the new country.
const followHost = this.el('span', 'cdp-follow-btn-host');
followHost.dataset.country = code;
const handle = renderFollowButton({
countryCode: code,
countryName: country,
size: 'md',
});
setTrustedHtml(followHost, trustedHtml(handle.html, "legacy direct innerHTML migration"));
this.followButtonTeardown = handle.attach(followHost);
// U8 (degraded path) — "Notify me about this country" sub-action.
// Visible only when the user is currently following this country.
// The schema PR for `alertRules.countries` has NOT merged, so the
// click just opens the existing notifications settings tab — no
// pre-fill. See plan U8 R9 + the TODO inside notify-country-link.ts
// for the future pre-fill injection point.
const notifyHost = this.el('span', 'cdp-notify-link-host');
notifyHost.dataset.country = code;
const notifyHandle = renderNotifyCountryLink({
countryCode: code,
countryName: country,
});
setTrustedHtml(notifyHost, trustedHtml(notifyHandle.html, "legacy direct innerHTML migration"));
this.notifyLinkTeardown = notifyHandle.attach(notifyHost);
left.append(flag, titleWrap, followHost, notifyHost);
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'));
setTrustedHtml(shareBtn, trustedHtml('<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>', "legacy direct innerHTML migration"));
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;
setTrustedHtml(shareBtn, trustedHtml('<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>', "legacy direct innerHTML migration"));
setTimeout(() => { setTrustedHtml(shareBtn, trustedHtml(orig, "legacy direct innerHTML migration")); }, 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);
}
});
const evidenceButton = this.el('button', 'cdp-action-btn cdp-evidence-export-btn', 'Evidence') as HTMLButtonElement;
evidenceButton.setAttribute('type', 'button');
evidenceButton.setAttribute('title', 'Export evidence bundle as Markdown (PRO)');
evidenceButton.addEventListener('click', () => {
if (!hasPremiumAccess(getAuthState())) {
trackGateHit('evidence-export');
showToast('Evidence export is available on Pro.');
return;
}
this.exportEvidenceBundle();
});
right.append(shareBtn, maxBtn, storyButton, exportButton, evidenceButton);
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 ${score?.lastUpdated ? this.shortDate(score.lastUpdated) : '—'}`);
top.append(label, updated);
scoreCard.append(top);
if (score) {
const band = ciiBandForLevel(score.level);
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')));
}
const summaryGrid = this.el('div', 'cdp-summary-grid');
summaryGrid.append(scoreCard, this.renderResilienceWidgetSlot(code));
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 [housingCard, housingBody] = this.sectionCard(
'Housing Cycle',
'BIS quarterly real residential and commercial property price indices plus household debt service ratio — early-warning signals for credit / property cycle turns.',
);
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'));
const [tradeCard, tradeBody] = this.sectionCard('Trade Exposure', 'Chokepoints most critical to this country\'s imports by sector');
this.tradeExposureBody = tradeBody;
tradeBody.append(this.makeLoading('Loading trade exposure\u2026'));
const isPro = hasPremiumAccess(getAuthState());
const [costShockCalcCard, costShockCalcBody] = this.sectionCard(
'Cost Shock Calculator',
'Model the per-sector added cost of a prolonged chokepoint closure. Drag the slider to change closure duration (1-90 days). Uses war risk premium + best bypass freight uplift × annual import value.',
);
this.costShockCalcBody = costShockCalcBody;
costShockCalcBody.append(
isPro ? this.makeLoading('Loading cost shock calculator\u2026') : this.makeProLocked('Upgrade to PRO for multi-sector cost shock modelling'),
);
const [productImportsCard, productImportsCardBody] = this.sectionCard('Product Imports', 'Top imported products by HS4 code with supplier breakdown and concentration risk.');
this.productImportsBody = productImportsCardBody;
productImportsCardBody.append(isPro ? this.makeLoading('Loading product data\u2026') : this.makeProLocked('Upgrade to PRO for product import data'));
const [debtCard, debtBody] = this.sectionCard('National Debt', 'Government debt-to-GDP ratio, total debt, and year-over-year growth.');
this.debtBody = debtBody;
debtBody.append(isPro ? this.makeLoading('Loading debt data\u2026') : this.makeProLocked('Upgrade to PRO for national debt data'));
const [sanctionsCard, sanctionsBody] = this.sectionCard('Sanctions Pressure', 'Sanctioned entities, vessels, and aircraft linked to this country.');
this.sanctionsBody = sanctionsBody;
sanctionsBody.append(isPro ? this.makeLoading('Loading sanctions data\u2026') : this.makeProLocked('Upgrade to PRO for sanctions data'));
const [comtradeCard, comtradeBody] = this.sectionCard('Trade Flows', 'Top Comtrade trade flows sorted by value, with partner and commodity.');
this.comtradeBody = comtradeBody;
comtradeBody.append(isPro ? this.makeLoading('Loading trade flows\u2026') : this.makeProLocked('Upgrade to PRO for trade flow data'));
const [tariffCard, tariffBody] = this.sectionCard('Tariff Trends', 'Effective tariff rate and historical trend direction.');
this.tariffBody = tariffBody;
tariffBody.append(isPro ? this.makeLoading('Loading tariff data\u2026') : this.makeProLocked('Upgrade to PRO for tariff trend data'));
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;
let chinaSummaryCard: HTMLElement | null = null;
// Drop any body left over from a previous China render so a non-China
// country never keeps a detached summary body reachable.
this.chinaSummaryBody = null;
if (code.toUpperCase() === 'CN') {
const [card, body] = this.sectionCard(t('countryBrief.china.title'), t('countryBrief.china.description'));
card.classList.add('cdp-china-summary');
card.setAttribute('aria-label', t('countryBrief.china.title'));
this.chinaSummaryBody = body;
this.renderChinaCountrySummary(
CHINA_DECISION_SIGNAL_GROUP_IDS.map((id) => ({
id,
state: 'loading',
signals: [],
})),
);
chinaSummaryCard = card;
}
this.housingBody = housingBody;
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…'));
housingBody.append(this.makeLoading('Loading housing cycle data…'));
marketsBody.append(this.makeLoading(t('countryBrief.loadingMarkets')));
briefBody.append(this.makeLoading(t('countryBrief.generatingBrief')));
bodyGrid.append(briefCard, ...(chinaSummaryCard ? [chinaSummaryCard] : []), factsExpanded, energyCard, maritimeCard, tradeCard, costShockCalcCard, productImportsCard, debtCard, sanctionsCard, comtradeCard, tariffCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, housingCard, marketsCard);
shell.append(header, summaryGrid, bodyGrid);
this.content.append(shell);
}
private destroyResilienceWidget(): void {
this.resilienceWidgetRequestId += 1;
this.resilienceWidget?.destroy();
this.resilienceWidget = null;
this.pendingResilienceEnergyMix = null;
}
private renderResilienceWidgetSlot(code: string): HTMLElement {
const slot = this.el('div', 'cdp-card resilience-widget');
slot.append(this.makeLoading(t('countryBrief.loadingResilienceScore')));
const requestId = ++this.resilienceWidgetRequestId;
const renderFallback = (error: unknown) => {
if (requestId !== this.resilienceWidgetRequestId) return;
this.resilienceWidget?.destroy();
this.resilienceWidget = null;
console.warn('[CountryDeepDivePanel] Failed to load resilience widget', error);
this.captureResilienceWidgetLoadFailure(error, code);
slot.replaceChildren(this.makeEmpty(t('countryBrief.resilienceScoreUnavailable')));
};
import('@/components/ResilienceWidget')
.then(({ ResilienceWidget }) => {
if (requestId !== this.resilienceWidgetRequestId) return;
if (typeof ResilienceWidget !== 'function') throw new Error('ResilienceWidget export is unavailable.');
const widget = new ResilienceWidget(code);
try {
if (this.pendingResilienceEnergyMix) {
widget.setEnergyMix(this.pendingResilienceEnergyMix);
}
this.replaceResilienceSlot(slot, widget.getElement());
this.resilienceWidget = widget;
} catch (error) {
widget.destroy();
throw error;
}
})
.catch(renderFallback);
return slot;
}
private captureResilienceWidgetLoadFailure(error: unknown, countryCode: string): void {
enqueueSentryCall((Sentry) => {
Sentry.addBreadcrumb?.({
category: 'country-deep-dive',
level: 'warning',
message: 'Resilience widget lazy load failed',
data: { countryCode },
});
Sentry.captureException?.(error instanceof Error ? error : new Error(String(error)), {
tags: { surface: 'country-deep-dive', widget: 'resilience' },
extra: { countryCode },
});
});
}
private replaceResilienceSlot(slot: HTMLElement, next: HTMLElement): void {
if (typeof slot.replaceWith === 'function') {
slot.replaceWith(next);
return;
}
if (typeof slot.parentNode?.replaceChild === 'function') {
slot.parentNode.replaceChild(next, slot);
return;
}
if (typeof slot.parentNode?.insertBefore === 'function' && typeof slot.parentNode?.removeChild === 'function') {
slot.parentNode.insertBefore(next, slot);
slot.parentNode.removeChild(slot);
}
}
private tearDownFollowButton(): void {
if (this.followButtonTeardown) {
try {
this.followButtonTeardown();
} catch {
/* swallow */
}
this.followButtonTeardown = null;
}
if (this.notifyLinkTeardown) {
try {
this.notifyLinkTeardown();
} catch {
/* swallow */
}
this.notifyLinkTeardown = null;
}
}
private resetPanelContent(): void {
this.destroyResilienceWidget();
this.tearDownFollowButton();
this.selectedSectorHs2 = null;
this.sectorBypassAbort?.abort();
this.sectorBypassAbort = null;
this.cachedTradeExposureData = null;
this.cachedSectors = [];
this.map?.clearHighlightedRoute();
this.scoreCard = null;
this.energyBody = null;
this.maritimeBody = null;
this.tradeExposureBody = null;
this.chinaSummaryBody = null;
this.productImportsBody = null;
this.debtBody = null;
this.housingBody = null;
this.sanctionsBody = null;
this.comtradeBody = null;
this.tariffBody = null;
this.costShockCalcAbort?.abort();
this.costShockCalcAbort = null;
if (this.costShockCalcDebounceTimer) {
clearTimeout(this.costShockCalcDebounceTimer);
this.costShockCalcDebounceTimer = null;
}
this.costShockCalcBody = null;
this.costShockCalcTable = null;
this.costShockCalcDurationLabel = null;
this.costShockCalcTotalLabel = null;
this.costShockCalcPrimaryChokepoint = null;
this.costShockCalcClosureDays = 30;
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', `${signals.militaryFlights} near · ${signals.militaryFlightsInCountry} inside borders`);
this.addSignalChip(chips, signals.militaryVessels, t('countryBrief.chips.navalVessels'), '⚓', 'military', `${signals.militaryVessels} near · ${signals.militaryVesselsInCountry} inside borders`);
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, tooltip?: string): void {
if (count <= 0) return;
container.append(this.makeSignalChip(`${icon} ${count} ${label}`, cls, tooltip));
}
private makeSignalChip(text: string, cls: string, tooltip?: string): HTMLElement {
const chip = this.el('span', `cdp-signal-chip chip-${cls}`, text);
if (tooltip) chip.title = tooltip;
return chip;
}
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, 6)) {
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 renderChinaCountrySummary(groups: ChinaCountrySummaryGroup[]): void {
if (!this.chinaSummaryBody) return;
// The group <section>s are aria-live regions. They must stay in the DOM
// across updates — screen readers only announce mutations *inside* an
// existing live region, so tearing the sections down and rebuilding them
// (the old replaceChildren-the-card approach) meant state transitions
// like loading→stale were never announced.
let grid = this.chinaSummaryBody.querySelector<HTMLElement>('.cdp-china-summary-grid');
if (!grid) {
this.chinaSummaryBody.replaceChildren();
grid = this.el('div', 'cdp-china-summary-grid');
this.chinaSummaryBody.append(grid);
}
for (const group of groups) {
let section = grid.querySelector<HTMLElement>(`[data-group-id="${group.id}"]`);
if (!section) {
section = this.el('section', 'cdp-china-summary-group');
section.setAttribute('data-group-id', group.id);
section.setAttribute('role', 'status');
section.setAttribute('aria-live', 'polite');
grid.append(section);
}
// Manager updates push the full five-group snapshot each time any one
// group resolves; skip untouched groups so unchanged content is not
// re-announced on every sibling resolution.
const revision = JSON.stringify(group);
if (section.dataset.revision === revision) continue;
section.dataset.revision = revision;
const groupLabel = this.chinaSummaryGroupLabel(group.id);
const stateLabel = t(`countryBrief.china.status.${group.state}`);
section.className = `cdp-china-summary-group cdp-china-summary-group--${group.state}`;
section.setAttribute('aria-label', `${groupLabel}: ${stateLabel}`);
const heading = this.el('div', 'cdp-china-summary-heading');
heading.append(
this.el('h4', 'cdp-china-summary-title', groupLabel),
this.el('span', 'cdp-china-summary-state', stateLabel),
);
const children: HTMLElement[] = [heading];
if (group.signals.length === 0) {
children.push(this.el(
'div',
'cdp-china-summary-empty',
group.unavailableReason || t(`countryBrief.china.status.${group.state}`),
));
} else {
for (const signal of group.signals) {
const item = this.el('div', 'cdp-china-summary-signal');
if (signal.stale) item.dataset.stale = 'true';
item.append(
this.el('div', 'cdp-china-summary-signal-label', signal.label),
this.el('div', 'cdp-china-summary-signal-value', signal.value),
);
if (signal.observedAt) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.observed')} ${signal.observedAt}`));
}
if (signal.publishedAt) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.published')} ${signal.publishedAt}`));
}
if (signal.effectiveAt) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.effective')} ${signal.effectiveAt}`));
}
if (signal.sectors?.length) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.sectors')} ${signal.sectors.join(', ')}`));
}
if (signal.entities?.length) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.entities')} ${signal.entities.join(', ')}`));
}
if (signal.translationState) {
item.append(this.el('div', 'cdp-china-summary-attribution', `${t('countryBrief.china.translation')} ${signal.translationState}`));
}
const sourceAttribution = this.el('div', 'cdp-china-summary-attribution');
const sourcePrefix = `${t('countryBrief.china.source')} `;
const safeHref = signal.sourceUrl ? sanitizeUrl(signal.sourceUrl) : '';
if (safeHref) {
sourceAttribution.append(document.createTextNode(sourcePrefix));
const sourceLink = this.el('a', 'cdp-china-summary-source-link', signal.source);
sourceLink.setAttribute('href', safeHref);
sourceLink.setAttribute('target', '_blank');
sourceLink.setAttribute('rel', 'noopener noreferrer');
sourceAttribution.append(sourceLink);
} else {
sourceAttribution.textContent = `${sourcePrefix}${signal.source}`;
}
item.append(sourceAttribution);
children.push(item);
}
if (group.unavailableReason) {
children.push(this.el('div', 'cdp-china-summary-note', group.unavailableReason));
}
}
section.replaceChildren(...children);
}
}
private chinaSummaryGroupLabel(id: ChinaCountrySummaryGroupId): string {
const keys: Record<ChinaCountrySummaryGroupId, string> = {
macro: 'macroSignals',
'policy-enforcement': 'policyEvents',
'cross-strait-activity': 'crossStraitActivity',
'corporate-disclosures': 'corporateDisclosures',
'corridor-conditions': 'corridorConditions',
'activity-nowcast': 'activityNowcast',
};
return t(`countryBrief.china.${keys[id]}`);
}
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', t('common.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, sources: BriefSource[] = [], headlineCount = 0): string {
return formatIntelBrief(
text,
sources.length > 0
? { sources }
: headlineCount > 0
? { count: headlineCount, hrefPrefix: '#cdp-news-' }
: undefined,
);
}
private exportEvidenceBundle(): void {
if (!this.currentCode || !this.currentName) return;
const exportedAt = new Date().toISOString();
const data: CountryEvidenceBundleInput = {
country: this.currentName,
code: this.currentCode,
context: 'Country dossier',
generatedAt: exportedAt,
exportedAt,
};
if (this.currentScore) {
data.score = this.currentScore.score;
data.level = this.currentScore.level;
data.trend = this.currentScore.trend;
data.components = this.currentScore.components;
}
if (this.currentSignals) {
data.signals = {
criticalNews: this.currentSignals.criticalNews,
protests: this.currentSignals.protests,
militaryFlights: this.currentSignals.militaryFlights,
militaryVessels: this.currentSignals.militaryVessels,
militaryFlightsInCountry: this.currentSignals.militaryFlightsInCountry,
militaryVesselsInCountry: this.currentSignals.militaryVesselsInCountry,
outages: this.currentSignals.outages,
aisDisruptions: this.currentSignals.aisDisruptions,
satelliteFires: this.currentSignals.satelliteFires,
radiationAnomalies: this.currentSignals.radiationAnomalies,
temporalAnomalies: this.currentSignals.temporalAnomalies,
cyberThreats: this.currentSignals.cyberThreats,
earthquakes: this.currentSignals.earthquakes,
displacementOutflow: this.currentSignals.displacementOutflow,
climateStress: this.currentSignals.climateStress,
conflictEvents: this.currentSignals.conflictEvents,
activeStrikes: this.currentSignals.activeStrikes,
orefSirens: this.currentSignals.orefSirens,
orefHistory24h: this.currentSignals.orefHistory24h,
aviationDisruptions: this.currentSignals.aviationDisruptions,
travelAdvisories: this.currentSignals.travelAdvisories,
travelAdvisoryMaxLevel: this.currentSignals.travelAdvisoryMaxLevel,
gpsJammingHexes: this.currentSignals.gpsJammingHexes,
thermalEscalations: this.currentSignals.thermalEscalations,
sanctionsDesignations: this.currentSignals.sanctionsDesignations,
sanctionsNewDesignations: this.currentSignals.sanctionsNewDesignations,
};
}
if (this.currentBrief) data.brief = this.currentBrief;
if (this.currentBriefGeneratedAt) data.briefGeneratedAt = new Date(this.currentBriefGeneratedAt).toISOString();
if (this.currentBriefCached != null) data.briefCached = this.currentBriefCached;
if (this.currentHeadlines.length > 0) {
data.headlines = this.currentHeadlines.map((headline) => ({
title: headline.title,
source: headline.source,
link: headline.link,
pubDate: headline.pubDate ? new Date(headline.pubDate).toISOString() : undefined,
}));
}
exportCountryEvidenceMarkdown(data);
}
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 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, '🌍');
}
}