mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-06-01 00:56:50 +02:00
* feat(maps): add regional map downloads via go-pmtiles extract * address Copilot review feedback on PR #780 - auto-refresh preflight on selection/maxzoom change with 400ms debounce and requestId stale-safety so the confirm button no longer requires a two-step "Estimate Size" -> "Start Download" dance - safeUpdateProgress helper replaces fire-and-forget updateProgress().catch() pattern so cancelled-job errors (code -1) can't surface as unhandled rejections - gate world basemap source on worldBasemapReady - when ensureWorldBasemap() fails we already delete world.pmtiles, so emitting the source was producing 404s on every tile request - verify go-pmtiles binary SHA256 at image build time; upstream doesn't ship a checksums file so per-arch hashes are pinned as build args with a regenerate note when bumping PMTILES_VERSION
309 lines
9.8 KiB
TypeScript
309 lines
9.8 KiB
TypeScript
import { access, readFile, writeFile, mkdir } from 'fs/promises'
|
|
import { join, resolve } from 'path'
|
|
import { createHash } from 'crypto'
|
|
import { tmpdir } from 'os'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import type { Country, CountryCode, CountryGroup } from '../../types/maps.js'
|
|
|
|
interface NEFeature {
|
|
type: 'Feature'
|
|
properties: Record<string, any>
|
|
geometry: unknown
|
|
}
|
|
|
|
interface NEFeatureCollection {
|
|
type: 'FeatureCollection'
|
|
features: NEFeature[]
|
|
}
|
|
|
|
const COUNTRY_GEOJSON_PATH = join(
|
|
process.cwd(),
|
|
'resources',
|
|
'geodata',
|
|
'ne_50m_admin_0_countries.geojson'
|
|
)
|
|
|
|
// Natural Earth country polygons are land-only (no territorial waters), so a
|
|
// strict intersect leaves tiles fully over the ocean out of the extract —
|
|
// coastal cities render as grey off their coast. Inflate each polygon outward
|
|
// by ~11 km to pull in adjacent tiles without ballooning the extract size.
|
|
const REGION_BUFFER_DEGREES = 0.1
|
|
|
|
const GROUP_ORDER = [
|
|
'north-america',
|
|
'south-america',
|
|
'europe',
|
|
'africa',
|
|
'asia',
|
|
'oceania',
|
|
]
|
|
|
|
const GROUP_META: Record<string, { id: string; name: string; description: string }> = {
|
|
'North America': {
|
|
id: 'north-america',
|
|
name: 'North America',
|
|
description: 'All countries in North America and the Caribbean.',
|
|
},
|
|
'South America': {
|
|
id: 'south-america',
|
|
name: 'South America',
|
|
description: 'All countries in South America.',
|
|
},
|
|
Europe: {
|
|
id: 'europe',
|
|
name: 'Europe',
|
|
description: 'All countries in Europe.',
|
|
},
|
|
Africa: {
|
|
id: 'africa',
|
|
name: 'Africa',
|
|
description: 'All countries in Africa.',
|
|
},
|
|
Asia: {
|
|
id: 'asia',
|
|
name: 'Asia',
|
|
description: 'All countries in Asia.',
|
|
},
|
|
Oceania: {
|
|
id: 'oceania',
|
|
name: 'Oceania',
|
|
description: 'Australia, New Zealand, and Pacific island nations.',
|
|
},
|
|
}
|
|
|
|
export class CountriesService {
|
|
private static instance: CountriesService | null = null
|
|
private loadPromise: Promise<void> | null = null
|
|
private countries: Country[] = []
|
|
private byCode: Map<CountryCode, { country: Country; feature: NEFeature }> = new Map()
|
|
private groups: CountryGroup[] = []
|
|
|
|
static getInstance(): CountriesService {
|
|
if (!this.instance) {
|
|
this.instance = new CountriesService()
|
|
}
|
|
return this.instance
|
|
}
|
|
|
|
private async ensureLoaded(): Promise<void> {
|
|
if (this.byCode.size > 0) return
|
|
if (!this.loadPromise) {
|
|
this.loadPromise = this.load()
|
|
}
|
|
await this.loadPromise
|
|
}
|
|
|
|
private async load(): Promise<void> {
|
|
const raw = await readFile(COUNTRY_GEOJSON_PATH, 'utf8')
|
|
const fc = JSON.parse(raw) as NEFeatureCollection
|
|
|
|
// Natural Earth reuses a sovereign state's ISO_A2 for its dependencies
|
|
// (e.g. AU covers both Australia and Australian territories). Sort so the
|
|
// sovereign mainland wins the ISO-code slot, and skip any subsequent
|
|
// same-code dependency — otherwise the "AU" entry ends up being some tiny
|
|
// island territory.
|
|
const sortedFeatures = [...fc.features].sort((a, b) => typeRank(a) - typeRank(b))
|
|
|
|
const countries: Country[] = []
|
|
const byCode = new Map<CountryCode, { country: Country; feature: NEFeature }>()
|
|
const groupCodes: Record<string, CountryCode[]> = {}
|
|
|
|
for (const feature of sortedFeatures) {
|
|
const p = feature.properties
|
|
const code = resolveIso2(p)
|
|
if (!code) continue
|
|
if (byCode.has(code)) continue
|
|
|
|
const continent = typeof p.CONTINENT === 'string' ? p.CONTINENT : 'Other'
|
|
if (continent === 'Antarctica' || continent === 'Seven seas (open ocean)') continue
|
|
|
|
const country: Country = {
|
|
code,
|
|
code3: resolveIso3(p) ?? code,
|
|
name: typeof p.NAME === 'string' ? p.NAME : code,
|
|
continent,
|
|
subregion: typeof p.SUBREGION === 'string' ? p.SUBREGION : continent,
|
|
population: typeof p.POP_EST === 'number' ? p.POP_EST : 0,
|
|
}
|
|
|
|
countries.push(country)
|
|
byCode.set(code, { country, feature })
|
|
|
|
if (GROUP_META[continent]) {
|
|
const groupId = GROUP_META[continent].id
|
|
if (!groupCodes[groupId]) groupCodes[groupId] = []
|
|
groupCodes[groupId].push(code)
|
|
}
|
|
}
|
|
|
|
countries.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
const groups: CountryGroup[] = GROUP_ORDER.flatMap((groupId) => {
|
|
const meta = Object.values(GROUP_META).find((m) => m.id === groupId)
|
|
if (!meta) return []
|
|
const codes = (groupCodes[groupId] ?? []).slice().sort()
|
|
if (codes.length === 0) return []
|
|
return [{ id: meta.id, name: meta.name, description: meta.description, countries: codes }]
|
|
})
|
|
|
|
this.countries = countries
|
|
this.byCode = byCode
|
|
this.groups = groups
|
|
|
|
logger.info(
|
|
`[CountriesService] Loaded ${countries.length} countries across ${groups.length} groups`
|
|
)
|
|
}
|
|
|
|
async list(): Promise<Country[]> {
|
|
await this.ensureLoaded()
|
|
return this.countries
|
|
}
|
|
|
|
async listGroups(): Promise<CountryGroup[]> {
|
|
await this.ensureLoaded()
|
|
return this.groups
|
|
}
|
|
|
|
/** Throws when a supplied code does not map to a known country. */
|
|
async resolveCodes(codes: CountryCode[]): Promise<CountryCode[]> {
|
|
await this.ensureLoaded()
|
|
const normalized = [...new Set(codes.map((c) => c.toUpperCase()))].sort()
|
|
const unknown = normalized.filter((c) => !this.byCode.has(c))
|
|
if (unknown.length > 0) {
|
|
throw new Error(`Unknown country code(s): ${unknown.join(', ')}`)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
/**
|
|
* Filename is keyed on a hash of the sorted ISO codes + buffer size so
|
|
* repeated calls with the same selection reuse the same path, and bumping
|
|
* the buffer auto-invalidates stale files.
|
|
*/
|
|
async writeRegionFile(codes: CountryCode[]): Promise<string> {
|
|
await this.ensureLoaded()
|
|
const resolved = await this.resolveCodes(codes)
|
|
const key = `b${REGION_BUFFER_DEGREES}:${resolved.join(',')}`
|
|
const hash = createHash('sha1').update(key).digest('hex').slice(0, 12)
|
|
|
|
const dir = resolve(tmpdir(), 'nomad-pmtiles-regions')
|
|
await mkdir(dir, { recursive: true })
|
|
const filepath = join(dir, `region-${hash}.geojson`)
|
|
|
|
try {
|
|
await access(filepath)
|
|
return filepath
|
|
} catch {}
|
|
|
|
const fc = {
|
|
type: 'FeatureCollection',
|
|
features: resolved.map((code) => {
|
|
const entry = this.byCode.get(code)!
|
|
return {
|
|
type: 'Feature',
|
|
properties: { iso: code, name: entry.country.name },
|
|
geometry: bufferGeometry(entry.feature.geometry, REGION_BUFFER_DEGREES),
|
|
}
|
|
}),
|
|
}
|
|
|
|
await writeFile(filepath, JSON.stringify(fc))
|
|
return filepath
|
|
}
|
|
}
|
|
|
|
function typeRank(f: NEFeature): number {
|
|
const t = typeof f.properties.TYPE === 'string' ? f.properties.TYPE : ''
|
|
if (t === 'Sovereign country') return 0
|
|
if (t === 'Country') return 1
|
|
if (t === 'Sovereignty') return 2
|
|
if (t === 'Disputed') return 3
|
|
if (t === 'Dependency') return 4
|
|
return 5
|
|
}
|
|
|
|
function resolveIso2(p: Record<string, any>): CountryCode | null {
|
|
// Natural Earth's ISO_A2 sometimes holds political escapes like "CN-TW" for
|
|
// Taiwan or "-99" for countries involved in disputes. Only accept clean
|
|
// 2-letter codes; fall back to ISO_A2_EH (which reliably has the real code).
|
|
const primary = typeof p.ISO_A2 === 'string' ? p.ISO_A2 : null
|
|
if (primary && /^[A-Z]{2}$/i.test(primary)) return primary.toUpperCase()
|
|
const fallback = typeof p.ISO_A2_EH === 'string' ? p.ISO_A2_EH : null
|
|
if (fallback && /^[A-Z]{2}$/i.test(fallback)) return fallback.toUpperCase()
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Inflate each polygon ring outward by `buffer` degrees via per-vertex
|
|
* averaged-normal offset. Not geodesically accurate — but at small buffers
|
|
* (<= 0.2°) it's within a few percent of a proper geodesic buffer at
|
|
* country scale, which is plenty for tile-inclusion purposes.
|
|
*/
|
|
function bufferGeometry(geometry: unknown, buffer: number): unknown {
|
|
const geom = geometry as { type: string; coordinates: any }
|
|
if (geom?.type === 'Polygon') {
|
|
return { type: 'Polygon', coordinates: bufferPolygonRings(geom.coordinates, buffer) }
|
|
}
|
|
if (geom?.type === 'MultiPolygon') {
|
|
return {
|
|
type: 'MultiPolygon',
|
|
coordinates: geom.coordinates.map((poly: number[][][]) =>
|
|
bufferPolygonRings(poly, buffer)
|
|
),
|
|
}
|
|
}
|
|
return geometry
|
|
}
|
|
|
|
function bufferPolygonRings(rings: number[][][], buffer: number): number[][][] {
|
|
return rings.map((ring) => bufferRing(ring, buffer))
|
|
}
|
|
|
|
function bufferRing(ring: number[][], buffer: number): number[][] {
|
|
if (ring.length < 4) return ring
|
|
const sign = signedArea(ring) > 0 ? 1 : -1
|
|
const n = ring.length - 1
|
|
const out: number[][] = []
|
|
for (let i = 0; i < n; i++) {
|
|
const prev = ring[(i - 1 + n) % n]
|
|
const curr = ring[i]
|
|
const next = ring[(i + 1) % n]
|
|
const e1x = curr[0] - prev[0]
|
|
const e1y = curr[1] - prev[1]
|
|
const e2x = next[0] - curr[0]
|
|
const e2y = next[1] - curr[1]
|
|
const l1 = Math.hypot(e1x, e1y) || 1
|
|
const l2 = Math.hypot(e2x, e2y) || 1
|
|
const n1x = (e1y / l1) * sign
|
|
const n1y = (-e1x / l1) * sign
|
|
const n2x = (e2y / l2) * sign
|
|
const n2y = (-e2x / l2) * sign
|
|
const sumX = n1x + n2x
|
|
const sumY = n1y + n2y
|
|
const sl = Math.hypot(sumX, sumY) || 1
|
|
out.push([curr[0] + (sumX / sl) * buffer, curr[1] + (sumY / sl) * buffer])
|
|
}
|
|
out.push(out[0])
|
|
return out
|
|
}
|
|
|
|
function signedArea(ring: number[][]): number {
|
|
let a = 0
|
|
for (let i = 0; i < ring.length - 1; i++) {
|
|
a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]
|
|
}
|
|
return a / 2
|
|
}
|
|
|
|
function resolveIso3(p: Record<string, any>): string | null {
|
|
const primary = typeof p.ISO_A3 === 'string' ? p.ISO_A3 : null
|
|
if (primary && primary !== '-99') return primary.toUpperCase()
|
|
const fallback = typeof p.ISO_A3_EH === 'string' ? p.ISO_A3_EH : null
|
|
if (fallback && fallback !== '-99') return fallback.toUpperCase()
|
|
const adm = typeof p.ADM0_A3 === 'string' ? p.ADM0_A3 : null
|
|
if (adm && adm !== '-99') return adm.toUpperCase()
|
|
return null
|
|
}
|
|
|