Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartZoom.swift
T
alexandrev-tibco db844d5e80 Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento
Charts iPad (rediseño):
- Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max
  Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre
  los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a
  calculateMetrics producía KPIs absurdos)
- Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección:
  Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad
- Selector de periodo como Picker segmentado nativo en la cabecera del chart
  (fuera del sidebar); título + descripción del chart visibles
- Eliminado sheet de paywall duplicado del layout iPad

Zoom (todas las series temporales):
- Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el
  único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom
- Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility

Focus Mode eliminado (todo siempre disponible):
- Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage
- Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList
- Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in
- PeriodReturnsCard siempre visible

Rendimiento y bugs (de la auditoría):
- El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown
- calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación
  lineal sobreestimaba con historiales cortos)
- Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación
  real de las asignaciones simuladas del usuario
- UITest de captura de Charts con manejo del alert de notificaciones

12 strings nuevas localizadas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 10:17:10 +02:00

150 lines
5.5 KiB
Swift

import SwiftUI
import Charts
// MARK: - Time-series chart zoom
//
// Shared zoom behavior for every chart with a Date X axis. Default state is
// "fit all" (no behavior change); zooming in narrows the visible X domain and
// enables horizontal scrolling so the user can pan through history. Zoom is
// driven by pinch (magnification) and by explicit +/- buttons for
// discoverability and accessibility (HIG: never make gestures the only way).
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
struct ChartZoomModel {
/// Currently visible X-domain length in seconds; nil = show everything.
var visibleSpan: TimeInterval?
/// Span captured when a pinch gesture begins.
var pinchAnchor: TimeInterval?
init() {
visibleSpan = nil
pinchAnchor = nil
}
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
private static let zoomStep = 0.6 // per button tap
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
let upper = max(fullSpan, Self.minSpan)
let clamped = min(max(span, Self.minSpan), upper)
// Snap back to fit-all when zoomed (almost) fully out.
return clamped >= upper * 0.98 ? nil : clamped
}
mutating func zoomIn(fullSpan: TimeInterval) {
let current = visibleSpan ?? fullSpan
visibleSpan = clamped(current * Self.zoomStep, fullSpan: fullSpan)
}
mutating func zoomOut(fullSpan: TimeInterval) {
guard let current = visibleSpan else { return }
visibleSpan = clamped(current / Self.zoomStep, fullSpan: fullSpan)
}
mutating func reset() {
visibleSpan = nil
}
}
extension View {
/// Applies zoomable behavior to a Chart with a Date X axis.
/// - Parameters:
/// - dates: the full set of X values (used to compute the total span)
/// - zoom: binding to the chart's zoom state
@ViewBuilder
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
let fullSpan = ChartZoomHelper.fullSpan(of: dates)
self
.modifier(ZoomDomainModifier(zoom: zoom, fullSpan: fullSpan))
.simultaneousGesture(
MagnifyGesture()
.onChanged { value in
var model = zoom.wrappedValue
if model.pinchAnchor == nil {
model.pinchAnchor = model.visibleSpan ?? fullSpan
}
if let anchor = model.pinchAnchor, value.magnification > 0 {
model.visibleSpan = model.clamped(anchor / value.magnification, fullSpan: fullSpan)
}
zoom.wrappedValue = model
}
.onEnded { _ in
zoom.wrappedValue.pinchAnchor = nil
}
)
.overlay(alignment: .topTrailing) {
ChartZoomControls(zoom: zoom, fullSpan: fullSpan)
}
}
}
enum ChartZoomHelper {
static func fullSpan(of dates: [Date]) -> TimeInterval {
guard let min = dates.min(), let max = dates.max(), max > min else {
return ChartZoomModel.minSpan
}
return max.timeIntervalSince(min)
}
}
private struct ZoomDomainModifier: ViewModifier {
@Binding var zoom: ChartZoomModel
let fullSpan: TimeInterval
func body(content: Content) -> some View {
if let span = zoom.visibleSpan {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
} else {
content
}
}
}
/// Compact +/- (and reset when zoomed) control cluster shown on top of the chart.
struct ChartZoomControls: View {
@Binding var zoom: ChartZoomModel
let fullSpan: TimeInterval
var body: some View {
HStack(spacing: 0) {
controlButton(systemName: "minus.magnifyingglass", enabled: zoom.visibleSpan != nil) {
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomOut(fullSpan: fullSpan) }
}
.accessibilityLabel(String(localized: "chart_zoom_out"))
Divider().frame(height: 16)
controlButton(systemName: "plus.magnifyingglass",
enabled: (zoom.visibleSpan ?? fullSpan) > ChartZoomModel.minSpan) {
withAnimation(.easeOut(duration: 0.15)) { zoom.zoomIn(fullSpan: fullSpan) }
}
.accessibilityLabel(String(localized: "chart_zoom_in"))
if zoom.visibleSpan != nil {
Divider().frame(height: 16)
controlButton(systemName: "arrow.counterclockwise", enabled: true) {
withAnimation(.easeOut(duration: 0.15)) { zoom.reset() }
}
.accessibilityLabel(String(localized: "chart_zoom_reset"))
}
}
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
.padding(6)
}
private func controlButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 13, weight: .medium))
.foregroundColor(enabled ? .appPrimary : .secondary.opacity(0.4))
.frame(width: 32, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
}