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)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.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(
|
AreaMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
@@ -1049,6 +1054,22 @@ struct EvolutionChartView: View {
|
|||||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
.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] {
|
private var chartCategoryNames: [String] {
|
||||||
@@ -1139,6 +1160,15 @@ struct ContributionsChartView: View {
|
|||||||
struct RollingReturnChartView: View {
|
struct RollingReturnChartView: View {
|
||||||
let data: [(date: Date, value: Double)]
|
let data: [(date: Date, value: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -1164,9 +1194,30 @@ struct RollingReturnChartView: View {
|
|||||||
y: .value("Return", item.value)
|
y: .value("Return", item.value)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import SwiftUI
|
|||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
struct ComparisonChartView: View {
|
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
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +110,21 @@ struct ComparisonChartView: View {
|
|||||||
.frame(height: 260)
|
.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 {
|
private var lineChart: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Chart {
|
Chart {
|
||||||
@@ -115,9 +138,34 @@ struct ComparisonChartView: View {
|
|||||||
)
|
)
|
||||||
.foregroundStyle(seriesColor)
|
.foregroundStyle(seriesColor)
|
||||||
.interpolationMethod(.catmullRom)
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
|
||||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import Charts
|
|||||||
struct DrawdownChart: View {
|
struct DrawdownChart: View {
|
||||||
let data: [(date: Date, drawdown: Double)]
|
let data: [(date: Date, drawdown: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@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 {
|
var maxDrawdown: Double {
|
||||||
abs(data.map { $0.drawdown }.min() ?? 0)
|
abs(data.map { $0.drawdown }.min() ?? 0)
|
||||||
@@ -36,27 +45,57 @@ struct DrawdownChart: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
if data.count >= 2 {
|
if data.count >= 2 {
|
||||||
Chart(data, id: \.date) { item in
|
Chart {
|
||||||
AreaMark(
|
ForEach(data, id: \.date) { item in
|
||||||
x: .value("Date", item.date),
|
AreaMark(
|
||||||
y: .value("Drawdown", item.drawdown)
|
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
|
|
||||||
)
|
)
|
||||||
)
|
.foregroundStyle(
|
||||||
.interpolationMethod(.catmullRom)
|
LinearGradient(
|
||||||
|
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
|
||||||
LineMark(
|
LineMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date),
|
||||||
y: .value("Drawdown", item.drawdown)
|
y: .value("Drawdown", item.drawdown)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.negativeRed)
|
.foregroundStyle(Color.negativeRed)
|
||||||
.interpolationMethod(.catmullRom)
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||||
@@ -167,6 +206,15 @@ struct DrawdownStatView: View {
|
|||||||
struct VolatilityChartView: View {
|
struct VolatilityChartView: View {
|
||||||
let data: [(date: Date, volatility: Double)]
|
let data: [(date: Date, volatility: Double)]
|
||||||
@State private var zoom = ChartZoomModel()
|
@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 {
|
var currentVolatility: Double {
|
||||||
data.last?.volatility ?? 0
|
data.last?.volatility ?? 0
|
||||||
@@ -191,26 +239,40 @@ struct VolatilityChartView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
if data.count >= 2 {
|
if data.count >= 2 {
|
||||||
Chart(data, id: \.date) { item in
|
Chart {
|
||||||
LineMark(
|
ForEach(data, id: \.date) { item in
|
||||||
x: .value("Date", item.date),
|
LineMark(
|
||||||
y: .value("Volatility", item.volatility)
|
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
|
|
||||||
)
|
)
|
||||||
)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.interpolationMethod(.catmullRom)
|
.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
|
// Average line
|
||||||
RuleMark(y: .value("Average", averageVolatility))
|
RuleMark(y: .value("Average", averageVolatility))
|
||||||
@@ -221,7 +283,23 @@ struct VolatilityChartView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import SwiftUI
|
|||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
struct PeriodComparisonChartView: View {
|
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
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
|
||||||
private let colorA = Color(hex: "#3478F6") ?? .blue
|
private let colorA = Color(hex: "#3478F6") ?? .blue
|
||||||
@@ -225,13 +239,35 @@ struct PeriodComparisonChartView: View {
|
|||||||
y: .value("Return", point.returnPct)
|
y: .value("Return", point.returnPct)
|
||||||
)
|
)
|
||||||
.foregroundStyle(by: .value("Period", point.seriesLabel))
|
.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))
|
RuleMark(y: .value("Zero", 0))
|
||||||
.foregroundStyle(Color.secondary.opacity(0.4))
|
.foregroundStyle(Color.secondary.opacity(0.4))
|
||||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 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(
|
.chartForegroundStyleScale(
|
||||||
domain: viewModel.periodComparisonData.map { $0.label },
|
domain: viewModel.periodComparisonData.map { $0.label },
|
||||||
range: seriesColors
|
range: seriesColors
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import Charts
|
|||||||
struct PredictionChartView: View {
|
struct PredictionChartView: View {
|
||||||
let predictions: [Prediction]
|
let predictions: [Prediction]
|
||||||
let historicalData: [(date: Date, value: Decimal)]
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@@ -47,7 +63,12 @@ struct PredictionChartView: View {
|
|||||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundStyle(Color.appPrimary)
|
||||||
.symbolSize(24)
|
.symbolSize(showAllLabels ? 36 : 24)
|
||||||
|
.annotation(position: .top, spacing: 3) {
|
||||||
|
if showAllLabels {
|
||||||
|
ChartValueBubble(text: item.value.compactCurrencyString)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confidence interval area
|
// Confidence interval area
|
||||||
@@ -74,7 +95,12 @@ struct PredictionChartView: View {
|
|||||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||||
)
|
)
|
||||||
.foregroundStyle(Color.appSecondary)
|
.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)
|
// Bull scenario line (upper confidence interval)
|
||||||
@@ -118,7 +144,27 @@ struct PredictionChartView: View {
|
|||||||
.foregroundStyle(Color.appSecondary)
|
.foregroundStyle(Color.appSecondary)
|
||||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Charts
|
|||||||
|
|
||||||
struct YearOverYearChartView: View {
|
struct YearOverYearChartView: View {
|
||||||
@ObservedObject var viewModel: ChartsViewModel
|
@ObservedObject var viewModel: ChartsViewModel
|
||||||
|
@State private var selectedMonthIdx: Int?
|
||||||
|
|
||||||
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||||
@@ -92,6 +93,18 @@ struct YearOverYearChartView: View {
|
|||||||
|
|
||||||
// MARK: - Chart
|
// 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
|
@ViewBuilder
|
||||||
private var chartBody: some View {
|
private var chartBody: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
@@ -99,6 +112,7 @@ struct YearOverYearChartView: View {
|
|||||||
ForEach(selectedSeries) { yearSeries in
|
ForEach(selectedSeries) { yearSeries in
|
||||||
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||||
let color = lineColors[idx % lineColors.count]
|
let color = lineColors[idx % lineColors.count]
|
||||||
|
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
|
||||||
ForEach(0..<12, id: \.self) { monthIdx in
|
ForEach(0..<12, id: \.self) { monthIdx in
|
||||||
let value = yearSeries.values[monthIdx]
|
let value = yearSeries.values[monthIdx]
|
||||||
if !value.isNaN {
|
if !value.isNaN {
|
||||||
@@ -109,10 +123,35 @@ struct YearOverYearChartView: View {
|
|||||||
)
|
)
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.interpolationMethod(.catmullRom)
|
.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 {
|
.chartXAxis {
|
||||||
AxisMarks(values: Array(0..<12)) { value in
|
AxisMarks(values: Array(0..<12)) { value in
|
||||||
AxisValueLabel {
|
AxisValueLabel {
|
||||||
|
|||||||
Reference in New Issue
Block a user