fix(panels): Hormuz — remove summary text, per-chart colors, interactive tooltip (#2231)

* fix(panels): Hormuz — remove summary text, per-chart colors, interactive tooltip

- Remove summary text blob (user-facing text from WTO page)
- Per-chart colors: crude=#e67e22, LNG=#1abc9c, fertilizer=#9b59b6, agriculture=#27ae60
- Zero-value bars in red to signal disruption
- Interactive bar hover: fixed-position tooltip shows date + value + unit
- Event delegation on stable this.element so setContent debounce does not break listeners

* fix(panels): label-based unit detection, deduplicate bar data attrs, clamp tooltip

- Revert unit from index-based (idx===0) to label-based (chart.label.includes('crude_oil'))
  so unit stays correct if seed CHART_CONFIGS order ever changes
- Remove redundant data attrs from visible rects; only hit rects need them
- Clamp tooltip top to Math.max(8, ...) to prevent viewport overflow on top charts
This commit is contained in:
Elie Habib
2026-03-25 11:10:03 +04:00
committed by GitHub
parent 8465810167
commit 08cdf25865

View File

@@ -3,60 +3,63 @@ import { escapeHtml } from '@/utils/sanitize';
import { fetchHormuzTracker } from '@/services/hormuz-tracker';
import type { HormuzTrackerData, HormuzChart, HormuzSeries } from '@/services/hormuz-tracker';
const CHART_COLORS = ['#e67e22', '#1abc9c', '#9b59b6', '#27ae60'];
const ZERO_COLOR = 'rgba(231,76,60,0.5)';
function statusColor(status: string): string {
switch (status) {
case 'closed': return '#e74c3c';
case 'disrupted': return '#e67e22';
case 'closed': return '#e74c3c';
case 'disrupted': return '#e67e22';
case 'restricted': return '#f39c12';
default: return '#2ecc71';
default: return '#2ecc71';
}
}
function statusLabel(status: string): string {
return status.toUpperCase();
}
function barChart(series: HormuzSeries[], width = 280, height = 48): string {
if (!series.length) return `<div style="height:${height}px;display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:10px">No data</div>`;
function barChart(series: HormuzSeries[], color: string, unit: string, width = 280, height = 52): string {
if (!series.length) return `<div style="height:${height}px;display:flex;align-items:center;color:var(--text-dim);font-size:10px">No data</div>`;
const max = Math.max(...series.map(p => p.value), 1);
const barW = Math.max(1, Math.floor((width - series.length) / series.length));
const bars = series.map(p => {
const h = Math.round((p.value / max) * (height - 2));
const color = p.value === 0 ? '#e74c3c' : '#3498db';
return `<rect x="0" y="${height - h}" width="${barW}" height="${h}" fill="${color}" rx="1"/>`;
});
const barW = Math.max(2, Math.floor((width - series.length) / series.length));
let x = 0;
const positioned = bars.map(b => {
const rect = `<g transform="translate(${x},0)">${b}</g>`;
const rects = series.map(p => {
const h = Math.max(p.value > 0 ? 2 : 1, Math.round((p.value / max) * (height - 2)));
const fill = p.value === 0 ? ZERO_COLOR : color;
const rect = `<rect x="${x}" y="${height - h}" width="${barW}" height="${h}" fill="${fill}" rx="1"/>`;
x += barW + 1;
return rect;
});
return `<svg width="${width}" height="${height}" style="display:block;overflow:visible">
${positioned.join('')}
</svg>`;
x = 0;
const hits = series.map(p => {
const hit = `<rect class="hbar" x="${x}" y="0" width="${barW}" height="${height}" fill="transparent" data-date="${escapeHtml(p.date)}" data-val="${p.value}" data-unit="${escapeHtml(unit)}" style="cursor:crosshair"/>`;
x += barW + 1;
return hit;
});
return `<svg class="hz-svg" width="${width}" height="${height}" style="display:block;overflow:visible">${rects.join('')}${hits.join('')}</svg>`;
}
function renderChart(chart: HormuzChart): string {
function renderChart(chart: HormuzChart, idx: number): string {
const color = CHART_COLORS[idx % CHART_COLORS.length] ?? '#3498db';
const last = chart.series[chart.series.length - 1];
const lastVal = last ? last.value.toFixed(0) : 'N/A';
const lastDate = last ? last.date.slice(5) : '';
const unit = chart.label.includes('crude_oil') ? 'kt/day' : 'units';
return `
<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:3px">
<div class="hz-chart" style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em">${escapeHtml(chart.title)}</span>
<span style="font-size:11px;font-weight:600;color:var(--text)">${escapeHtml(lastVal)} <span style="font-size:9px;color:var(--text-dim)">${unit} · ${escapeHtml(lastDate)}</span></span>
<span style="font-size:11px;font-weight:600;color:${color}">${escapeHtml(lastVal)} <span style="font-size:9px;color:var(--text-dim)">${unit} · ${escapeHtml(lastDate)}</span></span>
</div>
${barChart(chart.series)}
<div style="position:relative">${barChart(chart.series, color, unit)}</div>
</div>`;
}
export class HormuzPanel extends Panel {
private data: HormuzTrackerData | null = null;
private tooltipBound = false;
constructor() {
super({ id: 'hormuz-tracker', title: 'Hormuz Trade Tracker', showCount: false });
@@ -72,6 +75,7 @@ export class HormuzPanel extends Panel {
}
this.data = data;
this.renderPanel();
this.bindTooltip();
return true;
} catch (e) {
this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData());
@@ -79,29 +83,52 @@ export class HormuzPanel extends Panel {
}
}
private bindTooltip(): void {
if (this.tooltipBound || !this.element) return;
this.tooltipBound = true;
this.element.addEventListener('mousemove', (e: Event) => {
const target = e.target as Element;
if (!target.classList?.contains('hbar')) return;
const date = (target.getAttribute('data-date') ?? '').slice(5);
const val = target.getAttribute('data-val') ?? '';
const unit = target.getAttribute('data-unit') ?? '';
const tip = this.element?.querySelector<HTMLElement>('.hz-tip');
if (!tip) return;
const barRect = (target as SVGRectElement).getBoundingClientRect();
tip.style.left = `${barRect.left + barRect.width / 2}px`;
tip.style.top = `${Math.max(8, barRect.top - 28)}px`;
tip.style.transform = 'translateX(-50%)';
tip.style.opacity = '1';
tip.textContent = `${date} ${val} ${unit}`;
});
this.element.addEventListener('mouseleave', () => {
const tip = this.element?.querySelector<HTMLElement>('.hz-tip');
if (tip) tip.style.opacity = '0';
});
}
private renderPanel(): void {
if (!this.data) return;
const d = this.data;
const color = statusColor(d.status);
const sColor = statusColor(d.status);
const charts = d.charts.length
? d.charts.map(c => renderChart(c)).join('')
? d.charts.map((c, i) => renderChart(c, i)).join('')
: '<div style="color:var(--text-dim);font-size:11px;padding:8px 0">Chart data unavailable</div>';
const dateStr = d.updatedDate ? `<span style="font-size:10px;color:var(--text-dim)">${escapeHtml(d.updatedDate)}</span>` : '';
const summary = d.summary ? `<div style="font-size:11px;color:var(--text-dim);margin:6px 0;line-height:1.4">${escapeHtml(d.summary)}</div>` : '';
const html = `
<div style="padding:12px 14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="background:${color};color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:3px;letter-spacing:0.08em">${statusLabel(d.status)}</span>
<div style="padding:12px 14px;position:relative">
<div class="hz-tip" style="position:fixed;pointer-events:none;background:rgba(15,17,26,0.95);border:1px solid rgba(255,255,255,0.15);border-radius:4px;padding:3px 8px;font-size:10px;color:#fff;white-space:nowrap;z-index:9999;opacity:0;transition:opacity 0.08s;letter-spacing:0.02em"></div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<span style="background:${sColor};color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:3px;letter-spacing:0.08em">${d.status.toUpperCase()}</span>
${dateStr}
</div>
${summary}
<div style="border-top:1px solid rgba(255,255,255,0.07);padding-top:8px;margin-top:4px">
${charts}
</div>
<div style="margin-top:6px;font-size:9px;color:var(--text-dim)">
<div>${charts}</div>
<div style="margin-top:4px;font-size:9px;color:var(--text-dim)">
Source: <a href="${escapeHtml(d.attribution.url)}" target="_blank" rel="noopener" style="color:var(--text-dim);text-decoration:underline">${escapeHtml(d.attribution.source)}</a>
</div>
</div>`;