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.

339 lines
9.8 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. import { ProjectBounds } from './common';
  7. import { getCountryGeometry } from './geo';
  8. type Projection = [string, ...unknown[]]
  9. type Rect = {
  10. width: number,
  11. height: number,
  12. }
  13. export type ProjectOptions = {
  14. bounds: ProjectBounds,
  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 loadProjection = (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 getMidpoint = (a: GeoJSON.Position, b: GeoJSON.Position) => [
  48. (a[0] + b[0]) / 2,
  49. (a[1] + b[1]) / 2,
  50. ];
  51. const getCentroid = (boundsRaw: d3geo.GeoGeometryObjects): GeoJSON.Position => {
  52. if (boundsRaw.type === 'Polygon') {
  53. return boundsRaw.coordinates[0].reduce(getMidpoint);
  54. }
  55. if (boundsRaw.type === 'MultiPolygon') {
  56. return boundsRaw.coordinates
  57. .map((polygon) => polygon[0].reduce(getMidpoint))
  58. .reduce(getMidpoint);
  59. }
  60. throw new Error(`Invalid type: ${boundsRaw.type}`);
  61. };
  62. const buildGeoProjection = (
  63. projection: Projection,
  64. boundsGeoJson: d3geo.GeoGeometryObjects,
  65. options: ProjectOptions,
  66. inputWidth: number,
  67. ) => {
  68. const geoProjection = loadProjection(projection);
  69. const center2 = d3geo.geoCentroid(boundsGeoJson); // why is d3geo's centroid different than our simple centroid function?
  70. const center = getCentroid(boundsGeoJson);
  71. console.log(center, center2);
  72. const transformedGeoProjection = geoProjection
  73. .reflectX(false)
  74. .reflectY(false)
  75. .rotate([-center[0], -center[1]]);
  76. // fixme: fitX() methods don't modify scale, only the dimensions?
  77. if (typeof options.outputSize?.width === 'number') {
  78. if (typeof options.outputSize.height === 'number') {
  79. const paddingX = options.outputPadding?.x ?? 0;
  80. const paddingY = options.outputPadding?.y ?? 0;
  81. return transformedGeoProjection.fitExtent(
  82. [
  83. [paddingX, paddingY],
  84. [options.outputSize.width - paddingX, options.outputSize.height - paddingY],
  85. ],
  86. boundsGeoJson,
  87. );
  88. }
  89. return transformedGeoProjection.fitWidth(
  90. options.outputSize.width,
  91. boundsGeoJson,
  92. );
  93. }
  94. if (typeof options.outputSize?.height === 'number') {
  95. return transformedGeoProjection.fitHeight(
  96. options.outputSize.height,
  97. boundsGeoJson,
  98. );
  99. }
  100. return transformedGeoProjection.fitWidth(inputWidth, boundsGeoJson);
  101. };
  102. const computeOutputBounds = (
  103. transformedGeoProjection: d3geo.GeoProjection,
  104. _projectionName: string,
  105. bounds: d3geo.GeoGeometryObjects,
  106. ) => {
  107. const path = d3geo.geoPath(transformedGeoProjection);
  108. const bbox = path.bounds(bounds);
  109. // console.log(bbox);
  110. return [
  111. Math.round(Math.abs(bbox[1][0] - bbox[0][0])),
  112. Math.round(Math.abs(bbox[1][1] - bbox[0][1])),
  113. ];
  114. };
  115. // const computeOutputBounds = (
  116. // transformedGeoProjection: d3geo.GeoProjection,
  117. // projectionName: string,
  118. // bounds: d3geo.GeoGeometryObjects,
  119. // outputSize?: Partial<Rect>,
  120. // ) => {
  121. // // TODO: what would it be for polygonal projections?
  122. //
  123. // if (
  124. // typeof outputSize?.width === 'number'
  125. // && typeof outputSize.height === 'number'
  126. // ) {
  127. // return [
  128. // outputSize.width,
  129. // outputSize.height,
  130. // ];
  131. // }
  132. //
  133. // const path = d3.geoPath(transformedGeoProjection);
  134. //
  135. // path.bounds(bounds)
  136. // // TODO better way to compute width and height directly from d3?
  137. //
  138. // let extremeProjectedBoundsRaw: Bounds | null | undefined;
  139. // if (bounds.type === 'Polygon') {
  140. // extremeProjectedBoundsRaw = bounds.coordinates[0].reduce(
  141. // (theBounds, point) => {
  142. // const projected = transformedGeoProjection(point as [number, number]);
  143. //
  144. // if (projected === null) {
  145. // return null;
  146. // }
  147. //
  148. // if (theBounds === null) {
  149. // return [
  150. // projected,
  151. // projected,
  152. // ];
  153. // }
  154. //
  155. // return [
  156. // [
  157. // Math.min(theBounds[0][0], projected[0]),
  158. // Math.max(theBounds[0][1], projected[1]),
  159. // ],
  160. // [
  161. // Math.max(theBounds[1][0], projected[0]),
  162. // Math.min(theBounds[1][1], projected[1]),
  163. // ],
  164. // ];
  165. // },
  166. // null as unknown as (GeoJSON.Position[] | null),
  167. // );
  168. // }
  169. //
  170. // if (!extremeProjectedBoundsRaw) {
  171. // return null;
  172. // }
  173. //
  174. // const [nwBoundsRaw, seBoundsRaw] = extremeProjectedBoundsRaw;
  175. // const outputWidthRaw = seBoundsRaw[0] - nwBoundsRaw[0];
  176. // const outputHeightRaw = seBoundsRaw[1] - nwBoundsRaw[1];
  177. //
  178. // // switch (projectionName) {
  179. // // case 'Mercator':
  180. // // return computeLatitudeSensitiveProjectionVerticalBounds(
  181. // // transformedGeoProjection,
  182. // // bounds,
  183. // // outputWidthRaw,
  184. // // );
  185. // // case 'Robinson':
  186. // // case 'Sinusoidal':
  187. // // return computeLongitudeSensitiveProjectionHorizontalBounds(
  188. // // transformedGeoProjection,
  189. // // bounds,
  190. // // outputHeightRaw,
  191. // // );
  192. // // default:
  193. // // break;
  194. // // }
  195. //
  196. // return [
  197. // Math.round(outputWidthRaw),
  198. // Math.round(outputHeightRaw),
  199. // ];
  200. // };
  201. const determineGeoJsonBounds = async (bounds: ProjectBounds): Promise<d3geo.GeoGeometryObjects> => {
  202. if (bounds.type === 'country') {
  203. const countryGeometry = await getCountryGeometry(
  204. bounds.value,
  205. { mergeBoundingBoxes: true },
  206. );
  207. return countryGeometry.geometry;
  208. }
  209. if (bounds.type === 'geojson') {
  210. return bounds.value;
  211. }
  212. throw new Error(`Invalid bounds type: ${(bounds as Record<string, string>).type}`);
  213. };
  214. export const project = async (
  215. pngInput: string | Buffer | PNG,
  216. projection: Projection,
  217. options = {
  218. wrapAround: false,
  219. bounds: {
  220. type: 'geojson',
  221. value: {
  222. type: 'Sphere',
  223. },
  224. },
  225. outputSize: undefined,
  226. } as ProjectOptions,
  227. ) => {
  228. console.log(options);
  229. const inputPng = await Png.load(pngInput);
  230. const theBounds = await determineGeoJsonBounds(options.bounds);
  231. const transformedGeoProjection = buildGeoProjection(
  232. projection,
  233. theBounds,
  234. options,
  235. inputPng.width,
  236. );
  237. const [projectionName] = projection;
  238. if (!transformedGeoProjection.invert) {
  239. throw new Error(`No invert() function for projection "${projectionName}"`);
  240. }
  241. const outputBounds = computeOutputBounds(
  242. transformedGeoProjection,
  243. projectionName,
  244. theBounds,
  245. );
  246. if (!outputBounds) {
  247. throw new Error(
  248. `Cannot compute bounds, possibly unimplemented. Check logic of "${projectionName}" projection.`,
  249. );
  250. }
  251. const [outputWidth, outputHeight] = outputBounds;
  252. // outputWidth = options.outputSize?.width ?? 461;
  253. // outputHeight = options.outputSize?.height ?? 461;
  254. const { data: inputData } = inputPng;
  255. const outputPng = new PNG({
  256. width: outputWidth,
  257. height: outputHeight,
  258. });
  259. const { data: outputData } = outputPng;
  260. let i = 0;
  261. for (let y = 0; y < outputHeight; y += 1) {
  262. for (let x = 0; x < outputWidth; x += 1) {
  263. const projected = transformedGeoProjection.invert([x, y]);
  264. if (projected) {
  265. if (options.wrapAround) {
  266. const [lambda, phi] = projected;
  267. if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
  268. // eslint-disable-next-line no-bitwise
  269. const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
  270. // eslint-disable-next-line no-bitwise
  271. + (((180 + lambda) / 360) * inputPng.width | 0) << 2;
  272. outputData[i] = inputData[q];
  273. outputData[i + 1] = inputData[q + 1];
  274. outputData[i + 2] = inputData[q + 2];
  275. outputData[i + 3] = inputData[q + 3];
  276. }
  277. } else {
  278. const reversed = transformedGeoProjection(projected);
  279. if (reversed) {
  280. const isSamePoint = (
  281. Math.abs(reversed[0] - x) < 0.5
  282. && Math.abs(reversed[1] - y) < 0.5
  283. );
  284. if (isSamePoint) {
  285. const [lambda, phi] = projected;
  286. if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
  287. // eslint-disable-next-line no-bitwise
  288. const q = (((90 - phi) / 180) * inputPng.height | 0) * inputPng.width
  289. // eslint-disable-next-line no-bitwise
  290. + (((180 + lambda) / 360) * inputPng.width | 0) << 2;
  291. outputData[i] = inputData[q];
  292. outputData[i + 1] = inputData[q + 1];
  293. outputData[i + 2] = inputData[q + 2];
  294. outputData[i + 3] = inputData[q + 3];
  295. }
  296. }
  297. }
  298. }
  299. }
  300. i += 4;
  301. }
  302. }
  303. return PNG.sync.write(outputPng);
  304. };