spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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