Browse Source

Add better handling of growing bounds, wrap-around

The growing bounds are now respected from the input map bounds. For
instance, the Mercator scales appropriately with the out-of-bound
points.

In addition, a wrap-around option is available for when checking if a
point's inversion inverses to the original point.
master
TheoryOfNekomata 2 years ago
parent
commit
95fe1b606c
2 changed files with 105 additions and 42 deletions
  1. +1
    -1
      src/__tests__/index.test.ts
  2. +104
    -41
      src/index.ts

test/index.test.ts → src/__tests__/index.test.ts View File

@@ -1,6 +1,6 @@
// import * as fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import * as orbisCore from '../src';
import * as orbisCore from '../index';

describe('orbis-core', () => {
describe('projectPoint', () => {

+ 104
- 41
src/index.ts View File

@@ -7,9 +7,7 @@ import { readFile } from 'fs/promises';
type Coords = [number, number]
type Bounds = [Coords, Coords]

type ProjectionFunction = (...args: unknown[]) => (
d3geo.GeoProjection | d3geoProjection.GeoProjection
)
type ProjectionFunction = (...args: unknown[]) => d3geo.GeoProjection;

const referenceWidth = 960 as const;
const referenceHeight = 480 as const;
@@ -17,12 +15,11 @@ const referenceHeight = 480 as const;
type ProjectOptions = {
inputDimensions?: { width: number, height: number },
bounds?: Bounds,
wrapAround?: boolean,
}

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

const isValidLongitude = (lng: number) => lng >= -180 && lng <= 180;
const isValidLatitude = (lat: number) => lat >= -90 && lat <= 90;
const getCenter = (bounds: Bounds): Coords => {
const [nw, se] = bounds;
const [nwLng, nwLat] = nw;
@@ -34,52 +31,87 @@ const getCenter = (bounds: Bounds): Coords => {
];
};

const getProjectionFunctionSource = (projectionFunctionName: string): Record<string, unknown> => {
if (projectionFunctionName in d3geoPolygon) {
return d3geoPolygon as Record<string, unknown>;
}
if (projectionFunctionName in d3geoProjection) {
return d3geoProjection as Record<string, unknown>;
}
if (projectionFunctionName in d3geo) {
return d3geo as Record<string, unknown>;
}

throw new Error(`Unknown projection: ${projectionFunctionName}`);
};

const buildProjectionFunction = (projection: Projection, options: ProjectOptions = {}) => {
const [projectionName, ...projectionArgs] = projection;
const projectionFunctionName = `geo${projectionName}`;
const geoProjectionSource = getProjectionFunctionSource(projectionFunctionName);
const projectionFn = geoProjectionSource[projectionFunctionName] as ProjectionFunction;
const baseProjection = projectionFn(...projectionArgs) as unknown as d3geo.GeoProjection;
const {
bounds = [[-180, 90], [180, -90]],
} = options;

return baseProjection.center(getCenter(bounds));
};

export const projectPoint = (
point: [number, number],
projection: Projection,
options: ProjectOptions = {},
): [number, number] | null => {
const [lng, lat] = point;
if (!(isValidLongitude(lng) && isValidLatitude(lat))) {
throw new RangeError('Invalid value for point. Only values from [-180, -90] to [180, 90] are allowed');
}
const [x, y] = point;

const [projectionName, ...projectionArgs] = projection;
const [projectionName] = projection;
if (projectionName === 'Equirectangular') {
return point;
}

const projectionFunctionName = `geo${projectionName}`;
let geoProjectionSource: Record<string, unknown> | undefined;
if (projectionFunctionName in d3geoPolygon) {
geoProjectionSource = d3geoPolygon as Record<string, unknown>;
} else if (projectionFunctionName in d3geoProjection) {
geoProjectionSource = d3geoProjection;
} else if (projectionFunctionName in d3geo) {
geoProjectionSource = d3geo;
}

if (!geoProjectionSource) {
throw new Error(`Unknown projection: ${projectionName}`);
}

const projectionFn = geoProjectionSource[projectionFunctionName] as ProjectionFunction;
const baseProjection = projectionFn(...projectionArgs);
const {
bounds = [[-180, 90], [180, -90]],
inputDimensions = { width: referenceWidth, height: referenceHeight },
wrapAround = false,
} = options;

const { invert } = baseProjection.center(getCenter(bounds));
if (!invert) {
const projectionFunction = buildProjectionFunction(projection, options);
if (!projectionFunction.invert) {
return null;
}

const { width: sx, height: sy } = inputDimensions;
return invert([
(lng / sx) * referenceWidth,
(lat / sy) * referenceHeight,
]) as [number, number];
const originalPoint = [
(x / sx) * referenceWidth,
(y / sy) * referenceHeight,
] as [number, number];
const inverted = projectionFunction.invert(originalPoint);

if (!inverted) {
return null;
}

if (wrapAround) {
return inverted;
}

const validation = projectionFunction(inverted);
if (!validation) {
return null;
}

// https://stackoverflow.com/questions/41832043/how-to-fix-map-boundaries-on-d3-cartographic-raster-reprojection/41856996#41856996
const tolerance = 0.5;

if (
Math.abs(validation[0] - originalPoint[0]) < tolerance
&& Math.abs(validation[1] - originalPoint[1]) < tolerance
) {
// to avoid wrapping, let's only pick the points that inverts back to its original point
return inverted;
}

return null;
};

const retrievePngData = async (pngInput: string | Buffer | PNG) => {
@@ -96,11 +128,40 @@ const retrievePngData = async (pngInput: string | Buffer | PNG) => {
};

const prepareOutputData = (
_projection: Projection,
sourceWidth: number,
sourceHeight: number,
projection: Projection,
sourcePng: { width: number, height: number },
options = {} as ProjectOptions,
): PNG => {
const [tx, ty] = [sourceWidth, sourceHeight];
const p = buildProjectionFunction(projection, options);
const {
bounds = [[-180, 90], [180, -90]],
} = options;
const nwBoundsRaw = p(bounds[0]);
const seBoundsRaw = p(bounds[1]);
const nwBounds = nwBoundsRaw ? [
(nwBoundsRaw[0] / referenceWidth) * sourcePng.width,
(nwBoundsRaw[1] / referenceHeight) * sourcePng.height,
] : null;

const seBounds = seBoundsRaw ? [
(seBoundsRaw[0] / referenceWidth) * sourcePng.width,
(seBoundsRaw[1] / referenceHeight) * sourcePng.height,
] : null;

let tx = sourcePng.width;
let ty = sourcePng.height;
if (seBounds && nwBounds) {
tx = (nwBounds[0] + seBounds[0]);
ty = (nwBounds[1] + seBounds[1]);

if (
projection[0] === 'Mercator'
&& ty > tx
) {
ty = tx;
}
}

return new PNG({
width: tx,
height: ty,
@@ -114,9 +175,7 @@ export const project = async (
) => {
const sourcePngData = await retrievePngData(pngInput);
const { width: sx, height: sy, data: sourceData } = sourcePngData;
// const [tx, ty] = currentProjectionData.bounds(bounds)([sx, sy]);
// TODO correctly compute result bounds
const outputPngData = prepareOutputData(projection, sx, sy);
const outputPngData = prepareOutputData(projection, sourcePngData, options);
const { width: tx, height: ty, data: targetData } = outputPngData;

let i = 0;
@@ -124,6 +183,10 @@ export const project = async (
for (let x = 0; x < (tx + ((sx - tx) / 2)); x += 1) {
const projected = projectPoint([x, y], projection, {
bounds: options?.bounds,
inputDimensions: {
width: sx,
height: sy,
},
});
if (projected) {
const [lambda, phi] = projected;
@@ -140,5 +203,5 @@ export const project = async (
}
}

return outputPngData;
return PNG.sync.write(outputPngData);
};

Loading…
Cancel
Save