Packages/MoonriseKit/Sources/SkyRenderer/PolarMap.swift
102 lines · 4199 bytes
5 symbols in this file
1import Foundation
2import SwiftUI
3
4/// Natural Earth 1:110m coastlines (public domain), polylines of [longitude, latitude],
5/// kept where they reach 30° or more from the equator.
6public enum Coastline {
7 public static let lines: [[(lon: Double, lat: Double)]] = {
8 let url = Bundle.module.url(forResource: "coastline", withExtension: "json")!
9 let raw = try! JSONDecoder().decode([[[Double]]].self, from: Data(contentsOf: url))
10 return raw.map { $0.map { (lon: $0[0], lat: $0[1]) } }
11 }()
12}
13
14/// Azimuthal equidistant view of one polar region, rotated so the given
15/// longitude points down the screen.
16public struct PolarProjection: Sendable {
17 public var north: Bool
18 public var centerLongitude: Double
19 /// Latitude at the edge of the map (absolute).
20 public var edgeLatitude: Double
21 public var size: CGSize
22
23 public init(north: Bool, centerLongitude: Double, edgeLatitude: Double = 40, size: CGSize) {
24 self.north = north
25 self.centerLongitude = centerLongitude
26 self.edgeLatitude = edgeLatitude
27 self.size = size
28 }
29
30 public func point(lon: Double, lat: Double) -> CGPoint? {
31 let colat = 90 - (north ? lat : -lat)
32 guard colat <= 90 - edgeLatitude + 2 else { return nil }
33 let scale = min(size.width, size.height) / 2 / (90 - edgeLatitude)
34 let a = (lon - centerLongitude) * .pi / 180 * (north ? 1 : -1)
35 let r = colat * scale
36 return CGPoint(x: size.width / 2 + r * sin(a), y: size.height / 2 + r * cos(a))
37 }
38}
39
40/// A gridded value (e.g. aurora probability, 0–100) on 1° cells.
41public struct GridValue: Sendable {
42 public var lon: Double
43 public var lat: Double
44 public var value: Double
45
46 public init(lon: Double, lat: Double, value: Double) {
47 self.lon = lon
48 self.lat = lat
49 self.value = value
50 }
51}
52
53public struct AuroraMap: View {
54 public var cells: [GridValue]
55 public var observer: (lat: Double, lon: Double)
56 public var ink: Color
57 public var aurora: Color
58
59 public init(cells: [GridValue], observer: (lat: Double, lon: Double), ink: Color, aurora: Color = Color(red: 0.35, green: 0.85, blue: 0.5)) {
60 self.cells = cells
61 self.observer = observer
62 self.ink = ink
63 self.aurora = aurora
64 }
65
66 public var body: some View {
67 Canvas { context, size in
68 let north = observer.lat >= 0
69 let p = PolarProjection(north: north, centerLongitude: observer.lon, size: size)
70 let scale = min(size.width, size.height) / 2 / (90 - p.edgeLatitude)
71
72 for c in cells where (c.lat >= 0) == north && c.value >= 3 {
73 guard let pt = p.point(lon: c.lon, lat: c.lat) else { continue }
74 let s = max(2, scale * 1.1)
75 context.fill(Path(CGRect(x: pt.x - s / 2, y: pt.y - s / 2, width: s, height: s)),
76 with: .color(aurora.opacity(min(0.9, 0.15 + c.value / 40))))
77 }
78
79 for lat in stride(from: 50.0, through: 80, by: 10) {
80 let r = (90 - lat) * scale
81 context.stroke(Path(ellipseIn: CGRect(x: size.width / 2 - r, y: size.height / 2 - r, width: 2 * r, height: 2 * r)),
82 with: .color(ink.opacity(0.15)), style: StrokeStyle(lineWidth: 0.5, dash: [2, 3]))
83 }
84
85 var coast = Path()
86 for line in Coastline.lines {
87 var pen = false
88 for c in line {
89 guard let pt = p.point(lon: c.lon, lat: c.lat) else { pen = false; continue }
90 if pen { coast.addLine(to: pt) } else { coast.move(to: pt); pen = true }
91 }
92 }
93 context.stroke(coast, with: .color(ink.opacity(0.6)), lineWidth: 0.7)
94
95 if let you = p.point(lon: observer.lon, lat: observer.lat) {
96 context.fill(Path(ellipseIn: CGRect(x: you.x - 4, y: you.y - 4, width: 8, height: 8)), with: .color(ink))
97 context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: you.x, y: you.y + 12))
98 }
99 }
100 .aspectRatio(1, contentMode: .fit)
101 }
102}