Browse Source

Load country data directly from code

Load the Natural Earth country data to avoid having to depend on the
script for generating bounding boxes.
master
TheoryOfNekomata 1 year ago
parent
commit
6a200bd007
13 changed files with 372 additions and 148535 deletions
  1. +3
    -0
      .gitignore
  2. +2
    -2
      package.json
  3. +0
    -52
      scripts/generate-bounding-box-data/index.ts
  4. +0
    -260
      scripts/generate-bounding-box-data/ne_10m_admin_0_countries.json
  5. +0
    -148051
      scripts/generate-bounding-box-data/sample-russia.json
  6. +16
    -0
      src/common.ts
  7. +162
    -2
      src/geo.ts
  8. +1
    -0
      src/index.ts
  9. +7
    -0
      src/json.ts
  10. +179
    -143
      src/projection.ts
  11. +1
    -2
      tsconfig.json
  12. +0
    -22
      tsconfig.script.json
  13. +1
    -1
      yarn.lock

+ 3
- 0
.gitignore View File

@@ -105,3 +105,6 @@ dist
.tern-port

.npmrc

scripts/generate-bounding-box-data/*.json
assets/

+ 2
- 2
package.json View File

@@ -52,6 +52,7 @@
"devDependencies": {
"@types/d3": "^7.1.0",
"@types/d3-geo": "^3.0.2",
"@types/geojson": "^7946.0.8",
"@types/node": "^17.0.13",
"@types/pngjs": "^6.0.1",
"@types/sharp": "^0.30.1",
@@ -73,8 +74,7 @@
"watch": "pridepack watch",
"start": "pridepack start",
"dev": "pridepack dev",
"test": "vitest",
"generate-bounding-box-data": "ts-node --project tsconfig.script.json scripts/generate-bounding-box-data/index.ts"
"test": "vitest"
},
"private": false,
"description": "Utility for map projections.",


+ 0
- 52
scripts/generate-bounding-box-data/index.ts View File

@@ -1,52 +0,0 @@
import { readFile, writeFile } from 'fs/promises';

// const getBoundingBox = (coordinates: any) => {
// return coordinates.reduce(
// (coordinatesBoundingBox, polygonGroup) => {
// const pg = polygonGroup.reduce(
// (polygonGroupBoundingBox, polygon) => {
// return [
// Math.min()
// ]
// },
// coordinatesBoundingBox
// )
// },
// [[180, -90], [-180, 90]]
// )
// }

const main = async () => {
let json;
try {
const jsonBuffer = await readFile('scripts/generate-bounding-box-data/ne_10m_admin_0_countries.json');
const jsonString = jsonBuffer.toString('utf-8');
json = JSON.parse(jsonString);
} catch {
process.stderr.write('Unable to read Admin 0 Countries GeoJSON. Obtain data from Natural Earth and convert to GeoJSON.');
process.exit(1);
return;
}

const recognizedCountries = json.features.filter(f => f.properties['ISO_A2_EH'] !== '-99');
await writeFile(
'scripts/generate-bounding-box-data/sample-russia.json',
JSON.stringify(
json.features.find(f => f.properties['NAME'] === 'Russia'),
null,
2
)
);

const countries = recognizedCountries.map(f => ({
name: f.properties['NAME'],
countryCode: f.properties['ISO_A2_EH'],
}))
.sort((a, b) => a.name.localeCompare(b.name));

countries.forEach(c => {
console.log(c);
})
}

void main();

+ 0
- 260
scripts/generate-bounding-box-data/ne_10m_admin_0_countries.json
File diff suppressed because it is too large
View File


+ 0
- 148051
scripts/generate-bounding-box-data/sample-russia.json
File diff suppressed because it is too large
View File


+ 16
- 0
src/common.ts View File

@@ -0,0 +1,16 @@
import * as d3geo from 'd3-geo';

export type Point = [number, number] | GeoJSON.Position
export type Bounds = [Point, Point] | GeoJSON.Position[]

type ProjectPolygonBounds = {
type: 'geojson',
value: d3geo.GeoGeometryObjects,
}

type ProjectCountryBounds = {
type: 'country',
value: string,
}

export type ProjectBounds = ProjectPolygonBounds | ProjectCountryBounds;

+ 162
- 2
src/geo.ts View File

@@ -1,3 +1,163 @@
export const getBounds = () => {
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<GeoJSON.Position[]>(
(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<GeoJSON.Feature>, 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<GeoJSON.FeatureCollection>('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<BoundingBoxData[]> => {
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}`);
};

+ 1
- 0
src/index.ts View File

@@ -1,2 +1,3 @@
export * from './projection';
export * from './geo';
export * from './common';

+ 7
- 0
src/json.ts View File

@@ -0,0 +1,7 @@
import { readFile } from 'fs/promises';

export const read = async <T>(path: string) => {
const jsonBuffer = await readFile(path);
const jsonString = jsonBuffer.toString('utf-8');
return JSON.parse(jsonString) as T;
};

+ 179
- 143
src/projection.ts View File

@@ -3,9 +3,8 @@ import * as d3geoProjection from 'd3-geo-projection';
import * as d3geoPolygon from 'd3-geo-polygon';
import { PNG } from 'pngjs';
import * as Png from './png';

export type Point = [number, number]
export type Bounds = [Point, Point]
import { ProjectBounds } from './common';
import { getCountryGeometry } from './geo';

type Projection = [string, ...unknown[]]

@@ -15,7 +14,7 @@ type Rect = {
}

export type ProjectOptions = {
bounds: Bounds,
bounds: ProjectBounds,
wrapAround: boolean,
outputSize?: Partial<Rect>,
outputPadding?: {
@@ -47,7 +46,7 @@ const getProjectionFunction = (projectionFunctionName: string): GeoProjectionFac
throw new Error(`Unknown projection: ${properProjectionName}`);
};

const buildProjection = (projection: Projection) => {
const loadProjection = (projection: Projection) => {
const [projectionName, ...projectionArgs] = projection;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const projectionFunction = getProjectionFunction(`${geoProjectionNamePrefix}${projectionName}`);
@@ -55,45 +54,42 @@ const buildProjection = (projection: Projection) => {
return projectionFunction(projectionArgs) as d3geo.GeoProjection;
};

const getCenter = (bounds: Bounds): Point => {
const [nw, se] = bounds;
const [nwLng, nwLat] = nw;
const [seLng, seLat] = se;
let seLngNorm = seLng;
const getMidpoint = (a: GeoJSON.Position, b: GeoJSON.Position) => [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2,
];

while (seLngNorm < nwLng) {
seLngNorm += 360;
const getCentroid = (boundsRaw: d3geo.GeoGeometryObjects): GeoJSON.Position => {
if (boundsRaw.type === 'Polygon') {
return boundsRaw.coordinates[0].reduce(getMidpoint);
}

return [
((((nwLng + 180) + (seLngNorm + 180)) / 2) % 360) - 180,
(nwLat + seLat) / 2,
];
if (boundsRaw.type === 'MultiPolygon') {
return boundsRaw.coordinates
.map((polygon) => polygon[0].reduce(getMidpoint))
.reduce(getMidpoint);
}

throw new Error(`Invalid type: ${boundsRaw.type}`);
};

const transformGeoProjection = (
geoProjection: d3geo.GeoProjection,
const buildGeoProjection = (
projection: Projection,
boundsGeoJson: d3geo.GeoGeometryObjects,
options: ProjectOptions,
inputWidth: number,
) => {
const center = getCenter(options.bounds);
const geoProjection = loadProjection(projection);
const center2 = d3geo.geoCentroid(boundsGeoJson); // why is d3geo's centroid different than our simple centroid function?
const center = getCentroid(boundsGeoJson);

console.log(center, center2);
const transformedGeoProjection = geoProjection
.reflectX(false)
.reflectY(false)
.rotate([-center[0], -center[1]]);

const boundsGeoJson: GeoJSON.Polygon = {
type: 'Polygon',
coordinates: [
[
[options.bounds[0][0], options.bounds[0][1]],
[options.bounds[1][0], options.bounds[0][1]],
[options.bounds[1][0], options.bounds[1][1]],
[options.bounds[0][0], options.bounds[1][1]],
],
],
};

// fixme: fitX() methods don't modify scale, only the dimensions?
if (typeof options.outputSize?.width === 'number') {
if (typeof options.outputSize.height === 'number') {
const paddingX = options.outputPadding?.x ?? 0;
@@ -121,108 +117,123 @@ const transformGeoProjection = (
return transformedGeoProjection.fitWidth(inputWidth, boundsGeoJson);
};

const computeLatitudeSensitiveProjectionVerticalBounds = (
const computeOutputBounds = (
transformedGeoProjection: d3geo.GeoProjection,
bounds: Bounds,
outputWidthRaw: number,
_projectionName: string,
bounds: d3geo.GeoGeometryObjects,
) => {
// web mercator clipping
// const maxAbsLatitude
// = ((2 * Math.atan(Math.E ** Math.PI) - (Math.PI / 2)) / (Math.PI * 2)) * 360;
const maxAbsLatitude = 85.0511287798066;
const adjustedNwBoundsRaw = transformedGeoProjection([
bounds[0][0],
Math.min(bounds[0][1], maxAbsLatitude),
]);
const adjustedSeBoundsRaw = transformedGeoProjection([
bounds[1][0],
Math.max(bounds[1][1], -maxAbsLatitude),
]);

if (!(adjustedNwBoundsRaw && adjustedSeBoundsRaw)) {
return null;
}
const path = d3geo.geoPath(transformedGeoProjection);

return [
Math.round(outputWidthRaw),
Math.round(adjustedSeBoundsRaw[1] - adjustedNwBoundsRaw[1]),
];
};

const computeLongitudeSensitiveProjectionHorizontalBounds = (
transformedGeoProjection: d3geo.GeoProjection,
bounds: Bounds,
outputHeightRaw: number,
) => {
const adjustedWBoundsRaw = transformedGeoProjection([
bounds[0][0],
(bounds[0][1] + bounds[1][1]) / 2,
]);
const adjustedEBoundsRaw = transformedGeoProjection([
bounds[1][0],
(bounds[0][1] + bounds[1][1]) / 2,
]);

if (!(adjustedWBoundsRaw && adjustedEBoundsRaw)) {
return null;
}
const bbox = path.bounds(bounds);
// console.log(bbox);

return [
Math.round(adjustedEBoundsRaw[0] - adjustedWBoundsRaw[0]),
Math.round(outputHeightRaw),
Math.round(Math.abs(bbox[1][0] - bbox[0][0])),
Math.round(Math.abs(bbox[1][1] - bbox[0][1])),
];
};

const computeOutputBounds = (
transformedGeoProjection: d3geo.GeoProjection,
projectionName: string,
bounds: Bounds,
outputSize?: Partial<Rect>,
) => {
// TODO: what would it be for polygonal projections?

if (
typeof outputSize?.width === 'number'
&& typeof outputSize.height === 'number'
) {
return [
outputSize.width,
outputSize.height,
];
// const computeOutputBounds = (
// transformedGeoProjection: d3geo.GeoProjection,
// projectionName: string,
// bounds: d3geo.GeoGeometryObjects,
// outputSize?: Partial<Rect>,
// ) => {
// // TODO: what would it be for polygonal projections?
//
// if (
// typeof outputSize?.width === 'number'
// && typeof outputSize.height === 'number'
// ) {
// return [
// outputSize.width,
// outputSize.height,
// ];
// }
//
// const path = d3.geoPath(transformedGeoProjection);
//
// path.bounds(bounds)
// // TODO better way to compute width and height directly from d3?
//
// let extremeProjectedBoundsRaw: Bounds | null | undefined;
// if (bounds.type === 'Polygon') {
// extremeProjectedBoundsRaw = bounds.coordinates[0].reduce(
// (theBounds, point) => {
// const projected = transformedGeoProjection(point as [number, number]);
//
// if (projected === null) {
// return null;
// }
//
// if (theBounds === null) {
// return [
// projected,
// projected,
// ];
// }
//
// return [
// [
// Math.min(theBounds[0][0], projected[0]),
// Math.max(theBounds[0][1], projected[1]),
// ],
// [
// Math.max(theBounds[1][0], projected[0]),
// Math.min(theBounds[1][1], projected[1]),
// ],
// ];
// },
// null as unknown as (GeoJSON.Position[] | null),
// );
// }
//
// if (!extremeProjectedBoundsRaw) {
// return null;
// }
//
// const [nwBoundsRaw, seBoundsRaw] = extremeProjectedBoundsRaw;
// const outputWidthRaw = seBoundsRaw[0] - nwBoundsRaw[0];
// const outputHeightRaw = seBoundsRaw[1] - nwBoundsRaw[1];
//
// // switch (projectionName) {
// // case 'Mercator':
// // return computeLatitudeSensitiveProjectionVerticalBounds(
// // transformedGeoProjection,
// // bounds,
// // outputWidthRaw,
// // );
// // case 'Robinson':
// // case 'Sinusoidal':
// // return computeLongitudeSensitiveProjectionHorizontalBounds(
// // transformedGeoProjection,
// // bounds,
// // outputHeightRaw,
// // );
// // default:
// // break;
// // }
//
// return [
// Math.round(outputWidthRaw),
// Math.round(outputHeightRaw),
// ];
// };

const determineGeoJsonBounds = async (bounds: ProjectBounds): Promise<d3geo.GeoGeometryObjects> => {
if (bounds.type === 'country') {
const countryGeometry = await getCountryGeometry(
bounds.value,
{ mergeBoundingBoxes: true },
);
return countryGeometry.geometry;
}

const nwBoundsRaw = transformedGeoProjection(bounds[0]);
const seBoundsRaw = transformedGeoProjection(bounds[1]);

if (!(nwBoundsRaw && seBoundsRaw)) {
return null;
if (bounds.type === 'geojson') {
return bounds.value;
}

const outputWidthRaw = seBoundsRaw[0] - nwBoundsRaw[0];
const outputHeightRaw = seBoundsRaw[1] - nwBoundsRaw[1];

switch (projectionName) {
case 'Mercator':
return computeLatitudeSensitiveProjectionVerticalBounds(
transformedGeoProjection,
bounds,
outputWidthRaw,
);
case 'Robinson':
case 'Sinusoidal':
return computeLongitudeSensitiveProjectionHorizontalBounds(
transformedGeoProjection,
bounds,
outputHeightRaw,
);
default:
break;
}

return [
Math.round(outputWidthRaw),
Math.round(outputHeightRaw),
];
throw new Error(`Invalid bounds type: ${(bounds as Record<string, string>).type}`);
};

export const project = async (
@@ -230,15 +241,27 @@ export const project = async (
projection: Projection,
options = {
wrapAround: false,
bounds: [[-180, 90], [180, -90]],
bounds: {
type: 'geojson',
value: {
type: 'Sphere',
},
},
outputSize: undefined,
} as ProjectOptions,
) => {
console.log(options);
const inputPng = await Png.load(pngInput);
const geoProjection = buildProjection(projection);
const [projectionName] = projection;
const theBounds = await determineGeoJsonBounds(options.bounds);

const transformedGeoProjection = transformGeoProjection(geoProjection, options, inputPng.width);
const transformedGeoProjection = buildGeoProjection(
projection,
theBounds,
options,
inputPng.width,
);

const [projectionName] = projection;
if (!transformedGeoProjection.invert) {
throw new Error(`No invert() function for projection "${projectionName}"`);
}
@@ -246,8 +269,7 @@ export const project = async (
const outputBounds = computeOutputBounds(
transformedGeoProjection,
projectionName,
options.bounds,
options.outputSize,
theBounds,
);

if (!outputBounds) {
@@ -272,24 +294,38 @@ export const project = async (
for (let x = 0; x < outputWidth; x += 1) {
const projected = transformedGeoProjection.invert([x, y]);
if (projected) {
const reversed = transformedGeoProjection(projected);
if (reversed) {
const isSamePoint = (
Math.abs(reversed[0] - x) < 0.5
&& Math.abs(reversed[1] - y) < 0.5
);

if (isSamePoint) {
const [lambda, phi] = projected;
if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
if (options.wrapAround) {
const [lambda, phi] = projected;
if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
// eslint-disable-next-line no-bitwise
const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
// eslint-disable-next-line no-bitwise
const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
+ (((180 + lambda) / 360) * inputPng.width | 0) << 2;
outputData[i] = inputData[q];
outputData[i + 1] = inputData[q + 1];
outputData[i + 2] = inputData[q + 2];
outputData[i + 3] = inputData[q + 3];
}
} else {
const reversed = transformedGeoProjection(projected);
if (reversed) {
const isSamePoint = (
Math.abs(reversed[0] - x) < 0.5
&& Math.abs(reversed[1] - y) < 0.5
);

if (isSamePoint) {
const [lambda, phi] = projected;
if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
// eslint-disable-next-line no-bitwise
+ (((180 + lambda) / 360) * inputPng.width | 0) << 2;
outputData[i] = inputData[q];
outputData[i + 1] = inputData[q + 1];
outputData[i + 2] = inputData[q + 2];
outputData[i + 3] = inputData[q + 3];
const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
// eslint-disable-next-line no-bitwise
+ (((180 + lambda) / 360) * inputPng.width | 0) << 2;
outputData[i] = inputData[q];
outputData[i + 1] = inputData[q + 1];
outputData[i + 2] = inputData[q + 2];
outputData[i + 3] = inputData[q + 3];
}
}
}
}


+ 1
- 2
tsconfig.json View File

@@ -16,7 +16,6 @@
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"target": "ES2017",
"resolveJsonModule": true
"target": "ES2017"
}
}

+ 0
- 22
tsconfig.script.json View File

@@ -1,22 +0,0 @@
{
"exclude": ["node_modules"],
"include": ["src", "types"],
"compilerOptions": {
"module": "CommonJS",
"lib": ["ESNext", "DOM"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"target": "ES2017",
"resolveJsonModule": true
}
}

+ 1
- 1
yarn.lock View File

@@ -578,7 +578,7 @@
"@types/d3-transition" "*"
"@types/d3-zoom" "*"
"@types/geojson@*":
"@types/geojson@*", "@types/geojson@^7946.0.8":
version "7946.0.8"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca"
integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==


Loading…
Cancel
Save