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::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
26/// Escape text for HTML/SVG content and attribute values.
27pub fn esc(s: &str) -> String {
28    s.replace('&', "&")
29        .replace('<', "&lt;")
30        .replace('>', "&gt;")
31        .replace('"', "&quot;")
32        .replace('\'', "&#39;")
33}
34
35/// Percent-encode `s` for use as a URL query value.
36pub 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
49/// `var(--sN)` for a tool.
50pub 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// ── bars ────────────────────────────────────────────────────────────────────
59
60/// One tool's segment of a stacked bar.
61#[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/// One bucket: the stacked segments plus the full-height hit area.
71#[derive(Debug, Clone, PartialEq)]
72pub struct Col {
73    pub index: usize,
74    pub label: String,
75    /// Native tooltip (`<title>`): the bucket, every tool's value and the total.
76    pub title: String,
77    pub selected: bool,
78    /// Left edge and width of the whole column (the click target).
79    pub slot_x: String,
80    pub slot_w: String,
81    pub segs: Vec<Seg>,
82    /// Axis position of the plot's top/height (for the hit rect).
83    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    /// Every other label: narrow screens hide these (and use a bigger font) so the
100    /// rest stay readable when the chart is scaled down.
101    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    /// Whether any bar is selected (the others dim).
111    pub has_selection: bool,
112    pub left: String,
113    pub right: String,
114}
115
116/// Lay out `series` for `metric`; `selected` is the selected bucket label. A cost
117/// series must already be in `currency` (`TimeSeries::in_currency`).
118pub 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    // X labels, thinned so they never overlap.
201    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
213// ── donut ───────────────────────────────────────────────────────────────────
214
215pub const DONUT: f32 = 200.0;
216const R_OUT: f32 = 92.0;
217const R_IN: f32 = 56.0;
218/// How far a hovered/selected slice moves out, in viewBox units.
219const POP: f32 = 6.0;
220
221/// SVG path for one ring segment from `start` to `end` radians (clockwise from
222/// 12 o'clock) between radii `r_in` and `r_out`.
223pub fn ring_path(cx: f32, cy: f32, r_out: f32, r_in: f32, start: f32, end: f32) -> String {
224    // A full circle is a degenerate arc: stop a hair short of closing it.
225    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    /// Full `tool:project` key; `None` for `other` (not selectable).
245    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    /// Hover/selected offset (CSS custom properties `--dx`/`--dy`, along the slice's middle).
252    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
264/// Lay out `pie`; `selected` is the selected project key. A cost pie must already be
265/// in `currency` (`Pie::in_currency`).
266pub 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
309// ── links (static mode) ─────────────────────────────────────────────────────
310
311/// `base` (a `/?…` URL) with the selection appended.
312pub 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            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;"
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        // A stale label selects nothing.
411        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 an_empty_series_is_flagged_not_drawn() {
430        let b = bars(&TimeSeries::default(), Metric::Tokens, None, &Currency::USD);
431        assert!(b.empty && b.cols.is_empty() && b.grid.is_empty());
432    }
433
434    #[test]
435    fn everything_stays_inside_the_viewbox() {
436        for c in bars(&series(), Metric::Tokens, None, &Currency::USD).cols {
437            for s in c.segs {
438                let (x, w): (f32, f32) = (s.x.parse().unwrap(), s.w.parse().unwrap());
439                assert!(x >= LEFT && x + w <= BARS_W - RIGHT + 0.1, "{x} {w}");
440                let (y, h): (f32, f32) = (s.y.parse().unwrap(), s.h.parse().unwrap());
441                assert!(y >= TOP - 0.1 && y + h <= BARS_H - BOTTOM + 1.1, "{y} {h}");
442            }
443        }
444    }
445
446    #[test]
447    fn x_labels_are_thinned_and_shortened() {
448        let b = bars(&series(), Metric::Tokens, None, &Currency::USD);
449        assert!(!b.x_labels.is_empty() && b.x_labels.len() <= b.cols.len());
450        assert!(b.x_labels[0].text.len() <= 5, "{}", b.x_labels[0].text);
451    }
452
453    #[test]
454    fn donut_slices_carry_keys_percentages_and_theme_colours() {
455        let pie = Pie {
456            metric: Metric::Cost,
457            slices: vec![
458                slice("pi:a", Some("pi:full-a"), 30.0),
459                slice("pi:b", Some("pi:full-b"), 10.0),
460            ],
461        };
462        let d = donut(&pie, None, &Currency::USD);
463        assert_eq!(d.slices.len(), 2);
464        assert_eq!(d.total_label, "$40.00");
465        assert_eq!(d.slices[0].pct, "75%");
466        assert_eq!(d.slices[0].var, "var(--s0)");
467        assert_eq!(d.slices[1].var, "var(--s1)");
468        assert_eq!(d.slices[0].key.as_deref(), Some("pi:full-a"));
469        assert!(d.slices[0].title.contains("click to select"));
470        assert!(!d.has_selection);
471    }
472
473    #[test]
474    fn the_selected_slice_is_flagged_and_other_is_not_selectable() {
475        let pie = Pie {
476            metric: Metric::Tokens,
477            slices: vec![slice("pi:a", Some("pi:a"), 3.0), slice("other", None, 1.0)],
478        };
479        let d = donut(&pie, Some("pi:a"), &Currency::USD);
480        assert!(d.has_selection && d.slices[0].selected && !d.slices[1].selected);
481        assert!(d.slices[0].title.contains("click to clear"));
482        assert!(d.slices[1].title.contains("several small projects"));
483        assert!(!donut(&pie, Some("pi:gone"), &Currency::USD).has_selection);
484    }
485
486    #[test]
487    fn slices_pop_outwards_along_their_middle() {
488        let pie = Pie {
489            metric: Metric::Tokens,
490            slices: vec![slice("a", Some("a"), 1.0), slice("b", Some("b"), 1.0)],
491        };
492        let d = donut(&pie, None, &Currency::USD);
493        // First half is the right-hand side (12 → 6 o'clock): offset points right; the other left.
494        assert!(d.slices[0].dx.parse::<f32>().unwrap() > 0.0);
495        assert!(d.slices[1].dx.parse::<f32>().unwrap() < 0.0);
496        for s in &d.slices {
497            let (dx, dy): (f32, f32) = (s.dx.parse().unwrap(), s.dy.parse().unwrap());
498            assert!(((dx * dx + dy * dy).sqrt() - POP).abs() < 0.2);
499        }
500    }
501
502    #[test]
503    fn a_single_slice_still_draws_a_closed_ring() {
504        let pie = Pie {
505            metric: Metric::Tokens,
506            slices: vec![slice("only", Some("only"), 1.0)],
507        };
508        let d = donut(&pie, None, &Currency::USD);
509        assert_eq!(d.slices.len(), 1);
510        assert!(!d.slices[0].path.contains("NaN") && !d.slices[0].path.contains("inf"));
511    }
512
513    #[test]
514    fn an_empty_pie_is_flagged() {
515        assert!(
516            donut(
517                &Pie {
518                    metric: Metric::Tokens,
519                    slices: vec![]
520                },
521                None,
522                &Currency::USD
523            )
524            .empty
525        );
526    }
527
528    #[test]
529    fn ring_paths_use_the_large_arc_flag_past_half_a_circle() {
530        let small = ring_path(100.0, 100.0, 90.0, 50.0, 0.0, 1.0);
531        let big = ring_path(100.0, 100.0, 90.0, 50.0, 0.0, 4.0);
532        assert!(small.contains(" 0 0 1 "), "{small}");
533        assert!(big.contains(" 0 1 1 "), "{big}");
534        assert!(small.starts_with('M') && small.ends_with('Z'));
535    }
536
537    #[test]
538    fn links_append_the_selection_encoded() {
539        let base = "/?tool=pi&bucket=daily";
540        assert_eq!(href(base, &Selection::default()), base);
541        let sel = Selection {
542            bucket: Some("2026-09-22".into()),
543            project: Some("pi:atlas".into()),
544        };
545        assert_eq!(
546            href(base, &sel),
547            "/?tool=pi&bucket=daily&sel_bucket=2026-09-22&sel_project=pi%3Aatlas"
548        );
549    }
550}