File size: 8,756 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | /**
* Impact tab — shows strategic-product impact data for the destination
* country. Renders top 5 products by value with chokepoint exposure,
* lane-specific value for the selected HS2, and dependency flags.
*
* Clicking a strategic-product row fires onDrillSideways with that HS2,
* allowing the explorer to re-query with the clicked product.
*/
import type {
GetRouteImpactResponse,
StrategicProduct,
} from '@/generated/server/worldmonitor/supply_chain/v1/service_server';
import {
formatScoredResilienceOverallLabel,
formatResilienceConfidence,
formatResilienceScoreInterval,
hasScoredResilienceOverall,
} from '@/components/resilience-widget-utils';
import type { ResilienceScoreResponse } from '@/services/resilience';
import { escapeHtml } from './route-utils';
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
export interface CountryImpactTabOptions {
onDrillSideways?: (hs2: string) => void;
}
function hs4ToHs2(hs4: string): string {
return String(Number.parseInt(hs4.slice(0, 2), 10));
}
function formatUsd(value: number): string {
if (value >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
if (value >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `$${(value / 1e3).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
}
const FLAG_LABELS: Record<string, string> = {
DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL: 'Single Source Critical',
DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL: 'Single Corridor Critical',
DEPENDENCY_FLAG_COMPOUND_RISK: 'Compound Risk',
DEPENDENCY_FLAG_DIVERSIFIABLE: 'Diversifiable',
};
export class CountryImpactTab {
public readonly element: HTMLDivElement;
private opts: CountryImpactTabOptions;
private data: GetRouteImpactResponse | null = null;
private resilience: ResilienceScoreResponse | null = null;
constructor(opts: CountryImpactTabOptions = {}) {
this.opts = opts;
this.element = document.createElement('div');
this.element.className = 're-tab re-tab--impact';
this.element.setAttribute('role', 'tabpanel');
this.renderPlaceholder();
}
public update(data: GetRouteImpactResponse | null): void {
this.data = data;
if (!data) this.resilience = null;
if (!data) { this.renderPlaceholder(); return; }
if (data.comtradeSource === 'missing') { this.renderMissing(); return; }
if (data.comtradeSource === 'empty') { this.renderEmpty(); return; }
if (data.comtradeSource === 'lazy') { this.renderLazy(); return; }
this.renderData(data);
}
public updateResilience(resilience: ResilienceScoreResponse | null): void {
this.resilience = resilience;
if (this.data && this.data.comtradeSource !== 'missing' && this.data.comtradeSource !== 'empty' && this.data.comtradeSource !== 'lazy') {
this.updateResilienceSlot(this.data);
}
}
private renderPlaceholder(): void {
setTrustedHtml(this.element, trustedHtml('<div class="re-tab__placeholder">Pick a country pair and product to see the impact analysis.</div>', "legacy direct innerHTML migration"));
}
private renderMissing(): void {
setTrustedHtml(this.element, trustedHtml('<div class="re-tab__empty">' +
'<h3>No trade data available</h3>' +
'<p>WorldMonitor does not have bilateral trade data for this destination country yet.</p>' +
'</div>', "legacy direct innerHTML migration"));
}
private renderEmpty(): void {
setTrustedHtml(this.element, trustedHtml('<div class="re-tab__empty">' +
'<h3>No strategic products found</h3>' +
'<p>The bilateral trade store returned empty data for this destination.</p>' +
'</div>', "legacy direct innerHTML migration"));
}
private renderLazy(): void {
setTrustedHtml(this.element, trustedHtml('<div class="re-tab__empty">' +
'<h3>Loading trade data</h3>' +
'<p>WorldMonitor is fetching trade data for this destination for the first time. ' +
'Try again in a few seconds.</p>' +
'</div>', "legacy direct innerHTML migration"));
}
private renderData(data: GetRouteImpactResponse): void {
const bannerHtml = !data.hs2InSeededUniverse
? '<div class="re-impact__banner">Lane value for this HS code is not in WorldMonitor\'s strategic-products dataset. Top strategic products shown below.</div>'
: '';
const laneHtml = data.hs2InSeededUniverse
? `<div class="re-impact__lane">
<div class="re-impact__lane-value">${formatUsd(data.laneValueUsd)}</div>
<div class="re-impact__lane-label">Lane value at risk</div>
${data.primaryExporterIso2 ? `<div class="re-impact__lane-exporter">Top exporter: ${escapeHtml(data.primaryExporterIso2)} (${Math.round(data.primaryExporterShare * 100)}%)</div>` : ''}
</div>`
: '';
const flagsHtml = data.dependencyFlags.length > 0
? `<div class="re-impact__flags">${data.dependencyFlags.map((f) => `<span class="re-impact__flag re-impact__flag--${f.toLowerCase().replace(/^dependency_flag_/, '')}">${escapeHtml(FLAG_LABELS[f] ?? f)}</span>`).join('')}</div>`
: '';
const productsHtml = this.renderProducts(data.topStrategicProducts);
setTrustedHtml(this.element, trustedHtml(`${bannerHtml}${laneHtml}${flagsHtml}<div class="re-impact__resilience-slot">${this.renderResilience(data)}</div><h3 class="re-impact__products-title">Top strategic products</h3>${productsHtml}`, "legacy direct innerHTML migration"));
this.attachDrillListeners();
}
private updateResilienceSlot(data: GetRouteImpactResponse): void {
const slot = this.element.querySelector('.re-impact__resilience-slot');
if (!slot) return;
setTrustedHtml(slot, trustedHtml(this.renderResilience(data), "legacy direct innerHTML migration"));
}
private renderResilience(data: GetRouteImpactResponse): string {
const resilience = this.resilience;
if (resilience && hasScoredResilienceOverall(resilience)) {
const scoreLabel = formatScoredResilienceOverallLabel(resilience.overallScore);
const confidence = formatResilienceConfidence(resilience);
const interval = formatResilienceScoreInterval(resilience.scoreInterval);
return [
'<div class="re-impact__resilience">',
` <span>Resilience: <strong>${escapeHtml(scoreLabel)}/100</strong></span>`,
...(interval
? [` <span class="re-resilience-interval" title="${escapeHtml(interval.title)}">${escapeHtml(interval.label)}</span>`]
: []),
` <span class="re-resilience-confidence${resilience.lowConfidence ? ' re-resilience-confidence--low' : ''}">${escapeHtml(confidence)}</span>`,
'</div>',
].join('');
}
if (resilience) {
return '<div class="re-impact__resilience"><span>Resilience: <strong>\u2014</strong></span><span class="re-resilience-confidence re-resilience-confidence--low">No scored resilience data</span></div>';
}
const fallbackScore = Number.isFinite(data.resilienceScore) ? Math.round(data.resilienceScore) : 0;
if (fallbackScore > 0) {
return `<div class="re-impact__resilience"><span>Resilience: <strong>${fallbackScore}/100</strong></span><span class="re-resilience-confidence">Confidence unavailable</span></div>`;
}
return '';
}
private renderProducts(products: StrategicProduct[]): string {
if (products.length === 0) return '<div class="re-tab__empty">No products available.</div>';
const rows = products.map((p) =>
`<tr class="re-impact__product-row" data-hs2="${escapeHtml(hs4ToHs2(p.hs4))}" tabindex="0">` +
`<td class="re-impact__product-code">HS ${escapeHtml(p.hs4)}</td>` +
`<td class="re-impact__product-name">${escapeHtml(p.label)}</td>` +
`<td class="re-impact__product-value">${formatUsd(p.totalValueUsd)}</td>` +
`<td class="re-impact__product-exporter">${escapeHtml(p.topExporterIso2)} (${Math.round(p.topExporterShare * 100)}%)</td>` +
`<td class="re-impact__product-chokepoint">${escapeHtml(p.primaryChokepointId)}</td>` +
`</tr>`,
);
return [
'<table class="re-impact__products">',
'<thead><tr><th>HS4</th><th>Product</th><th>Value</th><th>Top Exporter</th><th>Chokepoint</th></tr></thead>',
`<tbody>${rows.join('')}</tbody>`,
'</table>',
].join('');
}
private attachDrillListeners(): void {
if (!this.opts.onDrillSideways) return;
const rows = this.element.querySelectorAll<HTMLElement>('.re-impact__product-row');
rows.forEach((row) => {
const hs2 = row.dataset.hs2;
if (!hs2) return;
const drill = () => this.opts.onDrillSideways?.(hs2);
row.addEventListener('click', drill);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); drill(); }
});
});
}
}
|