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