This commit is contained in:
2026-08-12 07:06:01 +07:00
parent 1025658518
commit 9bc96dbc2d
39 changed files with 1682 additions and 658 deletions
+135
View File
@@ -0,0 +1,135 @@
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..<limit).map { values[Int((Double($0) * step).rounded())] }
}
}