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.

82 lines
2.4 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. type Coords = [number, number]
  5. type Bounds = [Coords, Coords]
  6. type ProjectionFunction = (...args: unknown[]) => (
  7. d3geo.GeoProjection | d3geoProjection.GeoProjection
  8. )
  9. const referenceWidth = 960 as const;
  10. const referenceHeight = 480 as const;
  11. type ProjectOptions = {
  12. inputDimensions?: { width: number, height: number },
  13. bounds?: Bounds,
  14. }
  15. type Projection = [string, ...unknown[]]
  16. const isValidLongitude = (lng: number) => lng >= -180 && lng <= 180;
  17. const isValidLatitude = (lat: number) => lat >= -90 && lat <= 90;
  18. const getCenter = (bounds: Bounds): Coords => {
  19. const [nw, se] = bounds;
  20. const [nwLng, nwLat] = nw;
  21. const [seLng, seLat] = se;
  22. return [
  23. // TODO: better center checking that wraps longitudes
  24. (nwLng + seLng) / 2,
  25. (nwLat + seLat) / 2,
  26. ];
  27. };
  28. export const project = (
  29. point: [number, number],
  30. projection: Projection,
  31. options: ProjectOptions = {},
  32. ): [number, number] | null => {
  33. const [lng, lat] = point;
  34. if (!(isValidLongitude(lng) && isValidLatitude(lat))) {
  35. throw new RangeError('Invalid value for point. Only values from [-180, -90] to [180, 90] are allowed');
  36. }
  37. const [projectionName, ...projectionArgs] = projection;
  38. if (projectionName === 'Equirectangular') {
  39. return point;
  40. }
  41. const projectionFunctionName = `geo${projectionName}`;
  42. let geoProjectionSource: Record<string, unknown> | undefined;
  43. if (projectionFunctionName in d3geoPolygon) {
  44. geoProjectionSource = d3geoPolygon as Record<string, unknown>;
  45. } else if (projectionFunctionName in d3geoProjection) {
  46. geoProjectionSource = d3geoProjection;
  47. } else if (projectionFunctionName in d3geo) {
  48. geoProjectionSource = d3geo;
  49. }
  50. if (!geoProjectionSource) {
  51. throw new Error(`Unknown projection: ${projectionName}`);
  52. }
  53. const projectionFn = geoProjectionSource[projectionFunctionName] as ProjectionFunction;
  54. const baseProjection = projectionFn(...projectionArgs);
  55. const {
  56. bounds = [[-180, 90], [180, -90]],
  57. inputDimensions = { width: referenceWidth, height: referenceHeight },
  58. } = options;
  59. const { invert } = baseProjection.center(getCenter(bounds));
  60. if (!invert) {
  61. return null;
  62. }
  63. const { width: sx, height: sy } = inputDimensions;
  64. return invert([
  65. (lng / sx) * referenceWidth,
  66. (lat / sy) * referenceHeight,
  67. ]) as [number, number];
  68. };