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

274 lines
11 KiB
Swift

import SwiftUI
import Charts
struct ChlorineView: View {
@Bindable var state: AppState
var body: some View {
VStack(spacing: 0) {
controlsRow
Divider()
GeometryReader { geo in
if geo.size.width > 700 {
HSplitLayout(ratio: 0.55) {
VStack(spacing: 4) {
resultBanner
chlorinePlot
}
} trailing: {
clTable
}
} else {
ScrollView {
VStack(spacing: 12) {
resultBanner
chlorinePlot.frame(height: 350)
clTable.frame(height: 300)
}
.padding()
}
}
}
}
}
// MARK: - Controls
private var controlsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
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 (_, 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: - 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
}
}
}