Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartZoom.swift
T
alexandrev-tibco 680255f69d iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las
  size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o
  cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab.
- Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho
  regular, stack colapsable en compacto) con selección nativa de List.
- Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos
  en regular, sheet en compacto).
- Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:),
  sin ignoresSafeArea en contenido interactivo.
- Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00

423 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
import UIKit
// MARK: - Time-series chart zoom (window model)
//
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
// window center, native horizontal pan once zoomed, double-tap to reset.
//
// The DATA range is a separate, global concern: the range brush
// (`ChartRangeBrush`) lives in the period bar and drives
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
// of the chart content also keeps it out of the chart-swipe gesture's reach.
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
struct ChartZoomModel {
/// Visible X-domain length in seconds; nil = show everything.
var spanSeconds: TimeInterval?
/// Start of the visible window (chart scroll position).
var startX: Date = .distantPast
/// (span, center) captured when a pinch begins.
var pinchAnchor: (span: TimeInterval, center: Date)?
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
/// Padding after the last point so it isn't clipped at the window edge.
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
init() {}
var isZoomed: Bool { spanSeconds != nil }
/// The currently visible window, given the full data domain.
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
guard let first = dates.first, let last = dates.last else { return nil }
guard let span = spanSeconds else { return first...last }
let start = max(first, min(startX, last))
let end = min(last, start.addingTimeInterval(span))
return start <= end ? start...end : first...last
}
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
let upper = max(fullSpan + Self.endPadding, 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 reset() {
spanSeconds = nil
pinchAnchor = nil
}
}
// MARK: - Haptics
enum ChartHaptics {
private static let selection = UISelectionFeedbackGenerator()
private static let impact = UIImpactFeedbackGenerator(style: .light)
static func tick() { selection.selectionChanged() }
static func bump() { impact.impactOccurred() }
}
extension View {
/// Applies zoomable window behavior to a Chart with a Date X axis.
@ViewBuilder
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
}
}
private struct ZoomableTimeSeriesModifier: ViewModifier {
let dates: [Date]
@Binding var zoom: ChartZoomModel
private var fullSpan: TimeInterval {
guard let min = dates.min(), let max = dates.max(), max > min else {
return ChartZoomModel.minSpan
}
return max.timeIntervalSince(min)
}
func body(content: Content) -> some View {
Group {
if let span = zoom.spanSeconds {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
.chartScrollPosition(x: $zoom.startX)
} else {
content
}
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(doubleTapGesture)
}
// Pinch anchored on the window center: the point you're looking at stays put.
private var pinchGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
var model = zoom
if model.pinchAnchor == nil {
let window = model.visibleWindow(dates: dates)
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
let center = window.map {
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
} ?? Date()
model.pinchAnchor = (span, center)
}
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
model.spanSeconds = newSpan
if let newSpan, let first = dates.first, let last = dates.last {
let start = anchor.center.addingTimeInterval(-newSpan / 2)
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
model.startX = max(first, min(start, maxStart))
}
zoom = model
}
.onEnded { _ in
zoom.pinchAnchor = nil
}
}
private var doubleTapGesture: some Gesture {
TapGesture(count: 2)
.onEnded {
guard zoom.isZoomed else { return }
withAnimation(.snappy) { zoom.reset() }
ChartHaptics.bump()
}
}
}
// MARK: - Range brush (full-history sparkline + month-snapping handles)
//
// Index-based over the months that actually have data. Bound to a
// ClosedRange<Int> selection owned by the caller (the charts container maps it
// to ChartsViewModel.customRange). Selecting everything = "no filter".
struct ChartRangeBrush: View {
let dates: [Date]
let values: [Double]
@Binding var selection: ClosedRange<Int>
@State private var dragStartIndices: (lo: Int, hi: Int)?
@State private var dragTarget: DragTarget?
@State private var lastTickedIndex: Int = -1
@State private var showingPickers = false
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
/// One unified gesture instead of per-element gestures: with a narrow
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
/// window and moving it was impossible.
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
private static let labelFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
return f
}()
private var clampedSelection: (lo: Int, hi: Int) {
guard !dates.isEmpty else { return (0, 0) }
let lo = max(0, min(selection.lowerBound, dates.count - 1))
let hi = max(lo, min(selection.upperBound, dates.count - 1))
return (lo, hi)
}
var body: some View {
VStack(spacing: 6) {
rangeLabel
GeometryReader { geo in
brushBody(width: geo.size.width)
}
.frame(height: 44)
}
.sheet(isPresented: $showingPickers) {
ChartRangePickerSheet(dates: dates, selection: $selection)
.presentationDetents([.height(320)])
}
}
private var rangeLabel: some View {
let (lo, hi) = clampedSelection
return Button {
showingPickers = true
} label: {
HStack(spacing: 5) {
Text("\(Self.labelFormatter.string(from: dates[lo])) \(Self.labelFormatter.string(from: dates[hi]))")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "chart_range_button"))
}
@ViewBuilder
private func brushBody(width: CGFloat) -> some View {
let (lo, hi) = clampedSelection
let xLo = xPosition(index: lo, width: width)
let xHi = xPosition(index: hi, width: width)
ZStack(alignment: .leading) {
// Full-history sparkline (widget-style: gradient line, no axes)
SparklinePath(values: values)
.stroke(
LinearGradient(colors: [.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
)
.opacity(0.85)
// Dimming outside the window
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, xLo))
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, width - xHi))
.offset(x: xHi)
// Window frame
RoundedRectangle(cornerRadius: 6)
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
)
.frame(width: max(10, xHi - xLo))
.offset(x: xLo)
// Handles (visual only hit-testing lives in the unified gesture)
handle(at: xLo)
handle(at: xHi)
}
.contentShape(Rectangle())
.gesture(unifiedGesture(width: width))
}
private func handle(at x: CGFloat) -> some View {
Capsule()
.fill(Color.appPrimary)
.frame(width: 5, height: 26)
.overlay(
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
)
.frame(height: 44)
.offset(x: x - 2.5)
.allowsHitTesting(false)
}
// MARK: Geometry helpers
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
guard dates.count > 1 else { return 0 }
return width * CGFloat(index) / CGFloat(dates.count - 1)
}
private func index(atX x: CGFloat, width: CGFloat) -> Int {
guard dates.count > 1, width > 0 else { return 0 }
let frac = max(0, min(1, x / width))
return Int((frac * CGFloat(dates.count - 1)).rounded())
}
// MARK: Unified gesture
//
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
// anywhere else (inside the window OR on the dimmed history) slides the
// whole window keeping its width select JanMar, then swipe to land on
// JunAug. Narrow windows stay movable because the edge grip only wins in
// its 18pt zone (from outside when the window is too narrow to share).
private func unifiedGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? clampedSelection
if dragStartIndices == nil {
dragStartIndices = (lo, hi)
dragTarget = target(
forX: value.startLocation.x,
xLo: xPosition(index: lo, width: width),
xHi: xPosition(index: hi, width: width)
)
}
guard let target = dragTarget else { return }
var newLo = lo
var newHi = hi
switch target {
case .lowerHandle:
newLo = min(index(atX: value.location.x, width: width), hi)
case .upperHandle:
newHi = max(index(atX: value.location.x, width: width), lo)
case .moveWindow:
let span = hi - lo
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
newHi = newLo + span
}
let ticked = (target == .upperHandle) ? newHi : newLo
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
}
selection = newLo...newHi
}
.onEnded { _ in
dragStartIndices = nil
dragTarget = nil
lastTickedIndex = -1
}
}
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
let grip: CGFloat = 18
let windowWidth = xHi - xLo
if windowWidth >= grip * 3 {
// Wide window: edges grab from either side of the handle.
if abs(x - xLo) <= grip { return .lowerHandle }
if abs(x - xHi) <= grip { return .upperHandle }
} else {
// Narrow window: the inside belongs to MOVE; edges only from outside.
if x < xLo && xLo - x <= grip { return .lowerHandle }
if x > xHi && x - xHi <= grip { return .upperHandle }
}
return .moveWindow
}
}
/// Minimal normalized line path for the brush background.
private struct SparklinePath: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
var path = Path()
guard values.count > 1 else { return path }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = maxV - minV
let inset: CGFloat = 4
func point(_ i: Int) -> CGPoint {
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
let norm = range > 0 ? (values[i] - minV) / range : 0.5
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
return CGPoint(x: x, y: y)
}
path.move(to: point(0))
for i in 1..<values.count {
path.addLine(to: point(i))
}
return path
}
}
// MARK: - Precise month/year pickers
/// Two wheel pickers over the months that actually have data the precise,
/// non-gesture way to land exactly on e.g. FebMar 2025.
struct ChartRangePickerSheet: View {
let dates: [Date]
@Binding var selection: ClosedRange<Int>
@Environment(\.dismiss) private var dismiss
@State private var fromIndex = 0
@State private var toIndex = 0
private static let optionFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f
}()
var body: some View {
NavigationStack {
HStack(spacing: 0) {
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
}
.navigationTitle(String(localized: "chart_range_button"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("OK") {
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
ChartHaptics.bump()
dismiss()
}
}
}
}
.onAppear {
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
}
.onChange(of: fromIndex) { _, new in
if new > toIndex { toIndex = new }
}
.onChange(of: toIndex) { _, new in
if new < fromIndex { fromIndex = new }
}
}
private func picker(title: String, selection: Binding<Int>) -> some View {
VStack(spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Picker(title, selection: selection) {
ForEach(dates.indices, id: \.self) { i in
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
}
}
.pickerStyle(.wheel)
}
}
}