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 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: - 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 } } }