diff --git a/PortfolioJournal/Views/Charts/ChartValueLabels.swift b/PortfolioJournal/Views/Charts/ChartValueLabels.swift new file mode 100644 index 0000000..5e23ca5 --- /dev/null +++ b/PortfolioJournal/Views/Charts/ChartValueLabels.swift @@ -0,0 +1,69 @@ +import SwiftUI +import Charts + +// MARK: - Point value labels for line charts +// +// Shared pieces for the "points + value labels" behavior across every line chart +// in the Charts tab: permanent labels when the series is short (they carry a lot +// of value at a glance), and a tap/drag selection bubble when it's dense. + +enum ChartLabels { + /// A series with at most this many points shows its value labels permanently. + static let alwaysShowThreshold = 12 + + static func compactCurrency(_ value: Decimal) -> String { + value.compactCurrencyString + } + + static func percent(_ value: Double, decimals: Int = 1) -> String { + String(format: "%+.\(decimals)f%%", value) + } +} + +/// Small capsule bubble used both for permanent point labels and the selection popover. +struct ChartValueBubble: View { + let text: String + var color: Color = .appPrimary + var prominent: Bool = false + + var body: some View { + Text(text) + .font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold)) + .foregroundColor(prominent ? .white : color) + .padding(.horizontal, prominent ? 8 : 5) + .padding(.vertical, prominent ? 4 : 2) + .background( + Capsule().fill(prominent ? color : color.opacity(0.12)) + ) + .fixedSize() + } +} + +/// Multi-line selection card (one row per series) used by multi-series charts. +struct ChartSelectionCard: View { + let title: String + let rows: [(label: String, value: String, color: Color)] + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption2.weight(.semibold)) + .foregroundColor(.secondary) + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 5) { + Circle().fill(row.color).frame(width: 6, height: 6) + Text(row.label) + .font(.caption2) + .foregroundColor(.secondary) + Text(row.value) + .font(.caption2.weight(.semibold)) + .foregroundColor(.primary) + } + } + } + .padding(8) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.2), lineWidth: 0.5)) + .fixedSize() + } +} diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 019903d..0312114 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -999,7 +999,12 @@ struct EvolutionChartView: View { y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) - .symbolSize(30) + .symbolSize(data.count <= ChartLabels.alwaysShowThreshold ? 40 : 30) + .annotation(position: .top, spacing: 3) { + if data.count <= ChartLabels.alwaysShowThreshold { + ChartValueBubble(text: item.value.compactCurrencyString) + } + } AreaMark( x: .value("Date", item.date), @@ -1049,6 +1054,22 @@ struct EvolutionChartView: View { .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4])) } } + + // Scrub/tap selection bubble (total mode) + if chartMode == .total, let sel = selectedDataPoint { + RuleMark(x: .value("Selected", sel.date)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + PointMark( + x: .value("Selected", sel.date), + y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .symbolSize(80) + .annotation(position: .top, spacing: 4) { + ChartValueBubble(text: sel.value.compactCurrencyString, prominent: true) + } + } } private var chartCategoryNames: [String] { @@ -1139,6 +1160,15 @@ struct ContributionsChartView: View { struct RollingReturnChartView: View { let data: [(date: Date, value: Double)] @State private var zoom = ChartZoomModel() + @State private var selectedDate: Date? + + private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var selectedPoint: (date: Date, value: Double)? { + guard let selectedDate else { return nil } + return data.min(by: { + abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) + }) + } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -1164,9 +1194,30 @@ struct RollingReturnChartView: View { y: .value("Return", item.value) ) .foregroundStyle(Color.appPrimary) - .symbolSize(24) + .symbolSize(showAllLabels ? 36 : 24) + .annotation(position: item.value >= 0 ? .top : .bottom, spacing: 3) { + if showAllLabels { + ChartValueBubble(text: ChartLabels.percent(item.value)) + } + } + } + + if let sel = selectedPoint { + RuleMark(x: .value("Selected", sel.date)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + PointMark( + x: .value("Selected", sel.date), + y: .value("Return", sel.value) + ) + .foregroundStyle(Color.appPrimary) + .symbolSize(70) + .annotation(position: sel.value >= 0 ? .top : .bottom, spacing: 4) { + ChartValueBubble(text: ChartLabels.percent(sel.value), prominent: true) + } } } + .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) diff --git a/PortfolioJournal/Views/Charts/ComparisonChartView.swift b/PortfolioJournal/Views/Charts/ComparisonChartView.swift index 785404e..113aae9 100644 --- a/PortfolioJournal/Views/Charts/ComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/ComparisonChartView.swift @@ -2,6 +2,14 @@ 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 @@ -102,6 +110,21 @@ struct ComparisonChartView: View { .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 { @@ -115,9 +138,34 @@ struct ComparisonChartView: View { ) .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])) diff --git a/PortfolioJournal/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift index 0a3ed3a..2b64fef 100644 --- a/PortfolioJournal/Views/Charts/DrawdownChart.swift +++ b/PortfolioJournal/Views/Charts/DrawdownChart.swift @@ -4,6 +4,15 @@ import Charts struct DrawdownChart: View { let data: [(date: Date, drawdown: Double)] @State private var zoom = ChartZoomModel() + @State private var selectedDate: Date? + + private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var selectedPoint: (date: Date, drawdown: Double)? { + guard let selectedDate else { return nil } + return data.min(by: { + abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) + }) + } var maxDrawdown: Double { abs(data.map { $0.drawdown }.min() ?? 0) @@ -36,27 +45,57 @@ struct DrawdownChart: View { .foregroundColor(.secondary) if data.count >= 2 { - Chart(data, id: \.date) { item in - AreaMark( - x: .value("Date", item.date), - y: .value("Drawdown", item.drawdown) - ) - .foregroundStyle( - LinearGradient( - colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)], - startPoint: .top, - endPoint: .bottom + Chart { + ForEach(data, id: \.date) { item in + AreaMark( + x: .value("Date", item.date), + y: .value("Drawdown", item.drawdown) ) - ) - .interpolationMethod(.catmullRom) + .foregroundStyle( + LinearGradient( + colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) - LineMark( - x: .value("Date", item.date), - y: .value("Drawdown", item.drawdown) - ) - .foregroundStyle(Color.negativeRed) - .interpolationMethod(.catmullRom) + LineMark( + x: .value("Date", item.date), + y: .value("Drawdown", item.drawdown) + ) + .foregroundStyle(Color.negativeRed) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Date", item.date), + y: .value("Drawdown", item.drawdown) + ) + .foregroundStyle(Color.negativeRed) + .symbolSize(showAllLabels ? 36 : 20) + .annotation(position: .bottom, spacing: 3) { + if showAllLabels { + ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed) + } + } + } + + if let sel = selectedPoint { + RuleMark(x: .value("Selected", sel.date)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + PointMark( + x: .value("Selected", sel.date), + y: .value("Drawdown", sel.drawdown) + ) + .foregroundStyle(Color.negativeRed) + .symbolSize(70) + .annotation(position: .bottom, spacing: 4) { + ChartValueBubble(text: ChartLabels.percent(sel.drawdown), color: .negativeRed, prominent: true) + } + } } + .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) @@ -167,6 +206,15 @@ struct DrawdownStatView: View { struct VolatilityChartView: View { let data: [(date: Date, volatility: Double)] @State private var zoom = ChartZoomModel() + @State private var selectedDate: Date? + + private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold } + private var selectedPoint: (date: Date, volatility: Double)? { + guard let selectedDate else { return nil } + return data.min(by: { + abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) + }) + } var currentVolatility: Double { data.last?.volatility ?? 0 @@ -191,26 +239,40 @@ struct VolatilityChartView: View { .foregroundColor(.secondary) if data.count >= 2 { - Chart(data, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Volatility", item.volatility) - ) - .foregroundStyle(Color.appPrimary) - .interpolationMethod(.catmullRom) - - AreaMark( - x: .value("Date", item.date), - y: .value("Volatility", item.volatility) - ) - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], - startPoint: .top, - endPoint: .bottom + Chart { + ForEach(data, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Volatility", item.volatility) ) - ) - .interpolationMethod(.catmullRom) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Volatility", item.volatility) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Date", item.date), + y: .value("Volatility", item.volatility) + ) + .foregroundStyle(Color.appPrimary) + .symbolSize(showAllLabels ? 36 : 20) + .annotation(position: .top, spacing: 3) { + if showAllLabels { + ChartValueBubble(text: String(format: "%.1f%%", item.volatility)) + } + } + } // Average line RuleMark(y: .value("Average", averageVolatility)) @@ -221,7 +283,23 @@ struct VolatilityChartView: View { .font(.caption2) .foregroundColor(.secondary) } + + if let sel = selectedPoint { + RuleMark(x: .value("Selected", sel.date)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + PointMark( + x: .value("Selected", sel.date), + y: .value("Volatility", sel.volatility) + ) + .foregroundStyle(Color.appPrimary) + .symbolSize(70) + .annotation(position: .top, spacing: 4) { + ChartValueBubble(text: String(format: "%.1f%%", sel.volatility), prominent: true) + } + } } + .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 2)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated)) diff --git a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift index 65f357d..deb9e4d 100644 --- a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift @@ -2,6 +2,20 @@ import SwiftUI import Charts struct PeriodComparisonChartView: View { + @State private var selectedMonth: Int? + + /// Period charts are short by design (a handful of months) — labels always help. + private var showAllLabels: Bool { + (viewModel.periodComparisonData.map { $0.points.count }.max() ?? 0) <= ChartLabels.alwaysShowThreshold + } + + private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] { + viewModel.periodComparisonData.compactMap { series in + guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil } + return (series.label, ChartLabels.percent(point.returnPct), Color(hex: series.colorHex) ?? .gray) + } + } + @ObservedObject var viewModel: ChartsViewModel private let colorA = Color(hex: "#3478F6") ?? .blue @@ -225,13 +239,35 @@ struct PeriodComparisonChartView: View { y: .value("Return", point.returnPct) ) .foregroundStyle(by: .value("Period", point.seriesLabel)) - .symbolSize(30) + .symbolSize(showAllLabels ? 40 : 30) + .annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom, + spacing: 3) { + if showAllLabels { + ChartValueBubble( + text: ChartLabels.percent(point.returnPct), + color: Color(hex: point.colorHex) ?? .gray + ) + } + } } RuleMark(y: .value("Zero", 0)) .foregroundStyle(Color.secondary.opacity(0.4)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + + if let selectedMonth { + RuleMark(x: .value("Selected", selectedMonth)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + .annotation(position: .top, spacing: 4) { + ChartSelectionCard( + title: "M\(selectedMonth + 1)", + rows: selectionRows(for: selectedMonth) + ) + } + } } + .chartXSelection(value: $selectedMonth) .chartForegroundStyleScale( domain: viewModel.periodComparisonData.map { $0.label }, range: seriesColors diff --git a/PortfolioJournal/Views/Charts/PredictionChartView.swift b/PortfolioJournal/Views/Charts/PredictionChartView.swift index 0dea4c2..d4dcc09 100644 --- a/PortfolioJournal/Views/Charts/PredictionChartView.swift +++ b/PortfolioJournal/Views/Charts/PredictionChartView.swift @@ -4,6 +4,22 @@ import Charts struct PredictionChartView: View { let predictions: [Prediction] let historicalData: [(date: Date, value: Decimal)] + @State private var selectedDate: Date? + + private var showAllLabels: Bool { + historicalData.count + predictions.count <= ChartLabels.alwaysShowThreshold + } + + /// Nearest point (historical or predicted) to the selected date. + private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? { + guard let selectedDate else { return nil } + let all: [(date: Date, value: Decimal, isPrediction: Bool)] = + historicalData.map { ($0.date, $0.value, false) } + + predictions.map { ($0.date, $0.predictedValue, true) } + return all.min(by: { + abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate)) + }) + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -47,7 +63,12 @@ struct PredictionChartView: View { y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) ) .foregroundStyle(Color.appPrimary) - .symbolSize(24) + .symbolSize(showAllLabels ? 36 : 24) + .annotation(position: .top, spacing: 3) { + if showAllLabels { + ChartValueBubble(text: item.value.compactCurrencyString) + } + } } // Confidence interval area @@ -74,7 +95,12 @@ struct PredictionChartView: View { y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue) ) .foregroundStyle(Color.appSecondary) - .symbolSize(24) + .symbolSize(showAllLabels ? 36 : 24) + .annotation(position: .top, spacing: 3) { + if showAllLabels { + ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary) + } + } } // Bull scenario line (upper confidence interval) @@ -118,7 +144,27 @@ struct PredictionChartView: View { .foregroundStyle(Color.appSecondary) .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5])) } + + if let sel = selectedPoint { + RuleMark(x: .value("Selected", sel.date)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + PointMark( + x: .value("Selected", sel.date), + y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue) + ) + .foregroundStyle(sel.isPrediction ? Color.appSecondary : Color.appPrimary) + .symbolSize(80) + .annotation(position: .top, spacing: 4) { + ChartValueBubble( + text: sel.value.compactCurrencyString, + color: sel.isPrediction ? .appSecondary : .appPrimary, + prominent: true + ) + } + } } + .chartXSelection(value: $selectedDate) .chartXAxis { AxisMarks(values: .stride(by: .month, count: 3)) { value in AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) diff --git a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift index c4e00b2..79adb89 100644 --- a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift +++ b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift @@ -3,6 +3,7 @@ import Charts struct YearOverYearChartView: View { @ObservedObject var viewModel: ChartsViewModel + @State private var selectedMonthIdx: Int? private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] @@ -92,6 +93,18 @@ struct YearOverYearChartView: View { // MARK: - Chart + /// Two selected years × 12 months stays readable with permanent labels. + private var showAllLabels: Bool { selectedSeries.count <= 2 } + + private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] { + selectedSeries.compactMap { series in + let value = series.values[month] + guard !value.isNaN else { return nil } + let idx = allYears.firstIndex(where: { $0.id == series.id }) ?? 0 + return (String(series.year), ChartLabels.percent(value), lineColors[idx % lineColors.count]) + } + } + @ViewBuilder private var chartBody: some View { if #available(iOS 16.0, *) { @@ -99,6 +112,7 @@ struct YearOverYearChartView: View { ForEach(selectedSeries) { yearSeries in let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 let color = lineColors[idx % lineColors.count] + let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 ForEach(0..<12, id: \.self) { monthIdx in let value = yearSeries.values[monthIdx] if !value.isNaN { @@ -109,10 +123,35 @@ struct YearOverYearChartView: View { ) .foregroundStyle(color) .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Month", monthIdx), + y: .value("Return", value) + ) + .foregroundStyle(color) + .symbolSize(showAllLabels ? 32 : 18) + .annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) { + if showAllLabels { + ChartValueBubble(text: ChartLabels.percent(value), color: color) + } + } } } } + + if let selectedMonthIdx, selectedMonthIdx >= 0, selectedMonthIdx < 12 { + RuleMark(x: .value("Selected", selectedMonthIdx)) + .foregroundStyle(Color.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + .annotation(position: .top, spacing: 4) { + ChartSelectionCard( + title: monthAbbreviations[selectedMonthIdx], + rows: selectionRows(for: selectedMonthIdx) + ) + } + } } + .chartXSelection(value: $selectedMonthIdx) .chartXAxis { AxisMarks(values: Array(0..<12)) { value in AxisValueLabel {