spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { geoNaturalEarth1, geoPath } from "d3-geo";4import Link from "next/link";5import { useEffect, useMemo, useState } from "react";6import * as topojson from "topojson-client";7import type { Topology, GeometryCollection } from "topojson-specification";8import { cx } from "@/lib/format";9import { useNow } from "@/lib/stream";10import type { MarketsOverview } from "@/lib/types";1112type Ex = MarketsOverview["exchanges"][number] & { breadth?: { median_change_percent: number | null } | null };1314const STATE_FILL: Record<string, string> = { OPEN: "var(--positive)", PRE: "var(--warning)", POST: "var(--warning)", AUCTION: "var(--warning)", HALTED: "var(--negative)", CLOSED: "var(--stale)", UNKNOWN: "var(--stale)" };1516const W = 960;17const H = 470;1819/**20 * World Market Map: countries-110m (world-atlas) drawn with d3-geo Natural Earth; one marker per21 * exchange, coloured by session state, sized by whether it trades now. Time-aware: as the clock22 * moves, states come from the API (refreshed by the parent) and local times tick here.23 */24export function WorldMap({ exchanges, className }: { exchanges: Ex[]; className?: string }) {25 const [land, setLand] = useState<string | null>(null);26 const [hover, setHover] = useState<string | null>(null);27 const now = useNow(1000);28 const projection = useMemo(() => geoNaturalEarth1().scale(W / 6.1).translate([W / 2, H / 2 + 8]), []);29 const path = useMemo(() => geoPath(projection), [projection]);3031 useEffect(() => {32 let alive = true;33 import("world-atlas/countries-110m.json")34 .then((mod) => {35 if (!alive) return;36 const topo = (mod.default ?? mod) as unknown as Topology<{ countries: GeometryCollection }>;37 const fc = topojson.feature(topo, topo.objects.countries);38 setLand(path(fc) ?? "");39 })40 .catch(() => setLand(""));41 return () => {42 alive = false;43 };44 }, [path]);4546 const markers = useMemo(47 () =>48 exchanges49 .filter((e) => e.lat != null && e.lon != null)50 .map((e) => {51 const p = projection([e.lon!, e.lat!]);52 return p ? { e, x: p[0], y: p[1] } : null;53 })54 .filter((m): m is { e: Ex; x: number; y: number } => !!m),55 [exchanges, projection],56 );57 const open = exchanges.filter((e) => e.status.state === "OPEN").length;58 const hovered = markers.find((m) => m.e.id === hover);59 const timeIn = (tz: string) => {60 try {61 return new Intl.DateTimeFormat("en-GB", { timeZone: tz, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(now || Date.now()));62 } catch {63 return "";64 }65 };66 return (67 <div className={cx("relative overflow-hidden rounded-md border border-rule bg-surface", className)}>68 <div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule px-3 py-2 text-[11px] text-ink-3">69 <span className="uppercase tracking-wide">70 World market map · <span className="text-ink-2">{open}</span> of {exchanges.length} venues trading now71 </span>72 <span className="flex items-center gap-3">73 <Legend color="var(--positive)" label="open" />74 <Legend color="var(--warning)" label="pre/post" />75 <Legend color="var(--stale)" label="closed" />76 </span>77 </div>78 <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full" role="img" aria-label="World map of exchanges and their session state">79 {land != null ? <path d={land} fill="var(--map-land)" stroke="var(--map-stroke)" strokeWidth="0.6" /> : <rect width={W} height={H} fill="var(--surface-2)" />}80 {markers.map(({ e, x, y }) => {81 const isOpen = e.status.state === "OPEN";82 const fill = STATE_FILL[e.status.state] ?? "var(--stale)";83 return (84 <g key={e.id} transform={`translate(${x},${y})`} onMouseEnter={() => setHover(e.id)} onMouseLeave={() => setHover(null)} className="cursor-pointer">85 <Link href={`/exchanges/${e.id}`}>86 {isOpen && <circle r={9} fill={fill} opacity={0.18} className="live-dot" />}87 <circle r={isOpen ? 4.2 : 3} fill={fill} stroke="var(--surface)" strokeWidth="1" />88 <circle r={12} fill="transparent" />89 </Link>90 </g>91 );92 })}93 </svg>94 {hovered && (95 <div className="pointer-events-none absolute left-3 top-11 rounded-md border border-rule bg-surface/95 px-3 py-2 text-xs shadow-lg backdrop-blur">96 <div className="font-medium">{hovered.e.name}</div>97 <div className="text-ink-3">98 {hovered.e.city ?? hovered.e.country} · <span className="mono">{timeIn(hovered.e.timezone)}</span> local · <span className="uppercase">{hovered.e.status.state.toLowerCase()}</span>99 {hovered.e.status.isHoliday && hovered.e.status.holidayName ? ` · ${hovered.e.status.holidayName}` : ""}100 </div>101 {hovered.e.breadth?.median_change_percent != null && <div className="mono text-ink-2">median change {hovered.e.breadth.median_change_percent.toFixed(2)}%</div>}102 </div>103 )}104 </div>105 );106}107108function Legend({ color, label }: { color: string; label: string }) {109 return (110 <span className="inline-flex items-center gap-1">111 <span className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />112 {label}113 </span>114 );115}116