소스 검색

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


불러오는 중...
취소
저장