Skip to content

Commit 9d5ed27

Browse files
committed
Render dashboard chart as single canvas
1 parent 1ec52e9 commit 9d5ed27

1 file changed

Lines changed: 143 additions & 138 deletions

File tree

Sources/TokenMeter/ChartViews.swift

Lines changed: 143 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
601631
struct 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

Comments
 (0)