import SwiftUI import Playgrounds import Charts struct DataPoint: Identifiable { var id: Int { x } let x: Int let y: Int } struct ContentView: View { static let pageSize: Int = 1000 @State var data: [DataPoint] = (0.. { Binding( get: { selectedXValue }, set: { value in guard !isPinching else { return } selectedXValue = value } ) } // Visible plot viewport in the chart view's coordinate space, kept up to // date by the chart overlay so the pinch gesture can map screen x to data // x. This must be the container frame: in a scrollable chart, plotFrame is // the full scrolled content, not the viewport. @State private var plotContainerFrame: CGRect = .zero @State private var pinchStart: PinchStart? @GestureState private var isPinching = false private struct PinchStart { let length: Double let anchorX: Double let fraction: Double let magnification: Double } var body: some View { Chart(visibleData) { point in LineMark( x: .value("X axis", Double(point.x)), y: .value("Y axis", point.y) ) if let selectedXValue { RuleMark( x: .value("Selected", selectedXValue) ) } } // Pin the full domain explicitly: with only the visible slice passed to // Chart, an inferred domain would collapse the scrollable range. .chartXScale(domain: 0...Double(max(data.count - 1, 1))) .chartXVisibleDomain(length: visibleDomain) .chartScrollableAxes(.horizontal) // Selection must use the chart's built-in gestures; a custom // .chartGesture would replace them and break selection. .chartXSelection(value: selectedXValueBinding) .chartOverlay { proxy in GeometryReader { geometry in let frame = proxy.plotContainerFrame.map { geometry[$0] } ?? .zero Color.clear .onChange(of: frame, initial: true) { _, newValue in plotContainerFrame = newValue } } .allowsHitTesting(false) } .simultaneousGesture(magnifyGesture) .scrollDisabled(isPinching) .chartScrollPosition(x: $scrollPosition) .chartXAxis { AxisMarks(values: chartMarks) { AxisTick() AxisGridLine() AxisValueLabel() } } .padding() .onChange(of: scrollPosition) { oldValue, newValue in if Int(newValue) + Int(visibleDomain) > data.count { generateNextPage() } updateChartMarks() updateVisibleData() } .onChange(of: visibleDomain, initial: true) { updateChartMarks(force: true) updateVisibleData(force: true) } .onChange(of: data.count) { updateVisibleData(force: true) } .onChange(of: isPinching) { _, active in if !active { endPinch() } } } private var magnifyGesture: some Gesture { MagnifyGesture() .updating($isPinching) { _, active, _ in active = true } .onChanged { gesture in let magnification = Double(gesture.magnification) guard magnification.isFinite, magnification > 0 else { return } if pinchStart == nil { guard plotContainerFrame.width > 0 else { return } let fraction = (gesture.startLocation.x - plotContainerFrame.minX) / plotContainerFrame.width guard (0...1).contains(fraction) else { return } pinchStart = PinchStart( length: visibleDomain, anchorX: scrollPosition + fraction * visibleDomain, fraction: fraction, magnification: magnification ) } guard let start = pinchStart else { return } // Match the gesture baseline to the captured domain. The first // delivered magnification may already differ substantially from 1. let relativeMagnification = magnification / start.magnification let upperBound = Double(data.last?.x ?? 0) let length = min( upperBound, max( minVisibleDomain, min(maxVisibleDomain, start.length / relativeMagnification) ) ) let requestedPosition = start.anchorX - start.fraction * length let position = min(max(0, requestedPosition), max(0, upperBound - length)) var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { visibleDomain = length scrollPosition = position } } .onEnded { _ in endPinch() } } private func endPinch() { guard pinchStart != nil else { return } pinchStart = nil scrollPosition = round(scrollPosition) } func generateNextPage() { Task { data += (data.count.. visibleDomain || force else { return } let strideBy = visibleDomain > 300 ? 100 : visibleDomain > 10 ? 10 : 1 let startX = Int(scrollPosition) let start = Int(max(0, startX - Int(visibleDomain))) let alignedStart = Int(start).isMultiple(of: strideBy) ? start : start / strideBy * strideBy let end = startX + 2 * Int(visibleDomain) chartMarks = stride(from: alignedStart, to: end, by: strideBy).map { Double($0) } chartMarksGeneratedAtPosition = Double(startX) } func updateVisibleData(force: Bool = false) { guard abs(scrollPosition - visibleDataGeneratedAtPosition) > visibleDomain || force else { return } // One visibleDomain of headroom on each side of the visible window; // data.x equals its array index, so the slice is a direct index range. let startX = Int(scrollPosition) let start = min(max(0, startX - Int(visibleDomain)), data.count) let end = min(data.count, max(start, startX + 2 * Int(visibleDomain))) visibleData = Array(data[start..