1use std::f32::consts::{FRAC_PI_2, PI, TAU};
12
13use tokenburn_core::utils::fmt::percent;
14use tokenburn_core::{
15 Currency, Metric, Pie, Selection, TimeSeries, Tool, axis_label, nice_max, short_label,
16 slice_angles,
17};
18
19pub const BARS_W: f32 = 700.0;
20pub const BARS_H: f32 = 240.0;
21pub const LEFT: f32 = 56.0;
22pub const RIGHT: f32 = 8.0;
23pub const TOP: f32 = 10.0;
24pub const BOTTOM: f32 = 26.0;
25
26pub fn esc(s: &str) -> String {
28 s.replace('&', "&")
29 .replace('<', "<")
30 .replace('>', ">")
31 .replace('"', """)
32 .replace('\'', "'")
33}
34
35pub fn encode(s: &str) -> String {
37 let mut out = String::with_capacity(s.len());
38 for b in s.bytes() {
39 match b {
40 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
41 out.push(b as char);
42 }
43 _ => out.push_str(&format!("%{b:02X}")),
44 }
45 }
46 out
47}
48
49pub fn tool_var(tool: Tool) -> String {
51 format!("var(--s{})", tool.series_index() % 8)
52}
53
54fn f(v: f32) -> String {
55 format!("{v:.1}")
56}
57
58#[derive(Debug, Clone, PartialEq)]
62pub struct Seg {
63 pub x: String,
64 pub y: String,
65 pub w: String,
66 pub h: String,
67 pub var: String,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct Col {
73 pub index: usize,
74 pub label: String,
75 pub title: String,
77 pub selected: bool,
78 pub slot_x: String,
80 pub slot_w: String,
81 pub segs: Vec<Seg>,
82 pub plot_y: String,
84 pub plot_h: String,
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct GridLine {
89 pub y: String,
90 pub text_y: String,
91 pub label: String,
92}
93
94#[derive(Debug, Clone, PartialEq)]
95pub struct XLabel {
96 pub x: String,
97 pub y: String,
98 pub text: String,
99 pub alt: bool,
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub struct Bars {
106 pub empty: bool,
107 pub grid: Vec<GridLine>,
108 pub cols: Vec<Col>,
109 pub x_labels: Vec<XLabel>,
110 pub has_selection: bool,
112 pub left: String,
113 pub right: String,
114}
115
116pub fn bars(
119 series: &TimeSeries,
120 metric: Metric,
121 selected: Option<&str>,
122 currency: &Currency,
123) -> Bars {
124 let n = series.labels.len();
125 let mut out = Bars {
126 empty: n == 0,
127 grid: Vec::new(),
128 cols: Vec::new(),
129 x_labels: Vec::new(),
130 has_selection: selected.is_some_and(|s| series.labels.iter().any(|l| l == s)),
131 left: f(LEFT),
132 right: f(BARS_W - RIGHT),
133 };
134 if n == 0 {
135 return out;
136 }
137 let plot_w = BARS_W - LEFT - RIGHT;
138 let plot_h = BARS_H - TOP - BOTTOM;
139 let max = nice_max(series.max_total(metric));
140
141 for i in 0..=4 {
142 let y = TOP + plot_h - plot_h * i as f32 / 4.0;
143 out.grid.push(GridLine {
144 y: f(y),
145 text_y: f(y + 4.0),
146 label: axis_label(metric, max * f64::from(i) / 4.0, currency),
147 });
148 }
149
150 let slot = plot_w / n as f32;
151 let bar_w = (slot * 0.78).max(1.0);
152 for i in 0..n {
153 let x = LEFT + slot * i as f32 + (slot - bar_w) / 2.0;
154 let mut y = TOP + plot_h;
155 let mut lines = vec![series.labels[i].clone()];
156 let mut segs = Vec::new();
157 for tool in Tool::ALL {
158 let v = series.value(metric, i, tool);
159 if v <= 0.0 {
160 continue;
161 }
162 let h = (v / max) as f32 * plot_h;
163 y -= h;
164 lines.push(format!(
165 "{}: {}",
166 tool.display_name(),
167 axis_label(metric, v, currency)
168 ));
169 segs.push(Seg {
170 x: f(x),
171 y: f(y),
172 w: f(bar_w),
173 h: f(h.max(1.0)),
174 var: tool_var(tool),
175 });
176 }
177 lines.push(format!(
178 "total: {}",
179 axis_label(metric, series.bucket_total(metric, i), currency)
180 ));
181 let is_sel = selected == Some(series.labels[i].as_str());
182 lines.push(if is_sel {
183 "click to clear".into()
184 } else {
185 "click to select".into()
186 });
187 out.cols.push(Col {
188 index: i,
189 label: series.labels[i].clone(),
190 title: lines.join("\n"),
191 selected: is_sel,
192 slot_x: f(LEFT + slot * i as f32),
193 slot_w: f(slot),
194 segs,
195 plot_y: f(TOP),
196 plot_h: f(plot_h),
197 });
198 }
199
200 let every = ((72.0 / slot).ceil() as usize).max(1);
202 for (k, i) in (0..n).step_by(every).enumerate() {
203 out.x_labels.push(XLabel {
204 alt: k % 2 == 1,
205 x: f(LEFT + slot * (i as f32 + 0.5)),
206 y: f(TOP + plot_h + 16.0),
207 text: short_label(&series.labels[i]),
208 });
209 }
210 out
211}
212
213pub const DONUT: f32 = 200.0;
216const R_OUT: f32 = 92.0;
217const R_IN: f32 = 56.0;
218const POP: f32 = 6.0;
220
221pub fn ring_path(cx: f32, cy: f32, r_out: f32, r_in: f32, start: f32, end: f32) -> String {
224 let end = end.min(start + TAU - 0.0005);
226 let pt = |r: f32, a: f32| {
227 let a = a - FRAC_PI_2;
228 (cx + r * a.cos(), cy + r * a.sin())
229 };
230 let large = u8::from(end - start > PI);
231 let (x0, y0) = pt(r_out, start);
232 let (x1, y1) = pt(r_out, end);
233 let (x2, y2) = pt(r_in, end);
234 let (x3, y3) = pt(r_in, start);
235 format!(
236 "M{x0:.2} {y0:.2} A{r_out:.2} {r_out:.2} 0 {large} 1 {x1:.2} {y1:.2} L{x2:.2} {y2:.2} A{r_in:.2} {r_in:.2} 0 {large} 0 {x3:.2} {y3:.2} Z"
237 )
238}
239
240#[derive(Debug, Clone, PartialEq)]
241pub struct SliceGeom {
242 pub index: usize,
243 pub label: String,
244 pub key: Option<String>,
246 pub path: String,
247 pub var: String,
248 pub title: String,
249 pub pct: String,
250 pub selected: bool,
251 pub dx: String,
253 pub dy: String,
254}
255
256#[derive(Debug, Clone, PartialEq)]
257pub struct Donut {
258 pub empty: bool,
259 pub slices: Vec<SliceGeom>,
260 pub total_label: String,
261 pub has_selection: bool,
262}
263
264pub fn donut(pie: &Pie, selected: Option<&str>, currency: &Currency) -> Donut {
267 let total = pie.total();
268 let mut out = Donut {
269 empty: total <= 0.0,
270 slices: Vec::new(),
271 total_label: axis_label(pie.metric, total, currency),
272 has_selection: selected
273 .is_some_and(|k| pie.slices.iter().any(|s| s.key.as_deref() == Some(k))),
274 };
275 if out.empty {
276 return out;
277 }
278 let values: Vec<f64> = pie.slices.iter().map(|s| s.value).collect();
279 for (i, (a, b)) in slice_angles(&values).into_iter().enumerate() {
280 let s = &pie.slices[i];
281 let mid = (a + b) / 2.0 - FRAC_PI_2;
282 let is_sel = s.key.is_some() && s.key.as_deref() == selected;
283 let pct = percent(s.value / total);
284 out.slices.push(SliceGeom {
285 index: i,
286 label: s.label.clone(),
287 key: s.key.clone(),
288 path: ring_path(100.0, 100.0, R_OUT, R_IN, a, b),
289 var: format!("var(--s{})", i % 8),
290 title: format!(
291 "{}\n{} ({pct})\n{}",
292 s.label,
293 axis_label(pie.metric, s.value, currency),
294 match (&s.key, is_sel) {
295 (None, _) => "(several small projects)",
296 (_, true) => "click to clear",
297 _ => "click to select",
298 }
299 ),
300 pct,
301 selected: is_sel,
302 dx: f(POP * mid.cos()),
303 dy: f(POP * mid.sin()),
304 });
305 }
306 out
307}
308
309pub fn href(base: &str, sel: &Selection) -> String {
313 let mut url = base.to_string();
314 if let Some(b) = &sel.bucket {
315 url.push_str("&sel_bucket=");
316 url.push_str(&encode(b));
317 }
318 if let Some(p) = &sel.project {
319 url.push_str("&sel_project=");
320 url.push_str(&encode(p));
321 }
322 url
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use tokenburn_core::{Bucket, Query, Row, Slice, Window};
329
330 fn row(tool: Tool, ts: &str, tokens: u64) -> Row {
331 Row {
332 tool,
333 project: "p".into(),
334 id: "i".into(),
335 ts: ts.parse().unwrap(),
336 input: tokens,
337 output: 0,
338 cache_read: 0,
339 cache_write: 0,
340 cost: tokens as f64 / 1000.0,
341 }
342 }
343
344 fn series() -> TimeSeries {
345 let rows = [
346 row(Tool::Pi, "2026-05-10T12:00:00Z", 1000),
347 row(Tool::Claude, "2026-05-10T13:00:00Z", 500),
348 row(Tool::Pi, "2026-05-12T12:00:00Z", 2000),
349 ];
350 tokenburn_core::time_series(
351 &rows,
352 &Query {
353 bucket: Bucket::Day,
354 window: Window::Month,
355 ..Query::default()
356 },
357 )
358 }
359
360 fn slice(label: &str, key: Option<&str>, value: f64) -> Slice {
361 Slice {
362 label: label.into(),
363 tool: None,
364 key: key.map(Into::into),
365 value,
366 }
367 }
368
369 #[test]
370 fn escaping_and_url_encoding() {
371 assert_eq!(
372 esc(r#"<a href="x">&'"#),
373 "<a href="x">&'"
374 );
375 assert_eq!(encode("pi:atlas v2/é"), "pi%3Aatlas%20v2%2F%C3%A9");
376 assert_eq!(encode("2026-09-22_a.b~"), "2026-09-22_a.b~");
377 }
378
379 #[test]
380 fn there_is_a_column_per_bucket_and_a_segment_per_tool() {
381 let b = bars(&series(), Metric::Tokens, None, &Currency::USD);
382 assert!(!b.empty);
383 assert_eq!(b.cols.len(), 3, "May 10, 11 (gap) and 12");
384 assert_eq!(b.cols[0].segs.len(), 2, "pi + claude");
385 assert_eq!(b.cols[1].segs.len(), 0, "the gap day");
386 assert_eq!(b.cols[2].segs.len(), 1);
387 assert!(b.cols[0].segs[0].var.starts_with("var(--s"));
388 assert!(!b.has_selection && b.cols.iter().all(|c| !c.selected));
389 }
390
391 #[test]
392 fn tooltips_list_the_tools_and_the_total_and_the_click_hint() {
393 let b = bars(&series(), Metric::Tokens, None, &Currency::USD);
394 let t = &b.cols[0].title;
395 assert!(
396 t.contains("Claude Code") && t.contains("total: 1.5k"),
397 "{t}"
398 );
399 assert!(t.ends_with("click to select"));
400 }
401
402 #[test]
403 fn the_selected_bucket_is_flagged_and_its_hint_flips() {
404 let s = series();
405 let label = s.labels[2].clone();
406 let b = bars(&s, Metric::Tokens, Some(&label), &Currency::USD);
407 assert!(b.has_selection);
408 assert!(b.cols[2].selected && !b.cols[0].selected);
409 assert!(b.cols[2].title.ends_with("click to clear"));
410 assert!(!bars(&s, Metric::Tokens, Some("1999-01-01"), &Currency::USD).has_selection);
412 }
413
414 #[test]
415 fn the_axis_tops_out_at_a_nice_maximum() {
416 let b = bars(&series(), Metric::Tokens, None, &Currency::USD);
417 assert_eq!(b.grid.len(), 5);
418 assert_eq!(b.grid[0].label, "0");
419 assert_eq!(b.grid[4].label, "2k");
420 }
421
422 #[test]
423 fn the_cost_metric_uses_dollars() {
424 let b = bars(&series(), Metric::Cost, None, &Currency::USD);
425 assert!(b.grid[4].label.starts_with('$'));
426 }
427
428 #[test]
429 fn the_saved_metric_uses_money_and_the_saved_values() {
430 let rows = [
431 Row {
432 cache_read: 1_000_000,
433 ..row(Tool::Pi, "2026-05-10T12:00:00Z", 1000)
434 },
435 row(Tool::Pi, "2026-05-12T12:00:00Z", 2000),
436 ];
437 let s = tokenburn_core::time_series_priced(
438 &rows,
439 &Query {
440 bucket: Bucket::Day,
441 window: Window::Month,
442 ..Query::default()
443 },
444 &tokenburn_core::SavedInput {
445 price: 10.0,
446 external: None,
447 },
448 );
449 let b = bars(&s, Metric::Saved, None, &Currency::USD);
450 assert!(b.grid[4].label.starts_with('$'), "{}", b.grid[4].label);
451 assert_eq!(b.grid[4].label, "$10.00", "nice max of $9");
452 assert!(b.cols[0].title.contains("$9"), "tooltip carries the money");
453 }
454
455 #[test]
456 fn an_empty_series_is_flagged_not_drawn() {
457 let b = bars(&TimeSeries::default(), Metric::Tokens, None, &Currency::USD);
458 assert!(b.empty && b.cols.is_empty() && b.grid.is_empty());
459 }
460
461 #[test]
462 fn everything_stays_inside_the_viewbox() {
463 for c in bars(&series(), Metric::Tokens, None, &Currency::USD).cols {
464 for s in c.segs {
465 let (x, w): (f32, f32) = (s.x.parse().unwrap(), s.w.parse().unwrap());
466 assert!(x >= LEFT && x + w <= BARS_W - RIGHT + 0.1, "{x} {w}");
467 let (y, h): (f32, f32) = (s.y.parse().unwrap(), s.h.parse().unwrap());
468 assert!(y >= TOP - 0.1 && y + h <= BARS_H - BOTTOM + 1.1, "{y} {h}");
469 }
470 }
471 }
472
473 #[test]
474 fn x_labels_are_thinned_and_shortened() {
475 let b = bars(&series(), Metric::Tokens, None, &Currency::USD);
476 assert!(!b.x_labels.is_empty() && b.x_labels.len() <= b.cols.len());
477 assert!(b.x_labels[0].text.len() <= 5, "{}", b.x_labels[0].text);
478 }
479
480 #[test]
481 fn donut_slices_carry_keys_percentages_and_theme_colours() {
482 let pie = Pie {
483 metric: Metric::Cost,
484 slices: vec![
485 slice("pi:a", Some("pi:full-a"), 30.0),
486 slice("pi:b", Some("pi:full-b"), 10.0),
487 ],
488 };
489 let d = donut(&pie, None, &Currency::USD);
490 assert_eq!(d.slices.len(), 2);
491 assert_eq!(d.total_label, "$40.00");
492 assert_eq!(d.slices[0].pct, "75%");
493 assert_eq!(d.slices[0].var, "var(--s0)");
494 assert_eq!(d.slices[1].var, "var(--s1)");
495 assert_eq!(d.slices[0].key.as_deref(), Some("pi:full-a"));
496 assert!(d.slices[0].title.contains("click to select"));
497 assert!(!d.has_selection);
498 }
499
500 #[test]
501 fn the_selected_slice_is_flagged_and_other_is_not_selectable() {
502 let pie = Pie {
503 metric: Metric::Tokens,
504 slices: vec![slice("pi:a", Some("pi:a"), 3.0), slice("other", None, 1.0)],
505 };
506 let d = donut(&pie, Some("pi:a"), &Currency::USD);
507 assert!(d.has_selection && d.slices[0].selected && !d.slices[1].selected);
508 assert!(d.slices[0].title.contains("click to clear"));
509 assert!(d.slices[1].title.contains("several small projects"));
510 assert!(!donut(&pie, Some("pi:gone"), &Currency::USD).has_selection);
511 }
512
513 #[test]
514 fn slices_pop_outwards_along_their_middle() {
515 let pie = Pie {
516 metric: Metric::Tokens,
517 slices: vec![slice("a", Some("a"), 1.0), slice("b", Some("b"), 1.0)],
518 };
519 let d = donut(&pie, None, &Currency::USD);
520 assert!(d.slices[0].dx.parse::<f32>().unwrap() > 0.0);
522 assert!(d.slices[1].dx.parse::<f32>().unwrap() < 0.0);
523 for s in &d.slices {
524 let (dx, dy): (f32, f32) = (s.dx.parse().unwrap(), s.dy.parse().unwrap());
525 assert!(((dx * dx + dy * dy).sqrt() - POP).abs() < 0.2);
526 }
527 }
528
529 #[test]
530 fn a_single_slice_still_draws_a_closed_ring() {
531 let pie = Pie {
532 metric: Metric::Tokens,
533 slices: vec![slice("only", Some("only"), 1.0)],
534 };
535 let d = donut(&pie, None, &Currency::USD);
536 assert_eq!(d.slices.len(), 1);
537 assert!(!d.slices[0].path.contains("NaN") && !d.slices[0].path.contains("inf"));
538 }
539
540 #[test]
541 fn an_empty_pie_is_flagged() {
542 assert!(
543 donut(
544 &Pie {
545 metric: Metric::Tokens,
546 slices: vec![]
547 },
548 None,
549 &Currency::USD
550 )
551 .empty
552 );
553 }
554
555 #[test]
556 fn ring_paths_use_the_large_arc_flag_past_half_a_circle() {
557 let small = ring_path(100.0, 100.0, 90.0, 50.0, 0.0, 1.0);
558 let big = ring_path(100.0, 100.0, 90.0, 50.0, 0.0, 4.0);
559 assert!(small.contains(" 0 0 1 "), "{small}");
560 assert!(big.contains(" 0 1 1 "), "{big}");
561 assert!(small.starts_with('M') && small.ends_with('Z'));
562 }
563
564 #[test]
565 fn links_append_the_selection_encoded() {
566 let base = "/?tool=pi&bucket=daily";
567 assert_eq!(href(base, &Selection::default()), base);
568 let sel = Selection {
569 bucket: Some("2026-09-22".into()),
570 project: Some("pi:atlas".into()),
571 };
572 assert_eq!(
573 href(base, &sel),
574 "/?tool=pi&bucket=daily&sel_bucket=2026-09-22&sel_project=pi%3Aatlas"
575 );
576 }
577}