feat(supply-chain): stacked vessel-type transit chart with 7d MA, DWT tab, zoom (#2777)

* feat(supply-chain): stacked vessel-type transit chart with 7d MA, DWT tab, zoom

- Update TransitDayCount proto (fields 6-14): container, dry_bulk,
  general_cargo, roro, cap_* DWT capacity fields; regenerate TS types
- Rewrite transit-chart.ts: 5-type stacked bar (container/dryBulk/
  generalCargo/roro/tanker), 7d MA dashed overlay, Transit Calls /
  Trade Volume tab toggle, 1m/3m/6m zoom buttons, richer tooltip
- SupplyChainPanel: enlarge chart placeholder min-height 120->200px

* fix(transit-chart): stop control clicks bubbling + track source div in destroy

- stopPropagation on controls container prevents tab/zoom button clicks
  from collapsing the chokepoint card
- source div now tracked as this.source and cleaned up in destroy(),
  preventing duplicate attribution lines on repeated remounts

* fix(transit-chart): import from generated client, reuse data in onMouseMove

- Import TransitDayCount from generated client stub instead of server
  layer; keeps src/ imports within src/
- onMouseMove: reuse already-bound data array for MA computation instead
  of calling visibleData() again on every mouse event
This commit is contained in:
Elie Habib
2026-04-07 08:46:27 +04:00
committed by GitHub
parent 588f76b698
commit 190095ca89
7 changed files with 260 additions and 109 deletions

File diff suppressed because one or more lines are too long

View File

@@ -299,6 +299,33 @@ components:
total:
type: integer
format: int32
container:
type: integer
format: int32
dryBulk:
type: integer
format: int32
generalCargo:
type: integer
format: int32
roro:
type: integer
format: int32
capContainer:
type: number
format: double
capDryBulk:
type: number
format: double
capGeneralCargo:
type: number
format: double
capRoro:
type: number
format: double
capTanker:
type: number
format: double
GetCriticalMineralsRequest:
type: object
GetCriticalMineralsResponse:

View File

@@ -47,6 +47,15 @@ message TransitDayCount {
int32 cargo = 3;
int32 other = 4;
int32 total = 5;
int32 container = 6;
int32 dry_bulk = 7;
int32 general_cargo = 8;
int32 roro = 9;
double cap_container = 10;
double cap_dry_bulk = 11;
double cap_general_cargo = 12;
double cap_roro = 13;
double cap_tanker = 14;
}
message TransitSummary {

View File

@@ -180,7 +180,7 @@ export class SupplyChainPanel extends Panel {
? `<div class="sc-routing-advisory">${escapeHtml(ts.riskReportAction)}</div>`
: '';
const chartPlaceholder = expanded && ts?.history?.length
? `<div data-chart-cp="${escapeHtml(cp.name)}" style="margin-top:8px;min-height:120px"></div>`
? `<div data-chart-cp="${escapeHtml(cp.name)}" style="margin-top:8px;min-height:200px"></div>`
: '';
return `<div class="trade-restriction-card${expanded ? ' expanded' : ''}" data-cp-id="${escapeHtml(cp.name)}" style="cursor:pointer">

View File

@@ -79,6 +79,15 @@ export interface TransitDayCount {
cargo: number;
other: number;
total: number;
container: number;
dryBulk: number;
generalCargo: number;
roro: number;
capContainer: number;
capDryBulk: number;
capGeneralCargo: number;
capRoro: number;
capTanker: number;
}
export interface GetCriticalMineralsRequest {

View File

@@ -79,6 +79,15 @@ export interface TransitDayCount {
cargo: number;
other: number;
total: number;
container: number;
dryBulk: number;
generalCargo: number;
roro: number;
capContainer: number;
capDryBulk: number;
capGeneralCargo: number;
capRoro: number;
capTanker: number;
}
export interface GetCriticalMineralsRequest {

View File

@@ -1,110 +1,189 @@
import type { TransitDayCount } from '../generated/client/worldmonitor/supply_chain/v1/service_client';
import { getCSSColor } from '@/utils';
interface TransitPoint {
date: string;
tanker: number;
cargo: number;
type ZoomWindow = 30 | 90 | 180;
type Tab = 'calls' | 'dwt';
const PAD = { top: 14, right: 38, bottom: 24, left: 4 };
const GRID_LINES = 4;
const VESSEL_KEYS: Array<keyof TransitDayCount & string> = ['container', 'dryBulk', 'generalCargo', 'roro', 'tanker'];
const CAP_KEYS: Array<keyof TransitDayCount & string> = ['capContainer', 'capDryBulk', 'capGeneralCargo', 'capRoro', 'capTanker'];
const VESSEL_COLORS = ['#dc2626', '#ea580c', '#ca8a04', '#0284c7', '#15803d'];
const VESSEL_LABELS = ['Container', 'Dry Bulk', 'Gen. Cargo', 'RoRo', 'Tanker'];
const MA_COLOR = '#f59e0b';
function compute7dMA(values: number[]): number[] {
return values.map((_, i) => {
const start = Math.max(0, i - 6);
const slice = values.slice(start, i + 1);
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
}
const MAX_DAYS = 60;
const PAD = { top: 12, right: 36, bottom: 22, left: 4 };
const GRID_LINES = 4;
function fmtDWT(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
export class TransitChart {
private canvas: HTMLCanvasElement | null = null;
private tooltip: HTMLDivElement | null = null;
private controls: HTMLDivElement | null = null;
private legend: HTMLDivElement | null = null;
private source: HTMLDivElement | null = null;
private themeHandler: (() => void) | null = null;
private resizeObserver: ResizeObserver | null = null;
private data: TransitPoint[] = [];
private allData: TransitDayCount[] = [];
private zoom: ZoomWindow = 90;
private tab: Tab = 'calls';
mount(container: HTMLElement, history: TransitPoint[]): void {
mount(container: HTMLElement, history: TransitDayCount[]): void {
this.destroy();
if (!history.length) return;
this.data = history.slice(-MAX_DAYS);
container.style.minHeight = '120px';
this.allData = [...history].sort((a, b) => a.date.localeCompare(b.date));
container.style.position = 'relative';
this.controls = document.createElement('div');
Object.assign(this.controls.style, {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: '6px', gap: '6px', flexWrap: 'wrap',
});
this.controls.addEventListener('click', (e) => e.stopPropagation());
container.appendChild(this.controls);
this.canvas = document.createElement('canvas');
this.canvas.style.width = '100%';
this.canvas.style.height = '140px';
this.canvas.style.height = '180px';
this.canvas.style.display = 'block';
container.appendChild(this.canvas);
this.tooltip = document.createElement('div');
Object.assign(this.tooltip.style, {
position: 'absolute', display: 'none', pointerEvents: 'none', zIndex: '10',
background: 'var(--bg-elevated, #222244)', border: '1px solid var(--border-subtle, #444)',
borderRadius: '4px', padding: '5px 8px', fontSize: '11px', color: 'var(--text-primary, #eee)',
whiteSpace: 'nowrap', lineHeight: '1.5',
background: 'var(--bg-elevated, #1a1a2e)', border: '1px solid var(--border-subtle, #444)',
borderRadius: '4px', padding: '6px 9px', fontSize: '11px', color: 'var(--text-primary, #eee)',
whiteSpace: 'nowrap', lineHeight: '1.6',
});
container.appendChild(this.tooltip);
this.legend = document.createElement('div');
Object.assign(this.legend.style, {
display: 'flex', gap: '14px', padding: '6px 0 0',
display: 'flex', flexWrap: 'wrap', gap: '8px 12px', padding: '5px 0 0',
});
container.appendChild(this.legend);
this.source = document.createElement('div');
Object.assign(this.source.style, { fontSize: '10px', color: 'var(--text-dim, #888)', paddingTop: '4px' });
this.source.textContent = 'Source: IMF PortWatch · 180d history';
container.appendChild(this.source);
this.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.resizeObserver = new ResizeObserver(() => this.draw());
this.resizeObserver.observe(this.canvas);
this.themeHandler = () => this.draw();
this.themeHandler = () => { this.buildControls(); this.buildLegend(); this.draw(); };
window.addEventListener('theme-changed', this.themeHandler);
this.buildControls();
this.buildLegend();
this.draw();
}
destroy(): void {
if (this.themeHandler) {
window.removeEventListener('theme-changed', this.themeHandler);
this.themeHandler = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.themeHandler) { window.removeEventListener('theme-changed', this.themeHandler); this.themeHandler = null; }
if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; }
if (this.canvas) {
this.canvas.removeEventListener('mousemove', this.onMouseMove);
this.canvas.removeEventListener('mouseleave', this.onMouseLeave);
this.canvas.remove();
this.canvas = null;
this.canvas.remove(); this.canvas = null;
}
if (this.tooltip) { this.tooltip.remove(); this.tooltip = null; }
if (this.controls) { this.controls.remove(); this.controls = null; }
if (this.legend) { this.legend.remove(); this.legend = null; }
this.data = [];
if (this.source) { this.source.remove(); this.source = null; }
this.allData = [];
}
private colors() {
return {
text: getCSSColor('--text-dim') || '#888',
grid: getCSSColor('--border') || '#2a2a2a',
tanker: getCSSColor('--semantic-info') || '#3b82f6',
cargo: getCSSColor('--semantic-high') || '#ff8800',
bg: 'transparent',
};
private visibleData(): TransitDayCount[] {
return this.allData.slice(-this.zoom);
}
private metrics() {
const data = this.data;
const allVals = data.flatMap(d => [d.tanker, d.cargo]);
const minV = Math.floor(Math.min(...allVals) / 10) * 10;
const maxV = Math.ceil(Math.max(...allVals) / 10) * 10;
return { minV, maxV, range: maxV - minV || 1 };
private buildControls(): void {
const ctrl = this.controls;
if (!ctrl) return;
const textDim = getCSSColor('--text-dim') || '#888';
const textPrimary = getCSSColor('--text-primary') || '#eee';
const borderSubtle = getCSSColor('--border-subtle') || '#444';
const btnStyle = (active: boolean) =>
`font-size:10px;padding:2px 7px;border-radius:3px;cursor:pointer;border:1px solid ${borderSubtle};` +
`background:${active ? 'var(--accent,#3b82f6)' : 'transparent'};` +
`color:${active ? '#fff' : textDim};transition:background 0.15s`;
const tabs = document.createElement('div');
tabs.style.cssText = 'display:flex;gap:4px';
tabs.innerHTML =
`<button data-tab="calls" style="${btnStyle(this.tab === 'calls')}">Transit Calls</button>` +
`<button data-tab="dwt" style="${btnStyle(this.tab === 'dwt')}">Trade Volume</button>`;
tabs.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-tab]') as HTMLElement | null;
if (!btn) return;
this.tab = btn.dataset['tab'] as Tab;
this.buildControls(); this.buildLegend(); this.draw();
});
const zooms = document.createElement('div');
zooms.style.cssText = 'display:flex;gap:4px';
([30, 90, 180] as ZoomWindow[]).forEach(z => {
const label = z === 30 ? '1m' : z === 90 ? '3m' : '6m';
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = btnStyle(this.zoom === z);
btn.style.color = this.zoom === z ? '#fff' : textPrimary;
btn.addEventListener('click', () => {
this.zoom = z; this.buildControls(); this.buildLegend(); this.draw();
});
zooms.appendChild(btn);
});
ctrl.innerHTML = '';
ctrl.appendChild(tabs);
ctrl.appendChild(zooms);
}
private buildLegend(): void {
const leg = this.legend;
if (!leg) return;
const textDim = getCSSColor('--text-dim') || '#888';
const data = this.visibleData();
const last = data[data.length - 1];
leg.innerHTML = VESSEL_LABELS.map((label, i) => {
const key = this.tab === 'calls' ? VESSEL_KEYS[i]! : CAP_KEYS[i]!;
const val = last ? (last[key as keyof TransitDayCount] as number) : 0;
const display = this.tab === 'dwt' ? fmtDWT(val) : String(val);
return `<span style="display:flex;align-items:center;gap:4px;font-size:10px;color:${textDim}">` +
`<span style="width:7px;height:7px;border-radius:1px;background:${VESSEL_COLORS[i]}"></span>` +
`${label} <b style="color:${VESSEL_COLORS[i]}">${display}</b></span>`;
}).join('');
}
private draw = (): void => {
const canvas = this.canvas;
if (!canvas || !this.data.length) return;
if (!canvas) return;
const data = this.visibleData();
if (!data.length) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const W = rect.width;
const H = parseInt(canvas.style.height, 10) || 140;
const H = parseInt(canvas.style.height, 10) || 180;
canvas.width = W * dpr;
canvas.height = H * dpr;
@@ -112,104 +191,122 @@ export class TransitChart {
if (!ctx) return;
ctx.scale(dpr, dpr);
const c = this.colors();
const { minV, maxV, range } = this.metrics();
const data = this.data;
const textColor = getCSSColor('--text-dim') || '#888';
const gridColor = getCSSColor('--border') || '#2a2a2a';
// Build stacked totals per day
const stacks: number[][] = data.map(d =>
(this.tab === 'calls' ? VESSEL_KEYS : CAP_KEYS).map(k => d[k as keyof TransitDayCount] as number),
);
const totals = stacks.map(s => s.reduce((a, b) => a + b, 0));
const maxVal = Math.max(...totals, 1);
const yScale = Math.ceil(maxVal / GRID_LINES) * GRID_LINES;
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const x = (i: number) => PAD.left + (i / (data.length - 1)) * plotW;
const y = (v: number) => PAD.top + plotH - ((v - minV) / range) * plotH;
const barW = Math.max(1, plotW / data.length - 1);
const xBar = (i: number) => PAD.left + (i / data.length) * plotW;
const yPos = (v: number) => PAD.top + plotH - (v / yScale) * plotH;
// Grid + Y labels
ctx.font = '9px -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
// Grid
ctx.font = `9px -apple-system, BlinkMacSystemFont, system-ui, sans-serif`;
ctx.textAlign = 'left';
for (let i = 0; i <= GRID_LINES; i++) {
const gy = PAD.top + (i / GRID_LINES) * plotH;
const val = Math.round(maxV - (i / GRID_LINES) * range);
ctx.strokeStyle = c.grid;
const val = yScale - (i / GRID_LINES) * yScale;
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(PAD.left, gy);
ctx.lineTo(W - PAD.right, gy);
ctx.stroke();
ctx.fillStyle = c.text;
ctx.fillText(String(val), W - PAD.right + 4, gy + 3);
ctx.fillStyle = textColor;
const label = this.tab === 'dwt' ? fmtDWT(val) : String(Math.round(val));
ctx.fillText(label, W - PAD.right + 3, gy + 3);
}
// X labels
ctx.textAlign = 'center';
const labelStep = Math.max(1, Math.floor(data.length / 5));
for (let i = 0; i < data.length; i += labelStep) {
const d = new Date(data[i]!.date);
ctx.fillStyle = c.text;
ctx.fillText(d.toLocaleDateString('en', { month: 'short', day: 'numeric' }), x(i), H - 4);
const d = new Date(data[i]!.date + 'T00:00:00Z');
const lx = xBar(i) + barW / 2;
ctx.fillStyle = textColor;
ctx.fillText(d.toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }), lx, H - 5);
}
const drawLine = (key: 'tanker' | 'cargo', color: string) => {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
data.forEach((d, i) => {
const px = x(i), py = y(d[key]);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
// Endpoint dot
const last = data[data.length - 1]!;
ctx.beginPath();
ctx.arc(x(data.length - 1), y(last[key]), 3.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = getCSSColor('--panel-bg') || '#141414';
ctx.lineWidth = 1.5;
ctx.stroke();
};
drawLine('cargo', c.cargo);
drawLine('tanker', c.tanker);
// Legend
if (this.legend) {
const last = data[data.length - 1]!;
this.legend.innerHTML = [
{ label: 'Tanker', color: c.tanker, value: last.tanker },
{ label: 'Cargo', color: c.cargo, value: last.cargo },
].map(s => `<span style="display:flex;align-items:center;gap:5px;font-size:11px;color:${c.text}">
<span style="width:8px;height:8px;border-radius:50%;background:${s.color}"></span>
${s.label} <b style="color:${s.color}">${s.value}</b>
</span>`).join('');
// Stacked bars
for (let i = 0; i < data.length; i++) {
let base = 0;
const bx = xBar(i);
for (let k = VESSEL_COLORS.length - 1; k >= 0; k--) {
const v = stacks[i]![k]!;
const barH = (v / yScale) * plotH;
const by = yPos(base + v);
ctx.fillStyle = VESSEL_COLORS[k]!;
ctx.fillRect(bx, by, barW, barH);
base += v;
}
}
// 7d MA overlay
const maValues = compute7dMA(totals);
ctx.beginPath();
ctx.strokeStyle = MA_COLOR;
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.setLineDash([3, 2]);
maValues.forEach((v, i) => {
const px = xBar(i) + barW / 2;
const py = yPos(v);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.setLineDash([]);
};
private onMouseMove = (e: MouseEvent): void => {
const canvas = this.canvas;
const tooltip = this.tooltip;
if (!canvas || !tooltip || !this.data.length) return;
if (!canvas || !tooltip) return;
const data = this.visibleData();
if (!data.length) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const W = rect.width;
const plotW = W - PAD.left - PAD.right;
const idx = Math.round(((mx - PAD.left) / plotW) * (this.data.length - 1));
const barW = Math.max(1, plotW / data.length - 1);
if (idx < 0 || idx >= this.data.length) {
tooltip.style.display = 'none';
return;
}
let idx = Math.floor(((mx - PAD.left) / plotW) * data.length);
idx = Math.max(0, Math.min(data.length - 1, idx));
const d = data[idx]!;
const stack = (this.tab === 'calls' ? VESSEL_KEYS : CAP_KEYS).map(k => d[k as keyof TransitDayCount] as number);
const total = stack.reduce((a, b) => a + b, 0);
const maValues = compute7dMA(data.map(r =>
(this.tab === 'calls' ? VESSEL_KEYS : CAP_KEYS).reduce((s, k) => s + (r[k as keyof TransitDayCount] as number), 0),
));
const ma = maValues[idx] ?? 0;
const fmt = (v: number) => this.tab === 'dwt' ? fmtDWT(v) : String(v);
const d = this.data[idx]!;
const c = this.colors();
tooltip.innerHTML =
`<div style="font-weight:600;margin-bottom:2px">${d.date}</div>` +
`<div><span style="color:${c.tanker}">●</span> Tanker: ${d.tanker}</div>` +
`<div><span style="color:${c.cargo}"></span> Cargo: ${d.cargo}</div>`;
`<div style="font-weight:600;margin-bottom:3px">${d.date}</div>` +
VESSEL_LABELS.map((label, i) =>
`<div><span style="color:${VESSEL_COLORS[i]}"></span> ${label}: ${fmt(stack[i]!)}</div>`,
).join('') +
`<div style="margin-top:3px;border-top:1px solid #444;padding-top:2px">Total: <b>${fmt(total)}</b></div>` +
`<div><span style="color:${MA_COLOR}">—</span> 7d MA: ${fmt(Math.round(ma))}</div>`;
tooltip.style.display = 'block';
tooltip.style.left = Math.min(mx + 12, W - 130) + 'px';
tooltip.style.top = '4px';
const bx = PAD.left + (idx / data.length) * plotW + barW / 2;
const tipW = 160;
tooltip.style.left = (bx + tipW + 20 > W ? bx - tipW - 8 : bx + 8) + 'px';
tooltip.style.top = '20px';
};
private onMouseLeave = (): void => {