@trackunit/geo-json-utils 1.15.41 → 1.15.45
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 +203 -8
- package/index.esm.js +201 -9
- package/package.json +1 -1
- package/src/GeoJsonUtils.d.ts +35 -8
package/index.cjs.js
CHANGED
|
@@ -1079,6 +1079,76 @@ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westH
|
|
|
1079
1079
|
}
|
|
1080
1080
|
return true;
|
|
1081
1081
|
};
|
|
1082
|
+
const isSeamJumpDelta = (delta) => {
|
|
1083
|
+
const absDelta = Math.abs(delta);
|
|
1084
|
+
return absDelta > 180 && absDelta < 360;
|
|
1085
|
+
};
|
|
1086
|
+
const ringCrossesAntimeridian = (ring) => ring.some((position, index) => {
|
|
1087
|
+
const next = ring[index + 1];
|
|
1088
|
+
return next !== undefined && isSeamJumpDelta(next[0] - position[0]);
|
|
1089
|
+
});
|
|
1090
|
+
const shiftPositionLongitude = (position, offset) => {
|
|
1091
|
+
const longitude = position[0] + offset;
|
|
1092
|
+
if (position.length === 3) {
|
|
1093
|
+
return [longitude, position[1], position[2]];
|
|
1094
|
+
}
|
|
1095
|
+
return [longitude, position[1]];
|
|
1096
|
+
};
|
|
1097
|
+
/**
|
|
1098
|
+
* Rewrite a ring's longitudes to be continuous across the antimeridian.
|
|
1099
|
+
*
|
|
1100
|
+
* Backend polygons store every longitude in `[-180, 180]`, so a shape spanning
|
|
1101
|
+
* the dateline appears as a single ring with a ±360° jump between two adjacent
|
|
1102
|
+
* vertices (e.g. `170 → -170`). Walking the ring and accumulating a ±360° offset
|
|
1103
|
+
* whenever a jump is detected unwraps those vertices into continuous coordinates
|
|
1104
|
+
* (`170 → 190`), which is the form the unwrapped split path expects.
|
|
1105
|
+
*
|
|
1106
|
+
* `initialOffset` anchors the ring's first vertex to a specific longitude world.
|
|
1107
|
+
* Interior rings pass the offset computed from the exterior so both rings end up
|
|
1108
|
+
* in the same world.
|
|
1109
|
+
*/
|
|
1110
|
+
const unwrapRingLongitudes = (ring, initialOffset = 0) => {
|
|
1111
|
+
let offset = initialOffset;
|
|
1112
|
+
let prevLng;
|
|
1113
|
+
return ring.map(position => {
|
|
1114
|
+
const lng = position[0];
|
|
1115
|
+
if (prevLng !== undefined) {
|
|
1116
|
+
const delta = lng - prevLng;
|
|
1117
|
+
if (delta > 180 && delta < 360) {
|
|
1118
|
+
offset -= 360;
|
|
1119
|
+
}
|
|
1120
|
+
else if (delta < -180 && delta > -360) {
|
|
1121
|
+
offset += 360;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
prevLng = lng;
|
|
1125
|
+
return shiftPositionLongitude(position, offset);
|
|
1126
|
+
});
|
|
1127
|
+
};
|
|
1128
|
+
const HOLE_UNWRAP_OFFSETS = [0, 360, -360];
|
|
1129
|
+
const unwrapSeamJumpingPolygon = (polygon) => {
|
|
1130
|
+
const rings = polygon.coordinates;
|
|
1131
|
+
const exterior = rings[0];
|
|
1132
|
+
if (exterior === undefined) {
|
|
1133
|
+
return polygon;
|
|
1134
|
+
}
|
|
1135
|
+
const unwrappedExterior = unwrapRingLongitudes(exterior);
|
|
1136
|
+
const refLng = unwrappedExterior[0]?.[0] ?? 0;
|
|
1137
|
+
const unwrappedHoles = rings.slice(1).map(hole => {
|
|
1138
|
+
const firstLng = hole[0]?.[0];
|
|
1139
|
+
if (firstLng === undefined) {
|
|
1140
|
+
return unwrapRingLongitudes(hole, 0);
|
|
1141
|
+
}
|
|
1142
|
+
const useOffset = HOLE_UNWRAP_OFFSETS.reduce((best, offset) => {
|
|
1143
|
+
return Math.abs(firstLng + offset - refLng) < Math.abs(firstLng + best - refLng) ? offset : best;
|
|
1144
|
+
}, 0);
|
|
1145
|
+
return unwrapRingLongitudes(hole, useOffset);
|
|
1146
|
+
});
|
|
1147
|
+
return {
|
|
1148
|
+
type: "Polygon",
|
|
1149
|
+
coordinates: [unwrappedExterior, ...unwrappedHoles],
|
|
1150
|
+
};
|
|
1151
|
+
};
|
|
1082
1152
|
/**
|
|
1083
1153
|
* @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
|
|
1084
1154
|
* into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
|
|
@@ -1086,6 +1156,9 @@ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westH
|
|
|
1086
1156
|
* If the exterior does not cross the antimeridian, the input is returned unchanged
|
|
1087
1157
|
* (including any hole coordinates).
|
|
1088
1158
|
*
|
|
1159
|
+
* Exterior rings that stay in `[-180, 180]` but contain a longitude jump wider
|
|
1160
|
+
* than 180° (the form site geofence APIs return) are unwrapped first, then split.
|
|
1161
|
+
*
|
|
1089
1162
|
* Interior rings that lie entirely in [-180, 180] are assigned to exactly one
|
|
1090
1163
|
* shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
|
|
1091
1164
|
* If splitting the exterior or any crossing hole would produce a fragment with
|
|
@@ -1100,7 +1173,12 @@ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
|
|
|
1100
1173
|
const holeRings = polygon.coordinates.slice(1);
|
|
1101
1174
|
const { maxLng, minLng } = ringLongitudeBounds(outerRing);
|
|
1102
1175
|
if (maxLng <= 180 && minLng >= -180) {
|
|
1103
|
-
|
|
1176
|
+
if (!ringCrossesAntimeridian(outerRing)) {
|
|
1177
|
+
return polygon;
|
|
1178
|
+
}
|
|
1179
|
+
const unwrapped = unwrapSeamJumpingPolygon(polygon);
|
|
1180
|
+
const result = splitPolygonWithHolesAtAntimeridian(unwrapped);
|
|
1181
|
+
return result === unwrapped ? polygon : result;
|
|
1104
1182
|
}
|
|
1105
1183
|
const eastHoles = [];
|
|
1106
1184
|
const westHoles = [];
|
|
@@ -1162,17 +1240,89 @@ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
|
|
|
1162
1240
|
* MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
|
|
1163
1241
|
* antimeridian, it is returned unchanged.
|
|
1164
1242
|
*
|
|
1165
|
-
* Accepts unwrapped longitudes (outside [-180, 180]
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
* For use in a future polygon draw mode: the user draws a polygon on the map
|
|
1170
|
-
* with coordinates that may wrap past ±180, and this function produces the
|
|
1171
|
-
* RFC 7946-compliant split representation.
|
|
1243
|
+
* Accepts both unwrapped longitudes (outside [-180, 180], e.g. from a map SDK
|
|
1244
|
+
* after panning past the dateline) and seam-jumping rings that stay in
|
|
1245
|
+
* [-180, 180] (e.g. `170 → -170` from a site geofence API). The output is
|
|
1246
|
+
* always RFC 7946 compliant (all longitudes in [-180, 180]).
|
|
1172
1247
|
*
|
|
1173
1248
|
* Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
|
|
1174
1249
|
*/
|
|
1175
1250
|
const splitPolygonAtAntimeridian = (polygon) => splitPolygonWithHolesAtAntimeridian(polygon);
|
|
1251
|
+
const splitAntimeridianCrossingPolygonMembers = (members) => {
|
|
1252
|
+
let modified = false;
|
|
1253
|
+
const splitMembers = [];
|
|
1254
|
+
for (const coordinates of members) {
|
|
1255
|
+
const polygon = { type: "Polygon", coordinates };
|
|
1256
|
+
const result = splitPolygonAtAntimeridian(polygon);
|
|
1257
|
+
if (result !== polygon) {
|
|
1258
|
+
modified = true;
|
|
1259
|
+
}
|
|
1260
|
+
if (result.type === "Polygon") {
|
|
1261
|
+
splitMembers.push(result.coordinates);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
splitMembers.push(...result.coordinates);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return modified ? splitMembers : null;
|
|
1268
|
+
};
|
|
1269
|
+
/**
|
|
1270
|
+
* Split Polygon / MultiPolygon geometry that crosses the antimeridian into RFC
|
|
1271
|
+
* 7946 MultiPolygon halves. GeometryCollection children are split recursively.
|
|
1272
|
+
* Other geometry types are returned unchanged.
|
|
1273
|
+
*
|
|
1274
|
+
* Seam-jumping rings in `[-180, 180]` and already-unwrapped rings are both
|
|
1275
|
+
* handled. Already-split halves that do not themselves jump the seam are
|
|
1276
|
+
* returned by reference.
|
|
1277
|
+
*/
|
|
1278
|
+
const splitAntimeridianCrossingGeometry = (geometry) => {
|
|
1279
|
+
if (geometry.type === "Polygon") {
|
|
1280
|
+
return splitPolygonAtAntimeridian(geometry);
|
|
1281
|
+
}
|
|
1282
|
+
if (geometry.type === "MultiPolygon") {
|
|
1283
|
+
const splitMembers = splitAntimeridianCrossingPolygonMembers(geometry.coordinates);
|
|
1284
|
+
if (splitMembers === null) {
|
|
1285
|
+
return geometry;
|
|
1286
|
+
}
|
|
1287
|
+
return { type: "MultiPolygon", coordinates: splitMembers };
|
|
1288
|
+
}
|
|
1289
|
+
if (geometry.type === "GeometryCollection") {
|
|
1290
|
+
let modified = false;
|
|
1291
|
+
const geometries = [];
|
|
1292
|
+
for (const child of geometry.geometries) {
|
|
1293
|
+
const split = splitAntimeridianCrossingGeometry(child);
|
|
1294
|
+
if (split !== child) {
|
|
1295
|
+
modified = true;
|
|
1296
|
+
}
|
|
1297
|
+
geometries.push(split);
|
|
1298
|
+
}
|
|
1299
|
+
return modified ? { ...geometry, geometries } : geometry;
|
|
1300
|
+
}
|
|
1301
|
+
return geometry;
|
|
1302
|
+
};
|
|
1303
|
+
/**
|
|
1304
|
+
* RFC-canonicalize every polygonal feature that crosses the antimeridian.
|
|
1305
|
+
* Returns the input collection when nothing changed.
|
|
1306
|
+
*/
|
|
1307
|
+
const splitAntimeridianCrossingFeatures = (collection) => {
|
|
1308
|
+
let modified = false;
|
|
1309
|
+
const features = [];
|
|
1310
|
+
for (const feature of collection.features) {
|
|
1311
|
+
const sourceGeometry = feature.geometry;
|
|
1312
|
+
if (sourceGeometry === null) {
|
|
1313
|
+
features.push(feature);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
const geometry = splitAntimeridianCrossingGeometry(sourceGeometry);
|
|
1317
|
+
if (geometry === sourceGeometry) {
|
|
1318
|
+
features.push(feature);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
modified = true;
|
|
1322
|
+
features.push({ ...feature, geometry });
|
|
1323
|
+
}
|
|
1324
|
+
return modified ? { ...collection, features } : collection;
|
|
1325
|
+
};
|
|
1176
1326
|
/**
|
|
1177
1327
|
* @description Gets the extreme point of a polygon in a given direction.
|
|
1178
1328
|
* @param {object} params - The parameters object
|
|
@@ -1397,6 +1547,48 @@ const padGeoJsonBbox = (bbox, padding) => {
|
|
|
1397
1547
|
const [west, east] = getBboxLongitudesFromUnwrappedRange({ west: paddedWest, east: paddedEast });
|
|
1398
1548
|
return [west, clampedSouth, east, clampedNorth];
|
|
1399
1549
|
};
|
|
1550
|
+
/**
|
|
1551
|
+
* Expands any bbox axis narrower than `minSpanDegrees` to that span around the axis midpoint.
|
|
1552
|
+
* A single point (or a set of co-located points) produces a zero-area bbox, which
|
|
1553
|
+
* `geoJsonBboxSchema` rejects — north must be *strictly* greater than south (RFC 7946 §5.2) —
|
|
1554
|
+
* so map `fitBounds` calls silently no-op on it. Latitudes are clamped to ±90 after padding,
|
|
1555
|
+
* shifting the window back inside the valid range so the span is preserved even at the poles.
|
|
1556
|
+
* Longitudes use the unwrapped span (so a narrow antimeridian-crossing strip is padded too)
|
|
1557
|
+
* and are wrapped into `[-180, 180]` the same way as {@link padGeoJsonBbox}.
|
|
1558
|
+
*
|
|
1559
|
+
* Wide bboxes are returned as-is (same reference).
|
|
1560
|
+
*/
|
|
1561
|
+
const ensureMinimumGeoJsonBboxSpan = (bbox, minSpanDegrees) => {
|
|
1562
|
+
const [, minLat, , maxLat] = bbox;
|
|
1563
|
+
const { west: unwrappedWest, east: unwrappedEast, span: lngSpan } = getUnwrappedBboxLongitudeRange(bbox);
|
|
1564
|
+
const lngSpanTooSmall = lngSpan < minSpanDegrees;
|
|
1565
|
+
const latSpanTooSmall = maxLat - minLat < minSpanDegrees;
|
|
1566
|
+
if (!lngSpanTooSmall && !latSpanTooSmall) {
|
|
1567
|
+
return bbox;
|
|
1568
|
+
}
|
|
1569
|
+
const half = minSpanDegrees / 2;
|
|
1570
|
+
let [south, north] = [minLat, maxLat];
|
|
1571
|
+
if (latSpanTooSmall) {
|
|
1572
|
+
const midLat = (minLat + maxLat) / 2;
|
|
1573
|
+
south = midLat - half;
|
|
1574
|
+
north = midLat + half;
|
|
1575
|
+
if (north > MAX_LAT) {
|
|
1576
|
+
south -= north - MAX_LAT;
|
|
1577
|
+
north = MAX_LAT;
|
|
1578
|
+
}
|
|
1579
|
+
if (south < MIN_LAT) {
|
|
1580
|
+
north += MIN_LAT - south;
|
|
1581
|
+
south = MIN_LAT;
|
|
1582
|
+
north = Math.min(north, MAX_LAT);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
if (!lngSpanTooSmall) {
|
|
1586
|
+
return [bbox[0], south, bbox[2], north];
|
|
1587
|
+
}
|
|
1588
|
+
const midLng = (unwrappedWest + unwrappedEast) / 2;
|
|
1589
|
+
const [west, east] = getBboxLongitudesFromUnwrappedRange({ west: midLng - half, east: midLng + half });
|
|
1590
|
+
return [west, south, east, north];
|
|
1591
|
+
};
|
|
1400
1592
|
/**
|
|
1401
1593
|
* Computes the difference of subject minus the union of all clips.
|
|
1402
1594
|
* Returns null when the subject is fully covered (zero remaining area).
|
|
@@ -1917,6 +2109,7 @@ exports.coordinatesToStandardFormat = coordinatesToStandardFormat;
|
|
|
1917
2109
|
exports.denormalizeLongitude = denormalizeLongitude;
|
|
1918
2110
|
exports.distanceToGeoJsonPolygonBoundary = distanceToGeoJsonPolygonBoundary;
|
|
1919
2111
|
exports.edgePixelLength = edgePixelLength;
|
|
2112
|
+
exports.ensureMinimumGeoJsonBboxSpan = ensureMinimumGeoJsonBboxSpan;
|
|
1920
2113
|
exports.extractEdges = extractEdges;
|
|
1921
2114
|
exports.extractFirstPointCoordinate = extractFirstPointCoordinate;
|
|
1922
2115
|
exports.extractPositionsFromGeometry = extractPositionsFromGeometry;
|
|
@@ -1964,6 +2157,8 @@ exports.pixelsToLatDegrees = pixelsToLatDegrees;
|
|
|
1964
2157
|
exports.projectLngLatToWebMercator = projectLngLatToWebMercator;
|
|
1965
2158
|
exports.projectPolygonalToWebMercator = projectPolygonalToWebMercator;
|
|
1966
2159
|
exports.scaleGeoJsonBbox = scaleGeoJsonBbox;
|
|
2160
|
+
exports.splitAntimeridianCrossingFeatures = splitAntimeridianCrossingFeatures;
|
|
2161
|
+
exports.splitAntimeridianCrossingGeometry = splitAntimeridianCrossingGeometry;
|
|
1967
2162
|
exports.splitPolygonAtAntimeridian = splitPolygonAtAntimeridian;
|
|
1968
2163
|
exports.splitPolygonWithHolesAtAntimeridian = splitPolygonWithHolesAtAntimeridian;
|
|
1969
2164
|
exports.toFeatureCollection = toFeatureCollection;
|
package/index.esm.js
CHANGED
|
@@ -1077,6 +1077,76 @@ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westH
|
|
|
1077
1077
|
}
|
|
1078
1078
|
return true;
|
|
1079
1079
|
};
|
|
1080
|
+
const isSeamJumpDelta = (delta) => {
|
|
1081
|
+
const absDelta = Math.abs(delta);
|
|
1082
|
+
return absDelta > 180 && absDelta < 360;
|
|
1083
|
+
};
|
|
1084
|
+
const ringCrossesAntimeridian = (ring) => ring.some((position, index) => {
|
|
1085
|
+
const next = ring[index + 1];
|
|
1086
|
+
return next !== undefined && isSeamJumpDelta(next[0] - position[0]);
|
|
1087
|
+
});
|
|
1088
|
+
const shiftPositionLongitude = (position, offset) => {
|
|
1089
|
+
const longitude = position[0] + offset;
|
|
1090
|
+
if (position.length === 3) {
|
|
1091
|
+
return [longitude, position[1], position[2]];
|
|
1092
|
+
}
|
|
1093
|
+
return [longitude, position[1]];
|
|
1094
|
+
};
|
|
1095
|
+
/**
|
|
1096
|
+
* Rewrite a ring's longitudes to be continuous across the antimeridian.
|
|
1097
|
+
*
|
|
1098
|
+
* Backend polygons store every longitude in `[-180, 180]`, so a shape spanning
|
|
1099
|
+
* the dateline appears as a single ring with a ±360° jump between two adjacent
|
|
1100
|
+
* vertices (e.g. `170 → -170`). Walking the ring and accumulating a ±360° offset
|
|
1101
|
+
* whenever a jump is detected unwraps those vertices into continuous coordinates
|
|
1102
|
+
* (`170 → 190`), which is the form the unwrapped split path expects.
|
|
1103
|
+
*
|
|
1104
|
+
* `initialOffset` anchors the ring's first vertex to a specific longitude world.
|
|
1105
|
+
* Interior rings pass the offset computed from the exterior so both rings end up
|
|
1106
|
+
* in the same world.
|
|
1107
|
+
*/
|
|
1108
|
+
const unwrapRingLongitudes = (ring, initialOffset = 0) => {
|
|
1109
|
+
let offset = initialOffset;
|
|
1110
|
+
let prevLng;
|
|
1111
|
+
return ring.map(position => {
|
|
1112
|
+
const lng = position[0];
|
|
1113
|
+
if (prevLng !== undefined) {
|
|
1114
|
+
const delta = lng - prevLng;
|
|
1115
|
+
if (delta > 180 && delta < 360) {
|
|
1116
|
+
offset -= 360;
|
|
1117
|
+
}
|
|
1118
|
+
else if (delta < -180 && delta > -360) {
|
|
1119
|
+
offset += 360;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
prevLng = lng;
|
|
1123
|
+
return shiftPositionLongitude(position, offset);
|
|
1124
|
+
});
|
|
1125
|
+
};
|
|
1126
|
+
const HOLE_UNWRAP_OFFSETS = [0, 360, -360];
|
|
1127
|
+
const unwrapSeamJumpingPolygon = (polygon) => {
|
|
1128
|
+
const rings = polygon.coordinates;
|
|
1129
|
+
const exterior = rings[0];
|
|
1130
|
+
if (exterior === undefined) {
|
|
1131
|
+
return polygon;
|
|
1132
|
+
}
|
|
1133
|
+
const unwrappedExterior = unwrapRingLongitudes(exterior);
|
|
1134
|
+
const refLng = unwrappedExterior[0]?.[0] ?? 0;
|
|
1135
|
+
const unwrappedHoles = rings.slice(1).map(hole => {
|
|
1136
|
+
const firstLng = hole[0]?.[0];
|
|
1137
|
+
if (firstLng === undefined) {
|
|
1138
|
+
return unwrapRingLongitudes(hole, 0);
|
|
1139
|
+
}
|
|
1140
|
+
const useOffset = HOLE_UNWRAP_OFFSETS.reduce((best, offset) => {
|
|
1141
|
+
return Math.abs(firstLng + offset - refLng) < Math.abs(firstLng + best - refLng) ? offset : best;
|
|
1142
|
+
}, 0);
|
|
1143
|
+
return unwrapRingLongitudes(hole, useOffset);
|
|
1144
|
+
});
|
|
1145
|
+
return {
|
|
1146
|
+
type: "Polygon",
|
|
1147
|
+
coordinates: [unwrappedExterior, ...unwrappedHoles],
|
|
1148
|
+
};
|
|
1149
|
+
};
|
|
1080
1150
|
/**
|
|
1081
1151
|
* @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
|
|
1082
1152
|
* into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
|
|
@@ -1084,6 +1154,9 @@ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westH
|
|
|
1084
1154
|
* If the exterior does not cross the antimeridian, the input is returned unchanged
|
|
1085
1155
|
* (including any hole coordinates).
|
|
1086
1156
|
*
|
|
1157
|
+
* Exterior rings that stay in `[-180, 180]` but contain a longitude jump wider
|
|
1158
|
+
* than 180° (the form site geofence APIs return) are unwrapped first, then split.
|
|
1159
|
+
*
|
|
1087
1160
|
* Interior rings that lie entirely in [-180, 180] are assigned to exactly one
|
|
1088
1161
|
* shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
|
|
1089
1162
|
* If splitting the exterior or any crossing hole would produce a fragment with
|
|
@@ -1098,7 +1171,12 @@ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
|
|
|
1098
1171
|
const holeRings = polygon.coordinates.slice(1);
|
|
1099
1172
|
const { maxLng, minLng } = ringLongitudeBounds(outerRing);
|
|
1100
1173
|
if (maxLng <= 180 && minLng >= -180) {
|
|
1101
|
-
|
|
1174
|
+
if (!ringCrossesAntimeridian(outerRing)) {
|
|
1175
|
+
return polygon;
|
|
1176
|
+
}
|
|
1177
|
+
const unwrapped = unwrapSeamJumpingPolygon(polygon);
|
|
1178
|
+
const result = splitPolygonWithHolesAtAntimeridian(unwrapped);
|
|
1179
|
+
return result === unwrapped ? polygon : result;
|
|
1102
1180
|
}
|
|
1103
1181
|
const eastHoles = [];
|
|
1104
1182
|
const westHoles = [];
|
|
@@ -1160,17 +1238,89 @@ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
|
|
|
1160
1238
|
* MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
|
|
1161
1239
|
* antimeridian, it is returned unchanged.
|
|
1162
1240
|
*
|
|
1163
|
-
* Accepts unwrapped longitudes (outside [-180, 180]
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
* For use in a future polygon draw mode: the user draws a polygon on the map
|
|
1168
|
-
* with coordinates that may wrap past ±180, and this function produces the
|
|
1169
|
-
* RFC 7946-compliant split representation.
|
|
1241
|
+
* Accepts both unwrapped longitudes (outside [-180, 180], e.g. from a map SDK
|
|
1242
|
+
* after panning past the dateline) and seam-jumping rings that stay in
|
|
1243
|
+
* [-180, 180] (e.g. `170 → -170` from a site geofence API). The output is
|
|
1244
|
+
* always RFC 7946 compliant (all longitudes in [-180, 180]).
|
|
1170
1245
|
*
|
|
1171
1246
|
* Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
|
|
1172
1247
|
*/
|
|
1173
1248
|
const splitPolygonAtAntimeridian = (polygon) => splitPolygonWithHolesAtAntimeridian(polygon);
|
|
1249
|
+
const splitAntimeridianCrossingPolygonMembers = (members) => {
|
|
1250
|
+
let modified = false;
|
|
1251
|
+
const splitMembers = [];
|
|
1252
|
+
for (const coordinates of members) {
|
|
1253
|
+
const polygon = { type: "Polygon", coordinates };
|
|
1254
|
+
const result = splitPolygonAtAntimeridian(polygon);
|
|
1255
|
+
if (result !== polygon) {
|
|
1256
|
+
modified = true;
|
|
1257
|
+
}
|
|
1258
|
+
if (result.type === "Polygon") {
|
|
1259
|
+
splitMembers.push(result.coordinates);
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
splitMembers.push(...result.coordinates);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return modified ? splitMembers : null;
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Split Polygon / MultiPolygon geometry that crosses the antimeridian into RFC
|
|
1269
|
+
* 7946 MultiPolygon halves. GeometryCollection children are split recursively.
|
|
1270
|
+
* Other geometry types are returned unchanged.
|
|
1271
|
+
*
|
|
1272
|
+
* Seam-jumping rings in `[-180, 180]` and already-unwrapped rings are both
|
|
1273
|
+
* handled. Already-split halves that do not themselves jump the seam are
|
|
1274
|
+
* returned by reference.
|
|
1275
|
+
*/
|
|
1276
|
+
const splitAntimeridianCrossingGeometry = (geometry) => {
|
|
1277
|
+
if (geometry.type === "Polygon") {
|
|
1278
|
+
return splitPolygonAtAntimeridian(geometry);
|
|
1279
|
+
}
|
|
1280
|
+
if (geometry.type === "MultiPolygon") {
|
|
1281
|
+
const splitMembers = splitAntimeridianCrossingPolygonMembers(geometry.coordinates);
|
|
1282
|
+
if (splitMembers === null) {
|
|
1283
|
+
return geometry;
|
|
1284
|
+
}
|
|
1285
|
+
return { type: "MultiPolygon", coordinates: splitMembers };
|
|
1286
|
+
}
|
|
1287
|
+
if (geometry.type === "GeometryCollection") {
|
|
1288
|
+
let modified = false;
|
|
1289
|
+
const geometries = [];
|
|
1290
|
+
for (const child of geometry.geometries) {
|
|
1291
|
+
const split = splitAntimeridianCrossingGeometry(child);
|
|
1292
|
+
if (split !== child) {
|
|
1293
|
+
modified = true;
|
|
1294
|
+
}
|
|
1295
|
+
geometries.push(split);
|
|
1296
|
+
}
|
|
1297
|
+
return modified ? { ...geometry, geometries } : geometry;
|
|
1298
|
+
}
|
|
1299
|
+
return geometry;
|
|
1300
|
+
};
|
|
1301
|
+
/**
|
|
1302
|
+
* RFC-canonicalize every polygonal feature that crosses the antimeridian.
|
|
1303
|
+
* Returns the input collection when nothing changed.
|
|
1304
|
+
*/
|
|
1305
|
+
const splitAntimeridianCrossingFeatures = (collection) => {
|
|
1306
|
+
let modified = false;
|
|
1307
|
+
const features = [];
|
|
1308
|
+
for (const feature of collection.features) {
|
|
1309
|
+
const sourceGeometry = feature.geometry;
|
|
1310
|
+
if (sourceGeometry === null) {
|
|
1311
|
+
features.push(feature);
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const geometry = splitAntimeridianCrossingGeometry(sourceGeometry);
|
|
1315
|
+
if (geometry === sourceGeometry) {
|
|
1316
|
+
features.push(feature);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
modified = true;
|
|
1320
|
+
features.push({ ...feature, geometry });
|
|
1321
|
+
}
|
|
1322
|
+
return modified ? { ...collection, features } : collection;
|
|
1323
|
+
};
|
|
1174
1324
|
/**
|
|
1175
1325
|
* @description Gets the extreme point of a polygon in a given direction.
|
|
1176
1326
|
* @param {object} params - The parameters object
|
|
@@ -1395,6 +1545,48 @@ const padGeoJsonBbox = (bbox, padding) => {
|
|
|
1395
1545
|
const [west, east] = getBboxLongitudesFromUnwrappedRange({ west: paddedWest, east: paddedEast });
|
|
1396
1546
|
return [west, clampedSouth, east, clampedNorth];
|
|
1397
1547
|
};
|
|
1548
|
+
/**
|
|
1549
|
+
* Expands any bbox axis narrower than `minSpanDegrees` to that span around the axis midpoint.
|
|
1550
|
+
* A single point (or a set of co-located points) produces a zero-area bbox, which
|
|
1551
|
+
* `geoJsonBboxSchema` rejects — north must be *strictly* greater than south (RFC 7946 §5.2) —
|
|
1552
|
+
* so map `fitBounds` calls silently no-op on it. Latitudes are clamped to ±90 after padding,
|
|
1553
|
+
* shifting the window back inside the valid range so the span is preserved even at the poles.
|
|
1554
|
+
* Longitudes use the unwrapped span (so a narrow antimeridian-crossing strip is padded too)
|
|
1555
|
+
* and are wrapped into `[-180, 180]` the same way as {@link padGeoJsonBbox}.
|
|
1556
|
+
*
|
|
1557
|
+
* Wide bboxes are returned as-is (same reference).
|
|
1558
|
+
*/
|
|
1559
|
+
const ensureMinimumGeoJsonBboxSpan = (bbox, minSpanDegrees) => {
|
|
1560
|
+
const [, minLat, , maxLat] = bbox;
|
|
1561
|
+
const { west: unwrappedWest, east: unwrappedEast, span: lngSpan } = getUnwrappedBboxLongitudeRange(bbox);
|
|
1562
|
+
const lngSpanTooSmall = lngSpan < minSpanDegrees;
|
|
1563
|
+
const latSpanTooSmall = maxLat - minLat < minSpanDegrees;
|
|
1564
|
+
if (!lngSpanTooSmall && !latSpanTooSmall) {
|
|
1565
|
+
return bbox;
|
|
1566
|
+
}
|
|
1567
|
+
const half = minSpanDegrees / 2;
|
|
1568
|
+
let [south, north] = [minLat, maxLat];
|
|
1569
|
+
if (latSpanTooSmall) {
|
|
1570
|
+
const midLat = (minLat + maxLat) / 2;
|
|
1571
|
+
south = midLat - half;
|
|
1572
|
+
north = midLat + half;
|
|
1573
|
+
if (north > MAX_LAT) {
|
|
1574
|
+
south -= north - MAX_LAT;
|
|
1575
|
+
north = MAX_LAT;
|
|
1576
|
+
}
|
|
1577
|
+
if (south < MIN_LAT) {
|
|
1578
|
+
north += MIN_LAT - south;
|
|
1579
|
+
south = MIN_LAT;
|
|
1580
|
+
north = Math.min(north, MAX_LAT);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (!lngSpanTooSmall) {
|
|
1584
|
+
return [bbox[0], south, bbox[2], north];
|
|
1585
|
+
}
|
|
1586
|
+
const midLng = (unwrappedWest + unwrappedEast) / 2;
|
|
1587
|
+
const [west, east] = getBboxLongitudesFromUnwrappedRange({ west: midLng - half, east: midLng + half });
|
|
1588
|
+
return [west, south, east, north];
|
|
1589
|
+
};
|
|
1398
1590
|
/**
|
|
1399
1591
|
* Computes the difference of subject minus the union of all clips.
|
|
1400
1592
|
* Returns null when the subject is fully covered (zero remaining area).
|
|
@@ -1906,4 +2098,4 @@ const edgePixelLength = (x0, y0, x1, y1, zoom, midLatDeg, tileSize = 256) => {
|
|
|
1906
2098
|
return Math.sqrt(dxPx * dxPx + dyPx * dyPx);
|
|
1907
2099
|
};
|
|
1908
2100
|
|
|
1909
|
-
export { EARTH_RADIUS, EMPTY_FEATURE_COLLECTION, boundingBoxCrossesMeridian, checkCrossesMeridian, computeGeometryCentroid, coordinatesToStandardFormat, denormalizeLongitude, distanceToGeoJsonPolygonBoundary, edgePixelLength, extractEdges, extractFirstPointCoordinate, extractPositionsFromGeometry, geoJsonBboxSchema, geoJsonFeatureCollectionSchema, geoJsonFeatureSchema, geoJsonGeometryCollectionSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonDifference, geoJsonPolygonSchema, geoJsonPosition2dSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonBbox, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isBboxInsideFeatureCollection, isFullyContainedInGeoJsonGeometry, isFullyContainedInGeoJsonPolygon, isFullyContainedInGeometry, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, isPointInPolygon, isPositionInsideRing, lngLatToMercatorPxWS, lngLatToWebMercatorPx, mercatorPxToLngLatWS, normalizeLongitudes, padGeoJsonBbox, pixelsToLatDegrees, projectLngLatToWebMercator, projectPolygonalToWebMercator, scaleGeoJsonBbox, splitPolygonAtAntimeridian, splitPolygonWithHolesAtAntimeridian, toFeatureCollection, toPosition2d, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema, unprojectPolygonalFromWebMercator, unprojectWebMercatorToLngLat, validateBbox, validateBboxWithFallback, validateFeatureCollection, validatePosition, webMercatorPxToLngLat };
|
|
2101
|
+
export { EARTH_RADIUS, EMPTY_FEATURE_COLLECTION, boundingBoxCrossesMeridian, checkCrossesMeridian, computeGeometryCentroid, coordinatesToStandardFormat, denormalizeLongitude, distanceToGeoJsonPolygonBoundary, edgePixelLength, ensureMinimumGeoJsonBboxSpan, extractEdges, extractFirstPointCoordinate, extractPositionsFromGeometry, geoJsonBboxSchema, geoJsonFeatureCollectionSchema, geoJsonFeatureSchema, geoJsonGeometryCollectionSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonDifference, geoJsonPolygonSchema, geoJsonPosition2dSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonBbox, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isBboxInsideFeatureCollection, isFullyContainedInGeoJsonGeometry, isFullyContainedInGeoJsonPolygon, isFullyContainedInGeometry, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, isPointInPolygon, isPositionInsideRing, lngLatToMercatorPxWS, lngLatToWebMercatorPx, mercatorPxToLngLatWS, normalizeLongitudes, padGeoJsonBbox, pixelsToLatDegrees, projectLngLatToWebMercator, projectPolygonalToWebMercator, scaleGeoJsonBbox, splitAntimeridianCrossingFeatures, splitAntimeridianCrossingGeometry, splitPolygonAtAntimeridian, splitPolygonWithHolesAtAntimeridian, toFeatureCollection, toPosition2d, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema, unprojectPolygonalFromWebMercator, unprojectWebMercatorToLngLat, validateBbox, validateBboxWithFallback, validateFeatureCollection, validatePosition, webMercatorPxToLngLat };
|
package/package.json
CHANGED
package/src/GeoJsonUtils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GeoJsonBbox, GeoJsonLinearRing, GeoJsonMultiPolygon, GeoJsonPoint, GeoJsonPolygon, GeoJsonPosition } from "./GeoJsonSchemas";
|
|
1
|
+
import { GeoJsonBbox, GeoJsonFeatureCollection, GeoJsonGeometry, GeoJsonLinearRing, GeoJsonMultiPolygon, GeoJsonPoint, GeoJsonPolygon, GeoJsonPosition } from "./GeoJsonSchemas";
|
|
2
2
|
export declare const EARTH_RADIUS = 6378137;
|
|
3
3
|
/**
|
|
4
4
|
* @description Creates a polygon from a bounding box.
|
|
@@ -39,6 +39,9 @@ export declare const getPolygonFromPointAndRadius: (point: GeoJsonPoint, radius:
|
|
|
39
39
|
* If the exterior does not cross the antimeridian, the input is returned unchanged
|
|
40
40
|
* (including any hole coordinates).
|
|
41
41
|
*
|
|
42
|
+
* Exterior rings that stay in `[-180, 180]` but contain a longitude jump wider
|
|
43
|
+
* than 180° (the form site geofence APIs return) are unwrapped first, then split.
|
|
44
|
+
*
|
|
42
45
|
* Interior rings that lie entirely in [-180, 180] are assigned to exactly one
|
|
43
46
|
* shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
|
|
44
47
|
* If splitting the exterior or any crossing hole would produce a fragment with
|
|
@@ -51,17 +54,29 @@ export declare const splitPolygonWithHolesAtAntimeridian: (polygon: GeoJsonPolyg
|
|
|
51
54
|
* MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
|
|
52
55
|
* antimeridian, it is returned unchanged.
|
|
53
56
|
*
|
|
54
|
-
* Accepts unwrapped longitudes (outside [-180, 180]
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* For use in a future polygon draw mode: the user draws a polygon on the map
|
|
59
|
-
* with coordinates that may wrap past ±180, and this function produces the
|
|
60
|
-
* RFC 7946-compliant split representation.
|
|
57
|
+
* Accepts both unwrapped longitudes (outside [-180, 180], e.g. from a map SDK
|
|
58
|
+
* after panning past the dateline) and seam-jumping rings that stay in
|
|
59
|
+
* [-180, 180] (e.g. `170 → -170` from a site geofence API). The output is
|
|
60
|
+
* always RFC 7946 compliant (all longitudes in [-180, 180]).
|
|
61
61
|
*
|
|
62
62
|
* Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
|
|
63
63
|
*/
|
|
64
64
|
export declare const splitPolygonAtAntimeridian: (polygon: GeoJsonPolygon) => GeoJsonPolygon | GeoJsonMultiPolygon;
|
|
65
|
+
/**
|
|
66
|
+
* Split Polygon / MultiPolygon geometry that crosses the antimeridian into RFC
|
|
67
|
+
* 7946 MultiPolygon halves. GeometryCollection children are split recursively.
|
|
68
|
+
* Other geometry types are returned unchanged.
|
|
69
|
+
*
|
|
70
|
+
* Seam-jumping rings in `[-180, 180]` and already-unwrapped rings are both
|
|
71
|
+
* handled. Already-split halves that do not themselves jump the seam are
|
|
72
|
+
* returned by reference.
|
|
73
|
+
*/
|
|
74
|
+
export declare const splitAntimeridianCrossingGeometry: (geometry: GeoJsonGeometry) => GeoJsonGeometry;
|
|
75
|
+
/**
|
|
76
|
+
* RFC-canonicalize every polygonal feature that crosses the antimeridian.
|
|
77
|
+
* Returns the input collection when nothing changed.
|
|
78
|
+
*/
|
|
79
|
+
export declare const splitAntimeridianCrossingFeatures: (collection: GeoJsonFeatureCollection) => GeoJsonFeatureCollection;
|
|
65
80
|
/**
|
|
66
81
|
* @description Gets the extreme point of a polygon in a given direction.
|
|
67
82
|
* @param {object} params - The parameters object
|
|
@@ -146,6 +161,18 @@ export type BboxPadding = number | {
|
|
|
146
161
|
* Latitudes are clamped to `[-90, 90]`, longitudes to `[-180, 180]`.
|
|
147
162
|
*/
|
|
148
163
|
export declare const padGeoJsonBbox: (bbox: GeoJsonBbox, padding: BboxPadding) => GeoJsonBbox;
|
|
164
|
+
/**
|
|
165
|
+
* Expands any bbox axis narrower than `minSpanDegrees` to that span around the axis midpoint.
|
|
166
|
+
* A single point (or a set of co-located points) produces a zero-area bbox, which
|
|
167
|
+
* `geoJsonBboxSchema` rejects — north must be *strictly* greater than south (RFC 7946 §5.2) —
|
|
168
|
+
* so map `fitBounds` calls silently no-op on it. Latitudes are clamped to ±90 after padding,
|
|
169
|
+
* shifting the window back inside the valid range so the span is preserved even at the poles.
|
|
170
|
+
* Longitudes use the unwrapped span (so a narrow antimeridian-crossing strip is padded too)
|
|
171
|
+
* and are wrapped into `[-180, 180]` the same way as {@link padGeoJsonBbox}.
|
|
172
|
+
*
|
|
173
|
+
* Wide bboxes are returned as-is (same reference).
|
|
174
|
+
*/
|
|
175
|
+
export declare const ensureMinimumGeoJsonBboxSpan: (bbox: GeoJsonBbox, minSpanDegrees: number) => GeoJsonBbox;
|
|
149
176
|
/**
|
|
150
177
|
* Computes the difference of subject minus the union of all clips.
|
|
151
178
|
* Returns null when the subject is fully covered (zero remaining area).
|