Skip to main content

tokenburn_web/
charts.rs

1//! Chart geometry for the web dashboard: stacked bars and a donut.
2//!
3//! Everything here is **pure** — it turns [`TimeSeries`] / [`Pie`] into numbers,
4//! paths and labels (viewBox units). `components.rs` renders them as inline SVG,
5//! attaching a click handler to each bar and slice, and `style.rs` animates and
6//! highlights them with CSS.
7//!
8//! Colours are CSS custom properties (`var(--s3)`, `var(--line)` …), so a theme
9//! change restyles the charts without regenerating them.
10
11use 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
24/// Escape text for HTML/SVG content and attribute values.
25pub fn esc(s: &str) -> String {
26    s.replace('&', "&")
27        .replace('<', "&lt;")
28        .replace('>', "&gt;")
29        .replace('"', "&quot;")
30        .replace('\'', "&#39;")
31}
32
33/// Percent-encode `s` for use as a URL query value.
34pub 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
47/// `var(--sN)` for a tool.
48pub 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// ── bars ────────────────────────────────────────────────────────────────────
57
58/// One tool's segment of a stacked bar.
59#[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/// One bucket: the stacked segments plus the full-height hit area.
69#[derive(Debug, Clone, PartialEq)]
70pub struct Col {
71    pub index: usize,
72    pub label: String,
73    /// Native tooltip (`<title>`): the bucket, every tool's value and the total.
74    pub title: String,
75    pub selected: bool,
76    /// Left edge and width of the whole column (the click target).
77    pub slot_x: String,
78    pub slot_w: String,
79    pub segs: Vec<Seg>,
80    /// Axis position of the plot's top/height (for the hit rect).
81    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    /// Whether any bar is selected (the others dim).
106    pub has_selection: bool,
107    pub left: String,
108    pub right: String,
109}
110
111/// Lay out `series` for `metric`; `selected` is the selected bucket label.
112pub 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    // X labels, thinned so they never overlap.
190    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
201// ── donut ───────────────────────────────────────────────────────────────────
202
203pub const DONUT: f32 = 200.0;
204const R_OUT: f32 = 92.0;
205const R_IN: f32 = 56.0;
206/// How far a hovered/selected slice moves out, in viewBox units.
207const POP: f32 = 6.0;
208
209/// SVG path for one ring segment from `start` to `end` radians (clockwise from
210/// 12 o'clock) between radii `r_in` and `r_out`.
211pub fn ring_path(cx: f32, cy: f32, r_out: f32, r_in: f32, start: f32, end: f32) -> String {
212    // A full circle is a degenerate arc: stop a hair short of closing it.
213    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    /// Full `tool:project` key; `None` for `other` (not selectable).
233    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    /// Hover/selected offset (CSS custom properties `--dx`/`--dy`, along the slice's middle).
240    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
252/// Lay out `pie`; `selected` is the selected project key.
253pub 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
296// ── links (static mode) ─────────────────────────────────────────────────────
297
298/// `base` (a `/?…` URL) with the selection appended.
299pub 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            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;"
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        // A stale label selects nothing.
398        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        // First half is the right-hand side (12 → 6 o'clock): offset points right; the other left.
481        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}