import { Panel } from './Panel'; import { t } from '@/services/i18n'; import type { ConvergenceCard, CorrelationDomain } from '@/services/correlation-engine'; import { h, replaceChildren } from '@/utils/dom-utils'; import { readableTextColor } from '@/utils/contrast'; import { getHydratedData } from '@/services/bootstrap'; let correlationBootstrap: Record | null | undefined; function getCorrelationBootstrap(): Record | null { if (correlationBootstrap === undefined) { correlationBootstrap = (getHydratedData('correlationCards') as Record) ?? null; } return correlationBootstrap; } // Score-badge BACKGROUND colors. Badge text color is chosen per-background via // readableTextColor() so it clears WCAG AA on each: white on the dark `low` // badge, dark text on the light/mid critical/high/medium hues (white was 3.41 / // 2.39 / 1.51 on those). `low` was also darkened #888888 → #6f6f6f. (#4418/#4421) const SCORE_COLORS = { critical: '#ff4444', high: '#ff8800', medium: '#ffcc00', low: '#6f6f6f', }; const TREND_ICONS: Record = { escalating: { symbol: '\u2191', color: '#ff4444' }, stable: { symbol: '\u2192', color: '#888888' }, 'de-escalating': { symbol: '\u2193', color: '#44cc44' }, }; export class CorrelationPanel extends Panel { private domain: CorrelationDomain; private expandedCard: string | null = null; private onMapNavigate?: (lat: number, lon: number) => void; private boundUpdateHandler: EventListener; private hasLiveData = false; private correlationDestroyed = false; constructor(id: string, title: string, domain: CorrelationDomain, infoTooltip?: string) { super({ id, title, showCount: true, infoTooltip }); this.domain = domain; const bootstrap = getCorrelationBootstrap(); const cards = bootstrap?.[domain] ?? null; if (cards && cards.length > 0) { this.cards = cards; this.requestRender(); } else { this.showLoading(t('components.correlation.loading')); } this.boundUpdateHandler = ((e: CustomEvent) => { if (e.detail?.domains?.includes(this.domain)) { this.requestRender(); } }) as EventListener; document.addEventListener('wm:correlation-updated', this.boundUpdateHandler); } override destroy(): void { this.correlationDestroyed = true; document.removeEventListener('wm:correlation-updated', this.boundUpdateHandler); super.destroy(); } setMapNavigateHandler(handler: (lat: number, lon: number) => void): void { this.onMapNavigate = handler; } protected navigateToMap(lat: number, lon: number): void { this.onMapNavigate?.(lat, lon); } protected renderSupplement(): HTMLElement | null { return null; } private pendingRender = false; /** Schedule a safe redraw for subclasses that install deferred panel data. */ protected requestRender(): void { if (this.correlationDestroyed || this.pendingRender) return; this.pendingRender = true; requestAnimationFrame(() => { this.pendingRender = false; if (this.correlationDestroyed) return; this.render(); }); } private cards: ConvergenceCard[] = []; updateCards(cards: ConvergenceCard[]): void { this.hasLiveData = true; this.cards = cards; this.requestRender(); } private render(): void { if (this.correlationDestroyed) return; const cards = this.cards; this.setCount(cards.length); const supplement = this.renderSupplement(); if (cards.length === 0) { const empty = h('div', { className: 'correlation-empty', style: 'padding:12px;text-align:center;opacity:0.5;font-size:11px;', }, t('components.correlation.empty')); replaceChildren(this.content, ...(supplement ? [supplement] : []), empty); return; } const cardEls = cards.map(card => this.buildCard(card)); replaceChildren( this.content, ...(supplement ? [supplement] : []), h('div', { className: 'correlation-cards' }, ...cardEls), ); } private buildCard(card: ConvergenceCard): HTMLElement { const scoreColor = card.score >= 70 ? SCORE_COLORS.critical : card.score >= 50 ? SCORE_COLORS.high : card.score >= 30 ? SCORE_COLORS.medium : SCORE_COLORS.low; const trend = TREND_ICONS[card.trend] ?? TREND_ICONS.stable!; const isExpanded = this.expandedCard === card.id; const header = h('div', { className: 'correlation-card-header', style: 'display:flex;align-items:center;gap:6px;cursor:pointer;padding:8px;', }, h('span', { style: `display:inline-block;min-width:28px;text-align:center;padding:2px 6px;border-radius:10px;font-size:10px;font-weight:700;color:${readableTextColor(scoreColor)};background:${scoreColor};`, }, String(card.score)), h('span', { style: 'flex:1;font-size:11px;line-height:1.3;', }, card.title), h('span', { style: 'font-size:9px;opacity:0.6;white-space:nowrap;', }, t('components.correlation.signals', { count: card.signals.length })), h('span', { style: `font-size:12px;color:${trend.color};`, }, trend.symbol), ); const detailEl = h('div', { className: 'correlation-card-detail', style: `display:${isExpanded ? 'block' : 'none'};padding:0 8px 8px;font-size:10px;border-top:1px solid rgba(255,255,255,0.05);`, }); if (isExpanded) { this.populateDetail(detailEl, card); } header.addEventListener('click', () => { this.expandedCard = this.expandedCard === card.id ? null : card.id; this.render(); }); return h('div', { className: 'correlation-card', style: 'border:1px solid rgba(255,255,255,0.08);border-radius:6px;margin-bottom:4px;background:rgba(255,255,255,0.02);', }, header, detailEl); } private populateDetail(el: HTMLElement, card: ConvergenceCard): void { const signalList = card.signals.slice(0, 10).map(s => h('div', { style: 'padding:2px 0;display:flex;gap:6px;align-items:baseline;' }, h('span', { style: 'font-size:8px;padding:1px 4px;border-radius:3px;background:rgba(255,255,255,0.1);white-space:nowrap;', }, s.type), h('span', { style: 'opacity:0.8;' }, s.label), ), ); const children: HTMLElement[] = [ h('div', { style: 'padding:6px 0;' }, ...signalList), ]; if (card.assessment) { children.push(h('div', { style: 'padding:6px 8px;margin:4px 0;border-radius:4px;background:rgba(100,150,255,0.08);border-left:2px solid rgba(100,150,255,0.3);font-size:10px;line-height:1.4;', }, card.assessment)); } else if (card.score >= 60 && this.hasLiveData) { children.push(h('div', { style: 'padding:4px;font-size:9px;opacity:0.4;font-style:italic;', }, t('components.correlation.analyzing'))); } if (card.location) { const mapBtn = h('button', { style: 'margin-top:4px;padding:3px 8px;font-size:9px;border:1px solid rgba(255,255,255,0.15);border-radius:3px;background:transparent;color:inherit;cursor:pointer;', }, t('components.correlation.viewOnMap')); mapBtn.addEventListener('click', (e) => { e.stopPropagation(); this.navigateToMap(card.location!.lat, card.location!.lon); }); children.push(mapBtn); } replaceChildren(el, ...children); } }