Charts iPhone workable: chip bar, ventana+brush, header vivo, drill-down, overlay #29 #30

Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu
como navegación primaria), sin bubbles permanentes en compact (solo endpoint),
Performance a barras horizontales con >6 categorías, línea con gradiente y
estética de widget, pinch anclado al centro + doble-tap reset + haptics.

Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush;
minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango
con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con
haptic); drill-down por tap en Allocation/Performance → Evolution filtrada
(premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas
(premium); insight contextual del rango visible (premium). L10n ×7.

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 18:03:50 +02:00
parent 525911f67a
commit d38c6943e1
14 changed files with 1031 additions and 222 deletions
+449 -99
View File
@@ -1,149 +1,499 @@
import SwiftUI
import Charts
import UIKit
// MARK: - Time-series chart zoom
// MARK: - Time-series chart zoom (window model)
//
// 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).
// 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.
//
// - 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).
/// View-side zoom state for a time-series chart. `visibleSpan == nil` means fit-all.
/// View-side window state for a time-series chart. `spanSeconds == 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
}
/// Visible X-domain length in seconds; nil = show everything.
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)?
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
private static let zoomStep = 0.6 // per button tap
/// 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.minSpan)
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 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)
/// 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() {
visibleSpan = nil
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 behavior to a Chart with a Date X axis.
/// Applies zoomable window 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
/// - 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], zoom: Binding<ChartZoomModel>) -> 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)
}
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
))
}
}
enum ChartZoomHelper {
static func fullSpan(of dates: [Date]) -> TimeInterval {
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 {
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
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)))
}
}
}
}
/// 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) }
@ViewBuilder
private func chartWithGestures(_ content: Content) -> some View {
Group {
if let span = zoom.spanSeconds {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
.chartScrollPosition(x: $zoom.startX)
} else {
content
}
.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) }
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(doubleTapGesture)
.overlay(alignment: .topTrailing) {
if values != nil {
brushToggle
}
.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() }
// 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
// Zooming in reveals the brush so the window is visible & editable.
if zoom.isZoomed && !zoom.brushVisible {
withAnimation(.snappy) { zoom.brushVisible = true }
ChartHaptics.bump()
onExpandRange?()
}
.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)
private var doubleTapGesture: some Gesture {
TapGesture(count: 2)
.onEnded {
guard zoom.isZoomed else { return }
withAnimation(.snappy) { zoom.reset() }
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(enabled ? .appPrimary : .secondary.opacity(0.4))
.frame(width: 32, height: 28)
.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)
.disabled(!enabled)
.padding(6)
.accessibilityLabel(String(localized: "chart_range_button"))
}
}
// MARK: - Range brush (full-history sparkline + month-snapping handles)
struct ChartRangeBrush: View {
let dates: [Date]
let values: [Double]
@Binding var zoom: ChartZoomModel
@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
}()
/// 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)))
}
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, zoom: $zoom)
.presentationDetents([.height(320)])
}
}
private var rangeLabel: some View {
let (lo, hi) = windowIndices
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) = windowIndices
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
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())
}
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
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)
} else {
newHi = max(i, lo + 1)
}
let ticked = isLower ? newLo : newHi
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
}
zoom.setWindow(from: newLo, to: newHi, dates: dates)
}
.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 ?? windowIndices
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()
}
zoom.setWindow(from: newLo, to: newLo + span, dates: dates)
}
.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. FebMar 2025.
struct ChartRangePickerSheet: View {
let dates: [Date]
@Binding var zoom: ChartZoomModel
@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") {
zoom.setWindow(from: fromIndex, to: toIndex, dates: dates)
ChartHaptics.bump()
dismiss()
}
}
}
}
.onAppear {
guard let window = zoom.visibleWindow(dates: dates) else { return }
fromIndex = nearest(window.lowerBound)
toIndex = nearest(window.upperBound)
}
.onChange(of: fromIndex) { _, new in
if new >= toIndex { toIndex = min(new + 1, dates.count - 1) }
}
.onChange(of: toIndex) { _, new in
if new <= fromIndex { fromIndex = max(new - 1, 0) }
}
}
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)
}
}
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
}
}