浏览代码

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
父节点
当前提交
6a200bd007
共有 13 个文件被更改,包括 372 次插入148535 次删除
  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 查看文件

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

.npmrc

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

+ 2
- 2
package.json 查看文件

@@ -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 查看文件

@@ -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
文件差异内容过多而无法显示
查看文件


+ 0
- 148051
scripts/generate-bounding-box-data/sample-russia.json
文件差异内容过多而无法显示
查看文件


+ 16
- 0
src/common.ts 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 7
- 0
src/json.ts 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 0
- 22
tsconfig.script.json 查看文件

@@ -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 查看文件

@@ -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==


正在加载...
取消
保存