@@ -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) ;
};