EIS-BLE-S3/cue-ios/CueIOS/Views/MeasurementDataView.swift

642 lines
20 KiB
Swift

/// 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<T: Decodable>(_ type: T.Type) -> [T] {
let decoder = JSONDecoder()
return points.compactMap { try? decoder.decode(T.self, from: $0.payload) }
}
private func decodeResult<T: Decodable>(_ 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)
}
}
}
}