Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dc58cb1
Add raster base-map drawing (issue #7)
claude May 9, 2026
f9c8186
Cassini: replace procedural texture with NASA Blue Marble JPEG
nighthawk May 9, 2026
3ca2483
Cassini: default iOS to Blue Marble only, continents hidden
nighthawk May 9, 2026
ae97dff
Counter-flip CTM around base-map draw on UIKit
nighthawk May 9, 2026
68f8ca3
Add a manual sample renderer for image-export verification
nighthawk May 9, 2026
85e5a24
Generalise base-map source projection (any GeoProjector projection)
nighthawk May 9, 2026
00319cd
Move Apple-only sources into Sources/GeoDrawer/apple/
nighthawk May 9, 2026
cf49d9e
Add TileSource protocol and StaticTileSource
nighthawk May 9, 2026
69f9654
Add URLTemplateTileSource and CoreGraphics tile decoder
nighthawk May 9, 2026
53e6320
Wire TileSource into renderer via Content.tiledBaseMap
nighthawk May 9, 2026
547774c
Add tiled-base-map sample renderer
nighthawk May 9, 2026
5c5ace7
Auto zoom-level selection for TiledBaseMap
nighthawk May 9, 2026
f7c6012
README: document base maps and tile sources
nighthawk May 9, 2026
875fb02
Cassini: demo TiledBaseMap with an OpenStreetMap picker option
nighthawk May 10, 2026
bcca7c3
Persist tile cache + render base maps at backing-store resolution
nighthawk May 10, 2026
b2e43b2
Progressive tile loading and fix stale partial-coverage cache
nighthawk May 10, 2026
8dd1223
Expose GeoMap.Quality so apps choose the perf/crispness trade-off
nighthawk May 11, 2026
9fd9a06
Keep the prior frame on screen during busy renders
nighthawk May 11, 2026
780d900
Detach AppKit's projection task so the main thread stays responsive
nighthawk May 11, 2026
2ab9e77
Surface tile-fetch progress + a failure warning to the consumer
nighthawk May 11, 2026
f840387
Fall back to the current drawer for contents-only changes
nighthawk May 11, 2026
4028cac
Pre-resolve tile grid before the per-pixel sweep (perf)
nighthawk May 11, 2026
7eae7ff
Short-circuit duplicate property assignments in GeoMapView
nighthawk May 11, 2026
afea23e
Fix tilesNeeded gap coverage with per-pixel canvas sweep
nighthawk May 11, 2026
404e7da
Move cross-platform tile machinery out of apple/
nighthawk May 11, 2026
d1bcb0e
Fix Linux build: FoundationNetworking + safe baseAddress unwrap
nighthawk May 11, 2026
99d0e0b
Fall back to dataTask continuation only on legacy Linux
nighthawk May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions Examples/Cassini.xcodeproj/xcshareddata/xcschemes/Cassini.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A1B27C929448ADA00271603"
BuildableName = "Cassini.app"
BlueprintName = "Cassini"
ReferencedContainer = "container:Cassini.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A1B27C929448ADA00271603"
BuildableName = "Cassini.app"
BlueprintName = "Cassini"
ReferencedContainer = "container:Cassini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A1B27C929448ADA00271603"
BuildableName = "Cassini.app"
BlueprintName = "Cassini"
ReferencedContainer = "container:Cassini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "world.200408.3x5400x2700.jpg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions Examples/Cassini/Cassini.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
29 changes: 19 additions & 10 deletions Examples/Cassini/CassiniApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
162 changes: 154 additions & 8 deletions Examples/Cassini/ContentView+Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -198,7 +301,7 @@ extension ContentView {
}

extension GeoDrawer.Content {

func settingColor(_ color: CGColor) -> GeoDrawer.Content {
switch self {
case .line(let lineString, _, let strokeWidth):
Expand All @@ -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)
}
}
Loading