feat: v2
This commit is contained in:
@@ -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())] }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user