File size: 12,709 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
#!/usr/bin/env node
// Build server/assets/atlas/{admin0,admin1}.geojson.gz from geoBoundaries (gbOpen).
//
// Why: Atlas previously fetched country + sub-national boundaries from Natural Earth's
// GitHub `master` at runtime. Natural Earth is stale (e.g. it still shows Norway's
// pre-2020 counties) and depicts some contested territory in ways the project does not
// want (see nvkelso/natural-earth-vector#391). geoBoundaries (CC BY 4.0) is current,
// redistributable, and carries ISO 3166-2 codes on its per-country ADM1 files.
//
// This downloads the *simplified* per-country gbOpen ADM0 (countries) and ADM1
// (regions) layers from a pinned geoBoundaries revision, normalizes each feature to
// the property names the Atlas client/server already read, and writes two gzipped
// FeatureCollections that the server serves at runtime (no network at boot).
//
// geoBoundaries: CC BY 4.0 β€” https://www.geoboundaries.org/  (attribution required).

import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas')

// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f'
const MEDIA = (a3, level) =>
  `https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
  `/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`

// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
// that gbOpen omits or folds away β€” notably Svalbard (inside Norway's geometry) and
// Greenland. The country layer only needs A3/A2/name, so CGAZ's lack of `shapeISO` is
// irrelevant. (gbOpen ADM0 maxes Norway at 71Β°N and has no Svalbard at all.)
const CGAZ_ADM0 =
  `https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
  `/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`

const CONCURRENCY = 8
const RETRIES = 3

// Complete ISO-3166-1 alpha-3 β†’ alpha-2 map (source: lukes/ISO-3166-Countries-with-
// Regional-Codes). Drives ADM1 enumeration (one gbOpen request per code; missing ones
// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
// `shapeGroup`). A complete map β€” not the client's curated ~180 β€” is what restores the
// dropped territories (Greenland, Falklands, French Guiana, …).
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}

const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)

// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF)

async function fetchGeo(url) {
  const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0])
  if (fs.existsSync(cacheFile)) {
    const cached = fs.readFileSync(cacheFile, 'utf8')
    return cached === '' ? null : JSON.parse(cached)
  }
  for (let attempt = 1; attempt <= RETRIES; attempt++) {
    try {
      const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } })
      if (res.status === 404) { fs.writeFileSync(cacheFile, ''); return null } // no file β€” skip
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const text = await res.text()
      if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content')
      const parsed = JSON.parse(text)
      fs.writeFileSync(cacheFile, text)
      return parsed
    } catch (err) {
      if (attempt === RETRIES) {
        console.warn(`  ! ${url.split('/').slice(-1)[0]}: ${err.message}`)
        return null
      }
      await new Promise(r => setTimeout(r, 500 * attempt))
    }
  }
  return null
}

// Run async tasks with a fixed concurrency cap.
async function pool(items, worker) {
  const results = []
  let i = 0
  const runners = Array.from({ length: CONCURRENCY }, async () => {
    while (i < items.length) {
      const idx = i++
      results[idx] = await worker(items[idx], idx)
    }
  })
  await Promise.all(runners)
  return results
}

// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
// coordinates, which dominate the JSON size. Quantizing to a fixed grid (rounding
// preserves topology β€” identical input coords map to identical output) and dropping
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
// they tolerate a coarser grid than ADM1 region borders.
const ADM0_DECIMALS = 2 // ~1.1 km
const ADM1_DECIMALS = 3 // ~110 m

function quantizeRing(ring, decimals) {
  const m = 10 ** decimals
  const out = []
  let prevX, prevY
  for (const pt of ring) {
    const x = Math.round(pt[0] * m) / m
    const y = Math.round(pt[1] * m) / m
    if (x === prevX && y === prevY) continue
    out.push([x, y])
    prevX = x; prevY = y
  }
  return out
}

// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
function quantizeGeometry(geom, decimals) {
  if (!geom) return null
  if (geom.type === 'Polygon') {
    const rings = geom.coordinates.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4)
    return rings.length ? { type: 'Polygon', coordinates: rings } : null
  }
  if (geom.type === 'MultiPolygon') {
    const polys = geom.coordinates
      .map(poly => poly.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4))
      .filter(poly => poly.length)
    return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null
  }
  return geom
}

// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
// pseudo-entry or anything without a group/geometry.
function normalizeAdm0Feature(f) {
  const a3 = f.properties?.shapeGroup
  if (!a3) return null
  const name = f.properties?.shapeName || a3
  const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS)
  if (!geometry) return null
  return {
    type: 'Feature',
    properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
    geometry,
  }
}

function normalizeAdm1(geo, a3, countryName) {
  if (!geo?.features) return []
  return geo.features.map(f => {
    const name = f.properties?.shapeName || ''
    const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
    if (!geometry) return null
    const a2 = A3_TO_A2[a3] || null
    // shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
    // rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
    // (stable per polygon β†’ manual mark/unmark works, real ones match Nominatim). For
    // blank codes, synthesize a stable id mirroring the server's geocode fallback so
    // every region is still markable.
    let code = f.properties?.shapeISO || ''
    if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
    return {
      type: 'Feature',
      // Property names the Atlas region layer + server getRegionGeo already read.
      properties: {
        iso_a2: a2,
        iso_3166_2: code,
        name,
        name_en: name,
        admin: countryName,
      },
      geometry,
    }
  }).filter(Boolean)
}

async function main() {
  console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`)
  fs.mkdirSync(OUT_DIR, { recursive: true })
  fs.mkdirSync(CACHE_DIR, { recursive: true })

  // ADM0 (countries) β€” one comprehensive CGAZ file (large; cached). Also yields the
  // English country name (shapeGroup β†’ shapeName) used for the ADM1 `admin` field.
  console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…')
  const cgaz = await fetchGeo(CGAZ_ADM0)
  const adm0Features = []
  const a3ToName = {}
  for (const f of cgaz?.features || []) {
    const nf = normalizeAdm0Feature(f)
    if (nf) { a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME; adm0Features.push(nf) }
  }

  // ADM1 (sub-national regions) β€” per-country gbOpen (carries ISO 3166-2 `shapeISO`).
  console.log('[atlas-geo] downloading ADM1 (regions)…')
  const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
  const adm1Features = []
  let withCodes = 0
  COUNTRIES.forEach((a3, idx) => {
    const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3)
    for (const f of feats) if (f.properties.iso_3166_2) withCodes++
    adm1Features.push(...feats)
  })

  const write = (name, features) => {
    const fc = { type: 'FeatureCollection', features }
    const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 })
    const file = path.join(OUT_DIR, `${name}.geojson.gz`)
    fs.writeFileSync(file, gz)
    console.log(`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)} β€” ${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`)
  }

  write('admin0', adm0Features)
  write('admin1', adm1Features)

  const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length)
  console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`)
  console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`)
  console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`)
}

main().catch(err => { console.error(err); process.exit(1) })