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? { 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) -> 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 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 @State private var dragStartIndices: (lo: Int, hi: Int)? @State private var lastTickedIndex: Int = -1 @State private var showingPickers = false 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)) .foregroundColor(.appPrimary) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8, weight: .semibold)) .foregroundColor(.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 — draggable as a whole to slide through history 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) .contentShape(Rectangle()) .gesture(moveGesture(width: width)) // Handles handle(at: xLo) .gesture(handleGesture(isLower: true, width: width)) handle(at: xHi) .gesture(handleGesture(isLower: false, 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(width: 30, height: 44) // generous hit area .contentShape(Rectangle()) .offset(x: x - 15) } // 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: Gestures private func handleGesture(isLower: Bool, width: CGFloat) -> some Gesture { DragGesture(minimumDistance: 1) .onChanged { value in let (lo, hi) = dragStartIndices ?? clampedSelection if dragStartIndices == nil { dragStartIndices = (lo, hi) } let i = index(atX: value.location.x, width: width) var newLo = lo var newHi = hi if isLower { newLo = min(i, hi) } else { newHi = max(i, lo) } let ticked = isLower ? newLo : newHi if ticked != lastTickedIndex { lastTickedIndex = ticked ChartHaptics.tick() } selection = newLo...newHi } .onEnded { _ in dragStartIndices = nil lastTickedIndex = -1 } } /// Dragging the window itself slides it through history keeping its width — /// e.g. fix a 1-month window and replay the portfolio month by month. private func moveGesture(width: CGFloat) -> some Gesture { DragGesture(minimumDistance: 1) .onChanged { value in let (lo, hi) = dragStartIndices ?? clampedSelection if dragStartIndices == nil { dragStartIndices = (lo, hi) } let span = hi - lo let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded()) let newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span)) if newLo != lastTickedIndex { lastTickedIndex = newLo ChartHaptics.tick() } selection = newLo...(newLo + span) } .onEnded { _ in dragStartIndices = nil lastTickedIndex = -1 } } } /// 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.. @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) -> some View { VStack(spacing: 2) { Text(title) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) Picker(title, selection: selection) { ForEach(dates.indices, id: \.self) { i in Text(Self.optionFormatter.string(from: dates[i])).tag(i) } } .pickerStyle(.wheel) } } }