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) -> 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) } }