/** * Self-contained SVG world map (equirectangular, Natural Earth 110m via world-atlas). No external tiles. * Server-component friendly. Overlays: ground track (past/future), markers (launch sites, current position). */ import { geoEquirectangular, geoGraticule10, geoPath } from 'd3-geo'; import type { FeatureCollection, Geometry } from 'geojson'; import { feature } from 'topojson-client'; import type { Topology } from 'topojson-specification'; import land110 from 'world-atlas/land-110m.json'; import { cn } from '@/lib/cn'; const topo = land110 as unknown as Topology; const land = feature(topo, topo.objects.land as never) as unknown as FeatureCollection; export interface MapPoint { lat: number; lon: number; } export interface MapMarker extends MapPoint { label?: string; color?: string; size?: number; href?: string; pulse?: boolean; } const W = 960; const H = 480; const projection = geoEquirectangular().scale(W / (2 * Math.PI)).translate([W / 2, H / 2]); const path = geoPath(projection); const landPath = path(land) ?? ''; const gratPath = path(geoGraticule10()) ?? ''; /** Split a lon/lat polyline where it crosses the antimeridian so no line is drawn across the map. */ export function splitTrack(points: MapPoint[]): MapPoint[][] { const segs: MapPoint[][] = []; let cur: MapPoint[] = []; for (let i = 0; i < points.length; i++) { const p = points[i]!; const prev = points[i - 1]; if (prev && Math.abs(p.lon - prev.lon) > 180) { segs.push(cur); cur = []; } cur.push(p); } if (cur.length) segs.push(cur); return segs.filter((s) => s.length > 1); } function toXY(p: MapPoint): [number, number] { return projection([p.lon, p.lat]) ?? [0, 0]; } export 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 }) { return ( {title} {tracks.map((t, i) => splitTrack(t.points).map((seg, j) => ( 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} /> )), )} {markers.map((m, i) => { const [x, y] = toXY(m); const r = m.size ?? 4; const c = m.color ?? 'var(--accent)'; const dot = ( {m.pulse && } {m.label && {m.label}} ); return m.href ? ( {dot} ) : ( dot ); })} {children} ); } export const MAP_SIZE = { W, H, project: toXY };