diff --git a/cue-ios/CueIOS/Views/ChlorineView.swift b/cue-ios/CueIOS/Views/ChlorineView.swift index 9e2ba0b..66ad192 100644 --- a/cue-ios/CueIOS/Views/ChlorineView.swift +++ b/cue-ios/CueIOS/Views/ChlorineView.swift @@ -7,11 +7,13 @@ struct ChlorineView: View { var body: some View { VStack(spacing: 0) { controlsRow + clPeakLabels Divider() GeometryReader { geo in if geo.size.width > 700 { HSplitLayout(ratio: 0.55) { VStack(spacing: 4) { + voltammogramPlot resultBanner chlorinePlot } @@ -21,8 +23,9 @@ struct ChlorineView: View { } else { ScrollView { VStack(spacing: 12) { + voltammogramPlot.frame(height: 250) resultBanner - chlorinePlot.frame(height: 350) + chlorinePlot.frame(height: 250) clTable.frame(height: 300) } .padding() @@ -37,6 +40,21 @@ struct ChlorineView: View { private var controlsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { + 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) + + Divider().frame(height: 24) + LabeledField("Cond mV", text: $state.clCondV, width: 70) LabeledField("Cond ms", text: $state.clCondT, width: 70) LabeledField("Free mV", text: $state.clFreeV, width: 70) @@ -88,7 +106,102 @@ struct ChlorineView: View { } } - // MARK: - Plot + // MARK: - Peak labels + + @ViewBuilder + private var clPeakLabels: 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(clPeakColor(peak.kind)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + + private func clPeakColor(_ kind: PeakKind) -> Color { + switch kind { + case .freeCl: .green + case .totalCl: .orange + case .crossover: .purple + } + } + + // MARK: - Voltammogram + + private var voltammogramPlot: some View { + Group { + if state.lsvPoints.isEmpty { + Text("No LSV 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(clPeakColor(peak.kind)) + .symbolSize(100) + .symbol(.diamond) + RuleMark(x: .value("V", Double(peak.vMv))) + .foregroundStyle(clPeakColor(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)) + } + + // MARK: - Chlorine plot private var chlorinePlot: some View { Group {