Utilities for map projections.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

164 lines
4.2 KiB

  1. import {
  2. readFile,
  3. writeFile,
  4. mkdir,
  5. stat,
  6. } from 'fs/promises';
  7. import { Stats } from 'fs';
  8. import { resolve } from 'path';
  9. import { read } from './json';
  10. type RequiredFeatureProperties = {
  11. NAME: string,
  12. ISO_A2_EH: string,
  13. }
  14. type FeaturePropertyExtensions = {
  15. properties: RequiredFeatureProperties
  16. }
  17. type BoundingBoxData = {
  18. name: string,
  19. countryCode: string,
  20. geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon,
  21. bbox: GeoJSON.Position[] | GeoJSON.Position[][],
  22. }
  23. type GetCountryGeometryOptions = {
  24. mergeBoundingBoxes?: boolean;
  25. }
  26. type GeoOptions = GetCountryGeometryOptions
  27. const getPolygonBoundingBox = (
  28. theBoundingBox: GeoJSON.Position[],
  29. polygon: GeoJSON.Position[],
  30. ) => polygon.reduce<GeoJSON.Position[]>(
  31. (polygonBoundingBox, [lng, lat]) => [
  32. [
  33. Math.min(lng, polygonBoundingBox[0][0]),
  34. Math.max(lat, polygonBoundingBox[0][1]),
  35. ],
  36. [
  37. Math.max(lng, polygonBoundingBox[1][0]),
  38. Math.min(lat, polygonBoundingBox[1][1]),
  39. ],
  40. ],
  41. theBoundingBox,
  42. );
  43. // returns [nw, se] bounds
  44. const getBoundingBox = (feature: Partial<GeoJSON.Feature>, mergeBoundingBoxes = false) => {
  45. const { geometry } = feature;
  46. const { type, coordinates } = geometry as (GeoJSON.Polygon | GeoJSON.MultiPolygon);
  47. const initialBounds: GeoJSON.Position[] = [
  48. [180, -90],
  49. [-180, 90],
  50. ];
  51. if (type === 'Polygon') {
  52. const polygonBoundingBox = coordinates.reduce(
  53. (theBoundingBox, polygon) => getPolygonBoundingBox(
  54. theBoundingBox,
  55. polygon,
  56. ),
  57. initialBounds,
  58. );
  59. return (mergeBoundingBoxes ? [polygonBoundingBox] : polygonBoundingBox);
  60. }
  61. if (type === 'MultiPolygon') {
  62. if (mergeBoundingBoxes) {
  63. return coordinates.map((polygons) => polygons.reduce(
  64. getPolygonBoundingBox,
  65. initialBounds,
  66. ));
  67. }
  68. return coordinates.reduce(
  69. (theCoordinatesBoundingBox, polygons) => polygons.reduce(
  70. getPolygonBoundingBox,
  71. theCoordinatesBoundingBox,
  72. ),
  73. initialBounds,
  74. );
  75. }
  76. throw new Error(`Invalid feature type: ${type as string}. Only Polygon and MultiPolygon are supported.`);
  77. };
  78. const getRecognizedCountries = async () => {
  79. const json = await read<GeoJSON.FeatureCollection>('assets/ne_10m_admin_0_countries.json');
  80. const recognizedCountries = json.features.filter((f) => (
  81. f.properties
  82. && f.properties.ISO_A2_EH
  83. && f.properties.ISO_A2_EH !== '-99'
  84. && f.properties.NAME
  85. )) as (typeof json.features[number] & FeaturePropertyExtensions)[];
  86. return recognizedCountries.map((f) => ({
  87. name: f.properties.NAME,
  88. countryCode: f.properties.ISO_A2_EH,
  89. geometry: f.geometry as (GeoJSON.Polygon | GeoJSON.MultiPolygon),
  90. }))
  91. .sort((a, b) => a.name.localeCompare(b.name));
  92. };
  93. const getCountryData = async (options: GeoOptions): Promise<BoundingBoxData[]> => {
  94. const outputPath = resolve('assets/countries.json');
  95. let outputStat: Stats | undefined;
  96. try {
  97. outputStat = await stat(outputPath);
  98. } catch (errRaw) {
  99. const err = errRaw as { code: string };
  100. if (err.code !== 'ENOENT') {
  101. throw err;
  102. }
  103. }
  104. if (outputStat) {
  105. if (outputStat.isDirectory()) {
  106. throw new Error('Invalid country data. File expected, but directory found.');
  107. }
  108. const countryBuffer = await readFile(outputPath);
  109. const countryString = countryBuffer.toString('utf-8');
  110. return JSON.parse(countryString) as BoundingBoxData[];
  111. }
  112. const countries = await getRecognizedCountries();
  113. const countriesWithBoundingBox = countries.map(({ geometry, ...c }) => ({
  114. ...c,
  115. geometry,
  116. bbox: getBoundingBox({ ...c, geometry }, options.mergeBoundingBoxes),
  117. }));
  118. try {
  119. await mkdir('assets');
  120. } catch (errRaw) {
  121. const err = errRaw as { code: string };
  122. if (err.code !== 'EEXIST') {
  123. throw err;
  124. }
  125. // noop
  126. }
  127. await writeFile(outputPath, JSON.stringify(countriesWithBoundingBox));
  128. return countriesWithBoundingBox;
  129. };
  130. export const getCountryGeometry = async (countryCode: string, options = {} as GeoOptions) => {
  131. const countryData = await getCountryData(options);
  132. const country = countryData.find((c) => (
  133. c.countryCode.toLowerCase() === countryCode.toLowerCase()
  134. ));
  135. if (country) {
  136. return country;
  137. }
  138. throw new Error(`Invalid country code: ${countryCode}`);
  139. };