SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
3.5 KB · 92 lines tsx
Raw Blame History
1/**2 * Self-contained SVG world map (equirectangular, Natural Earth 110m via world-atlas). No external tiles.3 * Server-component friendly. Overlays: ground track (past/future), markers (launch sites, current position).4 */5import { geoEquirectangular, geoGraticule10, geoPath } from 'd3-geo';6import type { FeatureCollection, Geometry } from 'geojson';7import { feature } from 'topojson-client';8import type { Topology } from 'topojson-specification';9import land110 from 'world-atlas/land-110m.json';10import { cn } from '@/lib/cn';1112const topo = land110 as unknown as Topology;13const land = feature(topo, topo.objects.land as never) as unknown as FeatureCollection<Geometry>;1415export interface MapPoint {16  lat: number;17  lon: number;18}19export interface MapMarker extends MapPoint {20  label?: string;21  color?: string;22  size?: number;23  href?: string;24  pulse?: boolean;25}2627const W = 960;28const H = 480;29const projection = geoEquirectangular().scale(W / (2 * Math.PI)).translate([W / 2, H / 2]);30const path = geoPath(projection);31const landPath = path(land) ?? '';32const gratPath = path(geoGraticule10()) ?? '';3334/** Split a lon/lat polyline where it crosses the antimeridian so no line is drawn across the map. */35export function splitTrack(points: MapPoint[]): MapPoint[][] {36  const segs: MapPoint[][] = [];37  let cur: MapPoint[] = [];38  for (let i = 0; i < points.length; i++) {39    const p = points[i]!;40    const prev = points[i - 1];41    if (prev && Math.abs(p.lon - prev.lon) > 180) {42      segs.push(cur);43      cur = [];44    }45    cur.push(p);46  }47  if (cur.length) segs.push(cur);48  return segs.filter((s) => s.length > 1);49}5051function toXY(p: MapPoint): [number, number] {52  return projection([p.lon, p.lat]) ?? [0, 0];53}5455export function WorldMap({ className, tracks = [], markers = [], title = 'World map', children }: { className?: string; tracks?: { points: MapPoint[]; color?: string; dashed?: boolean; width?: number }[]; markers?: MapMarker[]; title?: string; children?: React.ReactNode }) {56  return (57    <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full select-none', className)} role="img" aria-label={title}>58      <title>{title}</title>59      <rect width={W} height={H} fill="var(--plane)" rx={8} />60      <path d={gratPath} fill="none" stroke="var(--rule)" strokeWidth={0.6} />61      <path d={landPath} fill="var(--plane-3)" stroke="var(--rule-strong)" strokeWidth={0.6} />62      {tracks.map((t, i) =>63        splitTrack(t.points).map((seg, j) => (64          <polyline key={`${i}-${j}`} points={seg.map((p) => toXY(p).join(',')).join(' ')} fill="none" stroke={t.color ?? 'var(--accent)'} strokeWidth={t.width ?? 1.6} strokeDasharray={t.dashed ? '4 4' : undefined} strokeLinejoin="round" strokeLinecap="round" opacity={0.95} />65        )),66      )}67      {markers.map((m, i) => {68        const [x, y] = toXY(m);69        const r = m.size ?? 4;70        const c = m.color ?? 'var(--accent)';71        const dot = (72          <g key={i}>73            {m.pulse && <circle cx={x} cy={y} r={r * 2.6} fill={c} opacity={0.18} />}74            <circle cx={x} cy={y} r={r} fill={c} stroke="var(--space)" strokeWidth={1.2} />75            {m.label && <text x={x + r + 4} y={y} dy="0.35em" fontSize={11} fill="var(--ink)" fontFamily="var(--font-mono)" stroke="var(--space)" strokeWidth={3} paintOrder="stroke">{m.label}</text>}76          </g>77        );78        return m.href ? (79          <a key={i} href={m.href}>80            {dot}81          </a>82        ) : (83          dot84        );85      })}86      {children}87    </svg>88  );89}9091export const MAP_SIZE = { W, H, project: toXY };92