db844d5e80
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
150 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|