Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/DrawdownChart.swift
T
alexandrev-tibco f7d4fdbe2d 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
2026-07-03 23:09:35 +02:00

424 lines
16 KiB
Swift

import SwiftUI
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)
}
var currentDrawdown: Double {
abs(data.last?.drawdown ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart {
ForEach(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
)
)
.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))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Statistics
HStack(spacing: 20) {
DrawdownStatView(
title: "Current",
value: String(format: "%.1f%%", currentDrawdown),
isHighlighted: currentDrawdown > maxDrawdown * 0.8
)
DrawdownStatView(
title: "Maximum",
value: String(format: "%.1f%%", maxDrawdown),
isHighlighted: true
)
DrawdownStatView(
title: "Average",
value: String(format: "%.1f%%", averageDrawdown),
isHighlighted: false
)
}
.padding(.top, 8)
ChartDataTable(
rows: drawdownTableRows,
valueHeader: "Drawdown",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
.padding(.top, 4)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var averageDrawdown: Double {
guard !data.isEmpty else { return 0 }
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
return sum / Double(data.count)
}
private var drawdownTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let val = abs(point.drawdown)
let prevVal = idx > 0 ? abs(data[idx - 1].drawdown) : val
let firstVal = abs(data.first?.drawdown ?? val)
let deltaPrev = -(val - prevVal) // negative delta = improvement (less drawdown)
let deltaFirst = -(val - firstVal)
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", val),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
struct DrawdownStatView: View {
let title: String
let value: String
let isHighlighted: Bool
var body: some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Volatility Chart 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
}
var averageVolatility: Double {
guard !data.isEmpty else { return 0 }
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
Spacer()
}
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart {
ForEach(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
)
)
.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))
.foregroundStyle(Color.gray.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.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))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: [
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
ChartStat(label: "Max", value: String(format: "%.1f%%", data.map { $0.volatility }.max() ?? 0), color: .negativeRed),
ChartStat(label: "Min", value: String(format: "%.1f%%", data.map { $0.volatility }.min() ?? 0), color: .positiveGreen),
ChartStat(label: "Average", value: String(format: "%.1f%%", averageVolatility), color: .secondary),
])
.padding(.top, 8)
ChartDataTable(
rows: volatilityTableRows,
valueHeader: "Volatility",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: nil
)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func volatilityColor(_ volatility: Double) -> Color {
switch volatility {
case 0..<10:
return .positiveGreen
case 10..<20:
return .appWarning
default:
return .negativeRed
}
}
private var volatilityTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].volatility : point.volatility
let delta = point.volatility - prevVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.volatility),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", delta) : nil,
deltaFirst: nil,
isPrevPositive: delta <= 0,
isFirstPositive: false
)
}
}
}
struct VolatilityLevelView: View {
let level: String
let range: String
let color: Color
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 0) {
Text(level)
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
#Preview {
let drawdownData: [(date: Date, drawdown: Double)] = [
(Date().adding(months: -6), -5),
(Date().adding(months: -5), -8),
(Date().adding(months: -4), -3),
(Date().adding(months: -3), -15),
(Date().adding(months: -2), -10),
(Date().adding(months: -1), -7),
(Date(), -4)
]
let volatilityData: [(date: Date, volatility: Double)] = [
(Date().adding(months: -6), 12),
(Date().adding(months: -5), 15),
(Date().adding(months: -4), 10),
(Date().adding(months: -3), 22),
(Date().adding(months: -2), 18),
(Date().adding(months: -1), 14),
(Date(), 11)
]
return VStack(spacing: 20) {
DrawdownChart(data: drawdownData)
VolatilityChartView(data: volatilityData)
}
.padding()
}