diff --git a/Sources/GISTools/GeoJson/BoundingBox.swift b/Sources/GISTools/GeoJson/BoundingBox.swift index 92855ba..c9ff962 100644 --- a/Sources/GISTools/GeoJson/BoundingBox.swift +++ b/Sources/GISTools/GeoJson/BoundingBox.swift @@ -600,6 +600,13 @@ extension BoundingBox { return nil } + /// `true` if the receiver crosses the anti-meridian. + public var crossesAntiMeridian: Bool { + let boundingBox = self.normalized() + + return boundingBox.southWest.longitude > boundingBox.northEast.longitude + } + } // MARK: - Helpers diff --git a/Sources/GISTools/Other/MapTile.swift b/Sources/GISTools/Other/MapTile.swift index ddfee69..41ad3ce 100644 --- a/Sources/GISTools/Other/MapTile.swift +++ b/Sources/GISTools/Other/MapTile.swift @@ -37,6 +37,10 @@ public struct MapTile: CustomStringConvertible, Sendable { ] } + public var siblings: [MapTile] { + parent.children + } + public init(x: Int, y: Int, z: Int) { self.x = x self.y = y @@ -52,6 +56,50 @@ public struct MapTile: CustomStringConvertible, Sendable { self.z = zoom } + // Ported from https://github.com/mapbox/tilebelt/blob/master/index.js + /// Initialize a tile from a bounding box. + /// The resulting tile will have a zoom level in `0...maxZoom`. + /// + /// - parameter boundingBox: The bounding box that the tile should completely contain + /// - parameter maxZoom: The maximum zoom level of the resulting tile, 0...32 + public init( + boundingBox: BoundingBox, + maxZoom: Int = 32) + { + if boundingBox.crossesAntiMeridian { + self.init(x: 0, y: 0, z: 0) + return + } + + let maxZoom = max(0, min(32, maxZoom)) + + let min = MapTile(coordinate: boundingBox.southWest, atZoom: 32) + let max = MapTile(coordinate: boundingBox.northEast, atZoom: 32) + + var bestZ = -1 + for z in 0 ..< maxZoom { + let mask = 1 << (32 - (z + 1)) + if (min.x & mask) != (max.x & mask) + || (min.y & mask) != (max.y & mask) + { + bestZ = z + break + } + } + if bestZ == 0 { + self.init(x: 0, y: 0, z: 0) + return + } + if bestZ == -1 { + bestZ = maxZoom + } + + self.init( + x: min.x >> (32 - bestZ), + y: min.y >> (32 - bestZ), + z: bestZ) + } + public init?(string: String) { guard let components = string.components(separatedBy: "/").nilIfEmpty, components.count == 3, diff --git a/Tests/GISToolsTests/Other/MapTileTests.swift b/Tests/GISToolsTests/Other/MapTileTests.swift index f878784..6539d59 100644 --- a/Tests/GISToolsTests/Other/MapTileTests.swift +++ b/Tests/GISToolsTests/Other/MapTileTests.swift @@ -18,6 +18,25 @@ final class MapTileTests: XCTestCase { } + func testTileFromBoundingBox() throws { + let boundingBox1 = BoundingBox( + southWest: Coordinate3D(latitude: 46.5, longitude: 10.5), + northEast: Coordinate3D(latitude: 48.5, longitude: 11.0)) + let boundingBox2 = BoundingBox( + southWest: Coordinate3D(latitude: 46.5, longitude: 10.5), + northEast: Coordinate3D(latitude: 48.5, longitude: 11.25)) + let boundingBox3 = try XCTUnwrap(BoundingBox(coordinates: [Coordinate3D(latitude: 47.56, longitude: 10.22)])) + + XCTAssertEqual(MapTile(boundingBox: boundingBox1), MapTile(x: 33, y: 22, z: 6)) + XCTAssertEqual(MapTile(boundingBox: boundingBox2), MapTile(x: 8, y: 5, z: 4)) + + XCTAssertEqual(MapTile(boundingBox: boundingBox3), MapTile(x: 2269412997, y: 1500804469, z: 32)) + XCTAssertEqual(MapTile(boundingBox: boundingBox3, maxZoom: 14), MapTile(x: 8657, y: 5725, z: 14)) + XCTAssertEqual(MapTile(boundingBox: boundingBox3, maxZoom: 8), MapTile(x: 135, y: 89, z: 8)) + XCTAssertEqual(MapTile(boundingBox: boundingBox3, maxZoom: 4), MapTile(x: 8, y: 5, z: 4)) + XCTAssertEqual(MapTile(boundingBox: boundingBox3, maxZoom: 0), MapTile(x: 0, y: 0, z: 0)) + } + func testCenter() { let coordinate1 = MapTile(x: 138513, y: 91601, z: 18).centerCoordinate() XCTAssertEqual(coordinate1.latitude, 47.56031069944929, accuracy: 0.00001)