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.. 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)" } } }