f1aeafccf6
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el swipe entre charts (el brush vivía dentro de chartContent, bajo el simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de periodo, fuera del área con gesto. Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow, comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries (agregación mensual extraída de calculateEvolutionData como monthlySeries). Presets limpian el rango custom; selección completa = sin filtro. El pinch por-chart se mantiene como zoom visual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
405 lines
15 KiB
Swift
405 lines
15 KiB
Swift
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 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..<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. Feb–Mar 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))
|
||
.foregroundColor(.secondary)
|
||
Picker(title, selection: selection) {
|
||
ForEach(dates.indices, id: \.self) { i in
|
||
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
|
||
}
|
||
}
|
||
.pickerStyle(.wheel)
|
||
}
|
||
}
|
||
}
|