import SwiftUI import Charts struct EISView: View { @Bindable var state: AppState var body: some View { VStack(spacing: 0) { controlsRow Divider() GeometryReader { geo in if geo.size.width > 700 { wideLayout(geo: geo) } else { compactLayout } } } } // MARK: - Controls private var controlsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { LabeledField("Start Hz", text: $state.freqStart, width: 90) LabeledField("Stop Hz", text: $state.freqStop, width: 90) LabeledField("PPD", text: $state.ppd, width: 50) LabeledPicker("RTIA", selection: $state.rtia, items: Rtia.allCases) { $0.label } .frame(width: 120) LabeledPicker("RCAL", selection: $state.rcal, items: Rcal.allCases) { $0.label } .frame(width: 170) LabeledPicker("Electrodes", selection: $state.electrode, items: Electrode.allCases) { $0.label } .frame(width: 180) Button("Apply") { state.applyEISSettings() } .buttonStyle(ActionButtonStyle(color: .blue)) Button("Sweep") { state.startSweep() } .buttonStyle(ActionButtonStyle(color: .green)) } .padding(.horizontal) .padding(.vertical, 8) } } // MARK: - Wide layout (iPad) private func wideLayout(geo: GeometryProxy) -> some View { HSplitLayout(ratio: 0.55) { VStack(spacing: 0) { HStack(spacing: 8) { bodePlot nyquistPlot } .padding(4) } } trailing: { eisTable } } // MARK: - Compact layout (iPhone) private var compactLayout: some View { ScrollView { VStack(spacing: 12) { bodePlot.frame(height: 300) nyquistPlot.frame(height: 300) eisTable.frame(height: 300) } .padding() } } // MARK: - Bode plot private var bodePlot: some View { VStack(spacing: 0) { if state.eisPoints.isEmpty { noDataPlaceholder } else { PlotContainer(title: "") { VStack(spacing: 0) { magnitudePlot .frame(maxHeight: .infinity) Divider() phasePlot .frame(maxHeight: .infinity) } } } } .background(Color.black.opacity(0.3)) .clipShape(RoundedRectangle(cornerRadius: 8)) } private var magnitudePlot: some View { Chart { if let ref = state.eisRef { ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in LineMark( x: .value("Freq", log10(max(Double(pt.freqHz), 1))), y: .value("|Z|", Double(pt.magOhms)) ) .foregroundStyle(Color.gray.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1.5)) } } ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in LineMark( x: .value("Freq", log10(max(Double(pt.freqHz), 1))), y: .value("|Z|", Double(pt.magOhms)) ) .foregroundStyle(Color.cyan) .lineStyle(StrokeStyle(lineWidth: 2)) } ForEach(Array(state.eisPoints.enumerated()), id: \.offset) { _, pt in PointMark( x: .value("Freq", log10(max(Double(pt.freqHz), 1))), y: .value("|Z|", Double(pt.magOhms)) ) .foregroundStyle(Color.cyan) .symbolSize(16) } } .chartXAxisLabel("|Z| (Ohm)", alignment: .leading) .chartXAxis { AxisMarks(values: .automatic) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) .foregroundStyle(Color.gray.opacity(0.3)) AxisValueLabel { if let v = value.as(Double.self) { Text(freqLabel(v)) .font(.caption2) .foregroundStyle(.secondary) } } } } .chartYAxisLabel("|Z|", position: .leading) .chartYAxis { AxisMarks(position: .leading) { _ in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) .foregroundStyle(Color.gray.opacity(0.3)) AxisValueLabel() .font(.caption2) .foregroundStyle(Color.cyan) } } .padding(8) } private var phasePlot: some View { Chart { if let ref = state.eisRef { ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in LineMark( x: .value("Freq", log10(max(Double(pt.freqHz), 1))), y: .value("Phase", Double(pt.phaseDeg)) ) .foregroundStyle(Color.gray.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1.5)) } } ForEach(Array(state.eisPoints.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(state.eisPoints.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("Phase (deg)", alignment: .leading) .chartXAxis { AxisMarks(values: .automatic) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) .foregroundStyle(Color.gray.opacity(0.3)) AxisValueLabel { if let v = value.as(Double.self) { Text(freqLabel(v)) .font(.caption2) .foregroundStyle(.secondary) } } } } .chartYAxisLabel("Phase", position: .leading) .chartYAxis { AxisMarks(position: .leading) { _ in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) .foregroundStyle(Color.gray.opacity(0.3)) AxisValueLabel() .font(.caption2) .foregroundStyle(Color.orange) } } .padding(8) } // MARK: - Nyquist plot private var nyquistPlot: some View { VStack(spacing: 0) { if state.eisPoints.isEmpty { noDataPlaceholder } else { PlotContainer(title: "") { nyquistCanvas } } } .background(Color.black.opacity(0.3)) .clipShape(RoundedRectangle(cornerRadius: 8)) } private var nyquistCanvas: 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 points = state.eisPoints.filter { $0.zReal.isFinite && $0.zImag.isFinite } guard !points.isEmpty else { return } let reVals = points.map { CGFloat($0.zReal) } let niVals = points.map { CGFloat(-$0.zImag) } let (reMin, reMax) = (reVals.min()!, reVals.max()!) let (niMin, niMax) = (niVals.min()!, niVals.max()!) let reSpan = max(reMax - reMin, 1) let niSpan = max(niMax - niMin, 1) let span = max(reSpan, niSpan) * 1.3 let reC = (reMin + reMax) / 2 let niC = (niMin + niMax) / 2 let xvLo = reC - span / 2, xvHi = reC + span / 2 let yvLo = niC - span / 2, yvHi = niC + span / 2 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) drawGrid(context: context, xl: xl, xr: xr, yt: yt, yb: yb, xvLo: xvLo, xvHi: xvHi, yvLo: yvLo, yvHi: yvHi, gridColor: gridColor, size: size) // zero line let zy = ly(0) if zy > yt && zy < yb { var zeroPath = Path() zeroPath.move(to: CGPoint(x: xl, y: zy)) zeroPath.addLine(to: CGPoint(x: xr, y: zy)) context.stroke(zeroPath, with: .color(.gray.opacity(0.6)), lineWidth: 1) } // axis labels context.draw( Text("-Z''").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)), at: CGPoint(x: 20, y: yt - 2)) context.draw( Text("Z'").font(.caption2).foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4)), at: CGPoint(x: (xl + xr) / 2, y: yb + 12)) // reference if let ref = state.eisRef { let refPts = ref.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) } drawPolyline(context: context, points: refPts, color: Color.gray.opacity(0.5), width: 1.5) } // data let dataPts = points.map { CGPoint(x: lx(CGFloat($0.zReal)), y: ly(CGFloat(-$0.zImag))) } let nyqColor = Color(red: 0.4, green: 1, blue: 0.4) drawPolyline(context: context, points: dataPts, color: nyqColor, width: 2) for pt in dataPts { context.fill(Path(ellipseIn: CGRect(x: pt.x - 3, y: pt.y - 3, width: 6, height: 6)), with: .color(nyqColor)) } // circle fit if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) { let disc = fit.r * fit.r - fit.cy * fit.cy if disc > 0 { let sd = sqrt(disc) let rs = fit.cx - sd let rp = 2 * sd if rp > 0 { let thetaR = atan2(-fit.cy, sd) var thetaL = atan2(-fit.cy, -sd) if thetaL < thetaR { thetaL += 2 * .pi } let nArc = 120 var arcPath = Path() for i in 0...nArc { let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc) let ax = fit.cx + fit.r * cos(t) let ay = fit.cy + fit.r * sin(t) let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay))) if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) } } context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)), lineWidth: 1.5) // Rs and Rp markers let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9) let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0)) let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0)) context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)), with: .color(fitPtColor)) context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)), with: .color(fitPtColor)) context.draw( Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor), at: CGPoint(x: rsScr.x, y: rsScr.y + 14)) context.draw( Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor), at: CGPoint(x: rpScr.x, y: rpScr.y + 14)) } } } } } // MARK: - Data table 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 (\u{00B0})", width: 70, alignment: .trailing), MeasurementColumn(header: "Re (Ohm)", width: 90, alignment: .trailing), MeasurementColumn(header: "Im (Ohm)", width: 90, alignment: .trailing), ], rows: state.eisPoints, 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: "" } } ) } // MARK: - Helpers private var noDataPlaceholder: some View { Text("No data") .foregroundStyle(Color(white: 0.4)) .frame(maxWidth: .infinity, maxHeight: .infinity) } private func freqLabel(_ logVal: Double) -> String { let hz = pow(10, logVal) if hz >= 1000 { return String(format: "%.0fk", hz / 1000) } return String(format: "%.0f", hz) } } // MARK: - Kasa circle fit (ported from plot.rs) struct CircleFitResult { let cx: Double let cy: Double let r: Double } func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? { let all = points.filter { $0.0.isFinite && $0.1.isFinite } guard all.count >= 4 else { return nil } let minPts = max(4, all.count / 3) var best: CircleFitResult? var bestScore = Double.greatestFiniteMagnitude for start in 0..= minPts { guard let raw = kasaFitRaw(pts) else { break } let (cx, cy, r) = raw let disc = r * r - cy * cy if disc > 0 { let sd = sqrt(disc) let rp = 2 * sd if rp > 0 { let avgErr = pts.map { p in abs(sqrt((p.0 - cx) * (p.0 - cx) + (p.1 - cy) * (p.1 - cy)) - r) }.reduce(0, +) / (Double(pts.count) * r) let coverage = Double(pts.count) / Double(all.count) let score = avgErr / coverage if score < bestScore { bestScore = score best = CircleFitResult(cx: cx, cy: cy, r: r) } break } } // remove worst outlier guard let worstIdx = pts.enumerated().max(by: { a, b in let dA = abs(sqrt((a.element.0 - cx) * (a.element.0 - cx) + (a.element.1 - cy) * (a.element.1 - cy)) - r) let dB = abs(sqrt((b.element.0 - cx) * (b.element.0 - cx) + (b.element.1 - cy) * (b.element.1 - cy)) - r) return dA < dB })?.offset else { break } pts.remove(at: worstIdx) } } return best } private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)? { guard pts.count >= 3 else { return nil } let n = Double(pts.count) var sx = 0.0, sy = 0.0 var sx2 = 0.0, sy2 = 0.0, sxy = 0.0 var sx3 = 0.0, sy3 = 0.0, sx2y = 0.0, sxy2 = 0.0 for (x, y) in pts { sx += x; sy += y let x2 = x * x, y2 = y * y, xy = x * y sx2 += x2; sy2 += y2; sxy += xy sx3 += x2 * x; sy3 += y2 * y; sx2y += x2 * y; sxy2 += x * y2 } let (a00, a01, a02) = (sx2, sxy, sx) let (a10, a11, a12) = (sxy, sy2, sy) let (a20, a21, a22) = (sx, sy, n) let (r0, r1, r2) = (sx3 + sxy2, sx2y + sy3, sx2 + sy2) let det = a00 * (a11 * a22 - a12 * a21) - a01 * (a10 * a22 - a12 * a20) + a02 * (a10 * a21 - a11 * a20) guard abs(det) > 1e-20 else { return nil } let a = (r0 * (a11 * a22 - a12 * a21) - a01 * (r1 * a22 - a12 * r2) + a02 * (r1 * a21 - a11 * r2)) / det let b = (a00 * (r1 * a22 - a12 * r2) - r0 * (a10 * a22 - a12 * a20) + a02 * (a10 * r2 - r1 * a20)) / det let c = (a00 * (a11 * r2 - r1 * a21) - a01 * (a10 * r2 - r1 * a20) + r0 * (a10 * a21 - a11 * a20)) / det let cx = a / 2 let cy = b / 2 let rSq = c + cx * cx + cy * cy guard rSq > 0 else { return nil } return (cx, cy, sqrt(rSq)) } // MARK: - Canvas drawing helpers private func drawPolyline(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 drawGrid(context: GraphicsContext, xl: CGFloat, xr: CGFloat, yt: CGFloat, yb: CGFloat, xvLo: CGFloat, xvHi: CGFloat, yvLo: CGFloat, yvHi: CGFloat, gridColor: Color, size: CGSize) { let xStep = niceStep(Double(xvHi - xvLo), targetTicks: 4) if xStep > 0 { var g = (Double(xvLo) / xStep).rounded(.up) * xStep while g <= Double(xvHi) { let x = xl + CGFloat((g - Double(xvLo)) / Double(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 += xStep } } let yStep = niceStep(Double(yvHi - yvLo), targetTicks: 4) if yStep > 0 { var g = (Double(yvLo) / yStep).rounded(.up) * yStep while g <= Double(yvHi) { let y = yt + CGFloat((Double(yvHi) - g) / Double(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: "%.0f", g)).font(.system(size: 9)).foregroundStyle(.secondary), at: CGPoint(x: xl - 20, y: y)) g += yStep } } } private func niceStep(_ range: Double, targetTicks: Int) -> Double { guard abs(range) > 1e-10 else { return 1 } let rough = range / Double(targetTicks) let mag = pow(10, floor(log10(abs(rough)))) let norm = abs(rough) / mag let s: Double 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 } // MARK: - Shared UI components struct LabeledField: View { let label: String @Binding var text: String let width: CGFloat init(_ label: String, text: Binding, width: CGFloat) { self.label = label self._text = text self.width = width } var body: some View { VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption) .foregroundStyle(.secondary) TextField(label, text: $text) .textFieldStyle(.roundedBorder) .frame(width: width) #if os(iOS) .keyboardType(.decimalPad) #endif } } } struct LabeledPicker: View { let label: String @Binding var selection: Item let items: [Item] let itemLabel: (Item) -> String init(_ label: String, selection: Binding, items: [Item], itemLabel: @escaping (Item) -> String) { self.label = label self._selection = selection self.items = items self.itemLabel = itemLabel } var body: some View { VStack(alignment: .leading, spacing: 2) { Text(label) .font(.caption) .foregroundStyle(.secondary) Picker(label, selection: $selection) { ForEach(items) { item in Text(itemLabel(item)).tag(item) } } .labelsHidden() #if os(iOS) .pickerStyle(.menu) #endif } } } struct ActionButtonStyle: ButtonStyle { let color: Color func makeBody(configuration: Configuration) -> some View { configuration.label .font(.subheadline.weight(.medium)) .padding(.horizontal, 16) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) .fill(color.opacity(configuration.isPressed ? 0.7 : 1.0)) ) .foregroundStyle(.white) } } struct HSplitLayout: View { let ratio: CGFloat @ViewBuilder let leading: () -> Leading @ViewBuilder let trailing: () -> Trailing var body: some View { GeometryReader { geo in HStack(spacing: 0) { leading() .frame(width: geo.size.width * ratio) Divider() trailing() .frame(width: geo.size.width * (1 - ratio)) } } } }