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