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:
alexandrev-tibco
2026-07-03 23:09:35 +02:00
parent 7c92ea36c0
commit f7d4fdbe2d
7 changed files with 409 additions and 42 deletions
@@ -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]))
+115 -37
View File
@@ -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 {