import SwiftUI import Charts struct ComparisonChartView: View { @State private var selectedDate: Date? private static let selectionDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "MMM yyyy" return f }() @ObservedObject var viewModel: ChartsViewModel var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Compare Sources") .font(.headline) sourceSelector Picker("", selection: $viewModel.comparisonDisplayMode) { ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.segmented) chartContent } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } // MARK: - Source Selector private var sourceSelector: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id) let chipColor = Color(hex: ChartsViewModel.sourceColorHexesPublic[index % ChartsViewModel.sourceColorHexesPublic.count]) ?? .appPrimary Button { if isSelected { viewModel.comparisonSelectedSourceIds.remove(source.id) } else { viewModel.comparisonSelectedSourceIds.insert(source.id) } } label: { HStack(spacing: 6) { Circle() .fill(chipColor) .frame(width: 8, height: 8) Text(source.name) .font(.caption.weight(.medium)) .lineLimit(1) } .padding(.horizontal, 12) .padding(.vertical, 7) .background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5) ) .cornerRadius(16) } .buttonStyle(.plain) } } .padding(.vertical, 2) } } // MARK: - Chart Content @ViewBuilder private var chartContent: some View { if viewModel.comparisonSelectedSourceIds.isEmpty { emptySelectionView } else if viewModel.comparisonData.isEmpty { noDataView } else { lineChart } } private var emptySelectionView: some View { VStack(spacing: 12) { Image(systemName: "chart.xyaxis.line") .font(.system(size: 36)) .foregroundColor(.secondary) Text("Select 2+ sources to compare") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .frame(height: 260) } private var noDataView: some View { Text("No data available for selected sources in this period.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .frame(height: 260) } /// Permanent labels only when the chart stays readable (few points AND few series). private var showAllLabels: Bool { let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0 return maxPoints <= ChartLabels.alwaysShowThreshold && viewModel.comparisonData.count <= 3 } private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] { viewModel.comparisonData.compactMap { series in guard let point = series.points.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) else { return nil } return (series.name, yAxisLabel(for: point.value), Color(hex: series.colorHex) ?? .appPrimary) } } private var lineChart: some View { VStack(spacing: 12) { Chart { ForEach(viewModel.comparisonData, id: \.id) { series in let seriesColor = Color(hex: series.colorHex) ?? .appPrimary ForEach(series.points, id: \.date) { point in LineMark( x: .value("Date", point.date), y: .value("Value", point.value), series: .value("Source", series.name) ) .foregroundStyle(seriesColor) .interpolationMethod(.catmullRom) PointMark( x: .value("Date", point.date), y: .value("Value", point.value) ) .foregroundStyle(seriesColor) .symbolSize(showAllLabels ? 32 : 16) .annotation(position: .top, spacing: 2) { if showAllLabels { ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor) } } } } if let selectedDate { RuleMark(x: .value("Selected", selectedDate)) .foregroundStyle(Color.secondary.opacity(0.35)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .annotation(position: .top, spacing: 4) { ChartSelectionCard( title: Self.selectionDateFormatter.string(from: selectedDate), rows: selectionRows(for: selectedDate) ) } } } .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3])) .foregroundStyle(Color.secondary.opacity(0.2)) AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) .font(.caption2) } } .chartYAxis { AxisMarks(position: .leading) { value in AxisValueLabel { if let d = value.as(Double.self) { Text(yAxisLabel(for: d)) .font(.caption2) } } AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3])) .foregroundStyle(Color.secondary.opacity(0.15)) } } .frame(height: 260) .drawingGroup() legend } } private var legend: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(viewModel.comparisonData, id: \.id) { series in HStack(spacing: 6) { RoundedRectangle(cornerRadius: 2) .fill(Color(hex: series.colorHex) ?? .appPrimary) .frame(width: 20, height: 3) Text(series.name) .font(.caption) .foregroundColor(.secondary) } } } } } // MARK: - Helpers private var xAxisStride: Int { let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0 switch maxPoints { case ...6: return 1 case ...12: return 2 case ...24: return 3 default: return 6 } } private func yAxisLabel(for value: Double) -> String { switch viewModel.comparisonDisplayMode { case .indexed: return String(format: "%.0f", value) case .returnPct: return String(format: "%.1f%%", value) case .absolute: return Decimal(value).shortCurrencyString case .monthlyReturn: return String(format: "%.1f%%", value) } } }