diff --git a/README.md b/README.md
index 9e3a219..09903f4 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://swiftpackageindex.com/maparoni/GeoJSONKit-Turf)
[](https://swiftpackageindex.com/maparoni/GeoJSONKit-Turf)
-This package provides various geospatial extensions for [GeoJSONKit](https://github.com/maparoni/geojsonkit). It is a fork of [turf-swift](https://github.com/mapbox/turf-swift.git), which is ported from [Turf.js](https://github.com/Turfjs/turf/).
+This package provides various geospatial extensions for [GeoJSONKit](https://github.com/maparoni/geojsonkit). It is a fork of [turf-swift](https://github.com/mapbox/turf-swift.git), which itself is a partial Swift-port of [Turf.js](https://github.com/Turfjs/turf/).
## Requirements
@@ -13,6 +13,7 @@ GeoJSONKitTurf requires Xcode 14.x and supports the following minimum deployment
- iOS 15 and above
- macOS 12 and above
- tvOS 15 and above
+- visionOS 1.0 and above
- watchOS 8.0 and above
It's also compatible with Linux (and possibly other platforms), as long as you have [Swift](https://swift.org/download/) 5.7 (or above) installed.
@@ -24,7 +25,7 @@ It's also compatible with Linux (and possibly other platforms), as long as you h
To install GeoJSONKitTurf using the [Swift Package Manager](https://swift.org/package-manager/), add the following package to the `dependencies` in your Package.swift file:
```swift
-.package(name: "GeoJSONKitTurf", url: "https://github.com/maparoni/geojsonkit-turf", from: "0.1.0")
+.package(name: "GeoJSONKitTurf", url: "https://github.com/maparoni/geojsonkit-turf", from: "0.3.0")
```
Then use:
@@ -42,7 +43,7 @@ Turf.js | GeoJSONKit-Turf
----|----
[turf-along](https://github.com/Turfjs/turf/tree/master/packages/turf-along/) | `GeoJSON.LineString.coordinateFromStart(distance:)`
[turf-area](https://github.com/Turfjs/turf/blob/master/packages/turf-area/) | `GeoJSON.Polygon.area`
-[turf-bbox-clip](https://turfjs.org/docs/#bboxClip) | `GeoJSON.Polygon.clip(to:)`
+[turf-bbox-clip](https://turfjs.org/docs/#bboxClip) | `GeoJSON.LineString.clipped(to:)`
`GeoJSON.Polygon.clipped(to:)`
[turf-bearing](https://turfjs.org/docs/#bearing) | `GeoJSON.Position.direction(to:)`
`RadianCoordinate2D.direction(to:)`
[turf-bezier-spline](https://github.com/Turfjs/turf/tree/master/packages/turf-bezier-spline/) | `GeoJSON.LineString.bezier(resolution:sharpness:)`
[turf-boolean-point-in-polygon](https://github.com/Turfjs/turf/tree/master/packages/turf-boolean-point-in-polygon) | `GeoJSON.Polygon.contains(_:)`
@@ -57,6 +58,7 @@ Turf.js | GeoJSONKit-Turf
[turf-helpers#radiansToDegrees](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers/#radiansToDegrees) | `GeoJSON.DegreesRadians.toDegrees()`
[turf-helpers#convertLength](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers#convertlength)
[turf-helpers#convertArea](https://github.com/Turfjs/turf/tree/master/packages/turf-helpers#convertarea) | `Measurement.converted(to:)`
[turf-length](https://github.com/Turfjs/turf/tree/master/packages/turf-length/) | `GeoJSON.LineString.distance(from:to:)`
+[turf-line-chunk](http://turfjs.org/docs/#lineChunk) | `GeoJSON.LineString.chunked(length:)`
`GeoJSON.Polygon.LinearRing.chunked(length:)` |
[turf-line-intersect](https://github.com/Turfjs/turf/tree/master/packages/turf-line-intersect/) | `GeoJSON.LineString.intersection(with:)`
[turf-line-slice](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice/) | `GeoJSON.LineString.sliced(from:to:)`
[turf-line-slice-along](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice-along/) | `GeoJSON.LineString.trimmed(from:distance:)`
`GeoJSON.LineString.trimmed(from:to:)`
@@ -66,8 +68,13 @@ Turf.js | GeoJSONKit-Turf
[turf-polygon-smooth](https://github.com/Turfjs/turf/tree/master/packages/turf-polygon-smooth) | `GeoJSON.Polygon.smooth(iterations:)`
[turf-union](https://github.com/Turfjs/turf/tree/master/packages/turf-union) | Not provided, but see [ASPolygonKit](https://github.com/nighthawk/ASPolygonKit)
[turf-simplify](https://github.com/Turfjs/turf/tree/master/packages/turf-simplify) | `GeoJSON.simplify(options:)`
-— | `GeoJSON.Direction.difference(from:)`
-— | `GeoJSON.Direction.wrap(min:max:)`
+
+Additionally, it adds the following features, which do not have a direct equivalent in turf.js:
+
+* `GeoJSON.Direction.difference(from:)`
+* `GeoJSON.Direction.wrap(min:max:)`
+* `GeoJSON.LineString.frechetDistance(to:)`: Determines the [Fréchet distance](https://en.wikipedia.org/wiki/Fréchet_distance) between two line strings, which is a measure of their similarity.
+* `GeoJSON.GeometryObject(splittingWhenCrossingAntiMeridian:)`: Breaks up a LineString or Polygon into two when crossing the anti-meridian.
## CLI
diff --git a/Sources/GeoJSONKitTurf/Position+Helpers.swift b/Sources/GeoJSONKitTurf/Position+Helpers.swift
index ed69dac..f36abdb 100644
--- a/Sources/GeoJSONKitTurf/Position+Helpers.swift
+++ b/Sources/GeoJSONKitTurf/Position+Helpers.swift
@@ -17,4 +17,9 @@ extension GeoJSON.Position {
return dx * dx + dy * dy
}
+ static func length(of positions: [GeoJSON.Position]) -> GeoJSON.Distance {
+ let pairs = zip(positions.prefix(upTo: positions.count - 1), positions.suffix(from: 1))
+ return pairs.map { $0.distance(to: $1) }.reduce(0, +)
+ }
+
}
diff --git a/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift b/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift
index 63dacf7..e3f25c9 100644
--- a/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift
+++ b/Sources/GeoJSONKitTurf/Turf+GeometryObject.swift
@@ -98,6 +98,7 @@ extension GeoJSON.Geometry {
return polygon.nearestPoint(to: position)
}
}
+
}
extension GeoJSON.GeometryObject {
@@ -119,4 +120,87 @@ extension GeoJSON.GeometryObject {
return objects.contains(where: { $0.contains(coordinate, ignoreBoundary: ignoreBoundary, checkBoundingBox: checkBoundingBox) })
}
}
+
+}
+
+// MARK: - Anti-meridian safety
+
+extension GeoJSON.Geometry {
+
+ func breakUpIfNecessary() -> [GeoJSON.Geometry] {
+ let bbox = GeoJSON.BoundingBox(positions: positions, allowSpanningAntimeridian: true)
+ guard bbox.spansAntimeridian else {
+ return [self]
+ }
+
+ let normalized = self.wrapped(min: 0, max: 360)
+
+ // Western-most point to anti-meridian
+ // No need to wrap, as already positive
+ let easterlyBox = GeoJSON.BoundingBox(positions: [
+ .init(latitude: bbox.northEasterlyLatitude, longitude: 180),
+ .init(latitude: bbox.southWesterlyLatitude, longitude: bbox.southWesterlyLongitude)
+ ])
+
+ // Anti-meridian to eastern-mode point
+ // This needs to be wrapped sa the clipping doesn't work otherwise
+ let westerlyBox = GeoJSON.BoundingBox(positions: [
+ .init(latitude: bbox.northEasterlyLatitude, longitude: bbox.northEasterlyLongitude.wrap(min: 0, max: 360)),
+ .init(latitude: bbox.southWesterlyLatitude, longitude: 180)
+ ])
+
+ let easterly = normalized.clipped(to: easterlyBox)
+ let westerly = normalized.clipped(to: westerlyBox)
+ return [
+ easterly,
+ westerly.wrapped(min: -180, max: 180),
+ ]
+ }
+
+ private func clipped(to bbox: GeoJSON.BoundingBox) -> GeoJSON.Geometry {
+ switch self {
+ case .point:
+ return self
+ case .lineString(let line):
+ return .lineString(line.clipped(to: bbox))
+ case .polygon(let polygon):
+ return .polygon(polygon.clipped(to: bbox))
+ }
+ }
+
+ private func wrapped(min: GeoJSON.Degrees, max: GeoJSON.Degrees) -> GeoJSON.Geometry {
+ switch self {
+ case .point:
+ return self
+
+ case .lineString(let line):
+ let normalized = line.positions.map {
+ GeoJSON.Position(latitude: $0.latitude, longitude: $0.longitude.wrap(min: min, max: max))
+ }
+ return .lineString(.init(positions: normalized))
+
+ case .polygon(let polygon):
+ let normalized = polygon.exterior.positions.map {
+ GeoJSON.Position(latitude: $0.latitude, longitude: $0.longitude.wrap(min: min, max: max))
+ }
+ return .polygon(.init(exterior: .init(positions: normalized)))
+ }
+ }
+}
+
+
+extension GeoJSON.GeometryObject {
+
+ /// Checks the provided geometry, if it crosses the anti-meridian. If it doesn't, it's returned as
+ /// a `.single`, otherwise it's broken up into two and returned as a `.multi`.
+ ///
+ /// - Parameter candidate: A geometry, which might cross the anti-meridian
+ public init(splittingWhenCrossingAntiMeridian candidate: GeoJSON.Geometry) {
+ let components = candidate.breakUpIfNecessary()
+ if components.count == 1, let only = components.first {
+ self = .single(only)
+ } else {
+ self = .multi(components)
+ }
+ }
}
diff --git a/Sources/GeoJSONKitTurf/Turf+LineString.swift b/Sources/GeoJSONKitTurf/Turf+LineString.swift
index e071d6d..89c5d51 100644
--- a/Sources/GeoJSONKitTurf/Turf+LineString.swift
+++ b/Sources/GeoJSONKitTurf/Turf+LineString.swift
@@ -6,11 +6,16 @@ extension GeoJSON.LineString {
var coordinates: [GeoJSON.Position] { positions }
/**
- Representation of current `LineString` as an array of `LineSegment`s.
- */
- var segments: [LineSegment] {
- return zip(coordinates.dropLast(), coordinates.dropFirst()).map { LineSegment($0.0, $0.1) }
- }
+ Representation of current `LineString` as an array of `LineSegment`s.
+ */
+ var segments: [LineSegment] {
+ return zip(coordinates.dropLast(), coordinates.dropFirst()).map { LineSegment($0.0, $0.1) }
+ }
+
+ /// The length of the line, in metres
+ public var length: GeoJSON.Distance {
+ GeoJSON.Position.length(of: positions)
+ }
/// Returns a new line string based on bezier transformation of the input line.
///
@@ -28,64 +33,19 @@ extension GeoJSON.LineString {
return GeoJSON.LineString(positions: coords)
}
+ public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.LineString {
+ return .init(positions: SutherlandHodgeman.clip(positions, to: boundingBox, close: false))
+ }
+
/**
Returns the portion of the line string that begins at the given start distance and extends the given stop distance along the line string.
This method is equivalent to the [turf-line-slice-along](https://turfjs.org/docs/#lineSliceAlong) package of Turf.js ([source code](https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice-along/)).
*/
public func trimmed(from startDistance: GeoJSON.Distance, to stopDistance: GeoJSON.Distance) -> GeoJSON.LineString? {
- // The method is porting from https://github.com/Turfjs/turf/blob/5375941072b90d489389db22b43bfe809d5e451e/packages/turf-line-slice-along/index.js
- guard startDistance >= 0, stopDistance >= startDistance else { return nil }
- let positions = self.coordinates
- var traveled: GeoJSON.Distance = 0
- var slice = [GeoJSON.Position]()
-
- for i in 0..= traveled, i == positions.endIndex - 1 {
- break
- } else if traveled > startDistance, slice.isEmpty {
- let overshoot = startDistance - traveled
- if overshoot == 0.0 {
- slice.append(positions[i])
- return GeoJSON.LineString(positions: slice)
- }
- let direction = positions[i].direction(to: positions[i - 1]) - 180
- let interpolated = positions[i].coordinate(at: overshoot, facing: direction)
- slice.append(interpolated)
- }
-
- if traveled >= stopDistance {
- let overshoot = stopDistance - traveled
- if overshoot == 0.0 {
- slice.append(positions[i])
- return GeoJSON.LineString(positions: slice)
- }
- let direction = positions[i].direction(to: positions[i - 1]) - 180
- let interpolated = positions[i].coordinate(at: overshoot, facing: direction)
- slice.append(interpolated)
- return GeoJSON.LineString(positions: slice)
- }
-
- if traveled >= startDistance {
- slice.append(positions[i])
- }
-
- if i == positions.count - 1 {
- return GeoJSON.LineString(positions: slice)
- }
-
- traveled += positions[i].distance(to: positions[i + 1])
- }
-
- if traveled < startDistance {
- return nil
- }
-
- if let last = positions.last {
- return GeoJSON.LineString(positions: [last, last])
- }
-
- return nil
+ let trimmed = Self.trimmed(positions, from: startDistance, to: stopDistance)
+ guard !trimmed.isEmpty else { return nil }
+ return .init(positions: trimmed)
}
/// Returns the portion of the line string that begins at the given coordinate and extends the given distance along the line string.
@@ -219,7 +179,7 @@ extension GeoJSON.LineString {
public func closestCoordinate(to coordinate: GeoJSON.Position) -> IndexedCoordinate? {
.findClosest(to: coordinate, on: positions)
}
-
+
/**
Returns all intersections with another `LineString`.
@@ -237,8 +197,109 @@ extension GeoJSON.LineString {
}
return intersections
}
+}
+
+// MARK: - Turf-Slice
+
+extension GeoJSON.LineString {
+
+ /// Divides a ``GeoJSON.LineString`` into chunks of a specified length.
+ /// If the line is shorter than the segment length then the original line is returned.
+ ///
+ /// Adopted This function is roughly equivalent to the [turf-line-chunk](https://turfjs.org/docs/#lineChunk) package of Turf.js ([source code](https://github.com/Turfjs/turf/tree/master/packages/turf-line-chunk/)). However, it returns another line string
+ /// rather than a feature collection.
+ ///
+ /// - Parameter length: How long to make each segment, in metres.
+ /// - Returns: Line string with positions repeating as requested.
+ public func chunked(length: GeoJSON.Distance) -> GeoJSON.LineString {
+ return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length))
+ }
+
+ // Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-line-chunk/index.js
+ static func sliceLineSegments(_ positions: [GeoJSON.Position], length: GeoJSON.Distance) -> [GeoJSON.Position] {
+ let lineLength = GeoJSON.Position.length(of: positions)
+
+ // If the line is shorter than the segment length then the orginal line is returned.
+ if lineLength <= length {
+ return positions
+ }
+
+ let segmentProportion = lineLength / length
+ var segmentCount = Int(segmentProportion)
+ if Double(segmentCount) < segmentProportion {
+ segmentCount += 1
+ }
+
+ return (0.. [GeoJSON.Position] in
+ let trimmed = Self.trimmed(
+ positions,
+ from: length * Double(i),
+ to: length * Double(i + 1)
+ )
+ if i == 0 {
+ return trimmed
+ } else {
+ return Array(trimmed.dropFirst())
+ }
+ }
+ }
+
+ // Ported from https://github.com/Turfjs/turf/tree/master/packages/turf-line-slice-along/
+ static func trimmed(_ positions: [GeoJSON.Position], from startDistance: GeoJSON.Distance, to stopDistance: GeoJSON.Distance) -> [GeoJSON.Position] {
+ guard startDistance >= 0.0, stopDistance >= startDistance else { return [] }
+ var traveled: GeoJSON.Distance = 0
+ var slice = [GeoJSON.Position]()
+
+ for i in 0..= traveled && i == positions.endIndex - 1 {
+ break
+ } else if traveled > startDistance && slice.isEmpty {
+ let overshoot = startDistance - traveled
+ if overshoot == 0.0 {
+ slice.append(positions[i])
+ return slice
+ }
+ let direction = positions[i].direction(to: positions[i - 1]) - 180
+ let interpolated = positions[i].coordinate(at: overshoot, facing: direction)
+ slice.append(interpolated)
+ }
+
+ if traveled >= stopDistance {
+ let overshoot = stopDistance - traveled
+ if overshoot == 0.0 {
+ slice.append(positions[i])
+ return slice
+ }
+ let direction = positions[i].direction(to: positions[i - 1]) - 180
+ let interpolated = positions[i].coordinate(at: overshoot, facing: direction)
+ slice.append(interpolated)
+ return slice
+ }
+
+ if traveled >= startDistance {
+ slice.append(positions[i])
+ }
+
+ if i == positions.count - 1 {
+ return slice
+ }
+
+ traveled += positions[i].distance(to: positions[i + 1])
+ }
+
+ if traveled < startDistance {
+ return []
+ }
+
+ if let last = positions.last {
+ return [last, last]
+ }
+
+ return []
+ }
- // MARK: - Fretched Distance
+ // MARK: - Fretchet Distance
/// Frechet distance to another line, which is a measure of how similar the the lines are
///
@@ -271,5 +332,4 @@ extension GeoJSON.LineString {
return c(i: path1.count - 1, j: path2.count - 1)
}
-
}
diff --git a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift
index 44e2bb0..4dc3c46 100644
--- a/Sources/GeoJSONKitTurf/Turf+LinearRing.swift
+++ b/Sources/GeoJSONKitTurf/Turf+LinearRing.swift
@@ -7,6 +7,10 @@ import GeoJSONKit
*/
extension GeoJSON.Polygon.LinearRing {
+ public var circumference: GeoJSON.Distance {
+ GeoJSON.Position.length(of: positions)
+ }
+
/**
* Calculate the approximate area of the polygon were it projected onto the earth, in square meters.
* Note that this area will be positive if ring is oriented clockwise, otherwise it will be negative.
@@ -126,5 +130,15 @@ extension GeoJSON.Polygon.LinearRing {
func closestPosition(to coordinate: GeoJSON.Position) -> GeoJSON.Position? {
GeoJSON.LineString.IndexedCoordinate.findClosest(to: coordinate, on: positions)?.coordinate
}
+
+ /// Divides a ``GeoJSON.Polygon.LinearRing`` into chunks of a specified length.
+ /// If the line is shorter than the segment length then the original line is returned.
+ public func chunked(length: GeoJSON.Distance) -> GeoJSON.Polygon.LinearRing {
+ return .init(positions: GeoJSON.LineString.sliceLineSegments(positions, length: length))
+ }
+
+ public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon.LinearRing {
+ return .init(positions: SutherlandHodgeman.clip(positions, to: boundingBox, close: true))
+ }
}
diff --git a/Sources/GeoJSONKitTurf/Turf+Polygon.swift b/Sources/GeoJSONKitTurf/Turf+Polygon.swift
index 5614c2d..075376a 100644
--- a/Sources/GeoJSONKitTurf/Turf+Polygon.swift
+++ b/Sources/GeoJSONKitTurf/Turf+Polygon.swift
@@ -79,10 +79,10 @@ extension GeoJSON.Polygon {
/// Clips a `.Polygon` to a bounding box
///
/// Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-bbox-clip/index.ts
- public func clip(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon {
+ public func clipped(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon {
var rings: [GeoJSON.Polygon.LinearRing] = []
for ring in [exterior] + interiors {
- var clip: GeoJSON.Polygon.LinearRing = ring.clip(to: boundingBox)
+ var clip: GeoJSON.Polygon.LinearRing = ring.clipped(to: boundingBox)
if let first = clip.positions.first {
if first != clip.positions.last {
clip.positions.append(first)
@@ -97,95 +97,6 @@ extension GeoJSON.Polygon {
}
-extension GeoJSON.Polygon.LinearRing {
- // Sutherland-Hodgeman polygon clipping algorithm
- func clip(to boundingBox: GeoJSON.BoundingBox) -> GeoJSON.Polygon.LinearRing {
- var result = [GeoJSON.Position]()
- var prev: GeoJSON.Position
- var prevInside: Bool
- var inside: Bool
- var points = positions
-
- for edge in [1, 2, 4, 8] {
- result = []
- prev = points.last!
- prevInside = !(bitCode(p: prev, bbox: boundingBox) & edge != 0)
-
- for point in points {
- inside = !(bitCode(p: point, bbox: boundingBox) & edge != 0)
-
- if inside != prevInside {
- if let intersection = intersect(a: prev, b: point, edge: edge, bbox: boundingBox) {
- result.append(intersection)
- }
- }
-
- if inside {
- result.append(point)
- }
-
- prev = point
- prevInside = inside
- }
-
- points = result
- if result.isEmpty {
- break
- }
- }
-
- return .init(positions: result)
- }
-
- private func intersect(a: GeoJSON.Position, b: GeoJSON.Position, edge: Int, bbox: GeoJSON.BoundingBox) -> GeoJSON.Position? {
- if edge & 8 != 0 {
- return .init(x: a.x + ((b.x - a.x) * (bbox.top - a.y)) / (b.y - a.y), y: bbox.top)
- } else if edge & 4 != 0 {
- return .init(x: a.x + ((b.x - a.x) * (bbox.bottom - a.y)) / (b.y - a.y), y: bbox.bottom)
- } else if edge & 2 != 0 {
- return .init(x: bbox.right, y: a.y + ((b.y - a.y) * (bbox.right - a.x)) / (b.x - a.x))
- } else if edge & 1 != 0 {
- return .init(x: bbox.left, y: a.y + ((b.y - a.y) * (bbox.left - a.x)) / (b.x - a.x))
- } else {
- return nil
- }
- }
-
- private func bitCode(p: GeoJSON.Position, bbox: GeoJSON.BoundingBox) -> Int {
- var code = 0
-
- if p.x < bbox.left {
- code |= 1
- } else if p.x > bbox.right {
- code |= 2
- }
-
- if p.y < bbox.bottom {
- code |= 4
- } else if p.y > bbox.top {
- code |= 8
- }
-
- return code
- }
-}
-
-fileprivate extension GeoJSON.BoundingBox {
- var left: Double { southWesterlyLongitude }
- var right: Double { northEasterlyLongitude }
- var top: Double { northEasterlyLatitude }
- var bottom: Double { southWesterlyLatitude }
-}
-
-fileprivate extension GeoJSON.Position {
- var x: Double { longitude }
- var y: Double { latitude }
-
- init(x: Double, y: Double) {
- self.init(latitude: y, longitude: x)
- }
-}
-
// MARK: - Polygon.smooth()
extension GeoJSON.Polygon {
diff --git a/Sources/GeoJSONKitTurf/Spline.swift b/Sources/GeoJSONKitTurf/algorithms/Spline.swift
similarity index 100%
rename from Sources/GeoJSONKitTurf/Spline.swift
rename to Sources/GeoJSONKitTurf/algorithms/Spline.swift
diff --git a/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift b/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift
new file mode 100644
index 0000000..ffbbb1d
--- /dev/null
+++ b/Sources/GeoJSONKitTurf/algorithms/SutherlandHodgeman.swift
@@ -0,0 +1,100 @@
+//
+// SutherlandHodgeman.swift
+//
+//
+// Created by Adrian Schönig on 25/11/2023.
+//
+
+import Foundation
+
+import GeoJSONKit
+
+// Sutherland-Hodgeman polygon clipping algorithm
+enum SutherlandHodgeman {
+
+ static func clip(_ positions: [GeoJSON.Position], to boundingBox: GeoJSON.BoundingBox, close: Bool) -> [GeoJSON.Position] {
+ var result = [GeoJSON.Position]()
+ var prev: GeoJSON.Position
+ var prevInside: Bool
+ var inside: Bool
+ var points = positions
+
+ for edge in [1, 2, 4, 8] {
+ result = []
+ prev = close ? points.last! : points.first!
+ prevInside = !(Self.bitCode(p: prev, bbox: boundingBox) & edge != 0)
+
+ for point in points {
+ inside = !(Self.bitCode(p: point, bbox: boundingBox) & edge != 0)
+
+ if inside != prevInside {
+ if let intersection = Self.intersect(a: prev, b: point, edge: edge, bbox: boundingBox) {
+ result.append(intersection)
+ }
+ }
+
+ if inside {
+ result.append(point)
+ }
+
+ prev = point
+ prevInside = inside
+ }
+
+ points = result
+ if result.isEmpty {
+ break
+ }
+ }
+ return result
+ }
+
+ private static func intersect(a: GeoJSON.Position, b: GeoJSON.Position, edge: Int, bbox: GeoJSON.BoundingBox) -> GeoJSON.Position? {
+ if edge & 8 != 0 {
+ return .init(x: a.x + ((b.x - a.x) * (bbox.top - a.y)) / (b.y - a.y), y: bbox.top)
+ } else if edge & 4 != 0 {
+ return .init(x: a.x + ((b.x - a.x) * (bbox.bottom - a.y)) / (b.y - a.y), y: bbox.bottom)
+ } else if edge & 2 != 0 {
+ return .init(x: bbox.right, y: a.y + ((b.y - a.y) * (bbox.right - a.x)) / (b.x - a.x))
+ } else if edge & 1 != 0 {
+ return .init(x: bbox.left, y: a.y + ((b.y - a.y) * (bbox.left - a.x)) / (b.x - a.x))
+ } else {
+ return nil
+ }
+ }
+
+ private static func bitCode(p: GeoJSON.Position, bbox: GeoJSON.BoundingBox) -> Int {
+ var code = 0
+
+ if p.x < bbox.left {
+ code |= 1
+ } else if p.x > bbox.right {
+ code |= 2
+ }
+
+ if p.y < bbox.bottom {
+ code |= 4
+ } else if p.y > bbox.top {
+ code |= 8
+ }
+
+ return code
+ }
+
+}
+
+fileprivate extension GeoJSON.BoundingBox {
+ var left: Double { southWesterlyLongitude }
+ var right: Double { northEasterlyLongitude }
+ var top: Double { northEasterlyLatitude }
+ var bottom: Double { southWesterlyLatitude }
+}
+
+fileprivate extension GeoJSON.Position {
+ var x: Double { longitude }
+ var y: Double { latitude }
+
+ init(x: Double, y: Double) {
+ self.init(latitude: y, longitude: x)
+ }
+}
diff --git a/Tests/GeoJSONKitTurfTests/LineStringTests.swift b/Tests/GeoJSONKitTurfTests/LineStringTests.swift
index 2696640..42a4c3f 100644
--- a/Tests/GeoJSONKitTurfTests/LineStringTests.swift
+++ b/Tests/GeoJSONKitTurfTests/LineStringTests.swift
@@ -205,6 +205,110 @@ class LineStringTests: XCTestCase {
indexLineString.coordinates[3].distance(to: indexLineString.coordinates.first!))
}
+ func testChunk() throws {
+ // Adapted from https://github.com/Turfjs/turf/blob/master/packages/turf-line-chunk/test.js
+ let input = try GeoJSON(geoJSONString: """
+ {
+ "type": "LineString",
+ "coordinates": [
+ [-86.28524780273438, 40.250184183819854],
+ [-85.98587036132812, 40.17887331434696],
+ [-85.97213745117188, 40.08857859823707],
+ [-85.77987670898438, 40.15578608609647]
+ ]
+ }
+ """)
+
+ let segmented = try GeoJSON(geoJSONString: """
+ {
+ "type": "LineString",
+ "coordinates": [
+ [-86.2852, 40.2501],
+ [-86.1947, 40.2287],
+ [-86.104, 40.2071],
+ [-86.0138, 40.185],
+ [-85.985, 40.1788],
+ [-85.9783, 40.1292],
+ [-85.9721, 40.0885],
+ [-85.9347, 40.1016],
+ [-85.8487, 40.1317],
+ [-85.7798, 40.155]
+ ]
+ }
+ """)
+
+ guard
+ case .geometry(.single(.lineString(let lineIn))) = input.object,
+ case .geometry(.single(.lineString(let lineOut))) = segmented.object
+ else { return XCTFail() }
+
+ // Chunked by 5 miles should split it into smaller segments
+ let fiveMiles = 8046.72
+ let chunked = lineIn.chunked(length: fiveMiles)
+ XCTAssertEqual(chunked.positions.count, lineOut.positions.count)
+ for (offset, pair) in zip(lineOut.positions, chunked.positions).enumerated() {
+ XCTAssertEqual(pair.0.latitude, pair.1.latitude, accuracy: 0.001, "Failure at: \(offset)")
+ XCTAssertEqual(pair.0.longitude, pair.1.longitude, accuracy: 0.001, "Failure at: \(offset)")
+ }
+
+ // Chunked by 50 miles should stay the same
+ XCTAssertEqual(lineIn.chunked(length: 10 * fiveMiles), lineIn)
+ }
+
+ func testChunkCrossingAntiMeridian() throws {
+ let input = try GeoJSON(geoJSONString: """
+ {
+ "type": "LineString",
+ "coordinates": [
+ [151.21, -33.86],
+ [-122.40, 37.78]
+ ]
+ }
+ """)
+
+ let segmented = try GeoJSON(geoJSONString: """
+ {
+ "type": "MultiLineString",
+ "coordinates": [
+ [
+ [151.21, -33.86],
+ [159.66614, -28.51214],
+ [167.27209, -22.69315],
+ [174.24093, -16.54493],
+ [180.0, -10.93165]
+ ],
+ [
+ [-180.0, -10.93165],
+ [-179.22884, -10.18002],
+ [-172.95704, -3.69086],
+ [-166.776739, 2.84170],
+ [-160.52609, 9.34092],
+ [-154.03934, 15.72689],
+ [-147.138400, 21.90916],
+ [-139.627215, 27.77830],
+ [-131.293853, 33.19675],
+ [-122.40, 37.78]
+ ]
+ ]
+ }
+ """)
+
+ guard
+ case .geometry(.single(.lineString(let lineIn))) = input.object,
+ case .geometry(let geoOut) = segmented.object
+ else { return XCTFail() }
+
+ let chunked = GeoJSON.GeometryObject(splittingWhenCrossingAntiMeridian: .lineString(lineIn.chunked(length: 1_000_000)))
+
+ XCTAssertEqual(chunked.positions.count, geoOut.positions.count)
+
+ for (offset, pair) in zip(geoOut.positions, chunked.positions).enumerated() {
+ XCTAssertEqual(pair.0.latitude, pair.1.latitude, accuracy: 0.001, "Failure at: \(offset)")
+ XCTAssertEqual(pair.0.longitude, pair.1.longitude, accuracy: 0.001, "Failure at: \(offset)")
+ }
+
+ }
+
func testCoordinateFromStart() {
// Ported from https://github.com/Turfjs/turf/blob/142e137ce0c758e2825a260ab32b24db0aa19439/packages/turf-along/test.js
diff --git a/Tests/GeoJSONKitTurfTests/PolygonTests.swift b/Tests/GeoJSONKitTurfTests/PolygonTests.swift
index f5ec6a2..33d2215 100644
--- a/Tests/GeoJSONKitTurfTests/PolygonTests.swift
+++ b/Tests/GeoJSONKitTurfTests/PolygonTests.swift
@@ -618,7 +618,7 @@ class PolygonTests: XCTestCase {
}
let bboxPositions = bboxFeature.geometry.positions
- let actualPolygon = polygon.clip(to: .init(positions: bboxPositions))
+ let actualPolygon = polygon.clipped(to: .init(positions: bboxPositions))
let actual = GeoJSON(features: [
inputFeature.colorized(color: "#080"),
.init(geometry: .single(.polygon(actualPolygon))).colorized(color: "#F00"),