import SwiftUI import SwiftData import Foundation /// 趋势桶的来源类别。 /// - `.monitor`:长期监测预设 / 自定义 / 血压(按 seriesKey 分组)。 /// - `.lab`:任意出现 ≥2 次的化验/手动/报告指标(按 name+unit 分组,无 seriesKey)。 enum SeriesKind { case monitor, lab } /// 长期监测系列在 Trends 折线图里的展示桶。 /// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。 struct SeriesBucket: Identifiable { let id: String let title: String let unit: String let lines: [SeriesLine] let latestDate: Date let kind: SeriesKind 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) } } // —— lab 段:任何没有 seriesKey 的指标,按 name+unit 归并;同名出现 ≥minPoints 次即成趋势。 var labBuckets: [String: [Indicator]] = [:] for i in indicators { if let key = i.seriesKey, !key.isEmpty { continue } // seriesKey 指标只进 monitor 段 let nk = normalizedKey(name: i.name, unit: i.unit) guard !nk.isEmpty else { continue } labBuckets[nk, default: []].append(i) } for (_, items) in labBuckets { guard items.count >= minPoints else { continue } if let bucket = buildLab(items: items) { results.append(bucket) } } return results.sorted { $0.latestDate > $1.latestDate } } /// name+unit 归一化:trim + 小写 + 折叠内部空白。空名返回空串(调用方跳过)。 static func normalizedKey(name: String, unit: String) -> String { func norm(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty } .joined(separator: " ") } let n = norm(name) guard !n.isEmpty else { return "" } return n + "|" + norm(unit) } /// 解析参考范围字符串 → ClosedRange。支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1"。 /// 单边("<5.2" / ">40" / "≤120")暂返回 nil(图不画带,正常)。 static func parseRange(_ raw: String) -> ClosedRange? { let s = raw.replacingOccurrences(of: "~", with: "~") .replacingOccurrences(of: "~", with: "-") guard let regex = try? NSRegularExpression( pattern: #"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)"# ) else { return nil } let range = NSRange(s.startIndex.. SeriesBucket? { let sorted = items.sorted { $0.capturedAt < $1.capturedAt } guard let latest = sorted.last else { return nil } let points = sorted.compactMap { point(from: $0) } guard points.count >= 2 else { return nil } // 值无法解析为数字的会被丢弃,可能不足 2 点 let line = SeriesLine( id: "lab:\(latest.name)", seriesKey: "lab:\(latest.name)", label: nil, color: Tj.Palette.ink, points: points, referenceRange: parseRange(latest.range) ) return SeriesBucket( id: "lab:\(normalizedKey(name: latest.name, unit: latest.unit))", title: latest.name, unit: latest.unit, lines: [line], latestDate: latest.capturedAt, kind: .lab ) } 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, kind: .monitor ) } 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, kind: .monitor ) } 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 } }