import { readFile, writeFile, mkdir, stat, } from 'fs/promises'; import { Stats } from 'fs'; import { resolve } from 'path'; import { read } from './json'; type RequiredFeatureProperties = { NAME: string, ISO_A2_EH: string, } type FeaturePropertyExtensions = { properties: RequiredFeatureProperties } type BoundingBoxData = { name: string, countryCode: string, geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon, bbox: GeoJSON.Position[] | GeoJSON.Position[][], } type GetCountryGeometryOptions = { mergeBoundingBoxes?: boolean; } type GeoOptions = GetCountryGeometryOptions const getPolygonBoundingBox = ( theBoundingBox: GeoJSON.Position[], polygon: GeoJSON.Position[], ) => polygon.reduce( (polygonBoundingBox, [lng, lat]) => [ [ Math.min(lng, polygonBoundingBox[0][0]), Math.max(lat, polygonBoundingBox[0][1]), ], [ Math.max(lng, polygonBoundingBox[1][0]), Math.min(lat, polygonBoundingBox[1][1]), ], ], theBoundingBox, ); // returns [nw, se] bounds const getBoundingBox = (feature: Partial, mergeBoundingBoxes = false) => { const { geometry } = feature; const { type, coordinates } = geometry as (GeoJSON.Polygon | GeoJSON.MultiPolygon); const initialBounds: GeoJSON.Position[] = [ [180, -90], [-180, 90], ]; if (type === 'Polygon') { const polygonBoundingBox = coordinates.reduce( (theBoundingBox, polygon) => getPolygonBoundingBox( theBoundingBox, polygon, ), initialBounds, ); return (mergeBoundingBoxes ? [polygonBoundingBox] : polygonBoundingBox); } if (type === 'MultiPolygon') { if (mergeBoundingBoxes) { return coordinates.map((polygons) => polygons.reduce( getPolygonBoundingBox, initialBounds, )); } return coordinates.reduce( (theCoordinatesBoundingBox, polygons) => polygons.reduce( getPolygonBoundingBox, theCoordinatesBoundingBox, ), initialBounds, ); } throw new Error(`Invalid feature type: ${type as string}. Only Polygon and MultiPolygon are supported.`); }; const getRecognizedCountries = async () => { const json = await read('assets/ne_10m_admin_0_countries.json'); const recognizedCountries = json.features.filter((f) => ( f.properties && f.properties.ISO_A2_EH && f.properties.ISO_A2_EH !== '-99' && f.properties.NAME )) as (typeof json.features[number] & FeaturePropertyExtensions)[]; return recognizedCountries.map((f) => ({ name: f.properties.NAME, countryCode: f.properties.ISO_A2_EH, geometry: f.geometry as (GeoJSON.Polygon | GeoJSON.MultiPolygon), })) .sort((a, b) => a.name.localeCompare(b.name)); }; const getCountryData = async (options: GeoOptions): Promise => { const outputPath = resolve('assets/countries.json'); let outputStat: Stats | undefined; try { outputStat = await stat(outputPath); } catch (errRaw) { const err = errRaw as { code: string }; if (err.code !== 'ENOENT') { throw err; } } if (outputStat) { if (outputStat.isDirectory()) { throw new Error('Invalid country data. File expected, but directory found.'); } const countryBuffer = await readFile(outputPath); const countryString = countryBuffer.toString('utf-8'); return JSON.parse(countryString) as BoundingBoxData[]; } const countries = await getRecognizedCountries(); const countriesWithBoundingBox = countries.map(({ geometry, ...c }) => ({ ...c, geometry, bbox: getBoundingBox({ ...c, geometry }, options.mergeBoundingBoxes), })); try { await mkdir('assets'); } catch (errRaw) { const err = errRaw as { code: string }; if (err.code !== 'EEXIST') { throw err; } // noop } await writeFile(outputPath, JSON.stringify(countriesWithBoundingBox)); return countriesWithBoundingBox; }; export const getCountryGeometry = async (countryCode: string, options = {} as GeoOptions) => { const countryData = await getCountryData(options); const country = countryData.find((c) => ( c.countryCode.toLowerCase() === countryCode.toLowerCase() )); if (country) { return country; } throw new Error(`Invalid country code: ${countryCode}`); };