mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(deep-dive): clickable sector rows with route visualization and bypass corridors (#2920)
* feat(deep-dive): clickable sector rows with route visualization and bypass corridors
- Trade Exposure sector rows expand on click to show route detail
- Route path: origin > chokepoints > destination from trade-routes config
- Top 3 bypass corridors load lazily on expand (PRO-gated)
- Map highlights selected route (bright arc, others dim to alpha 40)
- Map zooms to fit highlighted route segments
- Auto-minimizes panel if maximized (so map is visible)
- Click again to collapse and restore map
* fix(deep-dive): show all matching routes in detail, fire gate hit on click not render
- buildRouteDetail now iterates all matching routes for a chokepoint,
rendering each as a labeled path (route name + waypoint chain)
- Replaced single matchingRoutes[0] path with full list under a
"Routes via <chokepoint>:" heading
- Moved trackGateHit('sector-bypass-corridors') from render-time to
a { once: true } click handler on the PRO gate placeholder
* fix(deep-dive): clear route highlight before switching sectors
Move clearHighlightedRoute() to the top of handleSectorRowClick()
so it runs unconditionally before any branching. Previously it only
fired when collapsing the current selection, leaving a stale highlight
when switching to a new sector with no matching routes.
* test(deep-dive): add sector route explorer integration tests
Static analysis tests verifying the route highlighting pipeline across
DeckGLMap, MapContainer, and CountryDeepDivePanel. Covers method existence,
dispatch wiring, cleanup in reset, XSS sanitization, and data consistency.
* fix(tests): add missing stubs for sector route explorer imports
The country-deep-dive-panel harness was missing stubs for escapeHtml,
createCircuitBreaker, loadFromStorage, saveToStorage, and new modules
added by sector route explorer (trade-routes, geo, analytics, supply-chain).
This commit is contained in:
@@ -6,13 +6,17 @@ import { t } from '@/services/i18n';
|
||||
import { getCountryInfrastructure } from '@/services/related-assets';
|
||||
import type { PredictionMarket } from '@/services/prediction';
|
||||
import type { AssetType, NewsItem, RelatedAsset } from '@/types';
|
||||
import { sanitizeUrl } from '@/utils/sanitize';
|
||||
import { sanitizeUrl, escapeHtml } from '@/utils/sanitize';
|
||||
import { formatIntelBrief } from '@/utils/format-intel-brief';
|
||||
import { getCSSColor } from '@/utils';
|
||||
import { toFlagEmoji } from '@/utils/country-flag';
|
||||
import { PORTS } from '@/config/ports';
|
||||
import { getChokepointRoutes } from '@/config/trade-routes';
|
||||
import { STRATEGIC_WATERWAYS } from '@/config/geo';
|
||||
import { hasPremiumAccess } from '@/services/panel-gating';
|
||||
import { getAuthState } from '@/services/auth-state';
|
||||
import { trackGateHit } from '@/services/analytics';
|
||||
import { fetchBypassOptions } from '@/services/supply-chain';
|
||||
import { haversineDistanceKm } from '@/services/related-assets';
|
||||
import type {
|
||||
CountryBriefPanel,
|
||||
@@ -94,6 +98,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
private energyBody: HTMLElement | null = null;
|
||||
private maritimeBody: HTMLElement | null = null;
|
||||
private tradeExposureBody: HTMLElement | null = null;
|
||||
private selectedSectorHs2: string | null = null;
|
||||
private sectorBypassAbort: AbortController | null = null;
|
||||
private cachedTradeExposureData: GetCountryChokepointIndexResponse | null = null;
|
||||
private cachedSectors: SectorExposureSummary[] = [];
|
||||
private debtBody: HTMLElement | null = null;
|
||||
private sanctionsBody: HTMLElement | null = null;
|
||||
private comtradeBody: HTMLElement | null = null;
|
||||
@@ -1306,13 +1314,22 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedTradeExposureData = data;
|
||||
this.cachedSectors = sectors ?? [];
|
||||
|
||||
this.renderTradeExposureContent();
|
||||
}
|
||||
|
||||
private renderTradeExposureContent(): void {
|
||||
if (!this.tradeExposureBody || !this.cachedTradeExposureData) return;
|
||||
const data = this.cachedTradeExposureData;
|
||||
const sectors = this.cachedSectors;
|
||||
|
||||
this.tradeExposureBody.replaceChildren();
|
||||
|
||||
// Vulnerability index header
|
||||
const vulnDiv = this.el('div', 'cdp-vuln-index', `Vulnerability: ${Math.round(data.vulnerabilityIndex)}/100`);
|
||||
this.tradeExposureBody.append(vulnDiv);
|
||||
|
||||
// Sector-by-chokepoint matrix (if multi-sector data available)
|
||||
if (sectors && sectors.length > 0) {
|
||||
const sectorLabel = this.el('div', 'cdp-section-sublabel', 'Sector exposure by primary chokepoint');
|
||||
this.tradeExposureBody.append(sectorLabel);
|
||||
@@ -1328,7 +1345,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
|
||||
const tbody = this.el('tbody');
|
||||
for (const s of sectors.slice(0, 10)) {
|
||||
const isSelected = this.selectedSectorHs2 === s.hs2;
|
||||
const tr = this.el('tr');
|
||||
tr.className = `cdp-sector-row${isSelected ? ' cdp-sector-row--selected' : ''}`;
|
||||
tr.dataset.hs2 = s.hs2;
|
||||
const sectorCell = this.el('td', 'cdp-sector-label');
|
||||
sectorCell.textContent = s.label;
|
||||
const flag = DEPENDENCY_FLAG_LABELS[s.dependencyFlag];
|
||||
@@ -1344,11 +1364,27 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
scoreCell.style.color = scoreColor;
|
||||
tr.append(sectorCell, cpCell, scoreCell);
|
||||
tbody.append(tr);
|
||||
|
||||
if (isSelected) {
|
||||
const detailRow = this.el('tr');
|
||||
detailRow.className = 'cdp-sector-detail-row';
|
||||
const detailCell = this.el('td');
|
||||
detailCell.setAttribute('colspan', '3');
|
||||
detailCell.append(this.buildRouteDetail(s));
|
||||
detailRow.append(detailCell);
|
||||
tbody.append(detailRow);
|
||||
}
|
||||
}
|
||||
table.append(tbody);
|
||||
|
||||
tbody.addEventListener('click', (e) => {
|
||||
const row = (e.target as HTMLElement).closest<HTMLElement>('tr.cdp-sector-row');
|
||||
if (!row?.dataset.hs2) return;
|
||||
this.handleSectorRowClick(row.dataset.hs2);
|
||||
});
|
||||
|
||||
this.tradeExposureBody.append(table);
|
||||
} else {
|
||||
// Fallback: original chokepoint-only bars
|
||||
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');
|
||||
@@ -1372,6 +1408,140 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
this.tradeExposureBody.append(footer);
|
||||
}
|
||||
|
||||
private handleSectorRowClick(hs2: string): void {
|
||||
this.sectorBypassAbort?.abort();
|
||||
this.sectorBypassAbort = null;
|
||||
this.map?.clearHighlightedRoute();
|
||||
|
||||
if (this.selectedSectorHs2 === hs2) {
|
||||
this.selectedSectorHs2 = null;
|
||||
this.renderTradeExposureContent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMaximizedState) this.minimize();
|
||||
|
||||
this.selectedSectorHs2 = hs2;
|
||||
this.renderTradeExposureContent();
|
||||
|
||||
const sector = this.cachedSectors.find(s => s.hs2 === hs2);
|
||||
if (!sector) return;
|
||||
|
||||
const matchingRoutes = getChokepointRoutes(sector.primaryChokepointId);
|
||||
const matchingRouteIds = matchingRoutes.map(r => r.id);
|
||||
|
||||
if (matchingRouteIds.length > 0) {
|
||||
this.map?.highlightRoute(matchingRouteIds);
|
||||
this.map?.zoomToRoutes(matchingRouteIds);
|
||||
}
|
||||
}
|
||||
|
||||
private buildRouteDetail(sector: SectorExposureSummary): HTMLElement {
|
||||
const wrap = this.el('div', 'cdp-route-detail');
|
||||
|
||||
const matchingRoutes = getChokepointRoutes(sector.primaryChokepointId);
|
||||
|
||||
if (matchingRoutes.length === 0) {
|
||||
wrap.append(this.el('div', 'cdp-route-path', 'No maritime route data'));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const portMap = new Map(PORTS.map(p => [p.id, p.name]));
|
||||
const waterwayMap = new Map(STRATEGIC_WATERWAYS.map(w => [w.id, w.name]));
|
||||
|
||||
const cpName = waterwayMap.get(sector.primaryChokepointId) ?? sector.primaryChokepointName;
|
||||
const routesLabel = this.el('div', 'cdp-bypass-heading', `Routes via ${escapeHtml(cpName)}:`);
|
||||
wrap.append(routesLabel);
|
||||
|
||||
for (const route of matchingRoutes) {
|
||||
const pathParts: string[] = [];
|
||||
pathParts.push(portMap.get(route.from) ?? route.from);
|
||||
for (const wp of route.waypoints) {
|
||||
pathParts.push(waterwayMap.get(wp) ?? wp);
|
||||
}
|
||||
pathParts.push(portMap.get(route.to) ?? route.to);
|
||||
const pathStr = pathParts.map(p => escapeHtml(p)).join(' \u2192 ');
|
||||
|
||||
const pathEl = this.el('div', 'cdp-route-path');
|
||||
pathEl.innerHTML = `${escapeHtml(route.name)}: ${pathStr}`;
|
||||
wrap.append(pathEl);
|
||||
}
|
||||
|
||||
const statsEl = this.el('div', 'cdp-route-stats');
|
||||
const distEl = this.el('div');
|
||||
distEl.innerHTML = `Distance: <span>\u2014</span>`;
|
||||
const transitEl = this.el('div');
|
||||
transitEl.innerHTML = `Transit: <span>\u2014</span>`;
|
||||
const riskEl = this.el('div');
|
||||
const riskScore = sector.exposureScore;
|
||||
const riskColor = riskScore >= 70 ? '#ef4444' : riskScore > 30 ? '#f59e0b' : '#94a3b8';
|
||||
riskEl.innerHTML = `Chokepoint Risk: <span style="color:${riskColor}">${riskScore.toFixed(0)}/100</span>`;
|
||||
const routeCountEl = this.el('div');
|
||||
routeCountEl.innerHTML = `Routes via chokepoint: <span>${matchingRoutes.length}</span>`;
|
||||
statsEl.append(distEl, transitEl, riskEl, routeCountEl);
|
||||
wrap.append(statsEl);
|
||||
|
||||
const bypassSection = this.el('div', 'cdp-bypass-section');
|
||||
const bypassHeading = this.el('div', 'cdp-bypass-heading', 'Bypass Options');
|
||||
bypassSection.append(bypassHeading);
|
||||
const bypassContent = this.el('div');
|
||||
|
||||
const isPro = hasPremiumAccess(getAuthState());
|
||||
if (!isPro) {
|
||||
const gateEl = this.makeProLocked('Bypass corridors available with PRO');
|
||||
gateEl.addEventListener('click', () => trackGateHit('sector-bypass-corridors'), { once: true });
|
||||
bypassContent.append(gateEl);
|
||||
} else {
|
||||
bypassContent.append(this.makeLoading('Loading bypass options\u2026'));
|
||||
this.sectorBypassAbort = new AbortController();
|
||||
const signal = this.sectorBypassAbort.signal;
|
||||
void fetchBypassOptions(sector.primaryChokepointId, 'container', 100).then(resp => {
|
||||
if (signal.aborted) return;
|
||||
bypassContent.replaceChildren();
|
||||
const top3 = resp.options.slice(0, 3);
|
||||
if (top3.length === 0) {
|
||||
bypassContent.append(this.el('div', 'cdp-route-path', 'No bypass options available'));
|
||||
return;
|
||||
}
|
||||
const tbl = this.el('table', 'cdp-trade-exposure-table');
|
||||
const tHead = this.el('thead');
|
||||
const hRow = this.el('tr');
|
||||
hRow.append(this.el('th', '', 'Corridor'), this.el('th', '', '+Days'), this.el('th', '', '+Cost'), this.el('th', '', 'Risk'));
|
||||
tHead.append(hRow);
|
||||
tbl.append(tHead);
|
||||
const tBody = this.el('tbody');
|
||||
const riskTierMap: Record<string, string> = {
|
||||
WAR_RISK_TIER_UNSPECIFIED: 'Normal',
|
||||
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',
|
||||
};
|
||||
for (const opt of top3) {
|
||||
const r = this.el('tr');
|
||||
r.append(
|
||||
this.el('td', '', opt.name),
|
||||
this.el('td', '', opt.addedTransitDays > 0 ? `+${opt.addedTransitDays}d` : '\u2014'),
|
||||
this.el('td', '', opt.addedCostMultiplier > 1 ? `+${((opt.addedCostMultiplier - 1) * 100).toFixed(0)}%` : '\u2014'),
|
||||
this.el('td', '', riskTierMap[opt.bypassWarRiskTier] ?? opt.bypassWarRiskTier),
|
||||
);
|
||||
tBody.append(r);
|
||||
}
|
||||
tbl.append(tBody);
|
||||
bypassContent.append(tbl);
|
||||
}).catch(() => {
|
||||
if (signal.aborted) return;
|
||||
bypassContent.replaceChildren();
|
||||
bypassContent.append(this.el('div', 'cdp-route-path', 'Bypass data unavailable'));
|
||||
});
|
||||
}
|
||||
|
||||
bypassSection.append(bypassContent);
|
||||
wrap.append(bypassSection);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
private factItem(label: string, value: string): HTMLElement {
|
||||
const wrapper = this.el('div', 'cdp-fact-item');
|
||||
wrapper.append(this.el('div', 'cdp-fact-label', label));
|
||||
@@ -1674,6 +1844,12 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
|
||||
|
||||
private resetPanelContent(): void {
|
||||
this.destroyResilienceWidget();
|
||||
this.selectedSectorHs2 = null;
|
||||
this.sectorBypassAbort?.abort();
|
||||
this.sectorBypassAbort = null;
|
||||
this.cachedTradeExposureData = null;
|
||||
this.cachedSectors = [];
|
||||
this.map?.clearHighlightedRoute();
|
||||
this.scoreCard = null;
|
||||
this.energyBody = null;
|
||||
this.maritimeBody = null;
|
||||
|
||||
@@ -443,6 +443,7 @@ export class DeckGLMap {
|
||||
private tradeAnimationFrame: number | null = null;
|
||||
private tradeAnimationFrameCount = 0;
|
||||
private storedChokepointData: GetChokepointStatusResponse | null = null;
|
||||
private highlightedRouteIds: Set<string> = new Set();
|
||||
private scenarioState: ScenarioVisualState | null = null;
|
||||
private affectedIso2Set: Set<string> = new Set();
|
||||
private positiveEvents: PositiveGeoEvent[] = [];
|
||||
@@ -5060,15 +5061,30 @@ export class DeckGLMap {
|
||||
? new Set(this.scenarioState.disruptedChokepointIds)
|
||||
: null;
|
||||
|
||||
const hlActive = this.highlightedRouteIds.size > 0;
|
||||
const hlIds = this.highlightedRouteIds;
|
||||
|
||||
const dimColor = (c: [number, number, number, number]): [number, number, number, number] =>
|
||||
[c[0], c[1], c[2], 40];
|
||||
|
||||
const getColor = (d: TradeRouteSegment): [number, number, number, number] => {
|
||||
let base: [number, number, number, number];
|
||||
if (scenarioDisrupted && scenarioDisrupted.size > 0) {
|
||||
const waypoints = ROUTE_WAYPOINTS_MAP.get(d.routeId);
|
||||
if (waypoints && waypoints.some(wp => scenarioDisrupted.has(wp))) {
|
||||
return scenario;
|
||||
base = scenario;
|
||||
} else if (!hasPremiumAccess(getAuthState())) {
|
||||
base = active;
|
||||
} else {
|
||||
base = colorFor(d.status);
|
||||
}
|
||||
} else if (!hasPremiumAccess(getAuthState())) {
|
||||
base = active;
|
||||
} else {
|
||||
base = colorFor(d.status);
|
||||
}
|
||||
if (!hasPremiumAccess(getAuthState())) return active; // free users: always blue
|
||||
return colorFor(d.status);
|
||||
if (hlActive && !hlIds.has(d.routeId)) return dimColor(base);
|
||||
return base;
|
||||
};
|
||||
|
||||
return new ArcLayer<TradeRouteSegment>({
|
||||
@@ -5078,9 +5094,12 @@ export class DeckGLMap {
|
||||
getTargetPosition: (d) => d.targetPosition,
|
||||
getSourceColor: getColor,
|
||||
getTargetColor: getColor,
|
||||
getWidth: (d) => d.category === 'energy' ? 3 : 2,
|
||||
getWidth: (d) => {
|
||||
if (hlActive && hlIds.has(d.routeId)) return 6;
|
||||
return d.category === 'energy' ? 3 : 2;
|
||||
},
|
||||
widthMinPixels: 1,
|
||||
widthMaxPixels: 6,
|
||||
widthMaxPixels: 8,
|
||||
greatCircle: true,
|
||||
pickable: true,
|
||||
});
|
||||
@@ -5098,15 +5117,27 @@ export class DeckGLMap {
|
||||
? new Set(this.scenarioState.disruptedChokepointIds)
|
||||
: null;
|
||||
|
||||
const hlActive = this.highlightedRouteIds.size > 0;
|
||||
const hlIds = this.highlightedRouteIds;
|
||||
|
||||
const colorForRoute = (routeId: string, status: string): [number, number, number, number] => {
|
||||
let base: [number, number, number, number];
|
||||
if (scenarioDisrupted && scenarioDisrupted.size > 0) {
|
||||
const waypoints = ROUTE_WAYPOINTS_MAP.get(routeId);
|
||||
if (waypoints && waypoints.some(wp => scenarioDisrupted.has(wp))) {
|
||||
return scenarioColor;
|
||||
base = scenarioColor;
|
||||
} else if (!isPremium) {
|
||||
base = activeColor;
|
||||
} else {
|
||||
base = status === 'disrupted' ? disruptedColor : status === 'high_risk' ? highRiskColor : activeColor;
|
||||
}
|
||||
} else if (!isPremium) {
|
||||
base = activeColor;
|
||||
} else {
|
||||
base = status === 'disrupted' ? disruptedColor : status === 'high_risk' ? highRiskColor : activeColor;
|
||||
}
|
||||
if (!isPremium) return activeColor;
|
||||
return status === 'disrupted' ? disruptedColor : status === 'high_risk' ? highRiskColor : activeColor;
|
||||
if (hlActive && !hlIds.has(routeId)) return [base[0], base[1], base[2], 40];
|
||||
return base;
|
||||
};
|
||||
|
||||
const widthFor = (category: string): number =>
|
||||
@@ -5621,6 +5652,45 @@ export class DeckGLMap {
|
||||
this.render();
|
||||
}
|
||||
|
||||
public highlightRoute(routeIds: string[]): void {
|
||||
this.highlightedRouteIds = new Set(routeIds);
|
||||
this.buildTradeTrips();
|
||||
this.render();
|
||||
}
|
||||
|
||||
public clearHighlightedRoute(): void {
|
||||
if (this.highlightedRouteIds.size === 0) return;
|
||||
this.highlightedRouteIds.clear();
|
||||
this.buildTradeTrips();
|
||||
this.render();
|
||||
}
|
||||
|
||||
public zoomToRoutes(routeIds: string[]): void {
|
||||
if (!this.maplibreMap || routeIds.length === 0) return;
|
||||
const ids = new Set(routeIds);
|
||||
let minLng = 180, maxLng = -180, minLat = 90, maxLat = -90;
|
||||
let found = false;
|
||||
for (const seg of this.tradeRouteSegments) {
|
||||
if (!ids.has(seg.routeId)) continue;
|
||||
found = true;
|
||||
const [sLng, sLat] = seg.sourcePosition;
|
||||
const [tLng, tLat] = seg.targetPosition;
|
||||
if (sLng < minLng) minLng = sLng;
|
||||
if (sLng > maxLng) maxLng = sLng;
|
||||
if (sLat < minLat) minLat = sLat;
|
||||
if (sLat > maxLat) maxLat = sLat;
|
||||
if (tLng < minLng) minLng = tLng;
|
||||
if (tLng > maxLng) maxLng = tLng;
|
||||
if (tLat < minLat) minLat = tLat;
|
||||
if (tLat > maxLat) maxLat = tLat;
|
||||
}
|
||||
if (!found) return;
|
||||
this.maplibreMap.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
|
||||
padding: 60,
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
public setHappinessScores(data: HappinessData): void {
|
||||
this.happinessScores = data.scores;
|
||||
this.happinessYear = data.year;
|
||||
|
||||
@@ -972,6 +972,20 @@ export class MapContainer {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route Highlight ─────────────────────────────────────────────────────────
|
||||
|
||||
public highlightRoute(routeIds: string[]): void {
|
||||
this.deckGLMap?.highlightRoute(routeIds);
|
||||
}
|
||||
|
||||
public clearHighlightedRoute(): void {
|
||||
this.deckGLMap?.clearHighlightedRoute();
|
||||
}
|
||||
|
||||
public zoomToRoutes(routeIds: string[]): void {
|
||||
this.deckGLMap?.zoomToRoutes(routeIds);
|
||||
}
|
||||
|
||||
// ─── Scenario Engine ─────────────────────────────────────────────────────────
|
||||
|
||||
public setSupplyChainPanel(panel: import('@/components/SupplyChainPanel').SupplyChainPanel): void {
|
||||
|
||||
@@ -1249,3 +1249,48 @@
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.cdp-trade-exposure-table tbody tr.cdp-sector-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cdp-trade-exposure-table tbody tr.cdp-sector-row:hover {
|
||||
background: rgba(100, 160, 255, 0.06);
|
||||
}
|
||||
.cdp-trade-exposure-table tbody tr.cdp-sector-row--selected {
|
||||
background: rgba(100, 160, 255, 0.1);
|
||||
}
|
||||
.cdp-route-detail {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
.cdp-route-path {
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
font-family: var(--mono-font, 'JetBrains Mono', monospace);
|
||||
padding: 6px 8px;
|
||||
background: rgba(100, 116, 139, 0.06);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cdp-route-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cdp-route-stats span { color: #e2e8f0; font-weight: 500; }
|
||||
.cdp-bypass-heading {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.cdp-sector-detail-row td {
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,26 @@ async function loadCountryDeepDivePanel() {
|
||||
return 0;
|
||||
}
|
||||
`],
|
||||
['sanitize-stub', `export function sanitizeUrl(value) { return value ?? ''; }`],
|
||||
['sanitize-stub', `
|
||||
export function sanitizeUrl(value) { return value ?? ''; }
|
||||
export function escapeHtml(value) { return value ?? ''; }
|
||||
`],
|
||||
['intel-brief-stub', `export function formatIntelBrief(value) { return value; }`],
|
||||
['utils-stub', `export function getCSSColor() { return '#44ff88'; }`],
|
||||
['utils-stub', `
|
||||
export function getCSSColor() { return '#44ff88'; }
|
||||
export function createCircuitBreaker() { return { execute: (fn) => fn() }; }
|
||||
export function loadFromStorage() { return null; }
|
||||
export function saveToStorage() {}
|
||||
`],
|
||||
['country-flag-stub', `export function toFlagEmoji(code, fallback = '🌍') { return code ? ':' + code + ':' : fallback; }`],
|
||||
['ports-stub', `export const PORTS = [];`],
|
||||
['trade-routes-stub', `export function getChokepointRoutes() { return []; }`],
|
||||
['geo-stub', `export const STRATEGIC_WATERWAYS = [];`],
|
||||
['analytics-stub', `export function trackGateHit() {}`],
|
||||
['supply-chain-stub', `
|
||||
export function fetchBypassOptions() { return Promise.resolve({ corridors: [] }); }
|
||||
export function getCountryChokepointIndex() { return null; }
|
||||
`],
|
||||
['runtime-stub', `
|
||||
export function toApiUrl(path) { return path; }
|
||||
export function isDesktopRuntime() { return false; }
|
||||
@@ -126,6 +141,10 @@ async function loadCountryDeepDivePanel() {
|
||||
['@/utils', 'utils-stub'],
|
||||
['@/utils/country-flag', 'country-flag-stub'],
|
||||
['@/config/ports', 'ports-stub'],
|
||||
['@/config/trade-routes', 'trade-routes-stub'],
|
||||
['@/config/geo', 'geo-stub'],
|
||||
['@/services/analytics', 'analytics-stub'],
|
||||
['@/services/supply-chain', 'supply-chain-stub'],
|
||||
['./ResilienceWidget', 'resilience-widget-stub'],
|
||||
['@/services/runtime', 'runtime-stub'],
|
||||
['@/generated/client/worldmonitor/intelligence/v1/service_client', 'intelligence-client-stub'],
|
||||
|
||||
237
tests/sector-route-explorer.test.mjs
Normal file
237
tests/sector-route-explorer.test.mjs
Normal file
@@ -0,0 +1,237 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const root = join(import.meta.dirname, '..');
|
||||
|
||||
const deckGLMapSrc = readFileSync(join(root, 'src', 'components', 'DeckGLMap.ts'), 'utf-8');
|
||||
const mapContainerSrc = readFileSync(join(root, 'src', 'components', 'MapContainer.ts'), 'utf-8');
|
||||
const deepDiveSrc = readFileSync(join(root, 'src', 'components', 'CountryDeepDivePanel.ts'), 'utf-8');
|
||||
|
||||
describe('DeckGLMap route highlight methods', () => {
|
||||
it('highlightRoute method exists and accepts string array', () => {
|
||||
assert.ok(
|
||||
deckGLMapSrc.includes('highlightRoute(routeIds'),
|
||||
'DeckGLMap must have highlightRoute method accepting routeIds',
|
||||
);
|
||||
});
|
||||
|
||||
it('clearHighlightedRoute method exists', () => {
|
||||
assert.ok(
|
||||
deckGLMapSrc.includes('clearHighlightedRoute'),
|
||||
'DeckGLMap must have clearHighlightedRoute method',
|
||||
);
|
||||
});
|
||||
|
||||
it('zoomToRoutes method exists', () => {
|
||||
assert.ok(
|
||||
deckGLMapSrc.includes('zoomToRoutes(routeIds'),
|
||||
'DeckGLMap must have zoomToRoutes method accepting routeIds',
|
||||
);
|
||||
});
|
||||
|
||||
it('highlightedRouteIds field is a Set', () => {
|
||||
assert.ok(
|
||||
deckGLMapSrc.includes('highlightedRouteIds: Set<string>') ||
|
||||
deckGLMapSrc.includes('highlightedRouteIds = new Set'),
|
||||
'DeckGLMap must declare highlightedRouteIds as a Set<string>',
|
||||
);
|
||||
});
|
||||
|
||||
it('createTradeRoutesLayer checks highlightedRouteIds.size for dimming', () => {
|
||||
const defIdx = deckGLMapSrc.indexOf('private createTradeRoutesLayer');
|
||||
assert.ok(defIdx !== -1, 'createTradeRoutesLayer method definition must exist');
|
||||
const layerMethod = deckGLMapSrc.slice(defIdx, defIdx + 3000);
|
||||
assert.ok(
|
||||
layerMethod.includes('highlightedRouteIds.size'),
|
||||
'createTradeRoutesLayer must check highlightedRouteIds.size to determine dimming',
|
||||
);
|
||||
assert.ok(
|
||||
layerMethod.includes('dimColor'),
|
||||
'createTradeRoutesLayer must use dimColor for non-highlighted routes',
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTradeTrips handles highlighting', () => {
|
||||
const tripsMethod = deckGLMapSrc.slice(
|
||||
deckGLMapSrc.indexOf('buildTradeTrips'),
|
||||
deckGLMapSrc.indexOf('buildTradeTrips') + 3000,
|
||||
);
|
||||
assert.ok(
|
||||
tripsMethod.includes('highlightedRouteIds.size'),
|
||||
'buildTradeTrips must check highlightedRouteIds.size for route highlighting',
|
||||
);
|
||||
assert.ok(
|
||||
tripsMethod.includes('hlIds.has(routeId)'),
|
||||
'buildTradeTrips must check hlIds.has(routeId) for per-route dimming',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapContainer route highlight dispatch', () => {
|
||||
it('highlightRoute method exists', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('highlightRoute(routeIds'),
|
||||
'MapContainer must have highlightRoute method',
|
||||
);
|
||||
});
|
||||
|
||||
it('clearHighlightedRoute method exists', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('clearHighlightedRoute'),
|
||||
'MapContainer must have clearHighlightedRoute method',
|
||||
);
|
||||
});
|
||||
|
||||
it('zoomToRoutes method exists', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('zoomToRoutes(routeIds'),
|
||||
'MapContainer must have zoomToRoutes method',
|
||||
);
|
||||
});
|
||||
|
||||
it('highlightRoute dispatches to deckGLMap', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('deckGLMap?.highlightRoute'),
|
||||
'MapContainer.highlightRoute must dispatch to deckGLMap.highlightRoute',
|
||||
);
|
||||
});
|
||||
|
||||
it('clearHighlightedRoute dispatches to deckGLMap', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('deckGLMap?.clearHighlightedRoute'),
|
||||
'MapContainer.clearHighlightedRoute must dispatch to deckGLMap.clearHighlightedRoute',
|
||||
);
|
||||
});
|
||||
|
||||
it('zoomToRoutes dispatches to deckGLMap', () => {
|
||||
assert.ok(
|
||||
mapContainerSrc.includes('deckGLMap?.zoomToRoutes'),
|
||||
'MapContainer.zoomToRoutes must dispatch to deckGLMap.zoomToRoutes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CountryDeepDivePanel sector route interaction', () => {
|
||||
it('selectedSectorHs2 field exists', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes('selectedSectorHs2'),
|
||||
'CountryDeepDivePanel must have selectedSectorHs2 field',
|
||||
);
|
||||
});
|
||||
|
||||
it('handleSectorRowClick method exists', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes('handleSectorRowClick'),
|
||||
'CountryDeepDivePanel must have handleSectorRowClick method',
|
||||
);
|
||||
});
|
||||
|
||||
it('clearHighlightedRoute is called unconditionally at top of handleSectorRowClick', () => {
|
||||
const methodStart = deepDiveSrc.indexOf('handleSectorRowClick(hs2');
|
||||
assert.ok(methodStart !== -1, 'handleSectorRowClick must exist');
|
||||
const firstIf = deepDiveSrc.indexOf('if (this.selectedSectorHs2', methodStart);
|
||||
const clearCall = deepDiveSrc.indexOf('clearHighlightedRoute()', methodStart);
|
||||
assert.ok(clearCall !== -1, 'handleSectorRowClick must call clearHighlightedRoute');
|
||||
assert.ok(
|
||||
clearCall < firstIf,
|
||||
'clearHighlightedRoute must be called before the selectedSectorHs2 toggle check (unconditional cleanup)',
|
||||
);
|
||||
});
|
||||
|
||||
it('buildRouteDetail method exists', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes('buildRouteDetail'),
|
||||
'CountryDeepDivePanel must have buildRouteDetail method',
|
||||
);
|
||||
});
|
||||
|
||||
it('trackGateHit is NOT called during render (should be in click handler)', () => {
|
||||
const importLine = deepDiveSrc.indexOf("import { trackGateHit }");
|
||||
const importEnd = deepDiveSrc.indexOf('\n', importLine);
|
||||
const matches = [...deepDiveSrc.matchAll(/trackGateHit/g)];
|
||||
assert.ok(matches.length >= 2, 'trackGateHit must be imported and used at least once');
|
||||
for (const m of matches) {
|
||||
if (m.index >= importLine && m.index <= importEnd) continue;
|
||||
const contextBefore = deepDiveSrc.slice(Math.max(0, m.index - 200), m.index);
|
||||
assert.ok(
|
||||
contextBefore.includes('addEventListener') || contextBefore.includes("'click'"),
|
||||
'trackGateHit must only be called inside an event listener, never during render',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('resetPanelContent clears selectedSectorHs2', () => {
|
||||
const resetStart = deepDiveSrc.indexOf('resetPanelContent(): void');
|
||||
assert.ok(resetStart !== -1, 'resetPanelContent must exist');
|
||||
const resetBody = deepDiveSrc.slice(resetStart, resetStart + 500);
|
||||
assert.ok(
|
||||
resetBody.includes('this.selectedSectorHs2 = null'),
|
||||
'resetPanelContent must set selectedSectorHs2 to null',
|
||||
);
|
||||
});
|
||||
|
||||
it('sectorBypassAbort is aborted in reset', () => {
|
||||
const resetStart = deepDiveSrc.indexOf('resetPanelContent(): void');
|
||||
const resetBody = deepDiveSrc.slice(resetStart, resetStart + 500);
|
||||
assert.ok(
|
||||
resetBody.includes('sectorBypassAbort?.abort()'),
|
||||
'resetPanelContent must abort sectorBypassAbort',
|
||||
);
|
||||
assert.ok(
|
||||
resetBody.includes('sectorBypassAbort = null'),
|
||||
'resetPanelContent must set sectorBypassAbort to null after aborting',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapeHtml is used in route path rendering', () => {
|
||||
const defIdx = deepDiveSrc.indexOf('private buildRouteDetail');
|
||||
assert.ok(defIdx !== -1, 'buildRouteDetail method definition must exist');
|
||||
const buildDetail = deepDiveSrc.slice(defIdx, defIdx + 3000);
|
||||
assert.ok(
|
||||
buildDetail.includes('escapeHtml'),
|
||||
'buildRouteDetail must use escapeHtml to sanitize route path rendering',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sector route data consistency', () => {
|
||||
it('getChokepointRoutes is imported from trade-routes', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes("getChokepointRoutes") &&
|
||||
deepDiveSrc.includes("trade-routes"),
|
||||
'CountryDeepDivePanel must import getChokepointRoutes from trade-routes',
|
||||
);
|
||||
});
|
||||
|
||||
it('STRATEGIC_WATERWAYS is imported from geo', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes("STRATEGIC_WATERWAYS") &&
|
||||
deepDiveSrc.includes("from '@/config/geo'"),
|
||||
'CountryDeepDivePanel must import STRATEGIC_WATERWAYS from @/config/geo',
|
||||
);
|
||||
});
|
||||
|
||||
it('bypass options use fetchBypassOptions (not inline data)', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes('fetchBypassOptions'),
|
||||
'CountryDeepDivePanel must use fetchBypassOptions for bypass corridor data',
|
||||
);
|
||||
const defIdx = deepDiveSrc.indexOf('private buildRouteDetail');
|
||||
assert.ok(defIdx !== -1, 'buildRouteDetail method definition must exist');
|
||||
const buildDetail = deepDiveSrc.slice(defIdx, defIdx + 4000);
|
||||
assert.ok(
|
||||
buildDetail.includes('fetchBypassOptions'),
|
||||
'buildRouteDetail must call fetchBypassOptions rather than using inline bypass data',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapeHtml is imported from sanitize', () => {
|
||||
assert.ok(
|
||||
deepDiveSrc.includes("escapeHtml") &&
|
||||
deepDiveSrc.includes("from '@/utils/sanitize'"),
|
||||
'CountryDeepDivePanel must import escapeHtml from @/utils/sanitize',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user