diff --git a/Sources/GISTools/GeoJson/BoundingBox.swift b/Sources/GISTools/GeoJson/BoundingBox.swift index 07065d0..0843943 100644 --- a/Sources/GISTools/GeoJson/BoundingBox.swift +++ b/Sources/GISTools/GeoJson/BoundingBox.swift @@ -6,7 +6,6 @@ import Foundation /// A GeoJSON bounding box. public struct BoundingBox: GeoJsonReadable, - Projectable, CustomStringConvertible, Sendable { @@ -42,7 +41,16 @@ public struct BoundingBox: } /// Create a bounding box from `coordinates` and an optional padding in kilometers. - public init?(coordinates: [Coordinate3D], paddingKilometers: Double = 0.0) { + @available(*, deprecated, renamed: "init(coordinates:padding:)", message: "Padding is now expressed in meters") + public init?(coordinates: [Coordinate3D], paddingKilometers: Double) { + self.init(coordinates: coordinates, padding: paddingKilometers * 1000.0) + } + + /// Create a bounding box from `coordinates` and an optional padding. + /// + /// - Parameters: + /// - padding: The padding, in meters + public init?(coordinates: [Coordinate3D], padding: CLLocationDistance = 0.0) { guard !coordinates.isEmpty else { return nil } self.projection = coordinates.first?.projection ?? .epsg4326 @@ -60,21 +68,21 @@ public struct BoundingBox: northEast.longitude = max(northEast.longitude, currentLocationLongitude) } - if paddingKilometers > 0.0 { + if padding > 0.0 { switch projection { case .epsg3857: - southWest.latitude -= paddingKilometers * 1000.0 - northEast.latitude += paddingKilometers * 1000.0 - southWest.longitude -= paddingKilometers * 1000.0 - northEast.longitude += paddingKilometers * 1000.0 + southWest.latitude -= padding + northEast.latitude += padding + southWest.longitude -= padding + northEast.longitude += padding case .epsg4326: // Length of one minute at this latitude - let oneDegreeLongitudeDistanceInKilometers: Double = cos(southWest.latitude * Double.pi / 180.0) * 111.0 - let oneDegreeLatitudeDistanceInKilometers = 111.0 + let oneDegreeLatitudeDistance: CLLocationDistance = GISTool.earthCircumference / 360.0 // ~111 km + let oneDegreeLongitudeDistance: CLLocationDistance = cos(southWest.latitude * Double.pi / 180.0) * oneDegreeLatitudeDistance - let longitudeDistance: Double = (paddingKilometers / oneDegreeLongitudeDistanceInKilometers) - let latitudeDistance: Double = (paddingKilometers / oneDegreeLatitudeDistanceInKilometers) + let longitudeDistance: Double = (padding / oneDegreeLongitudeDistance) + let latitudeDistance: Double = (padding / oneDegreeLatitudeDistance) southWest.latitude -= latitudeDistance northEast.latitude += latitudeDistance @@ -162,17 +170,34 @@ public struct BoundingBox: } /// Returns a copy of the receiver with some padding in kilometers. + @available(*, deprecated, renamed: "padded(_:)", message: "Padding is now expressed in meters") public func with(padding paddingKilometers: Double) -> BoundingBox { BoundingBox( coordinates: [southWest, northEast], paddingKilometers: paddingKilometers)! } + /// Returns a copy of the receiver with some padding horizontally and vertically. + /// + /// - Parameters: + /// - padding: The padding, in meters + public func padded(_ padding: CLLocationDistance) -> BoundingBox { + BoundingBox( + coordinates: [southWest, northEast], + padding: padding)! + } + /// Returns a copy of the receiver expanded by `degrees`. + @available(*, deprecated, renamed: "expanded(byDegrees:)", message: "Renamed to expaned(byDegrees:)") public func expand(_ degrees: CLLocationDegrees) -> BoundingBox { + expanded(byDegrees: degrees) + } + + /// Returns a copy of the receiver expanded by `degrees` horizontally and vertically. + public func expanded(byDegrees degrees: CLLocationDegrees) -> BoundingBox { switch projection { case .epsg3857: - return projected(to: .epsg4326).expand(degrees).projected(to: .epsg3857) + return projected(to: .epsg4326).expanded(byDegrees: degrees).projected(to: .epsg3857) case .epsg4326: return BoundingBox( @@ -185,19 +210,40 @@ public struct BoundingBox: } /// Returns a copy of the receiver expanded by `distance` diagonally. + @available(*, deprecated, renamed: "expanded(byDistance:)", message: "Renamed to expaned(byDistance:)") public func expand(distance: CLLocationDistance) -> BoundingBox { + expanded(byDistance: distance) + } + + /// Returns a copy of the receiver expanded by `distance` diagonally. + /// + /// - Parameters: + /// - distance: The distance from the receiver, in meters + public func expanded(byDistance distance: CLLocationDistance) -> BoundingBox { BoundingBox( southWest: southWest.destination(distance: distance, bearing: 225.0), northEast: northEast.destination(distance: distance, bearing: 45.0)) } /// Returns a copy of the receiver that also includes `coordinate`. + @available(*, deprecated, renamed: "expanded(byIncluding:)", message: "Renamed to expanded(byIncluding:)") public func expand(including coordinate: Coordinate3D) -> BoundingBox { + expanded(byIncluding: coordinate) + } + + /// Returns a copy of the receiver that also includes `coordinate`. + public func expanded(byIncluding coordinate: Coordinate3D) -> BoundingBox { BoundingBox(coordinates: [southWest, northEast, coordinate.projected(to: projection)])! } /// Returns a copy of the receiver that also includes the other `boundingBox`. + @available(*, deprecated, renamed: "expanded(byIncluding:)", message: "Renamed to expanded(byIncluding:)") public func expand(including boundingBox: BoundingBox) -> BoundingBox { + expanded(byIncluding: boundingBox) + } + + /// Returns a copy of the receiver that also includes the other `boundingBox`. + public func expanded(byIncluding boundingBox: BoundingBox) -> BoundingBox { BoundingBox(coordinates: [ southWest, northEast, @@ -215,7 +261,7 @@ public struct BoundingBox: // MARK: - Projection -extension BoundingBox { +extension BoundingBox: Projectable { /// Reproject this bounding box. public func projected(to newProjection: Projection) -> BoundingBox { @@ -234,20 +280,38 @@ extension BoundingBox { extension BoundingBox { /// Create a bounding box from `coordinates` and an optional padding in kilometers. - public init?(coordinates: [CLLocationCoordinate2D], paddingKilometers: Double = 0.0) { + @available(*, deprecated, renamed: "init(coordinates:padding:)", message: "Padding is now expressed in meters") + public init?(coordinates: [CLLocationCoordinate2D], paddingKilometers: Double) { self.init(coordinates: coordinates.map({ Coordinate3D($0) }), paddingKilometers: paddingKilometers) } + /// Create a bounding box from `coordinates` and an optional padding. + /// + /// - Parameters: + /// - padding: The padding, in meters + public init?(coordinates: [CLLocationCoordinate2D], padding: Double = 0.0) { + self.init(coordinates: coordinates.map({ Coordinate3D($0) }), padding: padding) + } + /// Create a bounding box from a south-west and north-east coordinate. public init(southWest: CLLocationCoordinate2D, northEast: CLLocationCoordinate2D) { self.init(southWest: Coordinate3D(southWest), northEast: Coordinate3D(northEast)) } /// Create a bounding box from `locations` and an optional padding in kilometers. + @available(*, deprecated, renamed: "init(locations:padding:)", message: "Padding is now expressed in meters") public init?(locations: [CLLocation], paddingKilometers: Double = 0.0) { self.init(coordinates: locations.map({ Coordinate3D($0) }), paddingKilometers: paddingKilometers) } + /// Create a bounding box from `locations` and an optional padding. + /// + /// - Parameters: + /// - padding: The padding, in meters + public init?(locations: [CLLocation], padding: Double = 0.0) { + self.init(coordinates: locations.map({ Coordinate3D($0) }), padding: padding) + } + /// Create a bounding box from a south-west and north-east coordinate. public init(southWest: CLLocation, northEast: CLLocation) { self.init(southWest: Coordinate3D(southWest), northEast: Coordinate3D(northEast)) diff --git a/Sources/GISTools/GeoJson/Coordinate3D.swift b/Sources/GISTools/GeoJson/Coordinate3D.swift index 4d70381..9cb8bf1 100644 --- a/Sources/GISTools/GeoJson/Coordinate3D.swift +++ b/Sources/GISTools/GeoJson/Coordinate3D.swift @@ -12,7 +12,6 @@ import Foundation /// A three dimensional coordinate (``latitude``/``y``, ``longitude``/``x``, ``altitude``/``z``) /// plus a generic value ``m``. public struct Coordinate3D: - Projectable, CustomStringConvertible, Sendable { @@ -247,7 +246,7 @@ extension Coordinate3D { // MARK: - Projection -extension Coordinate3D { +extension Coordinate3D: Projectable { /// Reproject this coordinate. public func projected(to newProjection: Projection) -> Coordinate3D { diff --git a/Sources/GISTools/GeoJson/Feature.swift b/Sources/GISTools/GeoJson/Feature.swift index 5522194..f9f1f5c 100644 --- a/Sources/GISTools/GeoJson/Feature.swift +++ b/Sources/GISTools/GeoJson/Feature.swift @@ -4,7 +4,7 @@ import Foundation public struct Feature: GeoJson { /// A GeoJSON identifier that can either be a string or number. - public enum Identifier: Equatable, Hashable, CustomStringConvertible { + public enum Identifier: Equatable, Hashable, CustomStringConvertible, Sendable { case string(String) case int(Int) case double(Double) @@ -61,11 +61,11 @@ public struct Feature: GeoJson { } /// Only 'Feature' objects may have properties. - public var properties: [String: Any] + public var properties: [String: Sendable] public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] /// Create a ``Feature`` from any ``GeoJsonGeometry`` object. public init( diff --git a/Sources/GISTools/GeoJson/FeatureCollection.swift b/Sources/GISTools/GeoJson/FeatureCollection.swift index 81645c5..c379320 100644 --- a/Sources/GISTools/GeoJson/FeatureCollection.swift +++ b/Sources/GISTools/GeoJson/FeatureCollection.swift @@ -23,7 +23,7 @@ public struct FeatureCollection: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public init() { self.features = [] diff --git a/Sources/GISTools/GeoJson/GeoJson.swift b/Sources/GISTools/GeoJson/GeoJson.swift index c7be5ec..52c58c5 100644 --- a/Sources/GISTools/GeoJson/GeoJson.swift +++ b/Sources/GISTools/GeoJson/GeoJson.swift @@ -33,7 +33,8 @@ public protocol GeoJson: Projectable, ValidatableGeoJson, Codable, - CustomDebugStringConvertible + CustomDebugStringConvertible, + Sendable { /// GeoJSON object type. @@ -45,7 +46,7 @@ public protocol GeoJson: /// Any foreign members, i.e. keys in the JSON that are /// not part of the GeoJSON standard. /// - important: `values` must be a valid JSON objects or serialization will fail. - var foreignMembers: [String: Any] { get set } + var foreignMembers: [String: Sendable] { get set } /// Try to initialize a GeoJSON object from any JSON and calculate a bounding box if necessary. /// diff --git a/Sources/GISTools/GeoJson/GeometryCollection.swift b/Sources/GISTools/GeoJson/GeometryCollection.swift index d8d401f..ae27909 100644 --- a/Sources/GISTools/GeoJson/GeometryCollection.swift +++ b/Sources/GISTools/GeoJson/GeometryCollection.swift @@ -20,7 +20,7 @@ public struct GeometryCollection: GeoJsonGeometry { public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] /// Initialize a GeometryCollection with a geometry object. public init(_ geometry: GeoJsonGeometry, calculateBoundingBox: Bool = false) { diff --git a/Sources/GISTools/GeoJson/LineSegment.swift b/Sources/GISTools/GeoJson/LineSegment.swift index 0491954..74fcb06 100644 --- a/Sources/GISTools/GeoJson/LineSegment.swift +++ b/Sources/GISTools/GeoJson/LineSegment.swift @@ -4,7 +4,7 @@ import CoreLocation import Foundation /// A `LineSegment` is a line with exactly two coordinates. -public struct LineSegment: Projectable, Sendable { +public struct LineSegment: Sendable { public var boundingBox: BoundingBox? @@ -46,7 +46,7 @@ extension LineSegment { // MARK: - Projection -extension LineSegment { +extension LineSegment: Projectable { public func projected(to newProjection: Projection) -> LineSegment { guard newProjection != projection else { return self } diff --git a/Sources/GISTools/GeoJson/LineString.swift b/Sources/GISTools/GeoJson/LineString.swift index 7c5194f..8974cab 100644 --- a/Sources/GISTools/GeoJson/LineString.swift +++ b/Sources/GISTools/GeoJson/LineString.swift @@ -26,7 +26,7 @@ public struct LineString: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var lineStrings: [LineString] { return [self] diff --git a/Sources/GISTools/GeoJson/MultiLineString.swift b/Sources/GISTools/GeoJson/MultiLineString.swift index 1a75cb9..e9f7855 100644 --- a/Sources/GISTools/GeoJson/MultiLineString.swift +++ b/Sources/GISTools/GeoJson/MultiLineString.swift @@ -26,7 +26,7 @@ public struct MultiLineString: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var lineStrings: [LineString] { return coordinates.compactMap { LineString($0) } diff --git a/Sources/GISTools/GeoJson/MultiPoint.swift b/Sources/GISTools/GeoJson/MultiPoint.swift index e3a506a..102f2f2 100644 --- a/Sources/GISTools/GeoJson/MultiPoint.swift +++ b/Sources/GISTools/GeoJson/MultiPoint.swift @@ -26,7 +26,7 @@ public struct MultiPoint: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var points: [Point] { return coordinates.map { Point($0) } diff --git a/Sources/GISTools/GeoJson/MultiPolygon.swift b/Sources/GISTools/GeoJson/MultiPolygon.swift index c3fd8ca..80baa26 100644 --- a/Sources/GISTools/GeoJson/MultiPolygon.swift +++ b/Sources/GISTools/GeoJson/MultiPolygon.swift @@ -26,7 +26,7 @@ public struct MultiPolygon: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var polygons: [Polygon] { return coordinates.compactMap { Polygon($0) } diff --git a/Sources/GISTools/GeoJson/Point.swift b/Sources/GISTools/GeoJson/Point.swift index f59bdd1..fb21fff 100644 --- a/Sources/GISTools/GeoJson/Point.swift +++ b/Sources/GISTools/GeoJson/Point.swift @@ -23,7 +23,7 @@ public struct Point: PointGeometry { public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var points: [Point] { return [self] diff --git a/Sources/GISTools/GeoJson/Polygon.swift b/Sources/GISTools/GeoJson/Polygon.swift index 5b8d00e..75e644d 100644 --- a/Sources/GISTools/GeoJson/Polygon.swift +++ b/Sources/GISTools/GeoJson/Polygon.swift @@ -26,7 +26,7 @@ public struct Polygon: public var boundingBox: BoundingBox? - public var foreignMembers: [String: Any] = [:] + public var foreignMembers: [String: Sendable] = [:] public var polygons: [Polygon] { return [self] diff --git a/Sources/GISTools/GeoJson/Projectable.swift b/Sources/GISTools/GeoJson/Projectable.swift index 859d211..066f1f9 100644 --- a/Sources/GISTools/GeoJson/Projectable.swift +++ b/Sources/GISTools/GeoJson/Projectable.swift @@ -9,3 +9,14 @@ public protocol Projectable { func projected(to newProjection: Projection) -> Self } + +extension Projectable { + + /// Reproject this coordinate. + public mutating func project(to newProjection: Projection) { + guard newProjection != projection else { return } + + self = projected(to: newProjection) + } + +} diff --git a/Sources/GISTools/GeoJson/RTree.swift b/Sources/GISTools/GeoJson/RTree.swift index d1109d0..06c9164 100644 --- a/Sources/GISTools/GeoJson/RTree.swift +++ b/Sources/GISTools/GeoJson/RTree.swift @@ -33,7 +33,7 @@ public enum RTreeSortOption: Sendable { // MARK: - RTree /// An efficient implementation of the packed Hilbert R-tree algorithm. -public struct RTree { +public struct RTree: Sendable { /// The R-Tree's `projection`. public let projection: Projection diff --git a/Sources/GISTools/GeoJson/Ring.swift b/Sources/GISTools/GeoJson/Ring.swift index c62a7e1..00b31c3 100644 --- a/Sources/GISTools/GeoJson/Ring.swift +++ b/Sources/GISTools/GeoJson/Ring.swift @@ -14,10 +14,7 @@ import Foundation /// - A linear ring MUST follow the right-hand rule with respect to the /// area it bounds, i.e., exterior rings are counterclockwise, and /// holes are clockwise. -public struct Ring: - Projectable, - Sendable -{ +public struct Ring: Sendable { public var projection: Projection { coordinates.first?.projection ?? .noSRID @@ -47,7 +44,7 @@ public struct Ring: // MARK: - Projection -extension Ring { +extension Ring: Projectable { public func projected(to newProjection: Projection) -> Ring { guard newProjection != projection else { return self } diff --git a/Sources/GISTools/Other/MapTile.swift b/Sources/GISTools/Other/MapTile.swift index 4c2cec5..f80b8e4 100644 --- a/Sources/GISTools/Other/MapTile.swift +++ b/Sources/GISTools/Other/MapTile.swift @@ -3,7 +3,7 @@ import CoreLocation #endif import Foundation -public struct MapTile: CustomStringConvertible { +public struct MapTile: CustomStringConvertible, Sendable { public let x: Int public let y: Int diff --git a/Tests/GISToolsTests/GeoJson/BoundingBoxTests.swift b/Tests/GISToolsTests/GeoJson/BoundingBoxTests.swift index ce7ea8a..2ffa485 100644 --- a/Tests/GISToolsTests/GeoJson/BoundingBoxTests.swift +++ b/Tests/GISToolsTests/GeoJson/BoundingBoxTests.swift @@ -518,4 +518,39 @@ final class BoundingBoxTests: XCTestCase { XCTAssertEqual(clamped, BoundingBox.world) } + // MARK: - Expanding + + func testExpanding() throws { + let bbox1 = try XCTUnwrap(BoundingBox(coordinates: [.zero])) + let bbox1_3857 = bbox1.projected(to: .epsg3857) + + // Note: Expands diagonally + let bbox2_3857_distance = bbox1_3857.expanded(byDistance: 1000.0) + let bbox2_degrees = bbox1.expanded(byDegrees: 1.0) + + XCTAssertEqual(Coordinate3D.zero.distance(from: bbox2_3857_distance.southWest), 1000.0, accuracy: 0.00001) + XCTAssertEqual(Coordinate3D.zero.distance(from: bbox2_3857_distance.northEast), 1000.0, accuracy: 0.00001) + XCTAssertEqual(bbox2_degrees.southWest, Coordinate3D(latitude: -1.0, longitude: -1.0)) + XCTAssertEqual(bbox2_degrees.northEast, Coordinate3D(latitude: 1.0, longitude: 1.0)) + + let bbox2_distance = bbox1.expanded(byDistance: 1000.0) + let bbox2_3857_degrees = bbox1_3857.expanded(byDegrees: 1.0) + + XCTAssertEqual(bbox2_distance.projected(to: .epsg3857), bbox2_3857_distance) + XCTAssertEqual(bbox2_degrees, bbox2_3857_degrees.projected(to: .epsg4326)) + + // Note: Expanded horizontally and vertically + let bbox3_3857 = try XCTUnwrap(BoundingBox(coordinates: [.zero.projected(to: .epsg3857)], padding: 1000.0)) + let bbox3_4326 = try XCTUnwrap(BoundingBox(coordinates: [.zero], padding: 1000.0)) + + XCTAssertEqual(bbox3_3857.southWest.x, -1000.0, accuracy: 0.00001) + XCTAssertEqual(bbox3_3857.southWest.y, -1000.0, accuracy: 0.00001) + XCTAssertEqual(bbox3_3857.northEast.x, 1000.0, accuracy: 0.00001) + XCTAssertEqual(bbox3_3857.northEast.x, 1000.0, accuracy: 0.00001) + XCTAssertEqual(bbox3_4326.projected(to: .epsg3857).southWest.x, bbox3_3857.southWest.x, accuracy: 0.00001) + XCTAssertEqual(bbox3_4326.projected(to: .epsg3857).southWest.y, bbox3_3857.southWest.y, accuracy: 0.00001) + XCTAssertEqual(bbox3_4326.projected(to: .epsg3857).northEast.x, bbox3_3857.northEast.x, accuracy: 0.00001) + XCTAssertEqual(bbox3_4326.projected(to: .epsg3857).northEast.y, bbox3_3857.northEast.y, accuracy: 0.00001) + } + }