Files
steipete--codexbar/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
T
2026-07-13 12:22:33 +08:00

854 lines
33 KiB
Swift

import Charts
import CodexBarCore
import SwiftUI
@MainActor
struct PlanUtilizationHistoryChartMenuView: View {
private enum Layout {
static let chartHeight: CGFloat = 130
static let detailHeight: CGFloat = 16
static let emptyStateHeight: CGFloat = chartHeight + detailHeight
static let maxPoints = 30
static let maxAxisLabels = 4
static let barWidth: CGFloat = 6
}
private struct SeriesSelection: Hashable {
let name: PlanUtilizationSeriesName
let windowMinutes: Int
var id: String {
"\(self.name.rawValue):\(self.windowMinutes)"
}
}
private struct VisibleSeries: Identifiable, Equatable {
let selection: SeriesSelection
let title: String
let history: PlanUtilizationSeriesHistory
var id: String {
self.selection.id
}
}
private struct EntryPointAccumulator {
let effectiveBoundaryDate: Date
let displayBoundaryDate: Date
let observedAt: Date
let usedPercent: Double
let hasObservedResetBoundary: Bool
}
private struct ResetBoundaryLattice {
let referenceBoundaryDate: Date
let windowInterval: TimeInterval
}
private struct Point: Identifiable {
let id: Date
let index: Int
let date: Date
let usedPercent: Double
let isObserved: Bool
}
private struct Model {
let points: [Point]
let axisIndexes: [Double]
let xDomain: ClosedRange<Double>?
let pointsByID: [Date: Point]
let pointsByIndex: [Int: Point]
let barColor: Color
let trackColor: Color
}
private let provider: UsageProvider
private let visibleSeries: [VisibleSeries]
private let modelsBySeriesID: [String: Model]
private let emptyModel: Model
private let width: CGFloat
@State private var selectedSeriesID: String?
@State private var selectedPointID: Date?
init(
provider: UsageProvider,
histories: [PlanUtilizationSeriesHistory],
snapshot: UsageSnapshot? = nil,
width: CGFloat)
{
self.provider = provider
let visibleSeries = Self.visibleSeries(
histories: histories,
provider: provider,
snapshot: snapshot)
let referenceDate = Date()
self.visibleSeries = visibleSeries
self.modelsBySeriesID = Dictionary(uniqueKeysWithValues: visibleSeries.map {
($0.id, Self.makeModel(history: $0.history, provider: provider, referenceDate: referenceDate))
})
self.emptyModel = Self.emptyModel(provider: provider)
self.width = width
}
var body: some View {
let effectiveSelectedSeries = self.visibleSeries.first(where: { $0.id == self.selectedSeriesID })
?? self.visibleSeries.first
let model = effectiveSelectedSeries.flatMap { self.modelsBySeriesID[$0.id] } ?? self.emptyModel
VStack(alignment: .leading, spacing: 10) {
if self.visibleSeries.count > 1 {
Picker(selection: Binding(
get: { effectiveSelectedSeries?.id ?? "" },
set: { newValue in
self.selectedSeriesID = newValue
self.selectedPointID = nil
})) {
ForEach(self.visibleSeries) { series in
Text(series.title).tag(series.id)
}
} label: {
EmptyView()
}
.labelsHidden()
.pickerStyle(.segmented)
}
if model.points.isEmpty {
ZStack {
Text(Self.emptyStateText(title: effectiveSelectedSeries?.title))
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: Layout.emptyStateHeight)
} else {
self.utilizationChart(model: model)
.chartYAxis(.hidden)
.chartYScale(domain: 0...100)
.chartXAxis {
AxisMarks(values: model.axisIndexes) { value in
AxisGridLine().foregroundStyle(Color.clear)
AxisTick().foregroundStyle(Color.clear)
AxisValueLabel {
if let raw = value.as(Double.self) {
let index = Int(raw.rounded())
if let point = model.pointsByIndex[index] {
let isTrailingFullChartLabel = index == model.points.last?.index
&& model.points.count == Layout.maxPoints
Self.axisLabel(
for: point,
windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0,
isTrailingFullChartLabel: isTrailingFullChartLabel)
}
}
}
}
}
.chartLegend(.hidden)
.frame(height: Layout.chartHeight)
.accessibilityLabel(L("Plan utilization chart"))
.accessibilityValue(
model.points.isEmpty
? L("No data")
: String(format: L("%d utilization samples"), model.points.count))
.chartOverlay { proxy in
GeometryReader { geo in
MouseLocationReader { location in
self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
}
Text(self.detailLine(model: model, windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: Layout.detailHeight, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading)
.task(id: self.visibleSeries.map(\.id).joined(separator: ",")) {
guard let firstVisibleSeries = self.visibleSeries.first else { return }
guard !self.visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return }
self.selectedSeriesID = firstVisibleSeries.id
self.selectedPointID = nil
}
}
private nonisolated static func visibleSeries(
histories: [PlanUtilizationSeriesHistory],
provider: UsageProvider,
snapshot: UsageSnapshot?) -> [VisibleSeries]
{
let metadata = ProviderDescriptorRegistry.metadata[provider]
let allowedNames = self.visibleSeriesNames(provider: provider, snapshot: snapshot)
var historiesBySelection: [SeriesSelection: PlanUtilizationSeriesHistory] = [:]
for history in histories {
guard !history.entries.isEmpty else { continue }
guard history.windowMinutes > 0 else { continue }
guard allowedNames?.contains(history.name) ?? true else { continue }
let canonicalWindowMinutes = history.name.canonicalWindowMinutes(history.windowMinutes)
let selection = SeriesSelection(name: history.name, windowMinutes: canonicalWindowMinutes)
if let existingHistory = historiesBySelection[selection] {
historiesBySelection[selection] = PlanUtilizationSeriesHistory(
name: history.name,
windowMinutes: canonicalWindowMinutes,
entries: Self.mergedEntries(existingHistory.entries + history.entries))
} else {
historiesBySelection[selection] = PlanUtilizationSeriesHistory(
name: history.name,
windowMinutes: canonicalWindowMinutes,
entries: history.entries)
}
}
return historiesBySelection.values
.sorted { lhs, rhs in
let lhsOrder = self.seriesSortOrder(lhs.name)
let rhsOrder = self.seriesSortOrder(rhs.name)
if lhsOrder != rhsOrder {
return lhsOrder < rhsOrder
}
if lhs.windowMinutes != rhs.windowMinutes {
return lhs.windowMinutes < rhs.windowMinutes
}
return lhs.name.rawValue < rhs.name.rawValue
}
.map { history in
VisibleSeries(
selection: SeriesSelection(name: history.name, windowMinutes: history.windowMinutes),
title: self.seriesTitle(name: history.name, metadata: metadata),
history: history)
}
}
nonisolated static func mergedEntries(
_ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry]
{
var seen: Set<PlanUtilizationHistoryEntry> = []
return entries.filter { entry in
seen.insert(entry).inserted
}
}
private nonisolated static func visibleSeriesNames(
provider: UsageProvider,
snapshot: UsageSnapshot?) -> Set<PlanUtilizationSeriesName>?
{
guard let snapshot else { return nil }
var names: Set<PlanUtilizationSeriesName> = []
switch provider {
case .codex:
if snapshot.primary != nil { names.insert(.session) }
if snapshot.secondary != nil { names.insert(.weekly) }
case .claude:
if snapshot.primary != nil { names.insert(.session) }
if snapshot.secondary != nil { names.insert(.weekly) }
if snapshot.tertiary != nil,
ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true
{
names.insert(.opus)
}
default:
let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self)
+ (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? [])
guard windows.contains(where: { $0.windowMinutes == 7 * 24 * 60 }) else { return nil }
names.insert(.weekly)
}
return names
}
private nonisolated static func makeModel(
history: PlanUtilizationSeriesHistory?,
provider: UsageProvider,
referenceDate: Date) -> Model
{
guard let history else {
return self.emptyModel(provider: provider)
}
var points = self.seriesPoints(history: history, referenceDate: referenceDate)
if points.count > Layout.maxPoints {
points = Array(points.suffix(Layout.maxPoints))
}
points = points.enumerated().map { offset, point in
Point(
id: point.id,
index: offset,
date: point.date,
usedPercent: point.usedPercent,
isObserved: point.isObserved)
}
let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) })
let pointsByIndex = Dictionary(uniqueKeysWithValues: points.map { ($0.index, $0) })
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
let barColor = Color(red: color.red, green: color.green, blue: color.blue)
let trackColor = MenuHighlightStyle.progressTrack(false)
return Model(
points: points,
axisIndexes: self.axisIndexes(points: points, windowMinutes: history.windowMinutes),
xDomain: self.xDomain(points: points),
pointsByID: pointsByID,
pointsByIndex: pointsByIndex,
barColor: barColor,
trackColor: trackColor)
}
private nonisolated static func emptyModel(provider: UsageProvider) -> Model {
let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color
let barColor = Color(red: color.red, green: color.green, blue: color.blue)
let trackColor = MenuHighlightStyle.progressTrack(false)
return Model(
points: [],
axisIndexes: [],
xDomain: nil,
pointsByID: [:],
pointsByIndex: [:],
barColor: barColor,
trackColor: trackColor)
}
private nonisolated static func seriesPoints(
history: PlanUtilizationSeriesHistory,
referenceDate: Date) -> [Point]
{
guard history.windowMinutes > 0 else { return [] }
let windowInterval = Double(history.windowMinutes) * 60
let resetBoundaryLattice = self.resetBoundaryLattice(
entries: history.entries,
windowMinutes: history.windowMinutes)
var strongestObservedPointByPeriod: [Date: EntryPointAccumulator] = [:]
for entry in history.entries {
let candidate = self.observedPointCandidate(
for: entry,
windowMinutes: history.windowMinutes,
resetBoundaryLattice: resetBoundaryLattice)
if let existing = strongestObservedPointByPeriod[candidate.effectiveBoundaryDate],
!self.shouldPreferObservedPoint(candidate, over: existing)
{
continue
}
strongestObservedPointByPeriod[candidate.effectiveBoundaryDate] = candidate
}
guard !strongestObservedPointByPeriod.isEmpty else { return [] }
let sortedPeriodBoundaryDates = strongestObservedPointByPeriod.keys.sorted()
var points: [Point] = []
var previousPeriodBoundaryDate: Date?
for periodBoundaryDate in sortedPeriodBoundaryDates {
if let previousPeriodBoundaryDate {
var cursor = previousPeriodBoundaryDate.addingTimeInterval(windowInterval)
while cursor < periodBoundaryDate {
points.append(Point(
id: cursor,
index: 0,
date: cursor,
usedPercent: 0,
isObserved: false))
cursor = cursor.addingTimeInterval(windowInterval)
}
}
if let bucket = strongestObservedPointByPeriod[periodBoundaryDate] {
points.append(Point(
id: bucket.effectiveBoundaryDate,
index: 0,
date: bucket.displayBoundaryDate,
usedPercent: bucket.usedPercent,
isObserved: true))
}
previousPeriodBoundaryDate = periodBoundaryDate
}
if let lastObservedPeriodBoundaryDate = sortedPeriodBoundaryDates.last {
let currentPeriodBoundaryDate = self.currentPeriodBoundaryDate(
for: referenceDate,
windowMinutes: history.windowMinutes,
resetBoundaryLattice: resetBoundaryLattice)
if currentPeriodBoundaryDate > lastObservedPeriodBoundaryDate {
var cursor = lastObservedPeriodBoundaryDate.addingTimeInterval(windowInterval)
while cursor <= currentPeriodBoundaryDate {
points.append(Point(
id: cursor,
index: 0,
date: cursor,
usedPercent: 0,
isObserved: false))
cursor = cursor.addingTimeInterval(windowInterval)
}
}
}
return points
}
private nonisolated static func observedPointCandidate(
for entry: PlanUtilizationHistoryEntry,
windowMinutes: Int,
resetBoundaryLattice: ResetBoundaryLattice?) -> EntryPointAccumulator
{
let rawResetBoundaryDate = entry.resetsAt.map(self.normalizedBoundaryDate)
let effectiveBoundaryDate = self.effectivePeriodBoundaryDate(
for: entry,
windowMinutes: windowMinutes,
rawResetBoundaryDate: rawResetBoundaryDate,
resetBoundaryLattice: resetBoundaryLattice)
return EntryPointAccumulator(
effectiveBoundaryDate: effectiveBoundaryDate,
displayBoundaryDate: rawResetBoundaryDate ?? effectiveBoundaryDate,
observedAt: entry.capturedAt,
usedPercent: max(0, min(100, entry.usedPercent)),
hasObservedResetBoundary: rawResetBoundaryDate != nil)
}
private nonisolated static func resetBoundaryLattice(
entries: [PlanUtilizationHistoryEntry],
windowMinutes: Int) -> ResetBoundaryLattice?
{
guard let latestObservedResetBoundaryDate = entries
.compactMap(\.resetsAt)
.map(self.normalizedBoundaryDate)
.max()
else {
return nil
}
return ResetBoundaryLattice(
referenceBoundaryDate: latestObservedResetBoundaryDate,
windowInterval: Double(windowMinutes) * 60)
}
private nonisolated static func normalizedBoundaryDate(_ date: Date) -> Date {
Date(timeIntervalSince1970: floor(date.timeIntervalSince1970))
}
private nonisolated static func effectivePeriodBoundaryDate(
for entry: PlanUtilizationHistoryEntry,
windowMinutes: Int,
rawResetBoundaryDate: Date?,
resetBoundaryLattice: ResetBoundaryLattice?) -> Date
{
if let rawResetBoundaryDate {
if let resetBoundaryLattice {
return self.closestPeriodBoundaryDate(
to: rawResetBoundaryDate,
resetBoundaryLattice: resetBoundaryLattice)
}
return rawResetBoundaryDate
}
if let resetBoundaryLattice {
return self.periodBoundaryDate(
containing: entry.capturedAt,
resetBoundaryLattice: resetBoundaryLattice)
}
return self.syntheticBoundaryDate(for: entry.capturedAt, windowMinutes: windowMinutes)
}
private nonisolated static func shouldPreferObservedPoint(
_ candidate: EntryPointAccumulator,
over existing: EntryPointAccumulator) -> Bool
{
if candidate.usedPercent != existing.usedPercent {
return candidate.usedPercent > existing.usedPercent
}
if candidate.hasObservedResetBoundary != existing.hasObservedResetBoundary {
return candidate.hasObservedResetBoundary
}
if candidate.displayBoundaryDate != existing.displayBoundaryDate {
return candidate.displayBoundaryDate > existing.displayBoundaryDate
}
return candidate.observedAt >= existing.observedAt
}
private nonisolated static func currentPeriodBoundaryDate(
for referenceDate: Date,
windowMinutes: Int,
resetBoundaryLattice: ResetBoundaryLattice?) -> Date
{
if let resetBoundaryLattice {
return self.periodBoundaryDate(
containing: referenceDate,
resetBoundaryLattice: resetBoundaryLattice)
}
return self.syntheticBoundaryDate(for: referenceDate, windowMinutes: windowMinutes)
}
private nonisolated static func closestPeriodBoundaryDate(
to rawBoundaryDate: Date,
resetBoundaryLattice: ResetBoundaryLattice) -> Date
{
let offset = rawBoundaryDate.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate)
let periodOffset = (offset / resetBoundaryLattice.windowInterval).rounded()
return resetBoundaryLattice.referenceBoundaryDate
.addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval)
}
private nonisolated static func periodBoundaryDate(
containing capturedAt: Date,
resetBoundaryLattice: ResetBoundaryLattice) -> Date
{
let offset = capturedAt.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate)
let periodOffset = ceil(offset / resetBoundaryLattice.windowInterval)
return resetBoundaryLattice.referenceBoundaryDate
.addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval)
}
private nonisolated static func syntheticBoundaryDate(for date: Date, windowMinutes: Int) -> Date {
let bucketSeconds = Double(windowMinutes) * 60
let bucketIndex = floor(date.timeIntervalSince1970 / bucketSeconds)
return Date(timeIntervalSince1970: (bucketIndex + 1) * bucketSeconds)
}
private nonisolated static func xDomain(points: [Point]) -> ClosedRange<Double>? {
guard !points.isEmpty else { return nil }
return -0.5...(Double(Layout.maxPoints) - 0.5)
}
private nonisolated static func axisIndexes(points: [Point], windowMinutes: Int) -> [Double] {
let candidateIndexes = self.axisCandidateIndexes(points: points, windowMinutes: windowMinutes)
return self.proportionalAxisIndexes(points: points, candidateIndexes: candidateIndexes)
}
private nonisolated static func axisCandidateIndexes(points: [Point], windowMinutes: Int) -> [Int] {
if windowMinutes <= 300 {
return self.sessionAxisCandidateIndexes(points: points)
}
return points.map(\.index)
}
private nonisolated static func sessionAxisCandidateIndexes(points: [Point]) -> [Int] {
guard let firstPoint = points.first else { return [] }
let calendar = Calendar.current
var previousPoint = firstPoint
var rawIndexes: [Int] = [firstPoint.index]
for point in points.dropFirst() {
if !calendar.isDate(point.date, inSameDayAs: previousPoint.date) {
rawIndexes.append(point.index)
}
previousPoint = point
}
return rawIndexes
}
private nonisolated static func proportionalAxisIndexes(points: [Point], candidateIndexes: [Int]) -> [Double] {
guard !points.isEmpty, !candidateIndexes.isEmpty else { return [] }
let occupiedFraction = Double(points.count) / Double(Layout.maxPoints)
let proportionalBudget = Int(ceil(Double(Layout.maxAxisLabels) * occupiedFraction))
let labelBudget = max(1, min(Layout.maxAxisLabels, proportionalBudget, candidateIndexes.count))
if labelBudget == 1 {
return [Double(candidateIndexes[0])]
}
let step = Double(candidateIndexes.count - 1) / Double(labelBudget - 1)
var selectedIndexes = (0..<labelBudget).map { position in
let candidateOffset = Int((Double(position) * step).rounded())
return candidateIndexes[candidateOffset]
}
selectedIndexes = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes
let trailingLabelCutoff = points.first!.index + Int(floor(Double(points.count) * 0.8))
if selectedIndexes.count > 1,
let lastSelectedIndex = selectedIndexes.last,
lastSelectedIndex >= trailingLabelCutoff
{
selectedIndexes.removeLast()
}
if points.count == Layout.maxPoints,
let lastVisibleIndex = points.last?.index,
!selectedIndexes.contains(lastVisibleIndex)
{
selectedIndexes.append(lastVisibleIndex)
}
let deduplicated = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes
return deduplicated.map(Double.init)
}
@ViewBuilder
private static func axisLabel(
for point: Point,
windowMinutes: Int,
isTrailingFullChartLabel: Bool) -> some View
{
let label = Text(point.date.formatted(self.axisFormat(windowMinutes: windowMinutes)))
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
if isTrailingFullChartLabel {
label
.frame(width: 48, alignment: .trailing)
.offset(x: -24)
} else {
label
}
}
private nonisolated static func axisFormat(windowMinutes: Int) -> Date.FormatStyle {
if windowMinutes <= 300 {
return .dateTime.month(.abbreviated).day()
}
return .dateTime.month(.abbreviated).day()
}
private nonisolated static func seriesTitle(
name: PlanUtilizationSeriesName,
metadata: ProviderMetadata?) -> String
{
switch name {
case .session:
L(metadata?.sessionLabel ?? "Session")
case .weekly:
L(metadata?.weeklyLabel ?? "Weekly")
case .opus:
metadata?.opusLabel ?? "Opus"
default:
self.fallbackTitle(for: name.rawValue)
}
}
private nonisolated static func fallbackTitle(for rawValue: String) -> String {
let words = rawValue
.replacingOccurrences(of: "([a-z0-9])([A-Z])", with: "$1 $2", options: .regularExpression)
.split(separator: " ")
return words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: " ")
}
private nonisolated static func seriesSortOrder(_ name: PlanUtilizationSeriesName) -> Int {
switch name {
case .session:
0
case .weekly:
1
case .opus:
2
default:
100
}
}
private nonisolated static func emptyStateText(title: String?) -> String {
if let title {
return String(format: L("No %@ utilization data yet."), title.lowercased())
}
return L("No utilization data yet.")
}
#if DEBUG
struct ModelSnapshot: Equatable {
let pointCount: Int
let axisIndexes: [Double]
let xDomain: ClosedRange<Double>?
let selectedSeries: String?
let visibleSeries: [String]
let usedPercents: [Double]
let pointDates: [String]
}
nonisolated static func _modelSnapshotForTesting(
selectedSeriesRawValue: String? = nil,
histories: [PlanUtilizationSeriesHistory],
provider: UsageProvider,
snapshot: UsageSnapshot? = nil,
referenceDate: Date? = nil) -> ModelSnapshot
{
let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot)
let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first
let model = self.makeModel(
history: selectedSeries?.history,
provider: provider,
referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date())
return ModelSnapshot(
pointCount: model.points.count,
axisIndexes: model.axisIndexes,
xDomain: model.xDomain,
selectedSeries: selectedSeries?.id,
visibleSeries: visibleSeries.map(\.id),
usedPercents: model.points.map(\.usedPercent),
pointDates: model.points.map { point in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter.string(from: point.date)
})
}
nonisolated static func _detailLineForTesting(
selectedSeriesRawValue: String? = nil,
histories: [PlanUtilizationSeriesHistory],
provider: UsageProvider,
snapshot: UsageSnapshot? = nil,
referenceDate: Date? = nil) -> String
{
let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot)
let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first
let model = self.makeModel(
history: selectedSeries?.history,
provider: provider,
referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date())
return self.detailLine(point: model.points.last, windowMinutes: selectedSeries?.history.windowMinutes ?? 0)
}
nonisolated static func _emptyStateTextForTesting(title: String?) -> String {
self.emptyStateText(title: title)
}
#endif
private func xValue(for index: Int) -> PlottableValue<Double> {
.value(L("Series"), Double(index))
}
@ViewBuilder
private func utilizationChart(model: Model) -> some View {
if let xDomain = model.xDomain {
Chart {
self.utilizationChartContent(model: model)
}
.chartXScale(domain: xDomain)
} else {
Chart {
self.utilizationChartContent(model: model)
}
}
}
@ChartContentBuilder
private func utilizationChartContent(model: Model) -> some ChartContent {
ForEach(model.points) { point in
BarMark(
x: self.xValue(for: point.index),
yStart: .value(L("Capacity Start"), 0),
yEnd: .value(L("Capacity End"), 100),
width: .fixed(Layout.barWidth))
.foregroundStyle(model.trackColor)
BarMark(
x: self.xValue(for: point.index),
yStart: .value(L("Utilization Start"), 0),
yEnd: .value(L("Utilization End"), point.usedPercent),
width: .fixed(Layout.barWidth))
.foregroundStyle(model.barColor)
}
if let selected = self.selectedPoint(model: model) {
RuleMark(x: self.xValue(for: selected.index))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
}
}
private func selectedPoint(model: Model) -> Point? {
guard let selectedPointID else { return nil }
return model.pointsByID[selectedPointID]
}
private func detailLine(model: Model, windowMinutes: Int) -> String {
let activePoint = self.selectedPoint(model: model) ?? model.points.last
return Self.detailLine(point: activePoint, windowMinutes: windowMinutes)
}
private func updateSelection(
location: CGPoint?,
model: Model,
proxy: ChartProxy,
geo: GeometryProxy)
{
guard let location else {
if self.selectedPointID != nil { self.selectedPointID = nil }
return
}
guard let plotAnchor = proxy.plotFrame else { return }
let plotFrame = geo[plotAnchor]
guard plotFrame.contains(location) else {
if self.selectedPointID != nil { self.selectedPointID = nil }
return
}
let xInPlot = location.x - plotFrame.origin.x
guard let xValue: Double = proxy.value(atX: xInPlot) else { return }
var best: (id: Date, distance: Double)?
for point in model.points {
let distance = abs(Double(point.index) - xValue)
if let current = best {
if distance < current.distance {
best = (point.id, distance)
}
} else {
best = (point.id, distance)
}
}
// Stay on the last selected bar when cursor is in the gap between bars; only switch
// selection when the cursor is over the bar's own visual body.
if let best, let bestPoint = model.pointsByID[best.id],
let barX = proxy.position(forX: Double(bestPoint.index))
{
guard ChartBarHoverSelection.accepts(
distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)),
barHalfWidth: Layout.barWidth / 2,
selectableCount: model.points.count)
else { return }
}
if self.selectedPointID != best?.id {
self.selectedPointID = best?.id
}
}
}
extension PlanUtilizationHistoryChartMenuView {
private nonisolated static func detailLine(point: Point?, windowMinutes: Int) -> String {
guard let point else {
return "-"
}
let dateLabel = self.detailDateLabel(for: point.date, windowMinutes: windowMinutes)
let used = max(0, min(100, point.usedPercent))
if !point.isObserved {
return "\(dateLabel): -"
}
let usedText = used.formatted(.number.precision(.fractionLength(0...1)))
return L("%@: %@%% used", dateLabel, usedText)
}
private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String {
let formatter = DateFormatter()
formatter.locale = codexBarLocalizedLocale()
formatter.timeZone = TimeZone.current
formatter.setLocalizedDateFormatFromTemplate("MMM d, h:mm a")
var rendered = formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ")
let amSymbol = formatter.amSymbol ?? ""
let pmSymbol = formatter.pmSymbol ?? ""
if !amSymbol.isEmpty {
rendered = rendered.replacingOccurrences(of: amSymbol, with: amSymbol.lowercased())
}
if !pmSymbol.isEmpty {
rendered = rendered.replacingOccurrences(of: pmSymbol, with: pmSymbol.lowercased())
}
return rendered
}
}