| import { Panel } from './Panel'; |
| import { escapeHtml } from '@/utils/sanitize'; |
| import { t } from '@/services/i18n'; |
| import { getCSSColor } from '@/utils'; |
| import { |
| buildDependencyGraph, |
| calculateCascade, |
| getGraphStats, |
| clearGraphCache, |
| preloadCables, |
| type DependencyGraph, |
| } from '@/services/infrastructure-cascade'; |
| import type { CascadeResult, CascadeImpactLevel, InfrastructureNode } from '@/types'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
|
|
|
|
| type NodeFilter = 'all' | 'cable' | 'pipeline' | 'port' | 'chokepoint'; |
|
|
| export class CascadePanel extends Panel { |
| private graph: DependencyGraph | null = null; |
| private selectedNode: string | null = null; |
| private cascadeResult: CascadeResult | null = null; |
| private filter: NodeFilter = 'cable'; |
| private onSelectCallback: ((nodeId: string | null) => void) | null = null; |
|
|
| constructor() { |
| super({ |
| id: 'cascade', |
| title: t('panels.cascade'), |
| showCount: true, |
| trackActivity: true, |
| infoTooltip: t('components.cascade.infoTooltip'), |
| }); |
| this.setupDelegatedListeners(); |
| this.init(); |
| } |
|
|
| private async init(): Promise<void> { |
| this.showLoading(); |
| try { |
| await preloadCables(); |
| this.graph = buildDependencyGraph(); |
| const stats = getGraphStats(); |
| this.setCount(stats.nodes); |
| this.render(); |
| } catch (error) { |
| console.error('[CascadePanel] Init error:', error); |
| this.showError(t('common.failedDependencyGraph')); |
| } |
| } |
|
|
| private getImpactColor(level: CascadeImpactLevel): string { |
| switch (level) { |
| case 'critical': return getCSSColor('--semantic-critical'); |
| case 'high': return getCSSColor('--semantic-high'); |
| case 'medium': return getCSSColor('--semantic-elevated'); |
| case 'low': return getCSSColor('--semantic-normal'); |
| } |
| } |
|
|
| private getImpactEmoji(level: CascadeImpactLevel): string { |
| switch (level) { |
| case 'critical': return '🔴'; |
| case 'high': return '🟠'; |
| case 'medium': return '🟡'; |
| case 'low': return '🟢'; |
| } |
| } |
|
|
| private getNodeTypeEmoji(type: string): string { |
| switch (type) { |
| case 'cable': return '🔌'; |
| case 'pipeline': return '🛢️'; |
| case 'port': return '⚓'; |
| case 'chokepoint': return '🚢'; |
| case 'country': return '🏳️'; |
| default: return '📍'; |
| } |
| } |
|
|
| private getFilterLabel(filter: Exclude<NodeFilter, 'all'>): string { |
| const labels: Record<Exclude<NodeFilter, 'all'>, string> = { |
| cable: t('components.cascade.filters.cables'), |
| pipeline: t('components.cascade.filters.pipelines'), |
| port: t('components.cascade.filters.ports'), |
| chokepoint: t('components.cascade.filters.chokepoints'), |
| }; |
| return labels[filter]; |
| } |
|
|
| private getFilteredNodes(): InfrastructureNode[] { |
| if (!this.graph) return []; |
| const nodes: InfrastructureNode[] = []; |
| for (const node of this.graph.nodes.values()) { |
| if (this.filter === 'all' || node.type === this.filter) { |
| if (node.type !== 'country') { |
| nodes.push(node); |
| } |
| } |
| } |
| return nodes.sort((a, b) => a.name.localeCompare(b.name)); |
| } |
|
|
| private renderSelector(): string { |
| const nodes = this.getFilteredNodes(); |
| const filterButtons = ['cable', 'pipeline', 'port', 'chokepoint'].map((f) => |
| `<button class="panel-tab ${this.filter === f ? 'active' : ''}" data-filter="${f}" role="radio" aria-checked="${this.filter === f}" aria-label="${this.getFilterLabel(f as Exclude<NodeFilter, 'all'>)}"> |
| ${this.getNodeTypeEmoji(f)} ${this.getFilterLabel(f as Exclude<NodeFilter, 'all'>)} |
| </button>` |
| ).join(''); |
|
|
| const nodeOptions = nodes.map(n => |
| `<option value="${escapeHtml(n.id)}" ${this.selectedNode === n.id ? 'selected' : ''}> |
| ${escapeHtml(n.name)} |
| </option>` |
| ).join(''); |
| const selectedType = t(`components.cascade.filterType.${this.filter}`); |
|
|
| return ` |
| <div class="cascade-selector"> |
| <div class="panel-tabs" role="radiogroup" aria-label="Infrastructure type filter">${filterButtons}</div> |
| <select class="cascade-select" aria-label="${t('components.cascade.selectInfrastructureHint')}" ${nodes.length === 0 ? 'disabled' : ''}> |
| <option value="">${t('components.cascade.selectPrompt', { type: selectedType })}</option> |
| ${nodeOptions} |
| </select> |
| <button class="cascade-analyze-btn" ${!this.selectedNode ? 'disabled' : ''}> |
| ${t('components.cascade.analyzeImpact')} |
| </button> |
| </div> |
| `; |
| } |
|
|
| private renderCascadeResult(): string { |
| if (!this.cascadeResult) return ''; |
|
|
| const { source, countriesAffected, redundancies } = this.cascadeResult; |
|
|
| const countriesHtml = countriesAffected.length > 0 |
| ? countriesAffected.map(c => ` |
| <div class="cascade-country" style="border-left: 3px solid ${this.getImpactColor(c.impactLevel)}"> |
| <span class="cascade-emoji">${this.getImpactEmoji(c.impactLevel)}</span> |
| <span class="cascade-country-name">${escapeHtml(c.countryName)}</span> |
| <span class="cascade-impact">${t(`components.cascade.impactLevels.${c.impactLevel}`)}</span> |
| ${c.affectedCapacity > 0 ? `<span class="cascade-capacity">${t('components.cascade.capacityPercent', { percent: String(Math.round(c.affectedCapacity * 100)) })}</span>` : ''} |
| </div> |
| `).join('') |
| : `<div class="empty-state">${t('components.cascade.noCountryImpacts')}</div>`; |
|
|
| const redundanciesHtml = redundancies && redundancies.length > 0 |
| ? ` |
| <div class="cascade-section"> |
| <div class="cascade-section-title">${t('components.cascade.alternativeRoutes')}</div> |
| ${redundancies.map(r => ` |
| <div class="cascade-redundancy"> |
| <span class="cascade-redundancy-name">${escapeHtml(r.name)}</span> |
| <span class="cascade-redundancy-capacity">${Math.round(r.capacityShare * 100)}%</span> |
| </div> |
| `).join('')} |
| </div> |
| ` |
| : ''; |
|
|
| return ` |
| <div class="cascade-result"> |
| <div class="cascade-source"> |
| <span class="cascade-emoji">${this.getNodeTypeEmoji(source.type)}</span> |
| <span class="cascade-source-name">${escapeHtml(source.name)}</span> |
| <span class="cascade-source-type">${t(`components.cascade.filterType.${source.type}`)}</span> |
| </div> |
| <div class="cascade-section"> |
| <div class="cascade-section-title">${t('components.cascade.countriesAffected', { count: String(countriesAffected.length) })}</div> |
| <div class="cascade-countries">${countriesHtml}</div> |
| </div> |
| ${redundanciesHtml} |
| </div> |
| `; |
| } |
|
|
| private render(): void { |
| if (!this.graph) { |
| this.showLoading(); |
| return; |
| } |
|
|
| const stats = getGraphStats(); |
| const statsHtml = ` |
| <div class="cascade-stats"> |
| <span>🔌 ${stats.cables}</span> |
| <span>🛢️ ${stats.pipelines}</span> |
| <span>⚓ ${stats.ports}</span> |
| <span>🌊 ${stats.chokepoints}</span> |
| <span>🏳️ ${stats.countries}</span> |
| <span>📊 ${stats.edges} ${t('components.cascade.links')}</span> |
| </div> |
| `; |
|
|
| setTrustedHtml(this.content, trustedHtml(` |
| <div class="cascade-panel"> |
| ${statsHtml} |
| ${this.renderSelector()} |
| ${this.cascadeResult ? this.renderCascadeResult() : `<div class="cascade-hint">${t('components.cascade.selectInfrastructureHint')}</div>`} |
| </div> |
| `, "legacy direct innerHTML migration")); |
| } |
|
|
| |
| |
| |
| |
| private setupDelegatedListeners(): void { |
| this.content.addEventListener('click', (e: Event) => { |
| const target = e.target as HTMLElement; |
|
|
| const filterBtn = target.closest<HTMLElement>('.panel-tab'); |
| if (filterBtn) { |
| this.filter = filterBtn.getAttribute('data-filter') as NodeFilter; |
| this.selectedNode = null; |
| this.cascadeResult = null; |
| this.render(); |
| return; |
| } |
|
|
| if (target.closest('.cascade-analyze-btn')) { |
| this.runAnalysis(); |
| } |
| }); |
|
|
| this.content.addEventListener('change', (e: Event) => { |
| const target = e.target as HTMLElement; |
| if (target.closest('.cascade-select')) { |
| const select = target as HTMLSelectElement; |
| this.selectedNode = select.value || null; |
| this.cascadeResult = null; |
| if (this.onSelectCallback) { |
| this.onSelectCallback(this.selectedNode); |
| } |
| this.render(); |
| } |
| }); |
| } |
|
|
| private runAnalysis(): void { |
| if (!this.selectedNode) return; |
|
|
| this.cascadeResult = calculateCascade(this.selectedNode); |
| this.render(); |
|
|
| if (this.onSelectCallback) { |
| this.onSelectCallback(this.selectedNode); |
| } |
| } |
|
|
| public selectNode(nodeId: string): void { |
| this.selectedNode = nodeId; |
| const nodeType = nodeId.split(':')[0] as NodeFilter; |
| if (['cable', 'pipeline', 'port', 'chokepoint'].includes(nodeType)) { |
| this.filter = nodeType; |
| } |
| this.runAnalysis(); |
| } |
|
|
| public onSelect(callback: (nodeId: string | null) => void): void { |
| this.onSelectCallback = callback; |
| } |
|
|
| public getSelectedNode(): string | null { |
| return this.selectedNode; |
| } |
|
|
| public getCascadeResult(): CascadeResult | null { |
| return this.cascadeResult; |
| } |
|
|
| public refresh(): void { |
| clearGraphCache(); |
| this.graph = null; |
| this.cascadeResult = null; |
| this.init(); |
| } |
| } |
|
|