- 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>
347 lines
13 KiB
Swift
347 lines
13 KiB
Swift
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, 0–360
|
||
|
||
// 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)
|
||
}
|
||
}
|