chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,853 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user