diff --git a/Examples/Cassini.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Cassini.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index ca1341a..f9c4506 100644
--- a/Examples/Cassini.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Examples/Cassini.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -18,6 +18,15 @@
"version" : "0.4.1"
}
},
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms.git",
+ "state" : {
+ "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
+ "version" : "1.2.1"
+ }
+ },
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
@@ -26,6 +35,15 @@
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
}
],
"version" : 2
diff --git a/Examples/Cassini.xcodeproj/xcshareddata/xcschemes/Cassini.xcscheme b/Examples/Cassini.xcodeproj/xcshareddata/xcschemes/Cassini.xcscheme
new file mode 100644
index 0000000..51e91a9
--- /dev/null
+++ b/Examples/Cassini.xcodeproj/xcshareddata/xcschemes/Cassini.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/Contents.json b/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/Contents.json
new file mode 100644
index 0000000..0959352
--- /dev/null
+++ b/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "world.200408.3x5400x2700.jpg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/world.200408.3x5400x2700.jpg b/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/world.200408.3x5400x2700.jpg
new file mode 100644
index 0000000..0e16ca3
Binary files /dev/null and b/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/world.200408.3x5400x2700.jpg differ
diff --git a/Examples/Cassini/Cassini.entitlements b/Examples/Cassini/Cassini.entitlements
index f2ef3ae..275f07d 100644
--- a/Examples/Cassini/Cassini.entitlements
+++ b/Examples/Cassini/Cassini.entitlements
@@ -6,5 +6,7 @@
com.apple.security.files.user-selected.read-only
+ com.apple.security.network.client
+
diff --git a/Examples/Cassini/CassiniApp.swift b/Examples/Cassini/CassiniApp.swift
index 4c9e86f..f643dd0 100644
--- a/Examples/Cassini/CassiniApp.swift
+++ b/Examples/Cassini/CassiniApp.swift
@@ -26,16 +26,25 @@ struct CassiniApp: App {
}
var windowContent: some View {
- ContentView(model: .init(layers: [
- .init(
- name: "Continents",
- contents: try! GeoDrawer.Content.content(
- for: GeoDrawer.Content.countries(),
- style: .init(color: CassiniApp.Colors.continents.cgColor)
- ),
- color: CassiniApp.Colors.continents.cgColor
- )
- ]))
+ // iOS doesn't expose the layers list yet, so default to the Blue Marble
+ // raster on its own there, with the vector continents hidden.
+#if os(macOS)
+ let continentsVisible = true
+ let baseMapMode = ContentView.BaseMapMode.none
+#else
+ let continentsVisible = false
+ let baseMapMode = ContentView.BaseMapMode.blueMarble
+#endif
+ let continents = ContentView.Layer(
+ name: "Continents",
+ contents: try! GeoDrawer.Content.content(
+ for: GeoDrawer.Content.countries(),
+ style: .init(color: CassiniApp.Colors.continents.cgColor)
+ ),
+ color: CassiniApp.Colors.continents.cgColor,
+ visible: continentsVisible
+ )
+ return ContentView(model: .init(layers: [continents], baseMapMode: baseMapMode))
}
}
diff --git a/Examples/Cassini/ContentView+Model.swift b/Examples/Cassini/ContentView+Model.swift
index 975b936..b12f7cc 100644
--- a/Examples/Cassini/ContentView+Model.swift
+++ b/Examples/Cassini/ContentView+Model.swift
@@ -11,12 +11,69 @@ import Foundation
import CoreGraphics
import SwiftUI
+#if canImport(UIKit)
+import UIKit
+#elseif canImport(AppKit)
+import AppKit
+#endif
+
import GeoJSONKit
import GeoDrawer
import GeoProjectorDanseiji
extension ContentView {
+ /// Render-quality choices exposed in Cassini's UI. Maps to the
+ /// underlying `GeoMap.Quality` enum at use-site. The `.custom(_)`
+ /// library case isn't exposed here because there's no good UI for
+ /// a free-form Double in the demo.
+ enum RenderQuality: String, CaseIterable, Identifiable {
+ case draft
+ case standard
+ case matchDisplay
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .draft: return "Draft"
+ case .standard: return "Standard"
+ case .matchDisplay: return "Display"
+ }
+ }
+
+ var asQuality: GeoMap.Quality {
+ switch self {
+ case .draft: return .draft
+ case .standard: return .standard
+ case .matchDisplay: return .matchDisplay
+ }
+ }
+ }
+
+ enum BaseMapMode: String, CaseIterable, Identifiable {
+ case none
+ case blueMarble
+ case openStreetMap
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .none: return "None"
+ case .blueMarble: return "Blue Marble"
+ case .openStreetMap: return "OpenStreetMap"
+ }
+ }
+
+ var attribution: String? {
+ switch self {
+ case .none, .blueMarble: return nil
+ case .openStreetMap: return "© OpenStreetMap contributors"
+ }
+ }
+ }
+
enum ProjectionType: String, CaseIterable, Identifiable {
case equirectangular
case cassini
@@ -68,9 +125,10 @@ extension ContentView {
}
class Model: ObservableObject {
- init(layers: [Layer] = []) {
+ init(layers: [Layer] = [], baseMapMode: BaseMapMode = .none) {
self.layers = layers
self.projection = Projections.Orthographic()
+ self.baseMapMode = baseMapMode
}
@Published var layers: [Layer]
@@ -95,12 +153,40 @@ extension ContentView {
@Published var equirectangularPhiOne: Double = 0 {
didSet { updateProjection() }
}
-
+
@Published var insets: GeoProjector.EdgeInsets = .zero {
didSet { updateProjection() }
}
-
+
@Published var zoomTo: (GeoJSON.BoundingBox, Layer.ID)?
+
+ @AppStorage("options.baseMap")
+ var baseMapMode: BaseMapMode = .none {
+ didSet {
+ // Drop any stale tile progress when the source changes — the
+ // overlay shouldn't show a count from the previous source.
+ if baseMapMode != oldValue { tileProgress = nil }
+ }
+ }
+
+ @AppStorage("options.renderQuality")
+ var renderQuality: RenderQuality = .matchDisplay
+
+ /// Latest tile-fetch progress snapshot for the active tiled base
+ /// map. Nil when there isn't (or hasn't yet been) a fetch in
+ /// flight for the current selection.
+ @Published var tileProgress: TileFetchProgress?
+
+ /// Lazily-decoded NASA Blue Marble Next Generation base map. The asset is
+ /// shipped in the Cassini asset catalogue (5400×2700 equirectangular JPEG
+ /// from the 2004-08 monthly composite). The decode happens once on first
+ /// toggle and is cached for the app's lifetime.
+ private lazy var _blueMarble: GeoDrawer.BaseMap? = BlueMarble.load()
+
+ /// Lazily-built OpenStreetMap slippy-map tile source. Tiles are fetched
+ /// over HTTP via `URLTemplateTileSource` and decoded by the bundled
+ /// CoreGraphics decoder. The auto zoom level adapts to the canvas size.
+ private lazy var _osm: GeoDrawer.TiledBaseMap? = OpenStreetMap.makeTiledBaseMap()
func updateProjection() {
let reference = GeoJSON.Position(latitude: refLat, longitude: refLng)
@@ -138,11 +224,26 @@ extension ContentView {
}
var visibleContents: [GeoDrawer.Content] {
- layers
+ var result: [GeoDrawer.Content] = []
+ // Base map renders first so the vector layers land on top.
+ switch baseMapMode {
+ case .none:
+ break
+ case .blueMarble:
+ if let bm = _blueMarble {
+ result.append(.baseMap(bm))
+ }
+ case .openStreetMap:
+ if let tiled = _osm {
+ result.append(.tiledBaseMap(tiled))
+ }
+ }
+ result.append(contentsOf: layers
.filter(\.visible)
.flatMap { layer in
layer.contents.map { $0.settingColor(layer.color) }
- }
+ })
+ return result
}
/// The same projected `Rect` that `GeoDrawer` (and therefore `GeoMap`) uses for the
@@ -184,9 +285,11 @@ extension ContentView {
acc.append(contentsOf: line.positions)
case .polygon(let polygon, _, _, _):
acc.append(contentsOf: polygon.exterior.positions)
+ case .baseMap, .tiledBaseMap:
+ break
}
}
-
+
if positions.isEmpty {
zoomTo = nil
} else {
@@ -198,7 +301,7 @@ extension ContentView {
}
extension GeoDrawer.Content {
-
+
func settingColor(_ color: CGColor) -> GeoDrawer.Content {
switch self {
case .line(let lineString, _, let strokeWidth):
@@ -207,7 +310,50 @@ extension GeoDrawer.Content {
return .polygon(polygon, fill: color, strokeWidth: strokeWidth)
case .circle(let position, let radius, _, _, let strokeWidth):
return .circle(position, radius: radius, fill: color, strokeWidth: strokeWidth)
+ case .baseMap, .tiledBaseMap:
+ return self
}
}
-
+
+}
+
+// MARK: - Blue Marble base map
+
+/// Loads NASA's Blue Marble Next Generation 2004-08 composite from the bundled
+/// asset catalogue. Source: https://science.nasa.gov/earth/earth-observatory/blue-marble-next-generation/base-map/
+enum BlueMarble {
+ static let assetName = "world.200408.3x5400x2700"
+
+ static func load() -> GeoDrawer.BaseMap? {
+#if canImport(UIKit)
+ guard let image = UIImage(named: assetName) else { return nil }
+ return GeoDrawer.BaseMap(uiImage: image, sampling: .bilinear, alpha: 1.0)
+#elseif canImport(AppKit)
+ guard let image = NSImage(named: assetName) else { return nil }
+ return GeoDrawer.BaseMap(nsImage: image, sampling: .bilinear, alpha: 1.0)
+#else
+ return nil
+#endif
+ }
+}
+
+// MARK: - OpenStreetMap tile source
+
+/// Builds a `TiledBaseMap` over the public OpenStreetMap tile servers.
+/// Sends a non-default `User-Agent` (OSM's tile usage policy requires
+/// identifying the application) and lets the renderer pick a zoom level
+/// that matches the live canvas size.
+enum OpenStreetMap {
+ static func makeTiledBaseMap() -> GeoDrawer.TiledBaseMap {
+ let source = URLTemplateTileSource(
+ template: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ projection: Projections.Mercator(),
+ tileSize: 256,
+ minZoom: 0,
+ maxZoom: 19,
+ attribution: "© OpenStreetMap contributors",
+ userAgent: "Cassini Demo (https://github.com/maparoni/GeoProjector)"
+ )
+ return GeoDrawer.TiledBaseMap(source: source)
+ }
}
diff --git a/Examples/Cassini/ContentView.swift b/Examples/Cassini/ContentView.swift
index 28e5e8d..541fee7 100644
--- a/Examples/Cassini/ContentView.swift
+++ b/Examples/Cassini/ContentView.swift
@@ -37,6 +37,7 @@ struct ContentView_macOS: View {
@State private var hoverCoord: GeoJSON.Position?
@State private var lockedCoord: GeoJSON.Position?
+ @State private var hoveredFailedTileCount: Int?
var body: some View {
HSplitView {
@@ -51,7 +52,11 @@ struct ContentView_macOS: View {
zoomTo: model.zoomTo?.0,
insets: model.insets,
mapBackground: colorScheme == .dark ? .systemPurple : .systemTeal,
- mapOutline: colorScheme == .dark ? .white : .black
+ mapOutline: colorScheme == .dark ? .white : .black,
+ quality: model.renderQuality.asQuality,
+ onTileProgress: { progress in
+ model.tileProgress = progress
+ }
)
.onContinuousHover { phase in
switch phase {
@@ -66,12 +71,21 @@ struct ContentView_macOS: View {
lockedCoord = coord
}
}
+ .overlay(alignment: .bottomTrailing) {
+ TileProgressOverlay(
+ attribution: model.baseMapMode.attribution,
+ progress: model.tileProgress,
+ onFailureHoverChange: { hoveredFailedTileCount = $0 }
+ )
+ .padding(8)
+ }
}
.padding()
MapStatusBar(
live: hoverCoord,
locked: lockedCoord,
+ failedTileCount: hoveredFailedTileCount,
onCopy: copyLockedCoord,
onDiscard: { lockedCoord = nil }
)
@@ -100,12 +114,24 @@ struct ContentView_macOS: View {
struct MapStatusBar: View {
let live: GeoJSON.Position?
let locked: GeoJSON.Position?
+ let failedTileCount: Int?
let onCopy: () -> Void
let onDiscard: () -> Void
var body: some View {
HStack(spacing: 8) {
- if let locked {
+ // Hovering the warning icon in the overlay takes precedence over
+ // both the coord-hover and locked-coord states — it's a one-off
+ // explanatory mode.
+ if let failedTileCount {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.yellow)
+ Text(failedTileCount == 1
+ ? "1 tile failed to load"
+ : "\(failedTileCount) tiles failed to load")
+ .foregroundStyle(.secondary)
+ Spacer()
+ } else if let locked {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.tint)
Text(formatCoord(locked))
@@ -159,9 +185,21 @@ struct ContentView_iOS: View {
zoomTo: model.zoomTo?.0,
insets: model.insets,
mapBackground: colorScheme == .dark ? .systemPurple : .systemTeal,
- mapOutline: colorScheme == .dark ? .white : .black
+ mapOutline: colorScheme == .dark ? .white : .black,
+ quality: model.renderQuality.asQuality,
+ onTileProgress: { progress in
+ model.tileProgress = progress
+ }
)
-
+ .overlay(alignment: .bottomTrailing) {
+ TileProgressOverlay(
+ attribution: model.baseMapMode.attribution,
+ progress: model.tileProgress,
+ onFailureHoverChange: { _ in }
+ )
+ .padding(8)
+ }
+
ScrollView {
OptionsView(model: model)
}
@@ -171,6 +209,57 @@ struct ContentView_iOS: View {
}
#endif
+/// Bottom-right corner of the map: shows the active source's
+/// attribution alongside a circular progress indicator while tiles are
+/// loading and a warning triangle if any have failed. macOS uses the
+/// hover callback to route the failure count into the status bar.
+struct TileProgressOverlay: View {
+ let attribution: String?
+ let progress: TileFetchProgress?
+ /// macOS-only — non-nil count while the warning icon is hovered.
+ let onFailureHoverChange: (Int?) -> Void
+
+ var body: some View {
+ HStack(spacing: 6) {
+ if let progress, !progress.isComplete {
+ ProgressView(value: progress.fraction)
+ .progressViewStyle(.circular)
+ .controlSize(.small)
+ .help("Loading tiles — \(Int(progress.fraction * 100))%")
+ }
+ if let progress, progress.failed > 0 {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.yellow)
+ .help(progress.failed == 1
+ ? "1 tile failed to load"
+ : "\(progress.failed) tiles failed to load")
+#if os(macOS)
+ .onHover { hovering in
+ onFailureHoverChange(hovering ? progress.failed : nil)
+ }
+#endif
+ }
+ if let attribution {
+ AttributionLabel(text: attribution)
+ }
+ }
+ }
+}
+
+struct AttributionLabel: View {
+ let text: String
+
+ var body: some View {
+ Text(text)
+ .font(.caption2)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(Color.black.opacity(0.6))
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+}
+
struct OptionsView: View {
@ObservedObject var model: ContentView.Model
@@ -196,6 +285,26 @@ struct OptionsView: View {
}
#endif
+ GroupBox("Base map") {
+ Picker("Base map", selection: $model.baseMapMode) {
+ ForEach(ContentView.BaseMapMode.allCases) {
+ Text($0.label).tag($0)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ }
+
+ GroupBox("Render quality") {
+ Picker("Render quality", selection: $model.renderQuality) {
+ ForEach(ContentView.RenderQuality.allCases) {
+ Text($0.label).tag($0)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ }
+
GroupBox("Reference") {
HStack {
Slider(value: $model.refLat, in: -90...90) {
diff --git a/Package.swift b/Package.swift
index 9471066..a04b45b 100644
--- a/Package.swift
+++ b/Package.swift
@@ -66,6 +66,7 @@ let package = Package(
dependencies: [
"GeoDrawer",
"GeoProjector",
+ "GeoProjectorDanseiji",
]),
]
)
diff --git a/README.md b/README.md
index 7d298d6..0f6a1e9 100644
--- a/README.md
+++ b/README.md
@@ -15,13 +15,17 @@ on `0.x`.**
- **GeoProjectorDanseiji**: The six [Danseiji](https://kunimune.home.blog/2019/11/07/introducing-the-danseiji-projections/)
projections by Justin Kunimune, packaged as their own product so the
~1 MB of pre-baked mesh data only ships with apps that ask for it.
-- **GeoDrawer**: Draw GeoJSON using whichever projection you choose.
+- **GeoDrawer**: Draw GeoJSON using whichever projection you choose. Also
+ drape raster base maps under your vector layers — either a single source
+ image (e.g. NASA Blue Marble) or a tiled source (slippy `{z}/{x}/{y}`
+ URL templates, or any custom `TileSource`).
## Goals of this library
- Support a selection of map projections, but not an exhaustive list
- Provide methods for drawing those projections, draw GeoJSON content on top,
- and drawing just a section of the resulting map
+ draping raster imagery underneath, and drawing just a section of the
+ resulting map
- Provide methods for projecting points and inverting screen-space points back
to geographic coordinates
- Compatibility with Apple platforms and Linux
@@ -140,6 +144,74 @@ struct MyMap: View {
You can also draw straight into a `CGContext` (see `GeoDrawer.draw(_:in:)`)
or render to SVG (`GeoDrawer.drawSVG(_:)`).
+### Base maps
+
+Drape a raster image under the vector layers — useful for backdrops like
+NASA Blue Marble or any other equirectangular / Mercator world image. The
+source's projection is first-class: pass any `Projection` and the renderer
+forward-projects each output pixel through it to find the source pixel.
+
+```swift
+guard let bm = GeoDrawer.BaseMap(
+ uiImage: UIImage(named: "blue-marble")!,
+ sourceProjection: Projections.Equirectangular(), // default
+ sampling: .bilinear
+) else { return }
+
+GeoMap(
+ contents: [.baseMap(bm)] + vectorLayers,
+ projection: Projections.EqualEarth()
+)
+```
+
+For high-resolution imagery (split into tiles ahead of time) or live slippy
+maps, use a `TileSource` and `TiledBaseMap` instead. The protocol is pure
+Swift and works on Linux server-side; only the default tile-bytes decoder
+is gated behind CoreGraphics.
+
+```swift
+let osm = URLTemplateTileSource(
+ template: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ projection: Projections.Mercator(),
+ attribution: "© OpenStreetMap contributors",
+ userAgent: "MyApp/1.0 (you@example.com)"
+)
+let tiled = GeoDrawer.TiledBaseMap(source: osm) // .auto picks zoom from canvas
+
+GeoMap(
+ contents: [.tiledBaseMap(tiled)],
+ projection: Projections.Mercator()
+)
+```
+
+`StaticTileSource` covers the static-grid case (load all tiles into memory
+once); `URLTemplateTileSource` covers slippy-map services. Both are
+`Sendable` and safe to share across the renderer's parallel sampling
+tasks.
+
+### Render quality
+
+`GeoMap` exposes a `quality:` knob so the consuming app can trade off
+rendering cost against crispness:
+
+- `.draft` (½× point resolution) — fastest, visibly soft. Right for
+ interactive previews while the user is dragging sliders or cycling
+ projections.
+- `.standard` (1× point resolution) — fast, sharp on non-Retina, soft on
+ Retina.
+- `.matchDisplay` (the default) — renders at the destination display's
+ backing scale; matches what native UIKit/AppKit drawing produces.
+- `.custom(Double)` — pick a pixel-density factor directly (e.g. `2.0`
+ for an oversampled export at a non-Retina destination).
+
+```swift
+GeoMap(
+ contents: [.tiledBaseMap(osm)],
+ projection: Projections.Mercator(),
+ quality: isInteracting ? .draft : .matchDisplay
+)
+```
+
## Credits
The code in this repo is written by myself, [Adrian Schönig](https://github.com/nighthawk), along recently with help from [Claude](https://claude.ai)
diff --git a/Sources/GeoDrawer/BaseMap.swift b/Sources/GeoDrawer/BaseMap.swift
new file mode 100644
index 0000000..9956f76
--- /dev/null
+++ b/Sources/GeoDrawer/BaseMap.swift
@@ -0,0 +1,132 @@
+//
+// BaseMap.swift
+//
+//
+// Created by Adrian Schönig on 9/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+@preconcurrency import GeoProjector
+
+extension GeoDrawer {
+
+ /// A reference-typed wrapper around a pre-decoded source image used as a
+ /// base map. Pre-decoding once into a flat RGBA8 buffer avoids paying any
+ /// per-pixel decode cost in the inverse-projection sampler.
+ ///
+ /// On Apple platforms a `CGImage`-backed convenience initialiser is
+ /// available (see `BaseMapImage.decode(_:maxDimension:)` in
+ /// `apple/BaseMap+CoreGraphics.swift`); on Linux callers construct one
+ /// directly from a `[UInt8]` RGBA8 buffer they decoded themselves
+ /// (e.g. via `swift-png`).
+ ///
+ /// `Hashable` uses object identity so the `Content.baseMap` case can
+ /// participate in `Content`'s synthesised `Hashable` conformance.
+ public final class BaseMapImage: Hashable, @unchecked Sendable {
+ public let width: Int
+ public let height: Int
+ let pixels: UnsafeBufferPointer
+ private let storage: UnsafeMutablePointer
+
+ /// Backing initialiser used by platform decoders. Takes ownership of
+ /// `storage` and releases it on deinit.
+ init(width: Int, height: Int, storage: UnsafeMutablePointer) {
+ self.width = width
+ self.height = height
+ self.storage = storage
+ self.pixels = UnsafeBufferPointer(start: storage, count: width * height * 4)
+ }
+
+ /// Pure-Swift initialiser for callers that already have RGBA8
+ /// pre-multiplied bytes in hand — e.g. decoded via `swift-png` on
+ /// Linux server-side. The buffer is copied into the wrapper's own
+ /// allocation so the caller can deallocate theirs freely.
+ public convenience init?(width: Int, height: Int, pixels: [UInt8]) {
+ guard width > 0, height > 0 else { return nil }
+ let count = width * height * 4
+ guard pixels.count == count else { return nil }
+ let storage = UnsafeMutablePointer.allocate(capacity: count)
+ pixels.withUnsafeBufferPointer { src in
+ // `src.baseAddress` is non-nil because `count > 0` is enforced
+ // above (and Swift's array storage guarantees a contiguous
+ // base address for non-empty arrays).
+ if let base = src.baseAddress {
+ memcpy(storage, base, count)
+ }
+ }
+ self.init(width: width, height: height, storage: storage)
+ }
+
+ deinit {
+ storage.deallocate()
+ }
+
+ public static func == (lhs: BaseMapImage, rhs: BaseMapImage) -> Bool {
+ lhs === rhs
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(self))
+ }
+ }
+
+ /// Describes a raster image to drape under the projection's vector
+ /// layers.
+ ///
+ /// The source image is assumed to be a complete, axis-aligned rendering
+ /// of `sourceProjection` filling its `visibleBounds`. NASA Blue Marble
+ /// and most public-domain world imagery ship in
+ /// `Projections.Equirectangular` (the default). Web-Mercator world
+ /// imagery uses `Projections.Mercator`. In principle any projection
+ /// works as long as the image's aspect ratio matches the projection's
+ /// natural aspect ratio; otherwise the renderer will sample non-image
+ /// areas as transparent.
+ ///
+ /// SVG output (`drawSVG(_:)`) does not embed the raster.
+ public struct BaseMap {
+ public let image: BaseMapImage
+ public let sourceProjection: any Projection
+ public let sampling: Sampling
+ public let alpha: Double
+
+ public init(
+ image: BaseMapImage,
+ sourceProjection: any Projection = Projections.Equirectangular(),
+ sampling: Sampling = .bilinear,
+ alpha: Double = 1.0
+ ) {
+ self.image = image
+ self.sourceProjection = sourceProjection
+ self.sampling = sampling
+ self.alpha = max(0, min(1, alpha))
+ }
+ }
+}
+
+extension GeoDrawer.BaseMap: Hashable {
+
+ /// Two `BaseMap` values are equal when they share the same pre-decoded
+ /// image (by reference), the same source-projection identity (type
+ /// plus reference point), and the same sampling/alpha settings.
+ /// Constructing a new `BaseMap` from a fresh image therefore
+ /// invalidates the drawer's raster cache; rebuilding one with
+ /// identical arguments does not.
+ public static func == (lhs: GeoDrawer.BaseMap, rhs: GeoDrawer.BaseMap) -> Bool {
+ lhs.image === rhs.image
+ && lhs.sampling == rhs.sampling
+ && lhs.alpha == rhs.alpha
+ && type(of: lhs.sourceProjection) == type(of: rhs.sourceProjection)
+ && lhs.sourceProjection.reference == rhs.sourceProjection.reference
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(image))
+ hasher.combine(sampling)
+ hasher.combine(alpha)
+ hasher.combine(String(reflecting: type(of: sourceProjection)))
+ hasher.combine(sourceProjection.reference)
+ }
+}
diff --git a/Sources/GeoDrawer/GeoDrawer+SVG.swift b/Sources/GeoDrawer/GeoDrawer+SVG.swift
index b0292b6..d578859 100644
--- a/Sources/GeoDrawer/GeoDrawer+SVG.swift
+++ b/Sources/GeoDrawer/GeoDrawer+SVG.swift
@@ -67,6 +67,10 @@ extension GeoDrawer {
for polygon in polygons {
svg.addPolygon(polygon, fill: fill, stroke: stroke, strokeWidth: strokeWidth)
}
+#if canImport(CoreGraphics)
+ case .baseMap, .tiledBaseMap:
+ break // SVG output omits raster base maps in v1
+#endif
}
}
@@ -84,6 +88,10 @@ extension GeoDrawer {
position, radius: radius, fill: fill, stroke: stroke, strokeWidth: strokeWidth)
case .line, .polygon:
break // Already drawn
+#if canImport(CoreGraphics)
+ case .baseMap, .tiledBaseMap:
+ break // SVG output omits raster base maps in v1
+#endif
}
}
diff --git a/Sources/GeoDrawer/GeoDrawer+TilePrefetch.swift b/Sources/GeoDrawer/GeoDrawer+TilePrefetch.swift
new file mode 100644
index 0000000..4101101
--- /dev/null
+++ b/Sources/GeoDrawer/GeoDrawer+TilePrefetch.swift
@@ -0,0 +1,222 @@
+//
+// GeoDrawer+TilePrefetch.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+@preconcurrency import GeoProjector
+
+extension GeoDrawer {
+
+ /// Resolves a `TiledBaseMap.Zoom` to an integer level using this drawer's
+ /// canvas size. For `.auto`, picks the level whose source-canvas pixel
+ /// density most closely matches the output canvas (via
+ /// `log2(max(canvas) * pixelDensity / tileSize)`), clamped to the source's
+ /// range. `pixelDensity` is included so Retina displays fetch the next
+ /// zoom level up rather than upscaling lower-res tiles.
+ func resolvedZoom(_ zoom: TiledBaseMap.Zoom, source: any TileSource) -> Int {
+ switch zoom {
+ case .fixed(let z):
+ return z
+ case .auto:
+ let canvasMaxPixels = max(size.width, size.height) * pixelDensity
+ let raw = log2(max(canvasMaxPixels, 1) / Double(source.tileSize))
+ let z = Int(raw.rounded())
+ return min(max(z, source.minZoom), source.maxZoom)
+ }
+ }
+
+ // MARK: - Pre-fetch
+
+ /// Determines the set of tiles needed to cover the canvas at this
+ /// drawer's `(projection, size, zoomTo, insets, pixelDensity)`
+ /// configuration.
+ ///
+ /// Replays the renderer's per-pixel inverse-projection sweep at the
+ /// drawer's own `pixelDensity`. This *is* the renderer's hit set —
+ /// no sampling shortcut, no risk of leaving tiles unfetched that the
+ /// renderer then can't find in cache. Earlier implementations
+ /// (canvas-stride sampling at fixed step, source-grid sampling)
+ /// both missed tiles with small canvas footprints on irregular
+ /// projections (Danseiji IV pole-centered was the user-visible
+ /// case), so we just iterate every pixel.
+ ///
+ /// Cost scales with canvas area × density². Parallelised via
+ /// `concurrentPerform` over rows. For a 1500×1200-pt canvas at
+ /// `pixelDensity = 2.0` (~7 M iterations) this is ~50 ms on an
+ /// 8-core M-series Mac.
+ func tilesNeeded(for tiledBaseMap: TiledBaseMap) -> Set {
+ guard let projection else { return [] }
+ let source = tiledBaseMap.source
+ let z = resolvedZoom(tiledBaseMap.zoom, source: source)
+ let n = 1 << z
+ let tileSize = source.tileSize
+ let totalSize = Size(
+ width: Double(tileSize * n),
+ height: Double(tileSize * n)
+ )
+ let sourceProjection = source.projection
+ let outputBounds = projection.mapBounds
+ let outputProjSize = projection.projectionSize
+ let wraps = sourceProjection.wrapsLongitudinally
+ let density = max(0.1, pixelDensity)
+ let width = max(1, Int((size.width * density).rounded()))
+ let height = max(1, Int((size.height * density).rounded()))
+
+ // Per-row tile sets, unioned at the end. Each row writes its own
+ // slot, no synchronisation needed.
+ let rowTiles = UnsafeMutablePointer>.allocate(capacity: height)
+ rowTiles.initialize(repeating: [], count: height)
+ defer {
+ rowTiles.deinitialize(count: height)
+ rowTiles.deallocate()
+ }
+
+ let drawerSize = size
+ let drawerZoom = zoomTo
+ let drawerInsets = insets
+
+ DispatchQueue.concurrentPerform(iterations: height) { py in
+ let pyPoints = (Double(py) + 0.5) / density
+ var local = Set()
+ for px in 0..= totalSize.width {
+ continue
+ }
+ if sy < 0 || sy >= totalSize.height { continue }
+ let tx = Int(sx.rounded(.down)) / tileSize
+ let ty = Int(sy.rounded(.down)) / tileSize
+ if tx >= 0 && tx < n && ty >= 0 && ty < n {
+ local.insert(TileKey(z: z, x: tx, y: ty))
+ }
+ }
+ rowTiles[py] = local
+ }
+
+ var tiles = Set()
+ for py in 0.. Void)? = nil
+ ) async {
+ let needed = tilesNeeded(for: tiledBaseMap)
+ let source = tiledBaseMap.source
+ let sourceID = source.tileSourceID
+
+ // Tiles already in the cache (from a prior projection-switch) count
+ // as loaded — bumps the starting fraction so the UI doesn't flash
+ // back to 0% on every drag tick when most tiles are already there.
+ let totalNeeded = needed.count
+ var loaded = 0
+ var failed = 0
+ var tilesToFetch: [TileKey] = []
+ tilesToFetch.reserveCapacity(needed.count)
+ for tileKey in needed {
+ let cacheKey = TileCacheKey(sourceID: sourceID, tileKey: tileKey)
+ if tileCache.contains(cacheKey) {
+ loaded += 1
+ } else {
+ tilesToFetch.append(tileKey)
+ }
+ }
+ onProgress?(TileFetchProgress(total: totalNeeded, loaded: loaded, failed: failed))
+
+ await withTaskGroup(of: TileFetchOutcome.self) { group in
+ for tileKey in tilesToFetch {
+ let added = group.addTaskUnlessCancelled {
+ do {
+ let tile = try await source.tile(for: tileKey)
+ return TileFetchOutcome(key: tileKey, tile: tile, failed: false)
+ } catch {
+ return TileFetchOutcome(key: tileKey, tile: nil, failed: true)
+ }
+ }
+ if !added { break }
+ }
+ for await outcome in group {
+ if Task.isCancelled { break }
+ if outcome.failed {
+ failed += 1
+ } else if let tile = outcome.tile {
+ tileCache.set(TileCacheKey(sourceID: sourceID, tileKey: outcome.key), tile)
+ // The cached rendered raster (on Apple) had partial tile
+ // coverage; drop it so the next draw re-renders with the
+ // newly-arrived tile. No-op on Linux where the raster cache
+ // doesn't exist.
+ invalidateRenderedTiledRaster(matching: sourceID)
+ loaded += 1
+ } else {
+ // Source explicitly returned nil (no tile at that key, e.g.
+ // some services skip ocean) — count as resolved, not failed.
+ loaded += 1
+ }
+ onProgress?(TileFetchProgress(total: totalNeeded, loaded: loaded, failed: failed))
+ }
+ }
+ }
+}
+
+private struct TileFetchOutcome: Sendable {
+ let key: TileKey
+ let tile: TileImage?
+ let failed: Bool
+}
+
+extension GeoDrawer {
+ /// Hook called when a tile arrives so the CoreGraphics-rendered raster
+ /// cache can drop any stale partial-coverage entries for the source.
+ /// Pure-Swift default is a no-op; the Apple-side implementation
+ /// override (in `apple/GeoDrawer+RasterBaseMap.swift`) clears matching
+ /// entries from `BaseMapCache`.
+ func invalidateRenderedTiledRaster(matching sourceID: AnyHashable) {
+#if canImport(CoreGraphics)
+ baseMapCache.invalidateTiled(matching: sourceID)
+#endif
+ }
+}
diff --git a/Sources/GeoDrawer/GeoDrawer.swift b/Sources/GeoDrawer/GeoDrawer.swift
index c817ca4..53557cb 100644
--- a/Sources/GeoDrawer/GeoDrawer.swift
+++ b/Sources/GeoDrawer/GeoDrawer.swift
@@ -133,6 +133,38 @@ public struct GeoDrawer {
public let insets: EdgeInsets
+ /// Class-backed cache of fetched-and-decoded tile bitmaps, keyed by
+ /// source identity + tile coordinate. **Mutable on purpose**: owners
+ /// like `GeoMapView` swap in a shared cache so fetched tiles survive
+ /// drawer recreations triggered by projection/size/zoom/insets changes.
+ /// Tile bytes are projection-independent, so reusing them is correct
+ /// and avoids re-hitting the network.
+ ///
+ /// Cross-platform — Linux server-side renderers can pre-fetch tiles
+ /// into the same shared cache.
+ var tileCache: TileCache = TileCache()
+
+ /// Pixels-per-point for raster output. Use `2.0` on Retina displays so
+ /// base-map and tile rasters render at the backing-store resolution;
+ /// `CGContext.draw(image:in:)` then downscales smoothly to the
+ /// point-sized rect rather than upsampling a point-resolution buffer.
+ /// Vector content is unaffected (CG paths render natively at the
+ /// destination context's resolution).
+ ///
+ /// Used by both the Apple raster renderer and the pure-Swift
+ /// `tilesNeeded` / `prefetchTiles` machinery, so it lives outside
+ /// the CoreGraphics gate.
+ public var pixelDensity: Double = 1.0
+
+#if canImport(CoreGraphics)
+ /// Class-backed cache of rendered base-map rasters. Sharing a reference
+ /// across drawer copies is intentional: as long as the drawer's
+ /// `(projection, size, zoomTo, insets)` tuple is the same, the raster
+ /// for a given `BaseMap` is the same; when any of those change, the
+ /// owner (e.g. `GeoMapView`) builds a new `GeoDrawer`, dropping this cache.
+ let baseMapCache = BaseMapCache()
+#endif
+
var invertCheck: ((GeoJSON.Polygon) -> Bool)? { projection?.invertCheck }
let converter: (GeoJSON.Position, CoordinateSystem) -> Point?
@@ -175,6 +207,15 @@ extension GeoDrawer {
case line(GeoJSON.LineString, stroke: Color, strokeWidth: Double = 2)
case polygon(GeoJSON.Polygon, fill: Color, stroke: Color? = nil, strokeWidth: Double = 2)
case circle(GeoJSON.Position, radius: Double, fill: Color, stroke: Color? = nil, strokeWidth: Double = 2)
+#if canImport(CoreGraphics)
+ /// A raster image draped under the vector layers, sampled per output pixel
+ /// via the projection's `inverse(_:)`. SVG output omits this case in v1.
+ case baseMap(BaseMap)
+ /// A raster underlay backed by a `TileSource` — for slippy-map tiles or
+ /// pre-decoded high-resolution grids. Tiles are pre-fetched on the
+ /// async pipeline before the per-pixel sampler runs.
+ case tiledBaseMap(TiledBaseMap)
+#endif
}
}
@@ -200,6 +241,15 @@ extension GeoDrawer {
case line([ProjectedLineString], stroke: Color, strokeWidth: Double)
case polygon([ProjectedPolygon], fill: Color, stroke: Color?, strokeWidth: Double)
case circle(Point, radius: Double, fill: Color, stroke: Color?, strokeWidth: Double)
+#if canImport(CoreGraphics)
+ /// Pass-through case. The raster is rendered later, against the active
+ /// drawing context, since per-pixel inverse projection has nothing to do
+ /// with per-vertex projection.
+ case baseMap(BaseMap)
+ /// Pass-through; the tile prefetcher and per-pixel sampler run later
+ /// during the draw step.
+ case tiledBaseMap(TiledBaseMap)
+#endif
}
}
@@ -226,6 +276,12 @@ extension GeoDrawer {
case let .circle(center, radius, fill, stroke, strokeWidth):
guard let point = converter(center, coordinateSystem) else { return nil }
return .circle(point, radius: radius, fill: fill, stroke: stroke, strokeWidth: strokeWidth)
+#if canImport(CoreGraphics)
+ case let .baseMap(baseMap):
+ return .baseMap(baseMap)
+ case let .tiledBaseMap(tiled):
+ return .tiledBaseMap(tiled)
+#endif
}
}
diff --git a/Sources/GeoDrawer/GeoMap+AppKit.swift b/Sources/GeoDrawer/GeoMap+AppKit.swift
deleted file mode 100644
index ee70623..0000000
--- a/Sources/GeoDrawer/GeoMap+AppKit.swift
+++ /dev/null
@@ -1,236 +0,0 @@
-//
-// GeoMap+AppKit.swift
-//
-//
-// Created by Adrian Schönig on 10/12/2022.
-//
-// GeoProjector - Native Swift library for drawing map projections
-// Copyright (C) 2022 Corporoni Pty Ltd. See LICENSE.
-
-#if canImport(AppKit) && !targetEnvironment(macCatalyst)
-import AppKit
-import SwiftUI
-
-import GeoProjector
-import GeoJSONKit
-
-public class GeoMapView: NSView {
- public var contents: [GeoDrawer.Content] = [] {
- didSet {
- invalidateProjectedContents()
- setNeedsDisplay(bounds)
- }
- }
-
- public var projection: Projection = Projections.Equirectangular() {
- didSet {
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay(bounds)
- }
- }
-
- public var zoomTo: GeoJSON.BoundingBox? = nil {
- didSet {
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay(bounds)
- }
- }
-
- public var insets: GeoProjector.EdgeInsets = .zero {
- didSet {
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay(bounds)
- }
- }
-
- public var mapBackground: NSColor = .systemTeal {
- didSet {
- setNeedsDisplay(bounds)
- }
- }
-
- public var mapOutline: NSColor = .black {
- didSet {
- setNeedsDisplay(bounds)
- }
- }
-
- public var mapBackdrop: NSColor = .white {
- didSet {
- setNeedsDisplay(bounds)
- }
- }
-
- public override var frame: NSRect {
- didSet {
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay(bounds)
- }
- }
-
- private var _drawer: GeoDrawer!
- private var drawer: GeoDrawer {
- if let _drawer {
- return _drawer
- } else {
- let drawer = GeoDrawer(
- size: .init(frame.size),
- projection: projection,
- zoomTo: zoomTo,
- insets: insets
- )
- _drawer = drawer
- return drawer
- }
- }
-
- public override func draw(_ rect: NSRect) {
- // Don't draw if we're busy as this will flicker weirdly
- let projected: [GeoDrawer.ProjectedContent]
- switch projectProgress {
- case .busy(_, .some(let previous)):
- projected = previous
- case .busy(_, .none), .idle:
- return // Don't update drawing; will get called again instead when finished
- case .finished(let finished):
- projected = finished
- }
-
- super.draw(rect)
-
- // Get the current graphics context and cast it to a CGContext
- let context = NSGraphicsContext.current!.cgContext
-
- // Use Core Graphics functions to draw the content of your view
- drawer.draw(
- projected,
- mapBackground: mapBackground.cgColor,
- mapOutline: mapOutline.cgColor,
- mapBackdrop: mapBackdrop.cgColor,
- in: context
- )
- }
-
- // MARK: - Performance
-
- enum ProjectionProgress {
- case finished([GeoDrawer.ProjectedContent])
- case busy(Task, previously: [GeoDrawer.ProjectedContent]?)
- case idle
- }
-
- private var projectProgress = ProjectionProgress.idle
-
- private func invalidateProjectedContents() {
- let previous: [GeoDrawer.ProjectedContent]?
- switch projectProgress {
- case .finished(let projected):
- previous = projected
- case .busy(let task, let previously):
- task.cancel()
- previous = previously
- case .idle:
- previous = nil
- }
-
- projectProgress = .busy(Task(priority: .high) { [weak self] in
- guard let self else { return }
- do {
- let projected = try await drawer.projectInParallel(contents, coordinateSystem: .bottomLeft)
- await MainActor.run {
- self.projectProgress = .finished(projected)
- self.setNeedsDisplay(self.bounds)
- }
- } catch {
- assert(error is CancellationError)
- }
- }, previously: previous)
- }
-}
-
-@available(macOS 10.15, *)
-public struct GeoMap: NSViewRepresentable {
-
- public init(contents: [GeoDrawer.Content] = [], projection: Projection = Projections.Equirectangular(), zoomTo: GeoJSON.BoundingBox? = nil, insets: GeoProjector.EdgeInsets = .zero, mapBackground: NSColor? = nil, mapOutline: NSColor? = nil, mapBackdrop: NSColor? = nil) {
- self.contents = contents
- self.projection = projection
- self.zoomTo = zoomTo
- self.insets = insets
- self.mapBackground = mapBackground
- self.mapOutline = mapOutline
- self.mapBackdrop = mapBackdrop
- }
-
- public var contents: [GeoDrawer.Content] = []
-
- public var projection: Projection = Projections.Equirectangular()
-
- public var zoomTo: GeoJSON.BoundingBox? = nil
-
- public var insets: GeoProjector.EdgeInsets = .zero
-
- public var mapBackground: NSColor? = nil
-
- public var mapOutline: NSColor? = nil
-
- public var mapBackdrop: NSColor? = nil
-
- public typealias NSViewType = GeoMapView
-
- public func makeNSView(context: Context) -> GeoMapView {
- let view = GeoMapView()
- view.contents = contents
- view.projection = projection
- view.zoomTo = zoomTo
- view.insets = insets
- if let mapBackground {
- view.mapBackground = mapBackground
- }
- if let mapOutline {
- view.mapOutline = mapOutline
- }
- if let mapBackdrop {
- view.mapBackdrop = mapBackdrop
- }
- return view
- }
-
- public func updateNSView(_ view: GeoMapView, context: Context) {
- view.contents = contents
- view.projection = projection
- view.zoomTo = zoomTo
- view.insets = insets
- if let mapBackground {
- view.mapBackground = mapBackground
- }
- if let mapOutline {
- view.mapOutline = mapOutline
- }
- if let mapBackdrop {
- view.mapBackdrop = mapBackdrop
- }
- }
-
-}
-
-#if DEBUG
-@available(iOS 13.0, visionOS 1.0, macOS 11.0, *)
-struct GeoMap_Previews: PreviewProvider {
- static var previews: some View {
- GeoMap(
- contents: try! GeoDrawer.Content.content(
- for: GeoDrawer.Content.countries(),
- style: .init(color: .init(red: 0, green: 1, blue: 0, alpha: 0))
- ),
- projection: Projections.Cassini()
- )
- .previewLayout(.fixed(width: 300, height: 300))
- }
-}
-#endif
-
-#endif
diff --git a/Sources/GeoDrawer/GeoMap+UIKit.swift b/Sources/GeoDrawer/GeoMap+UIKit.swift
deleted file mode 100644
index 06a9bb5..0000000
--- a/Sources/GeoDrawer/GeoMap+UIKit.swift
+++ /dev/null
@@ -1,247 +0,0 @@
-//
-// GeoMap+UIKit.swift
-//
-//
-// Created by Adrian Schönig on 24/2/2023.
-//
-// GeoProjector - Native Swift library for drawing map projections
-// Copyright (C) 2022 Corporoni Pty Ltd. See LICENSE.
-
-#if canImport(UIKit)
-import UIKit
-import SwiftUI
-
-import GeoProjector
-import GeoJSONKit
-
-public class GeoMapView: UIView {
- public var contents: [GeoDrawer.Content] = [] {
- didSet {
- if contents == oldValue { return }
- invalidateProjectedContents()
- setNeedsDisplay()
- }
- }
-
- public var projection: Projection = Projections.Equirectangular() {
- didSet {
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay()
- }
- }
-
- public var zoomTo: GeoJSON.BoundingBox? = nil {
- didSet {
- if zoomTo == oldValue { return }
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay()
- }
- }
-
- public var insets: GeoProjector.EdgeInsets = .zero {
- didSet {
- if insets == oldValue { return }
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay()
- }
- }
-
- public var mapBackground: UIColor = .systemTeal {
- didSet {
- setNeedsDisplay()
- }
- }
-
- public var mapOutline: UIColor = .black {
- didSet {
- setNeedsDisplay()
- }
- }
-
-
- public override var frame: CGRect {
- didSet {
- if frame == oldValue { return }
- _drawer = nil
- invalidateProjectedContents()
- setNeedsDisplay()
- }
- }
-
- private var _drawer: GeoDrawer!
- private var drawer: GeoDrawer {
- if let _drawer {
- return _drawer
- } else {
- let drawer = GeoDrawer(
- size: .init(frame.size),
- projection: projection,
- zoomTo: zoomTo,
- insets: insets
- )
- _drawer = drawer
- return drawer
- }
- }
-
- public override func draw(_ rect: CGRect) {
-
- // Get the current graphics context and cast it to a CGContext
- let context = UIGraphicsGetCurrentContext()!
-
- let background: UIColor
- if let backgroundColor {
- background = backgroundColor
- } else if #available(iOS 13.0, *) {
- background = .systemBackground
- } else {
- background = .white
- }
-
- // Don't draw if we're busy as this will flicker weirdly
- let projected: [GeoDrawer.ProjectedContent]
- switch projectProgress {
- case .busy(_, .some(let previous)):
- projected = previous
- case .busy(_, .none), .idle:
- return // Don't update drawing; will get called again instead when finished
- case .finished(let finished):
- projected = finished
- }
-
- super.draw(rect)
-
- // Use Core Graphics functions to draw the content of your view
- drawer.draw(
- projected,
- mapBackground: mapBackground.cgColor,
- mapOutline: mapOutline.cgColor,
- mapBackdrop: background.cgColor,
- in: context
- )
-
- context.flush()
- }
-
- // MARK: - Performance
-
- enum ProjectionProgress {
- case finished([GeoDrawer.ProjectedContent])
- case busy(Task, previously: [GeoDrawer.ProjectedContent]?)
- case idle
- }
-
- private var projectProgress = ProjectionProgress.idle
-
- private func invalidateProjectedContents() {
- let previous: [GeoDrawer.ProjectedContent]?
- switch projectProgress {
- case .finished(let projected):
- previous = projected
- case .busy(let task, let previously):
- task.cancel()
- previous = previously
- case .idle:
- previous = nil
- }
-
- projectProgress = .busy(Task.detached(priority: .high) { [weak self] in
- guard let self else { return }
- do {
- let projected = try await drawer.projectInParallel(contents, coordinateSystem: .topLeft)
- await MainActor.run {
- self.projectProgress = .finished(projected)
- self.setNeedsDisplay(self.bounds)
- }
- } catch {
- assert(error is CancellationError)
- }
- }, previously: previous)
- }
-}
-
-@available(iOS 13.0, visionOS 1.0, *)
-public struct GeoMap: UIViewRepresentable {
-
- public init(contents: [GeoDrawer.Content] = [], projection: Projection = Projections.Equirectangular(), zoomTo: GeoJSON.BoundingBox? = nil, insets: GeoProjector.EdgeInsets = .zero, mapBackground: UIColor? = nil, mapOutline: UIColor? = nil, mapBackdrop: UIColor? = nil) {
- self.contents = contents
- self.projection = projection
- self.zoomTo = zoomTo
- self.insets = insets
- self.mapBackground = mapBackground
- self.mapOutline = mapOutline
- self.mapBackdrop = mapBackdrop
- }
-
- public var contents: [GeoDrawer.Content] = []
-
- public var projection: Projection = Projections.Equirectangular()
-
- public var zoomTo: GeoJSON.BoundingBox? = nil
-
- public var insets: GeoProjector.EdgeInsets = .zero
-
- public var mapBackground: UIColor? = nil
-
- public var mapOutline: UIColor? = nil
-
- public var mapBackdrop: UIColor? = nil
-
- public typealias UIViewType = GeoMapView
-
- public func makeUIView(context: Context) -> GeoMapView {
- let view = GeoMapView()
- view.contents = contents
- view.projection = projection
- view.zoomTo = zoomTo
- view.insets = insets
- if let mapBackground {
- view.mapBackground = mapBackground
- }
- if let mapOutline {
- view.mapOutline = mapOutline
- }
- if let mapBackdrop {
- view.backgroundColor = mapBackdrop
- }
- return view
- }
-
- public func updateUIView(_ view: GeoMapView, context: Context) {
- view.contents = contents
- view.projection = projection
- view.zoomTo = zoomTo
- view.insets = insets
- if let mapBackground {
- view.mapBackground = mapBackground
- }
- if let mapOutline {
- view.mapOutline = mapOutline
- }
- if let mapBackdrop {
- view.backgroundColor = mapBackdrop
- }
- }
-
-}
-
-#if DEBUG
-@available(iOS 13.0, visionOS 1.0, macOS 11.0, *)
-struct GeoMap_Previews: PreviewProvider {
- static var previews: some View {
- GeoMap(
- contents: try! GeoDrawer.Content.content(
- for: GeoDrawer.Content.countries(),
- style: .init(color: .init(red: 0, green: 1, blue: 0, alpha: 0))
- ),
- projection: Projections.Cassini()
- )
- .previewLayout(.fixed(width: 300, height: 300))
- }
-}
-#endif
-
-#endif
diff --git a/Sources/GeoDrawer/Sampling.swift b/Sources/GeoDrawer/Sampling.swift
new file mode 100644
index 0000000..94601ea
--- /dev/null
+++ b/Sources/GeoDrawer/Sampling.swift
@@ -0,0 +1,24 @@
+//
+// Sampling.swift
+//
+//
+// Created by Adrian Schönig on 11/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+extension GeoDrawer {
+
+ /// How a raster source is sampled when its pixels don't line up
+ /// one-to-one with the output canvas — applies to both single-image
+ /// `BaseMap`s and `TiledBaseMap`s.
+ public enum Sampling: Hashable, Sendable {
+ /// Pick the closest source pixel. Cheapest and produces visible
+ /// stair-stepping when the source is upsampled.
+ case nearest
+ /// Linearly blend the four nearest source pixels. Smoother result;
+ /// for cylindrical sources, wraps across the antimeridian so there's
+ /// no vertical seam at ±180°.
+ case bilinear
+ }
+}
diff --git a/Sources/GeoDrawer/TileCache.swift b/Sources/GeoDrawer/TileCache.swift
new file mode 100644
index 0000000..d46cc05
--- /dev/null
+++ b/Sources/GeoDrawer/TileCache.swift
@@ -0,0 +1,47 @@
+//
+// TileCache.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+extension GeoDrawer {
+
+ /// Pre-fetched tile storage keyed by `(sourceID, tileKey)`. Shared
+ /// across drawer copies so successive renders against the same drawer
+ /// don't re-fetch unchanged tiles. Owners like `GeoMapView` swap in
+ /// a long-lived instance via `drawer.tileCache = sharedCache` so
+ /// fetched tile bytes survive drawer recreations triggered by
+ /// projection/size/zoom/insets/quality changes.
+ struct TileCacheKey: Hashable, Sendable {
+ let sourceID: AnyHashable
+ let tileKey: TileKey
+ }
+
+ /// Thread-safe storage of pre-fetched, decoded tile bitmaps. Reads
+ /// and writes are serialised by an internal `NSLock`; tile bytes
+ /// themselves are immutable once stored.
+ final class TileCache: @unchecked Sendable {
+ private let lock = NSLock()
+ private var entries: [TileCacheKey: TileImage] = [:]
+
+ func get(_ key: TileCacheKey) -> TileImage? {
+ lock.lock(); defer { lock.unlock() }
+ return entries[key]
+ }
+
+ func set(_ key: TileCacheKey, _ image: TileImage) {
+ lock.lock(); defer { lock.unlock() }
+ entries[key] = image
+ }
+
+ func contains(_ key: TileCacheKey) -> Bool {
+ lock.lock(); defer { lock.unlock() }
+ return entries[key] != nil
+ }
+ }
+}
diff --git a/Sources/GeoDrawer/TileSource.swift b/Sources/GeoDrawer/TileSource.swift
new file mode 100644
index 0000000..01164fd
--- /dev/null
+++ b/Sources/GeoDrawer/TileSource.swift
@@ -0,0 +1,144 @@
+//
+// TileSource.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+@preconcurrency import GeoProjector
+
+/// Identifies a single tile in an XYZ ("slippy map") tile scheme.
+///
+/// At zoom `z` the source is divided into `2^z × 2^z` tiles. `(x, y)` is the
+/// tile position within that grid, with `(0, 0)` at the top-left
+/// (north-west) corner; `y` increases southward, matching OpenStreetMap,
+/// Google Maps, MapTiler, and most Web Mercator services. Static
+/// (non-zoomable) tile sets use `z = 0` and pack the world into a single
+/// row or column at that level.
+public struct TileKey: Hashable, Sendable {
+ public let z: Int
+ public let x: Int
+ public let y: Int
+
+ public init(z: Int, x: Int, y: Int) {
+ self.z = z
+ self.x = x
+ self.y = y
+ }
+}
+
+extension TileKey: CustomStringConvertible {
+ public var description: String { "\(z)/\(x)/\(y)" }
+}
+
+/// A pre-decoded tile bitmap. RGBA8 premultiplied, row-major, with row 0
+/// at the visual top of the tile (XYZ convention). The pixel buffer
+/// length must be exactly `width * height * 4`.
+public struct TileImage: Sendable, Hashable {
+ public let width: Int
+ public let height: Int
+ public let pixels: [UInt8]
+
+ public init(width: Int, height: Int, pixels: [UInt8]) {
+ precondition(width > 0 && height > 0)
+ precondition(pixels.count == width * height * 4,
+ "expected \(width * height * 4) RGBA bytes, got \(pixels.count)")
+ self.width = width
+ self.height = height
+ self.pixels = pixels
+ }
+}
+
+/// A pluggable source of map tiles. Implementations include slippy-map URL
+/// fetchers, on-disk caches, and pre-loaded static grids for high-resolution
+/// imagery like NASA Blue Marble Next Generation.
+///
+/// All members are read-only and `Sendable`: a `TileSource` is shared across
+/// the renderer's parallel pixel-sampling tasks, and `tile(for:)` may be
+/// invoked concurrently from many actors.
+public protocol TileSource: Sendable {
+ /// Stable identifier for this tile source. Two sources with the same
+ /// `tileSourceID` are treated as interchangeable by the renderer's cache
+ /// and by `Hashable`/`Equatable` checks on `Content` — so the value
+ /// should be cheap to compare and uniquely identify the underlying tile
+ /// data (a URL template string, a UUID generated at construction, etc.).
+ var tileSourceID: AnyHashable { get }
+
+ /// The projection that the tiles render through. Slippy-map services use
+ /// Web Mercator (`Projections.Mercator`); static grids may use any
+ /// projection that has a square or near-square `projectionSize` aspect.
+ /// Equirectangular tile sets are supported in principle but require the
+ /// caller to pick zoom levels where the `2^z × 2^z` grid divides cleanly
+ /// (e.g. an equirectangular set typically has `2^z` columns and
+ /// `2^(z-1)` rows; v1 doesn't model that asymmetry).
+ var projection: any Projection { get }
+
+ /// Pixel width/height of every tile. Most services serve 256×256;
+ /// high-DPI variants serve 512×512.
+ var tileSize: Int { get }
+
+ /// Lowest supported zoom level (inclusive). For static grids this is the
+ /// only level — set `minZoom == maxZoom`.
+ var minZoom: Int { get }
+
+ /// Highest supported zoom level (inclusive).
+ var maxZoom: Int { get }
+
+ /// Fetches and decodes the tile at `key`. Returns `nil` if the source
+ /// has no data at that location (e.g. some commercial services omit
+ /// ocean tiles); throws on network or decode errors. May be called
+ /// concurrently from multiple tasks.
+ func tile(for key: TileKey) async throws -> TileImage?
+}
+
+extension TileSource {
+ /// Whether `key` is within this source's supported zoom and grid range.
+ /// Useful for skipping out-of-range queries before hitting the network.
+ public func contains(_ key: TileKey) -> Bool {
+ guard key.z >= minZoom, key.z <= maxZoom else { return false }
+ let n = 1 << key.z
+ return key.x >= 0 && key.x < n && key.y >= 0 && key.y < n
+ }
+}
+
+/// Snapshot of how a tile prefetch is progressing.
+///
+/// `total` is the count of distinct tiles the renderer needs at the
+/// current canvas configuration. `loaded` includes both freshly-fetched
+/// tiles and tiles already in the drawer's `TileCache` from a prior
+/// call. `failed` covers network and decode errors per tile — these
+/// are counted here rather than re-thrown so partial coverage still
+/// renders.
+///
+/// Consumers (e.g. a SwiftUI overlay) typically watch `fraction` for
+/// the in-progress UI and `failed` for an at-a-glance warning state.
+public struct TileFetchProgress: Hashable, Sendable {
+ public let total: Int
+ public let loaded: Int
+ public let failed: Int
+
+ public init(total: Int, loaded: Int, failed: Int) {
+ self.total = total
+ self.loaded = loaded
+ self.failed = failed
+ }
+
+ /// Tiles still being awaited. `max(0, …)` because `loaded + failed`
+ /// is only ever >= `total` at completion.
+ public var pending: Int { max(0, total - loaded - failed) }
+
+ /// Resolved fraction in `0...1`. Failures count as resolved so the
+ /// bar finishes (rather than getting stuck near 100% on a partial
+ /// outage).
+ public var fraction: Double {
+ guard total > 0 else { return 1 }
+ return Double(loaded + failed) / Double(total)
+ }
+
+ /// Every needed tile has either landed or failed.
+ public var isComplete: Bool { loaded + failed >= total }
+}
diff --git a/Sources/GeoDrawer/TileSources/StaticTileSource.swift b/Sources/GeoDrawer/TileSources/StaticTileSource.swift
new file mode 100644
index 0000000..1253cfd
--- /dev/null
+++ b/Sources/GeoDrawer/TileSources/StaticTileSource.swift
@@ -0,0 +1,53 @@
+//
+// StaticTileSource.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+@preconcurrency import GeoProjector
+
+/// A `TileSource` backed by an in-memory dictionary of pre-decoded tiles.
+///
+/// Use this for static high-resolution imagery you can fully load at
+/// startup — for example, NASA's Blue Marble Next Generation 21600×10800
+/// composite split into a 16×8 grid of 1350×1350 tiles, decoded once and
+/// kept resident. Also handy for tests, where you want deterministic tile
+/// content without touching the network.
+///
+/// All zoom levels present in the supplied dictionary are supported;
+/// `minZoom`/`maxZoom` are computed from the keys. If the dictionary is
+/// empty, both default to `0`.
+public struct StaticTileSource: TileSource {
+
+ public let tileSourceID: AnyHashable
+ public let projection: any Projection
+ public let tileSize: Int
+ public let minZoom: Int
+ public let maxZoom: Int
+
+ private let tiles: [TileKey: TileImage]
+
+ public init(
+ projection: any Projection,
+ tileSize: Int,
+ tiles: [TileKey: TileImage],
+ id: AnyHashable = UUID()
+ ) {
+ self.tileSourceID = id
+ self.projection = projection
+ self.tileSize = tileSize
+ self.tiles = tiles
+ let zooms = tiles.keys.map(\.z)
+ self.minZoom = zooms.min() ?? 0
+ self.maxZoom = zooms.max() ?? 0
+ }
+
+ public func tile(for key: TileKey) async throws -> TileImage? {
+ tiles[key]
+ }
+}
diff --git a/Sources/GeoDrawer/TileSources/URLTemplateTileSource.swift b/Sources/GeoDrawer/TileSources/URLTemplateTileSource.swift
new file mode 100644
index 0000000..b22f5ec
--- /dev/null
+++ b/Sources/GeoDrawer/TileSources/URLTemplateTileSource.swift
@@ -0,0 +1,163 @@
+//
+// URLTemplateTileSource.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+@preconcurrency import GeoProjector
+
+/// A `TileSource` that fetches tiles over HTTP from a URL template.
+///
+/// The template uses `{z}`, `{x}`, and `{y}` placeholders, matching the
+/// XYZ slippy-map convention. Examples:
+///
+/// "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+/// "https://api.maptiler.com/maps/satellite/{z}/{x}/{y}.jpg?key=…"
+///
+/// Image decoding is performed by a caller-supplied closure so the type is
+/// platform-agnostic. On Apple platforms a CoreGraphics-backed default is
+/// available as `TileImage.coreGraphicsDecoder`; on Linux pass your own
+/// (e.g. wrapping `swift-png`).
+///
+/// Most public tile servers require a meaningful `User-Agent` header
+/// identifying your application — OSM in particular returns HTTP 429 for
+/// requests without one. Pass `userAgent` to set it.
+public struct URLTemplateTileSource: TileSource, @unchecked Sendable {
+
+ public let template: String
+ public let projection: any Projection
+ public let tileSize: Int
+ public let minZoom: Int
+ public let maxZoom: Int
+ public let attribution: String?
+
+ /// The URL template uniquely identifies the tile content for caching
+ /// purposes (assuming differing `{z}/{x}/{y}` substitutions return
+ /// distinct images).
+ public var tileSourceID: AnyHashable { template }
+
+ private let userAgent: String?
+ private let urlSession: URLSession
+ private let decoder: @Sendable (Data) throws -> TileImage?
+
+ public init(
+ template: String,
+ projection: any Projection,
+ tileSize: Int = 256,
+ minZoom: Int = 0,
+ maxZoom: Int = 19,
+ attribution: String? = nil,
+ userAgent: String? = nil,
+ urlSession: URLSession = .shared,
+ decoder: @escaping @Sendable (Data) throws -> TileImage?
+ ) {
+ self.template = template
+ self.projection = projection
+ self.tileSize = tileSize
+ self.minZoom = minZoom
+ self.maxZoom = maxZoom
+ self.attribution = attribution
+ self.userAgent = userAgent
+ self.urlSession = urlSession
+ self.decoder = decoder
+ }
+
+#if canImport(CoreGraphics)
+ /// Apple-platform convenience: defaults the decoder to
+ /// `TileImage.coreGraphicsDecoder` and the projection to Web Mercator.
+ public init(
+ template: String,
+ projection: any Projection = Projections.Mercator(),
+ tileSize: Int = 256,
+ minZoom: Int = 0,
+ maxZoom: Int = 19,
+ attribution: String? = nil,
+ userAgent: String? = nil,
+ urlSession: URLSession = .shared
+ ) {
+ self.init(
+ template: template,
+ projection: projection,
+ tileSize: tileSize,
+ minZoom: minZoom,
+ maxZoom: maxZoom,
+ attribution: attribution,
+ userAgent: userAgent,
+ urlSession: urlSession,
+ decoder: TileImage.coreGraphicsDecoder
+ )
+ }
+#endif
+
+ public func tile(for key: TileKey) async throws -> TileImage? {
+ guard contains(key) else { return nil }
+ guard let url = url(for: key) else {
+ throw URLTemplateTileSourceError.invalidTemplate(template)
+ }
+
+ var request = URLRequest(url: url)
+ if let userAgent {
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+ }
+
+ let (data, response) = try await fetchData(for: request)
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 404 {
+ return nil
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw URLTemplateTileSourceError.httpStatus(http.statusCode, key: key)
+ }
+ }
+
+ return try decoder(data)
+ }
+
+ /// Modern path uses `URLSession.data(for:)`. Linux toolchains older
+ /// than Swift 6.0 didn't ship that async overload on
+ /// `swift-corelibs-foundation`, so for those we fall back to a
+ /// continuation wrapped around `dataTask(with:completionHandler:)` —
+ /// same effect, just gluing the closure-based API onto async/await.
+#if !canImport(FoundationNetworking) || swift(>=6.0)
+ private func fetchData(for request: URLRequest) async throws -> (Data, URLResponse) {
+ try await urlSession.data(for: request)
+ }
+#else
+ private func fetchData(for request: URLRequest) async throws -> (Data, URLResponse) {
+ try await withCheckedThrowingContinuation { continuation in
+ let task = urlSession.dataTask(with: request) { data, response, error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else if let data, let response {
+ continuation.resume(returning: (data, response))
+ } else {
+ continuation.resume(throwing: URLError(.zeroByteResource))
+ }
+ }
+ task.resume()
+ }
+ }
+#endif
+
+ private func url(for key: TileKey) -> URL? {
+ let expanded = template
+ .replacingOccurrences(of: "{z}", with: String(key.z))
+ .replacingOccurrences(of: "{x}", with: String(key.x))
+ .replacingOccurrences(of: "{y}", with: String(key.y))
+ return URL(string: expanded)
+ }
+}
+
+public enum URLTemplateTileSourceError: Error, Equatable {
+ case invalidTemplate(String)
+ case httpStatus(Int, key: TileKey)
+}
diff --git a/Sources/GeoDrawer/TiledBaseMap.swift b/Sources/GeoDrawer/TiledBaseMap.swift
new file mode 100644
index 0000000..5156b6b
--- /dev/null
+++ b/Sources/GeoDrawer/TiledBaseMap.swift
@@ -0,0 +1,91 @@
+//
+// TiledBaseMap.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+import Foundation
+
+@preconcurrency import GeoProjector
+
+extension GeoDrawer {
+
+ /// A raster underlay backed by a `TileSource` instead of a single
+ /// pre-decoded image. The renderer fetches the tiles that cover the
+ /// canvas at the chosen `zoom` level, then samples them per output
+ /// pixel using the source's `projection`.
+ ///
+ /// Use this for high-resolution imagery you can't fit in memory as a
+ /// single buffer (e.g. the 21600×10800 NASA Blue Marble composite split
+ /// into a static grid) and for live slippy-map tiles
+ /// (`URLTemplateTileSource`). For a single-image source, use `BaseMap`.
+ ///
+ /// Tiles are pre-fetched on the same async path as the projection
+ /// pre-warm — see `GeoMapView.invalidateProjectedContents`.
+ public struct TiledBaseMap: @unchecked Sendable {
+
+ /// How the renderer chooses the zoom level to fetch tiles at.
+ public enum Zoom: Hashable {
+ /// The renderer picks a zoom level matching the canvas resolution
+ /// at draw time: `round(log2(max(canvas.width, canvas.height) /
+ /// source.tileSize))`, clamped to `[source.minZoom, source.maxZoom]`.
+ /// Doesn't account for `zoomTo`-region scaling — for tightly zoomed
+ /// regions, prefer `.fixed(_:)` with a manually-computed level.
+ case auto
+ /// Use this exact zoom level. Must lie in `[source.minZoom,
+ /// source.maxZoom]`.
+ case fixed(Int)
+ }
+
+ public let source: any TileSource
+ public let zoom: Zoom
+ public let sampling: GeoDrawer.Sampling
+ public let alpha: Double
+
+ public init(
+ source: any TileSource,
+ zoom: Zoom = .auto,
+ sampling: GeoDrawer.Sampling = .bilinear,
+ alpha: Double = 1.0
+ ) {
+ if case let .fixed(z) = zoom {
+ precondition(z >= source.minZoom && z <= source.maxZoom,
+ "zoom \(z) outside source range [\(source.minZoom), \(source.maxZoom)]")
+ }
+ self.source = source
+ self.zoom = zoom
+ self.sampling = sampling
+ self.alpha = max(0, min(1, alpha))
+ }
+
+ /// Convenience for `init(source:zoom:.fixed(_:),sampling:alpha:)`.
+ public init(
+ source: any TileSource,
+ zoom: Int,
+ sampling: GeoDrawer.Sampling = .bilinear,
+ alpha: Double = 1.0
+ ) {
+ self.init(source: source, zoom: .fixed(zoom), sampling: sampling, alpha: alpha)
+ }
+ }
+}
+
+extension GeoDrawer.TiledBaseMap: Hashable {
+
+ public static func == (lhs: GeoDrawer.TiledBaseMap, rhs: GeoDrawer.TiledBaseMap) -> Bool {
+ lhs.source.tileSourceID == rhs.source.tileSourceID
+ && lhs.zoom == rhs.zoom
+ && lhs.sampling == rhs.sampling
+ && lhs.alpha == rhs.alpha
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(source.tileSourceID)
+ hasher.combine(zoom)
+ hasher.combine(sampling)
+ hasher.combine(alpha)
+ }
+}
diff --git a/Sources/GeoDrawer/apple/BaseMap+CoreGraphics.swift b/Sources/GeoDrawer/apple/BaseMap+CoreGraphics.swift
new file mode 100644
index 0000000..26b26ff
--- /dev/null
+++ b/Sources/GeoDrawer/apple/BaseMap+CoreGraphics.swift
@@ -0,0 +1,134 @@
+//
+// BaseMap+CoreGraphics.swift
+//
+//
+// Created by Adrian Schönig on 9/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(CoreGraphics)
+import CoreGraphics
+import Foundation
+
+import GeoProjector
+
+#if canImport(UIKit)
+import UIKit
+#elseif canImport(AppKit)
+import AppKit
+#endif
+
+extension GeoDrawer.BaseMapImage {
+
+ /// Pre-decodes the supplied `CGImage` into the format used by the
+ /// base-map renderer. Returns `nil` if the image can't be drawn into
+ /// a deviceRGB bitmap context (extremely unusual).
+ ///
+ /// - Parameters:
+ /// - cgImage: Source image. Aspect ratio is preserved on downscale.
+ /// - maxDimension: Largest allowed width/height in pixels. Larger
+ /// inputs are scaled down. NASA Blue Marble at 21600×10800
+ /// decodes to ~933 MB RGBA, so a sensible cap is mandatory on
+ /// memory-constrained devices.
+ public static func decode(_ cgImage: CGImage, maxDimension: Int = 4096) -> GeoDrawer.BaseMapImage? {
+ let origW = cgImage.width
+ let origH = cgImage.height
+ guard origW > 0, origH > 0 else { return nil }
+
+ let largest = max(origW, origH)
+ let w: Int
+ let h: Int
+ if largest > maxDimension {
+ let scale = Double(maxDimension) / Double(largest)
+ w = max(1, Int((Double(origW) * scale).rounded()))
+ h = max(1, Int((Double(origH) * scale).rounded()))
+ } else {
+ w = origW
+ h = origH
+ }
+
+ let bytesPerRow = w * 4
+ let total = bytesPerRow * h
+ let buffer = UnsafeMutablePointer.allocate(capacity: total)
+ buffer.initialize(repeating: 0, count: total)
+
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+ guard let context = CGContext(
+ data: buffer,
+ width: w, height: h,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: bitmapInfo
+ ) else {
+ buffer.deallocate()
+ return nil
+ }
+
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: w, height: h))
+ return GeoDrawer.BaseMapImage(width: w, height: h, storage: buffer)
+ }
+}
+
+extension GeoDrawer.BaseMap {
+
+ /// Decodes `cgImage` and wraps it as a base map with the supplied source
+ /// projection (defaults to equirectangular).
+ public init?(
+ cgImage: CGImage,
+ sourceProjection: any Projection = Projections.Equirectangular(),
+ sampling: GeoDrawer.Sampling = .bilinear,
+ alpha: Double = 1.0,
+ maxDimension: Int = 4096
+ ) {
+ guard let img = GeoDrawer.BaseMapImage.decode(cgImage, maxDimension: maxDimension) else {
+ return nil
+ }
+ self.init(image: img, sourceProjection: sourceProjection, sampling: sampling, alpha: alpha)
+ }
+
+#if canImport(UIKit)
+ public init?(
+ uiImage: UIImage,
+ sourceProjection: any Projection = Projections.Equirectangular(),
+ sampling: GeoDrawer.Sampling = .bilinear,
+ alpha: Double = 1.0,
+ maxDimension: Int = 4096
+ ) {
+ guard let cg = uiImage.cgImage else { return nil }
+ self.init(
+ cgImage: cg,
+ sourceProjection: sourceProjection,
+ sampling: sampling,
+ alpha: alpha,
+ maxDimension: maxDimension
+ )
+ }
+#endif
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+ public init?(
+ nsImage: NSImage,
+ sourceProjection: any Projection = Projections.Equirectangular(),
+ sampling: GeoDrawer.Sampling = .bilinear,
+ alpha: Double = 1.0,
+ maxDimension: Int = 4096
+ ) {
+ var rect = CGRect(origin: .zero, size: nsImage.size)
+ guard let cg = nsImage.cgImage(forProposedRect: &rect, context: nil, hints: nil) else {
+ return nil
+ }
+ self.init(
+ cgImage: cg,
+ sourceProjection: sourceProjection,
+ sampling: sampling,
+ alpha: alpha,
+ maxDimension: maxDimension
+ )
+ }
+#endif
+}
+
+#endif
diff --git a/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift b/Sources/GeoDrawer/apple/GeoDrawer+CoreGraphics.swift
similarity index 79%
rename from Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift
rename to Sources/GeoDrawer/apple/GeoDrawer+CoreGraphics.swift
index 788552a..5a34a70 100644
--- a/Sources/GeoDrawer/GeoDrawer+CoreGraphics.swift
+++ b/Sources/GeoDrawer/apple/GeoDrawer+CoreGraphics.swift
@@ -141,27 +141,44 @@ extension GeoDrawer {
}
func draw(_ bounds: MapBounds, fillColor: CGColor? = nil, strokeColor: CGColor? = nil, in context: CGContext) {
- guard let projection else {
+ guard let path = mapBoundsPath(bounds) else {
return assertionFailure("Drawing map bounds not supported for provided converter.")
}
-
- let path: CGPath
-
+
+ context.addPath(path)
+ if let fillColor {
+ context.setFillColor(fillColor)
+ context.fillPath()
+ }
+
+ if let strokeColor {
+ context.setStrokeColor(strokeColor)
+ context.setLineWidth(2)
+ context.strokePath()
+ }
+
+ }
+
+ /// Builds the screen-space path of the projection's `mapBounds`. Returns
+ /// `nil` if the drawer was constructed without a projection. Factored out
+ /// so both `draw(_ bounds:...)` and the base-map raster step can use it
+ /// for clipping.
+ func mapBoundsPath(_ bounds: MapBounds) -> CGPath? {
+ guard let projection else { return nil }
+
switch bounds {
case .ellipse:
let min = projection.translate(.init(x: -1 * projection.projectionSize.width / 2, y: projection.projectionSize.height / 2), to: size, zoomTo: zoomTo, insets: insets, coordinateSystem: coordinateSystem)
let max = projection.translate(.init(x: projection.projectionSize.width / 2, y: -1 * projection.projectionSize.height / 2), to: size, zoomTo: zoomTo, insets: insets, coordinateSystem: coordinateSystem)
-
- path = CGPath(ellipseIn: .init(
+ return CGPath(ellipseIn: .init(
origin: min.cgPoint,
size: .init(width: max.x - min.x, height: max.y - min.y)
), transform: nil)
-
+
case .rectangle:
let min = projection.translate(.init(x: -1 * projection.projectionSize.width / 2, y: projection.projectionSize.height / 2), to: size, zoomTo: zoomTo, insets: insets, coordinateSystem: coordinateSystem)
let max = projection.translate(.init(x: projection.projectionSize.width / 2, y: -1 * projection.projectionSize.height / 2), to: size, zoomTo: zoomTo, insets: insets, coordinateSystem: coordinateSystem)
-
- path = CGPath(rect: .init(
+ return CGPath(rect: .init(
origin: min.cgPoint,
size: .init(width: max.x - min.x, height: max.y - min.y)
), transform: nil)
@@ -173,24 +190,9 @@ extension GeoDrawer {
for point in points[1...] {
mutable.addLine(to: point.cgPoint)
}
- // Close the ring so the stroke joins the last vertex back to the
- // first — otherwise there's a visible gap on Danseiji outlines.
mutable.closeSubpath()
- path = mutable
+ return mutable
}
-
- context.addPath(path)
- if let fillColor {
- context.setFillColor(fillColor)
- context.fillPath()
- }
-
- if let strokeColor {
- context.setStrokeColor(strokeColor)
- context.setLineWidth(2)
- context.strokePath()
- }
-
}
}
@@ -219,11 +221,53 @@ extension GeoDrawer {
if let mapBackground, let projection {
draw(projection.mapBounds, fillColor: mapBackground, in: context)
}
-
+
+ // Base maps go under the vector layers but above the map background fill,
+ // clipped to the projection's image so ellipse/bezier outlines don't leak
+ // raster pixels past the projection's edge.
+ if let projection {
+ let clipPath = mapBoundsPath(projection.mapBounds)
+ var clipped = false
+ for content in contents {
+ let raster: CGImage?
+ switch content {
+ case .baseMap(let baseMap):
+ raster = renderedBaseMap(baseMap, coordinateSystem: coordinateSystem)
+ case .tiledBaseMap(let tiled):
+ raster = renderedTiledBaseMap(tiled, coordinateSystem: coordinateSystem)
+ case .circle, .line, .polygon:
+ continue
+ }
+ guard let raster else { continue }
+
+ if !clipped, let clipPath {
+ context.saveGState()
+ context.addPath(clipPath)
+ context.clip()
+ clipped = true
+ }
+ context.saveGState()
+ // `CGContext.draw(image:in:)` ignores the user-space y-axis
+ // direction and always places image row 0 at the rect's maxY in
+ // its own (y-up) frame. On UIKit the surrounding CTM is flipped,
+ // so without a counter-flip the raster lands upside-down. AppKit
+ // contexts are unflipped, so leave the CTM alone there.
+ if coordinateSystem == .topLeft {
+ context.translateBy(x: 0, y: bounds.maxY)
+ context.scaleBy(x: 1, y: -1)
+ }
+ context.draw(raster, in: bounds)
+ context.restoreGState()
+ }
+ if clipped {
+ context.restoreGState()
+ }
+ }
+
for content in contents {
switch content {
- case .circle:
- break // this will go above the outline, as they might go outside projection
+ case .circle, .baseMap, .tiledBaseMap:
+ break // baseMap and tiledBaseMap drawn above; circles go above the outline
case let .line(lines, stroke, strokeWidth):
for line in lines {
draw(line, strokeColor: stroke, strokeWidth: strokeWidth, in: context)
@@ -234,17 +278,17 @@ extension GeoDrawer {
}
}
}
-
+
if let mapOutline, let projection {
// Draw a border background *on top*
draw(projection.mapBounds, strokeColor: mapOutline, in: context)
}
-
+
for content in contents {
switch content {
case let .circle(position, radius, fill, stroke, strokeWidth):
drawCircle(position, radius: radius, fillColor: fill, strokeColor: stroke, strokeWidth: strokeWidth, in: context)
- case .line, .polygon:
+ case .line, .polygon, .baseMap, .tiledBaseMap:
break // under the outline, as they follow projection
}
}
diff --git a/Sources/GeoDrawer/GeoDrawer+Image.swift b/Sources/GeoDrawer/apple/GeoDrawer+Image.swift
similarity index 100%
rename from Sources/GeoDrawer/GeoDrawer+Image.swift
rename to Sources/GeoDrawer/apple/GeoDrawer+Image.swift
diff --git a/Sources/GeoDrawer/apple/GeoDrawer+RasterBaseMap.swift b/Sources/GeoDrawer/apple/GeoDrawer+RasterBaseMap.swift
new file mode 100644
index 0000000..2ef3e10
--- /dev/null
+++ b/Sources/GeoDrawer/apple/GeoDrawer+RasterBaseMap.swift
@@ -0,0 +1,348 @@
+//
+// GeoDrawer+RasterBaseMap.swift
+//
+//
+// Created by Adrian Schönig on 9/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(CoreGraphics)
+import CoreGraphics
+import Foundation
+
+import GeoProjector
+
+extension GeoDrawer {
+
+ /// Identifier for a cached raster — independent of the drawer's
+ /// `(projection, size, zoomTo, insets)` tuple, which is captured implicitly
+ /// by the cache instance's lifetime. Two `BaseMap` values that produce the
+ /// same raster (same source image, sampling, alpha, pixel density) share a
+ /// cache slot.
+ struct BaseMapCacheKey: Hashable {
+ let imageID: ObjectIdentifier
+ let sampling: GeoDrawer.Sampling
+ let alphaMilli: Int
+ let pixelDensityMilli: Int
+
+ init(_ baseMap: BaseMap, pixelDensity: Double) {
+ self.imageID = ObjectIdentifier(baseMap.image)
+ self.sampling = baseMap.sampling
+ self.alphaMilli = Int((baseMap.alpha * 1000).rounded())
+ self.pixelDensityMilli = Int((pixelDensity * 1000).rounded())
+ }
+ }
+
+ /// Reference-typed cache shared across copies of a single `GeoDrawer` value.
+ /// Owners (e.g. `GeoMapView`) discard the entire `GeoDrawer` when projection
+ /// parameters change, which incidentally drops this cache.
+ final class BaseMapCache: @unchecked Sendable {
+ private let lock = NSLock()
+ private var entries: [BaseMapCacheKey: CGImage] = [:]
+ private var tiledEntries: [TiledRasterCacheKey: CGImage] = [:]
+
+ func get(_ key: BaseMapCacheKey) -> CGImage? {
+ lock.lock()
+ defer { lock.unlock() }
+ return entries[key]
+ }
+
+ func set(_ key: BaseMapCacheKey, _ image: CGImage) {
+ lock.lock()
+ defer { lock.unlock() }
+ entries[key] = image
+ }
+
+ func getTiled(_ key: TiledRasterCacheKey) -> CGImage? {
+ lock.lock()
+ defer { lock.unlock() }
+ return tiledEntries[key]
+ }
+
+ func setTiled(_ key: TiledRasterCacheKey, _ image: CGImage) {
+ lock.lock()
+ defer { lock.unlock() }
+ tiledEntries[key] = image
+ }
+
+ /// Drops every rendered tiled raster whose key matches `sourceID`.
+ /// Called when new tiles arrive for that source — the prior render
+ /// has stale tile coverage and must be recomputed.
+ func invalidateTiled(matching sourceID: AnyHashable) {
+ lock.lock()
+ defer { lock.unlock() }
+ tiledEntries = tiledEntries.filter { $0.key.sourceID != sourceID }
+ }
+ }
+
+ /// Rasterises the base map at the drawer's canvas size, using the
+ /// projection's `inverse(_:)` to look up a source pixel for each output
+ /// pixel.
+ ///
+ /// Output is a CGImage with premultiplied alpha and row 0 at the visual
+ /// top of the canvas (north pole). Pixels outside the projection's image
+ /// are left transparent so the surrounding `mapBackground` /
+ /// `mapBackdrop` shows through. The result is cached on the drawer keyed
+ /// by the `BaseMap`'s identity, so subsequent draws (e.g. triggered by
+ /// toggling vector layers) reuse the same raster.
+ ///
+ /// The `coordinateSystem` parameter is accepted for API symmetry with the
+ /// other `GeoDrawer` entry points but is intentionally ignored — the
+ /// caller (`draw(_:mapBackground:mapOutline:mapBackdrop:in:)`) is
+ /// responsible for counter-flipping the CTM on UIKit so the raster's
+ /// row 0 lands at the visual top in either coordinate system.
+ func renderedBaseMap(_ baseMap: BaseMap, coordinateSystem _: CoordinateSystem) -> CGImage? {
+ let key = BaseMapCacheKey(baseMap, pixelDensity: pixelDensity)
+ if let cached = baseMapCache.get(key) {
+ return cached
+ }
+ guard let raster = renderBaseMap(baseMap) else {
+ return nil
+ }
+ baseMapCache.set(key, raster)
+ return raster
+ }
+
+ private func renderBaseMap(_ baseMap: BaseMap) -> CGImage? {
+ guard let projection else { return nil }
+
+ let pointWidth = size.width
+ let pointHeight = size.height
+ let width = max(1, Int((pointWidth * pixelDensity).rounded()))
+ let height = max(1, Int((pointHeight * pixelDensity).rounded()))
+ let bytesPerRow = width * 4
+ let totalBytes = bytesPerRow * height
+
+ let buffer = UnsafeMutablePointer.allocate(capacity: totalBytes)
+ buffer.initialize(repeating: 0, count: totalBytes)
+
+ let projSize = projection.projectionSize
+ let mapBounds = projection.mapBounds
+ let drawerSize = size
+ let drawerZoom = zoomTo
+ let drawerInsets = insets
+
+ let sourceImage = baseMap.image
+ let sourceImageSize = Size(width: Double(sourceImage.width), height: Double(sourceImage.height))
+ let context = RasterContext(
+ buffer: buffer,
+ width: width,
+ bytesPerRow: bytesPerRow,
+ pixelDensity: pixelDensity,
+ drawerSize: drawerSize,
+ drawerZoom: drawerZoom,
+ drawerInsets: drawerInsets,
+ projection: projection,
+ projSize: projSize,
+ mapBounds: mapBounds,
+ sourceProjection: baseMap.sourceProjection,
+ sourceImageSize: sourceImageSize,
+ wrapsLongitudinally: baseMap.sourceProjection.wrapsLongitudinally,
+ sampling: baseMap.sampling,
+ alpha: baseMap.alpha,
+ // Holding a strong reference keeps the underlying buffer alive across
+ // every parallel iteration, even if `baseMap`'s scope contracts.
+ imageRef: sourceImage,
+ imagePixels: sourceImage.pixels,
+ imageW: sourceImage.width,
+ imageH: sourceImage.height
+ )
+
+ DispatchQueue.concurrentPerform(iterations: height) { py in
+ context.renderRow(py)
+ }
+
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+ let provider = CGDataProvider(
+ dataInfo: nil,
+ data: buffer,
+ size: totalBytes,
+ releaseData: { _, ptr, _ in
+ ptr.deallocate()
+ }
+ )
+ guard let provider else {
+ buffer.deallocate()
+ return nil
+ }
+
+ return CGImage(
+ width: width, height: height,
+ bitsPerComponent: 8,
+ bitsPerPixel: 32,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo),
+ provider: provider,
+ decode: nil,
+ shouldInterpolate: false,
+ intent: .defaultIntent
+ )
+ }
+}
+
+/// Captures everything the per-row rasterisation needs in a single value so
+/// `concurrentPerform` doesn't have to capture the enclosing `GeoDrawer`'s
+/// `self`. Marked `@unchecked Sendable` because each iteration writes only to
+/// its own row of the shared output buffer (no overlap, no races).
+private struct RasterContext: @unchecked Sendable {
+ let buffer: UnsafeMutablePointer
+ let width: Int
+ let bytesPerRow: Int
+ let pixelDensity: Double
+ let drawerSize: Size
+ let drawerZoom: Rect?
+ let drawerInsets: EdgeInsets
+ let projection: Projection
+ let projSize: Size
+ let mapBounds: MapBounds
+ let sourceProjection: Projection
+ let sourceImageSize: Size
+ let wrapsLongitudinally: Bool
+ let sampling: GeoDrawer.Sampling
+ let alpha: Double
+ let imageRef: GeoDrawer.BaseMapImage
+ let imagePixels: UnsafeBufferPointer
+ let imageW: Int
+ let imageH: Int
+
+ func renderRow(_ py: Int) {
+ // Output rows are in backing-pixel space; convert each to the
+ // drawer's point-space before asking the projection.
+ let pyPoints = (Double(py) + 0.5) / pixelDensity
+
+ for px in 0..= Double(imageW) {
+ continue
+ }
+ if sy < 0 || sy >= Double(imageH) {
+ continue
+ }
+
+ let pixel = sample(sx: sx, sy: sy)
+ // Source bytes are premultiplied (kCGImageAlphaPremultipliedLast on the
+ // pre-decode). Scaling all four channels by the global alpha multiplier
+ // keeps them in premultiplied form.
+ let outOff = py * bytesPerRow + px * 4
+ if alpha >= 1 {
+ buffer[outOff + 0] = pixel.0
+ buffer[outOff + 1] = pixel.1
+ buffer[outOff + 2] = pixel.2
+ buffer[outOff + 3] = pixel.3
+ } else {
+ buffer[outOff + 0] = UInt8(min(255, max(0, (Double(pixel.0) * alpha).rounded())))
+ buffer[outOff + 1] = UInt8(min(255, max(0, (Double(pixel.1) * alpha).rounded())))
+ buffer[outOff + 2] = UInt8(min(255, max(0, (Double(pixel.2) * alpha).rounded())))
+ buffer[outOff + 3] = UInt8(min(255, max(0, (Double(pixel.3) * alpha).rounded())))
+ }
+ }
+ }
+
+ /// Returns RGBA in 0...255 from a source-pixel coordinate `(sx, sy)`. The
+ /// source buffer is premultiplied — so are the returned components, and
+ /// we keep them premultiplied throughout. Wraps longitudinally for
+ /// cylindrical sources; clamps vertically.
+ private func sample(sx: Double, sy: Double) -> (UInt8, UInt8, UInt8, UInt8) {
+ switch sampling {
+ case .nearest:
+ let xi: Int
+ if wrapsLongitudinally {
+ xi = ((Int(sx.rounded(.down)) % imageW) + imageW) % imageW
+ } else {
+ xi = clamp(Int(sx.rounded(.down)), 0, imageW - 1)
+ }
+ let yi = clamp(Int(sy.rounded(.down)), 0, imageH - 1)
+ let off = (yi * imageW + xi) * 4
+ return (imagePixels[off], imagePixels[off + 1], imagePixels[off + 2], imagePixels[off + 3])
+
+ case .bilinear:
+ let fx = sx - 0.5
+ let fy = sy - 0.5
+ let x0 = Int(floor(fx))
+ let y0 = Int(floor(fy))
+ let tx = fx - Double(x0)
+ let ty = fy - Double(y0)
+
+ let x0w: Int
+ let x1w: Int
+ if wrapsLongitudinally {
+ x0w = ((x0 % imageW) + imageW) % imageW
+ x1w = (((x0 + 1) % imageW) + imageW) % imageW
+ } else {
+ x0w = clamp(x0, 0, imageW - 1)
+ x1w = clamp(x0 + 1, 0, imageW - 1)
+ }
+ let y0c = clamp(y0, 0, imageH - 1)
+ let y1c = clamp(y0 + 1, 0, imageH - 1)
+
+ let off00 = (y0c * imageW + x0w) * 4
+ let off01 = (y0c * imageW + x1w) * 4
+ let off10 = (y1c * imageW + x0w) * 4
+ let off11 = (y1c * imageW + x1w) * 4
+
+ func mix(_ a: UInt8, _ b: UInt8, _ t: Double) -> UInt8 {
+ let v = Double(a) * (1 - t) + Double(b) * t
+ return UInt8(min(255, max(0, v.rounded())))
+ }
+ let r = mix(mix(imagePixels[off00 + 0], imagePixels[off01 + 0], tx),
+ mix(imagePixels[off10 + 0], imagePixels[off11 + 0], tx), ty)
+ let g = mix(mix(imagePixels[off00 + 1], imagePixels[off01 + 1], tx),
+ mix(imagePixels[off10 + 1], imagePixels[off11 + 1], tx), ty)
+ let b = mix(mix(imagePixels[off00 + 2], imagePixels[off01 + 2], tx),
+ mix(imagePixels[off10 + 2], imagePixels[off11 + 2], tx), ty)
+ let a = mix(mix(imagePixels[off00 + 3], imagePixels[off01 + 3], tx),
+ mix(imagePixels[off10 + 3], imagePixels[off11 + 3], tx), ty)
+ return (r, g, b, a)
+ }
+ }
+}
+
+@inline(__always)
+private func clamp(_ value: T, _ lo: T, _ hi: T) -> T {
+ min(max(value, lo), hi)
+}
+
+#endif
diff --git a/Sources/GeoDrawer/apple/GeoDrawer+TiledBaseMap.swift b/Sources/GeoDrawer/apple/GeoDrawer+TiledBaseMap.swift
new file mode 100644
index 0000000..bd88173
--- /dev/null
+++ b/Sources/GeoDrawer/apple/GeoDrawer+TiledBaseMap.swift
@@ -0,0 +1,280 @@
+//
+// GeoDrawer+TiledBaseMap.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(CoreGraphics)
+import CoreGraphics
+import Foundation
+
+@preconcurrency import GeoProjector
+
+extension GeoDrawer {
+
+ /// Cache key for the rendered raster — independent of the drawer's
+ /// `(projection, size, zoomTo, insets)` tuple, which is captured by
+ /// the cache instance's lifetime. Uses the *resolved* zoom level so
+ /// `.auto` and `.fixed(z)` that pick the same level share a slot.
+ /// `pixelDensityMilli` is included so a raster rendered at one
+ /// backing scale isn't served from cache at another.
+ struct TiledRasterCacheKey: Hashable {
+ let sourceID: AnyHashable
+ let resolvedZoom: Int
+ let sampling: Sampling
+ let alphaMilli: Int
+ let pixelDensityMilli: Int
+ }
+
+ /// Renders the tiled base map at the drawer's canvas size using the
+ /// pre-fetched tiles. Pixels outside the source projection's image, or
+ /// in tiles that haven't been fetched, are left transparent. The
+ /// rendered raster is cached on the drawer.
+ func renderedTiledBaseMap(
+ _ tiledBaseMap: TiledBaseMap,
+ coordinateSystem: CoordinateSystem
+ ) -> CGImage? {
+ let z = resolvedZoom(tiledBaseMap.zoom, source: tiledBaseMap.source)
+ let cacheKey = TiledRasterCacheKey(
+ sourceID: tiledBaseMap.source.tileSourceID,
+ resolvedZoom: z,
+ sampling: tiledBaseMap.sampling,
+ alphaMilli: Int((tiledBaseMap.alpha * 1000).rounded()),
+ pixelDensityMilli: Int((pixelDensity * 1000).rounded())
+ )
+ if let cached = baseMapCache.getTiled(cacheKey) {
+ return cached
+ }
+ guard let raster = renderTiledBaseMap(tiledBaseMap, zoom: z) else { return nil }
+ baseMapCache.setTiled(cacheKey, raster)
+ return raster
+ }
+
+ private func renderTiledBaseMap(_ tiledBaseMap: TiledBaseMap, zoom z: Int) -> CGImage? {
+ guard let projection else { return nil }
+
+ // Render at backing-store resolution so the result downscales (rather
+ // than upscales) when CG composites it onto the destination context.
+ let pointWidth = size.width
+ let pointHeight = size.height
+ let width = max(1, Int((pointWidth * pixelDensity).rounded()))
+ let height = max(1, Int((pointHeight * pixelDensity).rounded()))
+ let bytesPerRow = width * 4
+ let totalBytes = bytesPerRow * height
+
+ let buffer = UnsafeMutablePointer.allocate(capacity: totalBytes)
+ buffer.initialize(repeating: 0, count: totalBytes)
+
+ let source = tiledBaseMap.source
+ let sourceID = source.tileSourceID
+ let n = 1 << z
+ let tileSize = source.tileSize
+ let totalSize = Size(width: Double(tileSize * n), height: Double(tileSize * n))
+
+ // Snapshot the tile grid into a flat array up-front so the per-pixel
+ // sampler can look up tiles by integer index — no NSLock acquire and
+ // no `AnyHashable` hash per pixel. The drawer's `tileCache` is a
+ // shared resource (one lock per render's tile-snapshot, not per
+ // sample), and the per-pixel cost drops to a bounds-checked array
+ // load.
+ var tileGrid: [TileImage?] = Array(repeating: nil, count: n * n)
+ for ty in 0..
+ let width: Int
+ let bytesPerRow: Int
+ let pixelDensity: Double
+ let drawerSize: Size
+ let drawerZoom: Rect?
+ let drawerInsets: EdgeInsets
+ let projection: Projection
+ let projSize: Size
+ let mapBounds: MapBounds
+ let sourceProjection: Projection
+ let sourceCanvasSize: Size
+ let tileSize: Int
+ let gridDimension: Int
+ let sampling: GeoDrawer.Sampling
+ let alpha: Double
+ /// Flat row-major grid of the tiles this render needs — pre-resolved
+ /// from the shared `TileCache` once, before the per-pixel sweep
+ /// starts. Index is `ty * gridDimension + tx`.
+ let tileGrid: [TileImage?]
+ let wrapsLongitudinally: Bool
+
+ func renderRow(_ py: Int) {
+ // Convert pixel-space row to point-space so the projection's screen
+ // transform (which operates in points) maps correctly.
+ let pyPoints = (Double(py) + 0.5) / pixelDensity
+
+ for px in 0..= totalW {
+ continue
+ }
+ if syFull < 0 || syFull >= sourceCanvasSize.height { continue }
+
+ // Locate which tile covers (sxFull, syFull) and the pixel inside it.
+ let tx = Int(sxFull.rounded(.down)) / tileSize
+ let ty = Int(syFull.rounded(.down)) / tileSize
+ if tx < 0 || tx >= gridDimension || ty < 0 || ty >= gridDimension { continue }
+
+ guard let tile = tileGrid[ty * gridDimension + tx] else { continue }
+
+ let tileX = sxFull - Double(tx * tileSize)
+ let tileY = syFull - Double(ty * tileSize)
+ let pixel = sample(tile: tile, sx: tileX, sy: tileY)
+
+ let outOff = py * bytesPerRow + px * 4
+ if alpha >= 1 {
+ buffer[outOff + 0] = pixel.0
+ buffer[outOff + 1] = pixel.1
+ buffer[outOff + 2] = pixel.2
+ buffer[outOff + 3] = pixel.3
+ } else {
+ buffer[outOff + 0] = UInt8(min(255, max(0, (Double(pixel.0) * alpha).rounded())))
+ buffer[outOff + 1] = UInt8(min(255, max(0, (Double(pixel.1) * alpha).rounded())))
+ buffer[outOff + 2] = UInt8(min(255, max(0, (Double(pixel.2) * alpha).rounded())))
+ buffer[outOff + 3] = UInt8(min(255, max(0, (Double(pixel.3) * alpha).rounded())))
+ }
+ }
+ }
+
+ private func sample(
+ tile: TileImage, sx: Double, sy: Double
+ ) -> (UInt8, UInt8, UInt8, UInt8) {
+ let w = tile.width
+ let h = tile.height
+ return tile.pixels.withUnsafeBufferPointer { ptr in
+ switch sampling {
+ case .nearest:
+ let xi = min(max(Int(sx.rounded(.down)), 0), w - 1)
+ let yi = min(max(Int(sy.rounded(.down)), 0), h - 1)
+ let off = (yi * w + xi) * 4
+ return (ptr[off], ptr[off + 1], ptr[off + 2], ptr[off + 3])
+
+ case .bilinear:
+ let fx = sx - 0.5
+ let fy = sy - 0.5
+ let x0 = Int(floor(fx))
+ let y0 = Int(floor(fy))
+ let tx = fx - Double(x0)
+ let ty = fy - Double(y0)
+
+ // Clamp at tile boundaries. Cross-tile bilinear blending isn't
+ // implemented in v1; tile edges get a 1-pixel hard seam at high
+ // sampling ratios. The slippy zoom-level selection should pick a
+ // zoom where the seam is sub-pixel anyway.
+ let x0c = min(max(x0, 0), w - 1)
+ let x1c = min(max(x0 + 1, 0), w - 1)
+ let y0c = min(max(y0, 0), h - 1)
+ let y1c = min(max(y0 + 1, 0), h - 1)
+
+ let off00 = (y0c * w + x0c) * 4
+ let off01 = (y0c * w + x1c) * 4
+ let off10 = (y1c * w + x0c) * 4
+ let off11 = (y1c * w + x1c) * 4
+
+ func mix(_ a: UInt8, _ b: UInt8, _ t: Double) -> UInt8 {
+ let v = Double(a) * (1 - t) + Double(b) * t
+ return UInt8(min(255, max(0, v.rounded())))
+ }
+ let r = mix(mix(ptr[off00 + 0], ptr[off01 + 0], tx),
+ mix(ptr[off10 + 0], ptr[off11 + 0], tx), ty)
+ let g = mix(mix(ptr[off00 + 1], ptr[off01 + 1], tx),
+ mix(ptr[off10 + 1], ptr[off11 + 1], tx), ty)
+ let b = mix(mix(ptr[off00 + 2], ptr[off01 + 2], tx),
+ mix(ptr[off10 + 2], ptr[off11 + 2], tx), ty)
+ let a = mix(mix(ptr[off00 + 3], ptr[off01 + 3], tx),
+ mix(ptr[off10 + 3], ptr[off11 + 3], tx), ty)
+ return (r, g, b, a)
+ }
+ }
+ }
+}
+
+#endif
diff --git a/Sources/GeoDrawer/apple/GeoMap+AppKit.swift b/Sources/GeoDrawer/apple/GeoMap+AppKit.swift
new file mode 100644
index 0000000..a1e69df
--- /dev/null
+++ b/Sources/GeoDrawer/apple/GeoMap+AppKit.swift
@@ -0,0 +1,469 @@
+//
+// GeoMap+AppKit.swift
+//
+//
+// Created by Adrian Schönig on 10/12/2022.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2022 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+import SwiftUI
+
+import GeoProjector
+import GeoJSONKit
+
+public class GeoMapView: NSView {
+ public var contents: [GeoDrawer.Content] = [] {
+ didSet {
+ if contents == oldValue { return }
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var projection: Projection = Projections.Equirectangular() {
+ didSet {
+ if Self.projectionsEquivalent(projection, oldValue) { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var zoomTo: GeoJSON.BoundingBox? = nil {
+ didSet {
+ if zoomTo == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var insets: GeoProjector.EdgeInsets = .zero {
+ didSet {
+ if insets == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var mapBackground: NSColor = .systemTeal {
+ didSet {
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var mapOutline: NSColor = .black {
+ didSet {
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ public var mapBackdrop: NSColor = .white {
+ didSet {
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ /// Render-resolution policy. `.matchDisplay` (the default) renders at
+ /// the destination display's backing scale; switch to `.draft` for
+ /// fast interactive previews that accept some blur, or `.custom(_)`
+ /// to pick an explicit pixel-density factor (e.g. for export).
+ public var quality: GeoMap.Quality = .matchDisplay {
+ didSet {
+ if quality == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ /// Called on the main thread whenever the in-flight tile prefetch
+ /// state changes — fires once with `loaded == 0` at the start of
+ /// each projection's prefetch, then per tile completion (success or
+ /// failure), and a final snapshot when complete. Drives Cassini's
+ /// progress overlay + warning icon.
+ public var onTileProgress: ((TileFetchProgress) -> Void)? = nil
+
+ /// Resolves `quality` to the concrete `pixelDensity` value for the
+ /// next drawer build. `.matchDisplay` reads the current window's
+ /// backing scale, so moving the window between displays picks up the
+ /// new value via `viewDidMoveToWindow`'s drawer rebuild.
+ private var resolvedPixelDensity: Double {
+ switch quality {
+ case .draft: return 0.5
+ case .standard: return 1.0
+ case .matchDisplay: return Double(window?.backingScaleFactor ?? 1.0)
+ case .custom(let d): return max(0.1, d)
+ }
+ }
+
+ public override var frame: NSRect {
+ didSet {
+ if frame == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+ }
+
+ /// `Projection` is a protocol type and isn't `Equatable`, but for the
+ /// view's redraw-dedupe purposes "same concrete type + same reference
+ /// point" is the property that matters — SwiftUI's body re-evaluation
+ /// can fire many times per second on @Published changes (e.g. tile
+ /// progress callbacks) and triggering a fresh drawer rebuild every
+ /// time would defeat all the caching.
+ private static func projectionsEquivalent(_ a: Projection, _ b: Projection) -> Bool {
+ type(of: a) == type(of: b) && a.reference == b.reference
+ }
+
+ /// Rebuild the drawer when the view's window changes so `pixelDensity`
+ /// picks up the new screen's backing scale factor (moving the window
+ /// between Retina and non-Retina displays, or initial window attach).
+ public override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ _drawer = nil
+ invalidateProjectedContents()
+ setNeedsDisplay(bounds)
+ }
+
+ private var _drawer: GeoDrawer!
+ /// The drawer we were rendering with at the last `.finished` state,
+ /// kept alive across the next projection / size / zoom / quality
+ /// change so `draw(_:)` can paint its prior frame while the new
+ /// render is in flight. Without this, returning early during `.busy`
+ /// cleared the backing store and flashed between every slider tick.
+ /// Cleared on the next `.finished` transition.
+ private var _previousDrawer: GeoDrawer?
+ /// Shared across drawer recreations so fetched OSM (or other) tiles
+ /// survive projection / size / zoom changes — tile bytes are
+ /// projection-independent, so re-hitting the network for them on every
+ /// projection switch would be wasteful and visibly delay redraw.
+ private let _tileCache = GeoDrawer.TileCache()
+
+ /// Save the current drawer for `draw(_:)` to use during the upcoming
+ /// busy state, then clear `_drawer` so the next access rebuilds it
+ /// against the new parameters. The save is sticky — burst slider
+ /// drags don't overwrite the original prior drawer until a render
+ /// actually finishes.
+ private func cycleDrawer() {
+ if _previousDrawer == nil, let existing = _drawer {
+ _previousDrawer = existing
+ }
+ _drawer = nil
+ }
+
+ private var drawer: GeoDrawer {
+ if let _drawer {
+ return _drawer
+ } else {
+ var drawer = GeoDrawer(
+ size: .init(frame.size),
+ projection: projection,
+ zoomTo: zoomTo,
+ insets: insets
+ )
+ drawer.tileCache = _tileCache
+ drawer.pixelDensity = resolvedPixelDensity
+ _drawer = drawer
+ return drawer
+ }
+ }
+
+ private var pendingTiledRerender: Task?
+
+ /// Schedule a background re-render of the tiled raster for `tiled` and
+ /// then a redraw, debounced so a burst of tile arrivals collapses into
+ /// a single render rather than thrashing the main thread.
+ private func scheduleTiledRerender(for tiled: GeoDrawer.TiledBaseMap) {
+ let coord = CoordinateSystem.bottomLeft
+ pendingTiledRerender?.cancel()
+ pendingTiledRerender = Task.detached(priority: .userInitiated) { [weak self] in
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ guard let self, !Task.isCancelled else { return }
+ _ = await self.drawer.renderedTiledBaseMap(tiled, coordinateSystem: coord)
+ if Task.isCancelled { return }
+ await MainActor.run {
+ self.setNeedsDisplay(self.bounds)
+ }
+ }
+ }
+
+ public override func draw(_ rect: NSRect) {
+ // Pick the drawer + projected content to paint. When busy with a
+ // prior finished frame, we want a drawer whose projection matches
+ // that frame:
+ // - If the drawer was cycled (projection / size / zoom / quality
+ // change), `_previousDrawer` holds the old drawer with the old
+ // projection — render the prior pair to reproduce the last
+ // good frame exactly. This dodges the hybrid "new drawer
+ // rendering old projected content" case that partially-cached
+ // a tiled raster, and also avoids white flashes from
+ // returning early.
+ // - If only `contents` changed, `_drawer` is still the same
+ // drawer with the same projection, so rendering the
+ // previously-projected content against it is correct — same
+ // layers as before the toggle, just briefly.
+ let activeDrawer: GeoDrawer
+ let projected: [GeoDrawer.ProjectedContent]
+ switch projectProgress {
+ case let .busy(_, .some(previously)):
+ if let stale = _previousDrawer {
+ activeDrawer = stale
+ } else if let current = _drawer {
+ activeDrawer = current
+ } else {
+ return
+ }
+ projected = previously
+ case .busy(_, .none), .idle:
+ return
+ case let .finished(finished):
+ activeDrawer = drawer
+ projected = finished
+ }
+
+ super.draw(rect)
+
+ // Get the current graphics context and cast it to a CGContext
+ let context = NSGraphicsContext.current!.cgContext
+
+ // Use Core Graphics functions to draw the content of your view
+ activeDrawer.draw(
+ projected,
+ mapBackground: mapBackground.cgColor,
+ mapOutline: mapOutline.cgColor,
+ mapBackdrop: mapBackdrop.cgColor,
+ in: context
+ )
+
+ if isStaleRender {
+ // The new render has been in flight long enough that the prior
+ // frame is misleadingly fresh — wash it in translucent grey to
+ // signal "loading".
+ context.setFillColor(CGColor(gray: 0.5, alpha: 0.25))
+ context.fill(rect)
+ }
+ }
+
+ // MARK: - Performance
+
+ enum ProjectionProgress {
+ case finished([GeoDrawer.ProjectedContent])
+ case busy(Task, previously: [GeoDrawer.ProjectedContent]?)
+ case idle
+ }
+
+ private var projectProgress = ProjectionProgress.idle
+
+ /// When `true`, `draw(_:)` tints the prior frame translucent grey to
+ /// signal that the new render has been in flight for a while.
+ private var isStaleRender = false
+ private var staleRenderTimer: Task?
+
+ private func startStaleRenderTimer() {
+ staleRenderTimer?.cancel()
+ staleRenderTimer = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ guard let self, !Task.isCancelled else { return }
+ if case .busy = self.projectProgress, !self.isStaleRender {
+ self.isStaleRender = true
+ self.setNeedsDisplay(self.bounds)
+ }
+ }
+ }
+
+ private func cancelStaleRenderTimer() {
+ staleRenderTimer?.cancel()
+ staleRenderTimer = nil
+ if isStaleRender {
+ isStaleRender = false
+ // `setNeedsDisplay` is implicit at the .finished transition; no
+ // need to schedule an extra one here.
+ }
+ }
+
+ private func invalidateProjectedContents() {
+ let previous: [GeoDrawer.ProjectedContent]?
+ switch projectProgress {
+ case .finished(let projected):
+ previous = projected
+ case .busy(let task, let previously):
+ task.cancel()
+ previous = previously
+ case .idle:
+ previous = nil
+ }
+
+ startStaleRenderTimer()
+
+ projectProgress = .busy(Task.detached(priority: .high) { [weak self] in
+ guard let self else { return }
+ do {
+ let projected = try await drawer.projectInParallel(contents, coordinateSystem: .bottomLeft)
+ if Task.isCancelled { return }
+
+ // Pre-warm the raster caches off the main thread using whatever
+ // tiles are already in the shared `tileCache`. Tiles from prior
+ // projections cover the geographic overlap with the new one, so
+ // the initial render is fast and visually meaningful for the
+ // common projection-switch case.
+ for content in projected {
+ if Task.isCancelled { break }
+ switch content {
+ case let .baseMap(baseMap):
+ _ = await drawer.renderedBaseMap(baseMap, coordinateSystem: .bottomLeft)
+ case let .tiledBaseMap(tiled):
+ _ = await drawer.renderedTiledBaseMap(tiled, coordinateSystem: .bottomLeft)
+ case .line, .polygon, .circle:
+ break
+ }
+ }
+ if Task.isCancelled { return }
+ await MainActor.run {
+ self.cancelStaleRenderTimer()
+ self._previousDrawer = nil
+ self.projectProgress = .finished(projected)
+ self.setNeedsDisplay(self.bounds)
+ }
+
+ // Now fetch any tiles the new projection needs that the cache is
+ // missing. Each tile arrival invalidates the rendered-raster cache
+ // and triggers a debounced background re-render, so the user sees
+ // tiles fill in progressively rather than waiting on the full set.
+ // Progress is surfaced to the consumer via `onTileProgress`.
+ await withTaskGroup(of: Void.self) { group in
+ for content in projected {
+ if Task.isCancelled { break }
+ guard case let .tiledBaseMap(tiled) = content else { continue }
+ group.addTask {
+ await self.drawer.prefetchTiles(for: tiled) { progress in
+ Task { @MainActor in
+ self.onTileProgress?(progress)
+ self.scheduleTiledRerender(for: tiled)
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ assert(error is CancellationError)
+ }
+ }, previously: previous)
+ }
+}
+
+@available(macOS 10.15, *)
+public struct GeoMap: NSViewRepresentable {
+
+ /// Render-resolution policy. The consuming app picks the trade-off
+ /// between rendering cost and crispness based on context (fast
+ /// `.draft` while the user is interactively exploring, sharper
+ /// `.matchDisplay` once they've settled on a view they like).
+ public enum Quality: Hashable, Sendable {
+ /// Render at half the canvas's logical-point size. Cheap and soft —
+ /// good for live previews while dragging sliders or cycling projections.
+ case draft
+ /// Render at the canvas's logical-point size (no oversampling). Sharp
+ /// on non-Retina displays; visibly soft on Retina.
+ case standard
+ /// Render at the destination display's backing scale factor — matches
+ /// what native UIKit/AppKit drawing produces. Default.
+ case matchDisplay
+ /// Render at the supplied pixel-density factor (e.g. for export at a
+ /// specific resolution, or fine-tuning quality vs. performance).
+ case custom(Double)
+ }
+
+ public init(contents: [GeoDrawer.Content] = [], projection: Projection = Projections.Equirectangular(), zoomTo: GeoJSON.BoundingBox? = nil, insets: GeoProjector.EdgeInsets = .zero, mapBackground: NSColor? = nil, mapOutline: NSColor? = nil, mapBackdrop: NSColor? = nil, quality: Quality = .matchDisplay, onTileProgress: ((TileFetchProgress) -> Void)? = nil) {
+ self.contents = contents
+ self.projection = projection
+ self.zoomTo = zoomTo
+ self.insets = insets
+ self.mapBackground = mapBackground
+ self.mapOutline = mapOutline
+ self.mapBackdrop = mapBackdrop
+ self.quality = quality
+ self.onTileProgress = onTileProgress
+ }
+
+ public var contents: [GeoDrawer.Content] = []
+
+ public var projection: Projection = Projections.Equirectangular()
+
+ public var zoomTo: GeoJSON.BoundingBox? = nil
+
+ public var insets: GeoProjector.EdgeInsets = .zero
+
+ public var mapBackground: NSColor? = nil
+
+ public var mapOutline: NSColor? = nil
+
+ public var mapBackdrop: NSColor? = nil
+
+ public var quality: Quality = .matchDisplay
+
+ public var onTileProgress: ((TileFetchProgress) -> Void)? = nil
+
+ public typealias NSViewType = GeoMapView
+
+ public func makeNSView(context: Context) -> GeoMapView {
+ let view = GeoMapView()
+ view.contents = contents
+ view.projection = projection
+ view.zoomTo = zoomTo
+ view.insets = insets
+ view.quality = quality
+ view.onTileProgress = onTileProgress
+ if let mapBackground {
+ view.mapBackground = mapBackground
+ }
+ if let mapOutline {
+ view.mapOutline = mapOutline
+ }
+ if let mapBackdrop {
+ view.mapBackdrop = mapBackdrop
+ }
+ return view
+ }
+
+ public func updateNSView(_ view: GeoMapView, context: Context) {
+ view.contents = contents
+ view.projection = projection
+ view.zoomTo = zoomTo
+ view.insets = insets
+ view.quality = quality
+ view.onTileProgress = onTileProgress
+ if let mapBackground {
+ view.mapBackground = mapBackground
+ }
+ if let mapOutline {
+ view.mapOutline = mapOutline
+ }
+ if let mapBackdrop {
+ view.mapBackdrop = mapBackdrop
+ }
+ }
+
+}
+
+#if DEBUG
+@available(iOS 13.0, visionOS 1.0, macOS 11.0, *)
+struct GeoMap_Previews: PreviewProvider {
+ static var previews: some View {
+ GeoMap(
+ contents: try! GeoDrawer.Content.content(
+ for: GeoDrawer.Content.countries(),
+ style: .init(color: .init(red: 0, green: 1, blue: 0, alpha: 0))
+ ),
+ projection: Projections.Cassini()
+ )
+ .previewLayout(.fixed(width: 300, height: 300))
+ }
+}
+#endif
+
+#endif
diff --git a/Sources/GeoDrawer/apple/GeoMap+UIKit.swift b/Sources/GeoDrawer/apple/GeoMap+UIKit.swift
new file mode 100644
index 0000000..34640bf
--- /dev/null
+++ b/Sources/GeoDrawer/apple/GeoMap+UIKit.swift
@@ -0,0 +1,476 @@
+//
+// GeoMap+UIKit.swift
+//
+//
+// Created by Adrian Schönig on 24/2/2023.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2022 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(UIKit)
+import UIKit
+import SwiftUI
+
+import GeoProjector
+import GeoJSONKit
+
+public class GeoMapView: UIView {
+ public var contents: [GeoDrawer.Content] = [] {
+ didSet {
+ if contents == oldValue { return }
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ public var projection: Projection = Projections.Equirectangular() {
+ didSet {
+ if Self.projectionsEquivalent(projection, oldValue) { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ /// `Projection` is a protocol type and isn't `Equatable`, but for the
+ /// view's redraw-dedupe purposes "same concrete type + same reference
+ /// point" is the property that matters — SwiftUI's body re-evaluation
+ /// can fire many times per second on @Published changes (e.g. tile
+ /// progress callbacks) and triggering a fresh drawer rebuild every
+ /// time would defeat all the caching.
+ private static func projectionsEquivalent(_ a: Projection, _ b: Projection) -> Bool {
+ type(of: a) == type(of: b) && a.reference == b.reference
+ }
+
+ public var zoomTo: GeoJSON.BoundingBox? = nil {
+ didSet {
+ if zoomTo == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ public var insets: GeoProjector.EdgeInsets = .zero {
+ didSet {
+ if insets == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ public var mapBackground: UIColor = .systemTeal {
+ didSet {
+ setNeedsDisplay()
+ }
+ }
+
+ public var mapOutline: UIColor = .black {
+ didSet {
+ setNeedsDisplay()
+ }
+ }
+
+ /// Render-resolution policy. `.matchDisplay` (the default) renders at
+ /// the destination display's backing scale; switch to `.draft` for
+ /// fast interactive previews that accept some blur, or `.custom(_)`
+ /// to pick an explicit pixel-density factor (e.g. for export).
+ public var quality: GeoMap.Quality = .matchDisplay {
+ didSet {
+ if quality == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ /// Called on the main thread whenever the in-flight tile prefetch
+ /// state changes — fires once with `loaded == 0` at the start of
+ /// each projection's prefetch, then per tile completion (success or
+ /// failure), and a final snapshot when complete. Drives Cassini's
+ /// progress overlay + warning icon.
+ public var onTileProgress: ((TileFetchProgress) -> Void)? = nil
+
+ /// Resolves `quality` to the concrete `pixelDensity` value for the
+ /// next drawer build. `.matchDisplay` reads the current display scale,
+ /// so moving the view between displays picks up the new value via
+ /// `didMoveToWindow`'s drawer rebuild.
+ private var resolvedPixelDensity: Double {
+ switch quality {
+ case .draft: return 0.5
+ case .standard: return 1.0
+ case .matchDisplay: return Double(traitCollection.displayScale)
+ case .custom(let d): return max(0.1, d)
+ }
+ }
+
+
+ public override var frame: CGRect {
+ didSet {
+ if frame == oldValue { return }
+ cycleDrawer()
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+ }
+
+ /// Rebuild the drawer when the view's window changes so `pixelDensity`
+ /// picks up the new screen's display scale (the simulator's 1x display
+ /// vs. a Retina device, or the initial window attach).
+ public override func didMoveToWindow() {
+ super.didMoveToWindow()
+ _drawer = nil
+ invalidateProjectedContents()
+ setNeedsDisplay()
+ }
+
+ private var _drawer: GeoDrawer!
+ /// The drawer we were rendering with at the last `.finished` state,
+ /// kept alive across the next projection / size / zoom / quality
+ /// change so `draw(_:)` can paint its prior frame while the new
+ /// render is in flight. Without this, returning early during `.busy`
+ /// cleared the backing store and flashed white between every slider
+ /// tick. Cleared on the next `.finished` transition.
+ private var _previousDrawer: GeoDrawer?
+ /// Shared across drawer recreations so fetched OSM (or other) tiles
+ /// survive projection / size / zoom changes — tile bytes are
+ /// projection-independent, so re-hitting the network for them on every
+ /// projection switch would be wasteful and visibly delay redraw.
+ private let _tileCache = GeoDrawer.TileCache()
+
+ /// Save the current drawer for `draw(_:)` to use during the upcoming
+ /// busy state, then clear `_drawer` so the next access rebuilds it
+ /// against the new parameters. The save is sticky — burst slider
+ /// drags don't overwrite the original prior drawer until a render
+ /// actually finishes.
+ private func cycleDrawer() {
+ if _previousDrawer == nil, let existing = _drawer {
+ _previousDrawer = existing
+ }
+ _drawer = nil
+ }
+
+ private var drawer: GeoDrawer {
+ if let _drawer {
+ return _drawer
+ } else {
+ var drawer = GeoDrawer(
+ size: .init(frame.size),
+ projection: projection,
+ zoomTo: zoomTo,
+ insets: insets
+ )
+ drawer.tileCache = _tileCache
+ drawer.pixelDensity = resolvedPixelDensity
+ _drawer = drawer
+ return drawer
+ }
+ }
+
+ private var pendingTiledRerender: Task?
+
+ /// Schedule a background re-render of the tiled raster for `tiled` and
+ /// then a redraw, debounced so a burst of tile arrivals collapses into
+ /// a single render rather than thrashing the main thread.
+ private func scheduleTiledRerender(for tiled: GeoDrawer.TiledBaseMap) {
+ let coord = CoordinateSystem.topLeft
+ pendingTiledRerender?.cancel()
+ pendingTiledRerender = Task.detached(priority: .userInitiated) { [weak self] in
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ guard let self, !Task.isCancelled else { return }
+ _ = self.drawer.renderedTiledBaseMap(tiled, coordinateSystem: coord)
+ if Task.isCancelled { return }
+ await MainActor.run {
+ self.setNeedsDisplay(self.bounds)
+ }
+ }
+ }
+
+ public override func draw(_ rect: CGRect) {
+
+ // Get the current graphics context and cast it to a CGContext
+ let context = UIGraphicsGetCurrentContext()!
+
+ let background: UIColor
+ if let backgroundColor {
+ background = backgroundColor
+ } else if #available(iOS 13.0, *) {
+ background = .systemBackground
+ } else {
+ background = .white
+ }
+
+ // Pick the drawer + projected content to paint. When busy with a
+ // prior finished frame, we want a drawer whose projection matches
+ // that frame:
+ // - If the drawer was cycled (projection / size / zoom / quality
+ // change), `_previousDrawer` holds the old drawer with the old
+ // projection — render the prior pair to reproduce the last
+ // good frame exactly. This dodges the hybrid "new drawer
+ // rendering old projected content" case that partially-cached
+ // a tiled raster, and also avoids white flashes from
+ // returning early.
+ // - If only `contents` changed, `_drawer` is still the same
+ // drawer with the same projection, so rendering the
+ // previously-projected content against it is correct — same
+ // layers as before the toggle, just briefly.
+ let activeDrawer: GeoDrawer
+ let projected: [GeoDrawer.ProjectedContent]
+ switch projectProgress {
+ case let .busy(_, .some(previously)):
+ if let stale = _previousDrawer {
+ activeDrawer = stale
+ } else if let current = _drawer {
+ activeDrawer = current
+ } else {
+ return
+ }
+ projected = previously
+ case .busy(_, .none), .idle:
+ return
+ case let .finished(finished):
+ activeDrawer = drawer
+ projected = finished
+ }
+
+ super.draw(rect)
+
+ // Use Core Graphics functions to draw the content of your view
+ activeDrawer.draw(
+ projected,
+ mapBackground: mapBackground.cgColor,
+ mapOutline: mapOutline.cgColor,
+ mapBackdrop: background.cgColor,
+ in: context
+ )
+
+ if isStaleRender {
+ // The new render has been in flight long enough that the prior
+ // frame is misleadingly fresh — wash it in translucent grey to
+ // signal "loading".
+ context.setFillColor(CGColor(gray: 0.5, alpha: 0.25))
+ context.fill(rect)
+ }
+
+ context.flush()
+ }
+
+ // MARK: - Performance
+
+ enum ProjectionProgress {
+ case finished([GeoDrawer.ProjectedContent])
+ case busy(Task, previously: [GeoDrawer.ProjectedContent]?)
+ case idle
+ }
+
+ private var projectProgress = ProjectionProgress.idle
+
+ /// When `true`, `draw(_:)` tints the prior frame translucent grey to
+ /// signal that the new render has been in flight for a while.
+ private var isStaleRender = false
+ private var staleRenderTimer: Task?
+
+ private func startStaleRenderTimer() {
+ staleRenderTimer?.cancel()
+ staleRenderTimer = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ guard let self, !Task.isCancelled else { return }
+ if case .busy = self.projectProgress, !self.isStaleRender {
+ self.isStaleRender = true
+ self.setNeedsDisplay(self.bounds)
+ }
+ }
+ }
+
+ private func cancelStaleRenderTimer() {
+ staleRenderTimer?.cancel()
+ staleRenderTimer = nil
+ if isStaleRender {
+ isStaleRender = false
+ // `setNeedsDisplay` is implicit at the .finished transition; no
+ // need to schedule an extra one here.
+ }
+ }
+
+ private func invalidateProjectedContents() {
+ let previous: [GeoDrawer.ProjectedContent]?
+ switch projectProgress {
+ case .finished(let projected):
+ previous = projected
+ case .busy(let task, let previously):
+ task.cancel()
+ previous = previously
+ case .idle:
+ previous = nil
+ }
+
+ startStaleRenderTimer()
+
+ projectProgress = .busy(Task.detached(priority: .high) { [weak self] in
+ guard let self else { return }
+ do {
+ let projected = try await drawer.projectInParallel(contents, coordinateSystem: .topLeft)
+ if Task.isCancelled { return }
+
+ // Pre-warm the raster caches off the main thread using whatever
+ // tiles are already in the shared `tileCache`. Tiles from prior
+ // projections cover the geographic overlap with the new one, so
+ // the initial render is fast and visually meaningful for the
+ // common projection-switch case.
+ for content in projected {
+ if Task.isCancelled { break }
+ switch content {
+ case let .baseMap(baseMap):
+ _ = drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft)
+ case let .tiledBaseMap(tiled):
+ _ = drawer.renderedTiledBaseMap(tiled, coordinateSystem: .topLeft)
+ case .line, .polygon, .circle:
+ break
+ }
+ }
+ if Task.isCancelled { return }
+ await MainActor.run {
+ self.cancelStaleRenderTimer()
+ self._previousDrawer = nil
+ self.projectProgress = .finished(projected)
+ self.setNeedsDisplay(self.bounds)
+ }
+
+ // Now fetch any tiles the new projection needs that the cache is
+ // missing. Each tile arrival invalidates the rendered-raster cache
+ // and triggers a debounced background re-render, so the user sees
+ // tiles fill in progressively rather than waiting on the full set.
+ // Progress is surfaced to the consumer via `onTileProgress`.
+ await withTaskGroup(of: Void.self) { group in
+ for content in projected {
+ if Task.isCancelled { break }
+ guard case let .tiledBaseMap(tiled) = content else { continue }
+ group.addTask {
+ await self.drawer.prefetchTiles(for: tiled) { progress in
+ Task { @MainActor in
+ self.onTileProgress?(progress)
+ self.scheduleTiledRerender(for: tiled)
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ assert(error is CancellationError)
+ }
+ }, previously: previous)
+ }
+}
+
+@available(iOS 13.0, visionOS 1.0, *)
+public struct GeoMap: UIViewRepresentable {
+
+ /// Render-resolution policy. The consuming app picks the trade-off
+ /// between rendering cost and crispness based on context (fast
+ /// `.draft` while the user is interactively exploring, sharper
+ /// `.matchDisplay` once they've settled on a view they like).
+ public enum Quality: Hashable, Sendable {
+ /// Render at half the canvas's logical-point size. Cheap and soft —
+ /// good for live previews while dragging sliders or cycling projections.
+ case draft
+ /// Render at the canvas's logical-point size (no oversampling). Sharp
+ /// on non-Retina displays; visibly soft on Retina.
+ case standard
+ /// Render at the destination display's backing scale factor — matches
+ /// what native UIKit/AppKit drawing produces. Default.
+ case matchDisplay
+ /// Render at the supplied pixel-density factor (e.g. for export at a
+ /// specific resolution, or fine-tuning quality vs. performance).
+ case custom(Double)
+ }
+
+ public init(contents: [GeoDrawer.Content] = [], projection: Projection = Projections.Equirectangular(), zoomTo: GeoJSON.BoundingBox? = nil, insets: GeoProjector.EdgeInsets = .zero, mapBackground: UIColor? = nil, mapOutline: UIColor? = nil, mapBackdrop: UIColor? = nil, quality: Quality = .matchDisplay, onTileProgress: ((TileFetchProgress) -> Void)? = nil) {
+ self.contents = contents
+ self.projection = projection
+ self.zoomTo = zoomTo
+ self.insets = insets
+ self.mapBackground = mapBackground
+ self.mapOutline = mapOutline
+ self.mapBackdrop = mapBackdrop
+ self.quality = quality
+ self.onTileProgress = onTileProgress
+ }
+
+ public var contents: [GeoDrawer.Content] = []
+
+ public var projection: Projection = Projections.Equirectangular()
+
+ public var zoomTo: GeoJSON.BoundingBox? = nil
+
+ public var insets: GeoProjector.EdgeInsets = .zero
+
+ public var mapBackground: UIColor? = nil
+
+ public var mapOutline: UIColor? = nil
+
+ public var mapBackdrop: UIColor? = nil
+
+ public var quality: Quality = .matchDisplay
+
+ public var onTileProgress: ((TileFetchProgress) -> Void)? = nil
+
+ public typealias UIViewType = GeoMapView
+
+ public func makeUIView(context: Context) -> GeoMapView {
+ let view = GeoMapView()
+ view.contents = contents
+ view.projection = projection
+ view.zoomTo = zoomTo
+ view.insets = insets
+ view.quality = quality
+ view.onTileProgress = onTileProgress
+ if let mapBackground {
+ view.mapBackground = mapBackground
+ }
+ if let mapOutline {
+ view.mapOutline = mapOutline
+ }
+ if let mapBackdrop {
+ view.backgroundColor = mapBackdrop
+ }
+ return view
+ }
+
+ public func updateUIView(_ view: GeoMapView, context: Context) {
+ view.contents = contents
+ view.projection = projection
+ view.zoomTo = zoomTo
+ view.insets = insets
+ view.quality = quality
+ view.onTileProgress = onTileProgress
+ if let mapBackground {
+ view.mapBackground = mapBackground
+ }
+ if let mapOutline {
+ view.mapOutline = mapOutline
+ }
+ if let mapBackdrop {
+ view.backgroundColor = mapBackdrop
+ }
+ }
+
+}
+
+#if DEBUG
+@available(iOS 13.0, visionOS 1.0, macOS 11.0, *)
+struct GeoMap_Previews: PreviewProvider {
+ static var previews: some View {
+ GeoMap(
+ contents: try! GeoDrawer.Content.content(
+ for: GeoDrawer.Content.countries(),
+ style: .init(color: .init(red: 0, green: 1, blue: 0, alpha: 0))
+ ),
+ projection: Projections.Cassini()
+ )
+ .previewLayout(.fixed(width: 300, height: 300))
+ }
+}
+#endif
+
+#endif
diff --git a/Sources/GeoDrawer/apple/TileImage+CoreGraphics.swift b/Sources/GeoDrawer/apple/TileImage+CoreGraphics.swift
new file mode 100644
index 0000000..f185d3a
--- /dev/null
+++ b/Sources/GeoDrawer/apple/TileImage+CoreGraphics.swift
@@ -0,0 +1,59 @@
+//
+// TileImage+CoreGraphics.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(CoreGraphics) && canImport(ImageIO)
+
+import Foundation
+import CoreGraphics
+import ImageIO
+
+extension TileImage {
+
+ /// Decoder closure suitable for passing to `URLTemplateTileSource`.
+ /// Decodes any image format ImageIO supports (PNG, JPEG, WebP on
+ /// recent OS versions, etc.) into RGBA8 premultiplied bytes that the
+ /// raster sampler can consume directly.
+ ///
+ /// Returns `nil` if the supplied bytes don't form a recognisable
+ /// image; throws are reserved for unrecoverable errors (none occur in
+ /// the current implementation).
+ public static let coreGraphicsDecoder: @Sendable (Data) throws -> TileImage? = { data in
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
+ let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
+ return nil
+ }
+
+ let width = cgImage.width
+ let height = cgImage.height
+ guard width > 0, height > 0 else { return nil }
+
+ let bytesPerRow = width * 4
+ var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+
+ let drew: Bool = pixels.withUnsafeMutableBufferPointer { ptr in
+ guard let ctx = CGContext(
+ data: ptr.baseAddress,
+ width: width, height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: bitmapInfo
+ ) else { return false }
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return true
+ }
+ guard drew else { return nil }
+
+ return TileImage(width: width, height: height, pixels: pixels)
+ }
+}
+
+#endif
diff --git a/Sources/GeoProjector/Projection.swift b/Sources/GeoProjector/Projection.swift
index 198c1af..1c0c1b4 100644
--- a/Sources/GeoProjector/Projection.swift
+++ b/Sources/GeoProjector/Projection.swift
@@ -67,6 +67,18 @@ public protocol Projection {
var mapBounds: MapBounds { get }
var invertCheck: ((GeoJSON.Polygon) -> Bool)? { get }
+
+ /// Whether this projection's image covers a full 360° longitudinal span
+ /// and joins continuously across the antimeridian. Equator-aligned
+ /// cylindrical projections (Equirectangular, Mercator, Gall–Peters) wrap;
+ /// pseudocylindrical, azimuthal, and transverse cylindrical projections
+ /// do not.
+ ///
+ /// Used by raster base-map sampling: when wrapping, longitudes that fall
+ /// just past ±180° are sampled from the opposite edge of the source image
+ /// (no antimeridian seam); when not wrapping, those samples are skipped.
+ /// Default is `false`.
+ var wrapsLongitudinally: Bool { get }
}
extension Projection {
@@ -80,6 +92,8 @@ extension Projection {
)
}
+ public var wrapsLongitudinally: Bool { false }
+
}
extension Projection {
diff --git a/Sources/GeoProjector/Projections+Cylindrical.swift b/Sources/GeoProjector/Projections+Cylindrical.swift
index 3d0d5fe..c8549a4 100644
--- a/Sources/GeoProjector/Projections+Cylindrical.swift
+++ b/Sources/GeoProjector/Projections+Cylindrical.swift
@@ -51,9 +51,11 @@ extension Projections {
var phiOne: Double = 0
public let projectionSize: Size
-
+
public let mapBounds: MapBounds = .rectangle
+ public let wrapsLongitudinally: Bool = true
+
public func project(_ point: Point) -> Point? {
let adjusted = Projections.adjust(point, reference: reference)
return .init(
@@ -112,9 +114,11 @@ extension Projections {
public let projectionSize: Size =
.init(width: 2 * .pi, height: 2 * .pi)
-
+
public let mapBounds: MapBounds = .rectangle
+ public let wrapsLongitudinally: Bool = true
+
public func project(_ point: Point) -> Point? {
var adjusted = Projections.adjust(point, reference: reference)
adjusted.y = min(Self.maxLat, max(Self.maxLat * -1, adjusted.y))
@@ -144,9 +148,11 @@ extension Projections {
public let projectionSize: Size =
.init(width: 2 * .pi, height: 4)
-
+
public let mapBounds: MapBounds = .rectangle
+ public let wrapsLongitudinally: Bool = true
+
public func project(_ point: Point) -> Point? {
let adjusted = Projections.adjust(point, reference: reference)
diff --git a/Tests/GeoDrawerTests/BaseMapTests.swift b/Tests/GeoDrawerTests/BaseMapTests.swift
new file mode 100644
index 0000000..d6daa9e
--- /dev/null
+++ b/Tests/GeoDrawerTests/BaseMapTests.swift
@@ -0,0 +1,257 @@
+//
+// BaseMapTests.swift
+//
+//
+// Created by Adrian Schönig on 9/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(Testing) && canImport(CoreGraphics)
+
+import Testing
+import Foundation
+import CoreGraphics
+
+import GeoJSONKit
+@testable import GeoDrawer
+@testable import GeoProjector
+
+struct BaseMapTests {
+
+ // MARK: - Helpers
+
+ /// Builds a synthetic equirectangular CGImage by filling each pixel from
+ /// `pixel(x:y:)`. Output is RGBA8 premultiplied.
+ private static func makeImage(
+ width: Int,
+ height: Int,
+ pixel: (Int, Int) -> (UInt8, UInt8, UInt8, UInt8)
+ ) -> CGImage {
+ let bytesPerRow = width * 4
+ let totalBytes = bytesPerRow * height
+ let buffer = UnsafeMutablePointer.allocate(capacity: totalBytes)
+ for y in 0.. (UInt8, UInt8, UInt8, UInt8) {
+ let width = image.width
+ let height = image.height
+ let bytesPerRow = width * 4
+ var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+ let context = buffer.withUnsafeMutableBufferPointer { ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: width, height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: bitmapInfo
+ )
+ }!
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ let off = y * bytesPerRow + x * 4
+ return (buffer[off], buffer[off + 1], buffer[off + 2], buffer[off + 3])
+ }
+
+ // MARK: - Tests
+
+ @Test func test_solidRedSource_orthographicCenter() throws {
+ let source = Self.makeImage(width: 16, height: 8) { _, _ in (255, 0, 0, 255) }
+ let baseMap = try #require(GeoDrawer.BaseMap(cgImage: source, sampling: .nearest))
+
+ let drawer = GeoDrawer(
+ size: .init(width: 200, height: 200),
+ projection: Projections.Orthographic()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ // Centre of the canvas projects to (0, 0) on the globe — must be red.
+ let centre = Self.readPixel(raster, x: 100, y: 100)
+ #expect(centre.0 > 200)
+ #expect(centre.1 < 50)
+ #expect(centre.2 < 50)
+ #expect(centre.3 > 200)
+
+ // A pixel well outside the disk (corner) must be transparent.
+ let corner = Self.readPixel(raster, x: 5, y: 5)
+ #expect(corner.3 == 0)
+ }
+
+ @Test func test_eastWestSplit_equirectangular() throws {
+ // Source: left half red, right half blue. In an equirectangular
+ // projection, the left half of the canvas (longitudes < 0) must be red.
+ let source = Self.makeImage(width: 32, height: 16) { x, _ in
+ x < 16 ? (255, 0, 0, 255) : (0, 0, 255, 255)
+ }
+ let baseMap = try #require(GeoDrawer.BaseMap(cgImage: source, sampling: .nearest))
+
+ let drawer = GeoDrawer(
+ size: .init(width: 200, height: 100),
+ projection: Projections.Equirectangular()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ // Sample 25% across — should be red.
+ let leftish = Self.readPixel(raster, x: 50, y: 50)
+ #expect(leftish.0 > 200)
+ #expect(leftish.2 < 50)
+
+ // Sample 75% across — should be blue.
+ let rightish = Self.readPixel(raster, x: 150, y: 50)
+ #expect(rightish.0 < 50)
+ #expect(rightish.2 > 200)
+ }
+
+ @Test func test_alphaMultiplier_appliedToOpaqueSource() throws {
+ let source = Self.makeImage(width: 8, height: 4) { _, _ in (200, 100, 50, 255) }
+ let baseMap = try #require(
+ GeoDrawer.BaseMap(cgImage: source, sampling: .nearest, alpha: 0.5)
+ )
+
+ let drawer = GeoDrawer(
+ size: .init(width: 100, height: 100),
+ projection: Projections.Equirectangular()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ let centre = Self.readPixel(raster, x: 50, y: 50)
+ // Premultiplied — every channel including alpha is scaled by 0.5.
+ #expect(abs(Int(centre.3) - 127) <= 2)
+ #expect(abs(Int(centre.0) - 100) <= 2)
+ #expect(abs(Int(centre.1) - 50) <= 2)
+ #expect(abs(Int(centre.2) - 25) <= 2)
+ }
+
+ @Test func test_renderedBaseMap_isCachedByDrawer() throws {
+ let source = Self.makeImage(width: 8, height: 4) { _, _ in (10, 20, 30, 255) }
+ let baseMap = try #require(GeoDrawer.BaseMap(cgImage: source, sampling: .nearest))
+
+ let drawer = GeoDrawer(
+ size: .init(width: 80, height: 40),
+ projection: Projections.Equirectangular()
+ )
+ let first = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+ let second = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+ // Same drawer + same base map => identical CGImage instance from the cache.
+ #expect(first === second)
+ }
+
+ @Test func test_mercatorSource_centerPixelMatchesSource() throws {
+ // Source rendered in Mercator projection (square aspect), all red.
+ // An Equirectangular target should still pick up red at the centre.
+ let source = Self.makeImage(width: 32, height: 32) { _, _ in (255, 0, 0, 255) }
+ let baseMap = try #require(GeoDrawer.BaseMap(
+ cgImage: source,
+ sourceProjection: Projections.Mercator(),
+ sampling: .nearest
+ ))
+
+ let drawer = GeoDrawer(
+ size: .init(width: 100, height: 100),
+ projection: Projections.Equirectangular()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ let centre = Self.readPixel(raster, x: 50, y: 50)
+ #expect(centre.0 > 200)
+ #expect(centre.1 < 50)
+ #expect(centre.2 < 50)
+ #expect(centre.3 > 200)
+ }
+
+ @Test func test_mercatorSource_verticalOrientation() throws {
+ // Top half red (north), bottom half blue (south). Verifies that the
+ // Equirectangular→Mercator latitude reprojection preserves orientation:
+ // at lat=+45° the renderer should sample the red top half, and at
+ // lat=-45° the blue bottom half.
+ let source = Self.makeImage(width: 32, height: 32) { _, y in
+ y < 16 ? (255, 0, 0, 255) : (0, 0, 255, 255)
+ }
+ let baseMap = try #require(GeoDrawer.BaseMap(
+ cgImage: source,
+ sourceProjection: Projections.Mercator(),
+ sampling: .nearest
+ ))
+
+ // 200x100 matches Equirectangular's 2:1 aspect, so the projection fills
+ // the canvas exactly. y=12 ≈ lat+47°, y=37 ≈ lat-43°.
+ let drawer = GeoDrawer(
+ size: .init(width: 200, height: 100),
+ projection: Projections.Equirectangular()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ let north = Self.readPixel(raster, x: 100, y: 12)
+ #expect(north.0 > 200)
+ #expect(north.2 < 50)
+
+ let south = Self.readPixel(raster, x: 100, y: 87)
+ #expect(south.0 < 50)
+ #expect(south.2 > 200)
+ }
+
+ @Test func test_mercatorSource_wrapsLongitudinally() throws {
+ // Mercator should expose wrapsLongitudinally = true so cylindrical
+ // sources don't show a vertical seam at the antimeridian.
+ #expect(Projections.Mercator().wrapsLongitudinally == true)
+ #expect(Projections.Equirectangular().wrapsLongitudinally == true)
+ #expect(Projections.GallPeters().wrapsLongitudinally == true)
+ // Default for non-cylindrical projections.
+ #expect(Projections.Cassini().wrapsLongitudinally == false)
+ #expect(Projections.Orthographic().wrapsLongitudinally == false)
+ }
+
+ @Test func test_outOfBoundsPole_isNotSmeared_mercator() throws {
+ // Source has a single bright magenta row at the very top (y=0) and
+ // dark pixels elsewhere. A naive cylindrical sampler with v clamped
+ // to 0 would smear the magenta across the top of the canvas; the
+ // half-pixel inset on `v` keeps the visible band thin.
+ let source = Self.makeImage(width: 16, height: 16) { _, y in
+ y == 0 ? (255, 0, 255, 255) : (0, 0, 0, 255)
+ }
+ let baseMap = try #require(GeoDrawer.BaseMap(cgImage: source, sampling: .bilinear))
+
+ let drawer = GeoDrawer(
+ size: .init(width: 100, height: 100),
+ projection: Projections.Mercator()
+ )
+ let raster = try #require(drawer.renderedBaseMap(baseMap, coordinateSystem: .topLeft))
+
+ // Several rows below the top must NOT be saturated magenta —
+ // confirms the bright source row isn't bleeding south.
+ let mid = Self.readPixel(raster, x: 50, y: 50)
+ #expect(mid.0 < 50)
+ #expect(mid.2 < 50)
+ }
+}
+
+#endif
diff --git a/Tests/GeoDrawerTests/RenderSamples.swift b/Tests/GeoDrawerTests/RenderSamples.swift
new file mode 100644
index 0000000..723217f
--- /dev/null
+++ b/Tests/GeoDrawerTests/RenderSamples.swift
@@ -0,0 +1,248 @@
+//
+// RenderSamples.swift
+//
+// Manual rendering helpers — gated behind the `RENDER_SAMPLES` env var so
+// they don't run in CI. Use them to produce a PNG you can eyeball:
+//
+// RENDER_SAMPLES=1 swift test --filter RenderSamples
+//
+// Output goes to `/tmp/geodrawer-samples/` (or `$RENDER_SAMPLES_DIR`).
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(Testing) && canImport(CoreGraphics) && canImport(ImageIO) && canImport(UniformTypeIdentifiers)
+
+import Testing
+import Foundation
+import CoreGraphics
+import ImageIO
+import UniformTypeIdentifiers
+
+@testable import GeoDrawer
+import GeoProjector
+import GeoProjectorDanseiji
+
+struct RenderSamples {
+
+ static var isEnabled: Bool {
+ ProcessInfo.processInfo.environment["RENDER_SAMPLES"] != nil
+ }
+
+ static var outputDirectory: URL {
+ let raw = ProcessInfo.processInfo.environment["RENDER_SAMPLES_DIR"]
+ ?? "/tmp/geodrawer-samples"
+ let url = URL(fileURLWithPath: raw, isDirectory: true)
+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+ }
+
+ static func loadBlueMarble() -> CGImage? {
+ let path = "/Users/adrian/Development/GeoProjector/Examples/Cassini/Assets.xcassets/world.200408.3x5400x2700.imageset/world.200408.3x5400x2700.jpg"
+ let url = URL(fileURLWithPath: path)
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
+ return CGImageSourceCreateImageAtIndex(source, 0, nil)
+ }
+
+ static func writePNG(_ image: CGImage, to url: URL) throws {
+ guard let dest = CGImageDestinationCreateWithURL(
+ url as CFURL,
+ UTType.png.identifier as CFString,
+ 1, nil
+ ) else {
+ throw NSError(domain: "RenderSamples", code: 1, userInfo: [NSLocalizedDescriptionKey: "destination"])
+ }
+ CGImageDestinationAddImage(dest, image, nil)
+ if !CGImageDestinationFinalize(dest) {
+ throw NSError(domain: "RenderSamples", code: 2, userInfo: [NSLocalizedDescriptionKey: "finalize"])
+ }
+ }
+
+ /// Renders a Danseiji IV map with the bundled Blue Marble base map and
+ /// writes it as `danseiji-iv-blue-marble.png` so the user can confirm the
+ /// `drawImage` / image-export path picks up `Content.baseMap` correctly.
+ @Test func render_danseiji_iv_blue_marble() throws {
+ guard Self.isEnabled else { return }
+
+ let cgImage = try #require(Self.loadBlueMarble(), "missing Blue Marble JPEG")
+ let baseMap = try #require(GeoDrawer.BaseMap(cgImage: cgImage, sampling: .bilinear))
+
+ let canvasW = 1600
+ let canvasH = 800
+ let drawer = GeoDrawer(
+ size: .init(width: Double(canvasW), height: Double(canvasH)),
+ projection: Projections.DanseijiIV()
+ )
+
+ let bytesPerRow = canvasW * 4
+ var buffer = [UInt8](repeating: 0, count: bytesPerRow * canvasH)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let context = try #require(buffer.withUnsafeMutableBufferPointer { ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: canvasW, height: canvasH,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ })
+
+ let backdrop = CGColor(red: 0.05, green: 0.06, blue: 0.10, alpha: 1)
+ let mapBackground = CGColor(red: 0.10, green: 0.20, blue: 0.30, alpha: 1)
+ let mapOutline = CGColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
+
+ let continents = try GeoDrawer.Content.content(
+ for: GeoDrawer.Content.countries(),
+ style: .init(
+ color: CGColor(red: 0.85, green: 0.85, blue: 0.85, alpha: 0.4),
+ lineWidth: 0.5
+ )
+ )
+
+ var contents: [GeoDrawer.Content] = [.baseMap(baseMap)]
+ contents.append(contentsOf: continents)
+
+ drawer.draw(
+ contents,
+ mapBackground: mapBackground,
+ mapOutline: mapOutline,
+ mapBackdrop: backdrop,
+ in: context
+ )
+
+ let rendered = try #require(context.makeImage())
+ let outputURL = Self.outputDirectory.appendingPathComponent("danseiji-iv-blue-marble.png")
+ try Self.writePNG(rendered, to: outputURL)
+
+ print("Wrote sample to \(outputURL.path)")
+ }
+
+ /// Renders the same Danseiji IV view but with the Blue Marble served
+ /// through a `StaticTileSource` (4×2 grid of 1350×1350 tiles inside a
+ /// virtual 4×4 zoom=2 grid; the top/bottom rows are empty because
+ /// equirectangular content is 2:1, not square). Output should match
+ /// `danseiji-iv-blue-marble.png` modulo a 1-pixel hard seam at the
+ /// inter-tile boundaries (cross-tile bilinear blending isn't
+ /// implemented in v1).
+ @Test func render_danseiji_iv_blue_marble_tiled() async throws {
+ guard Self.isEnabled else { return }
+
+ let cgImage = try #require(Self.loadBlueMarble(), "missing Blue Marble JPEG")
+ let tiles = try Self.tileEquirectangularImage(cgImage, gridX: 4, gridYContent: 2, zoom: 2)
+
+ let source = StaticTileSource(
+ projection: Projections.Equirectangular(),
+ tileSize: cgImage.width / 4, // 1350
+ tiles: tiles
+ )
+ let tiledBaseMap = GeoDrawer.TiledBaseMap(source: source, zoom: 2, sampling: .bilinear)
+
+ let canvasW = 1600
+ let canvasH = 800
+ let drawer = GeoDrawer(
+ size: .init(width: Double(canvasW), height: Double(canvasH)),
+ projection: Projections.DanseijiIV()
+ )
+ try await drawer.prefetchTiles(for: tiledBaseMap)
+
+ let bytesPerRow = canvasW * 4
+ var buffer = [UInt8](repeating: 0, count: bytesPerRow * canvasH)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let context = try #require(buffer.withUnsafeMutableBufferPointer { ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: canvasW, height: canvasH,
+ bitsPerComponent: 8, bytesPerRow: bytesPerRow,
+ space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ })
+
+ let backdrop = CGColor(red: 0.05, green: 0.06, blue: 0.10, alpha: 1)
+ let mapBackground = CGColor(red: 0.10, green: 0.20, blue: 0.30, alpha: 1)
+ let mapOutline = CGColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
+
+ let continents = try GeoDrawer.Content.content(
+ for: GeoDrawer.Content.countries(),
+ style: .init(
+ color: CGColor(red: 0.85, green: 0.85, blue: 0.85, alpha: 0.4),
+ lineWidth: 0.5
+ )
+ )
+
+ var contents: [GeoDrawer.Content] = [.tiledBaseMap(tiledBaseMap)]
+ contents.append(contentsOf: continents)
+
+ drawer.draw(
+ contents,
+ mapBackground: mapBackground,
+ mapOutline: mapOutline,
+ mapBackdrop: backdrop,
+ in: context
+ )
+
+ let rendered = try #require(context.makeImage())
+ let outputURL = Self.outputDirectory.appendingPathComponent("danseiji-iv-blue-marble-tiled.png")
+ try Self.writePNG(rendered, to: outputURL)
+
+ print("Wrote sample to \(outputURL.path)")
+ }
+
+ /// Splits an equirectangular CGImage into the standard square-tile grid
+ /// scheme used by `StaticTileSource`. The image content occupies
+ /// `gridYContent` rows in the middle of a `gridX × gridX` (zoom `z`)
+ /// virtual grid; the top and bottom rows are empty (no tiles emitted),
+ /// reflecting that equirectangular content is 2:1 in a square grid.
+ private static func tileEquirectangularImage(
+ _ cgImage: CGImage, gridX: Int, gridYContent: Int, zoom: Int
+ ) throws -> [TileKey: TileImage] {
+ let imageW = cgImage.width
+ let imageH = cgImage.height
+ precondition(imageW.isMultiple(of: gridX),
+ "image width \(imageW) must be a multiple of gridX \(gridX)")
+ precondition(imageH.isMultiple(of: gridYContent),
+ "image height \(imageH) must be a multiple of gridYContent \(gridYContent)")
+
+ let tileSize = imageW / gridX
+ precondition(imageH / gridYContent == tileSize,
+ "image must be \(gridYContent):\(gridX) aspect to fit square \(tileSize)px tiles")
+
+ let n = 1 << zoom
+ precondition(n == gridX, "gridX (\(gridX)) must equal 2^zoom (\(n))")
+
+ // Tiles populate rows at the centre of the n-row grid. For n=4 and
+ // gridYContent=2, content lives in rows 1 and 2.
+ let rowOffset = (n - gridYContent) / 2
+
+ let cs = CGColorSpaceCreateDeviceRGB()
+ var tiles: [TileKey: TileImage] = [:]
+
+ for ty in 0..
+ let slowTiles: Set
+ let failingTiles: Set
+ let slowDelay: Duration
+
+ /// Records of every `tile(for:)` invocation, in arrival order.
+ /// Locked because the renderer fetches in parallel.
+ private let lock = NSLock()
+ private var _calls: [TileKey] = []
+ var calls: [TileKey] {
+ lock.lock(); defer { lock.unlock() }
+ return _calls
+ }
+
+ init(
+ tileSize: Int = 16,
+ zoom: Int,
+ instantTiles: Set = [],
+ slowTiles: Set = [],
+ failingTiles: Set = [],
+ slowDelay: Duration = .milliseconds(50),
+ projection: any Projection = Projections.Mercator()
+ ) {
+ self.projection = projection
+ self.tileSize = tileSize
+ self.minZoom = zoom
+ self.maxZoom = zoom
+ self.tileSourceID = UUID()
+ self.instantTiles = instantTiles
+ self.slowTiles = slowTiles
+ self.failingTiles = failingTiles
+ self.slowDelay = slowDelay
+ }
+
+ /// Single-colour tile for the requested bucket. The renderer reads
+ /// only the byte buffer, so a solid fill is enough to differentiate
+ /// success buckets visually if a test later wants to.
+ private func makeTile(rgba: (UInt8, UInt8, UInt8, UInt8)) -> TileImage {
+ var pixels = [UInt8](repeating: 0, count: tileSize * tileSize * 4)
+ for i in stride(from: 0, to: pixels.count, by: 4) {
+ pixels[i + 0] = rgba.0
+ pixels[i + 1] = rgba.1
+ pixels[i + 2] = rgba.2
+ pixels[i + 3] = rgba.3
+ }
+ return TileImage(width: tileSize, height: tileSize, pixels: pixels)
+ }
+
+ func tile(for key: TileKey) async throws -> TileImage? {
+ lock.lock()
+ _calls.append(key)
+ lock.unlock()
+
+ if failingTiles.contains(key) {
+ throw NSError(domain: "MockTileSource", code: 1, userInfo: nil)
+ }
+ if slowTiles.contains(key) {
+ try await Task.sleep(for: slowDelay)
+ return makeTile(rgba: (0, 128, 255, 255)) // blueish
+ }
+ if instantTiles.contains(key) {
+ return makeTile(rgba: (0, 200, 0, 255)) // green
+ }
+ return nil
+ }
+ }
+
+ // MARK: - Helpers
+
+ /// Records every `TileFetchProgress` snapshot a prefetch emits, in
+ /// order. Used to assert the call shape rather than just the final
+ /// values.
+ final class ProgressRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _snapshots: [TileFetchProgress] = []
+ var snapshots: [TileFetchProgress] {
+ lock.lock(); defer { lock.unlock() }
+ return _snapshots
+ }
+ func record(_ snapshot: TileFetchProgress) {
+ lock.lock()
+ _snapshots.append(snapshot)
+ lock.unlock()
+ }
+ }
+
+ /// A small drawer sized so the Mercator output fully covers its 2×2
+ /// zoom=1 source grid — every tile in `(z=1, x∈{0,1}, y∈{0,1})` is
+ /// hit by at least one output pixel.
+ private static func makeDrawer() -> GeoDrawer {
+ GeoDrawer(
+ size: .init(width: 32, height: 32),
+ projection: Projections.Mercator()
+ )
+ }
+
+ // MARK: - Tests
+
+ /// Baseline: everything resolves successfully. Final progress should
+ /// report (total, total, 0) and the recorder should observe one
+ /// initial snapshot plus one per resolved tile.
+ @Test func prefetch_allInstant_reportsCleanCompletion() async throws {
+ let needed = Set([
+ TileKey(z: 1, x: 0, y: 0),
+ TileKey(z: 1, x: 1, y: 0),
+ TileKey(z: 1, x: 0, y: 1),
+ TileKey(z: 1, x: 1, y: 1),
+ ])
+ let source = MockTileSource(zoom: 1, instantTiles: needed)
+ let tiled = GeoDrawer.TiledBaseMap(source: source, zoom: .fixed(1))
+ let drawer = Self.makeDrawer()
+ let recorder = ProgressRecorder()
+
+ await drawer.prefetchTiles(for: tiled) { recorder.record($0) }
+
+ let final = try #require(recorder.snapshots.last)
+ #expect(final.total == needed.count)
+ #expect(final.loaded == needed.count)
+ #expect(final.failed == 0)
+ #expect(final.isComplete)
+ // Initial snapshot + one per tile = `total + 1` snapshots.
+ #expect(recorder.snapshots.count == needed.count + 1)
+ }
+
+ /// Failed tiles must be counted, never re-thrown. Caller should see
+ /// `failed > 0` and `isComplete == true` at the end.
+ @Test func prefetch_failedTiles_accumulateIntoCount() async throws {
+ let good = TileKey(z: 1, x: 0, y: 0)
+ let bad = TileKey(z: 1, x: 1, y: 1)
+ let source = MockTileSource(
+ zoom: 1,
+ instantTiles: [good],
+ failingTiles: [bad]
+ )
+ // Use the actual `tilesNeeded(for:)` set so the test mirrors the
+ // production prefetch's call shape — for this drawer the Mercator
+ // source needs all four (z=1) tiles, two of which our mock leaves
+ // out as "missing" (returns nil) and one of which fails.
+ let tiled = GeoDrawer.TiledBaseMap(source: source, zoom: .fixed(1))
+ let drawer = Self.makeDrawer()
+ let recorder = ProgressRecorder()
+
+ await drawer.prefetchTiles(for: tiled) { recorder.record($0) }
+
+ let final = try #require(recorder.snapshots.last)
+ #expect(final.failed == 1)
+ #expect(final.isComplete)
+ // Loaded + failed always equals total at completion.
+ #expect(final.loaded + final.failed == final.total)
+ }
+
+ /// A render against a cache that's missing some tiles must:
+ /// - Succeed (return a non-nil CGImage).
+ /// - Leave the affected output pixels transparent (alpha == 0).
+ /// This is the "tiles still in progress / failed" steady state — the
+ /// renderer should *not* refuse to render just because coverage is
+ /// incomplete.
+ @Test func render_partialCache_leavesUncachedRegionsTransparent() throws {
+ let cached = TileKey(z: 1, x: 0, y: 0) // covers NW
+ let missing = [
+ TileKey(z: 1, x: 1, y: 0), // NE
+ TileKey(z: 1, x: 0, y: 1), // SW
+ TileKey(z: 1, x: 1, y: 1), // SE
+ ]
+ let source = MockTileSource(zoom: 1, instantTiles: [cached])
+
+ // Hand-fill the drawer's tileCache with just the NW tile.
+ let drawer = Self.makeDrawer()
+ let nwTile = TileImage(
+ width: source.tileSize,
+ height: source.tileSize,
+ pixels: {
+ var p = [UInt8](repeating: 0, count: source.tileSize * source.tileSize * 4)
+ for i in stride(from: 0, to: p.count, by: 4) {
+ p[i + 0] = 0; p[i + 1] = 200; p[i + 2] = 0; p[i + 3] = 255 // green
+ }
+ return p
+ }()
+ )
+ let nwKey = GeoDrawer.TileCacheKey(sourceID: source.tileSourceID, tileKey: cached)
+ drawer.tileCache.set(nwKey, nwTile)
+
+ let tiled = GeoDrawer.TiledBaseMap(source: source, zoom: .fixed(1), sampling: .nearest)
+ let raster = try #require(drawer.renderedTiledBaseMap(tiled, coordinateSystem: .topLeft))
+
+ let nw = Self.readPixel(raster, x: 8, y: 8)
+ #expect(nw.0 < 50)
+ #expect(nw.1 > 150)
+ #expect(nw.2 < 50)
+ #expect(nw.3 > 200)
+
+ for (x, y) in [(24, 8), (8, 24), (24, 24)] {
+ let pixel = Self.readPixel(raster, x: x, y: y)
+ #expect(pixel.3 == 0, "(\(x),\(y)) should be transparent (missing tile)")
+ }
+
+ _ = missing // referenced for clarity, not asserted directly
+ }
+
+ /// After a tile arrives, the next `renderedTiledBaseMap` call (with
+ /// the rendered-raster cache invalidated, as `prefetchTiles` does
+ /// internally) must reflect the newly-cached tile.
+ @Test func render_afterTileArrival_picksUpNewTile() throws {
+ let nw = TileKey(z: 1, x: 0, y: 0)
+ let ne = TileKey(z: 1, x: 1, y: 0)
+ let source = MockTileSource(zoom: 1, instantTiles: [nw, ne])
+
+ let drawer = Self.makeDrawer()
+ let greenTile = TileImage(
+ width: source.tileSize, height: source.tileSize,
+ pixels: {
+ var p = [UInt8](repeating: 0, count: source.tileSize * source.tileSize * 4)
+ for i in stride(from: 0, to: p.count, by: 4) {
+ p[i + 1] = 200; p[i + 3] = 255
+ }
+ return p
+ }()
+ )
+ let nwCacheKey = GeoDrawer.TileCacheKey(sourceID: source.tileSourceID, tileKey: nw)
+ drawer.tileCache.set(nwCacheKey, greenTile)
+
+ let tiled = GeoDrawer.TiledBaseMap(source: source, zoom: .fixed(1), sampling: .nearest)
+ let first = try #require(drawer.renderedTiledBaseMap(tiled, coordinateSystem: .topLeft))
+ #expect(Self.readPixel(first, x: 24, y: 8).3 == 0, "NE empty initially")
+
+ // Tile arrives.
+ let blueTile = TileImage(
+ width: source.tileSize, height: source.tileSize,
+ pixels: {
+ var p = [UInt8](repeating: 0, count: source.tileSize * source.tileSize * 4)
+ for i in stride(from: 0, to: p.count, by: 4) {
+ p[i + 2] = 200; p[i + 3] = 255
+ }
+ return p
+ }()
+ )
+ let neCacheKey = GeoDrawer.TileCacheKey(sourceID: source.tileSourceID, tileKey: ne)
+ drawer.tileCache.set(neCacheKey, blueTile)
+ // Match production: `prefetchTiles` invalidates the rendered
+ // raster cache on every tile arrival so the next render rebuilds
+ // its tileGrid snapshot.
+ drawer.baseMapCache.invalidateTiled(matching: source.tileSourceID)
+
+ let second = try #require(drawer.renderedTiledBaseMap(tiled, coordinateSystem: .topLeft))
+ let ne_pixel = Self.readPixel(second, x: 24, y: 8)
+ #expect(ne_pixel.2 > 150, "NE should now be blue")
+ #expect(ne_pixel.3 > 200)
+ }
+
+ /// End-to-end: prefetch with mixed outcomes, then render. The render
+ /// should reflect everything that landed in the cache, even though
+ /// `prefetchTiles` reported a non-zero `failed` count.
+ @Test func prefetch_thenRender_partialOutcomesProduceCoverage() async throws {
+ let nw = TileKey(z: 1, x: 0, y: 0)
+ let ne = TileKey(z: 1, x: 1, y: 0)
+ let sw = TileKey(z: 1, x: 0, y: 1)
+ let se = TileKey(z: 1, x: 1, y: 1)
+ let source = MockTileSource(
+ zoom: 1,
+ instantTiles: [nw],
+ slowTiles: [ne],
+ failingTiles: [se]
+ // sw → nil
+ )
+ let tiled = GeoDrawer.TiledBaseMap(source: source, zoom: .fixed(1), sampling: .nearest)
+ let drawer = Self.makeDrawer()
+
+ let recorder = ProgressRecorder()
+ await drawer.prefetchTiles(for: tiled) { recorder.record($0) }
+
+ let final = try #require(recorder.snapshots.last)
+ #expect(final.isComplete)
+ #expect(final.failed == 1, "exactly one tile should be reported as failed: \(final)")
+ // loaded covers everything that wasn't a throw — `nw` (instant),
+ // `ne` (slow but succeeds), `sw` (source returned nil — counted
+ // as resolved rather than failed). So loaded == 3.
+ #expect(final.loaded == 3)
+
+ let raster = try #require(drawer.renderedTiledBaseMap(tiled, coordinateSystem: .topLeft))
+ // NW: instant → cached → rendered.
+ #expect(Self.readPixel(raster, x: 8, y: 8).3 > 200, "NW should be opaque")
+ // NE: slow but eventually succeeded → cached → rendered.
+ #expect(Self.readPixel(raster, x: 24, y: 8).3 > 200, "NE should be opaque after slow load")
+ // SW: source returned nil → not cached → transparent.
+ #expect(Self.readPixel(raster, x: 8, y: 24).3 == 0, "SW (nil) should be transparent")
+ // SE: source threw → not cached → transparent.
+ #expect(Self.readPixel(raster, x: 24, y: 24).3 == 0, "SE (failed) should be transparent")
+ }
+
+ /// Reproduces the user-visible flow: render Draft to seed the
+ /// shared tileCache, then build a *fresh* drawer sharing the same
+ /// cache (mirroring `GeoMapView.cycleDrawer`) and render at Display
+ /// density. Any opacity that Draft produces must also be opaque in
+ /// Display — anything else is the "stuck with gaps" symptom.
+ ///
+ /// Uses the same projection / canvas shape the user reported
+ /// (Danseiji IV with reference pinned at the pole). All tiles are
+ /// "instant" so success is on the library, not the network.
+ @Test func qualitySwitch_displayCovers_everywhereDraftCovers() async throws {
+ let canvas = Size(width: 480, height: 360)
+ let projection: any Projection = Projections.DanseijiIV(
+ reference: GeoJSON.Position(latitude: 90, longitude: 0)
+ )
+ let zoom = 3
+ let n = 1 << zoom
+ let allTiles: Set = Set((0..= totalSize.width { continue }
+ if sy < 0 || sy >= totalSize.height { continue }
+ let tx = Int(sx.rounded(.down)) / tileSize
+ let ty = Int(sy.rounded(.down)) / tileSize
+ guard tx >= 0, tx < n, ty >= 0, ty < n else { continue }
+ let key = TileKey(z: zoom, x: tx, y: ty)
+ realGaps.append((dx, dy, key))
+ }
+ }
+
+ if !realGaps.isEmpty {
+ let (dx, dy, key) = realGaps[0]
+ let cacheKey = GeoDrawer.TileCacheKey(sourceID: source.tileSourceID, tileKey: key)
+ let inNeeded = needed.contains(key)
+ let inCache = sharedCache.contains(cacheKey)
+ Issue.record(Comment(rawValue:
+ "\(realGaps.count) interior gap pixels found. First: (\(dx),\(dy)) wanted tile \(key.description); inNeeded=\(inNeeded), inCache=\(inCache)."
+ ))
+ }
+ }
+
+ /// Returns the full RGBA byte buffer of the given image. Avoids
+ /// repeated CGContext draws inside the comparison loop.
+ private static func readAll(_ image: CGImage) -> [UInt8] {
+ let width = image.width
+ let height = image.height
+ let bytesPerRow = width * 4
+ var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+ let context = buffer.withUnsafeMutableBufferPointer { ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: width, height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: bitmapInfo
+ )
+ }!
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ return buffer
+ }
+
+ // MARK: - Pixel reader
+
+ private static func readPixel(_ image: CGImage, x: Int, y: Int) -> (UInt8, UInt8, UInt8, UInt8) {
+ let width = image.width
+ let height = image.height
+ let bytesPerRow = width * 4
+ var buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
+ let cs = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
+ let context = buffer.withUnsafeMutableBufferPointer { ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: width, height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: cs,
+ bitmapInfo: bitmapInfo
+ )
+ }!
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+ let off = y * bytesPerRow + x * 4
+ return (buffer[off], buffer[off + 1], buffer[off + 2], buffer[off + 3])
+ }
+}
+
+#endif
diff --git a/Tests/GeoDrawerTests/TileSourceTests.swift b/Tests/GeoDrawerTests/TileSourceTests.swift
new file mode 100644
index 0000000..b5bbd14
--- /dev/null
+++ b/Tests/GeoDrawerTests/TileSourceTests.swift
@@ -0,0 +1,210 @@
+//
+// TileSourceTests.swift
+//
+//
+// Created by Adrian Schönig on 10/5/2026.
+//
+// GeoProjector - Native Swift library for drawing map projections
+// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE.
+
+#if canImport(Testing)
+
+import Testing
+import Foundation
+
+#if canImport(CoreGraphics) && canImport(ImageIO) && canImport(UniformTypeIdentifiers)
+import CoreGraphics
+import ImageIO
+import UniformTypeIdentifiers
+#endif
+
+@testable import GeoDrawer
+import GeoProjector
+
+struct TileSourceTests {
+
+ // MARK: - Helpers
+
+ /// 4×4 tile filled with one solid colour. Premultiplied RGBA.
+ private static func solidTile(_ rgba: (UInt8, UInt8, UInt8, UInt8)) -> TileImage {
+ var pixels = [UInt8](repeating: 0, count: 4 * 4 * 4)
+ for i in stride(from: 0, to: pixels.count, by: 4) {
+ pixels[i + 0] = rgba.0
+ pixels[i + 1] = rgba.1
+ pixels[i + 2] = rgba.2
+ pixels[i + 3] = rgba.3
+ }
+ return TileImage(width: 4, height: 4, pixels: pixels)
+ }
+
+ // MARK: - Tests
+
+ @Test func staticSource_returnsTileForKnownKey() async throws {
+ let red = Self.solidTile((255, 0, 0, 255))
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 4,
+ tiles: [TileKey(z: 0, x: 0, y: 0): red]
+ )
+ let fetched = try await source.tile(for: TileKey(z: 0, x: 0, y: 0))
+ let tile = try #require(fetched)
+ #expect(tile.width == 4)
+ #expect(tile.height == 4)
+ #expect(tile.pixels[0] == 255)
+ #expect(tile.pixels[3] == 255)
+ }
+
+ @Test func staticSource_missingKey_returnsNil() async throws {
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 4,
+ tiles: [TileKey(z: 0, x: 0, y: 0): Self.solidTile((1, 2, 3, 255))]
+ )
+ let fetched = try await source.tile(for: TileKey(z: 0, x: 1, y: 0))
+ #expect(fetched == nil)
+ }
+
+ @Test func staticSource_zoomRangeReflectsKeys() {
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 4,
+ tiles: [
+ TileKey(z: 0, x: 0, y: 0): Self.solidTile((1, 1, 1, 255)),
+ TileKey(z: 2, x: 1, y: 2): Self.solidTile((2, 2, 2, 255)),
+ TileKey(z: 3, x: 7, y: 7): Self.solidTile((3, 3, 3, 255)),
+ ]
+ )
+ #expect(source.minZoom == 0)
+ #expect(source.maxZoom == 3)
+ }
+
+#if canImport(CoreGraphics) && canImport(ImageIO) && canImport(UniformTypeIdentifiers)
+ @Test func coreGraphicsDecoder_roundTripsPNG() throws {
+ // Build a 4×4 image: top half red, bottom half blue.
+ let w = 4, h = 4
+ let bytesPerRow = w * 4
+ var raw = [UInt8](repeating: 0, count: bytesPerRow * h)
+ for y in 0.. CGContext? in
+ CGContext(
+ data: ptr.baseAddress, width: w, height: h,
+ bitsPerComponent: 8, bytesPerRow: bytesPerRow,
+ space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ })
+ let cgImage = try #require(context.makeImage())
+
+ let pngData = NSMutableData()
+ let dest = try #require(CGImageDestinationCreateWithData(
+ pngData, UTType.png.identifier as CFString, 1, nil
+ ))
+ CGImageDestinationAddImage(dest, cgImage, nil)
+ #expect(CGImageDestinationFinalize(dest))
+
+ let tile = try #require(try TileImage.coreGraphicsDecoder(pngData as Data))
+ #expect(tile.width == 4)
+ #expect(tile.height == 4)
+
+ // Top row (y=0) should be red, bottom row (y=3) should be blue.
+ #expect(tile.pixels[0 * bytesPerRow + 0 * 4 + 0] == 255)
+ #expect(tile.pixels[0 * bytesPerRow + 0 * 4 + 2] == 0)
+ #expect(tile.pixels[3 * bytesPerRow + 0 * 4 + 0] == 0)
+ #expect(tile.pixels[3 * bytesPerRow + 0 * 4 + 2] == 255)
+ }
+
+ @Test func coreGraphicsDecoder_returnsNilForGarbage() throws {
+ let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x04, 0x05])
+ let result = try TileImage.coreGraphicsDecoder(garbage)
+ #expect(result == nil)
+ }
+#endif
+
+#if canImport(CoreGraphics)
+ @Test func tiledBaseMap_autoZoom_picksLevelMatchingCanvas() throws {
+ // tileSize=256, source supports z=0..10. Auto-zoom should pick:
+ // round(log2(canvasMax / 256)).
+ // 1024-pixel canvas → log2(4) = 2.
+ // 800-pixel canvas → log2(800/256) = log2(3.125) ≈ 1.64 → 2.
+ // 384-pixel canvas → log2(384/256) = log2(1.5) ≈ 0.58 → 1.
+ let zoomLevels = (0...10).map {
+ TileKey(z: $0, x: 0, y: 0)
+ }
+ let dummyTiles = Dictionary(uniqueKeysWithValues: zoomLevels.map { key in
+ let pixels = [UInt8](repeating: 0, count: 256 * 256 * 4)
+ return (key, TileImage(width: 256, height: 256, pixels: pixels))
+ })
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 256,
+ tiles: dummyTiles
+ )
+ let auto = GeoDrawer.TiledBaseMap(source: source) // .auto
+
+ let cases: [(canvas: Double, expectedZoom: Int)] = [
+ (1024, 2),
+ (800, 2),
+ (384, 1),
+ ]
+ for (canvas, expected) in cases {
+ let drawer = GeoDrawer(
+ size: .init(width: canvas, height: canvas),
+ projection: Projections.Mercator()
+ )
+ #expect(drawer.resolvedZoom(auto.zoom, source: source) == expected,
+ "canvas=\(canvas) → expected z=\(expected)")
+ }
+ }
+
+ @Test func tiledBaseMap_autoZoom_clampsToSourceRange() throws {
+ let dummy = TileImage(width: 256, height: 256, pixels: [UInt8](repeating: 0, count: 256 * 256 * 4))
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 256,
+ tiles: [
+ TileKey(z: 3, x: 0, y: 0): dummy,
+ TileKey(z: 4, x: 0, y: 0): dummy,
+ ]
+ )
+ let auto = GeoDrawer.TiledBaseMap(source: source)
+ // Tiny canvas would suggest z<3, but source minZoom is 3.
+ let smallDrawer = GeoDrawer(size: .init(width: 64, height: 64), projection: Projections.Mercator())
+ #expect(smallDrawer.resolvedZoom(auto.zoom, source: source) == 3)
+ // Huge canvas would suggest z>4, but source maxZoom is 4.
+ let bigDrawer = GeoDrawer(size: .init(width: 16384, height: 16384), projection: Projections.Mercator())
+ #expect(bigDrawer.resolvedZoom(auto.zoom, source: source) == 4)
+ }
+#endif
+
+ @Test func contains_rejectsOutOfRangeZoomAndCoords() {
+ let source = StaticTileSource(
+ projection: Projections.Mercator(),
+ tileSize: 4,
+ tiles: [
+ TileKey(z: 1, x: 0, y: 0): Self.solidTile((1, 1, 1, 255)),
+ TileKey(z: 2, x: 0, y: 0): Self.solidTile((2, 2, 2, 255)),
+ ]
+ )
+ // Zoom in supported range, valid (x,y).
+ #expect(source.contains(TileKey(z: 1, x: 1, y: 1)))
+ #expect(source.contains(TileKey(z: 2, x: 3, y: 3)))
+ // Out of zoom range.
+ #expect(source.contains(TileKey(z: 0, x: 0, y: 0)) == false)
+ #expect(source.contains(TileKey(z: 3, x: 0, y: 0)) == false)
+ // Out of grid range at zoom 1 (2×2 tiles → max coord 1).
+ #expect(source.contains(TileKey(z: 1, x: 2, y: 0)) == false)
+ #expect(source.contains(TileKey(z: 1, x: 0, y: -1)) == false)
+ }
+}
+
+#endif