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:
@@ -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. Feb–Mar 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +116,7 @@ struct DrawdownChart: View {
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.drawdown),
|
||||
zoom: $zoom
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
// Statistics
|
||||
HStack(spacing: 20) {
|
||||
@@ -325,11 +321,7 @@ struct VolatilityChartView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
.zoomableTimeSeries(
|
||||
dates: data.map(\.date),
|
||||
values: data.map(\.volatility),
|
||||
zoom: $zoom
|
||||
)
|
||||
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
|
||||
|
||||
ChartStatsRow(stats: [
|
||||
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
|
||||
|
||||
Reference in New Issue
Block a user