@trackunit/react-map-adapter-shared 0.0.11 → 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 +214 -0
- package/index.esm.js +209 -1
- package/package.json +1 -1
- package/src/geodesicDensify.d.ts +39 -0
- package/src/index.d.ts +2 -0
- package/src/layerApiTypes.d.ts +20 -0
- package/src/sphericalMath.d.ts +18 -0
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
|
@@ -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";
|
package/src/layerApiTypes.d.ts
CHANGED
|
@@ -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;
|