Skip to main content

tokenburn_web/
components.rs

1//! Server-rendered page components.
2
3use tokenburn_core::utils::fmt::thousands;
4use tokenburn_core::{
5    Bucket, BucketSavings, CURRENCIES, Currency, Metric, Query, ReportRow, Savings, Summary,
6    ToolFilter, Window,
7};
8use topcoat::{
9    Result,
10    runtime::{Event, Signal},
11    view::{View, component, view},
12};
13
14use crate::charts::{self, Bars, Col, SliceGeom};
15use crate::data::{
16    Dashboard, MAX_BUCKET_ROWS, bucket_noun, bucket_rows, savings_rows, share_percent,
17};
18use crate::params::WINDOW_CHOICES;
19use crate::theme;
20use tokenburn_core::Selection;
21
22/// Title with the version, plus the Dashboard / Settings / About tabs.
23#[component]
24pub async fn brand(active: String) -> Result<impl View> {
25    let pick = |name: &str| if active == name { "tab active" } else { "tab" };
26    let (c_dash, c_set, c_about) = (pick("dashboard"), pick("settings"), pick("about"));
27    Ok(view! {
28        <div class="brand">
29            <h1><a href="/" style="color:inherit">"🔥 TokenBurn"</a> <small class="ver">(tokenburn_core::appinfo::version_label())</small></h1>
30            <nav class="tabs" aria-label="Pages">
31                <a class=(c_dash) href="/">"Dashboard"</a>
32                <a class=(c_set) href="/settings">"⚙ Settings"</a>
33                <a class=(c_about) href="/about">"ℹ About"</a>
34            </nav>
35        </div>
36    })
37}
38
39/// Row of headline totals.
40#[component]
41pub async fn summary_cards(
42    metric: Metric,
43    total: Summary,
44    currency: Currency,
45    savings: &Savings,
46) -> Result<impl View> {
47    // Which figures show follows the chart metric (tokens / cost / saved).
48    let cards = tokenburn_core::cards(metric, &total, savings, &currency);
49    Ok(view! {
50        <section class="cards">
51            for c in &cards {
52                <div class=(card_class(c.kind))><div class="label">(c.label.clone())</div><div class="value">(c.value.clone())</div></div>
53            }
54        </section>
55    })
56}
57
58/// CSS class of a card: `card`, `card cost` or `card saved`.
59pub fn card_class(kind: tokenburn_core::CardKind) -> &'static str {
60    match kind {
61        tokenburn_core::CardKind::Plain => "card",
62        tokenburn_core::CardKind::Cost => "card cost",
63        tokenburn_core::CardKind::Saved => "card saved",
64    }
65}
66
67/// The bar chart's title for `metric`.
68pub fn chart_title(metric: Metric) -> &'static str {
69    match metric {
70        Metric::Tokens => "Tokens over time",
71        Metric::Cost => "Cost over time",
72        Metric::Saved => "Saved over time (cache + compaction)",
73    }
74}
75
76/// What prompt caching, rtk and pi compaction saved, per source. Renders nothing
77/// when nothing was saved.
78#[component]
79pub async fn savings_panel(
80    savings: &Savings,
81    currency: Currency,
82    spent_usd: f64,
83    input_price: f64,
84    buckets: &[BucketSavings],
85    noun: &str,
86) -> Result<impl View> {
87    let rows = savings_rows(savings, currency, spent_usd);
88    let per_bucket = bucket_rows(buckets, currency, MAX_BUCKET_ROWS);
89    let per_title = format!("Saved per {noun}");
90    let basis = format!(
91        "estimate · valued at {}/M input tokens",
92        currency.format(input_price)
93    );
94    Ok(view! {
95        if !rows.is_empty() {
96            <section class="savings">
97                <h2>"Saved " <small class="ver">(basis)</small></h2>
98                <div class="table-wrap"><table>
99                    <thead>
100                        <tr>
101                            <th class="left">"source"</th>
102                            <th>"tokens"</th>
103                            <th>"saved"</th>
104                            <th class="left opt2">"basis"</th>
105                        </tr>
106                    </thead>
107                    <tbody>
108                        for r in &rows {
109                            <tr class=(if r.total { "total" } else { "" })>
110                                <td class="left">(r.label.clone())</td>
111                                <td>(r.tokens.clone())</td>
112                                <td class="cost">(r.money.clone())</td>
113                                <td class="left opt2">(r.basis.clone())</td>
114                            </tr>
115                        }
116                    </tbody>
117                </table></div>
118                if !per_bucket.is_empty() {
119                    <h2>(per_title)</h2>
120                    <div class="table-wrap"><table class="per-bucket">
121                        <thead>
122                            <tr>
123                                <th class="left">"interval"</th>
124                                <th class="opt2">"tokens"</th>
125                                <th>"saved"</th>
126                                <th class="left opt2">"by source"</th>
127                            </tr>
128                        </thead>
129                        <tbody>
130                            for b in &per_bucket {
131                                <tr>
132                                    <td class="left">(b.label.clone())</td>
133                                    <td class="opt2">(b.tokens.clone())</td>
134                                    <td class="cost">(b.money.clone())</td>
135                                    <td class="left opt2">(b.split.clone())</td>
136                                </tr>
137                            }
138                        </tbody>
139                    </table></div>
140                }
141            </section>
142        }
143    })
144}
145
146/// GET form that drives the query string.
147#[component]
148pub async fn filters(
149    query: Query,
150    theme: usize,
151    metric: Metric,
152    currency: Currency,
153) -> Result<impl View> {
154    Ok(view! {
155        <form class="filters" method="get" action="/">
156            <label>"chart "
157                <select name="metric" onchange="this.form.submit()">
158                    for m in Metric::ALL {
159                        <option value=(m.label()) selected=(m == metric)>(m.label())</option>
160                    }
161                </select>
162            </label>
163            <label>"tool "
164                <select name="tool" onchange="this.form.submit()">
165                    for t in ToolFilter::ALL {
166                        <option value=(t.as_str()) selected=(t == query.tool)>(t.as_str())</option>
167                    }
168                </select>
169            </label>
170            <label>"view "
171                <select name="bucket" onchange="this.form.submit()">
172                    for b in Bucket::ALL {
173                        <option value=(b.label()) selected=(b == query.bucket)>(b.label())</option>
174                    }
175                </select>
176            </label>
177            <label>"window "
178                <select name="window" onchange="this.form.submit()">
179                    for w in Window::ALL {
180                        <option value=(w.label()) selected=(w == query.window)>(w.label())</option>
181                    }
182                </select>
183            </label>
184
185            <label>"currency "
186                <select name="currency" onchange="this.form.submit()">
187                    for cur in CURRENCIES {
188                        <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
189                    }
190                </select>
191            </label>
192            <label>"theme "
193                <select name="theme" onchange="this.form.submit()">
194                    for n in theme::names() {
195                        <option value=(n) selected=(n == theme::name(theme))>(n)</option>
196                    }
197                </select>
198            </label>
199            <noscript><button type="submit">"Apply"</button></noscript>
200        </form>
201    })
202}
203
204/// Aggregated report table with a share-of-tokens bar per line.
205#[component]
206pub async fn report_table(report: &[ReportRow], currency: Currency) -> Result<impl View> {
207    let bucketed = report.iter().any(|r| r.bucket.is_some());
208    let max = report
209        .iter()
210        .filter(|r| r.tool != "TOTAL")
211        .map(|r| r.summary.total_tokens)
212        .max()
213        .unwrap_or(0);
214
215    Ok(view! {
216        <div class="table-wrap"><table>
217            <thead>
218                <tr>
219                    if bucketed { <th>"bucket"</th> }
220                    <th class="left">"tool"</th>
221                    <th class="opt2">"turns"</th>
222                    <th class="opt2">"input"</th>
223                    <th class="opt2">"output"</th>
224                    <th class="opt1">"cache read"</th>
225                    <th class="opt1">"cache write"</th>
226                    <th>"total"</th>
227                    <th>"cost"</th>
228                    <th class="left opt2">"share"</th>
229                </tr>
230            </thead>
231            <tbody>
232                for r in report {
233                    <tr class=(if r.tool == "TOTAL" { "total" } else { "" })>
234                        if bucketed { <td>(r.bucket.clone().unwrap_or_default())</td> }
235                        <td class="left">(r.tool.clone())</td>
236                        <td class="opt2">(thousands(r.summary.turns))</td>
237                        <td class="opt2">(thousands(r.summary.input))</td>
238                        <td class="opt2">(thousands(r.summary.output))</td>
239                        <td class="opt1">(thousands(r.summary.cache_read))</td>
240                        <td class="opt1">(thousands(r.summary.cache_write))</td>
241                        <td>(thousands(r.summary.total_tokens))</td>
242                        <td class="cost">(currency.format(r.summary.cost_usd))</td>
243                        <td class="left opt2">
244                            if r.tool != "TOTAL" {
245                                <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent(r.summary.total_tokens, max), theme::series_var(&r.tool)))></span></span>
246                            }
247                        </td>
248                    </tr>
249                }
250            </tbody>
251        </table></div>
252    })
253}
254
255/// Per-project / per-model breakdown, most expensive first.
256#[component]
257pub async fn projects_table(
258    projects: &[(String, Summary)],
259    currency: Currency,
260) -> Result<impl View> {
261    let max = projects
262        .iter()
263        .map(|(_, s)| (s.cost_usd * 10_000.0) as u64)
264        .max()
265        .unwrap_or(0);
266
267    Ok(view! {
268        <div class="table-wrap"><table>
269            <thead>
270                <tr>
271                    <th class="left">"tool:project/model"</th>
272                    <th class="opt2">"turns"</th>
273                    <th>"total tokens"</th>
274                    <th>"cost"</th>
275                    <th class="left opt2">"share"</th>
276                </tr>
277            </thead>
278            <tbody>
279                for (name, s) in projects {
280                    <tr>
281                        <td class="left">(name.clone())</td>
282                        <td class="opt2">(thousands(s.turns))</td>
283                        <td>(thousands(s.total_tokens))</td>
284                        <td class="cost">(currency.format(s.cost_usd))</td>
285                        <td class="left opt2">
286                            <span class="bar"><span style=(format!("width:{}%;background:{}", share_percent((s.cost_usd * 10_000.0) as u64, max), theme::series_var(name.split(':').next().unwrap_or(""))))></span></span>
287                        </td>
288                    </tr>
289                }
290            </tbody>
291        </table></div>
292    })
293}
294
295/// Selects bound to signals: changing one updates the signal in the browser
296/// and (because the enclosing shard reads it) re-renders the shard.
297#[component]
298pub async fn live_filters(
299    tool: &Signal<String>,
300    bucket: &Signal<String>,
301    window: &Signal<String>,
302    theme: &Signal<String>,
303    metric: &Signal<String>,
304    currency: &Signal<String>,
305) -> Result<impl View> {
306    let cur = currency.get_untracked();
307    let (t, b, w, th, m) = (
308        tool.get_untracked(),
309        bucket.get_untracked(),
310        window.get_untracked(),
311        theme.get_untracked(),
312        metric.get_untracked(),
313    );
314    Ok(view! {
315        <div class="filters">
316            <label>"chart "
317                <select :value=$(metric.get()) @change=$(|e: Event| metric.set(e.target.value))>
318                    for x in Metric::ALL {
319                        <option value=(x.label()) selected=(x.label() == m)>(x.label())</option>
320                    }
321                </select>
322            </label>
323            <label>"tool "
324                <select :value=$(tool.get()) @change=$(|e: Event| tool.set(e.target.value))>
325                    for x in ToolFilter::ALL {
326                        <option value=(x.as_str()) selected=(x.as_str() == t)>(x.as_str())</option>
327                    }
328                </select>
329            </label>
330            <label>"view "
331                <select :value=$(bucket.get()) @change=$(|e: Event| bucket.set(e.target.value))>
332                    for x in Bucket::ALL {
333                        <option value=(x.label()) selected=(x.label() == b)>(x.label())</option>
334                    }
335                </select>
336            </label>
337            <label>"window "
338                <select :value=$(window.get()) @change=$(|e: Event| window.set(e.target.value))>
339                    for x in WINDOW_CHOICES {
340                        <option value=(x) selected=(x == w)>(x)</option>
341                    }
342                </select>
343            </label>
344
345            <label>"currency "
346                <select :value=$(currency.get()) @change=$(|e: Event| currency.set(e.target.value))>
347                    for cc in CURRENCIES {
348                        <option value=(cc.code) selected=(cc.code == cur)>(format!("{} · {}", cc.code, cc.name))</option>
349                    }
350                </select>
351            </label>
352            <label>"theme "
353                <select :value=$(theme.get()) @change=$(|e: Event| theme.set(e.target.value))>
354                    for n in theme::names() {
355                        <option value=(n) selected=(n == th)>(n)</option>
356                    }
357                </select>
358            </label>
359        </div>
360    })
361}
362
363/// Cards, report and project tables for one [`Dashboard`] snapshot.
364///
365/// `live` shows the "live" badge (the page has an open connection and is
366/// refreshing itself).
367#[component]
368pub async fn dashboard_body(
369    d: &Dashboard,
370    live: bool,
371    metric: Metric,
372    base: &str,
373    click_bucket: Option<&Signal<String>>,
374    click_project: Option<&Signal<String>>,
375) -> Result<impl View> {
376    Ok(view! {
377        for w in &d.warnings {
378            <div class="warn">"⚠ " (w.clone())</div>
379        }
380
381        summary_cards(metric: metric, total: d.total, currency: d.currency, savings: &d.savings)
382
383
384        charts_panel(d: d, metric: metric, base: base, animate: live, click_bucket: click_bucket, click_project: click_project)
385
386        if metric == Metric::Saved {
387            savings_panel(savings: &d.savings, currency: d.currency, spent_usd: d.total.cost_usd, input_price: d.input_price, buckets: &d.savings_by_bucket, noun: bucket_noun(&d.query))
388        }
389
390        <section>
391            <h2>"Report · " (d.query.bucket.label())</h2>
392            if d.report.is_empty() {
393                <div class="empty">
394                    "No tokenburn results found for tool=" (d.query.tool.as_str())
395                    ", window=" (d.query.window.label()) "."
396                </div>
397            } else {
398                report_table(report: &d.report, currency: d.currency)
399            }
400        </section>
401
402        if tokenburn_core::projects_add_detail(&d.report, &d.projects) {
403            <section>
404                <h2>"Projects / models (by cost)"</h2>
405                projects_table(projects: &d.projects, currency: d.currency)
406            </section>
407        }
408
409        <footer>
410            if live {
411                <span class="badge live">"● live"</span>
412            } else {
413                <span class="badge">"○ static"</span>
414            }
415            " "
416            (d.row_count) " rows · updated " (d.updated_at.clone())
417            " · local logs only · JSON at "
418            <a href="/api/report">"/api/report"</a>
419        </footer>
420    })
421}
422
423/// The active theme's CSS variables, as a `<style>` element.
424#[component]
425pub async fn theme_style(index: usize) -> Result<impl View> {
426    let css = theme::css(index);
427    Ok(view! { <style>(css)</style> })
428}
429
430/// What a click on a bar or slice does.
431///
432/// * **Live** (runtime available): the handler sets the page's selection signals; the
433///   shard re-renders in place, so CSS transitions animate the change.
434/// * **Static**: every bar and slice is a link to the same page with the new
435///   selection in the query string (`base` is that page's URL without it).
436#[derive(Clone, Copy)]
437pub struct Clicks<'a> {
438    pub bucket: Option<&'a Signal<String>>,
439    pub project: Option<&'a Signal<String>>,
440}
441
442/// One bar: native tooltip, full-height hit area, the stacked segments and a
443/// selection outline. The wrapper (`<g>` + click handler/link) is added by the caller.
444#[component]
445pub async fn bar_col(col: &Col) -> Result<impl View> {
446    Ok(view! {
447        <title>(col.title.clone())</title>
448        <rect class="hit" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
449        for seg in &col.segs {
450            <rect class="seg" x=(seg.x.clone()) y=(seg.y.clone()) width=(seg.w.clone()) height=(seg.h.clone()) fill=(seg.var.clone())></rect>
451        }
452        <rect class="outline" x=(col.slot_x.clone()) y=(col.plot_y.clone()) width=(col.slot_w.clone()) height=(col.plot_h.clone())></rect>
453    })
454}
455
456/// One donut slice (the ring segment with its tooltip).
457#[component]
458pub async fn slice_path(slice: &SliceGeom) -> Result<impl View> {
459    Ok(view! {
460        <path d=(slice.path.clone()) fill=(slice.var.clone())>
461            <title>(slice.title.clone())</title>
462        </path>
463    })
464}
465
466fn col_class(c: &Col) -> String {
467    if c.selected {
468        "col sel".into()
469    } else {
470        "col".into()
471    }
472}
473
474fn slice_class(s: &SliceGeom) -> String {
475    let mut c = String::from("slice");
476    if s.selected {
477        c.push_str(" sel");
478    }
479    if s.key.is_none() {
480        c.push_str(" other");
481    }
482    c
483}
484
485/// Stagger so the whole sweep takes ~0.6 s however many bars there are.
486fn bar_delay(index: usize, n: usize) -> String {
487    let step = (600.0 / n.max(1) as f32).min(14.0);
488    format!("--d:{:.0}ms", step * index as f32)
489}
490
491/// The bar chart (`<svg>`).
492#[component]
493pub async fn bars_chart(
494    bars: &Bars,
495    metric: Metric,
496    base: &str,
497    sel: &Selection,
498    animate: bool,
499    click: Option<&Signal<String>>,
500) -> Result<impl View> {
501    let n = bars.cols.len();
502    // The selection each column would produce when clicked (toggle).
503    let cols: Vec<(Col, String, String, String)> = bars
504        .cols
505        .iter()
506        .map(|c| {
507            let mut next = sel.clone();
508            next.toggle_bucket(&c.label);
509            (
510                c.clone(),
511                charts::href(base, &next),
512                next.bucket.clone().unwrap_or_default(),
513                format!("{} {}", col_class(c), if animate { "anim" } else { "" }),
514            )
515        })
516        .collect();
517    let class = format!(
518        "chart bars{}{}",
519        if bars.has_selection { " has-sel" } else { "" },
520        if animate { " animate" } else { "" }
521    );
522    let label = format!("{} over time", metric.label());
523
524    Ok(view! {
525        <svg class=(class) viewBox=(format!("0 0 {} {}", charts::BARS_W, charts::BARS_H)) width="100%" role="img" aria-label=(label)>
526            if bars.empty {
527                <text class="empty-note" x=(charts::BARS_W / 2.0) y=(charts::BARS_H / 2.0) text-anchor="middle">"No data in this window"</text>
528            }
529            for g in &bars.grid {
530                <line class="grid" x1=(bars.left.clone()) x2=(bars.right.clone()) y1=(g.y.clone()) y2=(g.y.clone())></line>
531                <text class="axis" x=(charts::LEFT - 6.0) y=(g.text_y.clone()) text-anchor="end">(g.label.clone())</text>
532            }
533            for (c, href, next, class) in cols {
534                if let Some(sig) = click {
535                    <g class=(class) style=(bar_delay(c.index, n)) @click=$(|_e| sig.set(next))>
536                        bar_col(col: &c)
537                    </g>
538                } else {
539                    <a href=(href)>
540                        <g class=(class) style=(bar_delay(c.index, n))>
541                            bar_col(col: &c)
542                        </g>
543                    </a>
544                }
545            }
546            for l in &bars.x_labels {
547                <text class=(if l.alt { "axis alt" } else { "axis" }) x=(l.x.clone()) y=(l.y.clone()) text-anchor="middle">(l.text.clone())</text>
548            }
549        </svg>
550    })
551}
552
553/// The share donut (`<svg>`), its slices sweeping in through a mask.
554#[component]
555pub async fn donut_chart(
556    donut: &charts::Donut,
557    base: &str,
558    sel: &Selection,
559    animate: bool,
560    click: Option<&Signal<String>>,
561) -> Result<impl View> {
562    let slices: Vec<(SliceGeom, String, String, String)> = donut
563        .slices
564        .iter()
565        .map(|s| {
566            let mut next = sel.clone();
567            if let Some(k) = &s.key {
568                next.toggle_project(k);
569            }
570            (
571                s.clone(),
572                charts::href(base, &next),
573                next.project.clone().unwrap_or_default(),
574                format!("--dx:{};--dy:{}", s.dx, s.dy),
575            )
576        })
577        .collect();
578    let class = format!(
579        "chart donut{}{}",
580        if donut.has_selection { " has-sel" } else { "" },
581        if animate { " animate" } else { "" }
582    );
583    let total = donut.total_label.clone();
584
585    Ok(view! {
586        <svg class=(class) viewBox="0 0 200 200" width="100%" role="img" aria-label="share by project">
587            if donut.empty {
588                <text class="empty-note" x="100" y="104" text-anchor="middle">"No data"</text>
589            } else {
590                <defs>
591                    <mask id="sweep" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
592                        <circle class="sweep" cx="100" cy="100" r="74" fill="none" stroke="white" stroke-width="60" pathLength="1" transform="rotate(-90 100 100)"></circle>
593                    </mask>
594                </defs>
595                <g mask="url(#sweep)">
596                    for (s, href, next, vars) in slices {
597                        if s.key.is_none() {
598                            <g class=(slice_class(&s)) style=(vars)>
599                                slice_path(slice: &s)
600                            </g>
601                        } else if let Some(sig) = click {
602                            <g class=(slice_class(&s)) style=(vars) @click=$(|_e| sig.set(next))>
603                                slice_path(slice: &s)
604                            </g>
605                        } else {
606                            <a href=(href)>
607                                <g class=(slice_class(&s)) style=(vars)>
608                                    slice_path(slice: &s)
609                                </g>
610                            </a>
611                        }
612                    }
613                </g>
614                <text class="total" x="100" y="106" text-anchor="middle">(total)</text>
615            }
616        </svg>
617    })
618}
619
620/// "Showing …" chips with a clear control, or a hint when nothing is selected.
621#[component]
622pub async fn selection_bar(
623    sel: &Selection,
624    base: &str,
625    click_bucket: Option<&Signal<String>>,
626    click_project: Option<&Signal<String>>,
627) -> Result<impl View> {
628    let chips = sel.describe();
629    let clear = charts::href(base, &Selection::default());
630    // Runtime expressions only take single-identifier paths, so the empty values are passed in.
631    let (blank_a, blank_b) = (String::new(), String::new());
632    Ok(view! {
633        <div class="selbar">
634            if chips.is_empty() {
635                <span class="hint">"click a bar or a slice to drill down"</span>
636            } else {
637                <span class="hint">"Showing"</span>
638                for chip in chips {
639                    <span class="chip">(chip)</span>
640                }
641                if let (Some(b), Some(p)) = (click_bucket, click_project) {
642                    <button class="clear" @click=$(|_e| { b.set(blank_a); p.set(blank_b); })>"✕ clear"</button>
643                } else {
644                    <a class="clear" href=(clear)>"✕ clear"</a>
645                }
646            }
647        </div>
648    })
649}
650
651/// Stacked bars over time and the share donut, both clickable.
652#[component]
653pub async fn charts_panel(
654    d: &Dashboard,
655    metric: Metric,
656    base: &str,
657    animate: bool,
658    click_bucket: Option<&Signal<String>>,
659    click_project: Option<&Signal<String>>,
660) -> Result<impl View> {
661    let cur = d.currency;
662    let bars = charts::bars(
663        &d.series.in_currency(&cur),
664        metric,
665        d.selection.bucket.as_deref(),
666        &cur,
667    );
668    let donut = charts::donut(
669        &d.pie.in_currency(&cur),
670        d.selection.project.as_deref(),
671        &cur,
672    );
673    let sel = d.selection.clone();
674    let pie_metric = d.pie.metric;
675
676    // Legend rows (clickable like the slices): swatch, label, share.
677    let legend: Vec<(SliceGeom, String, String)> = donut
678        .slices
679        .iter()
680        .map(|s| {
681            let mut next = sel.clone();
682            if let Some(k) = &s.key {
683                next.toggle_project(k);
684            }
685            (
686                s.clone(),
687                charts::href(base, &next),
688                next.project.clone().unwrap_or_default(),
689            )
690        })
691        .collect();
692    let swatches: Vec<(String, String)> = d
693        .series
694        .active_tools(metric)
695        .into_iter()
696        .map(|t| (charts::tool_var(t), t.display_name().to_string()))
697        .collect();
698
699    Ok(view! {
700        <section class="charts">
701            <div class="panel">
702                <h2>(chart_title(metric))</h2>
703                bars_chart(bars: &bars, metric: metric, base: base, sel: &sel, animate: animate, click: click_bucket)
704                <ul class="legend">
705                    for (var, name) in swatches {
706                        <li><span class="sw" style=(format!("background:{var}"))></span>(name)</li>
707                    }
708                </ul>
709            </div>
710            <div class="panel">
711                <h2>"Share by project · " (pie_metric.label())</h2>
712                <div class="donut-row">
713                    <div class="donut-box">
714                        donut_chart(donut: &donut, base: base, sel: &sel, animate: animate, click: click_project)
715                    </div>
716                    <ul class=(if donut.has_selection { "legend vertical has-sel" } else { "legend vertical" })>
717                        for (s, href, next) in legend {
718                            if s.key.is_none() {
719                                <li class="item other"><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></li>
720                            } else if let Some(sig) = click_project {
721                                <li class=(if s.selected { "item sel" } else { "item" }) @click=$(|_e| sig.set(next))>
722                                    <span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span>
723                                </li>
724                            } else {
725                                <li class=(if s.selected { "item sel" } else { "item" })>
726                                    <a href=(href)><span class="sw" style=(format!("background:{}", s.var))></span>(s.label.clone()) <span class="pct">(s.pct.clone())</span></a>
727                                </li>
728                            }
729                        }
730                    </ul>
731                </div>
732            </div>
733        </section>
734        selection_bar(sel: &sel, base: base, click_bucket: click_bucket, click_project: click_project)
735    })
736}