add measurement data views with charts for all measurement types
This commit is contained in:
parent
d061a17e54
commit
f5394d01ca
|
|
@ -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<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 { 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue