@@ -14,64 +14,77 @@ struct TokenBarChart: View {
1414 let numberFormat : TokenNumberFormat
1515
1616 var body : some View {
17- VStack ( alignment: . leading, spacing: 8 ) {
18- GeometryReader { proxy in
19- let sparseTimeline = usesSparseTimeline
20- let visibleBuckets = visibleBuckets ( sparseTimeline: sparseTimeline)
21- let maxValue = niceMax ( max ( 1 , visibleBuckets. map ( \. usage. total) . max ( ) ?? 1 ) )
22- let axisWidth : CGFloat = numberFormat == . full ? 96 : 54
23- let xAxisHeight : CGFloat = 28
24- let plotWidth = max ( 180 , proxy. size. width - axisWidth - 8 )
25- let plotHeight = max ( 80 , proxy. size. height - xAxisHeight - 6 )
26-
27- HStack ( alignment: . top, spacing: 8 ) {
28- yAxis ( maxValue: maxValue)
29- . frame ( width: axisWidth, height: plotHeight)
30-
31- VStack ( spacing: 6 ) {
32- plotArea ( buckets: visibleBuckets, maxValue: maxValue, sparseTimeline: sparseTimeline)
33- . frame ( height: plotHeight)
34- xAxis ( buckets: sparseTimeline ? [ ] : visibleBuckets)
35- . frame ( height: xAxisHeight)
36- }
37- . frame ( width: plotWidth, alignment: . leading)
38- }
39- }
40- chartLegend
41- }
42- . padding ( 16 )
43- . tokenSurface ( elevated: true )
44- }
17+ GeometryReader { _ in
18+ let sparseTimeline = usesSparseTimeline
19+ let visibleBuckets = visibleBuckets ( sparseTimeline: sparseTimeline)
20+ let maxValue = niceMax ( max ( 1 , visibleBuckets. map ( \. usage. total) . max ( ) ?? 1 ) )
4521
46- private func plotArea( buckets: [ TimeBucket ] , maxValue: Int , sparseTimeline: Bool ) -> some View {
47- ZStack {
4822 Canvas ( opaque: false , rendersAsynchronously: true ) { context, size in
49- drawPlot (
23+ drawChart (
5024 context: & context,
5125 size: size,
52- buckets: buckets ,
26+ buckets: visibleBuckets ,
5327 maxValue: maxValue,
5428 sparseTimeline: sparseTimeline
5529 )
5630 }
31+ }
32+ . padding ( 16 )
33+ . tokenSurface ( elevated: true )
34+ }
5735
58- if buckets. isEmpty {
36+ private func drawChart(
37+ context: inout GraphicsContext ,
38+ size: CGSize ,
39+ buckets: [ TimeBucket ] ,
40+ maxValue: Int ,
41+ sparseTimeline: Bool
42+ ) {
43+ let axisWidth : CGFloat = numberFormat == . full ? 96 : 54
44+ let xAxisHeight : CGFloat = 28
45+ let legendHeight : CGFloat = 24
46+ let plotX = axisWidth + 8
47+ let plotWidth = max ( 180 , size. width - plotX)
48+ let plotHeight = max ( 80 , size. height - xAxisHeight - legendHeight - 14 )
49+
50+ drawYAxis ( context: & context, maxValue: maxValue, axisWidth: axisWidth, plotHeight: plotHeight)
51+ drawPlot (
52+ context: & context,
53+ size: CGSize ( width: plotWidth, height: plotHeight) ,
54+ origin: CGPoint ( x: plotX, y: 0 ) ,
55+ buckets: buckets,
56+ maxValue: maxValue,
57+ sparseTimeline: sparseTimeline
58+ )
59+ drawXAxis (
60+ context: & context,
61+ buckets: sparseTimeline ? [ ] : buckets,
62+ plotX: plotX,
63+ y: plotHeight + 6 ,
64+ width: plotWidth
65+ )
66+ drawLegend ( context: & context, y: plotHeight + xAxisHeight + 10 )
67+
68+ if buckets. isEmpty {
69+ context. draw (
5970 Text ( " No data " )
6071 . font ( . system( size: 12 ) )
61- . foregroundStyle ( TokenMeterTheme . secondaryText)
62- . frame ( maxWidth: . infinity, maxHeight: . infinity)
63- }
72+ . foregroundColor ( TokenMeterTheme . secondaryText) ,
73+ at: CGPoint ( x: plotX + plotWidth / 2 , y: plotHeight / 2 ) ,
74+ anchor: . center
75+ )
6476 }
6577 }
6678
6779 private func drawPlot(
6880 context: inout GraphicsContext ,
6981 size: CGSize ,
82+ origin: CGPoint ,
7083 buckets: [ TimeBucket ] ,
7184 maxValue: Int ,
7285 sparseTimeline: Bool
7386 ) {
74- drawGrid ( context: & context, size: size)
87+ drawGrid ( context: & context, origin : origin , size: size)
7588
7689 guard !buckets. isEmpty else { return }
7790
@@ -84,8 +97,9 @@ struct TokenBarChart: View {
8497 context: & context,
8598 bucket: bucket,
8699 maxValue: maxValue,
87- x: x - width / 2 ,
100+ x: origin . x + x - width / 2 ,
88101 width: width,
102+ originY: origin. y,
89103 height: size. height
90104 )
91105 }
@@ -98,21 +112,22 @@ struct TokenBarChart: View {
98112 context: & context,
99113 bucket: bucket,
100114 maxValue: maxValue,
101- x: x,
115+ x: origin . x + x,
102116 width: width,
117+ originY: origin. y,
103118 height: size. height
104119 )
105120 }
106121 }
107122 }
108123
109- private func drawGrid( context: inout GraphicsContext , size: CGSize ) {
124+ private func drawGrid( context: inout GraphicsContext , origin : CGPoint , size: CGSize ) {
110125 let lineColor = Color . white. opacity ( 0.055 )
111126 for index in 0 ..< 5 {
112- let y = size. height * CGFloat( index) / 4
127+ let y = origin . y + size. height * CGFloat( index) / 4
113128 var path = Path ( )
114- path. move ( to: CGPoint ( x: 0 , y: y) )
115- path. addLine ( to: CGPoint ( x: size. width, y: y) )
129+ path. move ( to: CGPoint ( x: origin . x , y: y) )
130+ path. addLine ( to: CGPoint ( x: origin . x + size. width, y: y) )
116131 context. stroke ( path, with: . color( lineColor) , lineWidth: 1 )
117132 }
118133 }
@@ -123,10 +138,11 @@ struct TokenBarChart: View {
123138 maxValue: Int ,
124139 x: CGFloat ,
125140 width: CGFloat ,
141+ originY: CGFloat ,
126142 height: CGFloat
127143 ) {
128144 let totalHeight = max ( 2 , height * CGFloat( bucket. usage. total) / CGFloat( maxValue) )
129- var y = height - totalHeight
145+ var y = originY + height - totalHeight
130146
131147 switch mode {
132148 case . bySource:
@@ -229,47 +245,54 @@ struct TokenBarChart: View {
229245 y += segmentHeight
230246 }
231247
232- private func yAxis( maxValue: Int ) -> some View {
233- VStack ( alignment: . trailing) {
234- let ticks = yAxisTickValues ( maxValue: maxValue)
235- ForEach ( ticks. indices, id: \. self) { index in
248+ private func drawYAxis(
249+ context: inout GraphicsContext ,
250+ maxValue: Int ,
251+ axisWidth: CGFloat ,
252+ plotHeight: CGFloat
253+ ) {
254+ let ticks = yAxisTickValues ( maxValue: maxValue)
255+ for index in ticks. indices {
256+ let y = plotHeight * CGFloat( index) / CGFloat( max ( 1 , ticks. count - 1 ) )
257+ let anchor : UnitPoint = {
258+ if index == ticks. startIndex { return . topTrailing }
259+ if index == ticks. index ( before: ticks. endIndex) { return . bottomTrailing }
260+ return . trailing
261+ } ( )
262+ context. draw (
236263 Text ( TokenFormatters . tokens ( ticks [ index] , format: numberFormat) )
237- . lineLimit ( 1 )
238- . minimumScaleFactor ( 0.78 )
239- if index < ticks . count - 1 {
240- Spacer ( )
241- }
242- }
264+ . font ( . system ( size : 10 ) )
265+ . foregroundColor ( TokenMeterTheme . tertiaryText )
266+ . monospacedDigit ( ) ,
267+ at : CGPoint ( x : axisWidth , y : y ) ,
268+ anchor : anchor
269+ )
243270 }
244- . font ( . system( size: 10 ) )
245- . foregroundStyle ( TokenMeterTheme . tertiaryText)
246- . monospacedDigit ( )
247271 }
248272
249273 private func yAxisTickValues( maxValue: Int ) -> [ Int ] {
250274 [ maxValue, maxValue * 3 / 4 , maxValue / 2 , maxValue / 4 , 0 ]
251275 }
252276
253- private func xAxis( buckets: [ TimeBucket ] ) -> some View {
254- GeometryReader { proxy in
255- let ticks = axisTicks ( buckets: buckets, width: proxy. size. width)
256- ZStack ( alignment: . topLeading) {
257- ForEach ( ticks) { tick in
258- VStack ( spacing: 4 ) {
259- Rectangle ( )
260- . fill ( Color . white. opacity ( 0.14 ) )
261- . frame ( width: 1 , height: 5 )
262- Text ( tick. title)
263- . lineLimit ( 1 )
264- . minimumScaleFactor ( 0.82 )
265- }
266- . frame ( width: tick. width)
267- . position ( x: tick. x, y: 12 )
268- }
269- }
277+ private func drawXAxis(
278+ context: inout GraphicsContext ,
279+ buckets: [ TimeBucket ] ,
280+ plotX: CGFloat ,
281+ y: CGFloat ,
282+ width: CGFloat
283+ ) {
284+ for tick in axisTicks ( buckets: buckets, width: width) {
285+ let x = plotX + tick. x
286+ let tickRect = CGRect ( x: x - 0.5 , y: y, width: 1 , height: 5 )
287+ context. fill ( Path ( tickRect) , with: . color( Color . white. opacity ( 0.14 ) ) )
288+ context. draw (
289+ Text ( tick. title)
290+ . font ( . system( size: 10 ) )
291+ . foregroundColor ( TokenMeterTheme . tertiaryText) ,
292+ at: CGPoint ( x: x, y: y + 10 ) ,
293+ anchor: . top
294+ )
270295 }
271- . font ( . system( size: 10 ) )
272- . foregroundStyle ( TokenMeterTheme . tertiaryText)
273296 }
274297
275298 private func axisTicks( buckets: [ TimeBucket ] , width: CGFloat ) -> [ AxisTick ] {
@@ -499,40 +522,55 @@ struct TokenBarChart: View {
499522 return min ( 3 , max ( 0.6 , slotWidth * 0.82 ) )
500523 }
501524
502- private var chartLegend : some View {
503- HStack ( spacing: 14 ) {
504- switch mode {
505- case . bySource:
506- legend ( " Codex " , . codex)
507- legend ( " Claude Code " , . claude)
508- case . byTokenKind:
509- legend ( " Input " , componentColor ( . input) )
510- legend ( " Cache " , componentColor ( . cache) )
511- legend ( " Output " , componentColor ( . output) )
512- legend ( " Reasoning " , componentColor ( . reasoning) )
513- }
514- Spacer ( )
525+ private func drawLegend( context: inout GraphicsContext , y: CGFloat ) {
526+ var x : CGFloat = 0
527+
528+ for item in legendItems {
529+ let width = legendWidth ( for: item. title)
530+ let rect = CGRect ( x: x, y: y, width: width, height: 24 )
531+ context. fill (
532+ Path ( roundedRect: rect, cornerRadius: TokenMeterTheme . compactControlRadius) ,
533+ with: . color( TokenMeterTheme . control)
534+ )
535+ context. stroke (
536+ Path ( roundedRect: rect, cornerRadius: TokenMeterTheme . compactControlRadius) ,
537+ with: . color( TokenMeterTheme . subtleBorder) ,
538+ lineWidth: 1
539+ )
540+ context. fill (
541+ Path ( roundedRect: CGRect ( x: x + 8 , y: y + 8 , width: 12 , height: 8 ) , cornerRadius: 2 ) ,
542+ with: . color( item. color)
543+ )
544+ context. draw (
545+ Text ( item. title)
546+ . font ( . system( size: 11 ) )
547+ . foregroundColor ( TokenMeterTheme . secondaryText) ,
548+ at: CGPoint ( x: x + 26 , y: y + 12 ) ,
549+ anchor: . leading
550+ )
551+ x += width + 10
515552 }
516- . font ( . system( size: 11 ) )
517- . foregroundStyle ( TokenMeterTheme . secondaryText)
518553 }
519554
520- private func legend( _ title: String , _ source: TokenSource ) -> some View {
521- legend ( title, sourceColor ( source) )
555+ private var legendItems : [ ( title: String , color: Color ) ] {
556+ switch mode {
557+ case . bySource:
558+ [
559+ ( " Codex " , sourceColor ( . codex) ) ,
560+ ( " Claude Code " , sourceColor ( . claude) )
561+ ]
562+ case . byTokenKind:
563+ [
564+ ( " Input " , componentColor ( . input) ) ,
565+ ( " Cache " , componentColor ( . cache) ) ,
566+ ( " Output " , componentColor ( . output) ) ,
567+ ( " Reasoning " , componentColor ( . reasoning) )
568+ ]
569+ }
522570 }
523571
524- private func legend( _ title: String , _ color: Color ) -> some View {
525- HStack ( spacing: 5 ) {
526- RoundedRectangle ( cornerRadius: 2 , style: . continuous)
527- . fill ( color)
528- . frame ( width: 12 , height: 8 )
529- Text ( title)
530- }
531- . padding ( . horizontal, 8 )
532- . frame ( height: 24 )
533- . background {
534- TokenControlChrome ( cornerRadius: TokenMeterTheme . compactControlRadius)
535- }
572+ private func legendWidth( for title: String ) -> CGFloat {
573+ max ( 58 , CGFloat ( title. count) * 6.4 + 34 )
536574 }
537575
538576 private func niceMax( _ value: Int ) -> Int {
@@ -590,14 +628,6 @@ private struct AxisTick: Identifiable {
590628 let width : CGFloat
591629}
592630
593- struct BarSegment {
594- let id : String
595- let label : String
596- let value : Int
597- let total : Int
598- let color : Color
599- }
600-
601631struct ProportionBar : View {
602632 let value : Int
603633 let maxValue : Int
@@ -787,28 +817,3 @@ func componentColor(_ kind: TokenComponentKind) -> Color {
787817 return Color ( red: 0.72 , green: 0.53 , blue: 1.0 )
788818 }
789819}
790-
791- func chartSegments( for bucket: TimeBucket , mode: ChartMode ) -> [ BarSegment ] {
792- switch mode {
793- case . bySource:
794- let codex = bucket. sourceUsage [ . codex] ? . total ?? 0
795- let claude = bucket. sourceUsage [ . claude] ? . total ?? 0
796- let total = max ( 1 , codex + claude)
797- return [
798- BarSegment ( id: " codex " , label: " Codex " , value: codex, total: total, color: sourceColor ( . codex) ) ,
799- BarSegment ( id: " claude " , label: " Claude Code " , value: claude, total: total, color: sourceColor ( . claude) )
800- ] . filter { $0. value > 0 }
801- case . byTokenKind( let source) :
802- let components = bucket. usage. displayComponents ( source: source)
803- let total = max ( 1 , components. map ( \. value) . reduce ( 0 , + ) )
804- return components. map { component in
805- BarSegment (
806- id: component. kind. rawValue,
807- label: component. kind. rawValue,
808- value: component. value,
809- total: total,
810- color: componentColor ( component. kind)
811- )
812- }
813- }
814- }
0 commit comments