From 6d9a2b7cc19b4a8ec863230c5942cd95cb92ea83 Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 31 Mar 2026 17:45:03 -0700 Subject: [PATCH] cue-ios: SwiftUI views -- 5 measurement tabs, session management, plots --- cue-ios/CueIOS/AppState.swift | 348 ++++++++++ cue-ios/CueIOS/Views/AmpView.swift | 142 ++++ cue-ios/CueIOS/Views/ChlorineView.swift | 273 ++++++++ .../Views/Components/MeasurementTable.swift | 61 ++ .../Views/Components/PlotContainer.swift | 61 ++ .../CueIOS/Views/Components/StatusBar.swift | 72 +++ cue-ios/CueIOS/Views/ContentView.swift | 142 ++++ cue-ios/CueIOS/Views/EISView.swift | 607 ++++++++++++++++++ cue-ios/CueIOS/Views/LSVView.swift | 136 ++++ cue-ios/CueIOS/Views/PhView.swift | 67 ++ cue-ios/CueIOS/Views/SessionView.swift | 259 ++++++++ 11 files changed, 2168 insertions(+) create mode 100644 cue-ios/CueIOS/AppState.swift create mode 100644 cue-ios/CueIOS/Views/AmpView.swift create mode 100644 cue-ios/CueIOS/Views/ChlorineView.swift create mode 100644 cue-ios/CueIOS/Views/Components/MeasurementTable.swift create mode 100644 cue-ios/CueIOS/Views/Components/PlotContainer.swift create mode 100644 cue-ios/CueIOS/Views/Components/StatusBar.swift create mode 100644 cue-ios/CueIOS/Views/ContentView.swift create mode 100644 cue-ios/CueIOS/Views/EISView.swift create mode 100644 cue-ios/CueIOS/Views/LSVView.swift create mode 100644 cue-ios/CueIOS/Views/PhView.swift create mode 100644 cue-ios/CueIOS/Views/SessionView.swift diff --git a/cue-ios/CueIOS/AppState.swift b/cue-ios/CueIOS/AppState.swift new file mode 100644 index 0000000..c69194c --- /dev/null +++ b/cue-ios/CueIOS/AppState.swift @@ -0,0 +1,348 @@ +import Foundation +import Observation + +enum Tab: String, CaseIterable, Identifiable { + case eis = "EIS" + case lsv = "LSV" + case amp = "Amperometry" + case chlorine = "Chlorine" + case ph = "pH" + case sessions = "Sessions" + + var id: String { rawValue } +} + +// MARK: - Enums mirroring protocol.rs + +enum Rtia: UInt8, CaseIterable, Identifiable { + case r200 = 0, r1k, r5k, r10k, r20k, r40k, r80k, r160k, extDe0 + + var id: UInt8 { rawValue } + + var label: String { + switch self { + case .r200: "200\u{2126}" + case .r1k: "1k\u{2126}" + case .r5k: "5k\u{2126}" + case .r10k: "10k\u{2126}" + case .r20k: "20k\u{2126}" + case .r40k: "40k\u{2126}" + case .r80k: "80k\u{2126}" + case .r160k: "160k\u{2126}" + case .extDe0: "Ext 3k\u{2126} (DE0)" + } + } +} + +enum Rcal: UInt8, CaseIterable, Identifiable { + case r200 = 0, r3k + + var id: UInt8 { rawValue } + + var label: String { + switch self { + case .r200: "200\u{2126} (RCAL0-RCAL1)" + case .r3k: "3k\u{2126} (RCAL0-AIN0)" + } + } +} + +enum Electrode: UInt8, CaseIterable, Identifiable { + case fourWire = 0, threeWire + + var id: UInt8 { rawValue } + + var label: String { + switch self { + case .fourWire: "4-wire (AIN)" + case .threeWire: "3-wire (CE0/RE0/SE0)" + } + } +} + +enum LpRtia: UInt8, CaseIterable, Identifiable { + case r200 = 0, r1k, r2k, r3k, r4k, r6k, r8k, r10k, r12k, r16k + case r20k, r24k, r30k, r32k, r40k, r48k, r64k, r85k, r96k + case r100k, r120k, r128k, r160k, r196k, r256k, r512k + + var id: UInt8 { rawValue } + + var label: String { + switch self { + case .r200: "200\u{2126}" + case .r1k: "1k\u{2126}" + case .r2k: "2k\u{2126}" + case .r3k: "3k\u{2126}" + case .r4k: "4k\u{2126}" + case .r6k: "6k\u{2126}" + case .r8k: "8k\u{2126}" + case .r10k: "10k\u{2126}" + case .r12k: "12k\u{2126}" + case .r16k: "16k\u{2126}" + case .r20k: "20k\u{2126}" + case .r24k: "24k\u{2126}" + case .r30k: "30k\u{2126}" + case .r32k: "32k\u{2126}" + case .r40k: "40k\u{2126}" + case .r48k: "48k\u{2126}" + case .r64k: "64k\u{2126}" + case .r85k: "85k\u{2126}" + case .r96k: "96k\u{2126}" + case .r100k: "100k\u{2126}" + case .r120k: "120k\u{2126}" + case .r128k: "128k\u{2126}" + case .r160k: "160k\u{2126}" + case .r196k: "196k\u{2126}" + case .r256k: "256k\u{2126}" + case .r512k: "512k\u{2126}" + } + } +} + +// MARK: - Data types mirroring protocol.rs + +struct EisPoint: Identifiable { + let id = UUID() + var freqHz: Float + var magOhms: Float + var phaseDeg: Float + var zReal: Float + var zImag: Float + var rtiaMagBefore: Float + var rtiaMagAfter: Float + var revMag: Float + var revPhase: Float + var pctErr: Float +} + +struct LsvPoint: Identifiable { + let id = UUID() + var vMv: Float + var iUa: Float +} + +struct AmpPoint: Identifiable { + let id = UUID() + var tMs: Float + var iUa: Float +} + +struct ClPoint: Identifiable { + let id = UUID() + var tMs: Float + var iUa: Float + var phase: UInt8 +} + +struct ClResult { + var iFreeUa: Float + var iTotalUa: Float +} + +struct PhResult { + var vOcpMv: Float + var ph: Float + var tempC: Float +} + +// MARK: - App State + +@Observable +final class AppState { + var tab: Tab = .eis + var status: String = "Disconnected" + var bleConnected: Bool = false + var tempC: Float = 25.0 + + // EIS + var eisPoints: [EisPoint] = [] + var sweepTotal: UInt16 = 0 + var freqStart: String = "1000" + var freqStop: String = "200000" + var ppd: String = "10" + var rtia: Rtia = .r5k + var rcal: Rcal = .r3k + var electrode: Electrode = .fourWire + + // LSV + var lsvPoints: [LsvPoint] = [] + var lsvTotal: UInt16 = 0 + var lsvStartV: String = "0" + var lsvStopV: String = "500" + var lsvScanRate: String = "50" + var lsvRtia: LpRtia = .r10k + + // Amperometry + var ampPoints: [AmpPoint] = [] + var ampTotal: UInt16 = 0 + var ampRunning: Bool = false + var ampVHold: String = "200" + var ampInterval: String = "100" + var ampDuration: String = "60" + var ampRtia: LpRtia = .r10k + + // Chlorine + var clPoints: [ClPoint] = [] + var clResult: ClResult? = nil + var clTotal: UInt16 = 0 + var clCondV: String = "800" + var clCondT: String = "2000" + var clFreeV: String = "100" + var clTotalV: String = "-200" + var clDepT: String = "5000" + var clMeasT: String = "5000" + var clRtia: LpRtia = .r10k + + // pH + var phResult: PhResult? = nil + var phStabilize: String = "30" + + // Reference baselines + var eisRef: [EisPoint]? = nil + var lsvRef: [LsvPoint]? = nil + var ampRef: [AmpPoint]? = nil + var clRef: (points: [ClPoint], result: ClResult)? = nil + var phRef: PhResult? = nil + + // Device reference collection + var collectingRefs: Bool = false + var hasDeviceRefs: Bool = false + + // Clean + var cleanV: String = "1200" + var cleanDur: String = "30" + + // MARK: - Actions + + func applyEISSettings() { + let fs = Float(freqStart) ?? 1000 + let fe = Float(freqStop) ?? 200000 + let p = UInt16(ppd) ?? 10 + status = "Applying: \(fs)-\(fe) Hz, \(p) PPD" + // BLEManager sends: set_sweep, set_rtia, set_rcal, set_electrode, get_config + } + + func startSweep() { + eisPoints.removeAll() + status = "Starting sweep..." + // BLEManager sends: get_temp, start_sweep + } + + func startLSV() { + lsvPoints.removeAll() + let vs = Float(lsvStartV) ?? 0 + let ve = Float(lsvStopV) ?? 500 + status = "Starting LSV: \(vs)-\(ve) mV" + // BLEManager sends: get_temp, start_lsv + } + + func startAmp() { + ampPoints.removeAll() + ampRunning = true + status = "Starting amperometry..." + // BLEManager sends: get_temp, start_amp + } + + func stopAmp() { + ampRunning = false + status = "Stopping amperometry..." + // BLEManager sends: stop_amp + } + + func startChlorine() { + clPoints.removeAll() + clResult = nil + status = "Starting chlorine measurement..." + // BLEManager sends: get_temp, start_cl + } + + func startPh() { + phResult = nil + status = "Starting pH measurement..." + // BLEManager sends: get_temp, start_ph + } + + func setReference() { + switch tab { + case .eis where !eisPoints.isEmpty: + eisRef = eisPoints + status = "EIS reference set (\(eisPoints.count) pts)" + case .lsv where !lsvPoints.isEmpty: + lsvRef = lsvPoints + status = "LSV reference set (\(lsvPoints.count) pts)" + case .amp where !ampPoints.isEmpty: + ampRef = ampPoints + status = "Amp reference set (\(ampPoints.count) pts)" + case .chlorine where !clPoints.isEmpty: + if let r = clResult { + clRef = (clPoints, r) + status = "Chlorine reference set" + } + case .ph: + if let r = phResult { + phRef = r + status = String(format: "pH reference set (%.2f)", r.ph) + } + default: + break + } + } + + func clearReference() { + switch tab { + case .eis: eisRef = nil; status = "EIS reference cleared" + case .lsv: lsvRef = nil; status = "LSV reference cleared" + case .amp: ampRef = nil; status = "Amp reference cleared" + case .chlorine: clRef = nil; status = "Chlorine reference cleared" + case .ph: phRef = nil; status = "pH reference cleared" + case .sessions: break + } + } + + func collectRefs() { + collectingRefs = true + status = "Starting reference collection..." + // BLEManager sends: start_refs + } + + func clearRefs() { + collectingRefs = false + hasDeviceRefs = false + eisRef = nil + lsvRef = nil + ampRef = nil + clRef = nil + phRef = nil + status = "Refs cleared" + // BLEManager sends: clear_refs + } + + func startClean() { + let v = Float(cleanV) ?? 1200 + let d = Float(cleanDur) ?? 30 + status = String(format: "Cleaning: %.0f mV for %.0fs", v, d) + // BLEManager sends: start_clean + } + + var hasCurrentRef: Bool { + switch tab { + case .eis: eisRef != nil + case .lsv: lsvRef != nil + case .amp: ampRef != nil + case .chlorine: clRef != nil + case .ph: phRef != nil + case .sessions: false + } + } + + var hasCurrentData: Bool { + switch tab { + case .eis: !eisPoints.isEmpty + case .lsv: !lsvPoints.isEmpty + case .amp: !ampPoints.isEmpty + case .chlorine: clResult != nil + case .ph: phResult != nil + case .sessions: false + } + } +} diff --git a/cue-ios/CueIOS/Views/AmpView.swift b/cue-ios/CueIOS/Views/AmpView.swift new file mode 100644 index 0000000..ae3127b --- /dev/null +++ b/cue-ios/CueIOS/Views/AmpView.swift @@ -0,0 +1,142 @@ +import SwiftUI +import Charts + +struct AmpView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + GeometryReader { geo in + if geo.size.width > 700 { + HSplitLayout(ratio: 0.55) { + amperogramPlot + } trailing: { + ampTable + } + } else { + ScrollView { + VStack(spacing: 12) { + amperogramPlot.frame(height: 350) + ampTable.frame(height: 300) + } + .padding() + } + } + } + } + } + + // MARK: - Controls + + private var controlsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + LabeledField("V hold mV", text: $state.ampVHold, width: 80) + LabeledField("Interval ms", text: $state.ampInterval, width: 80) + LabeledField("Duration s", text: $state.ampDuration, width: 80) + + LabeledPicker("RTIA", selection: $state.ampRtia, items: LpRtia.allCases) { $0.label } + .frame(width: 120) + + if state.ampRunning { + Button("Stop") { state.stopAmp() } + .buttonStyle(ActionButtonStyle(color: .red)) + } else { + Button("Start Amp") { state.startAmp() } + .buttonStyle(ActionButtonStyle(color: .green)) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + // MARK: - Plot + + private var amperogramPlot: some View { + Group { + if state.ampPoints.isEmpty { + Text("No data") + .foregroundStyle(Color(white: 0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.3)) + } else { + let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0) + PlotContainer(title: "") { + Chart { + if let ref = state.ampRef { + ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + ForEach(Array(state.ampPoints.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(ampColor) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(state.ampPoints.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(ampColor) + .symbolSize(16) + } + } + .chartXAxisLabel("t (ms)") + .chartYAxisLabel("I (uA)", position: .leading) + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(ampColor) + } + } + .padding(8) + } + } + } + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - Table + + private var ampTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: state.ampPoints, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } +} diff --git a/cue-ios/CueIOS/Views/ChlorineView.swift b/cue-ios/CueIOS/Views/ChlorineView.swift new file mode 100644 index 0000000..30f6951 --- /dev/null +++ b/cue-ios/CueIOS/Views/ChlorineView.swift @@ -0,0 +1,273 @@ +import SwiftUI +import Charts + +struct ChlorineView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + GeometryReader { geo in + if geo.size.width > 700 { + HSplitLayout(ratio: 0.55) { + VStack(spacing: 4) { + resultBanner + chlorinePlot + } + } trailing: { + clTable + } + } else { + ScrollView { + VStack(spacing: 12) { + resultBanner + chlorinePlot.frame(height: 350) + clTable.frame(height: 300) + } + .padding() + } + } + } + } + } + + // MARK: - Controls + + private var controlsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + LabeledField("Cond mV", text: $state.clCondV, width: 70) + LabeledField("Cond ms", text: $state.clCondT, width: 70) + LabeledField("Free mV", text: $state.clFreeV, width: 70) + LabeledField("Total mV", text: $state.clTotalV, width: 70) + LabeledField("Settle ms", text: $state.clDepT, width: 70) + LabeledField("Meas ms", text: $state.clMeasT, width: 70) + + LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label } + .frame(width: 120) + + Button("Measure") { state.startChlorine() } + .buttonStyle(ActionButtonStyle(color: .green)) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + // MARK: - Result banner + + @ViewBuilder + private var resultBanner: some View { + if let r = state.clResult { + HStack(spacing: 16) { + Text(String(format: "Free: %.3f uA", r.iFreeUa)) + .foregroundStyle(Color(red: 0.2, green: 1, blue: 0.5)) + Text(String(format: "Total: %.3f uA", r.iTotalUa)) + .foregroundStyle(Color(red: 1, green: 0.6, blue: 0.2)) + Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa)) + .foregroundStyle(.secondary) + + if let (_, refR) = state.clRef { + Divider().frame(height: 16) + Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f", + r.iFreeUa - refR.iFreeUa, + r.iTotalUa - refR.iTotalUa)) + .foregroundStyle(.secondary) + } + } + .font(.subheadline.monospacedDigit()) + .padding(.horizontal) + .padding(.vertical, 4) + } + } + + // MARK: - Plot + + private var chlorinePlot: some View { + Group { + if state.clPoints.isEmpty { + Text("No data") + .foregroundStyle(Color(white: 0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.3)) + } else { + PlotContainer(title: "") { + chlorineCanvas + } + } + } + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var chlorineCanvas: some View { + Canvas { context, size in + let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22 + let xl = ml, xr = size.width - mr + let yt = mt, yb = size.height - mb + + let valid = state.clPoints.filter { $0.tMs.isFinite && $0.iUa.isFinite } + guard !valid.isEmpty else { return } + + let ts = valid.map { CGFloat($0.tMs) } + let is_ = valid.map { CGFloat($0.iUa) } + let (tMin, tMax) = (ts.min()!, ts.max()!) + let (iMin, iMax) = (is_.min()!, is_.max()!) + let tPad = max(tMax - tMin, 100) * 0.05 + let iPad = max(iMax - iMin, 0.001) * 0.12 + let xvLo = tMin - tPad, xvHi = tMax + tPad + let yvLo = iMin - iPad, yvHi = iMax + iPad + + func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) } + func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) } + + // grid + let gridColor = Color(white: 0.25).opacity(0.6) + drawClGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb, + xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi, gridColor: gridColor) + + // axis labels + context.draw( + Text("I (uA)").font(.caption2).foregroundStyle(.secondary), + at: CGPoint(x: 20, y: yt - 2)) + context.draw( + Text("t (ms)").font(.caption2).foregroundStyle(.secondary), + at: CGPoint(x: (xl + xr) / 2, y: yb + 12)) + + // reference + if let refPts = state.clRef?.points { + let refFree = refPts.filter { $0.phase == 1 } + .map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) } + let refTotal = refPts.filter { $0.phase == 2 } + .map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) } + drawClPolyline(context: context, points: refFree, color: Color.gray.opacity(0.5), width: 1.5) + drawClPolyline(context: context, points: refTotal, color: Color.gray.opacity(0.5), width: 1.5) + } + + let clFreeColor = Color(red: 0.2, green: 1, blue: 0.5) + let clTotalColor = Color(red: 1, green: 0.6, blue: 0.2) + let condColor = Color.gray + + // conditioning + let condPts = valid.filter { $0.phase == 0 } + .map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) } + drawClPolyline(context: context, points: condPts, color: condColor, width: 1.5) + + // free + let freePts = valid.filter { $0.phase == 1 } + .map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) } + drawClPolyline(context: context, points: freePts, color: clFreeColor, width: 2) + for pt in freePts { + context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)), + with: .color(clFreeColor)) + } + + // total + let totalPts = valid.filter { $0.phase == 2 } + .map { CGPoint(x: lx(CGFloat($0.tMs)), y: ly(CGFloat($0.iUa))) } + drawClPolyline(context: context, points: totalPts, color: clTotalColor, width: 2) + for pt in totalPts { + context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)), + with: .color(clTotalColor)) + } + + // phase boundary + if let lastFree = valid.last(where: { $0.phase == 1 }), + let firstTotal = valid.first(where: { $0.phase == 2 }) { + let bx = lx(CGFloat((lastFree.tMs + firstTotal.tMs) / 2)) + if bx > xl && bx < xr { + var bp = Path() + bp.move(to: CGPoint(x: bx, y: yt)) + bp.addLine(to: CGPoint(x: bx, y: yb)) + context.stroke(bp, with: .color(Color(white: 0.4)), lineWidth: 1) + } + } + + // legend + context.draw( + Text("Free").font(.caption2).foregroundStyle(clFreeColor), + at: CGPoint(x: xl + 25, y: yt + 10)) + context.draw( + Text("Total").font(.caption2).foregroundStyle(clTotalColor), + at: CGPoint(x: xl + 70, y: yt + 10)) + } + } + + // MARK: - Table + + private var clTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + MeasurementColumn(header: "Phase", width: 60, alignment: .trailing), + ], + rows: state.clPoints, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + case 2: + switch pt.phase { + case 1: "Free" + case 2: "Total" + default: "Cond" + } + default: "" + } + } + ) + } +} + +// MARK: - Drawing helpers (chlorine-local) + +private func drawClPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) { + guard points.count >= 2 else { return } + var path = Path() + path.move(to: points[0]) + for pt in points.dropFirst() { + guard pt.x.isFinite && pt.y.isFinite else { continue } + path.addLine(to: pt) + } + context.stroke(path, with: .color(color), lineWidth: width) +} + +private func drawClGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat, + xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat, gridColor: Color) { + func niceStep(_ range: CGFloat, _ ticks: Int) -> CGFloat { + guard abs(range) > 1e-10 else { return 1 } + let rough = range / CGFloat(ticks) + let mag = pow(10, floor(log10(abs(rough)))) + let norm = abs(rough) / mag + let s: CGFloat + if norm < 1.5 { s = 1 } else if norm < 3.5 { s = 2 } else if norm < 7.5 { s = 5 } else { s = 10 } + return s * mag + } + + let xs = niceStep(xvHi - xvLo, 5) + if xs > 0 { + var g = (xvLo / xs).rounded(.up) * xs + while g <= xvHi { + let x = xl + (g - xvLo) / (xvHi - xvLo) * (xr - xl) + var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb)) + context.stroke(p, with: .color(gridColor), lineWidth: 0.5) + context.draw(Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary), + at: CGPoint(x: x, y: yb + 10)) + g += xs + } + } + let ys = niceStep(yvHi - yvLo, 4) + if ys > 0 { + var g = (yvLo / ys).rounded(.up) * ys + while g <= yvHi { + let y = yt + (yvHi - g) / (yvHi - yvLo) * (yb - yt) + var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y)) + context.stroke(p, with: .color(gridColor), lineWidth: 0.5) + context.draw(Text(String(format: "%.1f", g)).font(.system(size: 9)).foregroundStyle(.secondary), + at: CGPoint(x: xl - 20, y: y)) + g += ys + } + } +} diff --git a/cue-ios/CueIOS/Views/Components/MeasurementTable.swift b/cue-ios/CueIOS/Views/Components/MeasurementTable.swift new file mode 100644 index 0000000..d60c04c --- /dev/null +++ b/cue-ios/CueIOS/Views/Components/MeasurementTable.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct MeasurementColumn: Identifiable { + let id = UUID() + let header: String + let width: CGFloat + let alignment: Alignment +} + +struct MeasurementTable: View { + let columns: [MeasurementColumn] + let rows: [Row] + let cellText: (Row, Int) -> String + + var body: some View { + VStack(spacing: 0) { + headerRow + Divider().background(Color.gray.opacity(0.4)) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.element.id) { idx, row in + dataRow(row, even: idx % 2 == 0) + } + } + } + } + .font(.system(.caption, design: .monospaced)) + .background(Color(.systemBackground).opacity(0.05)) + } + + private var headerRow: some View { + HStack(spacing: 0) { + ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in + Text(col.header) + .fontWeight(.semibold) + .frame(width: col.width, alignment: col.alignment) + .foregroundStyle(.secondary) + if idx < columns.count - 1 { + Spacer(minLength: 4) + } + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + } + + private func dataRow(_ row: Row, even: Bool) -> some View { + HStack(spacing: 0) { + ForEach(Array(columns.enumerated()), id: \.element.id) { idx, col in + Text(cellText(row, idx)) + .frame(width: col.width, alignment: col.alignment) + if idx < columns.count - 1 { + Spacer(minLength: 4) + } + } + } + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background(even ? Color.clear : Color.white.opacity(0.03)) + } +} diff --git a/cue-ios/CueIOS/Views/Components/PlotContainer.swift b/cue-ios/CueIOS/Views/Components/PlotContainer.swift new file mode 100644 index 0000000..3cebb99 --- /dev/null +++ b/cue-ios/CueIOS/Views/Components/PlotContainer.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct PlotContainer: View { + let title: String + @ViewBuilder let content: () -> Content + + @State private var scale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + + var body: some View { + GeometryReader { geo in + content() + .scaleEffect(scale) + .offset(offset) + .gesture(dragGesture) + .gesture(magnificationGesture) + .simultaneousGesture(doubleTapReset) + .clipped() + .overlay(alignment: .topLeading) { + if !title.isEmpty { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(4) + } + } + } + } + + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } + .onEnded { _ in + lastOffset = offset + } + } + + private var magnificationGesture: some Gesture { + MagnifyGesture() + .onChanged { value in + scale = max(0.5, min(10.0, value.magnification)) + } + } + + private var doubleTapReset: some Gesture { + TapGesture(count: 2) + .onEnded { + withAnimation(.easeOut(duration: 0.2)) { + scale = 1.0 + offset = .zero + lastOffset = .zero + } + } + } +} diff --git a/cue-ios/CueIOS/Views/Components/StatusBar.swift b/cue-ios/CueIOS/Views/Components/StatusBar.swift new file mode 100644 index 0000000..ae335ae --- /dev/null +++ b/cue-ios/CueIOS/Views/Components/StatusBar.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct StatusBar: View { + let state: AppState + + var body: some View { + HStack(spacing: 8) { + connectionIndicator + Text(state.status) + .font(.subheadline) + .lineLimit(1) + + Spacer() + + refButtons + + Text(String(format: "%.1f\u{00B0}C", state.tempC)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + } + + private var connectionIndicator: some View { + Circle() + .fill(state.bleConnected ? Color.green : Color.red) + .frame(width: 8, height: 8) + } + + @ViewBuilder + private var refButtons: some View { + HStack(spacing: 4) { + if !state.collectingRefs { + Button("Collect Refs") { state.collectRefs() } + .buttonStyle(.bordered) + .tint(.green) + .controlSize(.small) + } else { + Button("Collecting...") {} + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(true) + } + + if state.hasDeviceRefs { + Button("Clear Refs") { state.clearRefs() } + .buttonStyle(.bordered) + .tint(.red) + .controlSize(.small) + } + + if state.hasCurrentData { + Button("Set Ref") { state.setReference() } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if state.hasCurrentRef { + Button("Clear Ref") { state.clearReference() } + .buttonStyle(.bordered) + .tint(.red) + .controlSize(.small) + + Text("REF") + .font(.caption2.bold()) + .foregroundStyle(.orange) + } + } + } +} diff --git a/cue-ios/CueIOS/Views/ContentView.swift b/cue-ios/CueIOS/Views/ContentView.swift new file mode 100644 index 0000000..064cdc7 --- /dev/null +++ b/cue-ios/CueIOS/Views/ContentView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct ContentView: View { + @State private var state = AppState() + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + Group { + if sizeClass == .regular { + iPadLayout + } else { + iPhoneLayout + } + } + .preferredColorScheme(.dark) + } + + // MARK: - iPad: NavigationSplitView + + private var iPadLayout: some View { + NavigationSplitView { + sidebar + } detail: { + VStack(spacing: 0) { + StatusBar(state: state) + Divider() + tabContent + } + } + } + + private var sidebar: some View { + List(selection: $state.tab) { + Section("Measurements") { + Label("EIS", systemImage: "waveform.path.ecg") + .tag(Tab.eis) + Label("LSV", systemImage: "chart.xyaxis.line") + .tag(Tab.lsv) + Label("Amperometry", systemImage: "bolt.fill") + .tag(Tab.amp) + Label("Chlorine", systemImage: "drop.fill") + .tag(Tab.chlorine) + Label("pH", systemImage: "scalemass") + .tag(Tab.ph) + } + Section("Data") { + Label("Sessions", systemImage: "folder") + .tag(Tab.sessions) + } + Section { + cleanControls + } + } + .navigationTitle("Cue") + .listStyle(.sidebar) + } + + private var cleanControls: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Electrode Clean") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + HStack(spacing: 6) { + TextField("mV", text: $state.cleanV) + .textFieldStyle(.roundedBorder) + .frame(width: 60) + #if os(iOS) + .keyboardType(.numberPad) + #endif + TextField("s", text: $state.cleanDur) + .textFieldStyle(.roundedBorder) + .frame(width: 45) + #if os(iOS) + .keyboardType(.numberPad) + #endif + Button("Clean") { state.startClean() } + .buttonStyle(.bordered) + .tint(Color(red: 0.65, green: 0.55, blue: 0.15)) + .controlSize(.small) + } + } + } + + // MARK: - iPhone: TabView + + private var iPhoneLayout: some View { + TabView(selection: $state.tab) { + VStack(spacing: 0) { + StatusBar(state: state) + EISView(state: state) + } + .tabItem { Label("EIS", systemImage: "waveform.path.ecg") } + .tag(Tab.eis) + + VStack(spacing: 0) { + StatusBar(state: state) + LSVView(state: state) + } + .tabItem { Label("LSV", systemImage: "chart.xyaxis.line") } + .tag(Tab.lsv) + + VStack(spacing: 0) { + StatusBar(state: state) + AmpView(state: state) + } + .tabItem { Label("Amp", systemImage: "bolt.fill") } + .tag(Tab.amp) + + VStack(spacing: 0) { + StatusBar(state: state) + ChlorineView(state: state) + } + .tabItem { Label("Chlorine", systemImage: "drop.fill") } + .tag(Tab.chlorine) + + VStack(spacing: 0) { + StatusBar(state: state) + PhView(state: state) + } + .tabItem { Label("pH", systemImage: "scalemass") } + .tag(Tab.ph) + + SessionView() + .tabItem { Label("Sessions", systemImage: "folder") } + .tag(Tab.sessions) + } + } + + // MARK: - Tab content + + @ViewBuilder + private var tabContent: some View { + switch state.tab { + case .eis: EISView(state: state) + case .lsv: LSVView(state: state) + case .amp: AmpView(state: state) + case .chlorine: ChlorineView(state: state) + case .ph: PhView(state: state) + case .sessions: SessionView() + } + } +} diff --git a/cue-ios/CueIOS/Views/EISView.swift b/cue-ios/CueIOS/Views/EISView.swift new file mode 100644 index 0000000..afff577 --- /dev/null +++ b/cue-ios/CueIOS/Views/EISView.swift @@ -0,0 +1,607 @@ +import SwiftUI +import Charts + +struct EISView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + GeometryReader { geo in + if geo.size.width > 700 { + wideLayout(geo: geo) + } else { + compactLayout + } + } + } + } + + // MARK: - Controls + + private var controlsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + LabeledField("Start Hz", text: $state.freqStart, width: 90) + LabeledField("Stop Hz", text: $state.freqStop, width: 90) + LabeledField("PPD", text: $state.ppd, width: 50) + + LabeledPicker("RTIA", selection: $state.rtia, items: Rtia.allCases) { $0.label } + .frame(width: 120) + LabeledPicker("RCAL", selection: $state.rcal, items: Rcal.allCases) { $0.label } + .frame(width: 170) + LabeledPicker("Electrodes", selection: $state.electrode, items: Electrode.allCases) { $0.label } + .frame(width: 180) + + Button("Apply") { state.applyEISSettings() } + .buttonStyle(ActionButtonStyle(color: .blue)) + Button("Sweep") { state.startSweep() } + .buttonStyle(ActionButtonStyle(color: .green)) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + // MARK: - Wide layout (iPad) + + private func wideLayout(geo: GeometryProxy) -> some View { + HSplitLayout(ratio: 0.55) { + VStack(spacing: 0) { + HStack(spacing: 8) { + bodePlot + nyquistPlot + } + .padding(4) + } + } trailing: { + eisTable + } + } + + // MARK: - Compact layout (iPhone) + + private var compactLayout: some View { + ScrollView { + VStack(spacing: 12) { + bodePlot.frame(height: 300) + nyquistPlot.frame(height: 300) + eisTable.frame(height: 300) + } + .padding() + } + } + + // MARK: - Bode plot + + private var bodePlot: some View { + VStack(spacing: 0) { + if state.eisPoints.isEmpty { + noDataPlaceholder + } else { + PlotContainer(title: "") { + VStack(spacing: 0) { + magnitudePlot + .frame(maxHeight: .infinity) + Divider() + phasePlot + .frame(maxHeight: .infinity) + } + } + } + } + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var magnitudePlot: some View { + Chart { + if let ref = state.eisRef { + ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", Double(pt.magOhms)) + ) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", Double(pt.magOhms)) + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", Double(pt.magOhms)) + ) + .foregroundStyle(Color.cyan) + .symbolSize(16) + } + } + .chartXAxisLabel("|Z| (Ohm)", alignment: .leading) + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(freqLabel(v)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .chartYAxisLabel("|Z|", position: .leading) + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(Color.cyan) + } + } + .padding(8) + } + + private var phasePlot: some View { + Chart { + if let ref = state.eisRef { + ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("Phase", Double(pt.phaseDeg)) + ) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("Phase", Double(pt.phaseDeg)) + ) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("Phase", Double(pt.phaseDeg)) + ) + .foregroundStyle(Color.orange) + .symbolSize(16) + } + } + .chartXAxisLabel("Phase (deg)", alignment: .leading) + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(freqLabel(v)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .chartYAxisLabel("Phase", position: .leading) + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(Color.orange) + } + } + .padding(8) + } + + // MARK: - Nyquist plot + + private var nyquistPlot: some View { + VStack(spacing: 0) { + if state.eisPoints.isEmpty { + noDataPlaceholder + } else { + PlotContainer(title: "") { + nyquistCanvas + } + } + } + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var nyquistCanvas: some View { + Canvas { context, size in + let ml: CGFloat = 50, mr: CGFloat = 12, mt: CGFloat = 12, mb: CGFloat = 22 + let xl = ml, xr = size.width - mr + let yt = mt, yb = size.height - mb + + let points = state.eisPoints.filter { $0.zReal.isFinite && $0.zImag.isFinite } + guard !points.isEmpty else { return } + + let reVals = points.map { CGFloat($0.zReal) } + let niVals = points.map { CGFloat(-$0.zImag) } + let (reMin, reMax) = (reVals.min()!, reVals.max()!) + let (niMin, niMax) = (niVals.min()!, niVals.max()!) + let reSpan = max(reMax - reMin, 1) + let niSpan = max(niMax - niMin, 1) + let span = max(reSpan, niSpan) * 1.3 + let reC = (reMin + reMax) / 2 + let niC = (niMin + niMax) / 2 + let xvLo = reC - span / 2, xvHi = reC + span / 2 + let yvLo = niC - span / 2, yvHi = niC + span / 2 + + func lx(_ v: CGFloat) -> CGFloat { xl + (v - xvLo) / (xvHi - xvLo) * (xr - xl) } + func ly(_ v: CGFloat) -> CGFloat { yt + (yvHi - v) / (yvHi - yvLo) * (yb - yt) } + + // grid + let gridColor = Color(white: 0.25).opacity(0.6) + drawGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb, + xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi, + gridColor: gridColor, size: size) + + // zero line + let zy = ly(0) + if zy > yt && zy < yb { + var zeroPath = Path() + zeroPath.move(to: CGPoint(x: xl, y: zy)) + zeroPath.addLine(to: CGPoint(x: xr, y: zy)) + context.stroke(zeroPath, with: .color(.gray.opacity(0.6)), lineWidth: 1) + } + + // axis labels + context.draw( + Text("-Z''").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)), + at: CGPoint(x: 20, y: yt - 2)) + context.draw( + Text("Z'").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)), + at: CGPoint(x: (xl + xr) / 2, y: yb + 12)) + + // reference + if let ref = state.eisRef { + let refPts = ref.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) } + drawPolyline(context: context, points: refPts, color: Color.gray.opacity(0.5), width: 1.5) + } + + // data + let dataPts = points.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) } + let nyqColor = Color(red: 0.4, green: 1, blue: 0.4) + drawPolyline(context: context, points: dataPts, color: nyqColor, width: 2) + for pt in dataPts { + context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)), + with: .color(nyqColor)) + } + + // circle fit + if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) { + let disc = fit.r * fit.r - fit.cy * fit.cy + if disc > 0 { + let sd = sqrt(disc) + let rs = fit.cx - sd + let rp = 2 * sd + + if rp > 0 { + let thetaR = atan2(-fit.cy, sd) + var thetaL = atan2(-fit.cy, -sd) + if thetaL < thetaR { thetaL += 2 * .pi } + + let nArc = 120 + var arcPath = Path() + for i in 0...nArc { + let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc) + let ax = fit.cx + fit.r * cos(t) + let ay = fit.cy + fit.r * sin(t) + let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay))) + if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) } + } + context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)), + lineWidth: 1.5) + + // Rs and Rp markers + let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9) + let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0)) + let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0)) + context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)), + with: .color(fitPtColor)) + context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)), + with: .color(fitPtColor)) + context.draw( + Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor), + at: CGPoint(x: rsScr.x, y: rsScr.y + 14)) + context.draw( + Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor), + at: CGPoint(x: rpScr.x, y: rpScr.y + 14)) + } + } + } + } + } + + // MARK: - Data table + + private var eisTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "Freq (Hz)", width: 80, alignment: .trailing), + MeasurementColumn(header: "|Z| (Ohm)", width: 90, alignment: .trailing), + MeasurementColumn(header: "Phase (\u{00B0})", width: 70, alignment: .trailing), + MeasurementColumn(header: "Re (Ohm)", width: 90, alignment: .trailing), + MeasurementColumn(header: "Im (Ohm)", width: 90, alignment: .trailing), + ], + rows: state.eisPoints, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.freqHz) + case 1: String(format: "%.2f", pt.magOhms) + case 2: String(format: "%.2f", pt.phaseDeg) + case 3: String(format: "%.2f", pt.zReal) + case 4: String(format: "%.2f", pt.zImag) + default: "" + } + } + ) + } + + // MARK: - Helpers + + private var noDataPlaceholder: some View { + Text("No data") + .foregroundStyle(Color(white: 0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func freqLabel(_ logVal: Double) -> String { + let hz = pow(10, logVal) + if hz >= 1000 { return String(format: "%.0fk", hz / 1000) } + return String(format: "%.0f", hz) + } +} + +// MARK: - Kasa circle fit (ported from plot.rs) + +struct CircleFitResult { + let cx: Double + let cy: Double + let r: Double +} + +func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? { + let all = points.filter { $0.0.isFinite && $0.1.isFinite } + guard all.count >= 4 else { return nil } + + let minPts = max(4, all.count / 3) + var best: CircleFitResult? + var bestScore = Double.greatestFiniteMagnitude + + for start in 0..= minPts { + guard let raw = kasaFitRaw(pts) else { break } + let (cx, cy, r) = raw + let disc = r * r - cy * cy + if disc > 0 { + let sd = sqrt(disc) + let rp = 2 * sd + if rp > 0 { + let avgErr = pts.map { p in + abs(sqrt((p.0 - cx) * (p.0 - cx) + (p.1 - cy) * (p.1 - cy)) - r) + }.reduce(0, +) / (Double(pts.count) * r) + let coverage = Double(pts.count) / Double(all.count) + let score = avgErr / coverage + if score < bestScore { + bestScore = score + best = CircleFitResult(cx: cx, cy: cy, r: r) + } + break + } + } + // remove worst outlier + guard let worstIdx = pts.enumerated().max(by: { a, b in + let dA = abs(sqrt((a.element.0 - cx) * (a.element.0 - cx) + + (a.element.1 - cy) * (a.element.1 - cy)) - r) + let dB = abs(sqrt((b.element.0 - cx) * (b.element.0 - cx) + + (b.element.1 - cy) * (b.element.1 - cy)) - r) + return dA < dB + })?.offset else { break } + pts.remove(at: worstIdx) + } + } + return best +} + +private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)? { + guard pts.count >= 3 else { return nil } + let n = Double(pts.count) + var sx = 0.0, sy = 0.0 + var sx2 = 0.0, sy2 = 0.0, sxy = 0.0 + var sx3 = 0.0, sy3 = 0.0, sx2y = 0.0, sxy2 = 0.0 + + for (x, y) in pts { + sx += x; sy += y + let x2 = x * x, y2 = y * y, xy = x * y + sx2 += x2; sy2 += y2; sxy += xy + sx3 += x2 * x; sy3 += y2 * y; sx2y += x2 * y; sxy2 += x * y2 + } + + let (a00, a01, a02) = (sx2, sxy, sx) + let (a10, a11, a12) = (sxy, sy2, sy) + let (a20, a21, a22) = (sx, sy, n) + let (r0, r1, r2) = (sx3 + sxy2, sx2y + sy3, sx2 + sy2) + + let det = a00 * (a11 * a22 - a12 * a21) + - a01 * (a10 * a22 - a12 * a20) + + a02 * (a10 * a21 - a11 * a20) + guard abs(det) > 1e-20 else { return nil } + + let a = (r0 * (a11 * a22 - a12 * a21) + - a01 * (r1 * a22 - a12 * r2) + + a02 * (r1 * a21 - a11 * r2)) / det + let b = (a00 * (r1 * a22 - a12 * r2) + - r0 * (a10 * a22 - a12 * a20) + + a02 * (a10 * r2 - r1 * a20)) / det + let c = (a00 * (a11 * r2 - r1 * a21) + - a01 * (a10 * r2 - r1 * a20) + + r0 * (a10 * a21 - a11 * a20)) / det + + let cx = a / 2 + let cy = b / 2 + let rSq = c + cx * cx + cy * cy + guard rSq > 0 else { return nil } + return (cx, cy, sqrt(rSq)) +} + +// MARK: - Canvas drawing helpers + +private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) { + guard points.count >= 2 else { return } + var path = Path() + path.move(to: points[0]) + for pt in points.dropFirst() { + guard pt.x.isFinite && pt.y.isFinite else { continue } + path.addLine(to: pt) + } + context.stroke(path, with: .color(color), lineWidth: width) +} + +private func drawGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat, + xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat, + gridColor: Color, size: CGSize) { + let xStep = niceStep(Double(xvHi - xvLo), targetTicks: 4) + if xStep > 0 { + var g = (Double(xvLo) / xStep).rounded(.up) * xStep + while g <= Double(xvHi) { + let x = xl + CGFloat((g - Double(xvLo)) / Double(xvHi - xvLo)) * (xr - xl) + var p = Path(); p.move(to: CGPoint(x: x, y: yt)); p.addLine(to: CGPoint(x: x, y: yb)) + context.stroke(p, with: .color(gridColor), lineWidth: 0.5) + context.draw( + Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary), + at: CGPoint(x: x, y: yb + 10)) + g += xStep + } + } + let yStep = niceStep(Double(yvHi - yvLo), targetTicks: 4) + if yStep > 0 { + var g = (Double(yvLo) / yStep).rounded(.up) * yStep + while g <= Double(yvHi) { + let y = yt + CGFloat((Double(yvHi) - g) / Double(yvHi - yvLo)) * (yb - yt) + var p = Path(); p.move(to: CGPoint(x: xl, y: y)); p.addLine(to: CGPoint(x: xr, y: y)) + context.stroke(p, with: .color(gridColor), lineWidth: 0.5) + context.draw( + Text(String(format: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary), + at: CGPoint(x: xl - 20, y: y)) + g += yStep + } + } +} + +private func niceStep(_ range: Double, targetTicks: Int) -> Double { + guard abs(range) > 1e-10 else { return 1 } + let rough = range / Double(targetTicks) + let mag = pow(10, floor(log10(abs(rough)))) + let norm = abs(rough) / mag + let s: Double + if norm < 1.5 { s = 1 } + else if norm < 3.5 { s = 2 } + else if norm < 7.5 { s = 5 } + else { s = 10 } + return s * mag +} + +// MARK: - Shared UI components + +struct LabeledField: View { + let label: String + @Binding var text: String + let width: CGFloat + + init(_ label: String, text: Binding, width: CGFloat) { + self.label = label + self._text = text + self.width = width + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + TextField(label, text: $text) + .textFieldStyle(.roundedBorder) + .frame(width: width) + #if os(iOS) + .keyboardType(.decimalPad) + #endif + } + } +} + +struct LabeledPicker: View { + let label: String + @Binding var selection: Item + let items: [Item] + let itemLabel: (Item) -> String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Picker(label, selection: $selection) { + ForEach(items) { item in + Text(itemLabel(item)).tag(item) + } + } + .labelsHidden() + #if os(iOS) + .pickerStyle(.menu) + #endif + } + } +} + +struct ActionButtonStyle: ButtonStyle { + let color: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(color.opacity(configuration.isPressed ? 0.7 : 1.0)) + ) + .foregroundStyle(.white) + } +} + +struct HSplitLayout: View { + let ratio: CGFloat + @ViewBuilder let leading: () -> Leading + @ViewBuilder let trailing: () -> Trailing + + var body: some View { + GeometryReader { geo in + HStack(spacing: 0) { + leading() + .frame(width: geo.size.width * ratio) + Divider() + trailing() + .frame(width: geo.size.width * (1 - ratio)) + } + } + } +} diff --git a/cue-ios/CueIOS/Views/LSVView.swift b/cue-ios/CueIOS/Views/LSVView.swift new file mode 100644 index 0000000..5bb2f5f --- /dev/null +++ b/cue-ios/CueIOS/Views/LSVView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import Charts + +struct LSVView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + GeometryReader { geo in + if geo.size.width > 700 { + HSplitLayout(ratio: 0.55) { + voltammogramPlot + } trailing: { + lsvTable + } + } else { + ScrollView { + VStack(spacing: 12) { + voltammogramPlot.frame(height: 350) + lsvTable.frame(height: 300) + } + .padding() + } + } + } + } + } + + // MARK: - Controls + + private var controlsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + LabeledField("Start mV", text: $state.lsvStartV, width: 80) + LabeledField("Stop mV", text: $state.lsvStopV, width: 80) + LabeledField("Scan mV/s", text: $state.lsvScanRate, width: 80) + + LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label } + .frame(width: 120) + + Button("Start LSV") { state.startLSV() } + .buttonStyle(ActionButtonStyle(color: .green)) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + // MARK: - Plot + + private var voltammogramPlot: some View { + Group { + if state.lsvPoints.isEmpty { + Text("No data") + .foregroundStyle(Color(white: 0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.3)) + } else { + PlotContainer(title: "") { + Chart { + if let ref = state.lsvRef { + ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.yellow) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.yellow) + .symbolSize(16) + } + } + .chartXAxisLabel("V (mV)") + .chartYAxisLabel("I (uA)", position: .leading) + .chartXAxis { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(Color.yellow) + } + } + .padding(8) + } + } + } + .background(Color.black.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - Table + + private var lsvTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: state.lsvPoints, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.vMv) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } +} diff --git a/cue-ios/CueIOS/Views/PhView.swift b/cue-ios/CueIOS/Views/PhView.swift new file mode 100644 index 0000000..64289cf --- /dev/null +++ b/cue-ios/CueIOS/Views/PhView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct PhView: View { + @Bindable var state: AppState + + var body: some View { + VStack(spacing: 0) { + controlsRow + Divider() + phBody + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } + + // MARK: - Controls + + private var controlsRow: some View { + HStack(spacing: 10) { + LabeledField("Stabilize s", text: $state.phStabilize, width: 80) + + Button("Measure pH") { state.startPh() } + .buttonStyle(ActionButtonStyle(color: .green)) + + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - Result body + + @ViewBuilder + private var phBody: some View { + if let r = state.phResult { + VStack(alignment: .leading, spacing: 8) { + Text(String(format: "pH: %.2f", r.ph)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + let nernstSlope = 0.1984 * (Double(r.tempC) + 273.15) + Text(String(format: "OCP: %.1f mV | Nernst slope: %.2f mV/pH | Temp: %.1f\u{00B0}C", + r.vOcpMv, nernstSlope, r.tempC)) + .font(.subheadline) + .foregroundStyle(.secondary) + + if let refR = state.phRef { + let dPh = r.ph - refR.ph + let dV = r.vOcpMv - refR.vOcpMv + Text(String(format: "vs Ref: dpH=%+.3f dOCP=%+.1f mV (ref pH=%.2f)", + dPh, dV, refR.ph)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else { + VStack(alignment: .leading, spacing: 8) { + Text("No measurement yet") + .font(.title3) + .foregroundStyle(.secondary) + Text("OCP method: V(SE0) - V(RE0) with Nernst correction") + .font(.caption) + .foregroundStyle(Color(white: 0.4)) + } + } + } +} diff --git a/cue-ios/CueIOS/Views/SessionView.swift b/cue-ios/CueIOS/Views/SessionView.swift new file mode 100644 index 0000000..5bd1a4d --- /dev/null +++ b/cue-ios/CueIOS/Views/SessionView.swift @@ -0,0 +1,259 @@ +import SwiftUI + +struct Session: Identifiable { + let id = UUID() + var name: String + var notes: String + var created: Date + var measurements: [SessionMeasurement] +} + +struct SessionMeasurement: Identifiable { + let id = UUID() + var type: Tab + var timestamp: Date + var pointCount: Int + var summary: String +} + +@Observable +final class SessionStore { + var sessions: [Session] = [] + var selectedSession: Session? + + func createSession(name: String, notes: String) { + let session = Session( + name: name, + notes: notes, + created: Date(), + measurements: [] + ) + sessions.insert(session, at: 0) + selectedSession = session + } + + func deleteSession(_ session: Session) { + sessions.removeAll { $0.id == session.id } + if selectedSession?.id == session.id { + selectedSession = nil + } + } +} + +struct SessionView: View { + @State var store = SessionStore() + @State private var showingNewSession = false + @State private var newName = "" + @State private var newNotes = "" + + var body: some View { + GeometryReader { geo in + if geo.size.width > 700 { + wideLayout + } else { + compactLayout + } + } + .sheet(isPresented: $showingNewSession) { + newSessionSheet + } + } + + // MARK: - Wide layout (iPad) + + private var wideLayout: some View { + HStack(spacing: 0) { + sessionList + .frame(width: 300) + Divider() + if let session = store.selectedSession { + sessionDetail(session) + } else { + Text("Select or create a session") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + // MARK: - Compact layout (iPhone) + + private var compactLayout: some View { + NavigationStack { + sessionList + .navigationTitle("Sessions") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { showingNewSession = true }) { + Image(systemName: "plus") + } + } + } + } + } + + // MARK: - Session list + + private var sessionList: some View { + VStack(spacing: 0) { + HStack { + Text("Sessions") + .font(.headline) + Spacer() + Button(action: { showingNewSession = true }) { + Image(systemName: "plus.circle.fill") + .imageScale(.large) + } + } + .padding() + + if store.sessions.isEmpty { + VStack(spacing: 8) { + Text("No sessions") + .foregroundStyle(.secondary) + Text("Create a session to organize measurements") + .font(.caption) + .foregroundStyle(Color(white: 0.4)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(selection: Binding( + get: { store.selectedSession?.id }, + set: { id in store.selectedSession = store.sessions.first { $0.id == id } } + )) { + ForEach(store.sessions) { session in + sessionRow(session) + .tag(session.id) + } + .onDelete { indices in + for idx in indices { + store.deleteSession(store.sessions[idx]) + } + } + } + .listStyle(.plain) + } + } + } + + private func sessionRow(_ session: Session) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(session.name) + .font(.subheadline.weight(.medium)) + HStack { + Text(session.created, style: .date) + Text(session.created, style: .time) + } + .font(.caption) + .foregroundStyle(.secondary) + if !session.notes.isEmpty { + Text(session.notes) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + if !session.measurements.isEmpty { + Text("\(session.measurements.count) measurement\(session.measurements.count == 1 ? "" : "s")") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + // MARK: - Session detail + + private func sessionDetail(_ session: Session) -> some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text(session.name) + .font(.title2.bold()) + HStack { + Text(session.created, style: .date) + Text(session.created, style: .time) + } + .font(.subheadline) + .foregroundStyle(.secondary) + if !session.notes.isEmpty { + Text(session.notes) + .font(.body) + .foregroundStyle(.secondary) + } + } + .padding() + + Divider() + + if session.measurements.isEmpty { + VStack(spacing: 8) { + Text("No measurements in this session") + .foregroundStyle(.secondary) + Text("Run a measurement to add data") + .font(.caption) + .foregroundStyle(Color(white: 0.4)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(session.measurements) { meas in + HStack { + Label(meas.type.rawValue, systemImage: measurementIcon(meas.type)) + Spacer() + VStack(alignment: .trailing) { + Text("\(meas.pointCount) pts") + .font(.caption.monospacedDigit()) + Text(meas.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.plain) + } + } + } + + // MARK: - New session sheet + + private var newSessionSheet: some View { + NavigationStack { + Form { + Section("Session Info") { + TextField("Name", text: $newName) + TextField("Notes (optional)", text: $newNotes, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle("New Session") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + showingNewSession = false + newName = "" + newNotes = "" + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + store.createSession(name: newName, notes: newNotes) + showingNewSession = false + newName = "" + newNotes = "" + } + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + .presentationDetents([.medium]) + } + + private func measurementIcon(_ tab: Tab) -> String { + switch tab { + case .eis: "waveform.path.ecg" + case .lsv: "chart.xyaxis.line" + case .amp: "bolt.fill" + case .chlorine: "drop.fill" + case .ph: "scalemass" + case .sessions: "folder" + } + } +}