import SwiftUI // MARK: - Форма линии /// Сглаженная ломаная по точкам; `closed` замыкает контур вниз для заливки. private struct SparkShape: Shape { let points: [CGPoint] let closed: Bool func path(in rect: CGRect) -> Path { var path = Path() guard points.count > 1 else { return path } path.move(to: points[0]) for index in 0..<(points.count - 1) { let current = points[index] let next = points[index + 1] let mid = CGPoint(x: (current.x + next.x) / 2, y: (current.y + next.y) / 2) if index == 0 { path.addLine(to: mid) } else { path.addQuadCurve(to: mid, control: current) } } path.addLine(to: points[points.count - 1]) if closed { path.addLine(to: CGPoint(x: points[points.count - 1].x, y: rect.maxY)) path.addLine(to: CGPoint(x: points[0].x, y: rect.maxY)) path.closeSubpath() } return path } } // MARK: - Спарклайн /// График в стиле макета: линия с горизонтальным градиентом (яркая точка /// на 77%), свечение-заливка под линией и «живая» точка на конце. struct Sparkline: View { let values: [Double] let positive: Bool var lineWidth: CGFloat = 2 var showPulse: Bool = true var pulseSize: CGFloat = 16 var areaOpacity: Double = 0.25 /// Отступ справа в диаметрах точки: линия обрывается, не доходя до края. var trailingRoom: CGFloat = 1.4 /// Больше точек глазом уже не различить, а форма тяжелеет. private static let maxPoints = 96 var body: some View { GeometryReader { geo in let points = points(in: geo.size) ZStack(alignment: .topLeading) { // Заливка: вертикальный уход в прозрачность + горизонтальная // маска — свечение сосредоточено слева, как в макете. SparkShape(points: points, closed: true) .fill( LinearGradient( colors: [ RapiraPalette.line(positive).opacity(areaOpacity), RapiraPalette.line(positive).opacity(0), ], startPoint: .top, endPoint: .bottom ) ) .mask( LinearGradient( colors: [.white, .white.opacity(0.25)], startPoint: .leading, endPoint: .trailing ) ) // Гало точки лежит под линией, ядро — над ней. if showPulse, let last = points.last { Circle() .fill(RapiraPalette.halo(positive)) .frame(width: pulseSize, height: pulseSize) .position(last) } SparkShape(points: points, closed: false) .stroke( LinearGradient( gradient: Gradient(stops: [ .init(color: RapiraPalette.lineEdge(positive), location: 0), .init(color: RapiraPalette.line(positive), location: 0.77), .init(color: RapiraPalette.lineEdge(positive), location: 1), ]), startPoint: .leading, endPoint: .trailing ), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) ) if showPulse, let last = points.last { Circle() .fill(RapiraPalette.line(positive)) .frame(width: pulseSize * 0.487, height: pulseSize * 0.487) .position(last) } } } } // MARK: - Геометрия private func points(in size: CGSize) -> [CGPoint] { let series = downsampled(values, limit: Self.maxPoints) guard series.count > 1, size.width > 0, size.height > 0 else { return [] } let trailingInset = showPulse ? pulseSize * trailingRoom : lineWidth / 2 let verticalPad = max(lineWidth, showPulse ? pulseSize / 2 : lineWidth) let width = max(size.width - trailingInset, 1) let height = max(size.height - verticalPad * 2, 1) let low = series.min() ?? 0 let high = series.max() ?? 1 let span = high - low return series.enumerated().map { index, value in let x = width * CGFloat(index) / CGFloat(series.count - 1) let normalized = span > 0 ? (value - low) / span : 0.5 let y = verticalPad + height * (1 - CGFloat(normalized)) return CGPoint(x: x, y: y) } } private func downsampled(_ values: [Double], limit: Int) -> [Double] { guard values.count > limit else { return values } let step = Double(values.count - 1) / Double(limit - 1) return (0..