699 lines
26 KiB
Swift
699 lines
26 KiB
Swift
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))
|
|
}
|
|
|
|
// fit overlay
|
|
let fitColor = Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)
|
|
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
|
|
|
|
if let result = fitNyquist(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
|
switch result {
|
|
case .circle(let fit):
|
|
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(fitColor), lineWidth: 1.5)
|
|
|
|
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))
|
|
}
|
|
}
|
|
case .linear(let fit):
|
|
let xVals = points.map { CGFloat($0.zReal) }.filter { $0.isFinite }
|
|
guard let xMin = xVals.min(), let xMax = xVals.max() else { break }
|
|
let pad = (xMax - xMin) * 0.1
|
|
let x0 = Double(xMin - pad)
|
|
let x1 = Double(xMax + pad)
|
|
let y0 = fit.slope * x0 + fit.yIntercept
|
|
let y1 = fit.slope * x1 + fit.yIntercept
|
|
var linePath = Path()
|
|
linePath.move(to: CGPoint(x: lx(CGFloat(x0)), y: ly(CGFloat(y0))))
|
|
linePath.addLine(to: CGPoint(x: lx(CGFloat(x1)), y: ly(CGFloat(y1))))
|
|
context.stroke(linePath, with: .color(fitColor), lineWidth: 1.5)
|
|
|
|
let rsScr = CGPoint(x: lx(CGFloat(fit.rs)), y: ly(0))
|
|
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
|
with: .color(fitPtColor))
|
|
context.draw(
|
|
Text(String(format: "Rs=%.0f", fit.rs)).font(.caption2).foregroundStyle(fitPtColor),
|
|
at: CGPoint(x: rsScr.x, y: rsScr.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: - Nyquist fit
|
|
|
|
struct CircleFitResult {
|
|
let cx: Double
|
|
let cy: Double
|
|
let r: Double
|
|
}
|
|
|
|
struct LinearFitResult {
|
|
let slope: Double
|
|
let yIntercept: Double
|
|
let rs: Double
|
|
}
|
|
|
|
enum NyquistFitResult {
|
|
case circle(CircleFitResult)
|
|
case linear(LinearFitResult)
|
|
}
|
|
|
|
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..<all.count {
|
|
var pts = Array(all[start...])
|
|
while pts.count >= 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))
|
|
}
|
|
|
|
private func cumulativeTurning(_ pts: [(Double, Double)]) -> Double {
|
|
guard pts.count >= 3 else { return 0 }
|
|
var total = 0.0
|
|
for i in 1..<(pts.count - 1) {
|
|
let (dx1, dy1) = (pts[i].0 - pts[i-1].0, pts[i].1 - pts[i-1].1)
|
|
let (dx2, dy2) = (pts[i+1].0 - pts[i].0, pts[i+1].1 - pts[i].1)
|
|
let cross = dx1 * dy2 - dy1 * dx2
|
|
let dot = dx1 * dx2 + dy1 * dy2
|
|
total += abs(atan2(cross, dot))
|
|
}
|
|
return total
|
|
}
|
|
|
|
private func fitLinear(_ pts: [(Double, Double)]) -> LinearFitResult? {
|
|
guard pts.count >= 2 else { return nil }
|
|
let n = Double(pts.count)
|
|
let sx = pts.map(\.0).reduce(0, +)
|
|
let sy = pts.map(\.1).reduce(0, +)
|
|
let sx2 = pts.map { $0.0 * $0.0 }.reduce(0, +)
|
|
let sxy = pts.map { $0.0 * $0.1 }.reduce(0, +)
|
|
let denom = n * sx2 - sx * sx
|
|
guard abs(denom) > 1e-20 else { return nil }
|
|
let slope = (n * sxy - sx * sy) / denom
|
|
let yInt = (sy - slope * sx) / n
|
|
let rs = abs(slope) > 1e-10 ? -yInt / slope : sx / n
|
|
return LinearFitResult(slope: slope, yIntercept: yInt, rs: rs)
|
|
}
|
|
|
|
func fitNyquist(points: [(Double, Double)]) -> NyquistFitResult? {
|
|
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
|
guard all.count >= 4 else { return nil }
|
|
|
|
if cumulativeTurning(all) < 0.524 {
|
|
if let lin = fitLinear(all) { return .linear(lin) }
|
|
}
|
|
|
|
if let circle = kasaCircleFit(points: points) {
|
|
let avgErr = all.map { p in
|
|
abs(sqrt((p.0 - circle.cx) * (p.0 - circle.cx) +
|
|
(p.1 - circle.cy) * (p.1 - circle.cy)) - circle.r)
|
|
}.reduce(0, +) / (Double(all.count) * circle.r)
|
|
if avgErr > 0.15 {
|
|
if let lin = fitLinear(all) { return .linear(lin) }
|
|
}
|
|
return .circle(circle)
|
|
}
|
|
|
|
if let lin = fitLinear(all) { return .linear(lin) }
|
|
return nil
|
|
}
|
|
|
|
// 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<String>, 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<Item: Hashable & Identifiable>: View {
|
|
let label: String
|
|
@Binding var selection: Item
|
|
let items: [Item]
|
|
let itemLabel: (Item) -> String
|
|
|
|
init(_ label: String, selection: Binding<Item>, 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<Leading: View, Trailing: View>: 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))
|
|
}
|
|
}
|
|
}
|
|
}
|