@@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View {
2020 }
2121 }
2222
23+ private struct DetailRow : Identifiable {
24+ let id : String
25+ let title : String
26+ let subtitle : String ?
27+ let accentColor : Color
28+ }
29+
30+ private struct DetailContent {
31+ let primary : String
32+ let rows : [ DetailRow ]
33+ }
34+
2335 private let provider : UsageProvider
2436 private let daily : [ DailyEntry ]
2537 private let totalCostUSD : Double ?
@@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View {
88100 }
89101 }
90102
91- let detail = self . detailLines ( model: model)
92- VStack ( alignment: . leading, spacing: 0 ) {
103+ let detail = self . detailContent ( model: model)
104+ VStack ( alignment: . leading, spacing: Self . detailSpacing ) {
93105 Text ( detail. primary)
94106 . font ( . caption)
95107 . foregroundStyle ( . secondary)
96108 . lineLimit ( 1 )
97109 . truncationMode ( . tail)
98- . frame ( height: 16 , alignment: . leading)
99- Text ( detail. secondary ?? " " )
100- . font ( . caption)
101- . foregroundStyle ( . secondary)
102- . lineLimit ( 1 )
103- . truncationMode ( . tail)
104- . frame ( height: 16 , alignment: . leading)
105- . opacity ( detail. secondary == nil ? 0 : 1 )
110+ . frame ( height: Self . detailPrimaryLineHeight, alignment: . leading)
111+ ForEach ( detail. rows) { row in
112+ HStack ( alignment: . top, spacing: 8 ) {
113+ Rectangle ( )
114+ . fill ( row. accentColor)
115+ . frame ( width: 2 , height: row. subtitle == nil ? 14 : Self . detailRowHeight)
116+ . padding ( . top, 1 )
117+
118+ VStack ( alignment: . leading, spacing: 1 ) {
119+ Text ( row. title)
120+ . font ( . caption)
121+ . foregroundStyle ( . secondary)
122+ . lineLimit ( 1 )
123+ . truncationMode ( . tail)
124+ if let subtitle = row. subtitle {
125+ Text ( subtitle)
126+ . font ( . caption2)
127+ . foregroundStyle ( Color ( nsColor: . tertiaryLabelColor) )
128+ . lineLimit ( 1 )
129+ . truncationMode ( . tail)
130+ }
131+ }
132+ }
133+ . frame ( height: Self . detailRowHeight, alignment: . leading)
134+ }
135+ ForEach ( 0 ..< max ( model. maxRenderedBreakdownRows - detail. rows. count, 0 ) , id: \. self) { _ in
136+ Text ( " " )
137+ . font ( . caption)
138+ . frame ( height: Self . detailRowHeight, alignment: . leading)
139+ . opacity ( 0 )
140+ }
106141 }
142+ . frame (
143+ height: Self . detailBlockHeight ( maxBreakdownRows: model. maxRenderedBreakdownRows) ,
144+ alignment: . topLeading)
107145 }
108146
109147 if let total = self . totalCostUSD {
@@ -126,9 +164,14 @@ struct CostHistoryChartMenuView: View {
126164 let barColor : Color
127165 let peakKey : String ?
128166 let maxCostUSD : Double
167+ let maxRenderedBreakdownRows : Int
129168 }
130169
131170 private static let selectionBandColor = Color ( nsColor: . labelColor) . opacity ( 0.1 )
171+ private static let maxVisibleDetailLines = 4
172+ private static let detailPrimaryLineHeight : CGFloat = 16
173+ private static let detailRowHeight : CGFloat = 24
174+ private static let detailSpacing : CGFloat = 6
132175
133176 private static func capHeight( maxValue: Double ) -> Double {
134177 maxValue * 0.05
@@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View {
150193
151194 var peak : ( key: String , costUSD: Double ) ?
152195 var maxCostUSD : Double = 0
196+ var maxRenderedBreakdownRows = 0
153197 for entry in sorted {
154198 guard let costUSD = entry. costUSD, costUSD >= 0 else { continue }
155199 guard let date = self . dateFromDayKey ( entry. date) else { continue }
@@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View {
158202 pointsByKey [ entry. date] = point
159203 entriesByKey [ entry. date] = entry
160204 dateKeys. append ( ( entry. date, date) )
205+ maxRenderedBreakdownRows = max ( maxRenderedBreakdownRows, Self . renderedBreakdownRowCount ( for: entry) )
161206 if let cur = peak {
162207 if costUSD > cur. costUSD { peak = ( entry. date, costUSD) }
163208 } else {
@@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View {
181226 axisDates: axisDates,
182227 barColor: barColor,
183228 peakKey: peak? . key,
184- maxCostUSD: maxCostUSD)
229+ maxCostUSD: maxCostUSD,
230+ maxRenderedBreakdownRows: maxRenderedBreakdownRows)
185231 }
186232
187233 private static func barColor( for provider: UsageProvider ) -> Color {
@@ -211,6 +257,21 @@ struct CostHistoryChartMenuView: View {
211257 return model. pointsByDateKey [ key]
212258 }
213259
260+ private static func renderedBreakdownRowCount( for entry: DailyEntry ) -> Int {
261+ guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return 0 }
262+ if breakdown. count > self . maxVisibleDetailLines {
263+ return self . maxVisibleDetailLines
264+ }
265+ return breakdown. count
266+ }
267+
268+ private static func detailBlockHeight( maxBreakdownRows: Int ) -> CGFloat {
269+ guard maxBreakdownRows > 0 else { return self . detailPrimaryLineHeight }
270+ return self . detailPrimaryLineHeight +
271+ ( CGFloat ( maxBreakdownRows) * self . detailRowHeight) +
272+ ( CGFloat ( maxBreakdownRows) * self . detailSpacing)
273+ }
274+
214275 private func selectionBandRect( model: Model , proxy: ChartProxy , geo: GeometryProxy ) -> CGRect ? {
215276 guard let key = self . selectedDateKey else { return nil }
216277 guard let plotAnchor = proxy. plotFrame else { return nil }
@@ -286,43 +347,84 @@ struct CostHistoryChartMenuView: View {
286347 return best? . key
287348 }
288349
289- private func detailLines ( model: Model ) -> ( primary : String , secondary : String ? ) {
350+ private func detailContent ( model: Model ) -> DetailContent {
290351 guard let key = self . selectedDateKey,
291352 let point = model. pointsByDateKey [ key] ,
292353 let date = Self . dateFromDayKey ( key)
293354 else {
294- return ( " Hover a bar for details " , nil )
355+ return DetailContent ( primary : " Hover a bar for details " , rows : [ ] )
295356 }
296357
297358 let dayLabel = date. formatted ( . dateTime. month ( . abbreviated) . day ( ) )
298359 let cost = UsageFormatter . usdString ( point. costUSD)
299- if let tokens = point. totalTokens {
300- let primary = " \( dayLabel) : \( cost) · \( UsageFormatter . tokenCountString ( tokens) ) tokens "
301- let secondary = self . topModelsText ( key : key , model : model )
302- return ( primary , secondary )
360+ let primary = if let tokens = point. totalTokens {
361+ " \( dayLabel) : \( cost) · \( UsageFormatter . tokenCountString ( tokens) ) tokens "
362+ } else {
363+ " \( dayLabel ) : \( cost ) "
303364 }
304- let primary = " \( dayLabel) : \( cost) "
305- let secondary = self . topModelsText ( key: key, model: model)
306- return ( primary, secondary)
365+ return DetailContent ( primary: primary, rows: self . breakdownRows ( key: key, model: model) )
307366 }
308367
309- private func topModelsText( key: String , model: Model ) -> String ? {
310- guard let entry = model. entriesByDateKey [ key] else { return nil }
311- guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return nil }
312- let parts = breakdown
313- . compactMap { item -> ( name: String , detail: String , costUSD: Double ) ? in
314- guard let costUSD = item. costUSD else { return nil }
315- let name = UsageFormatter . modelDisplayName ( item. modelName)
316- guard let detail = UsageFormatter . modelCostDetail ( item. modelName, costUSD: costUSD) else { return nil }
317- return ( name, detail, costUSD)
318- }
368+ private func breakdownRows( key: String , model: Model ) -> [ DetailRow ] {
369+ guard let entry = model. entriesByDateKey [ key] else { return [ ] }
370+ guard let breakdown = entry. modelBreakdowns, !breakdown. isEmpty else { return [ ] }
371+
372+ let sorted = breakdown
319373 . sorted { lhs, rhs in
320- if lhs. costUSD == rhs. costUSD { return lhs. name < rhs. name }
321- return lhs. costUSD > rhs. costUSD
374+ let lCost = lhs. costUSD ?? - 1
375+ let rCost = rhs. costUSD ?? - 1
376+ if lCost != rCost { return lCost > rCost }
377+ let lTokens = lhs. totalTokens ?? - 1
378+ let rTokens = rhs. totalTokens ?? - 1
379+ if lTokens != rTokens { return lTokens > rTokens }
380+ return lhs. modelName < rhs. modelName
381+ }
382+
383+ let visibleLimit = sorted. count > Self . maxVisibleDetailLines
384+ ? ( Self . maxVisibleDetailLines - 1 )
385+ : sorted. count
386+ let visible = Array ( sorted. prefix ( visibleLimit) )
387+ var rows = visible. enumerated ( ) . map { index, item in
388+ DetailRow (
389+ id: " \( item. modelName) - \( index) " ,
390+ title: UsageFormatter . modelDisplayName ( item. modelName) ,
391+ subtitle: Self . breakdownValueText ( costUSD: item. costUSD, totalTokens: item. totalTokens) ,
392+ accentColor: model. barColor. opacity ( Self . breakdownAccentOpacity ( for: index) ) )
393+ }
394+
395+ let hidden = Array ( sorted. dropFirst ( visibleLimit) )
396+ if !hidden. isEmpty {
397+ let hiddenCost = hidden. reduce ( 0.0 ) { partial, item in
398+ partial + ( item. costUSD ?? 0 )
399+ }
400+ let hiddenTokens = hidden. reduce ( 0 ) { partial, item in
401+ partial + ( item. totalTokens ?? 0 )
322402 }
323- . prefix ( 3 )
324- . map { " \( $0. name) \( $0. detail) " }
325- guard !parts. isEmpty else { return nil }
326- return " Top: \( parts. joined ( separator: " · " ) ) "
403+ rows. append ( DetailRow (
404+ id: " overflow " ,
405+ title: hidden. count == 1 ? " 1 more model " : " \( hidden. count) more models " ,
406+ subtitle: Self . breakdownValueText (
407+ costUSD: hiddenCost > 0 ? hiddenCost : nil ,
408+ totalTokens: hiddenTokens > 0 ? hiddenTokens : nil ) ,
409+ accentColor: Color ( nsColor: . tertiaryLabelColor) . opacity ( 0.55 ) ) )
410+ }
411+
412+ return rows
413+ }
414+
415+ private static func breakdownAccentOpacity( for index: Int ) -> Double {
416+ let opacity = 0.75 - ( Double ( index) * 0.12 )
417+ return max ( 0.3 , opacity)
418+ }
419+
420+ private static func breakdownValueText( costUSD: Double ? , totalTokens: Int ? ) -> String ? {
421+ var parts : [ String ] = [ ]
422+ if let costUSD, costUSD > 0 {
423+ parts. append ( UsageFormatter . usdString ( costUSD) )
424+ }
425+ if let totalTokens, totalTokens > 0 {
426+ parts. append ( " \( UsageFormatter . tokenCountString ( totalTokens) ) tokens " )
427+ }
428+ return parts. isEmpty ? nil : parts. joined ( separator: " · " )
327429 }
328430}
0 commit comments