From f33592b92ff18e94d60a4213c5dd5dabd1c16e0c Mon Sep 17 00:00:00 2001 From: zeevj Date: Fri, 19 Dec 2025 09:42:23 +0200 Subject: [PATCH] feat: Implement manual temperature range control with a new range slider and UI. --- IrProCapture/Camera/Camera.swift | 4 +- .../Camera/Components/ImageProcessor.swift | 3 +- IrProCapture/Camera/UIState.swift | 31 +++++ IrProCapture/ContentView.swift | 8 +- IrProCapture/Views/CaptureToolbar.swift | 16 +++ IrProCapture/Views/ColorMapDisplay.swift | 12 +- IrProCapture/Views/RangeSlider.swift | 116 ++++++++++++++++++ .../Views/TemperatureRangeControls.swift | 55 +++++++++ 8 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 IrProCapture/Views/RangeSlider.swift create mode 100644 IrProCapture/Views/TemperatureRangeControls.swift diff --git a/IrProCapture/Camera/Camera.swift b/IrProCapture/Camera/Camera.swift index 9a9bfc6..0ecb704 100644 --- a/IrProCapture/Camera/Camera.swift +++ b/IrProCapture/Camera/Camera.swift @@ -156,8 +156,8 @@ class Camera: NSObject, ObservableObject, CaptureDelegate { // Convert temperatures to a color mapped image guard let processedImage = CIImage.fromTemperatures( temperatures: tempResult.temperatures, - minTemp: tempResult.min, - maxTemp: tempResult.max, + minTemp: uiState.manualRangeEnabled ? uiState.manualMinTemp : tempResult.min, + maxTemp: uiState.manualRangeEnabled ? uiState.manualMaxTemp : tempResult.max, width: 256, height: 192, scale: SCALE, diff --git a/IrProCapture/Camera/Components/ImageProcessor.swift b/IrProCapture/Camera/Components/ImageProcessor.swift index df75fc9..2f3fe39 100644 --- a/IrProCapture/Camera/Components/ImageProcessor.swift +++ b/IrProCapture/Camera/Components/ImageProcessor.swift @@ -27,8 +27,9 @@ extension CIImage { var pixelData = [UInt8](repeating: 0, count: width * height * 4) let range = max(maxTemp - minTemp, 1.0) let scaledTemperatures = vDSP.multiply(255.0 / range, vDSP.add(-minTemp, temperatures)) + let clippedTemperatures = vDSP.clip(scaledTemperatures, to: 0.0...255.0) for index in 0..= manualMaxTemp { + manualMinTemp = manualMaxTemp - 0.1 + } + UserDefaults.standard.set(manualMinTemp, forKey: "manualMinTemp") + } + } + + /// Manual maximum temperature for display + @Published var manualMaxTemp: Float { + didSet { + if manualMaxTemp <= manualMinTemp { + manualMaxTemp = manualMinTemp + 0.1 + } + UserDefaults.standard.set(manualMaxTemp, forKey: "manualMaxTemp") + } + } + /// Indicates whether the camera is currently running @Published var isRunning = false @@ -128,6 +155,10 @@ class UIState: ObservableObject { self.temperatureFormat = .celsius UserDefaults.standard.set(TemperatureFormat.celsius.rawValue, forKey: "temperatureFormat") } + + self.manualRangeEnabled = UserDefaults.standard.bool(forKey: "manualRangeEnabled") + self.manualMinTemp = UserDefaults.standard.object(forKey: "manualMinTemp") as? Float ?? 20.0 + self.manualMaxTemp = UserDefaults.standard.object(forKey: "manualMaxTemp") as? Float ?? 40.0 } /// Cycles to the next orientation option diff --git a/IrProCapture/ContentView.swift b/IrProCapture/ContentView.swift index f868daa..9f064f9 100644 --- a/IrProCapture/ContentView.swift +++ b/IrProCapture/ContentView.swift @@ -43,13 +43,13 @@ struct ContentView: View { Divider() ColorMapDisplay( colorMap: uiState.currentColorMap, - maxTemperature: model.maxTemperature, - minTemperature: model.minTemperature, + maxTemperature: uiState.manualRangeEnabled ? uiState.manualMaxTemp : model.maxTemperature, + minTemperature: uiState.manualRangeEnabled ? uiState.manualMinTemp : model.minTemperature, format: uiState.temperatureFormat) TemperatureHistogramChart( histogram: model.histogram, - minTemperature: model.minTemperature, - maxTemperature: model.maxTemperature, + minTemperature: uiState.manualRangeEnabled ? uiState.manualMinTemp : model.minTemperature, + maxTemperature: uiState.manualRangeEnabled ? uiState.manualMaxTemp : model.maxTemperature, format: uiState.temperatureFormat ) } diff --git a/IrProCapture/Views/CaptureToolbar.swift b/IrProCapture/Views/CaptureToolbar.swift index fe6addf..ba14370 100644 --- a/IrProCapture/Views/CaptureToolbar.swift +++ b/IrProCapture/Views/CaptureToolbar.swift @@ -11,6 +11,7 @@ struct CaptureToolbar: View { @EnvironmentObject var model: Camera @EnvironmentObject var uiState: UIState @State private var alertMessage: String? = nil + @State private var showRangeControls = false var body: some View { HStack(spacing: 20) { @@ -87,6 +88,21 @@ struct CaptureToolbar: View { .buttonStyle(.bordered) .help("Next Orientation") + // Range control button + Button(action: { + showRangeControls.toggle() + }) { + Image(systemName: "thermometer.medium") + .font(.title) + .foregroundColor(uiState.manualRangeEnabled ? .blue : .primary) + } + .disabled(!uiState.isRunning) + .buttonStyle(.bordered) + .help("Display Range") + .popover(isPresented: $showRangeControls) { + TemperatureRangeControls() + } + Spacer() } .alert( diff --git a/IrProCapture/Views/ColorMapDisplay.swift b/IrProCapture/Views/ColorMapDisplay.swift index 9233ff8..35c6764 100644 --- a/IrProCapture/Views/ColorMapDisplay.swift +++ b/IrProCapture/Views/ColorMapDisplay.swift @@ -21,15 +21,23 @@ struct ColorMapDisplay: View { } var body: some View { - HStack { + HStack(spacing: 8) { VStack { Text(format.format(format.convert(maxTemperature))) + .font(.caption) + .monospacedDigit() Spacer() Text(format.format(format.convert(minTemperature))) + .font(.caption) + .monospacedDigit() } + .frame(minWidth: 50) + LinearGradient(gradient: Gradient(colors: colorMap.colors.map { Color(red: CGFloat($0.r), green: CGFloat($0.g), blue: CGFloat($0.b)) }), startPoint: .bottom, endPoint: .top) - .frame(width: 50) + .frame(width: 40) + .cornerRadius(4) } + .padding(.vertical, 4) } } diff --git a/IrProCapture/Views/RangeSlider.swift b/IrProCapture/Views/RangeSlider.swift new file mode 100644 index 0000000..cf5dfca --- /dev/null +++ b/IrProCapture/Views/RangeSlider.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct RangeSlider: View { + @Binding var lowValue: Float + @Binding var highValue: Float + let range: ClosedRange + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background Track + Capsule() + .fill(Color.secondary.opacity(0.3)) + .frame(height: 4) + + // Highlighted Track + Capsule() + .fill(Color.blue) + .frame(width: CGFloat(xForValue(highValue, in: geometry.size.width) - xForValue(lowValue, in: geometry.size.width)), height: 4) + .offset(x: CGFloat(xForValue(lowValue, in: geometry.size.width))) + + // Low Handle + HandleView(isFocused: focusedHandle == .low, side: .left) + .offset(x: CGFloat(xForValue(lowValue, in: geometry.size.width)) - 10) + .gesture( + DragGesture() + .onChanged { value in + focusedHandle = .low + let newValue = valueForX(Float(value.location.x), in: geometry.size.width) + lowValue = min(max(range.lowerBound, newValue), highValue - 0.1) + } + ) + .focusable() + .onMoveCommand { direction in + focusedHandle = .low + switch direction { + case .left: lowValue = max(range.lowerBound, lowValue - 0.5) + case .right: lowValue = min(highValue - 0.1, lowValue + 0.5) + default: break + } + } + + // High Handle + HandleView(isFocused: focusedHandle == .high, side: .right) + .offset(x: CGFloat(xForValue(highValue, in: geometry.size.width))) + .gesture( + DragGesture() + .onChanged { value in + focusedHandle = .high + let newValue = valueForX(Float(value.location.x), in: geometry.size.width) + highValue = max(min(range.upperBound, newValue), lowValue + 0.1) + } + ) + .focusable() + .onMoveCommand { direction in + focusedHandle = .high + switch direction { + case .left: highValue = max(lowValue + 0.1, highValue - 0.5) + case .right: highValue = min(range.upperBound, highValue + 0.5) + default: break + } + } + } + } + .frame(height: 20) + } + + enum FocusedHandle { + case low, high + } + @State private var focusedHandle: FocusedHandle? = nil + + private func xForValue(_ value: Float, in width: CGFloat) -> Float { + let percentage = (value - range.lowerBound) / (range.upperBound - range.lowerBound) + return Float(width) * percentage + } + + private func valueForX(_ x: Float, in width: CGFloat) -> Float { + let percentage = x / Float(width) + return range.lowerBound + percentage * (range.upperBound - range.lowerBound) + } +} + +enum HandleSide { + case left, right +} + +struct HandleView: View { + var isFocused: Bool + var side: HandleSide + + var body: some View { + ZStack { + UnevenRoundedRectangle( + topLeadingRadius: side == .left ? 10 : 0, + bottomLeadingRadius: side == .left ? 10 : 0, + bottomTrailingRadius: side == .right ? 10 : 0, + topTrailingRadius: side == .right ? 10 : 0, + style: .circular + ) + .fill(Color.white) + .frame(width: 10, height: 20) + .shadow(radius: 2) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: side == .left ? 10 : 0, + bottomLeadingRadius: side == .left ? 10 : 0, + bottomTrailingRadius: side == .right ? 10 : 0, + topTrailingRadius: side == .right ? 10 : 0, + style: .circular + ) + .stroke(isFocused ? Color.blue : Color.gray.opacity(0.2), lineWidth: isFocused ? 2 : 0.5) + ) + } + } +} diff --git a/IrProCapture/Views/TemperatureRangeControls.swift b/IrProCapture/Views/TemperatureRangeControls.swift new file mode 100644 index 0000000..7e1296f --- /dev/null +++ b/IrProCapture/Views/TemperatureRangeControls.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct TemperatureRangeControls: View { + @EnvironmentObject var uiState: UIState + @EnvironmentObject var model: Camera + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle("Manual Range", isOn: $uiState.manualRangeEnabled) + .toggleStyle(.checkbox) + + if uiState.manualRangeEnabled { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Range:") + .font(.headline) + Spacer() + Text("\(uiState.temperatureFormat.format(uiState.temperatureFormat.convert(uiState.manualMinTemp))) - \(uiState.temperatureFormat.format(uiState.temperatureFormat.convert(uiState.manualMaxTemp)))") + .font(.subheadline) + .monospacedDigit() + } + + RangeSlider( + lowValue: $uiState.manualMinTemp, + highValue: $uiState.manualMaxTemp, + range: -20...150 + ) + .padding(.horizontal, 10) + } + } + .padding(.vertical, 10) + + HStack { + Button("Reset to Current") { + uiState.manualMinTemp = model.minTemperature + uiState.manualMaxTemp = model.maxTemperature + } + .buttonStyle(.link) + + Spacer() + + Button("Reset to Default") { + uiState.manualMinTemp = 20.0 + uiState.manualMaxTemp = 40.0 + } + .buttonStyle(.link) + } + .padding(.top, 10) + } + } + .padding() + .frame(width: 320) + } +}