@trackunit/react-map-adapter-shared 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -762,6 +762,214 @@ const mercatorCenterFromBounds = (bounds) => {
762
762
  return [centerLon, centerLat];
763
763
  };
764
764
 
765
+ // ============================================================================
766
+ // Unit conversion
767
+ // ============================================================================
768
+ const toRad = (deg) => (deg * Math.PI) / 180;
769
+ const toDeg = (rad) => (rad * 180) / Math.PI;
770
+ // ============================================================================
771
+ // Great-circle math (sphere model — WGS-84 mean radius)
772
+ // ============================================================================
773
+ /**
774
+ * Haversine angular distance in radians between two lng/lat points.
775
+ * Returns a value in [0, π].
776
+ */
777
+ const angularDistance = (lng1, lat1, lng2, lat2) => {
778
+ const φ1 = toRad(lat1);
779
+ const φ2 = toRad(lat2);
780
+ const Δφ = toRad(lat2 - lat1);
781
+ const Δλ = toRad(lng2 - lng1);
782
+ const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
783
+ const clampedA = Math.max(0, Math.min(1, a));
784
+ return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
785
+ };
786
+ /**
787
+ * Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
788
+ * along the great-circle arc defined by `delta` (pre-computed angular distance).
789
+ *
790
+ * Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
791
+ *
792
+ * If `start` carries an altitude the result linearly interpolates it; otherwise
793
+ * the result is 2-D.
794
+ */
795
+ const intermediatePoint = (start, end, f, delta) => {
796
+ const sinDelta = Math.sin(delta);
797
+ // Degenerate edge (co-located points) — return start unchanged.
798
+ if (sinDelta === 0)
799
+ return start;
800
+ const A = Math.sin((1 - f) * delta) / sinDelta;
801
+ const B = Math.sin(f * delta) / sinDelta;
802
+ const φ1 = toRad(start[1]);
803
+ const λ1 = toRad(start[0]);
804
+ const φ2 = toRad(end[1]);
805
+ const λ2 = toRad(end[0]);
806
+ const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
807
+ const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
808
+ const z = A * Math.sin(φ1) + B * Math.sin(φ2);
809
+ const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
810
+ const lng = toDeg(Math.atan2(y, x));
811
+ if (start.length === 3 && end.length === 3) {
812
+ const alt = start[2] * (1 - f) + end[2] * f;
813
+ return [lng, lat, alt];
814
+ }
815
+ return [lng, lat];
816
+ };
817
+
818
+ // ============================================================================
819
+ // Constants
820
+ // ============================================================================
821
+ const EARTH_RADIUS_KM = 6371;
822
+ /**
823
+ * Default maximum arc-segment length used by {@link densifyGeodesicFeatures}.
824
+ *
825
+ * At 100 km, a 1 000 km polygon edge gets ~10 intermediate points — enough
826
+ * for a visually smooth great-circle curve at any map zoom level. Reduce only
827
+ * if you need sub-100 km accuracy for very large polygons near the poles.
828
+ */
829
+ const GEODESIC_MAX_SEGMENT_KM = 100;
830
+ // ============================================================================
831
+ // Ring / coordinate-array densification
832
+ // ============================================================================
833
+ /**
834
+ * Densify an array of positions (polygon ring or line-string coordinates) by
835
+ * inserting great-circle intermediate points wherever an edge exceeds
836
+ * `maxSegmentKm`.
837
+ *
838
+ * The result preserves the first and last positions exactly (no floating-point
839
+ * re-computation of the original vertices). Edges shorter than `maxSegmentKm`
840
+ * are left untouched — for typical urban-scale site polygons this is a no-op.
841
+ *
842
+ * Returns the **original array reference** when no intermediate points were
843
+ * inserted, enabling the caller to cheaply detect no-op cases via reference equality.
844
+ */
845
+ const densifyPositions = (positions, maxSegmentKm) => {
846
+ if (positions.length < 2)
847
+ return positions;
848
+ let modified = false;
849
+ const result = [];
850
+ for (let i = 0; i < positions.length - 1; i++) {
851
+ const start = positions[i];
852
+ const end = positions[i + 1];
853
+ // start is always defined because i < positions.length - 1
854
+ if (start === undefined || end === undefined)
855
+ continue;
856
+ result.push(start);
857
+ const delta = angularDistance(start[0], start[1], end[0], end[1]);
858
+ const distKm = delta * EARTH_RADIUS_KM;
859
+ if (distKm > maxSegmentKm) {
860
+ modified = true;
861
+ const nSegments = Math.ceil(distKm / maxSegmentKm);
862
+ // Insert nSegments-1 intermediate points (the end point is added by the
863
+ // next iteration, or after the loop for the final edge).
864
+ for (let s = 1; s < nSegments; s++) {
865
+ result.push(intermediatePoint(start, end, s / nSegments, delta));
866
+ }
867
+ }
868
+ }
869
+ // Return the original reference when nothing was inserted — allows callers to
870
+ // detect no-ops via reference equality without allocating a new array.
871
+ if (!modified)
872
+ return positions;
873
+ // Always preserve the original last position exactly.
874
+ const last = positions[positions.length - 1];
875
+ if (last !== undefined)
876
+ result.push(last);
877
+ return result;
878
+ };
879
+ // ============================================================================
880
+ // Feature-level densification
881
+ // ============================================================================
882
+ /**
883
+ * Densify all edge-bearing geometry types in a single GeoJSON feature.
884
+ * Point and MultiPoint features are returned unchanged.
885
+ */
886
+ const densifyFeature = (feature, maxSegmentKm) => {
887
+ const { geometry } = feature;
888
+ if (geometry === null)
889
+ return feature;
890
+ switch (geometry.type) {
891
+ case "Point":
892
+ case "MultiPoint":
893
+ return feature;
894
+ case "LineString": {
895
+ const coords = densifyPositions(geometry.coordinates, maxSegmentKm);
896
+ if (coords === geometry.coordinates)
897
+ return feature;
898
+ return { ...feature, geometry: { ...geometry, coordinates: coords } };
899
+ }
900
+ case "MultiLineString": {
901
+ const lines = geometry.coordinates.map(line => densifyPositions(line, maxSegmentKm));
902
+ if (lines.every((line, i) => line === geometry.coordinates[i]))
903
+ return feature;
904
+ return { ...feature, geometry: { ...geometry, coordinates: lines } };
905
+ }
906
+ case "Polygon": {
907
+ const rings = geometry.coordinates.map(ring => densifyPositions(ring, maxSegmentKm));
908
+ if (rings.every((ring, i) => ring === geometry.coordinates[i]))
909
+ return feature;
910
+ return { ...feature, geometry: { ...geometry, coordinates: rings } };
911
+ }
912
+ case "MultiPolygon": {
913
+ const polygons = geometry.coordinates.map(polygon => polygon.map(ring => densifyPositions(ring, maxSegmentKm)));
914
+ if (polygons.every((polygon, pi) => polygon.every((ring, ri) => ring === geometry.coordinates[pi]?.[ri])))
915
+ return feature;
916
+ return { ...feature, geometry: { ...geometry, coordinates: polygons } };
917
+ }
918
+ default:
919
+ return feature;
920
+ }
921
+ };
922
+ // ============================================================================
923
+ // Public API
924
+ // ============================================================================
925
+ /**
926
+ * Densify GeoJSON features by inserting great-circle intermediate points along
927
+ * polygon/line edges that exceed `maxSegmentKm`.
928
+ *
929
+ * This is the primary way to achieve geodesic rendering in adapters that lack
930
+ * native great-circle support (e.g. Mapbox GL). Adapters with native geodesic
931
+ * support (e.g. Google Maps via `geodesic: true` on `Polygon`/`Polyline`) do
932
+ * not need this function — they should apply the flag directly to their
933
+ * geometry objects instead.
934
+ *
935
+ * Geodesic eligibility is resolved per-feature:
936
+ * `featureStyles.get(id)?.geodesic ?? layerGeodesic ?? true`
937
+ *
938
+ * Features where geodesic resolves to `false` are passed through unchanged.
939
+ * Point and MultiPoint features are always passed through unchanged.
940
+ *
941
+ * The function returns the original collection reference when no feature is
942
+ * modified, avoiding unnecessary downstream work.
943
+ *
944
+ * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
945
+ * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
946
+ * the `atan2` result inside {@link intermediatePoint}.
947
+ *
948
+ * @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
949
+ * @param layerGeodesic - Layer-level geodesic flag (default `true`).
950
+ * @param featureStyles - Per-feature style overrides; `geodesic` is read from each entry.
951
+ * @param maxSegmentKm - Maximum arc-segment length in kilometres. Default: {@link GEODESIC_MAX_SEGMENT_KM}.
952
+ */
953
+ const densifyGeodesicFeatures = (features, layerGeodesic, featureStyles, maxSegmentKm = GEODESIC_MAX_SEGMENT_KM) => {
954
+ const densified = [];
955
+ let anyModified = false;
956
+ for (const feature of features.features) {
957
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
958
+ const featureGeodesic = featureId !== undefined ? (featureStyles?.get(featureId)?.geodesic ?? layerGeodesic) : layerGeodesic;
959
+ if (!featureGeodesic) {
960
+ densified.push(feature);
961
+ continue;
962
+ }
963
+ const result = densifyFeature(feature, maxSegmentKm);
964
+ if (result !== feature)
965
+ anyModified = true;
966
+ densified.push(result);
967
+ }
968
+ if (!anyModified)
969
+ return features;
970
+ return { ...features, features: densified };
971
+ };
972
+
765
973
  const isViewportLike = (value) => {
766
974
  return typeof value === "object" && value !== null && "type" in value;
767
975
  };
@@ -2402,6 +2610,7 @@ exports.CIRCLE_SYMBOL_DEFAULT_OPACITY = CIRCLE_SYMBOL_DEFAULT_OPACITY;
2402
2610
  exports.DEFAULT_CENTER = DEFAULT_CENTER;
2403
2611
  exports.DEFAULT_MAP_APPEARANCE = DEFAULT_MAP_APPEARANCE;
2404
2612
  exports.DEFAULT_ZOOM = DEFAULT_ZOOM;
2613
+ exports.GEODESIC_MAX_SEGMENT_KM = GEODESIC_MAX_SEGMENT_KM;
2405
2614
  exports.HIT_SURFACE_SELECTOR = HIT_SURFACE_SELECTOR;
2406
2615
  exports.INITIAL_CAMERA_STATE = INITIAL_CAMERA_STATE;
2407
2616
  exports.INITIAL_INTERACTION_STATE = INITIAL_INTERACTION_STATE;
@@ -2418,6 +2627,7 @@ exports.SHAPE_STYLE_DEFAULTS = SHAPE_STYLE_DEFAULTS;
2418
2627
  exports.WORLD_BBOX = WORLD_BBOX;
2419
2628
  exports.allocSafeAreaDebugId = allocSafeAreaDebugId;
2420
2629
  exports.anchorFromBottomCenter = anchorFromBottomCenter;
2630
+ exports.angularDistance = angularDistance;
2421
2631
  exports.attachSafeAreaHoverListeners = attachSafeAreaHoverListeners;
2422
2632
  exports.bboxEquals = bboxEquals;
2423
2633
  exports.bufferRectCorners = bufferRectCorners;
@@ -2439,6 +2649,7 @@ exports.createDefaultClusterElement = createDefaultClusterElement;
2439
2649
  exports.createSymbolDotElement = createSymbolDotElement;
2440
2650
  exports.darkenColor = darkenColor;
2441
2651
  exports.defineAdapter = defineAdapter;
2652
+ exports.densifyGeodesicFeatures = densifyGeodesicFeatures;
2442
2653
  exports.disableSafeAreaDebug = disableSafeAreaDebug;
2443
2654
  exports.discriminateRenderResult = discriminateRenderResult;
2444
2655
  exports.enableSafeAreaDebug = enableSafeAreaDebug;
@@ -2455,6 +2666,7 @@ exports.getAnchorRect = getAnchorRect;
2455
2666
  exports.getEffectiveRestrictBounds = getEffectiveRestrictBounds;
2456
2667
  exports.getHitSurfaceRect = getHitSurfaceRect;
2457
2668
  exports.hasSameFeatureIds = hasSameFeatureIds;
2669
+ exports.intermediatePoint = intermediatePoint;
2458
2670
  exports.isCanvasMarkerMode = isCanvasMarkerMode;
2459
2671
  exports.isEventOfType = isEventOfType;
2460
2672
  exports.isSafeAreaDebugEnabled = isSafeAreaDebugEnabled;
@@ -2477,5 +2689,7 @@ exports.resolveSelectedStyle = resolveSelectedStyle;
2477
2689
  exports.resolveStrokeColors = resolveStrokeColors;
2478
2690
  exports.resolveSymbolDescriptor = resolveSymbolDescriptor;
2479
2691
  exports.safePolygon = safePolygon;
2692
+ exports.toDeg = toDeg;
2693
+ exports.toRad = toRad;
2480
2694
  exports.validateInitialViewport = validateInitialViewport;
2481
2695
  exports.watchSafeAreaLeave = watchSafeAreaLeave;
package/index.esm.js CHANGED
@@ -760,6 +760,214 @@ const mercatorCenterFromBounds = (bounds) => {
760
760
  return [centerLon, centerLat];
761
761
  };
762
762
 
763
+ // ============================================================================
764
+ // Unit conversion
765
+ // ============================================================================
766
+ const toRad = (deg) => (deg * Math.PI) / 180;
767
+ const toDeg = (rad) => (rad * 180) / Math.PI;
768
+ // ============================================================================
769
+ // Great-circle math (sphere model — WGS-84 mean radius)
770
+ // ============================================================================
771
+ /**
772
+ * Haversine angular distance in radians between two lng/lat points.
773
+ * Returns a value in [0, π].
774
+ */
775
+ const angularDistance = (lng1, lat1, lng2, lat2) => {
776
+ const φ1 = toRad(lat1);
777
+ const φ2 = toRad(lat2);
778
+ const Δφ = toRad(lat2 - lat1);
779
+ const Δλ = toRad(lng2 - lng1);
780
+ const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
781
+ const clampedA = Math.max(0, Math.min(1, a));
782
+ return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
783
+ };
784
+ /**
785
+ * Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
786
+ * along the great-circle arc defined by `delta` (pre-computed angular distance).
787
+ *
788
+ * Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
789
+ *
790
+ * If `start` carries an altitude the result linearly interpolates it; otherwise
791
+ * the result is 2-D.
792
+ */
793
+ const intermediatePoint = (start, end, f, delta) => {
794
+ const sinDelta = Math.sin(delta);
795
+ // Degenerate edge (co-located points) — return start unchanged.
796
+ if (sinDelta === 0)
797
+ return start;
798
+ const A = Math.sin((1 - f) * delta) / sinDelta;
799
+ const B = Math.sin(f * delta) / sinDelta;
800
+ const φ1 = toRad(start[1]);
801
+ const λ1 = toRad(start[0]);
802
+ const φ2 = toRad(end[1]);
803
+ const λ2 = toRad(end[0]);
804
+ const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
805
+ const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
806
+ const z = A * Math.sin(φ1) + B * Math.sin(φ2);
807
+ const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
808
+ const lng = toDeg(Math.atan2(y, x));
809
+ if (start.length === 3 && end.length === 3) {
810
+ const alt = start[2] * (1 - f) + end[2] * f;
811
+ return [lng, lat, alt];
812
+ }
813
+ return [lng, lat];
814
+ };
815
+
816
+ // ============================================================================
817
+ // Constants
818
+ // ============================================================================
819
+ const EARTH_RADIUS_KM = 6371;
820
+ /**
821
+ * Default maximum arc-segment length used by {@link densifyGeodesicFeatures}.
822
+ *
823
+ * At 100 km, a 1 000 km polygon edge gets ~10 intermediate points — enough
824
+ * for a visually smooth great-circle curve at any map zoom level. Reduce only
825
+ * if you need sub-100 km accuracy for very large polygons near the poles.
826
+ */
827
+ const GEODESIC_MAX_SEGMENT_KM = 100;
828
+ // ============================================================================
829
+ // Ring / coordinate-array densification
830
+ // ============================================================================
831
+ /**
832
+ * Densify an array of positions (polygon ring or line-string coordinates) by
833
+ * inserting great-circle intermediate points wherever an edge exceeds
834
+ * `maxSegmentKm`.
835
+ *
836
+ * The result preserves the first and last positions exactly (no floating-point
837
+ * re-computation of the original vertices). Edges shorter than `maxSegmentKm`
838
+ * are left untouched — for typical urban-scale site polygons this is a no-op.
839
+ *
840
+ * Returns the **original array reference** when no intermediate points were
841
+ * inserted, enabling the caller to cheaply detect no-op cases via reference equality.
842
+ */
843
+ const densifyPositions = (positions, maxSegmentKm) => {
844
+ if (positions.length < 2)
845
+ return positions;
846
+ let modified = false;
847
+ const result = [];
848
+ for (let i = 0; i < positions.length - 1; i++) {
849
+ const start = positions[i];
850
+ const end = positions[i + 1];
851
+ // start is always defined because i < positions.length - 1
852
+ if (start === undefined || end === undefined)
853
+ continue;
854
+ result.push(start);
855
+ const delta = angularDistance(start[0], start[1], end[0], end[1]);
856
+ const distKm = delta * EARTH_RADIUS_KM;
857
+ if (distKm > maxSegmentKm) {
858
+ modified = true;
859
+ const nSegments = Math.ceil(distKm / maxSegmentKm);
860
+ // Insert nSegments-1 intermediate points (the end point is added by the
861
+ // next iteration, or after the loop for the final edge).
862
+ for (let s = 1; s < nSegments; s++) {
863
+ result.push(intermediatePoint(start, end, s / nSegments, delta));
864
+ }
865
+ }
866
+ }
867
+ // Return the original reference when nothing was inserted — allows callers to
868
+ // detect no-ops via reference equality without allocating a new array.
869
+ if (!modified)
870
+ return positions;
871
+ // Always preserve the original last position exactly.
872
+ const last = positions[positions.length - 1];
873
+ if (last !== undefined)
874
+ result.push(last);
875
+ return result;
876
+ };
877
+ // ============================================================================
878
+ // Feature-level densification
879
+ // ============================================================================
880
+ /**
881
+ * Densify all edge-bearing geometry types in a single GeoJSON feature.
882
+ * Point and MultiPoint features are returned unchanged.
883
+ */
884
+ const densifyFeature = (feature, maxSegmentKm) => {
885
+ const { geometry } = feature;
886
+ if (geometry === null)
887
+ return feature;
888
+ switch (geometry.type) {
889
+ case "Point":
890
+ case "MultiPoint":
891
+ return feature;
892
+ case "LineString": {
893
+ const coords = densifyPositions(geometry.coordinates, maxSegmentKm);
894
+ if (coords === geometry.coordinates)
895
+ return feature;
896
+ return { ...feature, geometry: { ...geometry, coordinates: coords } };
897
+ }
898
+ case "MultiLineString": {
899
+ const lines = geometry.coordinates.map(line => densifyPositions(line, maxSegmentKm));
900
+ if (lines.every((line, i) => line === geometry.coordinates[i]))
901
+ return feature;
902
+ return { ...feature, geometry: { ...geometry, coordinates: lines } };
903
+ }
904
+ case "Polygon": {
905
+ const rings = geometry.coordinates.map(ring => densifyPositions(ring, maxSegmentKm));
906
+ if (rings.every((ring, i) => ring === geometry.coordinates[i]))
907
+ return feature;
908
+ return { ...feature, geometry: { ...geometry, coordinates: rings } };
909
+ }
910
+ case "MultiPolygon": {
911
+ const polygons = geometry.coordinates.map(polygon => polygon.map(ring => densifyPositions(ring, maxSegmentKm)));
912
+ if (polygons.every((polygon, pi) => polygon.every((ring, ri) => ring === geometry.coordinates[pi]?.[ri])))
913
+ return feature;
914
+ return { ...feature, geometry: { ...geometry, coordinates: polygons } };
915
+ }
916
+ default:
917
+ return feature;
918
+ }
919
+ };
920
+ // ============================================================================
921
+ // Public API
922
+ // ============================================================================
923
+ /**
924
+ * Densify GeoJSON features by inserting great-circle intermediate points along
925
+ * polygon/line edges that exceed `maxSegmentKm`.
926
+ *
927
+ * This is the primary way to achieve geodesic rendering in adapters that lack
928
+ * native great-circle support (e.g. Mapbox GL). Adapters with native geodesic
929
+ * support (e.g. Google Maps via `geodesic: true` on `Polygon`/`Polyline`) do
930
+ * not need this function — they should apply the flag directly to their
931
+ * geometry objects instead.
932
+ *
933
+ * Geodesic eligibility is resolved per-feature:
934
+ * `featureStyles.get(id)?.geodesic ?? layerGeodesic ?? true`
935
+ *
936
+ * Features where geodesic resolves to `false` are passed through unchanged.
937
+ * Point and MultiPoint features are always passed through unchanged.
938
+ *
939
+ * The function returns the original collection reference when no feature is
940
+ * modified, avoiding unnecessary downstream work.
941
+ *
942
+ * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
943
+ * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
944
+ * the `atan2` result inside {@link intermediatePoint}.
945
+ *
946
+ * @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
947
+ * @param layerGeodesic - Layer-level geodesic flag (default `true`).
948
+ * @param featureStyles - Per-feature style overrides; `geodesic` is read from each entry.
949
+ * @param maxSegmentKm - Maximum arc-segment length in kilometres. Default: {@link GEODESIC_MAX_SEGMENT_KM}.
950
+ */
951
+ const densifyGeodesicFeatures = (features, layerGeodesic, featureStyles, maxSegmentKm = GEODESIC_MAX_SEGMENT_KM) => {
952
+ const densified = [];
953
+ let anyModified = false;
954
+ for (const feature of features.features) {
955
+ const featureId = feature.id !== undefined ? String(feature.id) : undefined;
956
+ const featureGeodesic = featureId !== undefined ? (featureStyles?.get(featureId)?.geodesic ?? layerGeodesic) : layerGeodesic;
957
+ if (!featureGeodesic) {
958
+ densified.push(feature);
959
+ continue;
960
+ }
961
+ const result = densifyFeature(feature, maxSegmentKm);
962
+ if (result !== feature)
963
+ anyModified = true;
964
+ densified.push(result);
965
+ }
966
+ if (!anyModified)
967
+ return features;
968
+ return { ...features, features: densified };
969
+ };
970
+
763
971
  const isViewportLike = (value) => {
764
972
  return typeof value === "object" && value !== null && "type" in value;
765
973
  };
@@ -2394,4 +2602,4 @@ const resolveStrokeColors = (style, shapeType, theme) => {
2394
2602
  };
2395
2603
  };
2396
2604
 
2397
- export { ANCHOR_SELECTOR, CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX, CIRCLE_SYMBOL_DEFAULT_OPACITY, DEFAULT_CENTER, DEFAULT_MAP_APPEARANCE, DEFAULT_ZOOM, HIT_SURFACE_SELECTOR, INITIAL_CAMERA_STATE, INITIAL_INTERACTION_STATE, INITIAL_MAP_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, LAYER_FADE_DURATION_MS, MAP_CURSORS, MAX_ZOOM, MIN_ZOOM, SAFE_AREA_DEFAULT_BUFFER_PX, SHAPE_STYLE_DEFAULTS, WORLD_BBOX, allocSafeAreaDebugId, anchorFromBottomCenter, attachSafeAreaHoverListeners, bboxEquals, bufferRectCorners, buildAdaptiveDomEntries, buildAdaptiveDomRenderFn, buildAdaptiveSymbolStyleFn, cameraStateEquals, canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, clearSafeArea, collectFeatureIdSet, colorWithOpacity, computeInitialState, computeMarkerDomPortalZIndex, convexHull, createClusterPinElement, createDefaultClusterElement, createSymbolDotElement, darkenColor, defineAdapter, disableSafeAreaDebug, discriminateRenderResult, enableSafeAreaDebug, estimateZoomFromBounds, extractLineCoordinates, extractPointCoordinates, extractPolygonPaths, extractSourceData, fadeInElement, filterRectsByCursorDirection, geometryTypeToShapeType, getAdaptiveDomFeatureIds, getAnchorRect, getEffectiveRestrictBounds, getHitSurfaceRect, hasSameFeatureIds, isCanvasMarkerMode, isEventOfType, isSafeAreaDebugEnabled, lightenColor, mapAppearanceSchema, mapStateEquals, mapThemeSchema, mapTypeSchema, mercatorCenterFromBounds, mergeAntimeridianFeatures, mixColor, patchPortalDescriptors, pointInPolygon, removeGoneIndexedMarkers, renderSafeArea, resetColorUtilsForTesting, resolveCircleSymbolDefaults, resolveHoveredStyle, resolveSelectedStyle, resolveStrokeColors, resolveSymbolDescriptor, safePolygon, validateInitialViewport, watchSafeAreaLeave };
2605
+ export { ANCHOR_SELECTOR, CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX, CIRCLE_SYMBOL_DEFAULT_OPACITY, DEFAULT_CENTER, DEFAULT_MAP_APPEARANCE, DEFAULT_ZOOM, GEODESIC_MAX_SEGMENT_KM, HIT_SURFACE_SELECTOR, INITIAL_CAMERA_STATE, INITIAL_INTERACTION_STATE, INITIAL_MAP_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, LAYER_FADE_DURATION_MS, MAP_CURSORS, MAX_ZOOM, MIN_ZOOM, SAFE_AREA_DEFAULT_BUFFER_PX, SHAPE_STYLE_DEFAULTS, WORLD_BBOX, allocSafeAreaDebugId, anchorFromBottomCenter, angularDistance, attachSafeAreaHoverListeners, bboxEquals, bufferRectCorners, buildAdaptiveDomEntries, buildAdaptiveDomRenderFn, buildAdaptiveSymbolStyleFn, cameraStateEquals, canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, clearSafeArea, collectFeatureIdSet, colorWithOpacity, computeInitialState, computeMarkerDomPortalZIndex, convexHull, createClusterPinElement, createDefaultClusterElement, createSymbolDotElement, darkenColor, defineAdapter, densifyGeodesicFeatures, disableSafeAreaDebug, discriminateRenderResult, enableSafeAreaDebug, estimateZoomFromBounds, extractLineCoordinates, extractPointCoordinates, extractPolygonPaths, extractSourceData, fadeInElement, filterRectsByCursorDirection, geometryTypeToShapeType, getAdaptiveDomFeatureIds, getAnchorRect, getEffectiveRestrictBounds, getHitSurfaceRect, hasSameFeatureIds, intermediatePoint, isCanvasMarkerMode, isEventOfType, isSafeAreaDebugEnabled, lightenColor, mapAppearanceSchema, mapStateEquals, mapThemeSchema, mapTypeSchema, mercatorCenterFromBounds, mergeAntimeridianFeatures, mixColor, patchPortalDescriptors, pointInPolygon, removeGoneIndexedMarkers, renderSafeArea, resetColorUtilsForTesting, resolveCircleSymbolDefaults, resolveHoveredStyle, resolveSelectedStyle, resolveStrokeColors, resolveSymbolDescriptor, safePolygon, toDeg, toRad, validateInitialViewport, watchSafeAreaLeave };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@trackunit/react-map-adapter-shared",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/geo-json-utils": "1.14.31",
11
- "@trackunit/ui-design-tokens": "1.13.29",
10
+ "@trackunit/geo-json-utils": "1.14.32",
11
+ "@trackunit/ui-design-tokens": "1.13.30",
12
12
  "es-toolkit": "^1.39.10",
13
13
  "zod": "^3.25.76"
14
14
  },
@@ -0,0 +1,39 @@
1
+ import type { GeoJsonFeatureCollection } from "@trackunit/geo-json-utils";
2
+ import type { ShapeStyle } from "./layerApiTypes";
3
+ /**
4
+ * Default maximum arc-segment length used by {@link densifyGeodesicFeatures}.
5
+ *
6
+ * At 100 km, a 1 000 km polygon edge gets ~10 intermediate points — enough
7
+ * for a visually smooth great-circle curve at any map zoom level. Reduce only
8
+ * if you need sub-100 km accuracy for very large polygons near the poles.
9
+ */
10
+ export declare const GEODESIC_MAX_SEGMENT_KM = 100;
11
+ /**
12
+ * Densify GeoJSON features by inserting great-circle intermediate points along
13
+ * polygon/line edges that exceed `maxSegmentKm`.
14
+ *
15
+ * This is the primary way to achieve geodesic rendering in adapters that lack
16
+ * native great-circle support (e.g. Mapbox GL). Adapters with native geodesic
17
+ * support (e.g. Google Maps via `geodesic: true` on `Polygon`/`Polyline`) do
18
+ * not need this function — they should apply the flag directly to their
19
+ * geometry objects instead.
20
+ *
21
+ * Geodesic eligibility is resolved per-feature:
22
+ * `featureStyles.get(id)?.geodesic ?? layerGeodesic ?? true`
23
+ *
24
+ * Features where geodesic resolves to `false` are passed through unchanged.
25
+ * Point and MultiPoint features are always passed through unchanged.
26
+ *
27
+ * The function returns the original collection reference when no feature is
28
+ * modified, avoiding unnecessary downstream work.
29
+ *
30
+ * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
31
+ * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
32
+ * the `atan2` result inside {@link intermediatePoint}.
33
+ *
34
+ * @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
35
+ * @param layerGeodesic - Layer-level geodesic flag (default `true`).
36
+ * @param featureStyles - Per-feature style overrides; `geodesic` is read from each entry.
37
+ * @param maxSegmentKm - Maximum arc-segment length in kilometres. Default: {@link GEODESIC_MAX_SEGMENT_KM}.
38
+ */
39
+ export declare const densifyGeodesicFeatures: (features: GeoJsonFeatureCollection, layerGeodesic: boolean, featureStyles: ReadonlyMap<string, ShapeStyle> | undefined, maxSegmentKm?: number) => GeoJsonFeatureCollection;
package/src/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./adapterContract";
2
2
  export * from "./antimeridianMerge";
3
3
  export * from "./colorUtils";
4
4
  export * from "./constants";
5
+ export * from "./geodesicDensify";
5
6
  export * from "./initialViewportValidation";
6
7
  export * from "./interactionTypes";
7
8
  export * from "./layerApiTypes";
@@ -18,4 +19,5 @@ export * from "./safeArea/safeAreaDebug";
18
19
  export * from "./safeArea/safePolygon";
19
20
  export * from "./safeArea/watchSafeAreaLeave";
20
21
  export * from "./shapeStyleDefaults";
22
+ export * from "./sphericalMath";
21
23
  export * from "./types";
@@ -46,6 +46,26 @@ export type ShapeStyleOverrides = Readonly<{
46
46
  export interface ShapeStyle extends ShapeStyleOverrides {
47
47
  /** Radius of point features in pixels. Default: 5 */
48
48
  readonly pointRadius?: number;
49
+ /**
50
+ * Whether polygon/line edges follow great-circle arcs (geodesics) rather than
51
+ * rhumb lines between the given coordinates.
52
+ *
53
+ * This is a semantic property of the shape — both adapters honour it, but via
54
+ * different mechanisms:
55
+ *
56
+ * - **Google Maps adapter**: passes `geodesic: true` directly to
57
+ * `google.maps.Polygon` / `Polyline` (native support).
58
+ * - **Mapbox adapter**: pre-processes the GeoJSON with `densifyGeodesicFeatures`,
59
+ * inserting great-circle intermediate points before the data reaches the source.
60
+ * Mapbox GL has no native geodesic option; densification is the workaround.
61
+ *
62
+ * Set to `false` for shapes whose edges are intentionally straight (e.g. a
63
+ * boundary defined as a rhumb line, or a small urban polygon where the
64
+ * difference is sub-pixel).
65
+ *
66
+ * Default: `true`
67
+ */
68
+ readonly geodesic?: boolean;
49
69
  /** Visual overrides applied when this shape is hovered */
50
70
  readonly hovered?: ShapeStyleOverrides;
51
71
  /** Visual overrides applied when this shape is selected */
@@ -0,0 +1,18 @@
1
+ import type { GeoJsonPosition } from "@trackunit/geo-json-utils";
2
+ export declare const toRad: (deg: number) => number;
3
+ export declare const toDeg: (rad: number) => number;
4
+ /**
5
+ * Haversine angular distance in radians between two lng/lat points.
6
+ * Returns a value in [0, π].
7
+ */
8
+ export declare const angularDistance: (lng1: number, lat1: number, lng2: number, lat2: number) => number;
9
+ /**
10
+ * Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
11
+ * along the great-circle arc defined by `delta` (pre-computed angular distance).
12
+ *
13
+ * Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
14
+ *
15
+ * If `start` carries an altitude the result linearly interpolates it; otherwise
16
+ * the result is 2-D.
17
+ */
18
+ export declare const intermediatePoint: (start: GeoJsonPosition, end: GeoJsonPosition, f: number, delta: number) => GeoJsonPosition;