"use client"; import { geoNaturalEarth1, geoPath } from "d3-geo"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import * as topojson from "topojson-client"; import type { Topology, GeometryCollection } from "topojson-specification"; import { cx } from "@/lib/format"; import { useNow } from "@/lib/stream"; import type { MarketsOverview } from "@/lib/types"; type Ex = MarketsOverview["exchanges"][number] & { breadth?: { median_change_percent: number | null } | null }; const STATE_FILL: Record = { OPEN: "var(--positive)", PRE: "var(--warning)", POST: "var(--warning)", AUCTION: "var(--warning)", HALTED: "var(--negative)", CLOSED: "var(--stale)", UNKNOWN: "var(--stale)" }; const W = 960; const H = 470; /** * World Market Map: countries-110m (world-atlas) drawn with d3-geo Natural Earth; one marker per * exchange, coloured by session state, sized by whether it trades now. Time-aware: as the clock * moves, states come from the API (refreshed by the parent) and local times tick here. */ export function WorldMap({ exchanges, className }: { exchanges: Ex[]; className?: string }) { const [land, setLand] = useState(null); const [hover, setHover] = useState(null); const now = useNow(1000); const projection = useMemo(() => geoNaturalEarth1().scale(W / 6.1).translate([W / 2, H / 2 + 8]), []); const path = useMemo(() => geoPath(projection), [projection]); useEffect(() => { let alive = true; import("world-atlas/countries-110m.json") .then((mod) => { if (!alive) return; const topo = (mod.default ?? mod) as unknown as Topology<{ countries: GeometryCollection }>; const fc = topojson.feature(topo, topo.objects.countries); setLand(path(fc) ?? ""); }) .catch(() => setLand("")); return () => { alive = false; }; }, [path]); const markers = useMemo( () => exchanges .filter((e) => e.lat != null && e.lon != null) .map((e) => { const p = projection([e.lon!, e.lat!]); return p ? { e, x: p[0], y: p[1] } : null; }) .filter((m): m is { e: Ex; x: number; y: number } => !!m), [exchanges, projection], ); const open = exchanges.filter((e) => e.status.state === "OPEN").length; const hovered = markers.find((m) => m.e.id === hover); const timeIn = (tz: string) => { try { return new Intl.DateTimeFormat("en-GB", { timeZone: tz, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(now || Date.now())); } catch { return ""; } }; return (
World market map · {open} of {exchanges.length} venues trading now
{land != null ? : } {markers.map(({ e, x, y }) => { const isOpen = e.status.state === "OPEN"; const fill = STATE_FILL[e.status.state] ?? "var(--stale)"; return ( setHover(e.id)} onMouseLeave={() => setHover(null)} className="cursor-pointer"> {isOpen && } ); })} {hovered && (
{hovered.e.name}
{hovered.e.city ?? hovered.e.country} · {timeIn(hovered.e.timezone)} local · {hovered.e.status.state.toLowerCase()} {hovered.e.status.isHoliday && hovered.e.status.holidayName ? ` · ${hovered.e.status.holidayName}` : ""}
{hovered.e.breadth?.median_change_percent != null &&
median change {hovered.e.breadth.median_change_percent.toFixed(2)}%
}
)}
); } function Legend({ color, label }: { color: string; label: string }) { return ( {label} ); }