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.

145 lines
4.3 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 { readFile } from 'fs/promises';
  6. type Coords = [number, number]
  7. type Bounds = [Coords, Coords]
  8. type ProjectionFunction = (...args: unknown[]) => (
  9. d3geo.GeoProjection | d3geoProjection.GeoProjection
  10. )
  11. const referenceWidth = 960 as const;
  12. const referenceHeight = 480 as const;
  13. type ProjectOptions = {
  14. inputDimensions?: { width: number, height: number },
  15. bounds?: Bounds,
  16. }
  17. type Projection = [string, ...unknown[]]
  18. const isValidLongitude = (lng: number) => lng >= -180 && lng <= 180;
  19. const isValidLatitude = (lat: number) => lat >= -90 && lat <= 90;
  20. const getCenter = (bounds: Bounds): Coords => {
  21. const [nw, se] = bounds;
  22. const [nwLng, nwLat] = nw;
  23. const [seLng, seLat] = se;
  24. return [
  25. // TODO: better center checking that wraps longitudes
  26. (nwLng + seLng) / 2,
  27. (nwLat + seLat) / 2,
  28. ];
  29. };
  30. export const projectPoint = (
  31. point: [number, number],
  32. projection: Projection,
  33. options: ProjectOptions = {},
  34. ): [number, number] | null => {
  35. const [lng, lat] = point;
  36. if (!(isValidLongitude(lng) && isValidLatitude(lat))) {
  37. throw new RangeError('Invalid value for point. Only values from [-180, -90] to [180, 90] are allowed');
  38. }
  39. const [projectionName, ...projectionArgs] = projection;
  40. if (projectionName === 'Equirectangular') {
  41. return point;
  42. }
  43. const projectionFunctionName = `geo${projectionName}`;
  44. let geoProjectionSource: Record<string, unknown> | undefined;
  45. if (projectionFunctionName in d3geoPolygon) {
  46. geoProjectionSource = d3geoPolygon as Record<string, unknown>;
  47. } else if (projectionFunctionName in d3geoProjection) {
  48. geoProjectionSource = d3geoProjection;
  49. } else if (projectionFunctionName in d3geo) {
  50. geoProjectionSource = d3geo;
  51. }
  52. if (!geoProjectionSource) {
  53. throw new Error(`Unknown projection: ${projectionName}`);
  54. }
  55. const projectionFn = geoProjectionSource[projectionFunctionName] as ProjectionFunction;
  56. const baseProjection = projectionFn(...projectionArgs);
  57. const {
  58. bounds = [[-180, 90], [180, -90]],
  59. inputDimensions = { width: referenceWidth, height: referenceHeight },
  60. } = options;
  61. const { invert } = baseProjection.center(getCenter(bounds));
  62. if (!invert) {
  63. return null;
  64. }
  65. const { width: sx, height: sy } = inputDimensions;
  66. return invert([
  67. (lng / sx) * referenceWidth,
  68. (lat / sy) * referenceHeight,
  69. ]) as [number, number];
  70. };
  71. const retrievePngData = async (pngInput: string | Buffer | PNG) => {
  72. if (typeof pngInput === 'string') {
  73. const pngFile = await readFile(pngInput);
  74. return PNG.sync.read(pngFile);
  75. }
  76. if (typeof pngInput === 'object') {
  77. return pngInput instanceof PNG ? pngInput : PNG.sync.read(pngInput);
  78. }
  79. throw new TypeError('Invalid input argument.');
  80. };
  81. const prepareOutputData = (
  82. _projection: Projection,
  83. sourceWidth: number,
  84. sourceHeight: number,
  85. ): PNG => {
  86. const [tx, ty] = [sourceWidth, sourceHeight];
  87. return new PNG({
  88. width: tx,
  89. height: ty,
  90. });
  91. };
  92. export const project = async (
  93. pngInput: string | Buffer | PNG,
  94. projection: Projection,
  95. options?: ProjectOptions,
  96. ) => {
  97. const sourcePngData = await retrievePngData(pngInput);
  98. const { width: sx, height: sy, data: sourceData } = sourcePngData;
  99. // const [tx, ty] = currentProjectionData.bounds(bounds)([sx, sy]);
  100. // TODO correctly compute result bounds
  101. const outputPngData = prepareOutputData(projection, sx, sy);
  102. const { width: tx, height: ty, data: targetData } = outputPngData;
  103. let i = 0;
  104. for (let y = (sy - ty) / 2; y < (ty + ((sy - ty) / 2)); y += 1) {
  105. for (let x = 0; x < (tx + ((sx - tx) / 2)); x += 1) {
  106. const projected = projectPoint([x, y], projection, {
  107. bounds: options?.bounds,
  108. });
  109. if (projected) {
  110. const [lambda, phi] = projected;
  111. if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) {
  112. // eslint-disable-next-line no-bitwise
  113. const q = (((90 - phi) / 180) * sy | 0) * sx + (((180 + lambda) / 360) * sx | 0) << 2;
  114. targetData[i] = sourceData[q];
  115. targetData[i + 1] = sourceData[q + 1];
  116. targetData[i + 2] = sourceData[q + 2];
  117. targetData[i + 3] = sourceData[q + 3];
  118. }
  119. }
  120. i += 4;
  121. }
  122. }
  123. return outputPngData;
  124. };