| 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, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| 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; |
| |
| |
| |
| |
| |
| private followButtonTeardown: (() => void) | null = null; |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| public updateMultiSectorCostShock(data: MultiSectorShockResponse | null): void { |
| if (!this.costShockCalcBody) return; |
| this.costShockCalcBody.replaceChildren(); |
|
|
| if (!data || (!data.sectors.length && !data.unavailableReason)) { |
| |
| |
| |
| |
| this.costShockCalcBody.parentElement?.remove(); |
| this.costShockCalcBody = null; |
| return; |
| } |
|
|
| this.costShockCalcPrimaryChokepoint = data.chokepointId; |
| this.costShockCalcClosureDays = Number.isFinite(data.closureDays) && data.closureDays > 0 ? data.closureDays : 30; |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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; |
|
|
| |
| 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; |
| this.renderMultiSectorShockRows(resp.sectors); |
| } catch { |
| |
| } |
| } |
|
|
| 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()}`; |
| } |
|
|
| |
| |
| |
| |
| 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()}`; |
| } |
|
|
| |
| |
| |
| |
| 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); |
| |
| |
| |
| |
| 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()); |
| } |
|
|
| |
| |
| |
| |
| |
| this.renderAtlasExposure(); |
| } |
|
|
| private renderAtlasExposure(): void { |
| if (!this.energyBody) return; |
| const iso2 = this.currentCode; |
| if (!iso2 || iso2.length !== 2) return; |
|
|
| |
| |
| |
| |
| 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; |
| }; |
| |
| |
| |
| |
| 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(() => {}); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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'); |
| |
| |
| |
| |
| 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, |
| |
| |
| |
| label: truncateDisruptionLabel(e.eventType, e.shortDescription), |
| |
| |
| |
| event: e.assetType === 'storage' |
| ? 'energy:open-storage-facility-detail' |
| : 'energy:open-pipeline-detail', |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| detail: e.assetType === 'storage' |
| ? { facilityId: e.assetId } |
| : { pipelineId: e.assetId }, |
| })), |
| ); |
| } catch { |
| |
| |
| |
| } |
| } |
|
|
| 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 { } |
| }); |
| 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) { |
| |
| |
| 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, |
| })); |
| |
| |
| 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; |
| |
| 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); |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| 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 { |
| |
| } |
| this.followButtonTeardown = null; |
| } |
| if (this.notifyLinkTeardown) { |
| try { |
| this.notifyLinkTeardown(); |
| } catch { |
| |
| } |
| 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; |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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, '🌍'); |
| } |
| } |
|
|