252 lines
9.5 KiB
Swift
252 lines
9.5 KiB
Swift
import CodexBarCore
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct ZaiHourlyUsageChartMenuView: View {
|
|
private let modelUsage: ZaiModelUsageData
|
|
private let width: CGFloat
|
|
|
|
@State private var selectedRange: RangeOption = .today
|
|
@State private var isExpanded = true
|
|
@State private var hoveredBarIndex: Int?
|
|
|
|
private enum RangeOption: Int, CaseIterable {
|
|
case today = 0
|
|
case last24h = 1
|
|
}
|
|
|
|
private let barHeight: CGFloat = 60
|
|
private let barGap: CGFloat = 2
|
|
private let maxLabelCount = 5
|
|
|
|
private let colorPalette: [Color] = [
|
|
Color(red: 10 / 255, green: 132 / 255, blue: 1),
|
|
Color(red: 255 / 255, green: 159 / 255, blue: 10 / 255),
|
|
Color(red: 48 / 255, green: 209 / 255, blue: 88 / 255),
|
|
Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255),
|
|
Color(red: 100 / 255, green: 210 / 255, blue: 255 / 255),
|
|
Color(red: 255 / 255, green: 55 / 255, blue: 95 / 255),
|
|
]
|
|
|
|
init(modelUsage: ZaiModelUsageData, width: CGFloat) {
|
|
self.modelUsage = modelUsage
|
|
self.width = width
|
|
}
|
|
|
|
private var range: ZaiHourlyRange {
|
|
switch self.selectedRange {
|
|
case .today: .today(referenceDate: Date())
|
|
case .last24h: .last24h
|
|
}
|
|
}
|
|
|
|
private var bars: [ZaiHourlyBar] {
|
|
ZaiHourlyBars.from(modelData: self.modelUsage, range: self.range)
|
|
}
|
|
|
|
private var modelNames: [String] {
|
|
self.modelUsage.modelNames
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 4) {
|
|
Button(
|
|
action: { withAnimation(.easeInOut(duration: 0.2)) { self.isExpanded.toggle() } },
|
|
label: {
|
|
Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 10)
|
|
})
|
|
.buttonStyle(.plain)
|
|
|
|
Text(L("Hourly Tokens"))
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
|
|
if self.isExpanded {
|
|
self.rangeToggle
|
|
}
|
|
}
|
|
|
|
if self.isExpanded {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if self.bars.isEmpty {
|
|
Text(L("No data"))
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 16)
|
|
} else {
|
|
GeometryReader { geometry in
|
|
let barWidth = max(
|
|
(geometry.size.width - self.barGap * CGFloat(max(self.bars.count - 1, 0)))
|
|
/ CGFloat(self.bars.count),
|
|
2)
|
|
HStack(alignment: .bottom, spacing: self.barGap) {
|
|
ForEach(Array(self.bars.enumerated()), id: \.offset) { index, bar in
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
self.barStack(bar: bar, barWidth: barWidth, maxTotal: self.maxTotal)
|
|
}
|
|
.frame(width: barWidth, height: self.barHeight)
|
|
.contentShape(Rectangle())
|
|
.onHover { hovering in
|
|
self.hoveredBarIndex = hovering ? index : nil
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if self.hoveredBarIndex == index {
|
|
self.tooltipOverlay(bar: bar)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: self.barHeight)
|
|
}
|
|
.frame(height: self.barHeight)
|
|
|
|
self.legend
|
|
self.xAxisLabels
|
|
}
|
|
}
|
|
.padding(.top, 6)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading)
|
|
.animation(.easeInOut(duration: 0.2), value: self.isExpanded)
|
|
}
|
|
|
|
private var maxTotal: Int {
|
|
self.bars.map(\.totalTokens).max() ?? 1
|
|
}
|
|
|
|
private var rangeToggle: some View {
|
|
Picker("", selection: Binding(
|
|
get: { self.selectedRange.rawValue },
|
|
set: { self.selectedRange = RangeOption(rawValue: $0) ?? .today }))
|
|
{
|
|
Text(L("Today")).tag(RangeOption.today.rawValue)
|
|
Text("24h").tag(RangeOption.last24h.rawValue)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 100)
|
|
.scaleEffect(0.8)
|
|
.frame(width: 80, height: 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func barStack(bar: ZaiHourlyBar, barWidth: CGFloat, maxTotal: Int) -> some View {
|
|
let scaleFactor = CGFloat(bar.totalTokens) / CGFloat(max(maxTotal, 1))
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(bar.segments.enumerated()), id: \.offset) { segIndex, segment in
|
|
let segFraction = CGFloat(segment.tokens) / CGFloat(max(bar.totalTokens, 1))
|
|
let segHeight = max(
|
|
self.barHeight * scaleFactor * segFraction,
|
|
segment.tokens > 0 ? 1 : 0)
|
|
RoundedRectangle(cornerRadius: segIndex == bar.segments.count - 1 ? 2 : 0)
|
|
.fill(self.colorForModel(segment.model))
|
|
.frame(height: segHeight)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
}
|
|
|
|
private func tooltipOverlay(bar: ZaiHourlyBar) -> some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(bar.label + ":00")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
ForEach(Array(bar.segments.enumerated()), id: \.offset) { _, segment in
|
|
HStack(spacing: 3) {
|
|
Circle()
|
|
.fill(self.colorForModel(segment.model))
|
|
.frame(width: 5, height: 5)
|
|
Text(segment.model)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
.layoutPriority(1)
|
|
Text(self.formatTokenCount(segment.tokens))
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
Divider()
|
|
.background(Color.primary.opacity(0.15))
|
|
Text(self.formatTokenCount(bar.totalTokens))
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.primary)
|
|
}
|
|
.padding(6)
|
|
.frame(minWidth: 90, maxWidth: 140)
|
|
.background(Color(nsColor: .controlBackgroundColor).opacity(0.95))
|
|
.background(.ultraThinMaterial)
|
|
.cornerRadius(6)
|
|
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
|
|
.offset(y: -self.barHeight - 8)
|
|
}
|
|
|
|
private var legend: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(self.modelNames, id: \.self) { name in
|
|
HStack(spacing: 2) {
|
|
Circle()
|
|
.fill(self.colorForModel(name))
|
|
.frame(width: 6, height: 6)
|
|
Text(name)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var xAxisLabels: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(self.labelIndices.enumerated()), id: \.offset) { _, index in
|
|
if index < self.bars.count {
|
|
Text(self.bars[index].label)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var labelIndices: [Int] {
|
|
guard self.bars.count > self.maxLabelCount else { return Array(0..<self.bars.count) }
|
|
let step = max(1, self.bars.count / (self.maxLabelCount - 1))
|
|
var indices = stride(from: 0, to: self.bars.count, by: step).map(\.self)
|
|
if indices.last != self.bars.count - 1 {
|
|
indices.append(self.bars.count - 1)
|
|
}
|
|
return indices
|
|
}
|
|
|
|
private func colorForModel(_ name: String) -> Color {
|
|
let index = self.modelNames.firstIndex(of: name) ?? 0
|
|
return self.colorPalette[index % self.colorPalette.count]
|
|
}
|
|
|
|
private func formatTokenCount(_ count: Int) -> String {
|
|
if count >= 1_000_000 {
|
|
String(format: "%.1fM", Double(count) / 1_000_000)
|
|
} else if count >= 1000 {
|
|
String(format: "%.1fk", Double(count) / 1000)
|
|
} else {
|
|
"\(count)"
|
|
}
|
|
}
|
|
}
|