Files
kangkang/康康/Features/Trends/SeriesBucket.swift
link2026 b3777d508d 根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
```
chore(project): 更新项目配置文件

移除未使用的依赖项并优化构建配置,
提升项目整体性能和可维护性。
```
2026-06-16 00:01:48 +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.teal,
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.teal,
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.teal,
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
}
}