Sfoglia il codice sorgente

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
parent
commit
6a200bd007
13 ha cambiato i file con 372 aggiunte e 148535 eliminazioni
  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 Vedi File

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

.npmrc

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

+ 2
- 2
package.json Vedi 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 Vedi 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 soppresso perché troppo grande
Vedi File


+ 0
- 148051
scripts/generate-bounding-box-data/sample-russia.json
File diff soppresso perché troppo grande
Vedi File


+ 16
- 0
src/common.ts Vedi 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 Vedi 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 Vedi File

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

+ 7
- 0
src/json.ts Vedi 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 Vedi 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 Vedi File

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

+ 0
- 22
tsconfig.script.json Vedi 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 Vedi 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==


Caricamento…
Annulla
Salva