Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
9 changes: 9 additions & 0 deletions Examples/Cassini/ContentView+Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
119 changes: 106 additions & 13 deletions Examples/Cassini/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/GeoDrawer/GeoDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public struct GeoDrawer {

public let size: Size

let zoomTo: Rect?
public let zoomTo: Rect?

public let insets: EdgeInsets

Expand Down
52 changes: 52 additions & 0 deletions Sources/GeoProjector/MapBounds+Contains.swift
Original file line number Diff line number Diff line change
@@ -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..<polygon.count {
let pi = polygon[i], pj = polygon[j]
if pi.x == p.x && pi.y == p.y { return true }
let intersects = ((pi.y > 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
}
}
100 changes: 100 additions & 0 deletions Sources/GeoProjector/Projection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading