import SwiftUI import Charts struct SeriesChartCard: View { let bucket: SeriesBucket private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] { bucket.lines.flatMap { line in line.points.map { (line, $0) } } } private var dateDomain: ClosedRange? { let dates = allPoints.map(\.point.date) guard let lo = dates.min(), let hi = dates.max() else { return nil } if lo == hi { // 只有一个点的极端情况:扩 1 天显示 let cal = Calendar.current let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo let later = cal.date(byAdding: .hour, value: 12, to: hi) ?? hi return earlier...later } return lo...hi } private var valueDomain: ClosedRange? { var lo = Double.greatestFiniteMagnitude var hi = -Double.greatestFiniteMagnitude for (_, p) in allPoints { lo = min(lo, p.value) hi = max(hi, p.value) } for line in bucket.lines { if let r = line.referenceRange { lo = min(lo, r.lowerBound) hi = max(hi, r.upperBound) } } // 无数据时 lo>hi → nil;所有点同值(lo==hi)时按值本身对称留白, // 否则会落到 0...1 把数据点挤出可视域。 guard lo <= hi else { return nil } let span = hi - lo let pad = span > 0 ? max(1, span * 0.12) : max(1, abs(lo) * 0.1) return (lo - pad)...(hi + pad) } var body: some View { VStack(alignment: .leading, spacing: 12) { header chart .frame(height: 120) if bucket.lines.count > 1 { legendLine } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } private var header: some View { HStack(alignment: .lastTextBaseline, spacing: 10) { Text(bucket.title) .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) Spacer() latestValueBadge } } private var latestValueBadge: some View { let parts = bucket.lines.compactMap { line -> String? in guard let p = line.latestPoint else { return nil } return formatValue(p.value) } let joined = parts.joined(separator: " / ") let anyAbnormal = bucket.lines.contains { line in (line.latestPoint?.status ?? .normal) != .normal } return HStack(spacing: 4) { Text(joined) .font(.tjScaled( 14, weight: .semibold, design: .monospaced)) .foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text) Text(bucket.unit) .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } private var chart: some View { Chart { // 参考范围带 ForEach(bucket.lines) { line in if let r = line.referenceRange, let dom = dateDomain { RectangleMark( xStart: .value("start", dom.lowerBound), xEnd: .value("end", dom.upperBound), yStart: .value("lo", r.lowerBound), yEnd: .value("hi", r.upperBound) ) .foregroundStyle(line.color.opacity(0.08)) } } // 单条线时,线下垫一层渐变面积,增加体量、柔化观感 //(多条线如血压不加,避免两片面积互相盖住)。 if bucket.lines.count == 1, let line = bucket.lines.first { ForEach(line.points) { p in // 显式把基线钉在值域下界:单值 AreaMark 的隐式基线(0/域外)会把渐变 // 拉到可视区外、几乎不淡出,看着像一块实色底色一路铺到图表底。 AreaMark( x: .value("时间", p.date), yStart: .value("基线", (valueDomain ?? 0...1).lowerBound), yEnd: .value(line.label ?? bucket.title, p.value) ) .foregroundStyle(LinearGradient( colors: [line.color.opacity(0.16), line.color.opacity(0)], startPoint: .top, endPoint: .bottom)) .interpolationMethod(.monotone) } } // 折线 + 点 ForEach(bucket.lines) { line in ForEach(line.points) { p in LineMark( x: .value("时间", p.date), y: .value(line.label ?? bucket.title, p.value) ) .foregroundStyle(line.color) // monotone:平滑但不在数据尖峰处过冲鼓包,比 catmullRom 更贴合真实读数。 .interpolationMethod(.monotone) // 圆角端点 + 连接,去掉折线的生硬尖角。 .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) } .symbol { Circle() .fill(line.color) .frame(width: 6, height: 6) } } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) { _ in AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel(format: .dateTime.month(.abbreviated).day(), centered: false) .foregroundStyle(Tj.Palette.text3) } } .chartYAxis { AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ in AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel() .foregroundStyle(Tj.Palette.text3) .font(.tjScaled( 10, design: .monospaced)) } } .chartYScale(domain: valueDomain ?? 0...1) } private var legendLine: some View { HStack(spacing: 12) { ForEach(bucket.lines) { line in HStack(spacing: 4) { Circle() .fill(line.color) .frame(width: 8, height: 8) Text(line.label ?? line.seriesKey) .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) } } } } private var daysSpanLabel: String { guard let dom = dateDomain else { return "—" } let days = Calendar.current.dateComponents([.day], from: dom.lowerBound, to: dom.upperBound).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 func formatValue(_ v: Double) -> String { v.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", v) : String(format: "%.1f", v) } }