import * as d3geo from 'd3-geo'; import * as d3geoProjection from 'd3-geo-projection'; import * as d3geoPolygon from 'd3-geo-polygon'; import { PNG } from 'pngjs'; import { readFile } from 'fs/promises'; type Coords = [number, number] type Bounds = [Coords, Coords] type ProjectionFunction = (...args: unknown[]) => ( d3geo.GeoProjection | d3geoProjection.GeoProjection ) const referenceWidth = 960 as const; const referenceHeight = 480 as const; type ProjectOptions = { inputDimensions?: { width: number, height: number }, bounds?: Bounds, } 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; const [seLng, seLat] = se; return [ // TODO: better center checking that wraps longitudes (nwLng + seLng) / 2, (nwLat + seLat) / 2, ]; }; 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 [projectionName, ...projectionArgs] = projection; if (projectionName === 'Equirectangular') { return point; } const projectionFunctionName = `geo${projectionName}`; let geoProjectionSource: Record | undefined; if (projectionFunctionName in d3geoPolygon) { geoProjectionSource = d3geoPolygon as Record; } 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 }, } = options; const { invert } = baseProjection.center(getCenter(bounds)); if (!invert) { return null; } const { width: sx, height: sy } = inputDimensions; return invert([ (lng / sx) * referenceWidth, (lat / sy) * referenceHeight, ]) as [number, number]; }; const retrievePngData = async (pngInput: string | Buffer | PNG) => { if (typeof pngInput === 'string') { const pngFile = await readFile(pngInput); return PNG.sync.read(pngFile); } if (typeof pngInput === 'object') { return pngInput instanceof PNG ? pngInput : PNG.sync.read(pngInput); } throw new TypeError('Invalid input argument.'); }; const prepareOutputData = ( _projection: Projection, sourceWidth: number, sourceHeight: number, ): PNG => { const [tx, ty] = [sourceWidth, sourceHeight]; return new PNG({ width: tx, height: ty, }); }; export const project = async ( pngInput: string | Buffer | PNG, projection: Projection, options?: ProjectOptions, ) => { 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 { width: tx, height: ty, data: targetData } = outputPngData; let i = 0; for (let y = (sy - ty) / 2; y < (ty + ((sy - ty) / 2)); y += 1) { for (let x = 0; x < (tx + ((sx - tx) / 2)); x += 1) { const projected = projectPoint([x, y], projection, { bounds: options?.bounds, }); if (projected) { const [lambda, phi] = projected; if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) { // eslint-disable-next-line no-bitwise const q = (((90 - phi) / 180) * sy | 0) * sx + (((180 + lambda) / 360) * sx | 0) << 2; targetData[i] = sourceData[q]; targetData[i + 1] = sourceData[q + 1]; targetData[i + 2] = sourceData[q + 2]; targetData[i + 3] = sourceData[q + 3]; } } i += 4; } } return outputPngData; };