diff --git a/README.md b/README.md index 9e3a219..09903f4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmaparoni%2FGeoJSONKit-Turf%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/maparoni/GeoJSONKit-Turf) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmaparoni%2FGeoJSONKit-Turf%2Fbadge%3Ftype%3Dplatforms)](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"),