393 lines
16 KiB
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
|
|
}
|
|
}
|
|
}
|