Files
iccery-v2-mac/Sources/ICCery/DriftChartView.swift
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f769cf7fec Milestone 5 — Profile generation, verification & installation (#23–#27).
Add the Stage 4/5 native workflow: deterministic colprof/applycal/iccgamut
and profcheck argv builders, verification history with CSV export and drift
analytics, user/system ColorSync profile installation, and the matching
SwiftUI views and fixture-driven tests. All Argyll interaction remains
AGPL-isolated via ProcessManager subprocesses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 10:43:12 +01:00

122 lines
4.9 KiB
Swift

import SwiftUI
import ICCeryCore
/// Minimal line chart for drift history without depending on the
/// `Charts` framework link. Renders avg/max series with shaded quality
/// bands.
struct DriftChartView: View {
let records: [VerificationRecord]
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack(alignment: .topLeading) {
if let (_, _, _, maxV) = scales(in: height) {
// Quality bands — bottom (red, > 3.5) drawn first, then
// orange, yellow, green so the upper-most bands overlay.
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
}
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
// Average ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.avgDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.blue, lineWidth: 2)
.accessibilityIdentifier("driftAvgSeries")
// Max ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.maxDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.orange, lineWidth: 2)
.accessibilityIdentifier("driftMaxSeries")
} else {
Text("No data")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
private func band(
from lower: Double,
to upper: Double,
color: Color,
height: CGFloat,
maxValue: Double
) -> some View {
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
return color
.frame(height: yBottom - yTop)
.offset(y: yTop)
}
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
return (minT, maxT, 0.0, maxV)
}
private func point(
for record: VerificationRecord,
minTime: Date,
maxTime: Date,
minValue: Double,
maxValue: Double,
width: CGFloat,
height: CGFloat,
keyPath: KeyPath<VerificationRecord, Double>
) -> CGPoint {
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
return CGPoint(x: x, y: y)
}
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
let valueSpan = max(1, maxValue - minValue)
return height - height * CGFloat((value - minValue) / valueSpan)
}
}