Login lives encrypted in the database (Instellingen → OpenF1) so the phone can use live data without env secrets.
697 lines
26 KiB
Swift
697 lines
26 KiB
Swift
import SwiftUI
|
|
|
|
private enum F1LiveLook {
|
|
static let bg = Color(red: 0.043, green: 0.051, blue: 0.071)
|
|
static let card = Color(red: 0.086, green: 0.102, blue: 0.137)
|
|
static let stroke = Color.white.opacity(0.10)
|
|
static let live = Color(red: 1.0, green: 0.231, blue: 0.188)
|
|
static let gold = Color(red: 1.0, green: 0.82, blue: 0.32)
|
|
}
|
|
|
|
struct F1LiveScreen: View {
|
|
enum Tab: String, CaseIterable, Identifiable {
|
|
case overview, timing, map, telemetry, news
|
|
var id: String { rawValue }
|
|
var label: String {
|
|
switch self {
|
|
case .overview: return "Overzicht"
|
|
case .timing: return "Live Timing"
|
|
case .map: return "Track Map"
|
|
case .telemetry: return "Telemetrie"
|
|
case .news: return "Nieuws"
|
|
}
|
|
}
|
|
}
|
|
|
|
@EnvironmentObject private var app: AppModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
|
|
@State private var tab: Tab = .overview
|
|
@State private var payload: F1LiveDashboard?
|
|
@State private var error: String?
|
|
@State private var speedTrail: [Double] = []
|
|
|
|
private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
F1LiveLook.bg.ignoresSafeArea()
|
|
if let payload {
|
|
loaded(payload)
|
|
} else if let error {
|
|
VStack(spacing: 14) {
|
|
header(live: false, title: "F1 Live", subtitle: nil)
|
|
ContentUnavailableView {
|
|
Label("Live timing niet beschikbaar", systemImage: "flag.checkered")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button("Opnieuw") { Task { await load() } }
|
|
.buttonStyle(McPrimaryButtonStyle())
|
|
}
|
|
Spacer()
|
|
}
|
|
} else {
|
|
VStack {
|
|
header(live: false, title: "F1 Live", subtitle: nil)
|
|
Spacer()
|
|
ProgressView().controlSize(.large).tint(.white)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.environment(\.colorScheme, .dark)
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.task { await load() }
|
|
.onReceive(clock) { _ in Task { await load(silent: true) } }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func loaded(_ p: F1LiveDashboard) -> some View {
|
|
VStack(spacing: 0) {
|
|
header(
|
|
live: p.live,
|
|
title: p.session.meetingName ?? "F1 Live",
|
|
subtitle: [
|
|
p.session.sessionName,
|
|
p.session.circuitName,
|
|
p.session.currentLap.map { "Ronde \($0)" },
|
|
].compactMap { $0 }.joined(separator: " · ")
|
|
)
|
|
tabBar
|
|
ScrollView {
|
|
Group {
|
|
switch tab {
|
|
case .overview: overview(p)
|
|
case .timing: timingTable(p.timing)
|
|
case .map: mapBlock(p)
|
|
case .telemetry: telemetryBlock(p)
|
|
case .news: newsList(p.news)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 14)
|
|
.padding(.bottom, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func header(live: Bool, title: String, subtitle: String?) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(.white.opacity(0.10), in: Circle())
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("F1 Live")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
Text(title)
|
|
.font(.system(size: 20, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 8)
|
|
if live {
|
|
Text("LIVE")
|
|
.font(.system(size: 11, weight: .heavy))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(F1LiveLook.live, in: Capsule())
|
|
}
|
|
}
|
|
HStack {
|
|
Text("Aangedreven door OpenF1")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
Spacer()
|
|
if let subtitle, !subtitle.isEmpty {
|
|
Text(subtitle)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.45))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 6)
|
|
.padding(.bottom, 10)
|
|
}
|
|
|
|
private var tabBar: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Tab.allCases) { item in
|
|
Button {
|
|
tab = item
|
|
} label: {
|
|
Text(item.label)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(tab == item ? .white : .white.opacity(0.55))
|
|
.padding(.horizontal, 14)
|
|
.frame(height: 34)
|
|
.background(tab == item ? F1LiveLook.live : Color.white.opacity(0.08), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
|
|
private func overview(_ p: F1LiveDashboard) -> some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if let w = p.weather {
|
|
weatherCard(w, circuit: p.session.circuitName)
|
|
}
|
|
card {
|
|
sectionTitle("Live posities")
|
|
ForEach(Array(p.timing.prefix(8))) { d in
|
|
timingRow(d, compact: true)
|
|
}
|
|
if p.timing.count > 8 {
|
|
Button {
|
|
tab = .timing
|
|
} label: {
|
|
Text("Bekijk alles")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
HStack(spacing: 10) {
|
|
shortcut("Live Timing", "timer") { tab = .timing }
|
|
shortcut("Track Map", "map") { tab = .map }
|
|
shortcut("Telemetrie", "speedometer") { tab = .telemetry }
|
|
}
|
|
if let t = p.telemetry {
|
|
telemetryCard(t)
|
|
}
|
|
if !p.news.isEmpty {
|
|
sectionTitle("Nieuws")
|
|
ForEach(p.news.prefix(4)) { item in
|
|
newsCard(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func timingTable(_ rows: [F1LiveDriver]) -> some View {
|
|
card {
|
|
HStack {
|
|
Text("POS").frame(width: 28, alignment: .leading)
|
|
Text("Coureur").frame(maxWidth: .infinity, alignment: .leading)
|
|
Text("Verschil").frame(width: 72, alignment: .trailing)
|
|
Text("Lap").frame(width: 64, alignment: .trailing)
|
|
Text("").frame(width: 22)
|
|
}
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
ForEach(rows) { d in
|
|
timingRow(d, compact: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func timingRow(_ d: F1LiveDriver, compact: Bool) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text("\(d.position)")
|
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.white)
|
|
.frame(width: 22, alignment: .leading)
|
|
Circle()
|
|
.fill(F1Hex.color(d.teamColor))
|
|
.frame(width: 8, height: 8)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(d.acronym)
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
if !compact {
|
|
Text(d.teamName)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.45))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
Text(d.position == 1 ? "—" : d.gap)
|
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(d.position == 1 ? F1LiveLook.gold : .white.opacity(0.85))
|
|
if !compact {
|
|
Text(d.lastLap)
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.65))
|
|
.frame(width: 64, alignment: .trailing)
|
|
}
|
|
tyreBadge(d.tyre)
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func mapBlock(_ p: F1LiveDashboard) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
card {
|
|
HStack {
|
|
Text(p.map?.name ?? p.session.circuitName ?? "Circuit")
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
Spacer()
|
|
Text("Van boven · live dots")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
}
|
|
F1TrackMapCanvas(path: p.map?.path ?? [], cars: p.map?.cars ?? [])
|
|
.frame(height: 280)
|
|
.padding(.top, 8)
|
|
}
|
|
if let leader = p.timing.first {
|
|
driverSpotlight(leader)
|
|
}
|
|
if let t = p.telemetry {
|
|
telemetryCard(t)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func telemetryBlock(_ p: F1LiveDashboard) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
if let t = p.telemetry {
|
|
telemetryCard(t)
|
|
if speedTrail.count > 1 {
|
|
card {
|
|
sectionTitle("Snelheid")
|
|
F1Sparkline(values: speedTrail)
|
|
.stroke(F1LiveLook.live, lineWidth: 2)
|
|
.frame(height: 120)
|
|
}
|
|
}
|
|
} else {
|
|
Text("Geen telemetrie voor deze sessie.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
if !p.raceControl.isEmpty {
|
|
card {
|
|
sectionTitle("Race control")
|
|
ForEach(p.raceControl.prefix(8)) { item in
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Circle()
|
|
.fill(flagColor(item.flag))
|
|
.frame(width: 8, height: 8)
|
|
.padding(.top, 5)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(item.message)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
if let date = item.date {
|
|
Text(F1LiveFormat.clock(date))
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func newsList(_ items: [F1NewsItem]) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("RacingNews365")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.45))
|
|
ForEach(items) { item in
|
|
newsCard(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View {
|
|
card {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(circuit ?? "Weer")
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
Text(w.rainfall == true ? "Regen" : "Droog")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
Spacer()
|
|
weatherStat("Lucht", w.airTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
|
|
weatherStat("Baan", w.trackTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
|
|
weatherStat("Vocht", w.humidity.map { "\(Int($0.rounded()))%" } ?? "—")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func weatherStat(_ label: String, _ value: String) -> some View {
|
|
VStack(spacing: 2) {
|
|
Text(value)
|
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
}
|
|
.frame(width: 54)
|
|
}
|
|
|
|
private func telemetryCard(_ t: F1LiveTelemetry) -> some View {
|
|
card {
|
|
HStack {
|
|
sectionTitle("Live telemetrie")
|
|
Spacer()
|
|
Text(t.acronym)
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
}
|
|
HStack(alignment: .bottom, spacing: 16) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
Text("\(Int(t.speed ?? 0))")
|
|
.font(.system(size: 44, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text("km/h")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.45))
|
|
}
|
|
Spacer()
|
|
Text("\(Int(t.gear ?? 0))")
|
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
|
.foregroundStyle(F1LiveLook.gold)
|
|
}
|
|
meter("Gas", (t.throttle ?? 0) / 100, .green)
|
|
meter("Rem", (t.brake ?? 0) / 100, F1LiveLook.live)
|
|
HStack {
|
|
Text("RPM \(t.rpm.map { "\(Int($0))" } ?? "—")")
|
|
Spacer()
|
|
if t.drs == true {
|
|
Text("DRS")
|
|
.font(.system(size: 11, weight: .heavy))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Color.green.opacity(0.25), in: Capsule())
|
|
}
|
|
}
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
}
|
|
}
|
|
|
|
private func meter(_ label: String, _ value: Double, _ color: Color) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(label)
|
|
Spacer()
|
|
Text("\(Int((value * 100).clamped(to: 0...100)))%")
|
|
}
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.white.opacity(0.08))
|
|
Capsule()
|
|
.fill(color)
|
|
.frame(width: max(4, geo.size.width * value.clamped(to: 0...1)))
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
}
|
|
}
|
|
|
|
private func driverSpotlight(_ d: F1LiveDriver) -> some View {
|
|
card {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle().fill(F1Hex.color(d.teamColor).opacity(0.25))
|
|
if let url = d.headshotUrl {
|
|
RemoteImage(url: url, contentMode: .fill)
|
|
.clipShape(Circle())
|
|
}
|
|
}
|
|
.frame(width: 64, height: 64)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(d.fullName)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
Text(d.teamName)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
Spacer()
|
|
Text("P\(d.position)")
|
|
.font(.system(size: 22, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
}
|
|
HStack {
|
|
Text("Laatste \(d.lastLap)")
|
|
Spacer()
|
|
Text("Beste \(d.bestLap)")
|
|
}
|
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
.padding(.top, 6)
|
|
}
|
|
}
|
|
|
|
private func newsCard(_ item: F1NewsItem) -> some View {
|
|
Button {
|
|
if let url = URL(string: item.url) { openURL(url) }
|
|
} label: {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
RemoteImage(url: item.imageUrl, contentMode: .fill)
|
|
.frame(width: 92, height: 64)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.title)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(3)
|
|
Text(F1LiveFormat.newsDate(item.publishedAt) + " · RacingNews365")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(10)
|
|
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
Text(title)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
content()
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func sectionTitle(_ t: String) -> some View {
|
|
Text(t)
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
private func tyreBadge(_ tyre: String) -> some View {
|
|
Text(tyre)
|
|
.font(.system(size: 10, weight: .heavy))
|
|
.foregroundStyle(tyre == "H" ? .black : .white)
|
|
.frame(width: 22, height: 22)
|
|
.background(tyreColor(tyre), in: Circle())
|
|
}
|
|
|
|
private func tyreColor(_ tyre: String) -> Color {
|
|
switch tyre.uppercased() {
|
|
case "S", "SS", "US": return .red
|
|
case "M": return .yellow
|
|
case "H": return .white
|
|
case "I": return .green
|
|
case "W": return .blue
|
|
default: return .gray
|
|
}
|
|
}
|
|
|
|
private func flagColor(_ flag: String?) -> Color {
|
|
switch (flag ?? "").uppercased() {
|
|
case "YELLOW", "DOUBLE YELLOW": return .yellow
|
|
case "GREEN": return .green
|
|
case "RED": return .red
|
|
case "BLUE": return .blue
|
|
case "BLACK AND WHITE": return .white
|
|
default: return F1LiveLook.live
|
|
}
|
|
}
|
|
|
|
private func load(silent: Bool = false) async {
|
|
do {
|
|
let next = try await app.api.f1Live()
|
|
payload = next
|
|
error = nil
|
|
if let speed = next.telemetry?.speed {
|
|
speedTrail.append(speed)
|
|
if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) }
|
|
}
|
|
} catch {
|
|
if payload == nil && !silent { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct F1TrackMapCanvas: View {
|
|
let path: [F1LivePoint]
|
|
let cars: [F1LiveCar]
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let inset: CGFloat = 28
|
|
let w = max(geo.size.width - inset * 2, 1)
|
|
let h = max(geo.size.height - inset * 2, 1)
|
|
ZStack(alignment: .topLeading) {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color.black.opacity(0.35))
|
|
if path.count > 1 {
|
|
Path { p in
|
|
p.move(to: point(path[0], w: w, h: h, inset: inset))
|
|
for pt in path.dropFirst() {
|
|
p.addLine(to: point(pt, w: w, h: h, inset: inset))
|
|
}
|
|
}
|
|
.stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineJoin: .round, lineCap: .round))
|
|
} else {
|
|
Text("Geen circuitkaart")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
ForEach(cars, id: \.driverNumber) { car in
|
|
let c = point(F1LivePoint(x: car.x, y: car.y), w: w, h: h, inset: inset)
|
|
Text(car.acronym)
|
|
.font(.system(size: 8, weight: .heavy))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(F1Hex.color(car.teamColor), in: Capsule())
|
|
.position(c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func point(_ p: F1LivePoint, w: CGFloat, h: CGFloat, inset: CGFloat) -> CGPoint {
|
|
CGPoint(x: inset + CGFloat(p.x) * w, y: inset + CGFloat(p.y) * h)
|
|
}
|
|
}
|
|
|
|
private struct F1Sparkline: Shape {
|
|
let values: [Double]
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
guard let minV = values.min(), let maxV = values.max(), values.count > 1 else {
|
|
return Path()
|
|
}
|
|
let span = max(maxV - minV, 1)
|
|
var p = Path()
|
|
for (i, v) in values.enumerated() {
|
|
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
|
|
let y = rect.height - CGFloat((v - minV) / span) * rect.height
|
|
if i == 0 { p.move(to: CGPoint(x: x, y: y)) }
|
|
else { p.addLine(to: CGPoint(x: x, y: y)) }
|
|
}
|
|
return p
|
|
}
|
|
}
|
|
|
|
private enum F1Hex {
|
|
static func color(_ hex: String) -> Color {
|
|
var h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
if h.count == 3 {
|
|
h = h.map { "\($0)\($0)" }.joined()
|
|
}
|
|
guard h.count == 6, let n = UInt64(h, radix: 16) else { return .gray }
|
|
return Color(
|
|
.sRGB,
|
|
red: Double((n >> 16) & 0xFF) / 255,
|
|
green: Double((n >> 8) & 0xFF) / 255,
|
|
blue: Double(n & 0xFF) / 255,
|
|
opacity: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
private enum F1LiveFormat {
|
|
static func clock(_ iso: String) -> String {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
|
|
guard let d else { return iso }
|
|
let out = DateFormatter()
|
|
out.locale = Locale(identifier: "nl_NL")
|
|
out.dateFormat = "HH:mm:ss"
|
|
return out.string(from: d)
|
|
}
|
|
|
|
static func newsDate(_ iso: String?) -> String {
|
|
guard let iso, !iso.isEmpty else { return "" }
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
|
|
guard let d else { return iso }
|
|
let out = DateFormatter()
|
|
out.locale = Locale(identifier: "nl_NL")
|
|
out.dateFormat = "d MMM HH:mm"
|
|
return out.string(from: d)
|
|
}
|
|
}
|
|
|
|
private extension Comparable {
|
|
func clamped(to range: ClosedRange<Self>) -> Self {
|
|
min(max(self, range.lowerBound), range.upperBound)
|
|
}
|
|
}
|