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

393 lines
16 KiB
Swift

import SwiftUI
import Charts
struct ChlorineView: View {
@Bindable var state: AppState
var body: some View {
VStack(spacing: 0) {
controlsRow
clPeakLabels
Divider()
GeometryReader { geo in
if geo.size.width > 700 {
HSplitLayout(ratio: 0.55) {
VStack(spacing: 4) {
voltammogramPlot
resultBanner
chlorinePlot
}
} trailing: {
clTable
}
} else {
ScrollView {
VStack(spacing: 12) {
voltammogramPlot.frame(height: 250)
resultBanner
chlorinePlot.frame(height: 250)
clTable.frame(height: 300)
}
.padding()
}
}
}
}
}
// MARK: - Controls
private var controlsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Button("Start LSV") { state.startLSV() }
.buttonStyle(ActionButtonStyle(color: .green))
Button(state.clManualPeaks ? "Manual" : "Auto") {
state.clManualPeaks.toggle()
if state.clManualPeaks {
state.lsvPeaks.removeAll()
} else {
state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
}
}
.font(.caption)
Divider().frame(height: 24)
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 f = state.clFactor {
let ppm = f * Double(abs(r.iFreeUa))
Text(String(format: "Free Cl: %.2f ppm", ppm))
.foregroundStyle(.cyan)
}
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: - Peak labels
@ViewBuilder
private var clPeakLabels: some View {
if !state.lsvPeaks.isEmpty {
HStack(spacing: 12) {
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
let label: String = {
switch peak.kind {
case .freeCl: return String(format: "Free: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .totalCl: return String(format: "Total: %.0fmV %.2fuA", peak.vMv, peak.iUa)
case .crossover: return String(format: "X-over: %.0fmV", peak.vMv)
}
}()
Text(label)
.font(.caption)
.foregroundStyle(clPeakColor(peak.kind))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
private func clPeakColor(_ kind: PeakKind) -> Color {
switch kind {
case .freeCl: .green
case .totalCl: .orange
case .crossover: .purple
}
}
// MARK: - Voltammogram
private var voltammogramPlot: some View {
Group {
if state.lsvPoints.isEmpty {
Text("No LSV 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)
}
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
PointMark(x: .value("V", Double(peak.vMv)), y: .value("I", Double(peak.iUa)))
.foregroundStyle(clPeakColor(peak.kind))
.symbolSize(100)
.symbol(.diamond)
RuleMark(x: .value("V", Double(peak.vMv)))
.foregroundStyle(clPeakColor(peak.kind).opacity(0.3))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
}
}
.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: - Chlorine 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
}
}
}