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(.system(size: 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)") .font(.system(size: 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(.system(size: 14, weight: .semibold, design: .monospaced)) .foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text) Text(bucket.unit) .font(.system(size: 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)) } } // 折线 + 点 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) .interpolationMethod(.catmullRom) .lineStyle(StrokeStyle(lineWidth: 2)) } .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(.system(size: 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(.system(size: 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) } }