Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)

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
This commit is contained in:
alexandrev-tibco
2026-08-06 20:33:58 +02:00
parent d941ecf27b
commit f1aeafccf6
5 changed files with 225 additions and 185 deletions
+34 -129
View File
@@ -4,15 +4,13 @@ import UIKit
// MARK: - Time-series chart zoom (window model)
//
// Shared behavior for every chart with a Date X axis. The state is a visible
// WINDOW (start + span) instead of a bare span: pinch, native horizontal pan
// and the range brush all manipulate the same window, so there are no modes.
// 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.
//
// - Pinch: zooms anchored on the window center, and reveals the brush.
// - Pan: native chart scrolling (chartScrollPosition) once zoomed.
// - Double tap: reset to fit-all.
// - Brush: full-history sparkline with month-snapping handles; the label opens
// precise month/year pickers (HIG: gestures are never the only way).
// 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 {
@@ -20,8 +18,6 @@ struct ChartZoomModel {
var spanSeconds: TimeInterval?
/// Start of the visible window (chart scroll position).
var startX: Date = .distantPast
/// Whether the range brush is expanded under the chart.
var brushVisible = false
/// (span, center) captured when a pinch begins.
var pinchAnchor: (span: TimeInterval, center: Date)?
@@ -49,19 +45,6 @@ struct ChartZoomModel {
return clamped >= upper * 0.98 ? nil : clamped
}
/// Sets the window to [dates[i], dates[j]]; fit-all when it covers everything.
mutating func setWindow(from i: Int, to j: Int, dates: [Date]) {
guard !dates.isEmpty else { return }
let lo = max(0, min(i, dates.count - 1))
let hi = max(lo, min(j, dates.count - 1))
if lo == 0 && hi == dates.count - 1 {
spanSeconds = nil
return
}
startX = dates[lo]
spanSeconds = dates[hi].timeIntervalSince(dates[lo]) + Self.endPadding
}
mutating func reset() {
spanSeconds = nil
pinchAnchor = nil
@@ -80,30 +63,15 @@ enum ChartHaptics {
extension View {
/// Applies zoomable window behavior to a Chart with a Date X axis.
/// - Parameters:
/// - dates: the full set of X values (sorted, typically monthly)
/// - values: optional Y values (same count) enables the range brush
/// - zoom: binding to the chart's window state
/// - onExpandRange: called when the brush is revealed, so the owner can
/// widen the loaded data range (e.g. switch the period to All)
@ViewBuilder
func zoomableTimeSeries(
dates: [Date],
values: [Double]? = nil,
zoom: Binding<ChartZoomModel>,
onExpandRange: (() -> Void)? = nil
) -> some View {
modifier(ZoomableTimeSeriesModifier(
dates: dates, values: values, zoom: zoom, onExpandRange: onExpandRange
))
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
}
}
private struct ZoomableTimeSeriesModifier: ViewModifier {
let dates: [Date]
let values: [Double]?
@Binding var zoom: ChartZoomModel
let onExpandRange: (() -> Void)?
private var fullSpan: TimeInterval {
guard let min = dates.min(), let max = dates.max(), max > min else {
@@ -113,17 +81,6 @@ private struct ZoomableTimeSeriesModifier: ViewModifier {
}
func body(content: Content) -> some View {
VStack(spacing: 10) {
chartWithGestures(content)
if zoom.brushVisible, let values, dates.count >= 2 {
ChartRangeBrush(dates: dates, values: values, zoom: $zoom)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
@ViewBuilder
private func chartWithGestures(_ content: Content) -> some View {
Group {
if let span = zoom.spanSeconds {
content
@@ -136,11 +93,6 @@ private struct ZoomableTimeSeriesModifier: ViewModifier {
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(doubleTapGesture)
.overlay(alignment: .topTrailing) {
if values != nil {
brushToggle
}
}
}
// Pinch anchored on the window center: the point you're looking at stays put.
@@ -168,12 +120,6 @@ private struct ZoomableTimeSeriesModifier: ViewModifier {
}
.onEnded { _ in
zoom.pinchAnchor = nil
// Zooming in reveals the brush so the window is visible & editable.
if zoom.isZoomed && !zoom.brushVisible {
withAnimation(.snappy) { zoom.brushVisible = true }
ChartHaptics.bump()
onExpandRange?()
}
}
}
@@ -185,37 +131,18 @@ private struct ZoomableTimeSeriesModifier: ViewModifier {
ChartHaptics.bump()
}
}
/// Small pill that expands/collapses the range brush the discoverable,
/// non-gesture entry point to custom ranges.
private var brushToggle: some View {
Button {
withAnimation(.snappy) { zoom.brushVisible.toggle() }
ChartHaptics.bump()
if zoom.brushVisible { onExpandRange?() }
} label: {
Image(systemName: zoom.brushVisible
? "arrow.down.right.and.arrow.up.left"
: "calendar.badge.clock")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.appPrimary)
.frame(width: 34, height: 28)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(6)
.accessibilityLabel(String(localized: "chart_range_button"))
}
}
// 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 zoom: ChartZoomModel
@Binding var selection: ClosedRange<Int>
@State private var dragStartIndices: (lo: Int, hi: Int)?
@State private var lastTickedIndex: Int = -1
@@ -228,12 +155,11 @@ struct ChartRangeBrush: View {
return f
}()
/// Current window as indices into `dates`.
private var windowIndices: (lo: Int, hi: Int) {
guard let window = zoom.visibleWindow(dates: dates) else { return (0, max(0, dates.count - 1)) }
let lo = nearestIndex(to: window.lowerBound)
let hi = nearestIndex(to: window.upperBound)
return (min(lo, hi), max(lo, hi, min(lo + 1, dates.count - 1)))
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 {
@@ -245,13 +171,13 @@ struct ChartRangeBrush: View {
.frame(height: 44)
}
.sheet(isPresented: $showingPickers) {
ChartRangePickerSheet(dates: dates, zoom: $zoom)
ChartRangePickerSheet(dates: dates, selection: $selection)
.presentationDetents([.height(320)])
}
}
private var rangeLabel: some View {
let (lo, hi) = windowIndices
let (lo, hi) = clampedSelection
return Button {
showingPickers = true
} label: {
@@ -270,7 +196,7 @@ struct ChartRangeBrush: View {
@ViewBuilder
private func brushBody(width: CGFloat) -> some View {
let (lo, hi) = windowIndices
let (lo, hi) = clampedSelection
let xLo = xPosition(index: lo, width: width)
let xHi = xPosition(index: hi, width: width)
@@ -293,7 +219,7 @@ struct ChartRangeBrush: View {
.frame(width: max(0, width - xHi))
.offset(x: xHi)
// Window frame
// Window frame draggable as a whole to slide through history
RoundedRectangle(cornerRadius: 6)
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
.background(
@@ -337,37 +263,27 @@ struct ChartRangeBrush: View {
return Int((frac * CGFloat(dates.count - 1)).rounded())
}
private func nearestIndex(to date: Date) -> Int {
var best = 0
var bestDist = TimeInterval.greatestFiniteMagnitude
for (i, d) in dates.enumerated() {
let dist = abs(d.timeIntervalSince(date))
if dist < bestDist { best = i; bestDist = dist }
}
return best
}
// MARK: Gestures
private func handleGesture(isLower: Bool, width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? windowIndices
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 - 1)
newLo = min(i, hi)
} else {
newHi = max(i, lo + 1)
newHi = max(i, lo)
}
let ticked = isLower ? newLo : newHi
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
}
zoom.setWindow(from: newLo, to: newHi, dates: dates)
selection = newLo...newHi
}
.onEnded { _ in
dragStartIndices = nil
@@ -380,7 +296,7 @@ struct ChartRangeBrush: View {
private func moveGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? windowIndices
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())
@@ -389,7 +305,7 @@ struct ChartRangeBrush: View {
lastTickedIndex = newLo
ChartHaptics.tick()
}
zoom.setWindow(from: newLo, to: newLo + span, dates: dates)
selection = newLo...(newLo + span)
}
.onEnded { _ in
dragStartIndices = nil
@@ -429,7 +345,7 @@ private struct SparklinePath: Shape {
/// non-gesture way to land exactly on e.g. FebMar 2025.
struct ChartRangePickerSheet: View {
let dates: [Date]
@Binding var zoom: ChartZoomModel
@Binding var selection: ClosedRange<Int>
@Environment(\.dismiss) private var dismiss
@State private var fromIndex = 0
@@ -453,7 +369,7 @@ struct ChartRangePickerSheet: View {
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("OK") {
zoom.setWindow(from: fromIndex, to: toIndex, dates: dates)
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
ChartHaptics.bump()
dismiss()
}
@@ -461,15 +377,14 @@ struct ChartRangePickerSheet: View {
}
}
.onAppear {
guard let window = zoom.visibleWindow(dates: dates) else { return }
fromIndex = nearest(window.lowerBound)
toIndex = nearest(window.upperBound)
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 = min(new + 1, dates.count - 1) }
if new > toIndex { toIndex = new }
}
.onChange(of: toIndex) { _, new in
if new <= fromIndex { fromIndex = max(new - 1, 0) }
if new < fromIndex { fromIndex = new }
}
}
@@ -486,14 +401,4 @@ struct ChartRangePickerSheet: View {
.pickerStyle(.wheel)
}
}
private func nearest(_ date: Date) -> Int {
var best = 0
var bestDist = TimeInterval.greatestFiniteMagnitude
for (i, d) in dates.enumerated() {
let dist = abs(d.timeIntervalSince(date))
if dist < bestDist { best = i; bestDist = dist }
}
return best
}
}