From 6c508c7144ee7440cb4a652c709a4fb23575fb5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 23:44:47 +0000 Subject: [PATCH 1/3] Add inverse projection support so callers can map a click back to lat/lon Adds a required `inverse(_:)` method to the `Projection` protocol and implements it for all 8 in-tree projections, plus a high-level `coordinate(at:size:zoomTo:insets:coordinateSystem:)` API that turns a screen-pixel `Point` into a `GeoJSON.Position?`. EqualEarth and NaturalEarth use Newton-Raphson; the rest are closed-form. Out-of-image clicks (e.g. off the globe of an Orthographic map) return nil via a new `MapBounds.contains` helper. https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7 --- Sources/GeoProjector/MapBounds+Contains.swift | 52 ++++++ Sources/GeoProjector/Projection.swift | 100 ++++++++++++ .../GeoProjector/Projections+Azimuthal.swift | 17 ++ .../Projections+Cylindrical.swift | 33 +++- .../Projections+Orthographic.swift | 17 +- .../Projections+Pseudocylindrical.swift | 62 +++++++- Sources/GeoProjector/Projections.swift | 10 ++ Tests/GeoProjectorTests/InverseTests.swift | 149 ++++++++++++++++++ 8 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 Sources/GeoProjector/MapBounds+Contains.swift create mode 100644 Tests/GeoProjectorTests/InverseTests.swift diff --git a/Sources/GeoProjector/MapBounds+Contains.swift b/Sources/GeoProjector/MapBounds+Contains.swift new file mode 100644 index 0000000..49fd210 --- /dev/null +++ b/Sources/GeoProjector/MapBounds+Contains.swift @@ -0,0 +1,52 @@ +// +// MapBounds+Contains.swift +// +// +// Created by Adrian Schönig on 8/5/2026. +// +// GeoProjector - Native Swift library for drawing map projections +// Copyright (C) 2022 Corporoni Pty Ltd. See LICENSE. + +import Foundation + +extension MapBounds { + /// Returns `true` if the projected `point` lies inside the projection's image. + /// + /// The projection's image is centred at `(0, 0)` with half-extents + /// `(projectionSize.width / 2, projectionSize.height / 2)`. + func contains(_ point: Point, projectionSize: Size) -> Bool { + let halfW = projectionSize.width / 2 + let halfH = projectionSize.height / 2 + let eps = 1e-12 + + switch self { + case .rectangle: + return point.x >= -halfW - eps && point.x <= halfW + eps + && point.y >= -halfH - eps && point.y <= halfH + eps + + case .ellipse: + let nx = point.x / halfW + let ny = point.y / halfH + return (nx * nx + ny * ny) <= 1.0 + eps + + case .bezier(let pts): + return MapBounds.pointInPolygon(point, polygon: pts) + } + } + + /// Standard ray-casting point-in-polygon test. Vertices are accepted as inside. + static func pointInPolygon(_ p: Point, polygon: [Point]) -> Bool { + guard polygon.count >= 3 else { return false } + var inside = false + var j = polygon.count - 1 + for i in 0.. p.y) != (pj.y > p.y)) + && (p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x) + if intersects { inside.toggle() } + j = i + } + return inside + } +} diff --git a/Sources/GeoProjector/Projection.swift b/Sources/GeoProjector/Projection.swift index 6087c2f..b99b454 100644 --- a/Sources/GeoProjector/Projection.swift +++ b/Sources/GeoProjector/Projection.swift @@ -39,6 +39,15 @@ public protocol Projection { /// ``projectionSize``. func project(_ point: Point) -> Point? + /// Inverse of ``project(_:)``. Maps a projected point (the output of ``project(_:)``, + /// in the projection's internal radian coordinate system) back to a geographic + /// coordinate in radians: x (longitude) in `-pi...pi`, y (latitude) in `(-pi/2)...(pi/2)`. + /// + /// Returns `nil` if the input lies outside the projection's image (e.g. outside the + /// unit ellipse for Orthographic, outside the bezier outline for EqualEarth/NaturalEarth, + /// or beyond the rectangle for the cylindricals). + func inverse(_ point: Point) -> Point? + func willWrap(_ point: Point) -> Bool /// The maximum width/height that the projection uses, in radians. @@ -144,6 +153,97 @@ extension Projection { ) } + /// Inverse of ``translate(_:to:zoomTo:insets:coordinateSystem:)``: turns a screen-space + /// point (e.g. the location of a click) back into projected radians. + public func untranslate(_ point: Point, from size: Size, zoomTo: Rect? = nil, insets: EdgeInsets = .zero, coordinateSystem: CoordinateSystem) -> Point { + let availableSize = Size( + width: size.width - insets.left - insets.right, + height: size.height - insets.top - insets.bottom + ) + + let xInAvailable = point.x - insets.left + let yInAvailable: Double + switch coordinateSystem { + case .bottomLeft: + yInAvailable = point.y - insets.bottom + case .topLeft: + yInAvailable = availableSize.height - (point.y - insets.top) + } + let pointInAvailable = Point(x: xInAvailable, y: yInAvailable) + + if let zoomTo, zoomTo.size != .zero { + return zoomedUntranslate(pointInAvailable, zoomTo: zoomTo, from: availableSize) + } else { + return simpleUntranslate(pointInAvailable, from: availableSize) + } + } + + /// Inverse of ``point(for:size:zoomTo:insets:coordinateSystem:)``. + /// + /// Given a screen-space point (e.g. a click), returns the geographic position in + /// **degrees**, or `nil` if the click is outside the projection's image (e.g. clicking + /// off the globe of an Orthographic map, or off the bezier outline of EqualEarth). + public func coordinate(at point: Point, size: Size, zoomTo: Rect? = nil, insets: EdgeInsets = .zero, coordinateSystem: CoordinateSystem) -> GeoJSON.Position? { + let projected = untranslate(point, from: size, zoomTo: zoomTo, insets: insets, coordinateSystem: coordinateSystem) + guard let geoRad = inverse(projected) else { return nil } + return .init(latitude: geoRad.y.toDegrees(), longitude: geoRad.x.toDegrees()) + } + + private func simpleUntranslate(_ point: Point, from size: Size) -> Point { + let myRatio = projectionSize.aspectRatio + let targetRatio = size.aspectRatio + + let canvasSize: Size + if myRatio > targetRatio { + canvasSize = .init(width: size.width, height: size.width / myRatio) + } else { + canvasSize = .init(width: size.height * myRatio, height: size.height) + } + + let canvasOffset = Point( + x: (size.width - canvasSize.width) / 2, + y: (size.height - canvasSize.height) / 2 + ) + + let normalized = Point( + x: (point.x - canvasOffset.x) / canvasSize.width, + y: (point.y - canvasOffset.y) / canvasSize.height + ) + + return .init( + x: normalized.x * projectionSize.width - projectionSize.width / 2, + y: normalized.y * projectionSize.height - projectionSize.height / 2 + ) + } + + private func zoomedUntranslate(_ point: Point, zoomTo: Rect, from size: Size) -> Point { + assert(zoomTo.size != .zero) + let myRatio = zoomTo.size.aspectRatio + let targetRatio = size.aspectRatio + + let canvasSize: Size + if myRatio > targetRatio { + canvasSize = .init(width: size.width, height: size.width / myRatio) + } else { + canvasSize = .init(width: size.height * myRatio, height: size.height) + } + + let canvasOffset = Point( + x: (size.width - canvasSize.width) / 2, + y: (size.height - canvasSize.height) / 2 + ) + + let normalized = Point( + x: (point.x - canvasOffset.x) / canvasSize.width, + y: (point.y - canvasOffset.y) / canvasSize.height + ) + + return .init( + x: normalized.x * zoomTo.size.width + zoomTo.origin.x, + y: normalized.y * zoomTo.size.height + zoomTo.origin.y + ) + } + private func zoomedTranslate(_ point: Point, zoomTo: Rect, to size: Size) -> Point { assert(zoomTo.size != .zero) let myRatio = zoomTo.size.aspectRatio diff --git a/Sources/GeoProjector/Projections+Azimuthal.swift b/Sources/GeoProjector/Projections+Azimuthal.swift index b4e55e7..1780669 100644 --- a/Sources/GeoProjector/Projections+Azimuthal.swift +++ b/Sources/GeoProjector/Projections+Azimuthal.swift @@ -54,6 +54,23 @@ extension Projections { y: k * (cos(reference.y) * sin(point.y) - sin(reference.y) * cos(point.y) * cos(point.x - reference.x)) ) } + + public func inverse(_ point: Point) -> Point? { + // For the Azimuthal Equidistant projection, the planar radius equals the + // angular distance c, so c = rho directly (no asin like in Orthographic). + let X = point.x, Y = point.y + let rho = sqrt(X*X + Y*Y) + guard rho <= .pi + 1e-12 else { return nil } + if rho < 1e-15 { return reference } + let c = rho + let sinC = sin(c), cosC = cos(c) + let phi = asin(cosC * sin(reference.y) + (Y * sinC * cos(reference.y)) / rho) + let lam = reference.x + atan2( + X * sinC, + rho * cos(reference.y) * cosC - Y * sin(reference.y) * sinC + ) + return .init(x: Projections.wrapLongitude(lam), y: phi) + } private func k(_ point: Point) -> Double { let c = self.c(point) diff --git a/Sources/GeoProjector/Projections+Cylindrical.swift b/Sources/GeoProjector/Projections+Cylindrical.swift index f206cb1..9280532 100644 --- a/Sources/GeoProjector/Projections+Cylindrical.swift +++ b/Sources/GeoProjector/Projections+Cylindrical.swift @@ -70,7 +70,13 @@ extension Projections { y: adjusted.y ) } - + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + let lambda = Projections.wrapLongitude(point.x / cos(phiOne) + reference.x) + return .init(x: lambda, y: point.y) + } + } /// https://en.wikipedia.org/wiki/Cassini_projection @@ -98,6 +104,14 @@ extension Projections { y: atan2(sin(point.phi), cos(point.phi) * cos(point.lambda)) ) } + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + let X = point.x, Y = point.y + let phi = asin(sin(Y) * cos(X)) + let lam = atan2(tan(X), cos(Y)) + return .init(x: lam, y: phi) + } } /// Web standard @@ -123,12 +137,19 @@ extension Projections { public func project(_ point: Point) -> Point? { var adjusted = Projections.adjust(point, reference: reference) adjusted.y = min(Self.maxLat, max(Self.maxLat * -1, adjusted.y)) - + return .init( x: adjusted.x, y: log(tan(.pi / 4 + adjusted.y / 2)) ) } + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + let phi = 2 * atan(exp(point.y)) - .pi / 2 + let lam = Projections.wrapLongitude(point.x + reference.x) + return .init(x: lam, y: phi) + } } /// Ugly, but good equal-area representation @@ -157,6 +178,14 @@ extension Projections { y: 2 * sin(adjusted.y) ) } + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + let yClamped = min(2.0, max(-2.0, point.y)) + let phi = asin(yClamped / 2) + let lam = Projections.wrapLongitude(point.x + reference.x) + return .init(x: lam, y: phi) + } } } diff --git a/Sources/GeoProjector/Projections+Orthographic.swift b/Sources/GeoProjector/Projections+Orthographic.swift index 321372e..5355bb6 100644 --- a/Sources/GeoProjector/Projections+Orthographic.swift +++ b/Sources/GeoProjector/Projections+Orthographic.swift @@ -33,12 +33,27 @@ extension Projections { if clip, isOnBackside(point) { return nil } - + return .init( x: cos(point.y) * sin(point.x - reference.x), y: cos(reference.y) * sin(point.y) - sin(reference.y) * cos(point.y) * cos(point.x - reference.x) ) } + + public func inverse(_ point: Point) -> Point? { + let X = point.x, Y = point.y + let rho = sqrt(X*X + Y*Y) + guard rho <= 1.0 + 1e-12 else { return nil } + if rho < 1e-15 { return reference } + let c = asin(min(1.0, rho)) + let sinC = sin(c), cosC = cos(c) + let phi = asin(cosC * sin(reference.y) + (Y * sinC * cos(reference.y)) / rho) + let lam = reference.x + atan2( + X * sinC, + rho * cos(reference.y) * cosC - Y * sin(reference.y) * sinC + ) + return .init(x: Projections.wrapLongitude(lam), y: phi) + } public func willWrap(_ point: Point) -> Bool { let adjusted = point.x - reference.x diff --git a/Sources/GeoProjector/Projections+Pseudocylindrical.swift b/Sources/GeoProjector/Projections+Pseudocylindrical.swift index 012bee0..7b16851 100644 --- a/Sources/GeoProjector/Projections+Pseudocylindrical.swift +++ b/Sources/GeoProjector/Projections+Pseudocylindrical.swift @@ -73,7 +73,25 @@ extension Projections { let adjusted = Projections.adjust(point, reference: reference) return Self.project(adjusted) } - + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + // Newton-Raphson on theta: f(θ) = poly9(θ) - Y, f'(θ) = poly8(θ). + // Matches d3-geo's equalEarthInvert. + var theta = point.y + for _ in 0..<12 { + let f = Self.poly9(theta) - point.y + let fp = Self.poly8(theta) + let delta = f / fp + theta -= delta + if abs(delta) < 1e-12 { break } + } + let sinTheta = sin(theta) + let phi = asin(min(1.0, max(-1.0, sinTheta / Self.B))) + let lambda = point.x * Self.B * Self.poly8(theta) / cos(theta) + return .init(x: Projections.wrapLongitude(lambda + reference.x), y: phi) + } + private static func project(_ point: Point) -> Point { let th = asin(Self.B * sin(point.y)) return .init( @@ -148,19 +166,53 @@ extension Projections { let adjusted = Projections.adjust(point, reference: reference) return Self.project(adjusted) } - + + public func inverse(_ point: Point) -> Point? { + guard mapBounds.contains(point, projectionSize: projectionSize) else { return nil } + // Newton-Raphson on phi from y(phi). Matches d3-geo-projection's naturalEarth1Invert. + var phi = point.y + for _ in 0..<25 { + let f = Self.yOfPhi(phi) - point.y + let fp = Self.dyOfPhi(phi) + let delta = f / fp + phi -= delta + if abs(delta) < 1e-9 { break } + } + phi = min(.pi/2, max(-.pi/2, phi)) + let lam = point.x / Self.fxOfPhi(phi) + return .init(x: Projections.wrapLongitude(lam + reference.x), y: phi) + } + private static func project(_ point: Point) -> Point { let phi = point.y let lam = point.x - + let phi2 = phi * phi let phi4 = phi2 * phi2 - + let x = lam * (A0 + phi2 * (A1 + phi2 * (A2 + phi4 * phi2 * (A3 + phi2 * A4)))) let y = phi * (B0 + phi2 * (B1 + phi4 * (B2 + B3 * phi2 + B4 * phi4))) - + return .init(x: x, y: y) } + + // y(phi) = B0·φ + B1·φ³ + B2·φ⁷ + B3·φ⁹ + B4·φ¹¹ + private static func yOfPhi(_ phi: Double) -> Double { + let p2 = phi * phi, p4 = p2 * p2 + return phi * (B0 + p2 * (B1 + p4 * (B2 + B3 * p2 + B4 * p4))) + } + + // dy/dphi = B0 + 3·B1·φ² + 7·B2·φ⁶ + 9·B3·φ⁸ + 11·B4·φ¹⁰ + private static func dyOfPhi(_ phi: Double) -> Double { + let p2 = phi * phi, p4 = p2 * p2, p6 = p4 * p2, p8 = p4 * p4, p10 = p8 * p2 + return B0 + 3 * B1 * p2 + 7 * B2 * p6 + 9 * B3 * p8 + 11 * B4 * p10 + } + + // Coefficient of lambda in the forward x(lambda, phi) expression. + private static func fxOfPhi(_ phi: Double) -> Double { + let p2 = phi * phi, p4 = p2 * p2 + return A0 + p2 * (A1 + p2 * (A2 + p4 * p2 * (A3 + p2 * A4))) + } } } diff --git a/Sources/GeoProjector/Projections.swift b/Sources/GeoProjector/Projections.swift index 85e3c06..bea16ba 100644 --- a/Sources/GeoProjector/Projections.swift +++ b/Sources/GeoProjector/Projections.swift @@ -12,3 +12,13 @@ import Foundation /// Namespace for all projections public enum Projections {} +extension Projections { + /// Wraps a longitude in radians back into `-pi...pi`. + static func wrapLongitude(_ x: Double) -> Double { + var v = x + if v > .pi { v -= 2 * .pi } + if v < -.pi { v += 2 * .pi } + return v + } +} + diff --git a/Tests/GeoProjectorTests/InverseTests.swift b/Tests/GeoProjectorTests/InverseTests.swift new file mode 100644 index 0000000..19f7db4 --- /dev/null +++ b/Tests/GeoProjectorTests/InverseTests.swift @@ -0,0 +1,149 @@ +#if canImport(Testing) +import Testing +import Foundation + +import GeoJSONKit +@testable import GeoProjector + +struct InverseTests { + + // Sample lat/lon grid (degrees), kept inside every projection's domain + // so that even Mercator (clipped at ±85.05°) is happy. + private static let grid: [(lat: Double, lon: Double)] = stride(from: -80.0, through: 80.0, by: 20).flatMap { lat in + stride(from: -170.0, through: 170.0, by: 30).map { lon in (lat, lon) } + } + + private func roundTrip(_ proj: P, tolerance: Double, label: String) { + for (lat, lon) in Self.grid { + let geo = Point(x: lon * .pi / 180, y: lat * .pi / 180) + guard let projected = proj.project(geo) else { continue } + guard let recovered = proj.inverse(projected) else { + Issue.record("\(label): inverse returned nil for valid projected point at (\(lat),\(lon))") + continue + } + var dx = recovered.x - geo.x + while dx > .pi { dx -= 2 * .pi } + while dx < -.pi { dx += 2 * .pi } + let dy = recovered.y - geo.y + // At |lat| → 90° longitude becomes degenerate; relax that one. + let lonTolerance = abs(lat) >= 89.9 ? 1e-3 : tolerance + #expect(abs(dx) < lonTolerance, "\(label): lon mismatch at (\(lat),\(lon)): dx=\(dx)") + #expect(abs(dy) < tolerance, "\(label): lat mismatch at (\(lat),\(lon)): dy=\(dy)") + } + } + + @Test func equirectangularRoundTrip() { + roundTrip(Projections.Equirectangular(), tolerance: 1e-9, label: "Equirectangular") + } + + @Test func cassiniRoundTrip() { + roundTrip(Projections.Cassini(), tolerance: 1e-9, label: "Cassini") + } + + @Test func mercatorRoundTrip() { + roundTrip(Projections.Mercator(), tolerance: 1e-9, label: "Mercator") + } + + @Test func gallPetersRoundTrip() { + roundTrip(Projections.GallPeters(), tolerance: 1e-9, label: "GallPeters") + } + + @Test func azimuthalRoundTrip() { + roundTrip(Projections.AzimuthalEquidistant(), tolerance: 1e-9, label: "AzimuthalEquidistant") + } + + @Test func orthographicRoundTrip() { + roundTrip(Projections.Orthographic(), tolerance: 1e-9, label: "Orthographic") + } + + @Test func equalEarthRoundTrip() { + roundTrip(Projections.EqualEarth(), tolerance: 1e-6, label: "EqualEarth") + } + + @Test func naturalEarthRoundTrip() { + roundTrip(Projections.NaturalEarth(), tolerance: 1e-6, label: "NaturalEarth") + } + + @Test func roundTripWithReference() { + let ortho = Projections.Orthographic(reference: .init(latitude: -33.8, longitude: 151.3)) + let geo = Point(x: 145.0 * .pi / 180, y: -37.8 * .pi / 180) // Melbourne + let projected = ortho.project(geo)! + let recovered = ortho.inverse(projected)! + #expect(abs(recovered.x - geo.x) < 1e-9) + #expect(abs(recovered.y - geo.y) < 1e-9) + } + + @Test func orthographicRejectsOutsideDisk() { + let ortho = Projections.Orthographic() + #expect(ortho.inverse(.init(x: 2.0, y: 0.0)) == nil) + #expect(ortho.inverse(.init(x: 0.0, y: 0.0)) != nil) + } + + @Test func equalEarthRejectsOutsideBezier() { + let ee = Projections.EqualEarth() + let halfW = ee.projectionSize.width / 2 + let halfH = ee.projectionSize.height / 2 + // Top-right corner of the bounding rect is well outside the bezier outline. + #expect(ee.inverse(.init(x: halfW * 0.99, y: halfH * 0.99)) == nil) + // Centre of the projection is always inside. + #expect(ee.inverse(.init(x: 0, y: 0)) != nil) + } + + @Test func clickAtCentreReturnsZero() { + let proj = Projections.Equirectangular() + let size = Size(width: 200, height: 100) + let geo = proj.coordinate(at: .init(x: 100, y: 50), size: size, coordinateSystem: .topLeft) + #expect(geo != nil) + #expect(abs((geo?.latitude ?? 1) - 0) < 1e-9) + #expect(abs((geo?.longitude ?? 1) - 0) < 1e-9) + } + + @Test func clickAtTopLeftReturnsExtremes() { + let proj = Projections.Equirectangular() + let size = Size(width: 200, height: 100) + let tl = proj.coordinate(at: .init(x: 0, y: 0), size: size, coordinateSystem: .topLeft) + #expect(tl != nil) + #expect(abs((tl?.latitude ?? 0) - 90) < 1e-9) + #expect(abs((tl?.longitude ?? 0) + 180) < 1e-9) + } + + @Test func clickOutsideOrthoGlobeReturnsNil() { + let proj = Projections.Orthographic() + let size = Size(width: 100, height: 100) + // Corners of a square render are outside the inscribed disk. + #expect(proj.coordinate(at: .init(x: 1, y: 1), size: size, coordinateSystem: .topLeft) == nil) + #expect(proj.coordinate(at: .init(x: 99, y: 99), size: size, coordinateSystem: .topLeft) == nil) + // Centre is the reference point: (0,0). + let centre = proj.coordinate(at: .init(x: 50, y: 50), size: size, coordinateSystem: .topLeft) + #expect(centre != nil) + #expect(abs(centre?.latitude ?? 1) < 1e-9) + #expect(abs(centre?.longitude ?? 1) < 1e-9) + } + + @Test func clickRoundTripsThroughForward() { + // A click at a known pixel should match what `point(for:)` produces for the same coord. + let proj = Projections.NaturalEarth() + let size = Size(width: 400, height: 200) + let geo = GeoJSON.Position(latitude: 35.0, longitude: -120.0) + + let pixel = proj.point(for: .init(x: geo.longitude.toRadians(), y: geo.latitude.toRadians()), + size: size, coordinateSystem: .topLeft)?.0 + #expect(pixel != nil) + + let recovered = proj.coordinate(at: pixel!, size: size, coordinateSystem: .topLeft) + #expect(recovered != nil) + #expect(abs((recovered?.latitude ?? 0) - geo.latitude) < 1e-4) + #expect(abs((recovered?.longitude ?? 0) - geo.longitude) < 1e-4) + } + + @Test func clickWithBottomLeftCoordinateSystem() { + let proj = Projections.Equirectangular() + let size = Size(width: 200, height: 100) + // In bottomLeft, (0, 0) is the bottom-left pixel: (lat -90, lon -180). + let bl = proj.coordinate(at: .init(x: 0, y: 0), size: size, coordinateSystem: .bottomLeft) + #expect(bl != nil) + #expect(abs((bl?.latitude ?? 0) + 90) < 1e-9) + #expect(abs((bl?.longitude ?? 0) + 180) < 1e-9) + } +} +#endif From 17c2547094df55976cfe10f2ee8cb04d425731d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 00:01:25 +0000 Subject: [PATCH 2/3] Cassini: status bar with live + lockable inverse coordinate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bottom status bar to the macOS Cassini sample that shows the geographic coordinate under the cursor, computed via the new `Projection.coordinate(at:size:zoomTo:insets:coordinateSystem:)` API. Clicking the map locks the coordinate; the status bar then offers Copy (to clipboard, returns to live mode) and Discard (returns to live mode without copying), with ⌘C and Esc shortcuts. Exposes `GeoDrawer.zoomTo` so the example can mirror the drawer's projected zoom rect when computing inverses. https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7 --- Examples/Cassini/ContentView+Model.swift | 9 ++ Examples/Cassini/ContentView.swift | 119 ++++++++++++++++++++--- Sources/GeoDrawer/GeoDrawer.swift | 2 +- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/Examples/Cassini/ContentView+Model.swift b/Examples/Cassini/ContentView+Model.swift index d053a72..7f4ab97 100644 --- a/Examples/Cassini/ContentView+Model.swift +++ b/Examples/Cassini/ContentView+Model.swift @@ -102,6 +102,15 @@ extension ContentView { layer.contents.map { $0.settingColor(layer.color) } } } + + /// The same projected `Rect` that `GeoDrawer` (and therefore `GeoMap`) uses for the + /// current `zoomTo` bounding box, suitable for passing to `Projection.coordinate(at:...)`. + var projectedZoomTo: Rect? { + guard let box = zoomTo?.0 else { return nil } + // Size doesn't influence the computed zoom rect — pick anything. + let drawer = GeoDrawer(size: .init(width: 100, height: 100), projection: projection, zoomTo: box, insets: insets) + return drawer.zoomTo + } func addLayer(_ data: Data, preferredName: String?) throws { let geoJSON = try GeoJSON(data: data) diff --git a/Examples/Cassini/ContentView.swift b/Examples/Cassini/ContentView.swift index 5c1b1b8..527351a 100644 --- a/Examples/Cassini/ContentView.swift +++ b/Examples/Cassini/ContentView.swift @@ -9,8 +9,13 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif + import GeoDrawer import GeoJSONKit +import GeoProjector struct ContentView: View { @ObservedObject var model: Model @@ -27,28 +32,116 @@ struct ContentView: View { #if os(macOS) struct ContentView_macOS: View { @ObservedObject var model: ContentView.Model - + @Environment(\.colorScheme) var colorScheme - + + @State private var hoverCoord: GeoJSON.Position? + @State private var lockedCoord: GeoJSON.Position? + var body: some View { HSplitView { OptionsView(model: model) .frame(maxWidth: 300) - - VStack { - GeoMap( - contents: model.visibleContents, - projection: model.projection, - zoomTo: model.zoomTo?.0, - insets: model.insets, - mapBackground: colorScheme == .dark ? .systemPurple : .systemTeal, - mapOutline: colorScheme == .dark ? .white : .black + + VStack(spacing: 0) { + GeometryReader { geo in + GeoMap( + contents: model.visibleContents, + projection: model.projection, + zoomTo: model.zoomTo?.0, + insets: model.insets, + mapBackground: colorScheme == .dark ? .systemPurple : .systemTeal, + mapOutline: colorScheme == .dark ? .white : .black + ) + .onContinuousHover { phase in + switch phase { + case .active(let location): + hoverCoord = coordinate(at: location, in: geo.size) + case .ended: + hoverCoord = nil + } + } + .onTapGesture(coordinateSpace: .local) { location in + if let coord = coordinate(at: location, in: geo.size) { + lockedCoord = coord + } + } + } + .padding() + + MapStatusBar( + live: hoverCoord, + locked: lockedCoord, + onCopy: copyLockedCoord, + onDiscard: { lockedCoord = nil } ) } - .padding() } - } + + private func coordinate(at location: CGPoint, in size: CGSize) -> GeoJSON.Position? { + model.projection.coordinate( + at: .init(x: location.x, y: location.y), + size: .init(width: size.width, height: size.height), + zoomTo: model.projectedZoomTo, + insets: model.insets, + coordinateSystem: .topLeft + ) + } + + private func copyLockedCoord() { + guard let lockedCoord else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(formatCoord(lockedCoord), forType: .string) + self.lockedCoord = nil + } +} + +struct MapStatusBar: View { + let live: GeoJSON.Position? + let locked: GeoJSON.Position? + let onCopy: () -> Void + let onDiscard: () -> Void + + var body: some View { + HStack(spacing: 8) { + if let locked { + Image(systemName: "mappin.circle.fill") + .foregroundStyle(.tint) + Text(formatCoord(locked)) + .font(.system(.body, design: .monospaced)) + Spacer() + Button("Copy", action: onCopy) + .keyboardShortcut("c", modifiers: [.command]) + Button("Discard", role: .cancel, action: onDiscard) + .keyboardShortcut(.escape, modifiers: []) + } else if let live { + Image(systemName: "mappin.and.ellipse") + .foregroundStyle(.secondary) + Text(formatCoord(live)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + } else { + Image(systemName: "mappin.slash") + .foregroundStyle(.tertiary) + Text("Hover over the map to see coordinates") + .foregroundStyle(.secondary) + Spacer() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, minHeight: 30) + .background(.bar) + .overlay(Divider(), alignment: .top) + } +} + +private func formatCoord(_ p: GeoJSON.Position) -> String { + let latHem = p.latitude >= 0 ? "N" : "S" + let lonHem = p.longitude >= 0 ? "E" : "W" + return String(format: "%7.4f° %@ %8.4f° %@", abs(p.latitude), latHem, abs(p.longitude), lonHem) } #else diff --git a/Sources/GeoDrawer/GeoDrawer.swift b/Sources/GeoDrawer/GeoDrawer.swift index 385a21c..ac79a7f 100644 --- a/Sources/GeoDrawer/GeoDrawer.swift +++ b/Sources/GeoDrawer/GeoDrawer.swift @@ -129,7 +129,7 @@ public struct GeoDrawer { public let size: Size - let zoomTo: Rect? + public let zoomTo: Rect? public let insets: EdgeInsets From 6fd434c28d7e2e51bd72f795cf3a698523053159 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 9 May 2026 10:52:20 +1000 Subject: [PATCH 3/3] Update GHA --- .github/workflows/swift.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index f786e3f..266873a 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -10,7 +10,7 @@ jobs: spm_macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build run: swift build - name: Test @@ -20,11 +20,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - swift: ["6.0", "5.10"] + swift: ["6.2", "6.0", "5.10"] container: image: swift:${{ matrix.swift }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build run: swift build - name: Test @@ -33,21 +33,21 @@ jobs: xcode_macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build run: xcodebuild build -scheme GeoProjector-Package -destination 'platform=macOS' xcode_ios: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build - run: xcodebuild build -scheme GeoProjector-Package -destination 'name=iPhone 14' -sdk iphoneos + run: xcodebuild build -scheme GeoProjector-Package -destination 'name=iPhone 17' -sdk iphoneos cassini_macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build run: | cd Examples @@ -56,8 +56,8 @@ jobs: cassini_ios: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build run: | cd Examples - xcodebuild build -scheme 'Cassini' -destination 'platform=iOS Simulator,name=iPhone 14' -sdk iphonesimulator + xcodebuild build -scheme 'Cassini' -destination 'platform=iOS Simulator,name=iPhone 17' -sdk iphonesimulator