SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
3.4 KB · 72 lines typescript
Raw Blame History
1import { geoEqualEarth, geoPath, type GeoPermissibleObjects, type GeoProjection } from 'd3-geo';2import { feature } from 'topojson-client';3import type { Topology, GeometryCollection } from 'topojson-specification';4import type { Feature, FeatureCollection, Geometry } from 'geojson';5import { atlasGeometryIso3 } from '@/lib/iso-numeric';67/**8 * Geometry side of the trial map — pure helpers (no React, no I/O) so the projection can be unit-9 * tested: TopoJSON → GeoJSON features keyed by ISO3, an Equal Earth projection fitted to the SVG10 * viewport, and a `project()` for the city layer. Equal Earth (Šavrič, Patterson & Jenny 2018) is11 * equal-area, so a country's visual weight is not inflated at high latitudes, and its outline is12 * familiar; it is fitted to a 960×480 viewBox and scaled by the browser.13 */1415export const MAP_WIDTH = 960;16export const MAP_HEIGHT = 480;1718export interface CountryFeature {19  /** ISO 3166-1 alpha-3, or null when Natural Earth has no code for the polygon (N. Cyprus, Somaliland). */20  iso3: string | null;21  /** Natural Earth short name (display fallback only). */22  name: string;23  /** SVG path data in viewBox coordinates. */24  d: string;25}2627export interface WorldGeometry {28  countries: CountryFeature[];29  /** Outline of the globe (the projection's sphere). */30  sphere: string;31  project: (lng: number, lat: number) => [number, number] | null;32}3334type CountriesTopology = Topology<{ countries: GeometryCollection<{ name: string }> }>;3536/** Fitted Equal Earth projection for the given viewport (default 960×480, 8 px padding). */37export function fitProjection(collection: FeatureCollection | GeoPermissibleObjects, width = MAP_WIDTH, height = MAP_HEIGHT, pad = 8): GeoProjection {38  return geoEqualEarth().fitExtent(39    [40      [pad, pad],41      [width - pad, height - pad],42    ],43    collection as GeoPermissibleObjects,44  );45}4647/** TopoJSON (world-atlas countries-110m) → projected SVG paths keyed by ISO3. */48export function buildWorldGeometry(topology: CountriesTopology, width = MAP_WIDTH, height = MAP_HEIGHT): WorldGeometry {49  const fc = feature(topology, topology.objects.countries) as FeatureCollection<Geometry, { name: string }>;50  // Fit on the sphere (not on the land bbox) so Antarctica's absence/presence never shifts the frame.51  const projection = fitProjection({ type: 'Sphere' }, width, height);52  // One decimal in a 960-unit viewBox is sub-pixel at any realistic display width and shrinks the53  // path data (shipped twice: HTML + RSC payload) by about 40 % versus the default 3 digits.54  const path = geoPath(projection).digits(1);55  const countries: CountryFeature[] = [];56  for (const f of fc.features as Array<Feature<Geometry, { name: string }>>) {57    const name = f.properties?.name ?? '';58    // Antarctica carries no trial sites and would dominate the lower band of the map.59    if (f.id === '010') continue;60    const d = path(f);61    if (!d) continue;62    countries.push({ iso3: atlasGeometryIso3(f.id as string | number | undefined, name), name, d });63  }64  const sphere = path({ type: 'Sphere' }) ?? '';65  const project = (lng: number, lat: number): [number, number] | null => {66    if (!Number.isFinite(lng) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;67    const p = projection([lng, lat]);68    return p ? [Math.round(p[0] * 10) / 10, Math.round(p[1] * 10) / 10] : null;69  };70  return { countries, sphere, project };71}72