Files
rapira-market-widget/crypto/Sparkline.swift
T
2026-08-12 07:06:01 +07:00

136 lines
5.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())] }
}
}