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