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