SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
5.3 KB · 116 lines tsx
Raw Blame History
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