@@ -7,11 +7,14 @@ struct SnapshotDetailView: View {
|
||
| 7 | 7 |
let snapshot: HealthSnapshot |
| 8 | 8 |
let baseline: HealthSnapshot? |
| 9 | 9 |
let profile: DeviceProfile? |
| 10 |
+ private let contextRadius = 3 |
|
| 10 | 11 |
|
| 11 | 12 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 12 | 13 |
|
| 13 | 14 |
private let diffService = SnapshotDiffService.shared |
| 14 | 15 |
|
| 16 |
+ @State private var xAxisMode: EvolutionXAxisMode = .time |
|
| 17 |
+ |
|
| 15 | 18 |
private var sortedTypeCounts: [TypeCount] {
|
| 16 | 19 |
(snapshot.typeCounts ?? []).sorted {
|
| 17 | 20 |
$0.displayName.localizedCompare($1.displayName) == .orderedAscending |
@@ -44,9 +47,45 @@ struct SnapshotDetailView: View {
|
||
| 44 | 47 |
} |
| 45 | 48 |
} |
| 46 | 49 |
|
| 50 |
+ private var timelineContextSnapshots: [HealthSnapshot] {
|
|
| 51 |
+ guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == snapshot.id }) else {
|
|
| 52 |
+ return [snapshot] |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 55 |
+ let desiredCount = contextRadius * 2 + 1 |
|
| 56 |
+ var start = max(0, currentIndex - contextRadius) |
|
| 57 |
+ var end = min(timelineSnapshots.count - 1, currentIndex + contextRadius) |
|
| 58 |
+ |
|
| 59 |
+ let currentCount = end - start + 1 |
|
| 60 |
+ if currentCount < desiredCount {
|
|
| 61 |
+ let missing = desiredCount - currentCount |
|
| 62 |
+ |
|
| 63 |
+ let extraBefore = min(start, missing) |
|
| 64 |
+ start -= extraBefore |
|
| 65 |
+ |
|
| 66 |
+ let remaining = missing - extraBefore |
|
| 67 |
+ let availableAfter = timelineSnapshots.count - 1 - end |
|
| 68 |
+ let extraAfter = min(availableAfter, remaining) |
|
| 69 |
+ end += extraAfter |
|
| 70 |
+ |
|
| 71 |
+ if extraAfter < remaining {
|
|
| 72 |
+ let finalRemaining = remaining - extraAfter |
|
| 73 |
+ let availableBefore = start |
|
| 74 |
+ let finalExtraBefore = min(availableBefore, finalRemaining) |
|
| 75 |
+ start -= finalExtraBefore |
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ return Array(timelineSnapshots[start...end]) |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ private var isTimelineContextTrimmed: Bool {
|
|
| 83 |
+ timelineContextSnapshots.count < timelineSnapshots.count |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 47 | 86 |
private var evolutionSeries: [TypeEvolutionSeries] {
|
| 48 | 87 |
sortedTypeCounts.compactMap { typeCount in
|
| 49 |
- let points = timelineSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
|
|
| 88 |
+ let points = timelineContextSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
|
|
| 50 | 89 |
guard let candidateTypeCount = candidate.typeCounts?.first(where: {
|
| 51 | 90 |
$0.typeIdentifier == typeCount.typeIdentifier |
| 52 | 91 |
}), |
@@ -83,7 +122,6 @@ struct SnapshotDetailView: View {
|
||
| 83 | 122 |
comparisonSection(baseline) |
| 84 | 123 |
} |
| 85 | 124 |
evolutionSection |
| 86 |
- typeCountsSection |
|
| 87 | 125 |
} |
| 88 | 126 |
.navigationTitle("Snapshot")
|
| 89 | 127 |
.navigationBarTitleDisplayMode(.inline) |
@@ -177,34 +215,66 @@ struct SnapshotDetailView: View {
|
||
| 177 | 215 |
} |
| 178 | 216 |
|
| 179 | 217 |
private var evolutionSection: some View {
|
| 180 |
- Section("Evolution") {
|
|
| 181 |
- if evolutionSeries.isEmpty {
|
|
| 182 |
- Text("No chartable data for this snapshot.")
|
|
| 218 |
+ Section("Data Types") {
|
|
| 219 |
+ HStack {
|
|
| 220 |
+ Text("X-Axis")
|
|
| 183 | 221 |
.foregroundStyle(.secondary) |
| 222 |
+ Spacer() |
|
| 223 |
+ Picker("X-Axis", selection: $xAxisMode) {
|
|
| 224 |
+ ForEach(EvolutionXAxisMode.allCases) { mode in
|
|
| 225 |
+ Text(mode.title).tag(mode) |
|
| 226 |
+ } |
|
| 227 |
+ } |
|
| 228 |
+ .pickerStyle(.segmented) |
|
| 229 |
+ .frame(maxWidth: 220) |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ if evolutionSeries.isEmpty {
|
|
| 233 |
+ if sortedTypeCounts.isEmpty {
|
|
| 234 |
+ Text("No tracked data types in this snapshot.")
|
|
| 235 |
+ .foregroundStyle(.secondary) |
|
| 236 |
+ } else {
|
|
| 237 |
+ ForEach(sortedTypeCounts) { typeCount in
|
|
| 238 |
+ SnapshotTypeCountRow( |
|
| 239 |
+ typeCount: typeCount, |
|
| 240 |
+ baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier] |
|
| 241 |
+ ) |
|
| 242 |
+ } |
|
| 243 |
+ } |
|
| 184 | 244 |
} else {
|
| 185 | 245 |
ForEach(evolutionSeries) { series in
|
| 186 | 246 |
TypeEvolutionChart( |
| 187 | 247 |
series: series, |
| 188 |
- selectedSnapshotID: snapshot.id |
|
| 248 |
+ contextSnapshots: timelineContextSnapshots, |
|
| 249 |
+ xAxisMode: xAxisMode, |
|
| 250 |
+ selectedSnapshotID: snapshot.id, |
|
| 251 |
+ selectedTimestamp: snapshot.timestamp, |
|
| 252 |
+ baselineTypeCount: baselineTypeMap[series.typeIdentifier] |
|
| 189 | 253 |
) |
| 190 | 254 |
} |
| 255 |
+ |
|
| 256 |
+ if isTimelineContextTrimmed {
|
|
| 257 |
+ Text("Charts show only the local window: 3 snapshots before and 3 after the current one.")
|
|
| 258 |
+ .font(.caption) |
|
| 259 |
+ .foregroundStyle(.secondary) |
|
| 260 |
+ } |
|
| 191 | 261 |
} |
| 192 | 262 |
} |
| 193 | 263 |
} |
| 264 |
+} |
|
| 194 | 265 |
|
| 195 |
- private var typeCountsSection: some View {
|
|
| 196 |
- Section("Data Types") {
|
|
| 197 |
- if sortedTypeCounts.isEmpty {
|
|
| 198 |
- Text("No tracked data types in this snapshot.")
|
|
| 199 |
- .foregroundStyle(.secondary) |
|
| 200 |
- } else {
|
|
| 201 |
- ForEach(sortedTypeCounts) { typeCount in
|
|
| 202 |
- SnapshotTypeCountRow( |
|
| 203 |
- typeCount: typeCount, |
|
| 204 |
- baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier] |
|
| 205 |
- ) |
|
| 206 |
- } |
|
| 207 |
- } |
|
| 266 |
+private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
|
|
| 267 |
+ case time |
|
| 268 |
+ case snapshots |
|
| 269 |
+ |
|
| 270 |
+ var id: String { rawValue }
|
|
| 271 |
+ |
|
| 272 |
+ var title: String {
|
|
| 273 |
+ switch self {
|
|
| 274 |
+ case .time: |
|
| 275 |
+ return "Time" |
|
| 276 |
+ case .snapshots: |
|
| 277 |
+ return "Snapshots" |
|
| 208 | 278 |
} |
| 209 | 279 |
} |
| 210 | 280 |
} |
@@ -249,45 +319,238 @@ private struct TypeEvolutionPoint: Identifiable {
|
||
| 249 | 319 |
|
| 250 | 320 |
private struct TypeEvolutionChart: View {
|
| 251 | 321 |
let series: TypeEvolutionSeries |
| 322 |
+ let contextSnapshots: [HealthSnapshot] |
|
| 323 |
+ let xAxisMode: EvolutionXAxisMode |
|
| 252 | 324 |
let selectedSnapshotID: UUID |
| 325 |
+ let selectedTimestamp: Date |
|
| 326 |
+ let baselineTypeCount: TypeCount? |
|
| 327 |
+ |
|
| 328 |
+ private struct SnapshotAxisPoint: Identifiable {
|
|
| 329 |
+ let snapshotID: UUID |
|
| 330 |
+ let contextIndex: Int |
|
| 331 |
+ let timestamp: Date |
|
| 332 |
+ let count: Int |
|
| 333 |
+ |
|
| 334 |
+ var id: UUID { snapshotID }
|
|
| 335 |
+ } |
|
| 253 | 336 |
|
| 254 | 337 |
private var selectedPoint: TypeEvolutionPoint? {
|
| 255 | 338 |
series.points.first { $0.snapshotID == selectedSnapshotID }
|
| 256 | 339 |
} |
| 257 | 340 |
|
| 258 |
- var body: some View {
|
|
| 259 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 260 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 261 |
- Text(series.displayName) |
|
| 262 |
- .font(.subheadline.weight(.semibold)) |
|
| 263 |
- Spacer() |
|
| 264 |
- if let selectedPoint {
|
|
| 265 |
- Text("\(selectedPoint.count)")
|
|
| 266 |
- .font(.subheadline.monospacedDigit()) |
|
| 267 |
- .foregroundStyle(.secondary) |
|
| 341 |
+ private var isMissingInSelectedSnapshot: Bool {
|
|
| 342 |
+ selectedPoint == nil |
|
| 343 |
+ } |
|
| 344 |
+ |
|
| 345 |
+ private var previousPoint: TypeEvolutionPoint? {
|
|
| 346 |
+ guard let selectedIndex = series.points.firstIndex(where: { $0.snapshotID == selectedSnapshotID }),
|
|
| 347 |
+ selectedIndex > 0 else { return nil }
|
|
| 348 |
+ return series.points[selectedIndex - 1] |
|
| 349 |
+ } |
|
| 350 |
+ |
|
| 351 |
+ private var delta: Int? {
|
|
| 352 |
+ guard let selected = selectedPoint, |
|
| 353 |
+ let previous = previousPoint, |
|
| 354 |
+ selected.count >= 0, |
|
| 355 |
+ previous.count >= 0 else { return nil }
|
|
| 356 |
+ return selected.count - previous.count |
|
| 357 |
+ } |
|
| 358 |
+ |
|
| 359 |
+ private var contextPointCountLabel: String {
|
|
| 360 |
+ "\(series.points.count)/\(contextSnapshots.count) snapshots with data" |
|
| 361 |
+ } |
|
| 362 |
+ |
|
| 363 |
+ private var contextAxisPoints: [SnapshotAxisPoint] {
|
|
| 364 |
+ contextSnapshots.enumerated().compactMap { index, snapshot in
|
|
| 365 |
+ guard let candidateTypeCount = snapshot.typeCounts?.first(where: {
|
|
| 366 |
+ $0.typeIdentifier == series.typeIdentifier |
|
| 367 |
+ }), candidateTypeCount.count >= 0 else {
|
|
| 368 |
+ return nil |
|
| 369 |
+ } |
|
| 370 |
+ |
|
| 371 |
+ return SnapshotAxisPoint( |
|
| 372 |
+ snapshotID: snapshot.id, |
|
| 373 |
+ contextIndex: index, |
|
| 374 |
+ timestamp: snapshot.timestamp, |
|
| 375 |
+ count: candidateTypeCount.count |
|
| 376 |
+ ) |
|
| 377 |
+ } |
|
| 378 |
+ } |
|
| 379 |
+ |
|
| 380 |
+ private var contextAxisGroups: [[SnapshotAxisPoint]] {
|
|
| 381 |
+ guard !contextAxisPoints.isEmpty else { return [] }
|
|
| 382 |
+ |
|
| 383 |
+ var groups: [[SnapshotAxisPoint]] = [] |
|
| 384 |
+ var currentGroup: [SnapshotAxisPoint] = [contextAxisPoints[0]] |
|
| 385 |
+ |
|
| 386 |
+ for point in contextAxisPoints.dropFirst() {
|
|
| 387 |
+ if let previous = currentGroup.last, point.contextIndex == previous.contextIndex + 1 {
|
|
| 388 |
+ currentGroup.append(point) |
|
| 389 |
+ } else {
|
|
| 390 |
+ groups.append(currentGroup) |
|
| 391 |
+ currentGroup = [point] |
|
| 392 |
+ } |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ groups.append(currentGroup) |
|
| 396 |
+ return groups |
|
| 397 |
+ } |
|
| 398 |
+ |
|
| 399 |
+ private var selectedContextIndex: Int? {
|
|
| 400 |
+ contextSnapshots.firstIndex { $0.id == selectedSnapshotID }
|
|
| 401 |
+ } |
|
| 402 |
+ |
|
| 403 |
+ private var snapshotAxisValues: [Int] {
|
|
| 404 |
+ Array(contextSnapshots.indices) |
|
| 405 |
+ } |
|
| 406 |
+ |
|
| 407 |
+ private func snapshotAxisLabel(for index: Int) -> String {
|
|
| 408 |
+ guard let selectedContextIndex else {
|
|
| 409 |
+ return "S\(index + 1)" |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ let offset = index - selectedContextIndex |
|
| 413 |
+ if offset == 0 { return "Current" }
|
|
| 414 |
+ if offset > 0 { return "S+\(offset)" }
|
|
| 415 |
+ return "S\(offset)" |
|
| 416 |
+ } |
|
| 417 |
+ |
|
| 418 |
+ private var snapshotAxisDomain: ClosedRange<Int> {
|
|
| 419 |
+ guard let first = snapshotAxisValues.first, let last = snapshotAxisValues.last else {
|
|
| 420 |
+ return 0...0 |
|
| 421 |
+ } |
|
| 422 |
+ return first...last |
|
| 423 |
+ } |
|
| 424 |
+ |
|
| 425 |
+ @ViewBuilder |
|
| 426 |
+ private var chartContent: some View {
|
|
| 427 |
+ switch xAxisMode {
|
|
| 428 |
+ case .time: |
|
| 429 |
+ timeChart |
|
| 430 |
+ case .snapshots: |
|
| 431 |
+ snapshotChart |
|
| 432 |
+ } |
|
| 433 |
+ } |
|
| 434 |
+ |
|
| 435 |
+ private var timeChart: some View {
|
|
| 436 |
+ Chart {
|
|
| 437 |
+ ForEach(contextSnapshots, id: \.id) { item in
|
|
| 438 |
+ RuleMark(x: .value("Timeline", item.timestamp))
|
|
| 439 |
+ .foregroundStyle(Color.secondary.opacity(0.10)) |
|
| 440 |
+ } |
|
| 441 |
+ |
|
| 442 |
+ RuleMark(x: .value("Selected Snapshot", selectedTimestamp))
|
|
| 443 |
+ .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) |
|
| 444 |
+ .foregroundStyle(Color.secondary.opacity(0.55)) |
|
| 445 |
+ |
|
| 446 |
+ ForEach(series.points) { point in
|
|
| 447 |
+ LineMark( |
|
| 448 |
+ x: .value("Date", point.timestamp),
|
|
| 449 |
+ y: .value("Records", point.count)
|
|
| 450 |
+ ) |
|
| 451 |
+ .interpolationMethod(.linear) |
|
| 452 |
+ |
|
| 453 |
+ PointMark( |
|
| 454 |
+ x: .value("Date", point.timestamp),
|
|
| 455 |
+ y: .value("Records", point.count)
|
|
| 456 |
+ ) |
|
| 457 |
+ .symbolSize(24) |
|
| 458 |
+ |
|
| 459 |
+ if point.snapshotID == selectedSnapshotID {
|
|
| 460 |
+ PointMark( |
|
| 461 |
+ x: .value("Selected Date", point.timestamp),
|
|
| 462 |
+ y: .value("Selected Records", point.count)
|
|
| 463 |
+ ) |
|
| 464 |
+ .symbolSize(64) |
|
| 268 | 465 |
} |
| 269 | 466 |
} |
| 467 |
+ } |
|
| 468 |
+ } |
|
| 469 |
+ |
|
| 470 |
+ private var snapshotChart: some View {
|
|
| 471 |
+ Chart {
|
|
| 472 |
+ ForEach(contextSnapshots.indices, id: \.self) { index in
|
|
| 473 |
+ RuleMark(x: .value("Snapshot", index))
|
|
| 474 |
+ .foregroundStyle(Color.secondary.opacity(0.10)) |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ if let selectedContextIndex {
|
|
| 478 |
+ RuleMark(x: .value("Selected Snapshot", selectedContextIndex))
|
|
| 479 |
+ .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) |
|
| 480 |
+ .foregroundStyle(Color.secondary.opacity(0.55)) |
|
| 481 |
+ } |
|
| 482 |
+ |
|
| 483 |
+ ForEach(contextAxisGroups.indices, id: \.self) { groupIndex in
|
|
| 484 |
+ let group = contextAxisGroups[groupIndex] |
|
| 270 | 485 |
|
| 271 |
- Chart {
|
|
| 272 |
- ForEach(series.points) { point in
|
|
| 486 |
+ ForEach(group) { point in
|
|
| 273 | 487 |
LineMark( |
| 274 |
- x: .value("Date", point.timestamp),
|
|
| 488 |
+ x: .value("Snapshot", point.contextIndex),
|
|
| 489 |
+ y: .value("Records", point.count)
|
|
| 490 |
+ ) |
|
| 491 |
+ .interpolationMethod(.linear) |
|
| 492 |
+ |
|
| 493 |
+ PointMark( |
|
| 494 |
+ x: .value("Snapshot", point.contextIndex),
|
|
| 275 | 495 |
y: .value("Records", point.count)
|
| 276 | 496 |
) |
| 277 |
- .interpolationMethod(.catmullRom) |
|
| 497 |
+ .symbolSize(24) |
|
| 278 | 498 |
|
| 279 | 499 |
if point.snapshotID == selectedSnapshotID {
|
| 280 | 500 |
PointMark( |
| 281 |
- x: .value("Selected Date", point.timestamp),
|
|
| 501 |
+ x: .value("Selected Snapshot", point.contextIndex),
|
|
| 282 | 502 |
y: .value("Selected Records", point.count)
|
| 283 | 503 |
) |
| 284 | 504 |
.symbolSize(64) |
| 285 | 505 |
} |
| 286 | 506 |
} |
| 287 | 507 |
} |
| 508 |
+ } |
|
| 509 |
+ .chartXAxis {
|
|
| 510 |
+ AxisMarks(values: snapshotAxisValues) { value in
|
|
| 511 |
+ AxisGridLine() |
|
| 512 |
+ AxisTick() |
|
| 513 |
+ if let rawIndex = value.as(Int.self) {
|
|
| 514 |
+ AxisValueLabel(snapshotAxisLabel(for: rawIndex)) |
|
| 515 |
+ } |
|
| 516 |
+ } |
|
| 517 |
+ } |
|
| 518 |
+ .chartXScale(domain: snapshotAxisDomain) |
|
| 519 |
+ } |
|
| 520 |
+ |
|
| 521 |
+ var body: some View {
|
|
| 522 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 523 |
+ HStack(alignment: .firstTextBaseline) {
|
|
| 524 |
+ Text(series.displayName) |
|
| 525 |
+ .font(.subheadline.weight(.semibold)) |
|
| 526 |
+ Spacer() |
|
| 527 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 528 |
+ if let selectedPoint {
|
|
| 529 |
+ Text("\(selectedPoint.count)")
|
|
| 530 |
+ .font(.subheadline.monospacedDigit()) |
|
| 531 |
+ .foregroundStyle(.secondary) |
|
| 532 |
+ } |
|
| 533 |
+ if let delta {
|
|
| 534 |
+ SeverityBadge(delta: delta) |
|
| 535 |
+ } |
|
| 536 |
+ } |
|
| 537 |
+ } |
|
| 538 |
+ |
|
| 539 |
+ chartContent |
|
| 288 | 540 |
.chartYScale(domain: series.yDomain) |
| 289 | 541 |
.chartXAxis {
|
| 290 |
- AxisMarks(values: .automatic(desiredCount: 3)) |
|
| 542 |
+ switch xAxisMode {
|
|
| 543 |
+ case .time: |
|
| 544 |
+ AxisMarks(values: .automatic(desiredCount: 3)) |
|
| 545 |
+ case .snapshots: |
|
| 546 |
+ AxisMarks(values: snapshotAxisValues) { value in
|
|
| 547 |
+ AxisGridLine() |
|
| 548 |
+ AxisTick() |
|
| 549 |
+ if let rawIndex = value.as(Int.self) {
|
|
| 550 |
+ AxisValueLabel(snapshotAxisLabel(for: rawIndex)) |
|
| 551 |
+ } |
|
| 552 |
+ } |
|
| 553 |
+ } |
|
| 291 | 554 |
} |
| 292 | 555 |
.chartYAxis {
|
| 293 | 556 |
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) |
@@ -295,11 +558,19 @@ private struct TypeEvolutionChart: View {
|
||
| 295 | 558 |
.frame(height: 120) |
| 296 | 559 |
.foregroundStyle(Color.accentColor) |
| 297 | 560 |
|
| 298 |
- if series.points.count == 1 {
|
|
| 561 |
+ if isMissingInSelectedSnapshot {
|
|
| 562 |
+ Text("Datatype missing in this snapshot")
|
|
| 563 |
+ .font(.caption2) |
|
| 564 |
+ .foregroundStyle(Color.warningAmber) |
|
| 565 |
+ } else if series.points.count == 1 {
|
|
| 299 | 566 |
Text("Only one measurement")
|
| 300 | 567 |
.font(.caption2) |
| 301 | 568 |
.foregroundStyle(.secondary) |
| 302 | 569 |
} |
| 570 |
+ |
|
| 571 |
+ Text(contextPointCountLabel) |
|
| 572 |
+ .font(.caption2) |
|
| 573 |
+ .foregroundStyle(.secondary) |
|
| 303 | 574 |
} |
| 304 | 575 |
.padding(.vertical, 4) |
| 305 | 576 |
.accessibilityElement(children: .combine) |