孩子喜欢飞机,于是我给她做了一个雷达

2023-11-28 15:17:28 浏览数 (1)

大数据文摘出品

作者:Caleb

今年夏天,我计划带着我的孩子出国。

她很兴奋。

在此之前,我和妻子决定大肆宣传一下这次的飞行之旅,主要是为了确保女儿能安稳地度过3小时的飞行时间。

可能是我们宣传有点过头了,以至于当我们不得不坐出租车去机场时,我蹒跚学步的孩子感到震惊——她原本以为会从我们家直接走上飞机。

我们登机后,发生了一件令人难以置信的事情。

原来,当机组人员发现你和一个痴迷于飞机的可爱小孩在一起时,他们会邀请你们去看看驾驶舱。

这激发了我女儿对飞机的痴迷。

从那之后,她一直要求我在天上为她寻找飞机,当我为她找到一架飞机时,她很高兴。

上周,我们在花园里待了一个小时,她坐在我的肩上,看着飞机一架接一架地在夜空中闪烁。

后来我找到了FlightRadar24,它能显示覆盖在地图上的飞机位置,但美中不足的是,我必须自己调整方向。

但是,对于一个孩子来说,她可能并不真正理解或关心地图是什么。

所以我们有了继续解决的新问题,比如方向,比如可用性。

作为一名非物理移动技术主管,我确实不知道从哪里开始为孩子打造一匹摇马,但没有什么能阻止我把这个想法变成一个很酷的应用程序。

在雷达上显示附近的航班

通过研究制定的要求:

  • 该应用程序需要保持正确的方向,随设备旋转,以便显示飞机的正确方向。
  • 该应用程序必须根据飞机的高度将飞机图标显示为更大或更小。
  • 该应用程序必须很有趣,要有一种复古儿童玩具的感觉,而不是严肃的商业应用程序。

这些要求导致了一些构成概念验证的活动部分:

  • 保持方向是差异化产品的核心要求,因为现有解决方案缺少这一点。我不关心详细的航班信息,我只是想制作一个很酷的雷达。iOS 核心位置API已被涵盖,每次用户重新调整设备方向时都会提供委托回调。
  • 最重要的组件是Flight Data API。OpenSky Network正是我所需要的。一个简单的REST API,免费供非商业用途,包含某个区域的航班实时数据。我们希望每隔几秒就对这个端点执行操作,以进行真实的雷达扫描。
  • 为了调用 API,还需要一些位置数据。Core Location可供查询距用户位置 /-1度的纬度,精度为0.1度(约10公里),以确保用户的位置足够模糊。我们也只需要在每个会话中获取一次该数据。
  • 最后,我们需要重新掌握三角学技能,将飞行位置数据与我们自己的定向坐标进行比较。这将使我们能够根据附近的飞机在天空中与我们的相对位置,将其绘制到屏幕上的正确位置。

概念验证

对于图标,我选择了一幅女儿戴着可爱飞行员帽的卡通画。所以我们已经有了应用程序名称:Aviator。

方向

第一个关键差异化产品要求是保持方向。

为了使用便利,屏幕上的对象需要与其现实生活中的位置相对应。因此,当用户旋转时,屏幕本身也会旋转并保持指向北。

代码语言:javascript复制
final class LocationManager: CLLocationManager, CLLocationManagerDelegate {
        
    static let shared = LocationManager()
    
    private(set) var rotationAngleSubject = CurrentValueSubject<Double, Never>(0)
    
    override private init() {
        super.init()
        requestWhenInUseAuthorization()
        delegate = self
        startUpdatingHeading()
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        rotationAngleSubject.send(-newHeading.magneticHeading)
    }
}

同时,为了获得好看的指南针效果,我还绘制了一组随旋转角度变化的矩形。

代码语言:javascript复制
@State private var rotationAngle: Angle = .degrees(0)

var body: some View {
    ZStack {
        ForEach(0..<36) {
            let angle = Angle.degrees(Double($0 * 10))   rotationAngle
            Rectangle()
                .frame(width: $0 == 0 ? 16 : 8, height: $0 == 0 ? 3 : 2)
                .foregroundColor($0 == 0 ? .red : .blue)
                .rotationEffect(angle)
                .offset(x: 120 * cos(CGFloat(angle.radians)), y: 120 * sin(CGFloat(angle.radians)))
                .animation(.bouncy, value: rotationAngle)
        }
    }
    .onReceive(LocationManager.shared.rotationAngleSubject) { angle in
        rotationAngle = Angle.degrees(angle)
    }
}

看起来相当不错,而且也完美地响应了我的真实位置。

可能你会注意到一个有趣的视觉故障,因为动画逻辑将0度和360度视为单独的数字——当我经过正北时,所有矩形都会旋转。

航班数据

热身结束,接下来是重要的部分。

OpenSky Network API允许用户给定一系列纬度和经度,通过一个简单的请求返回该范围内的本地航班数组。这意味着,只需将其粘贴到浏览器中,即可找出我可以看到的头顶上空的航班数据。

REST API记录良好,但数据按顺序显示为列表属性。

我们需要去解码它,让其按顺序从JSON响应中解析出字段。

代码语言:javascript复制
struct Flight: Decodable {

    let icao24: String 
    let callsign: String?
    let origin_country: String? 
    let time_position: Int?
    let last_contact: Int
    let longitude: Double
    let latitude: Double

    // ... 

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        icao24 = try container.decode(String.self)
        callsign = try? container.decode(String?.self)
        origin_country = try container.decode(String.self)
        time_position = try? container.decode(Int?.self)
        last_contact = try container.decode(Int.self)
        longitude = try container.decode(Double.self)
        latitude = try container.decode(Double.self)

        // ...
    }
}

我们还可以编写一个简单的API,根据用户的位置坐标执行请求。

代码语言:javascript复制
final class FlightAPI {
    
    func fetchLocalFlightData(coordinate: CLLocationCoordinate2D) async throws -> [Flight] {
        
        let lamin = String(format: "%.1f", coordinate.latitude - 0.25)
        let lamax = String(format: "%.1f", coordinate.latitude   0.25)
        let lomin = String(format: "%.1f", coordinate.longitude - 0.5)
        let lomax = String(format: "%.1f", coordinate.longitude   0.5)

        let url = URL(string: "https://opensky-network.org/api/states/all?lamin=(lamin)&lamax=(lamax)&lomin=(lomin)&lomax=(lomax)")!
        let data = try await URLSession.shared.data(from: url).0
        return try JSONDecoder().decode([Flight].self, from: data)
    }
}

这样飞行数据就被很好地解析为内存中对象的数组,也变得易于处理。

初步结果

如何实际测试飞机图纸的准确性?

我们可以在这些所有东西下面画一张地图:AviatorView顶部的指南针,绘制到屏幕上的飞机,以及朴素的SwiftUI视图。

代码语言:javascript复制
@State private var cameraPosition: MapCameraPosition = .camera(MapCamera(
        centerCoordinate: CLLocationCoordinate2D(latitude: 51.0, longitude: 0.0),
        distance: 100_000,
        heading: 0))

var body: some View {
    ZStack {
        Map(position: $cameraPosition) { } 
        airplanes
        compass
    }
}

这是我第一次熬夜跑出来的结果,与作为事实来源的FlightRadar进行比较。

可以看到,天空中飞机的数量和集群看起来都差不多,但位置却相差甚远。忽然,我灵光一闪,原来还需要使用注释在地图上绘制飞机。

MVP

这个想法我已经酝酿了一整天:我们使用地图,然后在其精确地理位置的顶部绘制飞机形状的注释,最终,我想找到一种方法来隐藏实际地图,并仅将飞机显示为雷达位置上的标记。

这应该会给我们带来我们想要的很酷的、完全定向的雷达效果。

地图注释

在iOS 17中,在地图上绘制注释非常简单。

代码语言:javascript复制
import MapKit
import SwiftUI

struct FlightMapView: View {
    
    @Binding var cameraPosition: MapCameraPosition
    
    let flights: [Flight]

    var body: some View {
        Map(position: $cameraPosition) {
            planeMapAnnotations
        }
        .mapStyle(.imagery)
        .allowsHitTesting(false)
    }
}

在这里,出于雷达的目的,我们希望防止命中测试——即我不希望地图是交互式的。在构想中,地图是不可见的,用户只能看到航班及其位置。

飞机缩放

定位之后,尺寸调整是下一个核心问题,现有的解决方案根本无法很好地处理这个问题。

我使用飞行高度在地图注释中添加了一些简单的对数缩放,以便更高的飞机在屏幕上显得更大。此外,我使用飞机的真实属性,结合核心位置中的用户方向,来显示飞机面向正确的方向。

代码语言:javascript复制
@State private var rotationAngle: Angle = .degrees(0)

private var planeMapAnnotations: some MapContent {
    ForEach(flights, id: .icao24) { flight in
        Annotation(flight.icao24, coordinate: flight.coordinate) {
            let rotation = rotationAngle.degrees   flight.true_track
            let scale = min(2, max(log10(height   1), 0.5))
            Image(systemName: "airplane")
                .rotationEffect(.degrees(rotation))
                .scaleEffect(scale)
            }
        }
        .tint(.white)
    }
}
代码语言:javascript复制

用户调研

现在是进行终极测试的时候了。

我和女儿一起去看飞机,现在我们有了真实的地图注释,能在地图上显示用户的位置和方向。最重要的是,它能够准确地找到飞机

这获得了巨大成功,因为我们在这上面找到了飞机。

初步测试还得出了两条重要信息。

首先,缩放逻辑是不正确的。看看伦敦城市机场地面上的小飞机。由于应用程序的重点是定位天空中的飞机,因此我们需要反转缩放比例,较低的平面必须显示得更大,因为我们是用眼睛来发现它们的。

其次,我的孩子不关心地图,只关心飞机。如果我想消除噪音并专注于发现飞机,我需要删除地图,并开始建造我的雷达!

更新缩放逻辑

我轻松地修复了飞机的缩放逻辑。

经过一番尝试和错误后,为了查看屏幕上看起来不错的内容,并给出合理的尺寸分布,我选择了缩放:

代码语言:javascript复制
min(2, max(4.7 - log10(flight.geo_altitude   1), 0.7))

这些缩放来自我的本地开销扫描:

代码语言:javascript复制
Scale:  1.0835408863965839
Scale:  0.8330645861650874
Scale:  1.095791123396205
Scale:  1.1077242935783653
Scale:  2.0
Scale:  1.4864702267977097
Scale:  0.7

创建雷达

我几乎准备好建造我所设想的雷达了,但是出现了一个问题。

API稳健性

开源OpenSky API不断超时,返回502错误,或者有时生成带有空数据的200响应。

这其实也不是问题,毕竟这不是个企业级应用程序,而且这个API不需要我花任何费用。他们没有SLA,我也觉得自己没有资格获得SLA。不过为了帮助提高客户端的稳健性,我在API调用中实现了一些基本的重试逻辑:

代码语言:javascript复制
private func fetchFlights(at coordinate: CLLocationCoordinate2D, retries: Int = 3) async {
    do {
        try await api.fetchLocalFlightData(coordinate: coordinate)

    } catch {
        if retries > 0 {
            try await fetchFlights(at: coordinate, retries: retries - 1)
        }
    }
}

第二天,API运行良好,除了某些高流量时刻外。

覆盖地图

最重要的降噪任务是使实际地图不可见。没有这个雷达就无法工作。

我能够使用MapPolygon来做到这一点,表面上设计这样你就可以放置叠加层来突出显示地图的各个部分。但我想用它来隐藏除注释之外的所有内容。

代码语言:javascript复制
struct FlightMapView: View {

    var body: some View {
        Map(position: $cameraPosition) {
            planeMapAnnotations
            MapPolygon(overlay(coordinate: coordinate))
        }
        .mapStyle(.imagery)
        .allowsHitTesting(false)
    }

    // ...
    
    private func rectangle(around coordinate: CLLocationCoordinate2D) -> [CLLocationCoordinate2D] {
        [
            CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude - 1),
            CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude   1),
            CLLocationCoordinate2D(latitude: coordinate.latitude   1, longitude: coordinate.longitude   1),
            CLLocationCoordinate2D(latitude: coordinate.latitude   1, longitude: coordinate.longitude - 1)
        ]
    }
    
    private func overlay(coordinate: CLLocationCoordinate2D) -> MKPolygon {
        let rectangle = rectangle(around: coordinate)
        return MKPolygon(coordinates: rectangle, count: rectangle.count)
    }
}

这种方法很有效!

我们现在可以看到飞机,但看不到地图,就像我们想要的那样。

最关键的是,苹果将叠加层设计为位于地图顶部、注释下方,如果他们采取其他方式,我女儿的新玩具就会跛行。

绘制雷达

核心需求的最后一部分是雷达视图,这本质上是一组直线、同心圆和20度的旋转角梯度。

难不倒我。

用户调研2

经过三个晚上的辛苦工作,女儿终于开始对我创造的玩具表现出一些兴趣。

我们已经证明了这个概念,并构建了一个 MVP,可以实现我们设定的核心初始目标。

现在可以考虑把它放到App Store上了。

当然在此之前还需要进行其他的优化。

比如让雷达有360度宽角渐变,从绿色,到透明,到透明,到透明,再到黑色。

代码语言:javascript复制
private var radarLine: some View {
    Circle()
        .fill(
            AngularGradient(
                gradient: Gradient(colors: [
                    Color.black, Color.black, Color.black, Color.black,
                    Color.black.opacity(0.8), Color.black.opacity(0.6),
                    Color.black.opacity(0.4), Color.black.opacity(0.2),
                    Color.clear, Color.clear, Color.clear, Color.clear,
                    Color.clear, Color.clear, Color.clear, Color.clear,
                    Color.clear, Color.clear, Color.clear, Color.green]),
                center: .center,
                startAngle: .degrees(rotationDegree),
                endAngle: .degrees(rotationDegree   360)
            )
        )
        .rotationEffect(Angle(degrees: rotationDegree))
        .animation(.linear(duration: 6).repeatForever(autoreverses: false), value: rotationDegree)
}

除此之外,我添加了CRT屏幕效果和电视扫描线,使应用程序看起来就像是在旧雷达扫描仪上绘制的。

代码语言:javascript复制
#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 crtScreen(
    float2 position,
    half4 color,
    float time
) {
    
    if (all(abs(color.rgb - half3(0.0, 0.0, 0.0)) < half3(0.01, 0.01, 0.01))) {
        return color;
    }
    
    const half scanlineIntensity = 0.2;
    const half scanlineFrequency = 400.0;
    half scanlineValue = sin((position.y   time * 10.0) * scanlineFrequency * 3.14159h) * scanlineIntensity;
    return half4(color.rgb - scanlineValue, color.a);
}

我还创建了一个视图修改器,可以将CRT效果应用到喜欢的任何视图。

代码语言:javascript复制
extension View {
    
    func crtScreenEffect(startTime: Date) -> some View {
        modifier(CRTScreen(startTime: startTime))
    }
}

struct CRTScreen: ViewModifier {
    
    let startTime: Date
    
    func body(content: Content) -> some View {
        content
            .colorEffect(
                ShaderLibrary.crtScreen(
                    .float(startTime.timeIntervalSinceNow)
                )
            )
    }
}

目前该应用程序已经上线了App Store。

同时下个版本的新功能也已经在构想中了,包括但不限于:

  • 向地图添加缩放级别,以将雷达限制为仅检测较近的飞机;
  • 使用OpenSky Network API的高级版本显示直升机、卫星和飞机尺寸类别;
  • 切换飞机上的出发地和目的地国家/地区显示;
  • 使用更先进的金属着色器改善CRT屏幕效果;
  • 实施滑块控件来过滤掉某些距离和高度,例如隐藏所有低矮、遥远的飞机;
  • 实施“滑稽模式”,在雷达上呈现不明飞行物、巨型虫子和外星人。

欢迎大家在评论区留言讨论~

‍相关报道:

https://jacobbartlett.substack.com/p/my-toddler-loves-planes-so-i-built

0 人点赞