| import { Panel } from './Panel'; |
| import { getCSSColor } from '@/utils'; |
| import type { CountryScore } from '@/services/country-instability'; |
| import { t } from '../services/i18n'; |
| import { h, replaceChildren, rawHtml, setTrustedHtml, trustedHtml, type TrustedHtml } from '@/utils/dom-utils'; |
| import type { CachedRiskScores } from '@/services/cached-risk-scores'; |
| import { toCountryScore } from '@/services/cached-risk-scores'; |
| import { renderFollowButton } from '@/utils/follow-button'; |
| import { |
| getFollowed, |
| isFollowFeatureEnabled, |
| subscribe as subscribeFollowed, |
| } from '@/services/followed-countries'; |
| import { |
| partitionByFollowed, |
| shouldRenderSectionLabels, |
| } from './_cii-panel-partition'; |
|
|
| export const CII_METHODOLOGY_HREF = '/docs/methodology/cii-risk-scores'; |
|
|
| export class CIIPanel extends Panel { |
| private scores: CountryScore[] = []; |
| private onShareStory?: (code: string, name: string) => void; |
| private onCountryClick?: (code: string) => void; |
| |
| |
| |
| |
| |
| private followButtonTeardowns = new Map<string, () => void>(); |
| |
| |
| |
| |
| private followedUnsubscribe: (() => void) | null = null; |
|
|
| constructor() { |
| super({ |
| id: 'cii', |
| title: t('panels.cii'), |
| |
| |
| |
| |
| infoTooltip: `${t('components.cii.infoTooltip')} ${t('components.cii.methodologyLink')}`, |
| defaultRowSpan: 2, |
| }); |
| this.showLoading(t('common.loading')); |
|
|
| |
| |
| |
| |
| |
| this.followedUnsubscribe = subscribeFollowed(() => { |
| this.rerenderRows(); |
| }); |
| } |
|
|
| public setShareStoryHandler(handler: (code: string, name: string) => void): void { |
| this.onShareStory = handler; |
| } |
|
|
| public setCountryClickHandler(handler: (code: string) => void): void { |
| this.onCountryClick = handler; |
| } |
|
|
| private getLevelColor(level: CountryScore['level']): string { |
| switch (level) { |
| case 'critical': return getCSSColor('--semantic-critical'); |
| case 'high': return getCSSColor('--semantic-high'); |
| case 'elevated': return getCSSColor('--semantic-elevated'); |
| case 'normal': return getCSSColor('--semantic-normal'); |
| case 'low': return getCSSColor('--semantic-low'); |
| } |
| } |
|
|
| private getLevelEmoji(level: CountryScore['level']): string { |
| switch (level) { |
| case 'critical': return '🔴'; |
| case 'high': return '🟠'; |
| case 'elevated': return '🟡'; |
| case 'normal': return '🟢'; |
| case 'low': return '⚪'; |
| } |
| } |
|
|
| private static readonly SHARE_SVG: TrustedHtml = 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>', |
| 'Static share icon SVG defined in source', |
| ); |
|
|
| private buildTrendArrow(trend: CountryScore['trend'], change: number): HTMLElement { |
| if (trend === 'rising') return h('span', { className: 'trend-up' }, `↑${change > 0 ? change : ''}`); |
| if (trend === 'falling') return h('span', { className: 'trend-down' }, `↓${Math.abs(change)}`); |
| return h('span', { className: 'trend-stable' }, '→'); |
| } |
|
|
| private buildCountry(country: CountryScore): HTMLElement { |
| const color = this.getLevelColor(country.level); |
| const emoji = this.getLevelEmoji(country.level); |
|
|
| const shareBtn = h('button', { |
| className: 'cii-share-btn', |
| dataset: { code: country.code, name: country.name }, |
| title: t('common.shareStory'), |
| }); |
| shareBtn.appendChild(rawHtml(CIIPanel.SHARE_SVG)); |
|
|
| |
| |
| |
| |
| |
| const followHost = h('span', { |
| className: 'cii-follow-btn-host', |
| dataset: { code: country.code }, |
| }); |
| const handle = renderFollowButton({ |
| countryCode: country.code, |
| countryName: country.name, |
| size: 'sm', |
| }); |
| setTrustedHtml(followHost, trustedHtml(handle.html, "legacy direct innerHTML migration")); |
| |
| |
| |
| |
| const teardown = handle.attach(followHost); |
| this.followButtonTeardowns.set(country.code, teardown); |
| |
| |
| |
| followHost.addEventListener('click', (e) => e.stopPropagation()); |
|
|
| return h('div', { className: 'cii-country', dataset: { code: country.code } }, |
| followHost, |
| h('div', { className: 'cii-header' }, |
| h('span', { className: 'cii-emoji' }, emoji), |
| h('span', { className: 'cii-name' }, country.name), |
| h('span', { className: 'cii-score' }, String(country.score)), |
| this.buildTrendArrow(country.trend, country.change24h), |
| shareBtn, |
| ), |
| h('div', { className: 'cii-bar-container' }, |
| h('div', { className: 'cii-bar', style: `width: ${country.score}%; background: ${color};` }), |
| ), |
| h('div', { className: 'cii-components' }, |
| h('span', { title: t('common.unrest') }, `U:${country.components.unrest}`), |
| h('span', { title: t('common.conflict') }, `C:${country.components.conflict}`), |
| h('span', { title: t('common.security') }, `S:${country.components.security}`), |
| h('span', { title: t('common.information') }, `I:${country.components.information}`), |
| ), |
| ); |
| } |
|
|
| private tearDownFollowButtons(): void { |
| for (const teardown of this.followButtonTeardowns.values()) { |
| try { |
| teardown(); |
| } catch { |
| |
| } |
| } |
| this.followButtonTeardowns.clear(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private buildList(scores: CountryScore[]): HTMLElement { |
| |
| |
| |
| |
| |
| |
| |
| const followedCodes = isFollowFeatureEnabled() ? getFollowed() : []; |
| const partition = partitionByFollowed(scores, followedCodes); |
|
|
| if (!shouldRenderSectionLabels(partition)) { |
| |
| |
| return h( |
| 'div', |
| { className: 'cii-list' }, |
| ...scores.map((s) => this.buildCountry(s)), |
| ); |
| } |
|
|
| const { followed, unfollowed } = partition; |
| const followingLabel = h( |
| 'div', |
| { className: 'cii-section-label' }, |
| t('components.cii.sectionFollowing'), |
| ); |
| const allLabel = h( |
| 'div', |
| { className: 'cii-section-label' }, |
| t('components.cii.sectionAll'), |
| ); |
|
|
| return h( |
| 'div', |
| { className: 'cii-list' }, |
| followingLabel, |
| ...followed.map((s) => this.buildCountry(s)), |
| allLabel, |
| ...unfollowed.map((s) => this.buildCountry(s)), |
| ); |
| } |
|
|
| private buildMethodologyFooter(): HTMLElement { |
| return h('div', { className: 'cii-methodology-footer' }, |
| h('a', { |
| href: CII_METHODOLOGY_HREF, |
| target: '_blank', |
| rel: 'noopener noreferrer', |
| }, t('components.cii.methodologyLink')), |
| ); |
| } |
|
|
| private formatCachedSourceDetail(cached: Pick<CachedRiskScores, 'degraded' | 'stale'>): string { |
| const flags: string[] = []; |
| if (cached.degraded) flags.push(t('components.cii.sourceStates.degraded')); |
| if (cached.stale) flags.push(t('components.cii.sourceStates.stale')); |
| return flags.join(' · '); |
| } |
|
|
| private updateSourceBadge(cached: Pick<CachedRiskScores, 'degraded' | 'stale'> | null): void { |
| if (!cached) { |
| this.clearDataBadge(); |
| return; |
| } |
| const detail = this.formatCachedSourceDetail(cached); |
| this.setDataBadge('cached', detail || undefined); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private rerenderRows(): void { |
| if (this.scores.length === 0) return; |
| const withData = this.scores.filter((s) => s.score > 0); |
| if (withData.length === 0) return; |
| this.tearDownFollowButtons(); |
| replaceChildren(this.content, this.buildList(withData), this.buildMethodologyFooter()); |
| this.bindShareButtons(); |
| } |
|
|
| private bindShareButtons(): void { |
| if (!this.onShareStory && !this.onCountryClick) return; |
|
|
| this.content.querySelectorAll('.cii-country').forEach(el => { |
| el.addEventListener('click', (e) => { |
| const target = e.currentTarget as HTMLElement; |
| const code = target.dataset.code; |
| if (code && this.onCountryClick) { |
| this.onCountryClick(code); |
| } |
| }); |
| }); |
|
|
| this.content.querySelectorAll('.cii-share-btn').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const el = e.currentTarget as HTMLElement; |
| const code = el.dataset.code || ''; |
| const name = el.dataset.name || ''; |
| if (code && name && this.onShareStory) this.onShareStory(code, name); |
| }); |
| }); |
| } |
|
|
| public renderUnavailable(): void { |
| this.scores = []; |
| this.setCount(0); |
| this.setErrorState(false); |
| this.setDataBadge('unavailable'); |
| this.tearDownFollowButtons(); |
| replaceChildren( |
| this.content, |
| h('div', { className: 'empty-state' }, t('common.failedCII')), |
| this.buildMethodologyFooter(), |
| ); |
| } |
|
|
| public renderFromCached(cached: CachedRiskScores): void { |
| const scores = cached.cii.map(toCountryScore).filter(s => s.score > 0); |
| if (scores.length === 0) return; |
| this.scores = scores; |
| this.updateSourceBadge(cached); |
| this.setCount(scores.length); |
| this.setErrorState(false); |
| |
| this.tearDownFollowButtons(); |
| replaceChildren(this.content, this.buildList(scores), this.buildMethodologyFooter()); |
| this.bindShareButtons(); |
| console.log(`[CIIPanel] Rendered ${scores.length} countries from cached/bootstrap data`); |
| } |
|
|
| public getScores(): CountryScore[] { |
| return this.scores; |
| } |
|
|
| public override destroy(): void { |
| this.tearDownFollowButtons(); |
| if (this.followedUnsubscribe) { |
| try { |
| this.followedUnsubscribe(); |
| } catch { |
| |
| } |
| this.followedUnsubscribe = null; |
| } |
| super.destroy(); |
| } |
| } |
|
|