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

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

首页休闲益智开心小飞机更新时间:2024-06-17

大数据文摘出品

作者:Caleb

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

她很兴奋。

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

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

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

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

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

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

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

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

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

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

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

在雷达上显示附近的航班

通过研究制定的要求:

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

概念验证

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

方向

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

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

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) }}

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

@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响应中解析出字段。

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,根据用户的位置坐标执行请求。

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视图。

@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中,在地图上绘制注释非常简单。

import MapKitimport SwiftUI
struct FlightMapView: View { @Binding var cameraPosition: MapCameraPosition let flights: [Flight]
var body: some View { Map(position: $cameraPosition) { planeMapAnnotations } .mapStyle(.imagery) .allowsHitTesting(false) }}

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

飞机缩放

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

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

@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) }}

用户调研

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

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

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

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

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

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

更新缩放逻辑

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

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

min(2, max(4.7 - log10(flight.geo_altitude 1), 0.7))

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

Scale: 1.0835408863965839Scale: 0.8330645861650874Scale: 1.095791123396205Scale: 1.1077242935783653Scale: 2.0Scale: 1.4864702267977097Scale: 0.7

创建雷达

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

API稳健性

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

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

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

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度宽角渐变,从绿色,到透明,到透明,到透明,再到黑色。

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屏幕效果和电视扫描线,使应用程序看起来就像是在旧雷达扫描仪上绘制的。

#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效果应用到喜欢的任何视图。

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。

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

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

相关报道:

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

租!GPU云资源

新上线一批A100/A800

运营商机房,服务有保障

扫码了解详情☝

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved