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:
@@ -88,6 +88,11 @@ struct ChartsContainerView: View {
|
||||
|
||||
chartToolbar
|
||||
|
||||
if showRangeBrush && supportsGlobalRange {
|
||||
globalRangeBrush
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
if !viewModel.isPremium {
|
||||
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
|
||||
.onAppear {
|
||||
@@ -503,6 +508,9 @@ struct ChartsContainerView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
if supportsGlobalRange {
|
||||
rangeToggleButton
|
||||
}
|
||||
shareButton
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
@@ -740,22 +748,125 @@ struct ChartsContainerView: View {
|
||||
}
|
||||
|
||||
/// Stocks-style segmented period control above the chart (the chart switcher
|
||||
/// lives in the title menu, filters in the toolbar).
|
||||
/// lives in the title menu, filters in the toolbar), plus the global range
|
||||
/// brush toggle. The brush lives HERE — outside chartContent — so its drags
|
||||
/// can't collide with the chart-swipe gesture, and its window filters the
|
||||
/// data of EVERY chart (it replaces the preset as data range).
|
||||
@ViewBuilder
|
||||
private var iPhonePeriodControl: some View {
|
||||
let ranges = viewModel.availableTimeRanges(for: viewModel.selectedChartType)
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performancePeriodPicker
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.selectedChartType == .performance {
|
||||
performancePeriodPicker
|
||||
} else if !ranges.isEmpty {
|
||||
Picker("Period", selection: $viewModel.selectedTimeRange) {
|
||||
ForEach(ranges) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
if supportsGlobalRange {
|
||||
rangeToggleButton
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if showRangeBrush && supportsGlobalRange {
|
||||
globalRangeBrush
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Global range brush (shared data window for all charts)
|
||||
|
||||
@State private var showRangeBrush = false
|
||||
|
||||
/// Charts whose data honors customRange (Performance/Simulator/Period vs
|
||||
/// Period fetch their own windows).
|
||||
private var supportsGlobalRange: Bool {
|
||||
!viewModel.availableTimeRanges(for: viewModel.selectedChartType).isEmpty
|
||||
&& viewModel.selectedChartType != .performance
|
||||
&& viewModel.fullHistorySeries.count >= 2
|
||||
}
|
||||
|
||||
private var rangeToggleButton: some View {
|
||||
Button {
|
||||
withAnimation(.snappy) { showRangeBrush.toggle() }
|
||||
ChartHaptics.bump()
|
||||
if showRangeBrush {
|
||||
// Load the full history so any window can be selected.
|
||||
if viewModel.selectedTimeRange != .all { viewModel.selectedTimeRange = .all }
|
||||
} else {
|
||||
viewModel.customRange = nil
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(showRangeBrush ? .white : .appPrimary)
|
||||
.frame(width: 38, height: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(showRangeBrush ? Color.appPrimary : Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(showRangeBrush ? 0 : 0.3), lineWidth: 0.8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "chart_range_button"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var globalRangeBrush: some View {
|
||||
let series = viewModel.fullHistorySeries
|
||||
if series.count >= 2 {
|
||||
ChartRangeBrush(
|
||||
dates: series.map(\.date),
|
||||
values: series.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
selection: rangeSelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the brush's month indices to the view model's date window.
|
||||
/// Full selection = no filter (nil).
|
||||
private var rangeSelection: Binding<ClosedRange<Int>> {
|
||||
Binding(
|
||||
get: {
|
||||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||||
guard let last = dates.indices.last else { return 0...0 }
|
||||
guard let range = viewModel.customRange else { return 0...last }
|
||||
let lo = nearestIndex(in: dates, to: range.lowerBound)
|
||||
let hi = nearestIndex(in: dates, to: range.upperBound)
|
||||
return min(lo, hi)...max(lo, hi)
|
||||
},
|
||||
set: { sel in
|
||||
let dates = viewModel.fullHistorySeries.map(\.date)
|
||||
guard let last = dates.indices.last else { return }
|
||||
if sel.lowerBound <= 0 && sel.upperBound >= last {
|
||||
viewModel.customRange = nil
|
||||
} else {
|
||||
viewModel.setCustomRange(
|
||||
fromMonth: dates[max(0, sel.lowerBound)],
|
||||
toMonth: dates[min(last, sel.upperBound)]
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func nearestIndex(in dates: [Date], 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: - Shared
|
||||
|
||||
private func syncState() {
|
||||
@@ -826,7 +937,6 @@ struct ChartsContainerView: View {
|
||||
goals: goalsViewModel.goals,
|
||||
contributions: viewModel.contributionsData,
|
||||
isPremium: viewModel.isPremium,
|
||||
onExpandRange: { viewModel.selectedTimeRange = .all },
|
||||
onPremiumNeeded: {
|
||||
FirebaseService.shared.logPaywallShown(trigger: "chart_workable")
|
||||
viewModel.showingPaywall = true
|
||||
@@ -990,7 +1100,6 @@ struct EvolutionChartView: View {
|
||||
let goals: [Goal]
|
||||
var contributions: [(date: Date, amount: Decimal)] = []
|
||||
var isPremium: Bool = true
|
||||
var onExpandRange: (() -> Void)? = nil
|
||||
var onPremiumNeeded: (() -> Void)? = nil
|
||||
@Environment(\.chartImageExport) private var chartImageExport
|
||||
@Environment(\.horizontalSizeClass) private var labelsSizeClass
|
||||
@@ -1332,12 +1441,7 @@ struct EvolutionChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.value).doubleValue },
|
||||
zoom: $zoom,
|
||||
onExpandRange: onExpandRange
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
// Performance: GPU rendering for smoother scrolling on older devices
|
||||
.chartDrawingGroup(disabledForExport: chartImageExport)
|
||||
}
|
||||
@@ -1522,11 +1626,7 @@ struct ContributionsChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.amount).doubleValue },
|
||||
zoom: $zoom
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: contributionStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
@@ -1633,11 +1733,7 @@ struct RollingReturnChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.value),
|
||||
zoom: $zoom
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: rollingStats).padding(.top, 4)
|
||||
ChartDataTable(
|
||||
rows: rollingTableRows,
|
||||
@@ -1802,11 +1898,7 @@ struct CashflowStackedChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map { NSDecimalNumber(decimal: $0.contributions + $0.netPerformance).doubleValue },
|
||||
zoom: $zoom
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user