import SwiftUI import SwiftData import Foundation /// 长期监测系列在 Trends 折线图里的展示桶。 /// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。 struct SeriesBucket: Identifiable { let id: String let title: String let unit: String let lines: [SeriesLine] let latestDate: Date struct SeriesLine: Identifiable { let id: String let seriesKey: String let label: String? let color: Color let points: [Point] let referenceRange: ClosedRange? var latestPoint: Point? { points.last } } struct Point: Identifiable, Hashable { let id: String let date: Date let value: Double let status: IndicatorStatus } } extension SeriesBucket { /// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。 /// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket; /// `minPoints` 以下的系列不返回,默认 2(单点不画线)。 static func build(from indicators: [Indicator], profile: UserProfile? = nil, customMetrics: [CustomMonitorMetric] = [], minPoints: Int = 2) -> [SeriesBucket] { var buckets: [String: [Indicator]] = [:] for i in indicators { guard let key = i.seriesKey, !key.isEmpty else { continue } buckets[key, default: []].append(i) } // 合并血压 let bpKeys: Set = ["bp.systolic", "bp.diastolic"] let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints var results: [SeriesBucket] = [] if bpHasEnoughPoints { results.append(buildBP(from: buckets, profile: profile)) } for k in bpKeys { buckets.removeValue(forKey: k) } let customByKey: [String: CustomMonitorMetric] = Dictionary( uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) } ) for (key, items) in buckets { guard items.count >= minPoints else { continue } if let bucket = buildSingle(key: key, items: items, profile: profile, custom: customByKey[key]) { results.append(bucket) } } return results.sorted { $0.latestDate > $1.latestDate } } private static func buildSingle(key: String, items: [Indicator], profile: UserProfile?, custom: CustomMonitorMetric? = nil) -> SeriesBucket? { let sorted = items.sorted { $0.capturedAt < $1.capturedAt } guard let latest = sorted.last else { return nil } // 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身 let metric = monitorMetric(for: key) let field = metric?.fields.first { $0.seriesKey == key } let title = custom?.name ?? metric?.displayName ?? sorted.first?.name ?? key let unit = custom?.unit.nonEmptyOr(nil) ?? field?.unit ?? sorted.first?.unit ?? "" let range = custom?.referenceRange ?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) } let line = SeriesLine( id: key, seriesKey: key, label: nil, color: Tj.Palette.ink, points: sorted.compactMap { point(from: $0) }, referenceRange: range ) return SeriesBucket( id: key, title: title, unit: unit, lines: [line], latestDate: latest.capturedAt ) } private static func buildBP(from buckets: [String: [Indicator]], profile: UserProfile?) -> SeriesBucket { let m = MonitorMetric.bloodPressure let sysField = m.fields[0] let diaField = m.fields[1] let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } let sysLine = SeriesLine( id: "bp.systolic", seriesKey: "bp.systolic", label: String(appLoc: "收缩"), color: Tj.Palette.brick, points: sysItems.compactMap { point(from: $0) }, referenceRange: m.effectiveRange(for: sysField, profile: profile) ) let diaLine = SeriesLine( id: "bp.diastolic", seriesKey: "bp.diastolic", label: String(appLoc: "舒张"), color: Tj.Palette.leaf, points: diaItems.compactMap { point(from: $0) }, referenceRange: m.effectiveRange(for: diaField, profile: profile) ) let latest = max( sysItems.last?.capturedAt ?? .distantPast, diaItems.last?.capturedAt ?? .distantPast ) // 只保留有数据点的线:某一路(收缩/舒张)为空时不画空线 + 残缺图例。 let lines = [sysLine, diaLine].filter { !$0.points.isEmpty } return SeriesBucket( id: "bp", title: String(appLoc: "血压"), unit: "mmHg", lines: lines, latestDate: latest ) } private static func point(from i: Indicator) -> Point? { guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil } return Point( id: "\(i.persistentModelID)", date: i.capturedAt, value: v, status: i.status ) } private static func monitorMetric(for seriesKey: String) -> MonitorMetric? { MonitorMetric.allCases.first { m in m.fields.contains { $0.seriesKey == seriesKey } } } } private extension String { /// 空串 → fallback;非空 → 自身。 func nonEmptyOr(_ fallback: String?) -> String? { trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self } }