128 lines
4.6 KiB
Swift
128 lines
4.6 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
/// 趋势列表的紧凑行:名称 + 条数/跨度 + mini sparkline + 最新值。
|
|
struct TrendRow: View {
|
|
let bucket: SeriesBucket
|
|
|
|
private var allPoints: [SeriesBucket.Point] {
|
|
bucket.lines.flatMap(\.points)
|
|
}
|
|
|
|
private var pointCount: Int { allPoints.count }
|
|
|
|
private var anyLatestAbnormal: Bool {
|
|
bucket.lines.contains { ($0.latestPoint?.status ?? .normal) != .normal }
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(bucket.title)
|
|
.font(.tjScaled( 15, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
.lineLimit(1)
|
|
Text(subtitle)
|
|
.font(.tjScaled( 11))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
sparkline
|
|
.frame(width: 76, height: 34)
|
|
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(latestValue)
|
|
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
|
.lineLimit(1)
|
|
Text(bucket.unit)
|
|
.font(.tjScaled( 9, design: .monospaced))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.fixedSize()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.tjScaled( 12, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity)
|
|
.tjCard(bordered: true)
|
|
}
|
|
|
|
private var sparkline: some View {
|
|
Chart {
|
|
// 单条线时,线下垫一层渐变面积,迷你图也更有体量、不发生硬。
|
|
if bucket.lines.count == 1, let line = bucket.lines.first {
|
|
ForEach(line.points) { p in
|
|
AreaMark(
|
|
x: .value("t", p.date),
|
|
y: .value(line.label ?? bucket.title, p.value)
|
|
)
|
|
.foregroundStyle(LinearGradient(
|
|
colors: [line.color.opacity(0.18), line.color.opacity(0)],
|
|
startPoint: .top, endPoint: .bottom))
|
|
.interpolationMethod(.monotone)
|
|
}
|
|
}
|
|
ForEach(bucket.lines) { line in
|
|
ForEach(line.points) { p in
|
|
LineMark(
|
|
x: .value("t", p.date),
|
|
y: .value(line.label ?? bucket.title, p.value),
|
|
series: .value("s", line.id)
|
|
)
|
|
.foregroundStyle(line.color)
|
|
// monotone + 圆角端点:迷你曲线更柔,无尖角无过冲。
|
|
.interpolationMethod(.monotone)
|
|
.lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
|
|
}
|
|
}
|
|
// 最新点高亮
|
|
ForEach(bucket.lines) { line in
|
|
if let p = line.latestPoint {
|
|
PointMark(
|
|
x: .value("t", p.date),
|
|
y: .value("v", p.value)
|
|
)
|
|
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
|
|
.symbolSize(28)
|
|
}
|
|
}
|
|
}
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.chartLegend(.hidden)
|
|
}
|
|
|
|
private var subtitle: String {
|
|
"\(pointCount) 条 · 近 \(spanLabel)"
|
|
}
|
|
|
|
private var spanLabel: String {
|
|
let dates = allPoints.map(\.date)
|
|
guard let lo = dates.min(), let hi = dates.max() else { return "—" }
|
|
let days = Calendar.current.dateComponents([.day], from: lo, to: hi).day ?? 0
|
|
if days <= 0 { return String(appLoc: "今天") }
|
|
if days < 30 { return String(appLoc: "\(days) 天") }
|
|
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
|
|
return String(appLoc: "\(days / 365) 年")
|
|
}
|
|
|
|
private var latestValue: String {
|
|
let parts = bucket.lines.compactMap { line -> String? in
|
|
guard let p = line.latestPoint else { return nil }
|
|
return formatValue(p.value)
|
|
}
|
|
return parts.joined(separator: "/")
|
|
}
|
|
|
|
private func formatValue(_ v: Double) -> String {
|
|
v.truncatingRemainder(dividingBy: 1) == 0
|
|
? String(format: "%.0f", v)
|
|
: String(format: "%.1f", v)
|
|
}
|
|
}
|