| import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; |
| import { formatIntelBrief } from '@/utils/format-intel-brief'; |
| import { collectBriefSources, renderBriefSourcesFooter, type BriefSource } from '@/utils/brief-sources'; |
| import { t } from '@/services/i18n'; |
| import { getCSSColor, showToast } from '@/utils'; |
| import type { CountryScore } from '@/services/country-instability'; |
| import type { NewsItem } from '@/types'; |
| import type { PredictionMarket } from '@/services/prediction'; |
| import type { AssetType } from '@/types'; |
| import type { CountryBriefSignals } from '@/types'; |
| import type { CountryBriefPanel, CountryIntelData, StockIndexData } from '@/components/CountryBriefPanel'; |
| import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets'; |
| import { PORTS } from '@/config/ports'; |
| import type { Port } from '@/types'; |
| import { exportCountryBriefJSON, exportCountryBriefCSV, exportCountryEvidenceMarkdown } from '@/utils/export'; |
| import type { CountryBriefExport, CountryEvidenceBundleInput } from '@/utils/export'; |
| import { ME_STRIKE_BOUNDS } from '@/services/country-geometry'; |
| import { toFlagEmoji } from '@/utils/country-flag'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
| import { getAuthState } from '@/services/auth-state'; |
| import { |
| evaluateAvailableExportFormats, |
| evaluateExportGate, |
| exportLockToGateReason, |
| hasPremiumAccess, |
| } from '@/services/panel-gating'; |
| import { primeExportGateActivation } from '@/services/export-gate'; |
| import { exportGateCopy } from '@/components/ExportGateControl'; |
| import { trackGateHit } from '@/services/analytics'; |
|
|
|
|
| type BriefAssetType = AssetType | 'port'; |
|
|
| export class CountryBriefPage implements CountryBriefPanel { |
| private static BRIEF_BOUNDS: Record<string, { n: number; s: number; e: number; w: number }> = { |
| ...ME_STRIKE_BOUNDS, |
| CN: { n: 53.6, s: 18.2, e: 134.8, w: 73.5 }, TW: { n: 25.3, s: 21.9, e: 122, w: 120 }, |
| JP: { n: 45.5, s: 24.2, e: 153.9, w: 122.9 }, KR: { n: 38.6, s: 33.1, e: 131.9, w: 124.6 }, |
| KP: { n: 43.0, s: 37.7, e: 130.7, w: 124.2 }, IN: { n: 35.5, s: 6.7, e: 97.4, w: 68.2 }, |
| PK: { n: 37, s: 24, e: 77, w: 61 }, AF: { n: 38.5, s: 29.4, e: 74.9, w: 60.5 }, |
| UA: { n: 52.4, s: 44.4, e: 40.2, w: 22.1 }, RU: { n: 82, s: 41.2, e: 180, w: 19.6 }, |
| BY: { n: 56.2, s: 51.3, e: 32.8, w: 23.2 }, PL: { n: 54.8, s: 49, e: 24.1, w: 14.1 }, |
| EG: { n: 31.7, s: 22, e: 36.9, w: 25 }, LY: { n: 33, s: 19.5, e: 25, w: 9.4 }, |
| SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 }, |
| GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 }, |
| FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 }, |
| }; |
|
|
| private static INFRA_ICONS: Record<BriefAssetType, string> = { |
| pipeline: '\u{1F50C}', |
| cable: '\u{1F310}', |
| datacenter: '\u{1F5A5}\uFE0F', |
| base: '\u{1F3DB}\uFE0F', |
| nuclear: '\u2622\uFE0F', |
| port: '\u2693', |
| }; |
|
|
| private static INFRA_LABELS: Record<BriefAssetType, string> = { |
| pipeline: 'pipeline', |
| cable: 'cable', |
| datacenter: 'datacenter', |
| base: 'base', |
| nuclear: 'nuclear', |
| port: 'port', |
| }; |
|
|
| private overlay: HTMLElement; |
| private currentCode: string | null = null; |
| private currentName: string | null = null; |
| private currentHeadlineCount = 0; |
| 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 onCloseCallback?: () => void; |
| private onShareStory?: (code: string, name: string) => void; |
| private onExportImage?: (code: string, name: string) => void; |
| private abortController: AbortController = new AbortController(); |
|
|
| constructor() { |
| this.overlay = document.createElement('div'); |
| this.overlay.className = 'country-brief-overlay'; |
| document.body.appendChild(this.overlay); |
|
|
| |
| |
| this.overlay.addEventListener('click', (e) => { |
| const target = e.target as HTMLElement; |
|
|
| |
| if (target.classList.contains('country-brief-overlay')) { |
| this.hide(); |
| return; |
| } |
|
|
| |
| if (target.closest('.cb-close')) { |
| this.hide(); |
| return; |
| } |
|
|
| |
| const linkShareBtn = target.closest('.cb-link-share-btn') as HTMLButtonElement | null; |
| if (linkShareBtn) { |
| if (!this.currentCode || !this.currentName) return; |
| const url = `${window.location.origin}/?c=${this.currentCode}`; |
| navigator.clipboard.writeText(url).then(() => { |
| const orig = linkShareBtn.innerHTML; |
| setTrustedHtml(linkShareBtn, trustedHtml('<svg width="16" height="16" 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(linkShareBtn, trustedHtml(orig, "legacy direct innerHTML migration")); }, 1500); |
| }).catch(() => {}); |
| return; |
| } |
|
|
| |
| if (target.closest('.cb-share-btn')) { |
| if (this.onShareStory && this.currentCode && this.currentName) { |
| this.onShareStory(this.currentCode, this.currentName); |
| } |
| return; |
| } |
|
|
| |
| if (target.closest('.cb-print-btn')) { |
| window.print(); |
| return; |
| } |
|
|
| |
| if (target.closest('.cb-export-btn')) { |
| e.stopPropagation(); |
| const exportMenu = this.overlay.querySelector('.cb-export-menu'); |
| this.syncStructuredExportOptions(); |
| exportMenu?.classList.toggle('hidden'); |
| return; |
| } |
|
|
| |
| const exportOption = target.closest('.cb-export-option') as HTMLElement | null; |
| if (exportOption) { |
| const format = exportOption.dataset.format; |
| if (format === 'image') { |
| if (this.onExportImage && this.currentCode && this.currentName) { |
| this.onExportImage(this.currentCode, this.currentName); |
| } |
| } else if (format === 'pdf') { |
| this.exportPdf(); |
| } else if (format === 'json' || format === 'csv') { |
| if (this.canExportStructuredData(format)) this.exportBrief(format); |
| } else if (format === 'evidence-md') { |
| this.exportBrief(format); |
| } |
| const exportMenu = this.overlay.querySelector('.cb-export-menu'); |
| exportMenu?.classList.add('hidden'); |
| return; |
| } |
|
|
| |
| if (target.classList.contains('cb-citation')) { |
| const href = target.getAttribute('href'); |
| if (href?.startsWith('#')) { |
| e.preventDefault(); |
| const el = this.overlay.querySelector(href); |
| el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| el?.classList.add('cb-news-highlight'); |
| setTimeout(() => el?.classList.remove('cb-news-highlight'), 2000); |
| } |
| return; |
| } |
|
|
| |
| const exportMenu = this.overlay.querySelector('.cb-export-menu'); |
| if (exportMenu && !exportMenu.classList.contains('hidden')) { |
| exportMenu.classList.add('hidden'); |
| } |
| }); |
|
|
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide(); |
| }); |
| } |
|
|
| private countryFlag(code: string): string { |
| return toFlagEmoji(code, '🌍'); |
| } |
|
|
| private levelColor(level: string): string { |
| const varMap: Record<string, string> = { |
| critical: '--semantic-critical', |
| high: '--semantic-high', |
| elevated: '--semantic-elevated', |
| normal: '--semantic-normal', |
| low: '--semantic-low', |
| }; |
| return getCSSColor(varMap[level] || '--text-dim'); |
| } |
|
|
| private levelBadge(level: string): string { |
| const color = this.levelColor(level); |
| const levelKey = level as 'critical' | 'high' | 'elevated' | 'moderate' | 'normal' | 'low'; |
| const label = t(`countryBrief.levels.${levelKey}`); |
| return `<span class="cb-badge" style="background:${color}20;color:${color};border:1px solid ${color}40">${label.toUpperCase()}</span>`; |
| } |
|
|
| private trendIndicator(trend: string): string { |
| const arrow = trend === 'rising' ? '↗' : trend === 'falling' ? '↘' : '→'; |
| const cls = trend === 'rising' ? 'trend-up' : trend === 'falling' ? 'trend-down' : 'trend-stable'; |
| const trendKey = trend as 'rising' | 'falling' | 'stable'; |
| const trendLabel = t(`countryBrief.trends.${trendKey}`); |
| return `<span class="cb-trend ${cls}">${arrow} ${trendLabel}</span>`; |
| } |
|
|
| private scoreRing(score: number, level: string): string { |
| const color = this.levelColor(level); |
| const pct = Math.min(100, Math.max(0, score)); |
| const circumference = 2 * Math.PI * 42; |
| const dashOffset = circumference * (1 - pct / 100); |
| return ` |
| <div class="cb-score-ring"> |
| <svg viewBox="0 0 100 100" width="120" height="120"> |
| <circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="6"/> |
| <circle cx="50" cy="50" r="42" fill="none" stroke="${color}" stroke-width="6" |
| stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}" |
| stroke-linecap="round" transform="rotate(-90 50 50)" |
| style="transition: stroke-dashoffset 0.8s ease"/> |
| </svg> |
| <div class="cb-score-value" style="color:${color}">${score}</div> |
| <div class="cb-score-label">/ 100</div> |
| </div>`; |
| } |
|
|
| private componentBars(components: CountryScore['components']): string { |
| const items = [ |
| { label: t('modals.countryBrief.components.unrest'), value: components.unrest, icon: '📢' }, |
| { label: t('modals.countryBrief.components.conflict'), value: components.conflict, icon: '⚔' }, |
| { label: t('modals.countryBrief.components.security'), value: components.security, icon: '🛡️' }, |
| { label: t('modals.countryBrief.components.information'), value: components.information, icon: '📡' }, |
| ]; |
| return items.map(({ label, value, icon }) => { |
| const pct = Math.min(100, Math.max(0, value)); |
| const color = pct >= 70 ? getCSSColor('--semantic-critical') : pct >= 50 ? getCSSColor('--semantic-high') : pct >= 30 ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-normal'); |
| return ` |
| <div class="cb-comp-row"> |
| <span class="cb-comp-icon">${icon}</span> |
| <span class="cb-comp-label">${label}</span> |
| <div class="cb-comp-bar"><div class="cb-comp-fill" style="width:${pct}%;background:${color}"></div></div> |
| <span class="cb-comp-val">${Math.round(value)}</span> |
| </div>`; |
| }).join(''); |
| } |
|
|
| private signalChips(signals: CountryBriefSignals): string { |
| const chips: string[] = []; |
| if (signals.criticalNews > 0) chips.push(`<span class="signal-chip conflict">🚨 ${signals.criticalNews} Critical News</span>`); |
| if (signals.protests > 0) chips.push(`<span class="signal-chip protest">📢 ${signals.protests} ${t('modals.countryBrief.signals.protests')}</span>`); |
| if (signals.militaryFlights > 0) { |
| const tip = `${signals.militaryFlights} near · ${signals.militaryFlightsInCountry} inside borders`; |
| chips.push(`<span class="signal-chip military" title="${tip}">✈️ ${signals.militaryFlights} ${t('modals.countryBrief.signals.militaryAir')}</span>`); |
| } |
| if (signals.militaryVessels > 0) { |
| const tip = `${signals.militaryVessels} near · ${signals.militaryVesselsInCountry} inside borders`; |
| chips.push(`<span class="signal-chip military" title="${tip}">⚓ ${signals.militaryVessels} ${t('modals.countryBrief.signals.militarySea')}</span>`); |
| } |
| if (signals.outages > 0) chips.push(`<span class="signal-chip outage">🌐 ${signals.outages} ${t('modals.countryBrief.signals.outages')}</span>`); |
| if (signals.aisDisruptions > 0) chips.push(`<span class="signal-chip outage">🚢 ${signals.aisDisruptions} AIS Disruptions</span>`); |
| if (signals.satelliteFires > 0) chips.push(`<span class="signal-chip climate">🔥 ${signals.satelliteFires} Satellite Fires</span>`); |
| if (signals.radiationAnomalies > 0) chips.push(`<span class="signal-chip outage">☢️ ${signals.radiationAnomalies} Radiation Anomalies</span>`); |
| if (signals.temporalAnomalies > 0) chips.push(`<span class="signal-chip outage">⏱️ ${signals.temporalAnomalies} Temporal Anomalies</span>`); |
| if (signals.cyberThreats > 0) chips.push(`<span class="signal-chip conflict">🛡️ ${signals.cyberThreats} Cyber Threats</span>`); |
| if (signals.earthquakes > 0) chips.push(`<span class="signal-chip quake">🌍 ${signals.earthquakes} ${t('modals.countryBrief.signals.earthquakes')}</span>`); |
| 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.push(`<span class="signal-chip displacement">🌊 ${fmt} ${t('modals.countryBrief.signals.displaced')}</span>`); |
| } |
| if (signals.climateStress > 0) chips.push(`<span class="signal-chip climate">🌡️ ${t('modals.countryBrief.signals.climate')}</span>`); |
| if (signals.conflictEvents > 0) chips.push(`<span class="signal-chip conflict">⚔️ ${signals.conflictEvents} ${t('modals.countryBrief.signals.conflictEvents')}</span>`); |
| if (signals.activeStrikes > 0) chips.push(`<span class="signal-chip conflict">\u{1F4A5} ${signals.activeStrikes} ${t('modals.countryBrief.signals.activeStrikes')}</span>`); |
| if (signals.travelAdvisories > 0 && signals.travelAdvisoryMaxLevel) { |
| const advisoryClass = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'conflict' |
| : signals.travelAdvisoryMaxLevel === 'reconsider' ? 'outage' |
| : 'military'; |
| const advisoryLabel = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'Do Not Travel' |
| : signals.travelAdvisoryMaxLevel === 'reconsider' ? 'Reconsider Travel' |
| : 'Exercise Caution'; |
| chips.push(`<span class="signal-chip ${advisoryClass}">\u26A0\uFE0F ${signals.travelAdvisories} Advisory: ${advisoryLabel}</span>`); |
| } |
| if (signals.orefSirens > 0) chips.push(`<span class="signal-chip conflict">\u{1F6A8} ${signals.orefSirens} Active Sirens</span>`); |
| if (signals.orefHistory24h > 0) chips.push(`<span class="signal-chip conflict">\u{1F553} ${signals.orefHistory24h} Sirens / 24h</span>`); |
| if (signals.aviationDisruptions > 0) chips.push(`<span class="signal-chip outage">\u{1F6AB} ${signals.aviationDisruptions} ${t('modals.countryBrief.signals.aviationDisruptions')}</span>`); |
| if (signals.gpsJammingHexes > 0) chips.push(`<span class="signal-chip outage">\u{1F4E1} ${signals.gpsJammingHexes} ${t('modals.countryBrief.signals.gpsJammingZones')}</span>`); |
| chips.push(`<span class="signal-chip stock-loading">📈 ${t('modals.countryBrief.loadingIndex')}</span>`); |
| return chips.join(''); |
| } |
|
|
| 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 showLoading(): void { |
| this.currentCode = '__loading__'; |
| setTrustedHtml(this.overlay, trustedHtml(` |
| <div class="country-brief-page"> |
| <div class="cb-header"> |
| <div class="cb-header-left"> |
| <span class="cb-flag">🌍</span> |
| <span class="cb-country-name">${t('modals.countryBrief.identifying')}</span> |
| </div> |
| <div class="cb-header-right"> |
| <button class="cb-close" aria-label="${t('components.newsPanel.close')}">×</button> |
| </div> |
| </div> |
| <div class="cb-body"> |
| <div class="cb-loading-state"> |
| <div class="intel-skeleton"></div> |
| <div class="intel-skeleton short"></div> |
| <span class="intel-loading-text">${t('modals.countryBrief.locating')}</span> |
| </div> |
| </div> |
| </div>`, "legacy direct innerHTML migration")); |
| |
| this.overlay.classList.add('active'); |
| } |
|
|
| public showGeoError(onRetry: () => void): void { |
| this.currentCode = '__error__'; |
| this.overlay.textContent = ''; |
|
|
| const page = document.createElement('div'); |
| page.className = 'country-brief-page'; |
|
|
| const header = document.createElement('div'); |
| header.className = 'cb-header'; |
| const headerLeft = document.createElement('div'); |
| headerLeft.className = 'cb-header-left'; |
| const flag = document.createElement('span'); |
| flag.className = 'cb-flag'; |
| flag.textContent = '\u26A0\uFE0F'; |
| const title = document.createElement('span'); |
| title.className = 'cb-country-name'; |
| title.textContent = t('countryBrief.geocodeFailed'); |
| headerLeft.append(flag, title); |
| const headerRight = document.createElement('div'); |
| headerRight.className = 'cb-header-right'; |
| const closeX = document.createElement('button'); |
| closeX.className = 'cb-close'; |
| closeX.setAttribute('aria-label', t('components.newsPanel.close')); |
| closeX.textContent = '\u00D7'; |
| headerRight.append(closeX); |
| header.append(headerLeft, headerRight); |
|
|
| const body = document.createElement('div'); |
| body.className = 'cb-body'; |
| const errorWrap = document.createElement('div'); |
| errorWrap.className = 'cb-geo-error'; |
| const actions = document.createElement('div'); |
| actions.className = 'cb-geo-error-actions'; |
| const retryBtn = document.createElement('button'); |
| retryBtn.className = 'cb-geo-retry-btn'; |
| retryBtn.textContent = t('countryBrief.retryBtn'); |
| retryBtn.addEventListener('click', () => onRetry(), { once: true }); |
| const closeBtn = document.createElement('button'); |
| closeBtn.className = 'cb-geo-close-btn'; |
| closeBtn.textContent = t('countryBrief.closeBtn'); |
| closeBtn.addEventListener('click', () => this.hide(), { once: true }); |
| actions.append(retryBtn, closeBtn); |
| errorWrap.append(actions); |
| body.append(errorWrap); |
|
|
| page.append(header, body); |
| this.overlay.append(page); |
| this.overlay.classList.add('active'); |
| } |
|
|
| public get signal(): AbortSignal { |
| return this.abortController.signal; |
| } |
|
|
| 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; |
| const flag = this.countryFlag(code); |
|
|
| const tierBadge = !signals.isTier1 |
| ? `<span class="cb-tier-badge">${t('modals.countryBrief.limitedCoverage')}</span>` |
| : ''; |
|
|
| setTrustedHtml(this.overlay, trustedHtml(` |
| <div class="country-brief-page"> |
| <div class="cb-header"> |
| <div class="cb-header-left"> |
| <span class="cb-flag">${flag}</span> |
| <span class="cb-country-name">${escapeHtml(country)}</span> |
| ${score ? this.levelBadge(score.level) : ''} |
| ${score ? this.trendIndicator(score.trend) : ''} |
| ${tierBadge} |
| </div> |
| <div class="cb-header-right"> |
| <button class="cb-link-share-btn" title="${t('components.countryBrief.shareLink')}"> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg> |
| </button> |
| <button class="cb-share-btn" title="${t('components.countryBrief.shareStory')}"> |
| <svg width="16" height="16" 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> |
| </button> |
| <button class="cb-print-btn" title="${t('components.countryBrief.printPdf')}"> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg> |
| </button> |
| <div style="position:relative;display:inline-block"> |
| <button class="cb-export-btn" title="${t('components.countryBrief.exportData')}"> |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
| </button> |
| <div class="cb-export-menu hidden"> |
| <button class="cb-export-option" data-format="image">${t('common.exportImage')}</button> |
| <button class="cb-export-option" data-format="pdf">${t('common.exportPdf')}</button> |
| <button class="cb-export-option" data-format="json">${t('common.exportJson')}</button> |
| <button class="cb-export-option" data-format="csv">${t('common.exportCsv')}</button> |
| <button class="cb-export-option" data-format="evidence-md">Evidence Markdown</button> |
| </div> |
| </div> |
| <button class="cb-close" aria-label="${t('components.newsPanel.close')}">×</button> |
| </div> |
| </div> |
| <div class="cb-body"> |
| <div class="cb-grid"> |
| <div class="cb-col-left"> |
| ${score ? ` |
| <section class="cb-section cb-risk-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.instabilityIndex')}</h3> |
| <div class="cb-risk-content"> |
| ${this.scoreRing(score.score, score.level)} |
| <div class="cb-components"> |
| ${this.componentBars(score.components)} |
| </div> |
| </div> |
| </section>` : signals.isTier1 ? '' : ` |
| <section class="cb-section cb-risk-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.instabilityIndex')}</h3> |
| <div class="cb-not-tracked"> |
| <span class="cb-not-tracked-icon">📊</span> |
| <span>${t('modals.countryBrief.notTracked', { country: escapeHtml(country) })}</span> |
| </div> |
| </section>`} |
| |
| <section class="cb-section cb-brief-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.intelBrief')}</h3> |
| <div class="cb-brief-content"> |
| <div class="intel-brief-loading"> |
| <div class="intel-skeleton"></div> |
| <div class="intel-skeleton short"></div> |
| <div class="intel-skeleton"></div> |
| <div class="intel-skeleton short"></div> |
| <span class="intel-loading-text">${t('modals.countryBrief.generatingBrief')}</span> |
| </div> |
| </div> |
| </section> |
| |
| <section class="cb-section cb-news-section" style="display:none"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.topNews')}</h3> |
| <div class="cb-news-content"></div> |
| </section> |
| </div> |
| |
| <div class="cb-col-right"> |
| <section class="cb-section cb-signals-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.activeSignals')}</h3> |
| <div class="cb-signals-grid"> |
| ${this.signalChips(signals)} |
| </div> |
| </section> |
| |
| <section class="cb-section cb-timeline-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.timeline')}</h3> |
| <div class="cb-timeline-mount"></div> |
| </section> |
| |
| <section class="cb-section cb-markets-section"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.predictionMarkets')}</h3> |
| <div class="cb-markets-content"> |
| <span class="intel-loading-text">${t('modals.countryBrief.loadingMarkets')}</span> |
| </div> |
| </section> |
| |
| <section class="cb-section cb-infra-section" style="display:none"> |
| <h3 class="cb-section-title">${t('modals.countryBrief.infrastructure')}</h3> |
| <div class="cb-infra-content"></div> |
| </section> |
| |
| </div> |
| </div> |
| </div> |
| </div>`, "legacy direct innerHTML migration")); |
|
|
| |
| |
|
|
| this.overlay.classList.add('active'); |
| } |
|
|
| public updateBrief(data: CountryIntelData): void { |
| if (data.code !== this.currentCode) return; |
| const section = this.overlay.querySelector('.cb-brief-content'); |
| if (!section) return; |
|
|
| if (data.error || data.skipped || !data.brief) { |
| const msg = data.error || data.reason || t('modals.countryBrief.briefUnavailable'); |
| setTrustedHtml(section, trustedHtml(`<div class="intel-error">${escapeHtml(msg)}</div>`, "legacy direct innerHTML migration")); |
| return; |
| } |
|
|
| this.currentBrief = data.brief; |
| this.currentBriefGeneratedAt = data.generatedAt ?? null; |
| this.currentBriefCached = data.cached === true; |
| const briefSources = collectBriefSources(data.sources ?? [], 6); |
| const formatted = this.formatBrief(data.brief, briefSources, this.currentHeadlineCount); |
| const sourcesFooter = renderBriefSourcesFooter(briefSources, { className: 'cb-brief-sources' }); |
| setTrustedHtml(section, trustedHtml(` |
| <div class="cb-brief-text">${formatted}</div> |
| ${sourcesFooter} |
| <div class="cb-brief-footer"> |
| ${data.cached ? `<span class="intel-cached">📋 ${t('modals.countryBrief.cached')}</span>` : `<span class="intel-fresh">✨ ${t('modals.countryBrief.fresh')}</span>`} |
| <span class="intel-timestamp">${data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString() : ''}</span> |
| </div>`, "legacy direct innerHTML migration")); |
| } |
|
|
| public updateMarkets(markets: PredictionMarket[]): void { |
| const section = this.overlay.querySelector('.cb-markets-content'); |
| if (!section) return; |
|
|
| if (markets.length === 0) { |
| setTrustedHtml(section, trustedHtml(`<span class="cb-empty">${t('modals.countryBrief.noMarkets')}</span>`, "legacy direct innerHTML migration")); |
| return; |
| } |
|
|
| setTrustedHtml(section, trustedHtml(markets.slice(0, 3).map(m => { |
| const pct = Math.round(m.yesPrice); |
| const noPct = 100 - pct; |
| const vol = m.volume ? `$${(m.volume / 1000).toFixed(0)}k vol` : ''; |
| const safeUrl = sanitizeUrl(m.url || ''); |
| const link = safeUrl ? ` <a href="${safeUrl}" target="_blank" rel="noopener" class="cb-market-link">↗</a>` : ''; |
| return ` |
| <div class="cb-market-item"> |
| <div class="cb-market-title">${escapeHtml(m.title.slice(0, 100))}${link}</div> |
| <div class="market-bar"> |
| <div class="market-yes" style="width:${pct}%">${pct}%</div> |
| <div class="market-no" style="width:${noPct}%">${noPct > 15 ? noPct + '%' : ''}</div> |
| </div> |
| ${vol ? `<div class="market-vol">${vol}</div>` : ''} |
| </div>`; |
| }).join(''), "legacy direct innerHTML migration")); |
| } |
|
|
| public updateStock(data: StockIndexData): void { |
| const el = this.overlay.querySelector('.stock-loading'); |
| if (!el) return; |
|
|
| if (!data.available) { |
| el.remove(); |
| return; |
| } |
|
|
| const pct = parseFloat(data.weekChangePercent); |
| const sign = pct >= 0 ? '+' : ''; |
| const cls = pct >= 0 ? 'stock-up' : 'stock-down'; |
| const arrow = pct >= 0 ? '📈' : '📉'; |
| el.className = `signal-chip stock ${cls}`; |
| setTrustedHtml(el, trustedHtml(`${arrow} ${escapeHtml(data.indexName)}: ${sign}${data.weekChangePercent}% (1W)`, "legacy direct innerHTML migration")); |
| } |
|
|
| public updateNews(headlines: NewsItem[]): void { |
| const section = this.overlay.querySelector('.cb-news-section') as HTMLElement | null; |
| const content = this.overlay.querySelector('.cb-news-content'); |
| if (!section || !content || headlines.length === 0) return; |
|
|
| const items = headlines.slice(0, 8); |
| this.currentHeadlineCount = items.length; |
| this.currentHeadlines = items; |
| section.style.display = ''; |
|
|
| setTrustedHtml(content, trustedHtml(items.map((item, i) => { |
| const safeUrl = sanitizeUrl(item.link); |
| const threatColor = item.threat?.level === 'critical' ? getCSSColor('--threat-critical') |
| : item.threat?.level === 'high' ? getCSSColor('--threat-high') |
| : item.threat?.level === 'medium' ? getCSSColor('--threat-medium') |
| : getCSSColor('--threat-info'); |
| const timeAgo = this.timeAgo(item.pubDate); |
| const cardBody = ` |
| <span class="cb-news-threat" style="background:${threatColor}"></span> |
| <div class="cb-news-body"> |
| <div class="cb-news-title">${escapeHtml(item.title)}</div> |
| <div class="cb-news-meta">${escapeHtml(item.source)} · ${timeAgo}</div> |
| </div>`; |
| if (safeUrl) { |
| return `<a href="${safeUrl}" target="_blank" rel="noopener" class="cb-news-card" id="cb-news-${i + 1}">${cardBody}</a>`; |
| } |
| return `<div class="cb-news-card" id="cb-news-${i + 1}">${cardBody}</div>`; |
| }).join(''), "legacy direct innerHTML migration")); |
| } |
|
|
|
|
| public updateInfrastructure(countryCode: string): void { |
| const bounds = CountryBriefPage.BRIEF_BOUNDS[countryCode]; |
| if (!bounds) return; |
|
|
| const centroidLat = (bounds.n + bounds.s) / 2; |
| const centroidLon = (bounds.e + bounds.w) / 2; |
|
|
| const assets = getNearbyInfrastructure(centroidLat, centroidLon, ['pipeline', 'cable', 'datacenter', 'base', 'nuclear']); |
|
|
| const nearbyPorts = PORTS |
| .map((p: Port) => ({ port: p, dist: haversineDistanceKm(centroidLat, centroidLon, p.lat, p.lon) })) |
| .filter(({ dist }) => dist <= 600) |
| .sort((a, b) => a.dist - b.dist) |
| .slice(0, 5); |
|
|
| const grouped = new Map<BriefAssetType, Array<{ name: string; distanceKm: number }>>(); |
| for (const a of assets) { |
| const list = grouped.get(a.type) || []; |
| list.push({ name: a.name, distanceKm: a.distanceKm }); |
| grouped.set(a.type, list); |
| } |
| if (nearbyPorts.length > 0) { |
| grouped.set('port', nearbyPorts.map(({ port, dist }) => ({ name: port.name, distanceKm: dist }))); |
| } |
|
|
| if (grouped.size === 0) return; |
|
|
| const section = this.overlay.querySelector('.cb-infra-section') as HTMLElement | null; |
| const content = this.overlay.querySelector('.cb-infra-content'); |
| if (!section || !content) return; |
|
|
| const order: BriefAssetType[] = ['pipeline', 'cable', 'datacenter', 'base', 'nuclear', 'port']; |
| let html = ''; |
| for (const type of order) { |
| const items = grouped.get(type); |
| if (!items || items.length === 0) continue; |
| const icon = CountryBriefPage.INFRA_ICONS[type]; |
| const key = CountryBriefPage.INFRA_LABELS[type]; |
| const label = t(`modals.countryBrief.infra.${key}`); |
| html += `<div class="cb-infra-group">`; |
| html += `<div class="cb-infra-type">${icon} ${label}</div>`; |
| for (const item of items) { |
| html += `<div class="cb-infra-item"><span>${escapeHtml(item.name)}</span><span class="cb-infra-dist">${Math.round(item.distanceKm)} km</span></div>`; |
| } |
| html += `</div>`; |
| } |
|
|
| setTrustedHtml(content, trustedHtml(html, "legacy direct innerHTML migration")); |
| section.style.display = ''; |
| } |
|
|
| public getTimelineMount(): HTMLElement | null { |
| return this.overlay.querySelector('.cb-timeline-mount'); |
| } |
|
|
| public getCode(): string | null { |
| return this.currentCode; |
| } |
|
|
| public getName(): string | null { |
| return this.currentName; |
| } |
|
|
| private timeAgo(date: Date): string { |
| const ms = Date.now() - new Date(date).getTime(); |
| const hours = Math.floor(ms / 3600000); |
| if (hours < 1) return t('modals.countryBrief.timeAgo.m', { count: Math.floor(ms / 60000) }); |
| if (hours < 24) return t('modals.countryBrief.timeAgo.h', { count: hours }); |
| return t('modals.countryBrief.timeAgo.d', { count: Math.floor(hours / 24) }); |
| } |
|
|
| private formatBrief(text: string, sources: BriefSource[] = [], headlineCount = 0): string { |
| return formatIntelBrief( |
| text, |
| sources.length > 0 |
| ? { sources } |
| : headlineCount > 0 |
| ? { count: headlineCount, hrefPrefix: '#cb-news-' } |
| : undefined, |
| ); |
| } |
|
|
| private exportBrief(format: 'json' | 'csv' | 'evidence-md'): void { |
| if (!this.currentCode || !this.currentName) return; |
| if (format === 'evidence-md' && !this.canExportEvidenceBundle()) return; |
| const exportedAt = new Date().toISOString(); |
| const data: CountryBriefExport & 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, |
| 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, |
| }; |
| } |
| 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(h => ({ |
| title: h.title, |
| source: h.source, |
| link: h.link, |
| pubDate: h.pubDate ? new Date(h.pubDate).toISOString() : undefined, |
| })); |
| } |
| if (format === 'evidence-md') exportCountryEvidenceMarkdown(data); |
| else if (format === 'json') exportCountryBriefJSON(data); |
| else exportCountryBriefCSV(data); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private syncStructuredExportOptions(): void { |
| const authState = getAuthState(); |
| const verdict = evaluateExportGate(authState); |
| |
| |
| const availableFormats = verdict.locked |
| ? null |
| : new Set(evaluateAvailableExportFormats(authState)); |
|
|
| this.overlay |
| .querySelectorAll<HTMLButtonElement>('.cb-export-option') |
| .forEach((button) => { |
| const format = button.dataset.format; |
| if (format !== 'json' && format !== 'csv') return; |
| button.hidden = availableFormats !== null && !availableFormats.has(format); |
| }); |
| } |
|
|
| private canExportStructuredData(format: 'json' | 'csv'): boolean { |
| const authState = getAuthState(); |
| const verdict = evaluateExportGate(authState); |
| if (!verdict.locked) { |
| if (verdict.pendingActivation) void primeExportGateActivation(); |
| |
| |
| return evaluateAvailableExportFormats(authState).includes(format); |
| } |
| trackGateHit('export'); |
| showToast(exportGateCopy(exportLockToGateReason(verdict.reason)).desc); |
| return false; |
| } |
|
|
| private canExportEvidenceBundle(): boolean { |
| if (hasPremiumAccess(getAuthState())) return true; |
| trackGateHit('evidence-export'); |
| showToast('Evidence export is available on Pro.'); |
| return false; |
| } |
|
|
| private exportPdf(): void { |
| const content = this.overlay.querySelector('.cb-body'); |
| const header = this.overlay.querySelector('.cb-header'); |
| if (!content) return; |
|
|
| const iframe = document.createElement('iframe'); |
| iframe.style.cssText = 'position:fixed;left:-9999px;width:0;height:0;border:none'; |
| document.body.appendChild(iframe); |
| const doc = iframe.contentDocument || iframe.contentWindow?.document; |
| if (!doc) { document.body.removeChild(iframe); return; } |
|
|
| const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')) |
| .map(el => el.outerHTML).join('\n'); |
|
|
| doc.open(); |
| doc.write(`<!DOCTYPE html><html><head><meta charset="utf-8">${styles} |
| <style> |
| @media print { body { margin: 0; padding: 16px; background: #fff; color: #111; } |
| .cb-grid { display: block !important; } |
| .cb-grid > * { break-inside: avoid; margin-bottom: 16px; } |
| .cb-badge, .cb-trend { print-color-adjust: exact; -webkit-print-color-adjust: exact; } |
| canvas { max-width: 100% !important; } |
| } |
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } |
| .country-brief-overlay { position: static !important; background: none !important; } |
| </style> |
| </head><body>${header ? header.outerHTML : ''}${content.outerHTML}</body></html>`); |
| doc.close(); |
|
|
| if (iframe.contentWindow) { |
| iframe.contentWindow.onafterprint = () => document.body.removeChild(iframe); |
| } |
| setTimeout(() => { |
| if (iframe.contentWindow) { |
| iframe.contentWindow.print(); |
| } |
| setTimeout(() => { if (iframe.parentNode) document.body.removeChild(iframe); }, 5000); |
| }, 300); |
| } |
|
|
| public hide(): void { |
| this.abortController.abort(); |
| this.overlay.classList.remove('active'); |
| this.currentCode = null; |
| this.currentName = null; |
| this.onCloseCallback?.(); |
| } |
|
|
| public onClose(cb: () => void): void { |
| this.onCloseCallback = cb; |
| } |
|
|
| public isVisible(): boolean { |
| return this.overlay.classList.contains('active'); |
| } |
| } |
|
|