import { geoEqualEarth, geoPath, type GeoPermissibleObjects, type GeoProjection } from 'd3-geo'; import { feature } from 'topojson-client'; import type { Topology, GeometryCollection } from 'topojson-specification'; import type { Feature, FeatureCollection, Geometry } from 'geojson'; import { atlasGeometryIso3 } from '@/lib/iso-numeric'; /** * Geometry side of the trial map — pure helpers (no React, no I/O) so the projection can be unit- * tested: TopoJSON → GeoJSON features keyed by ISO3, an Equal Earth projection fitted to the SVG * viewport, and a `project()` for the city layer. Equal Earth (Šavrič, Patterson & Jenny 2018) is * equal-area, so a country's visual weight is not inflated at high latitudes, and its outline is * familiar; it is fitted to a 960×480 viewBox and scaled by the browser. */ export const MAP_WIDTH = 960; export const MAP_HEIGHT = 480; export interface CountryFeature { /** ISO 3166-1 alpha-3, or null when Natural Earth has no code for the polygon (N. Cyprus, Somaliland). */ iso3: string | null; /** Natural Earth short name (display fallback only). */ name: string; /** SVG path data in viewBox coordinates. */ d: string; } export interface WorldGeometry { countries: CountryFeature[]; /** Outline of the globe (the projection's sphere). */ sphere: string; project: (lng: number, lat: number) => [number, number] | null; } type CountriesTopology = Topology<{ countries: GeometryCollection<{ name: string }> }>; /** Fitted Equal Earth projection for the given viewport (default 960×480, 8 px padding). */ export function fitProjection(collection: FeatureCollection | GeoPermissibleObjects, width = MAP_WIDTH, height = MAP_HEIGHT, pad = 8): GeoProjection { return geoEqualEarth().fitExtent( [ [pad, pad], [width - pad, height - pad], ], collection as GeoPermissibleObjects, ); } /** TopoJSON (world-atlas countries-110m) → projected SVG paths keyed by ISO3. */ export function buildWorldGeometry(topology: CountriesTopology, width = MAP_WIDTH, height = MAP_HEIGHT): WorldGeometry { const fc = feature(topology, topology.objects.countries) as FeatureCollection; // Fit on the sphere (not on the land bbox) so Antarctica's absence/presence never shifts the frame. const projection = fitProjection({ type: 'Sphere' }, width, height); // One decimal in a 960-unit viewBox is sub-pixel at any realistic display width and shrinks the // path data (shipped twice: HTML + RSC payload) by about 40 % versus the default 3 digits. const path = geoPath(projection).digits(1); const countries: CountryFeature[] = []; for (const f of fc.features as Array>) { const name = f.properties?.name ?? ''; // Antarctica carries no trial sites and would dominate the lower band of the map. if (f.id === '010') continue; const d = path(f); if (!d) continue; countries.push({ iso3: atlasGeometryIso3(f.id as string | number | undefined, name), name, d }); } const sphere = path({ type: 'Sphere' }) ?? ''; const project = (lng: number, lat: number): [number, number] | null => { if (!Number.isFinite(lng) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; const p = projection([lng, lat]); return p ? [Math.round(p[0] * 10) / 10, Math.round(p[1] * 10) / 10] : null; }; return { countries, sphere, project }; }