/// 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 { 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(.secondary) } } } 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 { 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(.secondary) } } } 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 { 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(.secondary) } } } 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 { 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(.secondary) } } } 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 { 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(.secondary) } } } 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 { 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(.secondary) } } .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) } } } }