Charts: puntos + labels de valor en todas las gráficas de líneas
- Nuevo ChartValueLabels.swift: ChartValueBubble (cápsula) y ChartSelectionCard
(tarjeta multi-serie), con umbral compartido (<=12 puntos → labels SIEMPRE
visibles; las gráficas cortas aportan mucho más con los valores a la vista)
- PointMark añadido donde faltaba (Compare, Year vs Year, Drawdown, Volatility);
agrandado donde ya existía (Evolution, Prediction, Period vs Period, Rolling)
- Selección por tap/arrastre (chartXSelection) en las 8 gráficas: RuleMark + burbuja
prominente (una serie) o tarjeta con el valor de cada serie (multi-serie)
- Evolution reutiliza su scrubbing existente y ahora muestra burbuja en el punto
- Period vs Period: labels A arriba / B abajo para evitar solapes
- Drawdown/Volatility migrados de Chart(data:) a Chart{ForEach} para poder añadir
marcas de selección
Verificado en iPad Pro 13" (labels visibles en Evolution y Period vs Period).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user