Utilities for map projections.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
8.2 KiB

  1. import * as d3geo from 'd3-geo';
  2. import * as d3geoProjection from 'd3-geo-projection';
  3. import * as d3geoPolygon from 'd3-geo-polygon';
  4. import { PNG } from 'pngjs';
  5. import * as Png from './png';
  6. export type Point = [number, number]
  7. export type Bounds = [Point, Point]
  8. type Projection = [string, ...unknown[]]
  9. type Rect = {
  10. width: number,
  11. height: number,
  12. }
  13. export type ProjectOptions = {
  14. bounds: Bounds,
  15. wrapAround: boolean,
  16. outputSize?: Partial<Rect>,
  17. outputPadding?: {
  18. x?: number,
  19. y?: number,
  20. }
  21. }
  22. const geoProjectionNamePrefix = 'geo';
  23. type GeoProjectionFactory = any
  24. const getProjectionFunction = (projectionFunctionName: string): GeoProjectionFactory => {
  25. if (projectionFunctionName in d3geoPolygon) {
  26. return (d3geoPolygon as Record<string, unknown>)[projectionFunctionName];
  27. }
  28. if (projectionFunctionName in d3geoProjection) {
  29. return (d3geoProjection as Record<string, unknown>)[projectionFunctionName];
  30. }
  31. if (projectionFunctionName in d3geo) {
  32. return (d3geo as Record<string, unknown>)[projectionFunctionName];
  33. }
  34. const properProjectionName = projectionFunctionName.slice(
  35. projectionFunctionName.indexOf(geoProjectionNamePrefix)
  36. + geoProjectionNamePrefix.length,
  37. );
  38. throw new Error(`Unknown projection: ${properProjectionName}`);
  39. };
  40. const buildProjection = (projection: Projection) => {
  41. const [projectionName, ...projectionArgs] = projection;
  42. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  43. const projectionFunction = getProjectionFunction(`${geoProjectionNamePrefix}${projectionName}`);
  44. // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  45. return projectionFunction(projectionArgs) as d3geo.GeoProjection;
  46. };
  47. const getCenter = (bounds: Bounds): Point => {
  48. const [nw, se] = bounds;
  49. const [nwLng, nwLat] = nw;
  50. const [seLng, seLat] = se;
  51. let seLngNorm = seLng;
  52. while (seLngNorm < nwLng) {
  53. seLngNorm += 360;
  54. }
  55. return [
  56. ((((nwLng + 180) + (seLngNorm + 180)) / 2) % 360) - 180,
  57. (nwLat + seLat) / 2,
  58. ];
  59. };
  60. const transformGeoProjection = (
  61. geoProjection: d3geo.GeoProjection,
  62. options: ProjectOptions,
  63. inputWidth: number,
  64. ) => {
  65. const center = getCenter(options.bounds);
  66. const transformedGeoProjection = geoProjection
  67. .reflectX(false)
  68. .reflectY(false)
  69. .rotate([-center[0], -center[1]]);
  70. const boundsGeoJson: GeoJSON.Polygon = {
  71. type: 'Polygon',
  72. coordinates: [
  73. [
  74. [options.bounds[0][0], options.bounds[0][1]],
  75. [options.bounds[1][0], options.bounds[0][1]],
  76. [options.bounds[1][0], options.bounds[1][1]],
  77. [options.bounds[0][0], options.bounds[1][1]],
  78. ],
  79. ],
  80. };
  81. if (typeof options.outputSize?.width === 'number') {
  82. if (typeof options.outputSize.height === 'number') {
  83. const paddingX = options.outputPadding?.x ?? 0;
  84. const paddingY = options.outputPadding?.y ?? 0;
  85. return transformedGeoProjection.fitExtent(
  86. [
  87. [paddingX, paddingY],
  88. [options.outputSize.width - paddingX, options.outputSize.height - paddingY],
  89. ],
  90. boundsGeoJson,
  91. );
  92. }
  93. return transformedGeoProjection.fitWidth(
  94. options.outputSize.width,
  95. boundsGeoJson,
  96. );
  97. }
  98. if (typeof options.outputSize?.height === 'number') {
  99. return transformedGeoProjection.fitHeight(
  100. options.outputSize.height,
  101. boundsGeoJson,
  102. );
  103. }
  104. return transformedGeoProjection.fitWidth(inputWidth, boundsGeoJson);
  105. };
  106. const computeLatitudeSensitiveProjectionVerticalBounds = (
  107. transformedGeoProjection: d3geo.GeoProjection,
  108. bounds: Bounds,
  109. outputWidthRaw: number,
  110. ) => {
  111. // web mercator clipping
  112. // const maxAbsLatitude
  113. // = ((2 * Math.atan(Math.E ** Math.PI) - (Math.PI / 2)) / (Math.PI * 2)) * 360;
  114. const maxAbsLatitude = 85.0511287798066;
  115. const adjustedNwBoundsRaw = transformedGeoProjection([
  116. bounds[0][0],
  117. Math.min(bounds[0][1], maxAbsLatitude),
  118. ]);
  119. const adjustedSeBoundsRaw = transformedGeoProjection([
  120. bounds[1][0],
  121. Math.max(bounds[1][1], -maxAbsLatitude),
  122. ]);
  123. if (!(adjustedNwBoundsRaw && adjustedSeBoundsRaw)) {
  124. return null;
  125. }
  126. return [
  127. Math.round(outputWidthRaw),
  128. Math.round(adjustedSeBoundsRaw[1] - adjustedNwBoundsRaw[1]),
  129. ];
  130. };
  131. const computeLongitudeSensitiveProjectionHorizontalBounds = (
  132. transformedGeoProjection: d3geo.GeoProjection,
  133. bounds: Bounds,
  134. outputHeightRaw: number,
  135. ) => {
  136. const adjustedWBoundsRaw = transformedGeoProjection([
  137. bounds[0][0],
  138. (bounds[0][1] + bounds[1][1]) / 2,
  139. ]);
  140. const adjustedEBoundsRaw = transformedGeoProjection([
  141. bounds[1][0],
  142. (bounds[0][1] + bounds[1][1]) / 2,
  143. ]);
  144. if (!(adjustedWBoundsRaw && adjustedEBoundsRaw)) {
  145. return null;
  146. }
  147. return [
  148. Math.round(adjustedEBoundsRaw[0] - adjustedWBoundsRaw[0]),
  149. Math.round(outputHeightRaw),
  150. ];
  151. };
  152. const computeOutputBounds = (
  153. transformedGeoProjection: d3geo.GeoProjection,
  154. projectionName: string,
  155. bounds: Bounds,
  156. outputSize?: Partial<Rect>,
  157. ) => {
  158. // TODO: what would it be for polygonal projections?
  159. if (
  160. typeof outputSize?.width === 'number'
  161. && typeof outputSize.height === 'number'
  162. ) {
  163. return [
  164. outputSize.width,
  165. outputSize.height,
  166. ];
  167. }
  168. const nwBoundsRaw = transformedGeoProjection(bounds[0]);
  169. const seBoundsRaw = transformedGeoProjection(bounds[1]);
  170. if (!(nwBoundsRaw && seBoundsRaw)) {
  171. return null;
  172. }
  173. const outputWidthRaw = seBoundsRaw[0] - nwBoundsRaw[0];
  174. const outputHeightRaw = seBoundsRaw[1] - nwBoundsRaw[1];
  175. switch (projectionName) {
  176. case 'Mercator':
  177. return computeLatitudeSensitiveProjectionVerticalBounds(
  178. transformedGeoProjection,
  179. bounds,
  180. outputWidthRaw,
  181. );
  182. case 'Robinson':
  183. case 'Sinusoidal':
  184. return computeLongitudeSensitiveProjectionHorizontalBounds(
  185. transformedGeoProjection,
  186. bounds,
  187. outputHeightRaw,
  188. );
  189. default:
  190. break;
  191. }
  192. return [
  193. Math.round(outputWidthRaw),
  194. Math.round(outputHeightRaw),
  195. ];
  196. };
  197. export const project = async (
  198. pngInput: string | Buffer | PNG,
  199. projection: Projection,
  200. options = {
  201. wrapAround: false,
  202. bounds: [[-180, 90], [180, -90]],
  203. outputSize: undefined,
  204. } as ProjectOptions,
  205. ) => {
  206. const inputPng = await Png.load(pngInput);
  207. const geoProjection = buildProjection(projection);
  208. const [projectionName] = projection;
  209. const transformedGeoProjection = transformGeoProjection(geoProjection, options, inputPng.width);
  210. if (!transformedGeoProjection.invert) {
  211. throw new Error(`No invert() function for projection "${projectionName}"`);
  212. }
  213. const outputBounds = computeOutputBounds(
  214. transformedGeoProjection,
  215. projectionName,
  216. options.bounds,
  217. options.outputSize,
  218. );
  219. if (!outputBounds) {
  220. throw new Error(
  221. `Cannot compute bounds, possibly unimplemented. Check logic of "${projectionName}" projection.`,
  222. );
  223. }
  224. const [outputWidth, outputHeight] = outputBounds;
  225. // outputWidth = options.outputSize?.width ?? 461;
  226. // outputHeight = options.outputSize?.height ?? 461;
  227. const { data: inputData } = inputPng;
  228. const outputPng = new PNG({
  229. width: outputWidth,
  230. height: outputHeight,
  231. });
  232. const { data: outputData } = outputPng;
  233. let i = 0;
  234. for (let y = 0; y < outputHeight; y += 1) {
  235. for (let x = 0; x < outputWidth; x += 1) {
  236. const projected = transformedGeoProjection.invert([x, y]);
  237. if (projected) {
  238. const reversed = transformedGeoProjection(projected);
  239. if (reversed) {
  240. const isSamePoint = (
  241. Math.abs(reversed[0] - x) < 0.5
  242. && Math.abs(reversed[1] - y) < 0.5
  243. );
  244. if (isSamePoint) {
  245. const [lambda, phi] = projected;
  246. if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
  247. // eslint-disable-next-line no-bitwise
  248. const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
  249. // eslint-disable-next-line no-bitwise
  250. + (((180 + lambda) / 360) * inputPng.width | 0) << 2;
  251. outputData[i] = inputData[q];
  252. outputData[i + 1] = inputData[q + 1];
  253. outputData[i + 2] = inputData[q + 2];
  254. outputData[i + 3] = inputData[q + 3];
  255. }
  256. }
  257. }
  258. }
  259. i += 4;
  260. }
  261. }
  262. return PNG.sync.write(outputPng);
  263. };