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