190 lines
7.2 KiB
Swift
190 lines
7.2 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct LSVView: View {
|
|
@Bindable var state: AppState
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
controlsRow
|
|
peakLabels
|
|
Divider()
|
|
GeometryReader { geo in
|
|
if geo.size.width > 700 {
|
|
HSplitLayout(ratio: 0.55) {
|
|
voltammogramPlot
|
|
} trailing: {
|
|
lsvTable
|
|
}
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
voltammogramPlot.frame(height: 350)
|
|
lsvTable.frame(height: 300)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Controls
|
|
|
|
private var controlsRow: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
LabeledField("Start mV", text: $state.lsvStartV, width: 80)
|
|
LabeledField("Stop mV", text: $state.lsvStopV, width: 80)
|
|
LabeledField("Scan mV/s", text: $state.lsvScanRate, width: 80)
|
|
|
|
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
|
.frame(width: 120)
|
|
|
|
Button("Start LSV") { state.startLSV() }
|
|
.buttonStyle(ActionButtonStyle(color: .green))
|
|
|
|
Button(state.lsvManualPeaks ? "Manual" : "Auto") {
|
|
state.lsvManualPeaks.toggle()
|
|
if state.lsvManualPeaks {
|
|
state.lsvPeaks.removeAll()
|
|
} else {
|
|
state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Plot
|
|
|
|
private var voltammogramPlot: some View {
|
|
Group {
|
|
if state.lsvPoints.isEmpty {
|
|
Text("No 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(peakColor(peak.kind))
|
|
.symbolSize(100)
|
|
.symbol(.diamond)
|
|
RuleMark(x: .value("V", Double(peak.vMv)))
|
|
.foregroundStyle(peakColor(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))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var peakLabels: 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(peakColor(peak.kind))
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
private func peakColor(_ kind: PeakKind) -> Color {
|
|
switch kind {
|
|
case .freeCl: .green
|
|
case .totalCl: .orange
|
|
case .crossover: .purple
|
|
}
|
|
}
|
|
|
|
// MARK: - Table
|
|
|
|
private var lsvTable: some View {
|
|
MeasurementTable(
|
|
columns: [
|
|
MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing),
|
|
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
|
],
|
|
rows: state.lsvPoints,
|
|
cellText: { pt, col in
|
|
switch col {
|
|
case 0: String(format: "%.1f", pt.vMv)
|
|
case 1: String(format: "%.3f", pt.iUa)
|
|
default: ""
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|