saltylab-ios/SulTee/SulTee/PilotView.swift
sl-ios e0c88983f1 feat: Pilot tab — HUD compass tape from saltybot/imu heading
- CompassTapeHUD: fighter-jet style horizontal scrolling tape,
  smooth sub-pixel phase correction, cardinal labels (N/E/S/W) in
  yellow, intercardinals (NE/SE/SW/NW), numeric ticks every 30°;
  fixed yellow ▼ centre indicator; degree readout (e.g. 327°)
- Toggle button (top-left, waveform icon) shows/hides HUD with
  fade+slide animation; defaults to on; yellow when active
- "NO IMU" placeholder when saltybot/imu data not yet received
- SensorManager: subscribe saltybot/imu in startStreaming() and
  ensureMQTTConnected(); handleBotIMU() parses heading / true_heading
  (degrees) or yaw (auto-detects radians vs degrees); normalises to
  [0, 360); exposes as @Published var botHeadingDeg: Double?

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:32:10 -04:00

347 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import WebKit
// MARK: - Camera source
struct CameraSource: Identifiable, Hashable {
let id: String
let name: String
let url: String
}
// MARK: - MJPEG stream renderer (WKWebView)
/// Renders an MJPEG stream URL inside a black-background WKWebView.
/// Reloads only when `urlString` changes (guarded by Coordinator).
struct MJPEGStreamView: UIViewRepresentable {
let urlString: String
class Coordinator {
var loadedURL = ""
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.allowsInlineMediaPlayback = true
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.backgroundColor = .black
wv.isOpaque = true
wv.scrollView.isScrollEnabled = false
wv.scrollView.bounces = false
return wv
}
func updateUIView(_ wv: WKWebView, context: Context) {
guard context.coordinator.loadedURL != urlString else { return }
context.coordinator.loadedURL = urlString
let html = """
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; width: 100vw; height: 100vh;
display: flex; align-items: center; justify-content: center; overflow: hidden; }
img { max-width: 100%; max-height: 100%; object-fit: contain; }
</style>
</head><body>
<img src="\(urlString)">
</body></html>
"""
wv.loadHTMLString(html, baseURL: nil)
}
}
// MARK: - Fighter-jet compass tape HUD
/// Horizontal scrolling compass tape fighter-jet / HUD style.
/// Current heading is always centred; cardinal labels glow yellow.
struct CompassTapeHUD: View {
let heading: Double // degrees, 0360
// Layout constants
private let tapeHeight: CGFloat = 52
private let pixPerDeg: CGFloat = 3.6 // horizontal pixels per degree
var body: some View {
ZStack {
// Frosted dark background
RoundedRectangle(cornerRadius: 8)
.fill(.black.opacity(0.50))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.white.opacity(0.15), lineWidth: 0.5)
)
Canvas { ctx, size in
drawTape(ctx: ctx, size: size)
}
.clipped()
// Fixed centre triangle points downward from the top edge
VStack(spacing: 0) {
Image(systemName: "arrowtriangle.down.fill")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.yellow)
.shadow(color: .yellow.opacity(0.6), radius: 4)
Spacer()
}
.padding(.top, 1)
// Heading readout centred at bottom of tape
VStack {
Spacer()
Text(String(format: "%03.0f°", heading))
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(.yellow)
.shadow(color: .black, radius: 2)
}
.padding(.bottom, 3)
}
.frame(height: tapeHeight)
}
// MARK: Canvas drawing
private func drawTape(ctx: GraphicsContext, size: CGSize) {
let midX = size.width / 2
let tickY: CGFloat = 10 // top of tick marks
// Snap to nearest whole degree for stable per-step grid
let hdgInt = Int(heading.rounded())
// Sub-pixel phase correction so the tape glides smoothly
let phase = heading - Double(hdgInt) // fractional remainder
// Draw ±80° around current heading in 5° steps
for offset in stride(from: -80, through: 80, by: 5) {
let actualDeg = ((hdgInt + offset) % 360 + 360) % 360
let x = midX + (CGFloat(offset) - CGFloat(phase)) * pixPerDeg
// Skip ticks outside the visible area
guard x >= 0 && x <= size.width else { continue }
let isCardinal = actualDeg % 90 == 0
let isIntercardinal = actualDeg % 45 == 0
let is30 = actualDeg % 30 == 0
let tickH: CGFloat
let lineW: CGFloat
let alpha: Double
if isCardinal {
tickH = 18; lineW = 2.0; alpha = 1.0
} else if isIntercardinal {
tickH = 13; lineW = 1.5; alpha = 0.85
} else {
tickH = 8; lineW = 1.0; alpha = 0.55
}
// Tick mark
var tick = Path()
tick.move(to: CGPoint(x: x, y: tickY))
tick.addLine(to: CGPoint(x: x, y: tickY + tickH))
ctx.stroke(tick, with: .color(.white.opacity(alpha)), lineWidth: lineW)
// Label for cardinals, intercardinals, and every 30°
if isCardinal || isIntercardinal || is30 {
let label = compassLabel(actualDeg)
let color: Color = isCardinal ? .yellow : .white.opacity(0.85)
let size12: CGFloat = isCardinal ? 12 : 9.5
let weight: Font.Weight = isCardinal ? .bold : .semibold
ctx.draw(
Text(label)
.font(.system(size: size12, weight: weight, design: .monospaced))
.foregroundStyle(color),
at: CGPoint(x: x, y: tickY + tickH + 9),
anchor: .top
)
}
}
// Subtle centre guide line (vertical)
var guide = Path()
guide.move(to: CGPoint(x: midX, y: tickY))
guide.addLine(to: CGPoint(x: midX, y: tickY + 18))
ctx.stroke(guide, with: .color(.yellow.opacity(0.25)), lineWidth: 1)
}
private func compassLabel(_ deg: Int) -> String {
switch deg {
case 0: return "N"
case 45: return "NE"
case 90: return "E"
case 135: return "SE"
case 180: return "S"
case 225: return "SW"
case 270: return "W"
case 315: return "NW"
default: return "\(deg)"
}
}
}
// MARK: - Virtual joystick
/// Single-touch virtual joystick. Knob snaps back to center on release (dead-man).
/// `value.x` [-1, 1] (right positive), `value.y` [-1, 1] (down positive).
struct JoystickControl: View {
@Binding var value: CGPoint
private let baseRadius: CGFloat = 64
private let knobRadius: CGFloat = 24
@State private var offset: CGSize = .zero
var body: some View {
let travel = baseRadius - knobRadius
let cx = min(max(offset.width, -travel), travel)
let cy = min(max(offset.height, -travel), travel)
ZStack {
// Base ring
Circle()
.fill(.white.opacity(0.12))
.frame(width: baseRadius * 2, height: baseRadius * 2)
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 1.5))
// Knob
Circle()
.fill(.white.opacity(offset == .zero ? 0.35 : 0.70))
.frame(width: knobRadius * 2, height: knobRadius * 2)
.offset(x: cx, y: cy)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { g in
offset = g.translation
let travel = baseRadius - knobRadius
value = CGPoint(
x: min(max(g.translation.width / travel, -1), 1),
y: min(max(g.translation.height / travel, -1), 1)
)
}
.onEnded { _ in
// Dead-man: finger lifted snap to zero immediately.
// The cmd timer will publish zeros on the next tick ( 100 ms).
offset = .zero
value = .zero
}
)
}
}
// MARK: - Pilot view
struct PilotView: View {
@EnvironmentObject var sensor: SensorManager
private static let cameras: [CameraSource] = [
CameraSource(id: "realsense", name: "RealSense", url: "http://100.64.0.2:8888/stream"),
// Add more cameras here: CameraSource(id: "...", name: "...", url: "...")
]
@State private var selectedCamIndex = 0
@State private var joystick: CGPoint = .zero
@State private var showHUD = true
/// 10 Hz publish timer rate-limits cmd_vel to Orin.
private let cmdTimer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.black.ignoresSafeArea()
// Full-screen camera feed
MJPEGStreamView(urlString: Self.cameras[selectedCamIndex].url)
.ignoresSafeArea()
// Top overlay bar
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 8) {
// HUD toggle top-left
Button {
withAnimation(.easeInOut(duration: 0.2)) { showHUD.toggle() }
} label: {
Image(systemName: showHUD ? "waveform.path.ecg.rectangle.fill"
: "waveform.path.ecg.rectangle")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(showHUD ? .yellow : .white.opacity(0.7))
.padding(8)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
}
// Compass tape fills remaining width
if showHUD, let hdg = sensor.botHeadingDeg {
CompassTapeHUD(heading: hdg)
.transition(.opacity.combined(with: .move(edge: .top)))
} else if showHUD {
// Placeholder when no heading data yet
RoundedRectangle(cornerRadius: 8)
.fill(.black.opacity(0.45))
.frame(height: 52)
.overlay(
Text("NO IMU")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(.white.opacity(0.4))
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Camera switcher top-right (hidden when only one source)
if Self.cameras.count > 1 {
cameraSwitcher
}
}
.padding(.top, 56)
.padding(.horizontal, 16)
Spacer()
}
// Joystick overlay bottom-right, semi-transparent
JoystickControl(value: $joystick)
.padding(.trailing, 32)
.padding(.bottom, 52)
}
// Publish at exactly 10 Hz joystick value is zero when not touched.
.onReceive(cmdTimer) { _ in
sensor.publishCmdVel(
linearX: Float(-joystick.y), // drag up positive linear_x (forward)
angularZ: Float(-joystick.x) // drag left positive angular_z (counter-cw)
)
}
.onAppear { sensor.ensureMQTTConnected() }
.onDisappear {
// Safety: publish a stop command and release MQTT if Follow-Me isn't using it.
joystick = .zero
sensor.publishCmdVel(linearX: 0, angularZ: 0)
sensor.releaseMQTTIfIdle()
}
}
private var cameraSwitcher: some View {
Menu {
ForEach(Array(Self.cameras.enumerated()), id: \.element.id) { i, cam in
Button {
selectedCamIndex = i
} label: {
if selectedCamIndex == i {
Label(cam.name, systemImage: "checkmark")
} else {
Text(cam.name)
}
}
}
} label: {
Label(Self.cameras[selectedCamIndex].name,
systemImage: "camera.on.rectangle.fill")
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.ultraThinMaterial, in: Capsule())
}
.foregroundStyle(.white)
}
}