feat(supply-chain): Sprint A — panel UI (war risk badge, bypass options, trade exposure, HS2 ring chart) (#2896)

* feat(supply-chain): A1 — complete country-port-clusters.json to 197 entries

* feat(supply-chain): Sprint A — panel UI (war risk badge, bypass options, trade exposure, HS2 ring chart)

A2: War risk tier badge on every SupplyChainPanel chokepoint card (free, uses existing warRiskTier field).
A3: Bypass corridors section in expanded chokepoint card — fetches top 3 bypass options, PRO-gated with locked placeholder and trackGateHit.
A4: Trade Exposure sectionCard in CountryDeepDivePanel showing top 3 chokepoints by exposure %, vulnerability index — call site wired in country-intel.ts.
A5: HS2 ring chart (canvas donut) in MapPopup waterway popup using CHOKEPOINT_HS2_SECTORS static data, PRO-gated.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(supply-chain): bypass section stuck on loading when chokepoint has no transit history

mountBypassOptions() was called only after mountTransitChart() succeeded, but
the chart placeholder is only rendered when ts?.history?.length is truthy.
Chokepoints without transit history never had data-chart-cp in the DOM, causing
both the MutationObserver and 220ms timer to return early — leaving bypass
forever at 'Loading bypass options…'.

Fix: use data-bypass-cp (always rendered for expanded cards) as the DOM-ready
sentinel instead of data-chart-cp. mountTransitChart() is now a best-effort
call that does not gate bypass initialization.

* fix(supply-chain): address Sprint A Greptile review findings

- MapPopup: gate trackGateHit only when CHOKEPOINT_HS2_SECTORS has data
  for the chokepoint (spurious gate hit on chokepoints with no sectors)
- hs2-ring-chart: scale canvas by devicePixelRatio for crisp retina rendering
- hs2-ring-chart: replace innerHTML with DOM element construction for
  legend items (eliminates slice.label / slice.color injection vector)
- SupplyChainPanel: guard auth-subscription callback against writing into
  a detached container when card collapses mid-fetch
- country-intel: remove redundant explicit '27' hs2 arg (same as default)

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
This commit is contained in:
Elie Habib
2026-04-10 15:33:58 +04:00
committed by GitHub
parent ac8e0de1ca
commit fd9acf377b
11 changed files with 378 additions and 6 deletions

View File

@@ -174,5 +174,27 @@
"SB": { "nearestRouteIds": ["brazil-china-bulk"], "coastSide": "pacific" },
"TO": { "nearestRouteIds": ["brazil-china-bulk"], "coastSide": "pacific" },
"VU": { "nearestRouteIds": ["brazil-china-bulk"], "coastSide": "pacific" },
"WS": { "nearestRouteIds": ["brazil-china-bulk"], "coastSide": "pacific" }
"WS": { "nearestRouteIds": ["brazil-china-bulk"], "coastSide": "pacific" },
"BH": { "nearestRouteIds": ["gulf-europe-oil", "gulf-asia-oil", "qatar-asia-lng"], "coastSide": "indian" },
"KP": { "nearestRouteIds": ["intra-asia-container"], "coastSide": "pacific" },
"KI": { "nearestRouteIds": ["china-us-west"], "coastSide": "pacific" },
"FM": { "nearestRouteIds": ["intra-asia-container", "china-us-west"], "coastSide": "pacific" },
"MH": { "nearestRouteIds": ["china-us-west"], "coastSide": "pacific" },
"NR": { "nearestRouteIds": ["china-us-west"], "coastSide": "pacific" },
"TV": { "nearestRouteIds": ["china-us-west"], "coastSide": "pacific" },
"AG": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"BS": { "nearestRouteIds": ["transatlantic", "panama-transit"], "coastSide": "atlantic" },
"DM": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"GD": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"KN": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"LC": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"VC": { "nearestRouteIds": ["transatlantic"], "coastSide": "atlantic" },
"GM": { "nearestRouteIds": ["china-africa", "transatlantic"], "coastSide": "atlantic" },
"LI": { "nearestRouteIds": [], "coastSide": "landlocked" },
"AD": { "nearestRouteIds": [], "coastSide": "landlocked" },
"MC": { "nearestRouteIds": ["singapore-med"], "coastSide": "med" },
"SM": { "nearestRouteIds": [], "coastSide": "landlocked" },
"BT": { "nearestRouteIds": [], "coastSide": "landlocked" },
"BN": { "nearestRouteIds": ["intra-asia-container"], "coastSide": "pacific" },
"TL": { "nearestRouteIds": ["intra-asia-container"], "coastSide": "pacific" }
}

View File

@@ -42,6 +42,7 @@ import { getNearbyInfrastructure } from '@/services/related-assets';
import { toFlagEmoji } from '@/utils/country-flag';
import { buildDependencyGraph } from '@/services/infrastructure-cascade';
import { getActiveFrameworkForPanel, subscribeFrameworkChange } from '@/services/analysis-framework-store';
import { fetchCountryChokepointIndex } from '@/services/supply-chain';
type IntlDisplayNamesCtor = new (
locales: string | string[],
@@ -396,6 +397,17 @@ export class CountryIntelManager implements AppModule {
this.ctx.countryBriefPage.updateMaritimeActivity?.({ available: false, ports: [], fetchedAt: '' });
});
// hs2='27' (mineral fuels) is the default; omit explicit arg to use the function default
fetchCountryChokepointIndex(code)
.then((result) => {
if (this.ctx.countryBriefPage?.getCode() !== code) return;
this.ctx.countryBriefPage.updateTradeExposure?.(result);
})
.catch(() => {
if (this.ctx.countryBriefPage?.getCode() !== code) return;
this.ctx.countryBriefPage.updateTradeExposure?.(null);
});
this.mountCountryTimeline(code, country);
try {

View File

@@ -2,6 +2,7 @@ import type { CountryBriefSignals } from '@/types';
import type { CountryScore } from '@/services/country-instability';
import type { PredictionMarket } from '@/services/prediction';
import type { NewsItem } from '@/types';
import type { GetCountryChokepointIndexResponse } from '@/services/supply-chain';
export interface CountryIntelData {
brief: string;
@@ -182,6 +183,7 @@ export interface CountryBriefPanel {
updateCountryFacts?(data: CountryFactsData): void;
updateEnergyProfile?(data: CountryEnergyProfileData): void;
updateMaritimeActivity?(data: CountryPortActivityData): void;
updateTradeExposure?(data: GetCountryChokepointIndexResponse | null): void;
maximize?(): void;
minimize?(): void;
getIsMaximized?(): boolean;

View File

@@ -24,6 +24,7 @@ import type {
CountryEnergyProfileData,
CountryPortActivityData,
} from './CountryBriefPanel';
import type { GetCountryChokepointIndexResponse } from '@/services/supply-chain';
import type { MapContainer } from './MapContainer';
import { ResilienceWidget } from './ResilienceWidget';
import { toApiUrl } from '@/services/runtime';
@@ -83,6 +84,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
private resilienceWidget: ResilienceWidget | null = null;
private energyBody: HTMLElement | null = null;
private maritimeBody: HTMLElement | null = null;
private tradeExposureBody: HTMLElement | null = null;
private readonly handleGlobalKeydown = (event: KeyboardEvent): void => {
if (!this.panel.classList.contains('active')) return;
@@ -1092,6 +1094,42 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
}
}
public updateTradeExposure(data: GetCountryChokepointIndexResponse | null): void {
if (!this.tradeExposureBody) return;
if (data == null || data.exposures.length === 0) {
this.tradeExposureBody.parentElement?.remove();
this.tradeExposureBody = null;
return;
}
this.tradeExposureBody.replaceChildren();
const vulnDiv = this.el('div', 'cdp-vuln-index', `Vulnerability: ${Math.round(data.vulnerabilityIndex)}/100`);
this.tradeExposureBody.append(vulnDiv);
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)}%`);
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');
this.tradeExposureBody.append(footer);
}
private factItem(label: string, value: string): HTMLElement {
const wrapper = this.el('div', 'cdp-fact-item');
wrapper.append(this.el('div', 'cdp-fact-label', label));
@@ -1334,6 +1372,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
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'));
this.signalsBody = signalBody;
this.timelineBody = timelineBody;
this.timelineBody.classList.add('cdp-timeline-mount');
@@ -1352,7 +1394,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
marketsBody.append(this.makeLoading(t('countryBrief.loadingMarkets')));
briefBody.append(this.makeLoading(t('countryBrief.generatingBrief')));
bodyGrid.append(briefCard, factsExpanded, energyCard, maritimeCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, marketsCard);
bodyGrid.append(briefCard, factsExpanded, energyCard, maritimeCard, tradeCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, marketsCard);
shell.append(header, summaryGrid, bodyGrid);
this.content.append(shell);
}
@@ -1367,6 +1409,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
this.scoreCard = null;
this.energyBody = null;
this.maritimeBody = null;
this.tradeExposureBody = null;
this.content.replaceChildren();
}

View File

@@ -10,6 +10,7 @@ import type { GeoHubActivity } from '@/services/geo-activity';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import { isMobileDevice, getCSSColor } from '@/utils';
import { TransitChart } from '@/utils/transit-chart';
import { HS2RingChart } from '@/utils/hs2-ring-chart';
import type { GetChokepointStatusResponse } from '@/services/supply-chain';
import { t } from '@/services/i18n';
import { fetchHotspotContext, formatArticleDate, extractDomain, type GdeltArticle } from '@/services/gdelt-intel';
@@ -278,6 +279,17 @@ export class MapPopup {
if (cp?.transitSummary?.history?.length && !hasPremiumAccess(getAuthState())) {
trackGateHit('chokepoint-transit-chart');
}
// Mount HS2 sector ring chart for PRO users
const sectors = CHOKEPOINT_HS2_SECTORS[waterway.chokepointId];
if (sectors?.length) {
const ringEl = this.popup.querySelector<HTMLElement>(`[data-hs2-ring="${waterway.chokepointId}"]`);
if (ringEl) {
new HS2RingChart().mount(ringEl, sectors);
} else if (!hasPremiumAccess(getAuthState())) {
trackGateHit('chokepoint-sector-ring');
}
}
}
// Close button handler via event delegation on the popup element.
@@ -1226,6 +1238,25 @@ export class MapPopup {
}
}
// Sector exposure ring is PRO-gated (canvas donut with legend)
let ringSection = '';
if (sectors) {
if (isPro) {
ringSection = `
<div class="popup-section-title" style="margin-top:10px;font-size:10px;text-transform:uppercase;opacity:.6;letter-spacing:.06em">Sector Exposure</div>
<div data-hs2-ring="${escapeHtml(waterway.chokepointId)}" class="popup-hs2-ring-container"></div>`;
} else {
ringSection = `
<div class="sector-pro-gate" data-gate="chokepoint-sector-ring" style="position:relative;overflow:hidden;border-radius:6px;margin-top:10px;min-height:80px;background:var(--surface-elevated, #111)">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:4px">
<span style="font-size:16px">🔒</span>
<span style="font-size:10px;font-weight:600;opacity:.8">PRO</span>
<span style="font-size:9px;opacity:.5">Sector Breakdown</span>
</div>
</div>`;
}
}
return `
<div class="popup-header waterway">
<span class="popup-title">${escapeHtml(waterway.name)}</span>
@@ -1241,6 +1272,7 @@ export class MapPopup {
</div>
</div>
${sectorSection}
${ringSection}
${chartSection}
</div>
`;

View File

@@ -5,11 +5,15 @@ import type {
GetCriticalMineralsResponse,
GetShippingStressResponse,
} from '@/services/supply-chain';
import { fetchBypassOptions } from '@/services/supply-chain';
import { TransitChart } from '@/utils/transit-chart';
import { t } from '@/services/i18n';
import { escapeHtml } from '@/utils/sanitize';
import { isFeatureAvailable } from '@/services/runtime-config';
import { isDesktopRuntime } from '@/services/runtime';
import { getAuthState, subscribeAuthState } from '@/services/auth-state';
import { hasPremiumAccess } from '@/services/panel-gating';
import { trackGateHit } from '@/services/analytics';
type TabId = 'chokepoints' | 'shipping' | 'indicators' | 'minerals' | 'stress';
@@ -25,6 +29,8 @@ export class SupplyChainPanel extends Panel {
private transitChart = new TransitChart();
private chartObserver: MutationObserver | null = null;
private chartMountTimer: ReturnType<typeof setTimeout> | null = null;
private bypassUnsubscribe: (() => void) | null = null;
private bypassGateTracked = false;
constructor() {
super({ id: 'supply-chain', title: t('panels.supplyChain'), defaultRowSpan: 2, infoTooltip: t('components.supplyChain.infoTooltip') });
@@ -53,6 +59,8 @@ export class SupplyChainPanel extends Panel {
if (this.chartMountTimer) { clearTimeout(this.chartMountTimer); this.chartMountTimer = null; }
if (this.chartObserver) { this.chartObserver.disconnect(); this.chartObserver = null; }
this.transitChart.destroy();
if (this.bypassUnsubscribe) { this.bypassUnsubscribe(); this.bypassUnsubscribe = null; }
this.bypassGateTracked = false;
}
public updateShippingRates(data: GetShippingRatesResponse): void {
@@ -131,18 +139,35 @@ export class SupplyChainPanel extends Panel {
`);
if (this.activeTab === 'chokepoints' && this.expandedChokepoint) {
const expandedCpName = this.expandedChokepoint;
const cp = this.chokepointData?.chokepoints?.find(c => c.name === expandedCpName);
const mountTransitChart = (): boolean => {
const el = this.content.querySelector(`[data-chart-cp="${this.expandedChokepoint}"]`) as HTMLElement | null;
const el = this.content.querySelector(`[data-chart-cp="${expandedCpName}"]`) as HTMLElement | null;
if (!el) return false;
const cp = this.chokepointData?.chokepoints?.find(c => c.name === this.expandedChokepoint);
if (cp?.transitSummary?.history?.length) {
this.transitChart.mount(el, cp.transitSummary.history);
}
return true;
};
const mountBypassOptions = (): boolean => {
const bypassEl = this.content.querySelector(`[data-bypass-cp="${cp?.id ?? ''}"]`) as HTMLElement | null;
if (!bypassEl) return false;
this.renderBypassSection(bypassEl, cp?.id ?? '');
return true;
};
// Use the bypass element as the "card is in DOM" sentinel — it is always rendered for
// expanded cards, unlike the chart placeholder which is conditional on transit history.
const mountAfterRender = (): boolean => {
if (!mountBypassOptions()) return false;
mountTransitChart();
return true;
};
this.chartObserver = new MutationObserver(() => {
if (!mountTransitChart()) return;
if (!mountAfterRender()) return;
if (this.chartMountTimer) { clearTimeout(this.chartMountTimer); this.chartMountTimer = null; }
this.chartObserver?.disconnect();
this.chartObserver = null;
@@ -151,13 +176,86 @@ export class SupplyChainPanel extends Panel {
// Fallback for no-op renders where setContent short-circuits and no mutation fires.
this.chartMountTimer = setTimeout(() => {
if (!mountTransitChart()) return;
if (!mountAfterRender()) return;
if (this.chartObserver) { this.chartObserver.disconnect(); this.chartObserver = null; }
this.chartMountTimer = null;
}, 220);
}
}
private renderBypassSection(container: HTMLElement, chokepointId: string): void {
if (!chokepointId) return;
const renderGate = (): string => {
return `<div class="sc-bypass-gate"><span class="sc-bypass-lock">\uD83D\uDD12</span><span class="sc-bypass-gate-text">Bypass corridors available with PRO</span></div>`;
};
const renderRows = (options: import('@/services/supply-chain').BypassOption[]): string => {
const top3 = options.slice(0, 3);
if (!top3.length) return `<div class="sc-bypass-error">No bypass options available</div>`;
const rows = top3.map(opt => {
const days = opt.addedTransitDays > 0 ? `+${opt.addedTransitDays}d` : '-';
const cost = opt.addedCostMultiplier > 1 ? `+${((opt.addedCostMultiplier - 1) * 100).toFixed(0)}%` : '-';
const riskTierMap: Record<string, string> = {
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',
};
const risk = riskTierMap[opt.bypassWarRiskTier] ?? opt.bypassWarRiskTier;
return `<tr><td>${escapeHtml(opt.name)}</td><td>${days}</td><td>${cost}</td><td>${escapeHtml(risk)}</td></tr>`;
}).join('');
return `<table class="sc-bypass-table">
<thead><tr><th>Corridor</th><th>+Days</th><th>+Cost</th><th>Risk</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
};
const applyAuthState = (isPro: boolean, bypassOptions?: import('@/services/supply-chain').BypassOption[]): void => {
if (!isPro) {
container.innerHTML = renderGate();
if (!this.bypassGateTracked) {
trackGateHit('bypass-corridors');
this.bypassGateTracked = true;
}
return;
}
if (bypassOptions !== undefined) {
container.innerHTML = renderRows(bypassOptions);
}
};
const isPro = hasPremiumAccess(getAuthState());
if (!isPro) {
applyAuthState(false);
if (this.bypassUnsubscribe) { this.bypassUnsubscribe(); }
this.bypassUnsubscribe = subscribeAuthState(state => {
if (hasPremiumAccess(state)) {
if (this.bypassUnsubscribe) { this.bypassUnsubscribe(); this.bypassUnsubscribe = null; }
if (!this.content.contains(container)) return;
container.innerHTML = `<div class="sc-bypass-loading">Loading bypass options\u2026</div>`;
void fetchBypassOptions(chokepointId, 'container', 100).then(resp => {
if (!this.content.contains(container)) return;
container.innerHTML = renderRows(resp.options);
}).catch(() => {
if (!this.content.contains(container)) return;
container.innerHTML = `<div class="sc-bypass-error">Bypass data unavailable</div>`;
});
}
});
return;
}
void fetchBypassOptions(chokepointId, 'container', 100).then(resp => {
if (!this.content.contains(container)) return;
applyAuthState(true, resp.options);
}).catch(() => {
if (!this.content.contains(container)) return;
container.innerHTML = `<div class="sc-bypass-error">Bypass data unavailable</div>`;
});
}
private renderChokepoints(): string {
if (!this.chokepointData || !this.chokepointData.chokepoints?.length) {
return `<div class="economic-empty">${t('components.supplyChain.noChokepoints')}</div>`;
@@ -185,6 +283,27 @@ export class SupplyChainPanel extends Panel {
? `<div data-chart-cp="${escapeHtml(cp.name)}" style="margin-top:8px;min-height:200px"></div>`
: '';
const tier = cp.warRiskTier ?? 'WAR_RISK_TIER_NORMAL';
const tierLabel: Record<string, string> = {
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',
};
const tierClass: Record<string, string> = {
WAR_RISK_TIER_WAR_ZONE: 'war',
WAR_RISK_TIER_CRITICAL: 'critical',
WAR_RISK_TIER_HIGH: 'high',
WAR_RISK_TIER_ELEVATED: 'elevated',
WAR_RISK_TIER_NORMAL: 'normal',
};
const warRiskBadge = `<span class="sc-war-risk-badge sc-war-risk-badge--${tierClass[tier] ?? 'normal'}">${tierLabel[tier] ?? 'Normal'}</span>`;
const bypassSection = expanded
? `<div class="sc-bypass-section" data-bypass-cp="${escapeHtml(cp.id)}"><div class="sc-bypass-loading">Loading bypass options\u2026</div></div>`
: '';
return `<div class="trade-restriction-card${expanded ? ' expanded' : ''}" data-cp-id="${escapeHtml(cp.name)}" style="cursor:pointer">
<div class="trade-restriction-header">
<span class="trade-country">${escapeHtml(cp.name)}</span>
@@ -206,6 +325,7 @@ export class SupplyChainPanel extends Panel {
<span>${t('components.supplyChain.riskLevel')}: <span class="${riskClass}">${escapeHtml(ts.riskLevel)}</span></span>
<span>${ts.incidentCount7d} ${t('components.supplyChain.incidents7d')}</span>
</div>` : ''}
<div class="sc-metric-row">${warRiskBadge}</div>
${cp.flowEstimate ? (() => {
const fe = cp.flowEstimate;
const pct = Math.round(fe.flowRatio * 100);
@@ -223,6 +343,7 @@ export class SupplyChainPanel extends Panel {
<div class="trade-affected">${cp.affectedRoutes.slice(0, 3).map(r => escapeHtml(r)).join(', ')}</div>
${actionRow}
${chartPlaceholder}
${bypassSection}
</div>
</div>`;
}).join('')}

View File

@@ -8,3 +8,4 @@
@import url('./main.css') layer(base);
@import url('./country-deep-dive.css') layer(base);
@import url('./map-context-menu.css') layer(base);
@import url('./supply-chain-panel.css') layer(base);

View File

@@ -987,3 +987,11 @@
opacity: 1;
}
.cdp-facts-grid .cdp-fact-label { color: var(--cdp-muted); text-transform: uppercase; font-size: 0.65rem; }
.cdp-vuln-index { font-size: 13px; color: #94a3b8; margin-bottom: 8px; }
.cdp-trade-exposure-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 8px; }
.cdp-chokepoint-name { color: #cbd5e1; padding: 3px 0; width: 45%; }
.cdp-exposure-bar-wrap { width: 40%; padding: 3px 6px; }
.cdp-exposure-bar { height: 6px; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 3px; min-width: 2px; }
.cdp-exposure-pct { color: #64748b; text-align: right; font-size: 11px; white-space: nowrap; }
.cdp-card-footer { font-size: 10px; color: #475569; margin-top: 6px; }

View File

@@ -7235,6 +7235,50 @@ a.prediction-link:hover {
margin-bottom: 5px;
}
.popup-hs2-ring-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 0;
}
.popup-hs2-ring-canvas {
display: block;
}
.popup-hs2-ring-legend {
width: 100%;
margin-top: 6px;
}
.popup-hs2-ring-legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
font-size: 11px;
}
.popup-hs2-ring-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.popup-hs2-ring-label {
flex: 1;
color: var(--text-secondary, #cbd5e1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popup-hs2-ring-pct {
color: var(--text-muted, #64748b);
font-size: 10px;
}
/* Collapsible sections */
.popup-section details {
border: none;

View File

@@ -0,0 +1,21 @@
.sc-war-risk-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.sc-war-risk-badge--war { background: rgba(220,38,38,0.15); color: #ef4444; border: 1px solid rgba(220,38,38,0.3); }
.sc-war-risk-badge--critical { background: rgba(220,38,38,0.12); color: #f87171; border: 1px solid rgba(220,38,38,0.25); }
.sc-war-risk-badge--high { background: rgba(249,115,22,0.12); color: #fb923c; border: 1px solid rgba(249,115,22,0.25); }
.sc-war-risk-badge--elevated { background: rgba(234,179,8,0.12); color: #fbbf24; border: 1px solid rgba(234,179,8,0.25); }
.sc-war-risk-badge--normal { background: rgba(100,116,139,0.1); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
.sc-bypass-section { margin-top: 10px; }
.sc-bypass-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.sc-bypass-table th { color: #64748b; font-weight: 500; padding: 4px 6px; text-align: left; border-bottom: 1px solid rgba(100,116,139,0.15); }
.sc-bypass-table td { padding: 4px 6px; border-bottom: 1px solid rgba(100,116,139,0.08); }
.sc-bypass-loading, .sc-bypass-error { font-size: 12px; color: #64748b; padding: 8px 0; text-align: center; }
.sc-bypass-gate { display: flex; align-items: center; gap: 8px; padding: 10px; background: rgba(100,116,139,0.06); border-radius: 6px; margin-top: 8px; font-size: 12px; color: #94a3b8; }

View File

@@ -0,0 +1,66 @@
interface SectorSlice {
label: string;
share: number;
color: string;
}
export class HS2RingChart {
mount(container: HTMLElement, sectors: SectorSlice[]): void {
if (!sectors.length) return;
const total = sectors.reduce((s, e) => s + e.share, 0) || 1;
const size = 110;
const cx = size / 2;
const cy = size / 2;
const r = 42;
const innerR = 24;
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
canvas.className = 'popup-hs2-ring-canvas';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
let startAngle = -Math.PI / 2;
sectors.forEach(slice => {
const sweep = (slice.share / total) * 2 * Math.PI;
ctx.beginPath();
ctx.arc(cx, cy, r, startAngle, startAngle + sweep);
ctx.arc(cx, cy, innerR, startAngle + sweep, startAngle, true);
ctx.closePath();
ctx.fillStyle = slice.color;
ctx.fill();
startAngle += sweep;
});
container.appendChild(canvas);
const legend = document.createElement('div');
legend.className = 'popup-hs2-ring-legend';
sectors.forEach(slice => {
const item = document.createElement('div');
item.className = 'popup-hs2-ring-legend-item';
const dot = document.createElement('span');
dot.className = 'popup-hs2-ring-dot';
dot.style.background = slice.color;
const label = document.createElement('span');
label.className = 'popup-hs2-ring-label';
label.textContent = slice.label;
const pct = document.createElement('span');
pct.className = 'popup-hs2-ring-pct';
pct.textContent = `${slice.share}%`;
item.appendChild(dot);
item.appendChild(label);
item.appendChild(pct);
legend.appendChild(item);
});
container.appendChild(legend);
}
}