Files
kangkang/康康/Features/Trends/SeriesBucket.swift
link2026 60b6ad6d65 缺少代码差异信息,无法生成具体的commit message。
请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
2026-06-07 09:40:59 +08:00

256 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}