152 lines
6.3 KiB
Swift
152 lines
6.3 KiB
Swift
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
|
||
/// В карточке-герое график уходит под края виджета и левая кромка заливки
|
||
/// не видна. Отдельно стоящий график гасит её, иначе получается срез.
|
||
var isFullBleed: Bool = true
|
||
|
||
/// Больше точек глазом уже не различить, а форма тяжелеет.
|
||
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(areaMask)
|
||
|
||
// Гало точки лежит под линией, ядро — над ней.
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Горизонтальная маска заливки: свечение сосредоточено слева, как в макете.
|
||
private var areaMask: LinearGradient {
|
||
LinearGradient(
|
||
gradient: Gradient(stops: isFullBleed
|
||
? [
|
||
.init(color: .white, location: 0),
|
||
.init(color: .white.opacity(0.25), location: 1),
|
||
]
|
||
: [
|
||
.init(color: .white.opacity(0), location: 0),
|
||
.init(color: .white, location: 0.3),
|
||
.init(color: .white.opacity(0.3), location: 1),
|
||
]),
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
}
|
||
|
||
// 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())] }
|
||
}
|
||
}
|