From f5394d01cacc0dc7602dedc354c8af2ade0be00d Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 3 Apr 2026 07:06:47 -0700 Subject: [PATCH] add measurement data views with charts for all measurement types --- .../CueIOS/Views/MeasurementDataView.swift | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 cue-ios/CueIOS/Views/MeasurementDataView.swift diff --git a/cue-ios/CueIOS/Views/MeasurementDataView.swift b/cue-ios/CueIOS/Views/MeasurementDataView.swift new file mode 100644 index 0000000..19dc315 --- /dev/null +++ b/cue-ios/CueIOS/Views/MeasurementDataView.swift @@ -0,0 +1,566 @@ +/// Measurement data viewer — switches on type to show appropriate charts. + +import SwiftUI +import Charts + +// MARK: - Router + +struct MeasurementDataView: View { + let measurement: Measurement + + @State private var points: [DataPoint] = [] + @State private var loaded = false + + var body: some View { + Group { + if !loaded { + ProgressView() + } else { + content + } + } + .navigationTitle(typeLabel) + .onAppear { loadPoints() } + } + + private func loadPoints() { + guard let mid = measurement.id else { return } + points = (try? Storage.shared.fetchDataPoints(measurementId: mid)) ?? [] + loaded = true + } + + @ViewBuilder + private var content: some View { + switch MeasurementType(rawValue: measurement.type) { + case .eis: + EisDataView(points: decodePoints(EisPoint.self)) + case .lsv: + LsvDataView(points: decodePoints(LsvPoint.self)) + case .amp: + AmpDataView(points: decodePoints(AmpPoint.self)) + case .chlorine: + ClDataView( + points: decodePoints(ClPoint.self), + result: decodeResult(ClResult.self) + ) + case .ph: + PhDataView(result: decodeResult(PhResult.self)) + case nil: + Text("Unknown type: \(measurement.type)") + .foregroundStyle(.secondary) + } + } + + private var typeLabel: String { + switch measurement.type { + case "eis": "EIS" + case "lsv": "LSV" + case "amp": "Amperometry" + case "chlorine": "Chlorine" + case "ph": "pH" + default: measurement.type + } + } + + private func decodePoints(_ type: T.Type) -> [T] { + let decoder = JSONDecoder() + return points.compactMap { try? decoder.decode(T.self, from: $0.payload) } + } + + private func decodeResult(_ type: T.Type) -> T? { + guard let data = measurement.resultSummary else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } +} + +// MARK: - EIS data view + +enum EisPlotMode: String, CaseIterable, Identifiable { + case nyquist = "Nyquist" + case bodeMag = "Bode |Z|" + case bodePhase = "Bode Phase" + + var id: String { rawValue } +} + +struct EisDataView: View { + let points: [EisPoint] + @State private var plotMode: EisPlotMode = .nyquist + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + Picker("Plot", selection: $plotMode) { + ForEach(EisPlotMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + if points.isEmpty { + noData + } else { + plotView + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + eisTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + @ViewBuilder + private var plotView: some View { + switch plotMode { + case .nyquist: + nyquistChart + .padding() + case .bodeMag: + bodeMagChart + .padding() + case .bodePhase: + bodePhaseChart + .padding() + } + } + + private var nyquistChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Z'", Double(pt.zReal)), + y: .value("-Z''", Double(-pt.zImag)) + ) + .foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)) + .symbolSize(20) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Z'", Double(pt.zReal)), + y: .value("-Z''", Double(-pt.zImag)) + ) + .foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4).opacity(0.6)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + .chartXAxisLabel("Z' (Ohm)") + .chartYAxisLabel("-Z'' (Ohm)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var bodeMagChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01))) + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("Freq", log10(max(Double(pt.freqHz), 1))), + y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01))) + ) + .foregroundStyle(Color.cyan) + .symbolSize(16) + } + } + .chartXAxisLabel("log10(Freq Hz)") + .chartYAxisLabel("log10(|Z| Ohm)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var bodePhaseChart: some View { + Chart { + ForEach(Array(points.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(points.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("log10(Freq Hz)") + .chartYAxisLabel("Phase (deg)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + 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", width: 70, alignment: .trailing), + MeasurementColumn(header: "Re", width: 80, alignment: .trailing), + MeasurementColumn(header: "Im", width: 80, alignment: .trailing), + ], + rows: points, + 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: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - LSV data view + +struct LsvDataView: View { + let points: [LsvPoint] + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if points.isEmpty { + noData + } else { + ivChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + lsvTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private var ivChart: some View { + Chart { + ForEach(Array(points.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(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("V", Double(pt.vMv)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(Color.yellow) + .symbolSize(16) + } + } + .chartXAxisLabel("Voltage (mV)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var lsvTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.vMv) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Amperometry data view + +struct AmpDataView: View { + let points: [AmpPoint] + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if points.isEmpty { + noData + } else { + ampChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + ampTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private var ampChart: some View { + let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0) + return Chart { + ForEach(Array(points.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(points.enumerated()), id: \.offset) { _, pt in + PointMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(ampColor) + .symbolSize(16) + } + } + .chartXAxisLabel("Time (ms)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + } + + private var ampTable: some View { + MeasurementTable( + columns: [ + MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing), + MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Chlorine data view + +struct ClDataView: View { + let points: [ClPoint] + let result: ClResult? + @State private var showTable = false + + var body: some View { + VStack(spacing: 0) { + if let r = result { + resultBanner(r) + } + + if points.isEmpty { + noData + } else { + clChart + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + if showTable && !points.isEmpty { + Divider() + clTable + .frame(maxHeight: 250) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(showTable ? "Hide Table" : "Show Table") { + showTable.toggle() + } + } + } + } + + private func resultBanner(_ r: ClResult) -> some View { + 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) + } + .font(.subheadline.monospacedDigit()) + .padding(.horizontal) + .padding(.vertical, 6) + } + + private var clChart: some View { + Chart { + ForEach(Array(points.enumerated()), id: \.offset) { _, pt in + LineMark( + x: .value("t", Double(pt.tMs)), + y: .value("I", Double(pt.iUa)) + ) + .foregroundStyle(by: .value("Phase", phaseLabel(pt.phase))) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + } + .chartForegroundStyleScale([ + "Conditioning": Color.gray, + "Free": Color(red: 0.2, green: 1, blue: 0.5), + "Total": Color(red: 1, green: 0.6, blue: 0.2), + ]) + .chartXAxisLabel("Time (ms)") + .chartYAxisLabel("Current (uA)") + .chartXAxis { darkAxis } + .chartYAxis { darkAxisLeading } + .chartLegend(position: .top) + } + + private func phaseLabel(_ phase: UInt8) -> String { + switch phase { + case 1: "Free" + case 2: "Total" + default: "Conditioning" + } + } + + 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: 70, alignment: .trailing), + ], + rows: points, + cellText: { pt, col in + switch col { + case 0: String(format: "%.1f", pt.tMs) + case 1: String(format: "%.3f", pt.iUa) + case 2: phaseLabel(pt.phase) + default: "" + } + } + ) + } + + private var noData: some View { + Text("No data points") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - pH data view + +struct PhDataView: View { + let result: PhResult? + + var body: some View { + VStack(spacing: 0) { + if let r = result { + VStack(alignment: .leading, spacing: 12) { + Text(String(format: "pH: %.2f", r.ph)) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text(String(format: "OCP: %.1f mV", r.vOcpMv)) + .font(.title3) + .foregroundStyle(.secondary) + + Text(String(format: "Temperature: %.1f C", r.tempC)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } else { + Text("No pH result") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +// MARK: - Shared axis styles + +private var darkAxis: some AxisContent { + AxisMarks { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } +} + +private var darkAxisLeading: some AxisContent { + AxisMarks(position: .leading) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel() + .font(.caption2) + .foregroundStyle(.secondary) + } +}