256 lines
9.4 KiB
Swift
256 lines
9.4 KiB
Swift
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<Double>?
|
||
|
||
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<String> = ["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<Double>? {
|
||
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..<s.endIndex, in: s)
|
||
guard let m = regex.firstMatch(in: s, range: range),
|
||
let r1 = Range(m.range(at: 1), in: s),
|
||
let r2 = Range(m.range(at: 2), in: s),
|
||
let lo = Double(s[r1]), let hi = Double(s[r2]),
|
||
lo <= hi else { return nil }
|
||
return lo...hi
|
||
}
|
||
|
||
private static func buildLab(items: [Indicator]) -> 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
|
||
}
|
||
}
|