Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 32 additions & 26 deletions src/components/CountryTimeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import * as d3 from 'd3';
import {
select,
scaleTime,
scaleBand,
axisBottom,
timeFormat,
type Selection,
type ScaleTime,
type ScaleBand,
type NumberValue,
} from 'd3';
import { escapeHtml } from '@/utils/sanitize';
import { getCSSColor } from '@/utils';
import { t } from '@/services/i18n';
Expand Down Expand Up @@ -32,7 +42,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;

export class CountryTimeline {
private container: HTMLElement;
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private svg: Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private tooltip: HTMLDivElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private currentEvents: TimelineEvent[] = [];
Expand Down Expand Up @@ -87,8 +97,7 @@ export class CountryTimeline {
const innerW = width - MARGIN.left - MARGIN.right;
const innerH = HEIGHT - MARGIN.top - MARGIN.bottom;

this.svg = d3
.select(this.container)
this.svg = select(this.container)
.append('svg')
.attr('width', width)
.attr('height', HEIGHT)
Expand All @@ -99,13 +108,11 @@ export class CountryTimeline {
.attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);

const now = Date.now();
const xScale = d3
.scaleTime()
const xScale = scaleTime()
.domain([new Date(now - SEVEN_DAYS_MS), new Date(now)])
.range([0, innerW]);

const yScale = d3
.scaleBand<string>()
const yScale = scaleBand<string>()
.domain(LANES)
.range([0, innerH])
.padding(0.2);
Expand All @@ -118,8 +125,8 @@ export class CountryTimeline {
}

private drawGrid(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
g: Selection<SVGGElement, unknown, null, undefined>,
xScale: ScaleTime<number, number>,
innerH: number,
): void {
const ticks = xScale.ticks(6);
Expand All @@ -135,15 +142,14 @@ export class CountryTimeline {
}

private drawAxes(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
yScale: d3.ScaleBand<string>,
g: Selection<SVGGElement, unknown, null, undefined>,
xScale: ScaleTime<number, number>,
yScale: ScaleBand<string>,
innerH: number,
): void {
const xAxis = d3
.axisBottom(xScale)
const xAxis = axisBottom(xScale)
.ticks(6)
.tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue, i: number) => string);
.tickFormat(timeFormat('%b %d') as (d: Date | NumberValue, i: number) => string);

const xAxisG = g
.append('g')
Expand Down Expand Up @@ -175,8 +181,8 @@ export class CountryTimeline {
}

private drawNowMarker(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
g: Selection<SVGGElement, unknown, null, undefined>,
xScale: ScaleTime<number, number>,
now: Date,
innerH: number,
): void {
Expand All @@ -201,9 +207,9 @@ export class CountryTimeline {
}

private drawEmptyLaneLabels(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
g: Selection<SVGGElement, unknown, null, undefined>,
events: TimelineEvent[],
yScale: d3.ScaleBand<string>,
yScale: ScaleBand<string>,
innerW: number,
): void {
const populatedLanes = new Set(events.map((e) => e.lane));
Expand All @@ -223,14 +229,14 @@ export class CountryTimeline {
}

private drawEvents(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
g: Selection<SVGGElement, unknown, null, undefined>,
events: TimelineEvent[],
xScale: d3.ScaleTime<number, number>,
yScale: d3.ScaleBand<string>,
xScale: ScaleTime<number, number>,
yScale: ScaleBand<string>,
): void {
const tooltip = this.tooltip!;
const container = this.container;
const fmt = d3.timeFormat('%b %d, %H:%M');
const fmt = timeFormat('%b %d, %H:%M');

g.selectAll('.event-circle')
.data(events)
Expand All @@ -244,7 +250,7 @@ export class CountryTimeline {
.attr('stroke', getCSSColor('--shadow-color'))
.attr('stroke-width', 0.5)
.on('mouseenter', function (event: MouseEvent, d: TimelineEvent) {
d3.select(this).attr('opacity', 1).attr('stroke', getCSSColor('--text')).attr('stroke-width', 1.5);
select(this).attr('opacity', 1).attr('stroke', getCSSColor('--text')).attr('stroke-width', 1.5);
const dateStr = fmt(new Date(d.timestamp));
tooltip.innerHTML = `<strong>${escapeHtml(d.label)}</strong><br/>${escapeHtml(dateStr)}`;
tooltip.style.display = 'block';
Expand All @@ -262,7 +268,7 @@ export class CountryTimeline {
tooltip.style.top = `${y}px`;
})
.on('mouseleave', function () {
d3.select(this).attr('opacity', 0.85).attr('stroke', getCSSColor('--shadow-color')).attr('stroke-width', 0.5);
select(this).attr('opacity', 0.85).attr('stroke', getCSSColor('--shadow-color')).attr('stroke-width', 0.5);
tooltip.style.display = 'none';
});
}
Expand Down
73 changes: 40 additions & 33 deletions src/components/Map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import * as d3 from 'd3';
import {
select,
geoPath,
geoEquirectangular,
geoGraticule,
line as d3Line,
curveCardinal,
type Selection,
type GeoProjection,
type GeoPath,
} from 'd3';
import * as topojson from 'topojson-client';
import { escapeHtml } from '@/utils/sanitize';
import { getCSSColor } from '@/utils';
Expand Down Expand Up @@ -104,7 +114,7 @@ export class MapComponent {
};

private container: HTMLElement;
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
private svg: Selection<SVGSVGElement, unknown, null, undefined>;
private wrapper: HTMLElement;
private overlays: HTMLElement;
private clusterCanvas: HTMLCanvasElement;
Expand All @@ -113,8 +123,8 @@ export class MapComponent {
private worldData: WorldTopology | null = null;
private countryFeatures: Feature<Geometry>[] | null = null;
private isResizing = false;
private baseLayerGroup: d3.Selection<SVGGElement, unknown, null, undefined> | null = null;
private dynamicLayerGroup: d3.Selection<SVGGElement, unknown, null, undefined> | null = null;
private baseLayerGroup: Selection<SVGGElement, unknown, null, undefined> | null = null;
private dynamicLayerGroup: Selection<SVGGElement, unknown, null, undefined> | null = null;
private baseRendered = false;
private baseWidth = 0;
private baseHeight = 0;
Expand Down Expand Up @@ -193,7 +203,7 @@ export class MapComponent {
container.appendChild(this.createLegend());
this.healthCheckIntervalId = setInterval(() => this.runHealthCheck(), 30000);

this.svg = d3.select(svgElement);
this.svg = select(svgElement);
this.baseLayerGroup = this.svg.append('g').attr('class', 'map-base');
this.dynamicLayerGroup = this.svg.append('g').attr('class', 'map-dynamic');
this.popup = new MapPopup(container);
Expand Down Expand Up @@ -894,7 +904,7 @@ export class MapComponent {
this.clusterGl.clear(this.clusterGl.COLOR_BUFFER_BIT);
}

private renderClusterLayer(_projection: d3.GeoProjection): void {
private renderClusterLayer(_projection: GeoProjection): void {
// WebGL clustering disabled - all layers use HTML markers for visual fidelity
// (severity colors, emoji icons, magnitude sizing, animations)
this.wrapper.classList.toggle('cluster-active', false);
Expand Down Expand Up @@ -989,7 +999,7 @@ export class MapComponent {

// Setup projection for base elements
const baseProjection = this.getProjection(width, height);
const basePath = d3.geoPath().projection(baseProjection);
const basePath = geoPath().projection(baseProjection);

// Graticule
this.renderGraticule(this.baseLayerGroup, basePath);
Expand Down Expand Up @@ -1051,7 +1061,7 @@ export class MapComponent {
}

private renderGrid(
group: d3.Selection<SVGGElement, unknown, null, undefined>,
group: Selection<SVGGElement, unknown, null, undefined>,
width: number,
height: number,
yStart = 0
Expand Down Expand Up @@ -1081,7 +1091,7 @@ export class MapComponent {
}
}

private getProjection(width: number, height: number): d3.GeoProjection {
private getProjection(width: number, height: number): GeoProjection {
// Equirectangular with cropped latitude range (72°N to 56°S = 128°)
// Shows Greenland/Iceland while trimming extreme polar regions
const LAT_NORTH = 72; // Includes Greenland (extends to ~83°N but 72 shows most)
Expand All @@ -1094,18 +1104,17 @@ export class MapComponent {
const scaleForHeight = height / (LAT_RANGE * Math.PI / 180);
const scale = Math.min(scaleForWidth, scaleForHeight);

return d3
.geoEquirectangular()
return geoEquirectangular()
.scale(scale)
.center([0, LAT_CENTER])
.translate([width / 2, height / 2]);
}

private renderGraticule(
group: d3.Selection<SVGGElement, unknown, null, undefined>,
path: d3.GeoPath
group: Selection<SVGGElement, unknown, null, undefined>,
path: GeoPath
): void {
const graticule = d3.geoGraticule();
const graticule = geoGraticule();
group
.append('path')
.datum(graticule())
Expand All @@ -1117,8 +1126,8 @@ export class MapComponent {
}

private renderCountries(
group: d3.Selection<SVGGElement, unknown, null, undefined>,
path: d3.GeoPath
group: Selection<SVGGElement, unknown, null, undefined>,
path: GeoPath
): void {
if (!this.countryFeatures) return;

Expand All @@ -1134,16 +1143,15 @@ export class MapComponent {
.attr('stroke-width', 0.7);
}

private renderCables(projection: d3.GeoProjection): void {
private renderCables(projection: GeoProjection): void {
if (!this.dynamicLayerGroup) return;
const cableGroup = this.dynamicLayerGroup.append('g').attr('class', 'cables');

UNDERSEA_CABLES.forEach((cable) => {
const lineGenerator = d3
.line<[number, number]>()
const lineGenerator = d3Line<[number, number]>()
.x((d) => projection(d)?.[0] ?? 0)
.y((d) => projection(d)?.[1] ?? 0)
.curve(d3.curveCardinal);
.curve(curveCardinal);

const isHighlighted = this.highlightedAssets.cable.has(cable.id);
const cableAdvisory = this.getCableAdvisory(cable.id);
Expand Down Expand Up @@ -1172,16 +1180,15 @@ export class MapComponent {
});
}

private renderPipelines(projection: d3.GeoProjection): void {
private renderPipelines(projection: GeoProjection): void {
if (!this.dynamicLayerGroup) return;
const pipelineGroup = this.dynamicLayerGroup.append('g').attr('class', 'pipelines');

PIPELINES.forEach((pipeline) => {
const lineGenerator = d3
.line<[number, number]>()
const lineGenerator = d3Line<[number, number]>()
.x((d) => projection(d)?.[0] ?? 0)
.y((d) => projection(d)?.[1] ?? 0)
.curve(d3.curveCardinal.tension(0.5));
.curve(curveCardinal.tension(0.5));

const color = PIPELINE_COLORS[pipeline.type] || getCSSColor('--text-dim');
const opacity = 0.85;
Expand Down Expand Up @@ -1218,7 +1225,7 @@ export class MapComponent {
});
}

private renderConflicts(projection: d3.GeoProjection): void {
private renderConflicts(projection: GeoProjection): void {
if (!this.dynamicLayerGroup) return;
const conflictGroup = this.dynamicLayerGroup.append('g').attr('class', 'conflicts');

Expand Down Expand Up @@ -1250,7 +1257,7 @@ export class MapComponent {
const useSanctions = this.state.layers.sanctions;

this.baseLayerGroup.selectAll('.country').each(function (datum) {
const el = d3.select(this);
const el = select(this);
const id = datum as { id?: number };
if (!useSanctions) {
el.attr('fill', defaultFill);
Expand All @@ -1271,7 +1278,7 @@ export class MapComponent {
// groupKey function ensures only items with same key can cluster (e.g., same city)
private clusterMarkers<T extends { lat: number; lon: number }>(
items: T[],
projection: d3.GeoProjection,
projection: GeoProjection,
pixelRadius: number,
getGroupKey?: (item: T) => string
): Array<{ items: T[]; center: [number, number]; pos: [number, number] }> {
Expand Down Expand Up @@ -1334,7 +1341,7 @@ export class MapComponent {
return clusters;
}

private renderOverlays(projection: d3.GeoProjection): void {
private renderOverlays(projection: GeoProjection): void {
this.overlays.innerHTML = '';

// Strategic waterways
Expand Down Expand Up @@ -2708,7 +2715,7 @@ export class MapComponent {
}
}

private renderWaterways(projection: d3.GeoProjection): void {
private renderWaterways(projection: GeoProjection): void {
STRATEGIC_WATERWAYS.forEach((waterway) => {
const pos = projection([waterway.lon, waterway.lat]);
if (!pos) return;
Expand Down Expand Up @@ -2738,7 +2745,7 @@ export class MapComponent {
});
}

private renderAisDisruptions(projection: d3.GeoProjection): void {
private renderAisDisruptions(projection: GeoProjection): void {
this.aisDisruptions.forEach((event) => {
const pos = projection([event.lon, event.lat]);
if (!pos) return;
Expand Down Expand Up @@ -2773,7 +2780,7 @@ export class MapComponent {
});
}

private renderAisDensity(projection: d3.GeoProjection): void {
private renderAisDensity(projection: GeoProjection): void {
if (!this.dynamicLayerGroup) return;
const densityGroup = this.dynamicLayerGroup.append('g').attr('class', 'ais-density');

Expand All @@ -2799,7 +2806,7 @@ export class MapComponent {
});
}

private renderPorts(projection: d3.GeoProjection): void {
private renderPorts(projection: GeoProjection): void {
PORTS.forEach((port) => {
const pos = projection([port.lon, port.lat]);
if (!pos) return;
Expand Down Expand Up @@ -2834,7 +2841,7 @@ export class MapComponent {
});
}

private renderAPTMarkers(projection: d3.GeoProjection): void {
private renderAPTMarkers(projection: GeoProjection): void {
APT_GROUPS.forEach((apt) => {
const pos = projection([apt.lon, apt.lat]);
if (!pos) return;
Expand Down
Loading