From cbd49e99d3a3c9e5636444cb5be3a925d0fce0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 21:59:24 -0400 Subject: [PATCH] T15 Charts: cartesian with axes, legend, crosshair, tooltip, zoom, annotations and export (T15a); pie, donut, radar, heatmaps, treemap, sunburst, waterfall and spreads (T15b); sankey, funnel, gantt, force graph, chord, parallel coordinates, bullet and progress (T15c) --- AGENTS.md | 3 + TASKS.md | 7 +- examples/gallery/pages/charts/cartesian.rs | 232 +++++++++++ examples/gallery/pages/charts/flows.rs | 170 ++++++++ examples/gallery/pages/charts/mod.rs | 83 ++++ examples/gallery/pages/charts/spread.rs | 53 +++ examples/gallery/pages/charts/wholes.rs | 142 +++++++ examples/gallery/pages/mod.rs | 2 + examples/gallery/script.rs | 6 + scripts/check.sh | 2 +- src/charts/axes.rs | 175 ++++++++ src/charts/calendar.rs | 194 +++++++++ src/charts/cartesian.rs | 412 +++++++++++++++++++ src/charts/distribution.rs | 406 +++++++++++++++++++ src/charts/export.rs | 230 +++++++++++ src/charts/flow.rs | 448 +++++++++++++++++++++ src/charts/funnel.rs | 261 ++++++++++++ src/charts/geometry.rs | 410 +++++++++++++++++++ src/charts/glide.rs | 93 +++++ src/charts/layout.rs | 135 +++++++ src/charts/mod.rs | 47 +++ src/charts/network.rs | 399 ++++++++++++++++++ src/charts/paint.rs | 232 +++++++++++ src/charts/parallel.rs | 295 ++++++++++++++ src/charts/parts.rs | 138 +++++++ src/charts/pie.rs | 397 ++++++++++++++++++ src/charts/plot.rs | 237 +++++++++++ src/charts/pointer.rs | 324 +++++++++++++++ src/charts/radar.rs | 362 +++++++++++++++++ src/charts/realtime.rs | 181 +++++++++ src/charts/relations.rs | 294 ++++++++++++++ src/charts/scale.rs | 163 ++++++++ src/charts/schedule.rs | 408 +++++++++++++++++++ src/charts/series.rs | 222 ++++++++++ src/charts/stats.rs | 200 +++++++++ src/charts/sunburst.rs | 276 +++++++++++++ src/charts/targets.rs | 447 ++++++++++++++++++++ src/charts/tests.rs | 29 ++ src/charts/tiles.rs | 443 ++++++++++++++++++++ src/lib.rs | 1 + src/theme/chart.rs | 41 ++ src/theme/mod.rs | 2 + src/theme/tokens.rs | 2 +- tasks/ch11-20.md | 70 ++-- 44 files changed, 8639 insertions(+), 35 deletions(-) create mode 100644 examples/gallery/pages/charts/cartesian.rs create mode 100644 examples/gallery/pages/charts/flows.rs create mode 100644 examples/gallery/pages/charts/mod.rs create mode 100644 examples/gallery/pages/charts/spread.rs create mode 100644 examples/gallery/pages/charts/wholes.rs create mode 100644 src/charts/axes.rs create mode 100644 src/charts/calendar.rs create mode 100644 src/charts/cartesian.rs create mode 100644 src/charts/distribution.rs create mode 100644 src/charts/export.rs create mode 100644 src/charts/flow.rs create mode 100644 src/charts/funnel.rs create mode 100644 src/charts/geometry.rs create mode 100644 src/charts/glide.rs create mode 100644 src/charts/layout.rs create mode 100644 src/charts/mod.rs create mode 100644 src/charts/network.rs create mode 100644 src/charts/paint.rs create mode 100644 src/charts/parallel.rs create mode 100644 src/charts/parts.rs create mode 100644 src/charts/pie.rs create mode 100644 src/charts/plot.rs create mode 100644 src/charts/pointer.rs create mode 100644 src/charts/radar.rs create mode 100644 src/charts/realtime.rs create mode 100644 src/charts/relations.rs create mode 100644 src/charts/scale.rs create mode 100644 src/charts/schedule.rs create mode 100644 src/charts/series.rs create mode 100644 src/charts/stats.rs create mode 100644 src/charts/sunburst.rs create mode 100644 src/charts/targets.rs create mode 100644 src/charts/tests.rs create mode 100644 src/charts/tiles.rs create mode 100644 src/theme/chart.rs diff --git a/AGENTS.md b/AGENTS.md index e1afcac..f112fe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. +- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. +- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. +- Tokens live in `src/theme`; the raw `px()` scan skips that folder only. ## Decisions diff --git a/TASKS.md b/TASKS.md index d9f43b8..54d63a1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -52,7 +52,9 @@ Per-component lines live in `tasks/`. Tags there: - [ ] T14a Tables · Core - [ ] T14b Tables · Columns & rows - [ ] T14c Tables · Grids -- [ ] T15 Charts +- [ ] T15a Charts · Cartesian +- [ ] T15b Charts · Parts and spreads +- [ ] T15c Charts · Flows and relations - [ ] T16a Finance · Market charts - [ ] T16b Finance · Technical analysis - [ ] T16c Finance · Quotes & book @@ -204,3 +206,6 @@ Per-component lines live in `tasks/`. Tags there: | T14a | 1 | FAIL | numbers and words compared out of order in a mixed column, and the sort panicked | | T14b | 1 | FAIL | a removed filter rule left its typing to the next rule, and two sort rows could take one column | | T14c | 1 | FAIL | a shrunk grid kept its cursor outside, long column letters overflowed, presses landed in merged cells, Tab left the grid, and ranges counted text and blanks | +| T15a | 1 | — | pending review | +| T15b | 1 | — | pending review | +| T15c | 1 | — | pending review | diff --git a/examples/gallery/pages/charts/cartesian.rs b/examples/gallery/pages/charts/cartesian.rs new file mode 100644 index 0000000..01fb032 --- /dev/null +++ b/examples/gallery/pages/charts/cartesian.rs @@ -0,0 +1,232 @@ +use std::time::Duration; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + charts::{ + AreaChart, BarChart, ChartTooltip, LineChart, Points, RealtimeChart, ScatterChart, Series, + }, + theme::ActiveTheme, + typography::Caption, +}; +use gpui::{App, ClipboardItem, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, row, section, set}, +}; + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// A steady pseudo-random series, so captures repeat. +pub(super) fn noise(seed: u64) -> impl FnMut() -> f64 { + let mut state = seed; + move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as f64 / (1u64 << 31) as f64 + } +} + +pub fn lines(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let turn = keep("lines-turn", || 0usize, window, cx); + let (now, next) = (*turn.read(cx), turn.clone()); + let copied = keep("lines-copied", || None::<&'static str>, window, cx); + let (told, tell) = (*copied.read(cx), copied.clone()); + let mut rng = noise(21 + now as u64); + let revenue: Vec = (0..12) + .map(|month| 40.0 + month as f64 * 4.0 + rng() * 12.0) + .collect(); + let costs: Vec = (0..12) + .map(|month| 30.0 + month as f64 * 2.5 + rng() * 8.0) + .collect(); + let chart = || { + LineChart::new("revenue", MONTHS) + .series(Series::new("Revenue", revenue.clone())) + .series(Series::new("Costs", costs.clone())) + .smooth() + .rule(70.0, "Target") + .note(8, "Launch") + .zoom() + .format(|value| { + if value == 0.0 { + "$0".into() + } else { + format!("${value:.0}k") + } + }) + }; + let (for_svg, for_csv) = (chart(), chart()); + section( + "LineChart / ChartAxis / ChartGrid / ChartLegend / ChartTooltip / ChartCrosshair / ChartAnnotation / ChartZoom / ChartExport", + "Hover for a crosshair and each series' value. Press a name in the legend to hide it. Drag across the chart to zoom into a span; a double press shows the whole year. The target and the launch are annotations. Copy the chart as SVG or its data as CSV.", + cx, + ) + .child(probe("revenue", div().w(px(720.)).child(chart()))) + .child( + row() + .child(Button::new("lines-next", "New data").variant(ButtonVariant::Ghost).on_click(move |_, _, cx| set(&next, now + 1, cx))) + .child(Button::new("lines-svg", "Copy SVG").variant(ButtonVariant::Ghost).on_click({ + let tell = tell.clone(); + move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(for_svg.svg(720.0, 240.0, cx))); + set(&tell, Some("SVG"), cx) + } + })) + .child(Button::new("lines-csv", "Copy CSV").variant(ButtonVariant::Ghost).on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(for_csv.csv())); + set(&tell, Some("CSV"), cx) + })), + ) + .children(told.map(|what| Caption::new(format!("Copied the chart as {what}.")))) +} + +pub fn areas_and_bars(cx: &mut App) -> impl IntoElement + use<> { + let quarters = ["Q1", "Q2", "Q3", "Q4"]; + let mut rng = noise(5); + let mut series = |name: &'static str, base: f64| { + Series::new( + name, + (0..4) + .map(|q| base + q as f64 * 6.0 + rng() * 10.0) + .collect::>(), + ) + }; + let (web, ios, android) = ( + series("Web", 30.0), + series("iOS", 22.0), + series("Android", 16.0), + ); + section( + "AreaChart / StackedArea / BarChart", + "Areas stack each platform on the ones before it. Bars stand grouped, stacked, or run sideways.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child(div().w(px(360.)).child(AreaChart::new("areas", quarters).series(web.clone()).series(ios.clone()).series(android.clone()).stacked().smooth())) + .child(probe("bars", div().w(px(360.)).child(BarChart::new("bars", quarters).series(web.clone()).series(ios.clone()).series(android.clone())))), + ) + .child( + div() + .flex() + .gap_6() + .child(div().w(px(360.)).child(BarChart::new("stacked", quarters).series(web.clone()).series(ios.clone()).series(android.clone()).stacked())) + .child(div().w(px(360.)).child(BarChart::new("sideways", ["Search", "Direct", "Social", "Email"]).series(Series::new("Visits", [412.0, 288.0, 164.0, 91.0])).horizontal())), + ) +} + +pub fn scatter(cx: &mut App) -> impl IntoElement + use<> { + let mut rng = noise(9); + let cloud = |name: &'static str, cx0: f64, cy0: f64, rng: &mut dyn FnMut() -> f64| { + Points::new( + name, + (0..28) + .map(|_| (cx0 + (rng() - 0.5) * 40.0, cy0 + (rng() - 0.5) * 30.0)) + .collect::>(), + ) + }; + let (a, b) = ( + cloud("Trial", 40.0, 60.0, &mut rng), + cloud("Paid", 75.0, 35.0, &mut rng), + ); + let cities = Points::new( + "Cities", + [ + (12.0, 70.0), + (35.0, 52.0), + (58.0, 81.0), + (80.0, 44.0), + (94.0, 66.0), + ], + ) + .sizes([3.2, 8.9, 1.4, 12.5, 5.6]); + section( + "ScatterChart / BubbleChart", + "Points placed by two values; sizes turn them into bubbles whose areas compare.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child( + div().w(px(360.)).child( + ScatterChart::new("scatter") + .points(a) + .points(b) + .axes("Sessions", "Retention"), + ), + ) + .child(probe( + "bubbles", + div().w(px(360.)).child( + ScatterChart::new("bubbles") + .points(cities) + .axes("Price", "Rating"), + ), + )), + ) +} + +pub fn realtime(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let stream = keep( + "stream", + || { + let mut rng = noise(17); + ( + (0..40).map(|_| 50.0 + rng() * 20.0).collect::>(), + 40u64, + ) + }, + window, + cx, + ); + let ticking = keep("stream-tick", || false, window, cx); + if !*ticking.read(cx) && !cx.theme().reduced_motion { + set(&ticking, true, cx); + let stream = stream.clone(); + cx.spawn(async move |cx| { + let mut rng = noise(29); + loop { + cx.background_executor() + .timer(Duration::from_millis(900)) + .await; + let pushed = cx.update(|cx| { + stream.update(cx, |(values, pushed), cx| { + let last = values.last().copied().unwrap_or(60.0); + values.push((last + (rng() - 0.5) * 10.0).clamp(20.0, 100.0)); + values.remove(0); + *pushed += 1; + cx.notify(); + }) + }); + if pushed.is_err() { + return; + } + } + }) + .detach(); + } + let (values, pushed) = stream.read(cx).clone(); + let latest = values.last().copied().unwrap_or_default(); + section( + "RealtimeChart", + "A new reading every second; the line slides left as each arrives.", + cx, + ) + .child( + div() + .w(px(720.)) + .child(RealtimeChart::new("stream", values, pushed)), + ) + .child(div().w(px(200.)).child(ChartTooltip::new("Latest").row( + Some(cx.theme().colors.chart[0]), + "Requests per second", + SharedString::from(format!("{latest:.1}")), + ))) +} diff --git a/examples/gallery/pages/charts/flows.rs b/examples/gallery/pages/charts/flows.rs new file mode 100644 index 0000000..4e5ccae --- /dev/null +++ b/examples/gallery/pages/charts/flows.rs @@ -0,0 +1,170 @@ +use ely_gpui_component::charts::{ + Bullet, BulletChart, ChordDiagram, FunnelChart, GanttChart, NetworkGraph, ParallelCoordinates, + ProgressChart, SankeyChart, Task, +}; +use gpui::{App, IntoElement, ParentElement, Styled, div, px}; +use jiff::civil::date; + +use super::cartesian::noise; +use crate::{probe::probe, ui::section}; + +pub fn flows(cx: &mut App) -> impl IntoElement + use<> { + let budget = SankeyChart::new( + "budget", + [ + "Salary", + "Freelance", + "Income", + "Home", + "Food", + "Savings", + "Travel", + ], + ) + .link(0, 2, 5200.0) + .link(1, 2, 1400.0) + .link(2, 3, 2100.0) + .link(2, 4, 900.0) + .link(2, 5, 2600.0) + .link(2, 6, 1000.0) + .format(|value| format!("${value:.0}")); + let signups = [ + ("Visited", 48200.0), + ("Signed up", 9640.0), + ("Verified", 7420.0), + ("Invited a teammate", 3110.0), + ("Paid", 1260.0), + ] + .iter() + .fold(FunnelChart::new("signups"), |funnel, (name, value)| { + funnel.stage(*name, *value) + }); + section( + "SankeyChart / FunnelChart", + "A month's money from where it came to where it went, and how many of a month's visitors reached each step. Hover a node, a ribbon or a stage.", + cx, + ) + .child(probe("sankey", div().w(px(720.)).child(budget))) + .child(probe("funnel", div().w(px(720.)).child(signups))) +} + +pub fn schedule(cx: &mut App) -> impl IntoElement + use<> { + let plan = GanttChart::new("launch") + .task(Task::new("Research", date(2026, 9, 1), date(2026, 9, 11)).done(1.0)) + .task( + Task::new("Design", date(2026, 9, 9), date(2026, 9, 25)) + .done(0.8) + .after(0), + ) + .task( + Task::new("Build", date(2026, 9, 21), date(2026, 10, 16)) + .done(0.35) + .after(1), + ) + .task( + Task::new("Write the docs", date(2026, 10, 5), date(2026, 10, 20)) + .done(0.1) + .after(1), + ) + .task( + Task::new("Beta", date(2026, 10, 19), date(2026, 10, 30)) + .after(2) + .after(3), + ) + .task(Task::new("Launch", date(2026, 11, 2), date(2026, 11, 3)).after(4)) + .today(date(2026, 9, 25)); + section( + "GanttChart", + "A launch plan: each bar filled as far as it is done, arrows from the work it waits for, and a line at today. Hover a row.", + cx, + ) + .child(probe("gantt", div().w(px(720.)).child(plan))) +} + +pub fn relations(cx: &mut App) -> impl IntoElement + use<> { + let people = [ + "Ada", "Grace", "Alan", "Edsger", "Barbara", "Donald", "Ken", "Dennis", "Bjarne", "Guido", + "Linus", "Margaret", + ]; + let graph = people + .iter() + .enumerate() + .fold(NetworkGraph::new("people"), |graph, (ix, name)| { + graph.node(*name, ix % 3) + }); + let graph = [ + (0, 1), + (0, 2), + (1, 2), + (2, 3), + (3, 4), + (4, 1), + (5, 3), + (6, 7), + (7, 8), + (6, 8), + (8, 9), + (9, 10), + (10, 6), + (11, 0), + (11, 5), + (5, 6), + ] + .into_iter() + .fold(graph, |graph, (a, b)| graph.edge(a, b)); + let regions = ["Europe", "Americas", "Asia", "Africa", "Oceania"]; + let trade = vec![ + vec![0.0, 42.0, 31.0, 12.0, 5.0], + vec![38.0, 0.0, 44.0, 6.0, 7.0], + vec![35.0, 50.0, 0.0, 14.0, 18.0], + vec![15.0, 5.0, 11.0, 0.0, 2.0], + vec![4.0, 6.0, 20.0, 1.0, 0.0], + ]; + let mut rng = noise(43); + let axes = ["Price", "Weight", "Range", "Charge", "Seats"]; + let cars = ["Aria", "Bolt", "Crest", "Dune", "Echo", "Flux"] + .iter() + .fold(ParallelCoordinates::new("cars", axes), |chart, name| { + chart.record( + *name, + [ + 28.0 + rng() * 40.0, + 1.4 + rng() * 1.2, + 250.0 + rng() * 350.0, + 20.0 + rng() * 40.0, + (4.0 + rng() * 3.0).floor(), + ], + ) + }); + section( + "NetworkGraph / ForceGraph / ChordDiagram / ParallelCoordinates", + "People and who they work with, settling as forces pull and push: drag a node, hover one to light its ties. Trade between regions around a circle, and cars on five measures at once.", + cx, + ) + .child(probe("network", div().w(px(720.)).child(graph))) + .child( + div() + .flex() + .gap_6() + .child(probe("chord", div().w(px(360.)).child(ChordDiagram::new("trade", regions, trade)))) + .child(probe("parallel", div().w(px(420.)).child(cars))), + ) +} + +pub fn targets(cx: &mut App) -> impl IntoElement + use<> { + let bullets = BulletChart::new("quarter") + .bullet(Bullet::new("Revenue", 268.0, 250.0).bands([150.0, 225.0, 300.0])) + .bullet(Bullet::new("Profit", 22.0, 27.0).bands([20.0, 25.0, 30.0])) + .bullet(Bullet::new("New customers", 1680.0, 2000.0).bands([1000.0, 1500.0, 2500.0])); + let rings = ProgressChart::new("goals") + .goal("Move", 480.0, 600.0) + .goal("Exercise", 27.0, 30.0) + .goal("Stand", 9.0, 12.0); + section( + "Bullet Chart / ProgressChart", + "A quarter's measures against their targets over bands from poor to good, and a day's goals as rings that fill toward them.", + cx, + ) + .child(probe("bullets", div().w(px(720.)).child(bullets))) + .child(probe("rings", rings)) +} diff --git a/examples/gallery/pages/charts/mod.rs b/examples/gallery/pages/charts/mod.rs new file mode 100644 index 0000000..9d2e9f7 --- /dev/null +++ b/examples/gallery/pages/charts/mod.rs @@ -0,0 +1,83 @@ +mod cartesian; +mod flows; +mod spread; +mod wholes; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 15, + slug: "charts", + title: "Charts", + summary: "Lines, bars, parts of a whole, spreads, flows and schedules on quiet axes, with legends, tooltips, zoom and export.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::HoverAt("revenue", 400.0, 140.0), + Step::Wait(300), + Step::Shot("crosshair"), + Step::DownAt("revenue", 150.0, 140.0), + Step::DragTo("revenue", 520.0, 140.0), + Step::UpAt("revenue", 520.0, 140.0), + Step::Wait(300), + Step::HoverAt("revenue", 360.0, 120.0), + Step::Wait(300), + Step::Shot("zoomed"), + Step::DownAt("revenue", 100.0, 8.0), + Step::UpAt("revenue", 100.0, 8.0), + Step::Wait(300), + Step::Shot("hidden"), + Step::HoverAt("bars", 160.0, 150.0), + Step::Wait(300), + Step::Shot("band"), + Step::HoverAt("pie", 236.0, 95.0), + Step::Wait(300), + Step::Shot("slice"), + Step::HoverAt("donut", 109.0, 223.0), + Step::Wait(300), + Step::Shot("donut"), + Step::HoverAt("bubbles", 291.0, 232.0), + Step::Wait(300), + Step::Shot("bubble"), + Step::HoverAt("radar", 309.0, 112.0), + Step::Wait(300), + Step::Shot("spoke"), + Step::HoverAt("treemap", 60.0, 60.0), + Step::Wait(300), + Step::Shot("tile"), + Step::HoverAt("sankey", 490.0, 177.0), + Step::Wait(300), + Step::Shot("ribbon"), + Step::HoverAt("gantt", 400.0, 98.0), + Step::Wait(300), + Step::Shot("task"), + Step::HoverAt("chord", 246.0, 58.0), + Step::Wait(300), + Step::Shot("chord"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(cartesian::lines(window, cx)) + .child(cartesian::areas_and_bars(cx)) + .child(wholes::pies(cx)) + .child(cartesian::scatter(cx)) + .child(wholes::radar(cx)) + .child(wholes::heatmaps(cx)) + .child(wholes::parts(cx)) + .child(flows::flows(cx)) + .child(spread::waterfall(cx)) + .child(spread::distributions(cx)) + .child(flows::schedule(cx)) + .child(flows::relations(cx)) + .child(flows::targets(cx)) + .child(cartesian::realtime(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/charts/spread.rs b/examples/gallery/pages/charts/spread.rs new file mode 100644 index 0000000..9f92f22 --- /dev/null +++ b/examples/gallery/pages/charts/spread.rs @@ -0,0 +1,53 @@ +use ely_gpui_component::charts::{BoxPlot, Histogram, ViolinPlot, WaterfallChart}; +use gpui::{App, IntoElement, ParentElement, Styled, div, px}; + +use super::cartesian::noise; +use crate::ui::section; + +pub fn distributions(cx: &mut App) -> impl IntoElement + use<> { + let mut rng = noise(13); + let mut normal = + |mean: f64, spread: f64| -> f64 { mean + spread * ((rng() + rng() + rng() + rng()) - 2.0) }; + let times: Vec = (0..240).map(|_| normal(320.0, 120.0).max(40.0)).collect(); + let groups: Vec<(&str, Vec)> = [("Europe", 220.0), ("Americas", 310.0), ("Asia", 270.0)] + .into_iter() + .map(|(name, mean)| (name, (0..60).map(|_| normal(mean, 90.0)).collect())) + .collect(); + let boxes = groups + .iter() + .fold(BoxPlot::new("boxes"), |plot, (name, values)| { + plot.group(*name, values.clone()) + }); + let violins = groups + .iter() + .fold(ViolinPlot::new("violins"), |plot, (name, values)| { + plot.group(*name, values.clone()) + }); + section( + "Histogram / BoxPlot / ViolinPlot", + "Response times by count in equal bins, and by region as boxes and as violins that widen where values gather.", + cx, + ) + .child(div().w(px(720.)).child(Histogram::new("times", times).bins(12))) + .child(div().flex().gap_6().child(div().w(px(360.)).child(boxes)).child(div().w(px(360.)).child(violins))) +} + +pub fn waterfall(cx: &mut App) -> impl IntoElement + use<> { + section( + "WaterfallChart", + "How a year's revenue became its profit: each step floats from where the last left off, and the totals stand on zero.", + cx, + ) + .child( + div().w(px(720.)).child( + WaterfallChart::new("profit") + .step("Revenue", 420.0) + .step("Refunds", -36.0) + .total("Net sales") + .step("Cost of sales", -148.0) + .step("Operating", -121.0) + .step("Other income", 18.0) + .total("Profit"), + ), + ) +} diff --git a/examples/gallery/pages/charts/wholes.rs b/examples/gallery/pages/charts/wholes.rs new file mode 100644 index 0000000..4501317 --- /dev/null +++ b/examples/gallery/pages/charts/wholes.rs @@ -0,0 +1,142 @@ +use ely_gpui_component::charts::{ + CalendarHeatmap, HeatmapChart, PieChart, RadarChart, Series, Slice, Sunburst, Treemap, +}; +use gpui::{App, IntoElement, ParentElement, Styled, div, px}; +use jiff::civil::date; + +use super::cartesian::noise; +use crate::{probe::probe, ui::section}; + +pub fn pies(cx: &mut App) -> impl IntoElement + use<> { + let traffic = [ + ("Search", 412.0), + ("Direct", 288.0), + ("Social", 164.0), + ("Email", 91.0), + ]; + let pie = traffic + .iter() + .fold(PieChart::new("sources"), |pie, (name, value)| { + pie.slice(*name, *value) + }); + let donut = [("Design", 38.0), ("Engineering", 54.0), ("Research", 12.0)] + .iter() + .fold(PieChart::new("time"), |pie, (name, hours)| { + pie.slice(*name, *hours) + }) + .donut("104 h", "This week"); + section( + "PieChart / DonutChart", + "Parts of a whole, clockwise from twelve. Hover a slice: it lifts and reads its share; the donut reads it in the middle.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child(probe("pie", div().w(px(360.)).child(pie))) + .child(probe("donut", div().w(px(360.)).child(donut))), + ) +} + +pub fn radar(cx: &mut App) -> impl IntoElement + use<> { + let axes = ["Speed", "Quality", "Price", "Support", "Docs", "Reach"]; + section( + "RadarChart", + "Two products on six measures. Hover a spoke to read both; press a name to hide it.", + cx, + ) + .child(probe( + "radar", + div().w(px(480.)).child( + RadarChart::new("compare", axes) + .series(Series::new("Ely", [86.0, 92.0, 70.0, 81.0, 88.0, 64.0])) + .series(Series::new("Others", [72.0, 68.0, 84.0, 60.0, 55.0, 79.0])), + ), + )) +} + +pub fn parts(cx: &mut App) -> impl IntoElement + use<> { + let spend = [ + ("Payroll", 612.0), + ("Cloud", 184.0), + ("Office", 96.0), + ("Travel", 58.0), + ("Software", 72.0), + ("Events", 31.0), + ("Legal", 22.0), + ]; + let treemap = spend + .iter() + .fold(Treemap::new("spend"), |map, (name, value)| { + map.tile(*name, *value) + }) + .format(|value| format!("${value:.0}k")); + let teams = [ + Slice::new("Product", 0.0).children([ + Slice::new("Design", 14.0), + Slice::new("Research", 6.0), + Slice::new("Writing", 4.0), + ]), + Slice::new("Engineering", 0.0).children([ + Slice::new("Platform", 0.0) + .children([Slice::new("Runtime", 9.0), Slice::new("Build", 5.0)]), + Slice::new("Apps", 18.0), + ]), + Slice::new("Go to market", 0.0) + .children([Slice::new("Sales", 11.0), Slice::new("Support", 8.0)]), + ]; + section( + "Treemap / Sunburst", + "A year's spend as tiles whose areas compare, and a company as rings: teams inside groups inside the whole. Hover to read a part.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child(probe("treemap", div().w(px(420.)).child(treemap))) + .child(probe("sunburst", div().w(px(300.)).child(Sunburst::new("teams", teams)))), + ) +} + +pub fn heatmaps(cx: &mut App) -> impl IntoElement + use<> { + let hours = ["0", "3", "6", "9", "12", "15", "18", "21"]; + let mut rng = noise(31); + let busy = |day: usize, hour: usize, rng: &mut dyn FnMut() -> f64| { + let work = if day < 5 && (3..7).contains(&hour) { + 60.0 + } else { + 12.0 + }; + work + rng() * 30.0 + }; + let week = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + .iter() + .enumerate() + .fold(HeatmapChart::new("load", hours), |chart, (day, name)| { + chart.row( + *name, + (0..8) + .map(|hour| busy(day, hour, &mut rng)) + .collect::>(), + ) + }); + let mut rng = noise(37); + let commits: Vec = (0..365) + .map(|day| { + if day % 7 >= 5 { + (rng() * 3.0).floor() + } else { + (rng() * 12.0).floor() + } + }) + .collect(); + section( + "HeatmapChart / CalendarHeatmap", + "Load by weekday and hour, and a year of commits a day, Monday first. Hover a cell to read it.", + cx, + ) + .child(probe("load", div().w(px(560.)).child(week))) + .child(probe("year", div().w(px(720.)).child(CalendarHeatmap::new("commits", date(2026, 1, 1), commits)))) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index d170d59..1446b3d 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,4 +1,5 @@ mod buttons; +mod charts; mod data; mod feedback; mod forms; @@ -43,6 +44,7 @@ pub const ALL: &[Page] = &[ data::PAGE, lists::PAGE, tables::PAGE, + charts::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index 2d38cd2..7bf3a83 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -14,6 +14,8 @@ use crate::{ pub enum Step { Rest, Hover(&'static str), + /// Moves the pointer to an offset from the probe's top-left corner. + HoverAt(&'static str, f32, f32), Down(&'static str), Up(&'static str), Click(&'static str), @@ -87,6 +89,10 @@ pub async fn play( let at = target(window, key, cx).await?; send(window, Mouse::Move, at, cx)?; } + Step::HoverAt(key, x, y) => { + last = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); + send(window, Mouse::Move, last, cx)?; + } Step::Down(key) => { let at = target(window, key, cx).await?; send(window, Mouse::Move, at, cx)?; diff --git a/scripts/check.sh b/scripts/check.sh index 56c8a82..2e01514 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -12,7 +12,7 @@ for f in $files AGENTS.md README.md TASKS.md tasks/*.md; do n=$(wc -l < "$f") [ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; } done -components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*' ! -path '*/tests/*' ! -name 'tests.rs') +components=$(find src -name '*.rs' ! -path 'src/theme/*' ! -path 'src/motion/*' ! -path '*/tests/*' ! -name 'tests.rs') raw=$(awk '/#\[cfg\(test\)\]/{nextfile} /(^|[^.[:alnum:]_])px\(/{print FILENAME":"FNR": "$0}' $components) [ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; } exit $fail diff --git a/src/charts/axes.rs b/src/charts/axes.rs new file mode 100644 index 0000000..0c61347 --- /dev/null +++ b/src/charts/axes.rs @@ -0,0 +1,175 @@ +use gpui::{ + App, Div, IntoElement, ParentElement, Pixels, Rems, SharedString, Styled, Window, div, + prelude::*, +}; + +use super::{ + geometry::{Geometry, Rect}, + plot::Plot, + scale::Linear, +}; +use crate::{ + theme::{ActiveTheme, TextSize}, + typography::tabular, +}; + +/// A label centered under `x`, below the frame. +pub(crate) fn below(x: f32, frame: Rect, label: Div) -> Div { + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(frame.y + frame.h)) + .w_0() + .flex() + .justify_center() + .pt_1p5() + .child(label) +} + +/// A label centered on `y`, set right in the gutter. +pub(crate) fn beside(y: f32, gutter: Rems, label: Div) -> Div { + div() + .absolute() + .left_0() + .top(Pixels::from(y)) + .w(gutter) + .h_0() + .flex() + .items_center() + .justify_end() + .pr_2() + .child(label) +} + +/// A tooltip beside a place, centered on it, to its right or, flipped, to its left. +pub(crate) fn anchored((x, y): (f32, f32), flip: bool, card: impl IntoElement) -> Div { + let card = div() + .flex_none() + .when(flip, |card| card.mr_3()) + .when(!flip, |card| card.ml_3()) + .child(card); + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(y)) + .w_0() + .h_0() + .flex() + .items_center() + .when(flip, |anchor| anchor.justify_end()) + .child(card) +} + +/// The words around a plot, and where its rules and notes run. +pub(crate) struct Marks { + pub labels: Vec
, + pub rules: Vec, + pub notes: Vec, +} + +/// Value ticks; category names, thinned to fit, or a scatter's x ticks; and the labels of rules and notes. +pub(crate) fn marks( + plot: &Plot, + geometry: &Geometry, + (rect, span): (Rect, (usize, usize)), + window: &Window, + cx: &App, +) -> Marks { + let theme = cx.theme(); + let (colors, sizes, rem) = (&theme.colors, theme.chart(), window.rem_size()); + let text = |content: SharedString| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(content) + }; + let mut labels: Vec
= geometry + .ticks + .iter() + .map(|(at, value)| { + let label = tabular(text((plot.format)(*value).into())); + if plot.horizontal { + below(*at, rect, label) + } else { + beside(*at, sizes.gutter, label) + } + }) + .collect(); + let along = if plot.horizontal { rect.h } else { rect.w }; + let room = f32::from(sizes.label.to_pixels(rem)); + let every = if plot.horizontal { + 1 + } else { + ((geometry.labels.len() as f32 * room / along.max(1.0)).ceil() as usize).max(1) + }; + labels.extend( + geometry + .labels + .iter() + .enumerate() + .filter(|(ix, _)| ix % every == 0) + .map(|(_, (at, label))| { + if plot.horizontal { + beside(*at, sizes.gutter, text(label.clone())) + } else { + below(*at, rect, text(label.clone())) + } + }), + ); + let place_value = |value: f64| { + let (first, last) = (geometry.ticks.first()?, geometry.ticks.last()?); + Some(Linear::new((first.1, last.1), (first.0, last.0)).at(value)) + }; + let rules: Vec<(f32, SharedString)> = plot + .rules + .iter() + .filter_map(|(value, label)| Some((place_value(*value)?, label.clone()))) + .collect(); + labels.extend(rules.iter().map(|(at, label)| { + if plot.horizontal { + div() + .absolute() + .left(Pixels::from(*at)) + .top(Pixels::from(rect.y)) + .pl_1p5() + .child(text(label.clone())) + } else { + div() + .absolute() + .left(Pixels::from(rect.x)) + .w(Pixels::from(rect.w)) + .top(Pixels::from(*at)) + .h_0() + .flex() + .justify_end() + .items_end() + .child(text(label.clone()).pb_0p5()) + } + })); + let notes: Vec<(f32, SharedString)> = plot + .notes + .iter() + .filter(|(ix, _)| !plot.horizontal && (span.0..=span.1).contains(ix)) + .filter_map(|(ix, label)| { + geometry + .labels + .get(ix - span.0) + .map(|(at, _)| (*at, label.clone())) + }) + .collect(); + labels.extend(notes.iter().map(|(at, label)| { + div() + .absolute() + .left(Pixels::from(*at)) + .top(Pixels::from(rect.y)) + .pl_1p5() + .child(text(label.clone())) + })); + Marks { + labels, + rules: rules.iter().map(|(at, _)| *at).collect(), + notes: notes.iter().map(|(at, _)| *at).collect(), + } +} diff --git a/src/charts/calendar.rs b/src/charts/calendar.rs new file mode 100644 index 0000000..e378a43 --- /dev/null +++ b/src/charts/calendar.rs @@ -0,0 +1,194 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, Entity, Hsla, IntoElement, ParentElement, Pixels, Refineable, RenderOnce, + StyleRefinement, Styled, Window, canvas, div, fill, +}; +use jiff::{Span, civil::Date}; + +use super::{ + axes::{anchored, beside}, + geometry::Rect, + layout::{calendar, level}, + paint::{measure, place, tint}, + parts::ChartTooltip, + plot::Format, + scale::compact, + tiles::{Tiles, key, tracked}, +}; +use crate::theme::{ActiveTheme, Radius, TextSize}; + +/// A year of daily counts as a grid of weeks, Monday first, each day as strong as its count on a scale of five. Hover reads a day. +#[derive(IntoElement)] +pub struct CalendarHeatmap { + base: Div, + id: ElementId, + start: Date, + counts: Vec, + format: Format, +} + +impl CalendarHeatmap { + /// A count for each day from `start` on. + pub fn new( + id: impl Into, + start: Date, + counts: impl IntoIterator, + ) -> Self { + let counts: Vec = counts.into_iter().collect(); + assert!( + counts + .iter() + .all(|count| count.is_finite() && *count >= 0.0), + "a calendar needs counts of zero or more" + ); + Self { + base: div(), + id: id.into(), + start, + counts, + format: Rc::new(compact), + } + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for CalendarHeatmap { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for CalendarHeatmap { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "tiles"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let first = self.start.weekday().to_monday_zero_offset() as usize; + let (places, weeks) = calendar(first, self.counts.len()); + let (gutter, top) = (pixels(sizes.gutter), pixels(sizes.foot) * 0.75); + let side = ((f32::from(bounds.size.width) - gutter) / weeks.max(1) as f32) + .min((f32::from(bounds.size.height) - top) / 7.0) + .max(0.0); + let day = |ix: usize| { + self.start + .checked_add(Span::new().days(ix as i64)) + .expect("a day within the calendar") + }; + let square = |(week, weekday): (usize, usize)| Rect { + x: gutter + side * week as f32, + y: top + side * weekday as f32, + w: side, + h: side, + }; + let highest = self.counts.iter().copied().fold(0.0, f64::max); + let ink = tint(&colors, 0); + let shades = [ + colors.border.opacity(0.5), + ink.opacity(0.3), + ink.opacity(0.52), + ink.opacity(0.76), + ink, + ]; + let text = |content: String| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(content) + }; + let months = (0..self.counts.len()) + .filter(|ix| *ix == 0 || day(*ix).day() == 1) + .map(|ix| { + let at = square(places[ix]); + div() + .absolute() + .left(Pixels::from(at.x)) + .top_0() + .child(text(day(ix).strftime("%b").to_string())) + }); + let weekdays = [0usize, 2, 4].map(|weekday| { + let date = (0..7) + .map(day) + .find(|date| date.weekday().to_monday_zero_offset() as usize == weekday) + .expect("seven days hold every weekday"); + beside( + top + side * (weekday as f32 + 0.5), + sizes.gutter, + text(date.strftime("%a").to_string()), + ) + }); + let format = self.format.clone(); + let tooltip = hover.map(|ix| { + let at = square(places[ix]); + let card = ChartTooltip::new(day(ix).strftime("%a %b %-d, %Y").to_string()).row( + Some(shades[level(self.counts[ix], highest)]), + "Count", + format(self.counts[ix]), + ); + anchored( + (at.x + at.w, at.y + at.h / 2.0), + at.x > f32::from(bounds.size.width) * 0.6, + card, + ) + }); + let days: Vec<(Rect, Hsla)> = self + .counts + .iter() + .enumerate() + .map(|(ix, count)| (square(places[ix]), shades[level(*count, highest)])) + .collect(); + let (gap, corner, edge, hairline) = ( + pixels(sizes.stroke) / 2.0, + Pixels::from(pixels(theme.radius(Radius::Sm)).min(side * 0.2)), + colors.fg, + sizes.hairline, + ); + let hit = days.iter().map(|(rect, _)| *rect).collect::>(); + let legend = key(shades, ("Less", "More"), cx); + let mut root = div().w_full().h(sizes.height * 0.5); + root.style().refine(self.base.style()); + let chart = tracked( + div().relative().flex_1().min_h_0(), + (self.id.clone(), "calendar").into(), + &tiles, + move |at| hit.iter().position(|rect| rect.contains(at)), + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for (ix, (square, color)) in days.iter().enumerate() { + let inner = Rect { + x: square.x + gap, + y: square.y + gap, + w: (square.w - gap * 2.0).max(0.0), + h: (square.h - gap * 2.0).max(0.0), + }; + let quad = fill(place(bounds.origin, inner), *color).corner_radii(corner); + window.paint_quad(if hover == Some(ix) { + quad.border_widths(hairline).border_color(edge) + } else { + quad + }); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(months) + .children(weekdays) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)); + root.flex().flex_col().gap_2().child(chart).child(legend) + } +} diff --git a/src/charts/cartesian.rs b/src/charts/cartesian.rs new file mode 100644 index 0000000..c536f39 --- /dev/null +++ b/src/charts/cartesian.rs @@ -0,0 +1,412 @@ +use std::{collections::HashSet, rc::Rc}; + +use gpui::{ + App, Div, ElementId, IntoElement, Refineable, RenderOnce, SharedString, StyleRefinement, + Styled, Window, div, +}; + +use super::{ + export::{Sheet, svg}, + geometry::{Categories, Ink, Mark, categories, scatter}, + plot::{Format, Layout, Pick, Plot, Scene, Tips, plot}, + scale::compact, + series::{Points, Series, csv, points_csv}, +}; +use crate::theme::ActiveTheme; + +/// A category chart's settings, before its plot takes them. +pub(crate) struct Chart { + id: ElementId, + labels: Vec, + pub series: Vec, + mark: Mark, + /// The share of each band bars leave empty. + pub gap: f32, + stacked: bool, + horizontal: bool, + smooth: bool, + rules: Vec<(f64, SharedString)>, + notes: Vec<(usize, SharedString)>, + zoom: bool, + format: Format, +} + +impl Chart { + pub(crate) fn new(id: ElementId, labels: Vec, mark: Mark) -> Self { + Self { + id, + labels, + series: Vec::new(), + mark, + gap: 0.28, + stacked: false, + horizontal: false, + smooth: false, + rules: Vec::new(), + notes: Vec::new(), + zoom: false, + format: Rc::new(compact), + } + } + + fn names(&self) -> Vec { + self.series + .iter() + .map(|series| series.name.clone()) + .collect() + } + + fn values(&self) -> Vec> { + self.series + .iter() + .map(|series| series.values.clone()) + .collect() + } + + fn layout(&self) -> Layout { + let (labels, names) = (self.labels.clone(), self.names()); + let (mark, stacked, horizontal, gap) = (self.mark, self.stacked, self.horizontal, self.gap); + Rc::new(move |scene: &Scene| { + let chart = Categories { + labels: &labels, + names: &names, + values: scene.values, + mark, + stacked, + horizontal, + gap, + hidden: scene.hidden, + span: scene.span, + }; + categories(&chart, scene.frame) + }) + } + + fn tips(&self) -> Tips { + let (labels, names, format) = (self.labels.clone(), self.names(), self.format.clone()); + Rc::new(move |ix, scene: &Scene| { + let rows = names + .iter() + .enumerate() + .filter(|(_, name)| !scene.hidden.contains(*name)) + .map(|(color, name)| { + ( + Some(Ink::Series(color)), + name.clone(), + format(scene.values[color][ix]), + ) + }) + .collect(); + (labels[ix].clone(), rows) + }) + } + + pub(crate) fn plot(self) -> Plot { + let pick = if self.mark == Mark::Bar { + Pick::Band + } else { + Pick::Point + }; + let mut plot = Plot::new(self.id.clone(), pick, self.layout(), self.tips()); + (plot.names, plot.values) = (self.names(), self.values()); + (plot.labels, plot.horizontal, plot.smooth) = (self.labels, self.horizontal, self.smooth); + (plot.rules, plot.notes, plot.zoom, plot.format) = + (self.rules, self.notes, self.zoom, self.format); + plot + } + + fn svg(&self, width: f32, height: f32, cx: &App) -> String { + let sheet = Sheet::new((width, height), self.horizontal, self.smooth, cx); + let (values, hidden) = (self.values(), HashSet::new()); + let span = (0, self.labels.len().saturating_sub(1)); + let scene = Scene { + frame: sheet.frame, + values: &values, + hidden: &hidden, + span, + }; + svg(&(self.layout())(&scene), &sheet, &*self.format) + } +} + +/// Renders a plot at the theme's chart height unless the owner sizes it. +pub(crate) fn sized(base: &mut Div, plotted: Plot, window: &mut Window, cx: &mut App) -> Div { + let mut frame = div().w_full().h(cx.theme().chart().height); + frame.style().refine(base.style()); + plot(plotted, frame, window, cx) +} + +macro_rules! category_chart { + ($name:ident, $mark:expr, $doc:literal) => { + #[doc = $doc] + #[derive(IntoElement)] + pub struct $name { + base: Div, + chart: Chart, + } + + impl $name { + pub fn new( + id: impl Into, + labels: impl IntoIterator>, + ) -> Self { + let labels = labels.into_iter().map(Into::into).collect(); + Self { + base: div(), + chart: Chart::new(id.into(), labels, $mark), + } + } + + /// A run of values, one for each label. + pub fn series(mut self, series: Series) -> Self { + assert_eq!( + series.values.len(), + self.chart.labels.len(), + "series {} needs a value per label", + series.name + ); + self.chart.series.push(series); + self + } + + /// A dashed level across the chart with its label, as a target or a limit. + pub fn rule(mut self, value: f64, label: impl Into) -> Self { + assert!(value.is_finite(), "a rule needs a finite value"); + self.chart.rules.push((value, label.into())); + self + } + + /// A mark at category `ix` with its label, as a release or an incident. + pub fn note(mut self, ix: usize, label: impl Into) -> Self { + assert!( + ix < self.chart.labels.len(), + "note {ix} is past the last label" + ); + self.chart.notes.push((ix, label.into())); + self + } + + /// A drag across the chart zooms to its span; Show all, or a double press, shows every category again. + pub fn zoom(mut self) -> Self { + self.chart.zoom = true; + self + } + + /// How values read on the axis and in the tooltip. + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.chart.format = Rc::new(format); + self + } + + /// The data as comma-separated text: a header of labels, then a row per series. + pub fn csv(&self) -> String { + csv(&self.chart.labels, &self.chart.series) + } + + /// The chart as an SVG drawing of this size, in the theme's colors. + pub fn svg(&self, width: f32, height: f32, cx: &App) -> String { + self.chart.svg(width, height, cx) + } + } + + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } + } + + impl RenderOnce for $name { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + sized(&mut self.base, self.chart.plot(), window, cx) + } + } + }; +} + +category_chart!( + LineChart, + Mark::Line, + "Values over categories as lines. Hover for a crosshair and every series' value; a press on a legend name hides it." +); +category_chart!( + AreaChart, + Mark::Area, + "Values over categories as lines with the area beneath lightly filled; `stacked` piles each series on the ones before it." +); +category_chart!( + BarChart, + Mark::Bar, + "Values over categories as bars: grouped side by side, `stacked`, or `horizontal`." +); + +impl LineChart { + /// Curves through the values that never overshoot them. + pub fn smooth(mut self) -> Self { + self.chart.smooth = true; + self + } +} + +impl AreaChart { + pub fn smooth(mut self) -> Self { + self.chart.smooth = true; + self + } + + pub fn stacked(mut self) -> Self { + self.chart.stacked = true; + self + } +} + +impl BarChart { + pub fn stacked(mut self) -> Self { + self.chart.stacked = true; + self + } + + /// Bars run sideways, categories down the side. + pub fn horizontal(mut self) -> Self { + self.chart.horizontal = true; + self.chart.gap = 0.45; + self + } +} + +/// Points placed by two values; with sizes, bubbles whose areas compare. Hover a point to read it. +#[derive(IntoElement)] +pub struct ScatterChart { + base: Div, + id: ElementId, + clouds: Vec, + axes: (SharedString, SharedString), + format: Format, +} + +impl ScatterChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + clouds: Vec::new(), + axes: ("x".into(), "y".into()), + format: Rc::new(compact), + } + } + + pub fn points(mut self, points: Points) -> Self { + self.clouds.push(points); + self + } + + /// What the two values measure, as the tooltip names them. + pub fn axes(mut self, x: impl Into, y: impl Into) -> Self { + self.axes = (x.into(), y.into()); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } + + /// The points as comma-separated text: a row per point with its series, x, y and size. + pub fn csv(&self) -> String { + points_csv(&self.clouds) + } + + pub fn svg(&self, width: f32, height: f32, cx: &App) -> String { + let sheet = Sheet::new((width, height), false, false, cx); + let (values, hidden) = (Vec::new(), HashSet::new()); + let scene = Scene { + frame: sheet.frame, + values: &values, + hidden: &hidden, + span: (0, 0), + }; + svg( + &(self.layout(radii(cx.theme().base_rem(), cx)))(&scene), + &sheet, + &*self.format, + ) + } + + fn layout(&self, radii: (f32, f32)) -> Layout { + let (clouds, format) = (Rc::new(self.clouds.clone()), self.format.clone()); + Rc::new(move |scene: &Scene| scatter(&clouds, scene.hidden, scene.frame, radii, &*format)) + } + + fn tips(&self) -> Tips { + let (clouds, format, (x_name, y_name)) = ( + Rc::new(self.clouds.clone()), + self.format.clone(), + self.axes.clone(), + ); + Rc::new(move |key, _| { + let (cloud, ix) = locate(&clouds, key); + let ((x, y), points) = (clouds[cloud].points[ix], &clouds[cloud]); + let mut rows = vec![ + (None, x_name.clone(), format(x)), + (None, y_name.clone(), format(y)), + ]; + rows.extend( + points + .sizes + .as_ref() + .map(|sizes| (None, "Size".into(), format(sizes[ix]))), + ); + (points.name.clone(), rows) + }) + } +} + +/// The widest bubble's radius and a plain dot's, at a rem size. +fn radii(rem: gpui::Pixels, cx: &App) -> (f32, f32) { + let theme = cx.theme(); + ( + f32::from(theme.chart().bubble.to_pixels(rem)), + f32::from(theme.status_dot().to_pixels(rem)) / 2.0, + ) +} + +/// Which cloud and which of its points a flat index names. +fn locate(clouds: &[Points], mut key: usize) -> (usize, usize) { + for (cloud, points) in clouds.iter().enumerate() { + if key < points.points.len() { + return (cloud, key); + } + key -= points.points.len(); + } + panic!("point {key} is past the last cloud"); +} + +impl Styled for ScatterChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for ScatterChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let layout = self.layout(radii(window.rem_size(), cx)); + let mut plotted = Plot::new(self.id.clone(), Pick::Dot, layout, self.tips()); + plotted.names = self.clouds.iter().map(|cloud| cloud.name.clone()).collect(); + plotted.format = self.format.clone(); + sized(&mut self.base, plotted, window, cx) + } +} + +#[cfg(test)] +mod tests { + use super::{Points, locate}; + + #[test] + fn a_flat_index_finds_its_cloud() { + let clouds = [ + Points::new("a", [(0.0, 0.0), (1.0, 1.0)]), + Points::new("b", [(2.0, 2.0)]), + ]; + assert_eq!(locate(&clouds, 1), (0, 1)); + assert_eq!(locate(&clouds, 2), (1, 0)); + } +} diff --git a/src/charts/distribution.rs b/src/charts/distribution.rs new file mode 100644 index 0000000..df5d02e --- /dev/null +++ b/src/charts/distribution.rs @@ -0,0 +1,406 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, +}; + +use super::{ + cartesian::{Chart, sized}, + geometry::{Dot, Geometry, Ink, Mark, Rect}, + plot::{Layout, Pick, Plot, Scene, Tips}, + scale::{Band, Linear, compact, nice}, + series::Series, + stats::{Step, bins, density, five, steps}, +}; + +/// Value ticks up the side and a band per category along the bottom of `frame`. +fn axes( + labels: &[SharedString], + (low, high): (f64, f64), + frame: Rect, + padding: f32, +) -> (Geometry, Linear, Band) { + let ((low, high), ticks) = nice(low, high, 5); + let scale = Linear::new((low, high), (frame.y + frame.h, frame.y)); + let band = Band { + count: labels.len().max(1), + range: (frame.x, frame.x + frame.w), + padding, + }; + let geometry = Geometry { + ticks: ticks.iter().map(|tick| (scale.at(*tick), *tick)).collect(), + labels: labels + .iter() + .enumerate() + .map(|(ix, label)| (band.middle(ix), label.clone())) + .collect(), + ..Geometry::default() + }; + (geometry, scale, band) +} + +/// A plot of categories whose marks its own layout draws; the pointer picks a band. +fn banded(id: ElementId, labels: Vec, layout: Layout, tips: Tips) -> Plot { + let mut plot = Plot::new(id, Pick::Band, layout, tips); + plot.labels = labels; + plot +} + +macro_rules! sized_chart { + ($name:ident) => { + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } + } + + impl RenderOnce for $name { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let mut base = std::mem::replace(&mut self.base, div()); + sized(&mut base, self.plot(), window, cx) + } + } + }; +} + +/// How values spread: bars counting them in equal bins across their rounded range. +#[derive(IntoElement)] +pub struct Histogram { + base: Div, + id: ElementId, + values: Vec, + bins: usize, +} + +impl Histogram { + pub fn new(id: impl Into, values: impl IntoIterator) -> Self { + let values: Vec = values.into_iter().collect(); + assert!( + values.iter().all(|value| value.is_finite()), + "a histogram needs finite values" + ); + Self { + base: div(), + id: id.into(), + values, + bins: 10, + } + } + + pub fn bins(mut self, bins: usize) -> Self { + assert!(bins > 0, "a histogram needs a bin"); + self.bins = bins; + self + } + + fn plot(self) -> Plot { + let bins = bins(&self.values, self.bins); + let labels = bins + .iter() + .map(|(start, end, _)| format!("{}–{}", compact(*start), compact(*end)).into()) + .collect(); + let mut chart = Chart::new(self.id, labels, Mark::Bar); + chart.gap = 0.06; + chart + .series + .push(Series::new("Count", bins.iter().map(|bin| bin.2 as f64))); + chart.plot() + } +} + +sized_chart!(Histogram); + +/// A running total from a start through its changes: rises and falls float from where the last left off, and totals stand on zero. +#[derive(IntoElement)] +pub struct WaterfallChart { + base: Div, + id: ElementId, + steps: Vec<(SharedString, f64, bool)>, +} + +impl WaterfallChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + steps: Vec::new(), + } + } + + /// A change, up or down, from the running total. + pub fn step(mut self, label: impl Into, change: f64) -> Self { + assert!(change.is_finite(), "a step needs a finite change"); + self.steps.push((label.into(), change, false)); + self + } + + /// A bar showing the running total so far. + pub fn total(mut self, label: impl Into) -> Self { + self.steps.push((label.into(), 0.0, true)); + self + } + + fn plot(self) -> Plot { + let labels: Vec = self + .steps + .iter() + .map(|(label, _, _)| label.clone()) + .collect(); + let bars = Rc::new(steps( + &self + .steps + .iter() + .map(|(_, change, total)| (*change, *total)) + .collect::>(), + )); + let ink = |step: Step| match step { + Step::Up => Ink::Rise, + Step::Down => Ink::Fall, + Step::Total => Ink::Series(0), + }; + let (drawn, names) = (bars.clone(), labels.clone()); + let layout: Layout = Rc::new(move |scene: &Scene| { + let (low, high) = drawn + .iter() + .fold((0.0f64, 0.0f64), |(low, high), (bottom, top, _)| { + (low.min(*bottom), high.max(*top)) + }); + let (mut geometry, scale, band) = axes(&names, (low, high), scene.frame, 0.3); + for (ix, (bottom, top, step)) in drawn.iter().enumerate() { + let (start, width) = band.slot(ix); + let (y0, y1) = (scale.at(*top), scale.at(*bottom)); + geometry.bars.push(( + ink(*step), + Rect { + x: start, + y: y0, + w: width, + h: y1 - y0, + }, + )); + if ix + 1 < drawn.len() { + let level = scale.at(if *step == Step::Down { *bottom } else { *top }); + geometry.strokes.push(( + Ink::Rule, + (start + width, level), + (band.slot(ix + 1).0, level), + )); + } + } + geometry + }); + let changes = self.steps; + let tips: Tips = Rc::new(move |ix, _| { + let ((label, change, total), (_, top, step)) = (&changes[ix], bars[ix]); + let (name, value) = if *total { + ("Total", compact(top)) + } else { + ( + "Change", + format!( + "{}{}", + if *change >= 0.0 { "+" } else { "" }, + compact(*change) + ), + ) + }; + (label.clone(), vec![(Some(ink(step)), name.into(), value)]) + }); + banded(self.id, labels, layout, tips) + } +} + +sized_chart!(WaterfallChart); + +/// Spreads side by side: a box from the first quartile to the third with the median across it, whiskers to the farthest values within reach, and dots past them. +#[derive(IntoElement)] +pub struct BoxPlot { + base: Div, + id: ElementId, + groups: Vec<(SharedString, Vec)>, +} + +/// Spreads side by side as mirrored shapes, each as wide as its values gather, the median marked. +#[derive(IntoElement)] +pub struct ViolinPlot { + base: Div, + id: ElementId, + groups: Vec<(SharedString, Vec)>, +} + +macro_rules! grouped { + ($name:ident) => { + impl $name { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + groups: Vec::new(), + } + } + + /// A named group of values. + pub fn group( + mut self, + label: impl Into, + values: impl IntoIterator, + ) -> Self { + let values: Vec = values.into_iter().collect(); + assert!( + values.len() >= 2 && values.iter().all(|value| value.is_finite()), + "a group needs two finite values" + ); + self.groups.push((label.into(), values)); + self + } + } + }; +} + +grouped!(BoxPlot); +grouped!(ViolinPlot); + +/// Where every group's values run, low to high. +fn reach(groups: &[(SharedString, Vec)]) -> (f64, f64) { + groups + .iter() + .flat_map(|(_, values)| values.iter()) + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }) +} + +/// A group's five numbers as tooltip rows. +fn spread_tips(groups: Rc)>>) -> Tips { + Rc::new(move |ix, _| { + let (label, values) = &groups[ix]; + let spread = five(values); + let rows = [ + ("Highest", spread.high), + ("Third quartile", spread.q3), + ("Median", spread.median), + ("First quartile", spread.q1), + ("Lowest", spread.low), + ] + .into_iter() + .map(|(name, value)| (None, name.into(), compact(value))) + .collect(); + (label.clone(), rows) + }) +} + +/// A rectangle as the outline of a shape. +fn outline(rect: Rect) -> Vec<(f32, f32)> { + vec![ + (rect.x, rect.y), + (rect.x + rect.w, rect.y), + (rect.x + rect.w, rect.y + rect.h), + (rect.x, rect.y + rect.h), + ] +} + +impl BoxPlot { + fn plot(self) -> Plot { + let labels: Vec = + self.groups.iter().map(|(label, _)| label.clone()).collect(); + let groups = Rc::new(self.groups); + let (drawn, names) = (groups.clone(), labels.clone()); + let layout: Layout = Rc::new(move |scene: &Scene| { + let (mut geometry, scale, band) = axes(&names, reach(&drawn), scene.frame, 0.55); + for (ix, (_, values)) in drawn.iter().enumerate() { + let (spread, (start, width)) = (five(values), band.slot(ix)); + let middle = start + width / 2.0; + let (top, bottom) = (scale.at(spread.q3), scale.at(spread.q1)); + geometry.shapes.push(( + Ink::Series(0), + outline(Rect { + x: start, + y: top, + w: width, + h: bottom - top, + }), + )); + geometry.strokes.push(( + Ink::Strong, + (start, scale.at(spread.median)), + (start + width, scale.at(spread.median)), + )); + for (from, to) in [(spread.q3, spread.high), (spread.q1, spread.low)] { + geometry.strokes.push(( + Ink::Rule, + (middle, scale.at(from)), + (middle, scale.at(to)), + )); + geometry.strokes.push(( + Ink::Rule, + (middle - width / 4.0, scale.at(to)), + (middle + width / 4.0, scale.at(to)), + )); + } + for outlier in spread.outliers { + geometry.dots.push(Dot { + ink: Ink::Series(0), + center: (middle, scale.at(outlier)), + radius: width / 16.0, + key: ix, + }); + } + } + geometry + }); + banded(self.id, labels, layout, spread_tips(groups)) + } +} + +impl ViolinPlot { + fn plot(self) -> Plot { + let labels: Vec = + self.groups.iter().map(|(label, _)| label.clone()).collect(); + let groups = Rc::new(self.groups); + let (drawn, names) = (groups.clone(), labels.clone()); + let layout: Layout = Rc::new(move |scene: &Scene| { + let shapes: Vec> = drawn + .iter() + .map(|(_, values)| density(values, 40)) + .collect(); + let (low, high) = shapes + .iter() + .flatten() + .fold((f64::MAX, f64::MIN), |(low, high), (at, _)| { + (low.min(*at), high.max(*at)) + }); + let widest = shapes + .iter() + .flatten() + .fold(0.0f64, |widest, (_, value)| widest.max(*value)); + let (mut geometry, scale, band) = axes(&names, (low, high), scene.frame, 0.2); + for (ix, shape) in shapes.iter().enumerate() { + let (start, width) = band.slot(ix); + let middle = start + width / 2.0; + let half = |value: f64| (width / 2.0) * (value / widest) as f32; + let right = shape + .iter() + .map(|(at, value)| (middle + half(*value), scale.at(*at))); + let left = shape + .iter() + .rev() + .map(|(at, value)| (middle - half(*value), scale.at(*at))); + geometry + .shapes + .push((Ink::Series(ix), right.chain(left).collect())); + let median = scale.at(five(&drawn[ix].1).median); + geometry.strokes.push(( + Ink::Strong, + (middle - width / 6.0, median), + (middle + width / 6.0, median), + )); + } + geometry + }); + banded(self.id, labels, layout, spread_tips(groups)) + } +} + +sized_chart!(BoxPlot); +sized_chart!(ViolinPlot); diff --git a/src/charts/export.rs b/src/charts/export.rs new file mode 100644 index 0000000..d7991c3 --- /dev/null +++ b/src/charts/export.rs @@ -0,0 +1,230 @@ +use std::fmt::Write; + +use gpui::{App, Hsla, Rems, Rgba, SharedString}; + +use super::{ + geometry::{Geometry, Ink, Rect, frame}, + series::tangents, +}; +use crate::theme::{ActiveTheme, Palette, Radius, TextSize}; + +/// An SVG page: its size, the plot's frame, and the theme's colors and measures at the base rem. +pub(crate) struct Sheet { + pub size: (f32, f32), + pub frame: Rect, + stroke: f32, + hairline: f32, + corner: f32, + text: f32, + font: SharedString, + colors: Palette, + horizontal: bool, + smooth: bool, +} + +impl Sheet { + pub(crate) fn new(size: (f32, f32), horizontal: bool, smooth: bool, cx: &App) -> Self { + let theme = cx.theme(); + let (sizes, rem) = (theme.chart(), theme.base_rem()); + let pixels = |length: Rems| f32::from(length.to_pixels(rem)); + Self { + size, + frame: frame( + size, + pixels(sizes.gutter), + pixels(sizes.foot), + pixels(sizes.inset), + ), + stroke: pixels(sizes.stroke), + hairline: f32::from(sizes.hairline), + corner: pixels(theme.radius(Radius::Sm)), + text: pixels(theme.text_size(TextSize::Xs)), + font: theme.font_family.clone(), + colors: theme.colors.clone(), + horizontal, + smooth, + } + } +} + +/// A color as SVG writes it, and its opacity apart. +fn hex(color: Hsla) -> (String, f32) { + let rgba: Rgba = color.into(); + let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8; + ( + format!( + "#{:02x}{:02x}{:02x}", + byte(rgba.r), + byte(rgba.g), + byte(rgba.b) + ), + rgba.a, + ) +} + +/// Text safe inside SVG. +fn escaped(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// A path's commands through `points`, straight or smooth. +fn path(points: &[(f32, f32)], smooth: bool) -> String { + let mut d = String::new(); + let Some((x, y)) = points.first() else { + return d; + }; + write!(d, "M{x:.1},{y:.1}").expect("a string takes writes"); + if !smooth || points.len() < 3 { + for (x, y) in &points[1..] { + write!(d, " L{x:.1},{y:.1}").expect("a string takes writes"); + } + return d; + } + let slopes = tangents(points); + for ix in 0..points.len() - 1 { + let ((x0, y0), (x1, y1)) = (points[ix], points[ix + 1]); + let third = (x1 - x0) / 3.0; + let (ax, ay, bx, by) = ( + x0 + third, + y0 + slopes[ix] * third, + x1 - third, + y1 - slopes[ix + 1] * third, + ); + write!(d, " C{ax:.1},{ay:.1} {bx:.1},{by:.1} {x1:.1},{y1:.1}") + .expect("a string takes writes"); + } + d +} + +/// A chart as a standalone SVG drawing: gridlines, marks and axis labels. +pub(crate) fn svg(geometry: &Geometry, sheet: &Sheet, format: &dyn Fn(f64) -> String) -> String { + let (colors, frame, (width, height)) = (&sheet.colors, sheet.frame, sheet.size); + let ink = |ink: Ink| hex(ink.color(colors)).0; + let (grid, _) = hex(colors.border); + let (label, _) = hex(colors.fg_subtle); + let (back, _) = hex(colors.bg); + let (gap, drop) = (sheet.text * 0.75, sheet.text * 1.6); + let (below, beside) = (frame.y + frame.h + drop, frame.x - gap); + let mut out = format!( + r#""#, + escaped(&sheet.font), + sheet.text + ); + let mut put = |text: String| out.push_str(&text); + put(format!( + r#""# + )); + let text = |x: f32, y: f32, anchor: &str, words: &str| { + format!( + r#"{}"#, + escaped(words) + ) + }; + let middle = sheet.text * 0.35; + for (at, value) in &geometry.ticks { + let words = format(*value); + if sheet.horizontal { + put(format!( + r#""#, + frame.y, + frame.y + frame.h, + sheet.hairline + )); + put(text(*at, below, "middle", &words)); + } else { + put(format!( + r#""#, + frame.x, + frame.x + frame.w, + sheet.hairline + )); + put(text(beside, at + middle, "end", &words)); + } + } + for (at, words) in &geometry.labels { + put(if sheet.horizontal { + text(beside, at + middle, "end", words) + } else { + text(*at, below, "middle", words) + }); + } + for line in &geometry.lines { + if let Some(floor) = &line.floor { + let back: String = floor + .iter() + .rev() + .map(|(x, y)| format!(" L{x:.1},{y:.1}")) + .collect(); + put(format!( + r#""#, + path(&line.points, sheet.smooth), + ink(line.ink) + )); + } + put(format!( + r#""#, + path(&line.points, sheet.smooth), + ink(line.ink), + sheet.stroke + )); + } + for (color, rect) in &geometry.bars { + let radius = sheet.corner.min(rect.w.min(rect.h) / 2.0); + put(format!( + r#""#, + rect.x, + rect.y, + rect.w, + rect.h, + ink(*color) + )); + } + for (color, outline) in &geometry.shapes { + put(format!( + r#""#, + path(outline, false), + sheet.hairline, + fill = ink(*color) + )); + } + for (color, (x1, y1), (x2, y2)) in &geometry.strokes { + let width = if *color == Ink::Rule { + sheet.hairline + } else { + sheet.stroke + }; + put(format!( + r#""#, + ink(*color) + )); + } + for dot in &geometry.dots { + let (x, y) = dot.center; + put(format!( + r#""#, + dot.radius, + sheet.hairline, + fill = ink(dot.ink) + )); + } + out.push_str(""); + out +} + +#[cfg(test)] +mod tests { + use super::{escaped, path}; + + #[test] + fn paths_and_text_write_as_svg_reads_them() { + assert_eq!( + path(&[(0.0, 10.0), (5.0, 2.5)], false), + "M0.0,10.0 L5.0,2.5" + ); + assert!(path(&[(0.0, 0.0), (1.0, 2.0), (2.0, 0.0)], true).contains(" C")); + assert_eq!(escaped("R&D "), "R&D <net>"); + } +} diff --git a/src/charts/flow.rs b/src/charts/flow.rs new file mode 100644 index 0000000..09844e6 --- /dev/null +++ b/src/charts/flow.rs @@ -0,0 +1,448 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseMoveEvent, + ParentElement, PathBuilder, Pixels, Refineable, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, fill, prelude::*, +}; + +use super::{ + axes::anchored, + geometry::Rect, + paint::{at, finish, measure, place, tint}, + parts::ChartTooltip, + plot::Format, + scale::compact, +}; +use crate::{ + theme::{ActiveTheme, Radius, TextSize}, + typography::tabular, +}; + +/// A flow chart's own state: its box and what the pointer is on. +#[derive(Default)] +struct Flows { + bounds: Bounds, + hover: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Hover { + Node(usize), + Link(usize), +} + +/// A link's ribbon: where it leaves its source and meets its target, the tops of both ends, and its thickness. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Ribbon { + pub from: (f32, f32), + pub to: (f32, f32), + pub thick: f32, +} + +impl Ribbon { + /// The ribbon's top edge at a share of the way along: a curve level at both ends. + fn top(&self, share: f32) -> (f32, f32) { + let middle = (self.from.0 + self.to.0) / 2.0; + let (a, b, c, d) = ( + (self.from.0, self.from.1), + (middle, self.from.1), + (middle, self.to.1), + (self.to.0, self.to.1), + ); + let bez = |p: [f32; 4]| { + let t = share; + let u = 1.0 - t; + u * u * u * p[0] + 3.0 * u * u * t * p[1] + 3.0 * u * t * t * p[2] + t * t * t * p[3] + }; + (bez([a.0, b.0, c.0, d.0]), bez([a.1, b.1, c.1, d.1])) + } + + /// Whether a place lies on the ribbon. + fn holds(&self, (x, y): (f32, f32)) -> bool { + if x < self.from.0 || x > self.to.0 { + return false; + } + let (mut low, mut high) = (0.0f32, 1.0f32); + for _ in 0..24 { + let middle = (low + high) / 2.0; + if self.top(middle).0 < x { + low = middle + } else { + high = middle + } + } + let top = self.top(low).1; + y >= top && y <= top + self.thick + } +} + +/// What flows into node `ix` and out of it. +fn flows_at(links: &[(usize, usize, f64)], ix: usize) -> (f64, f64) { + links + .iter() + .fold((0.0, 0.0), |(inward, outward), (from, to, value)| { + ( + inward + if *to == ix { *value } else { 0.0 }, + outward + if *from == ix { *value } else { 0.0 }, + ) + }) +} + +/// What passes through node `ix`: the more of what comes in and what goes out. +fn passing(links: &[(usize, usize, f64)], ix: usize) -> f64 { + let (inward, outward) = flows_at(links, ix); + inward.max(outward) +} + +/// Lays out a flow: a column per step from the sources, each node as tall as what passes through it, each link a ribbon between its ends. +pub(crate) fn sankey( + count: usize, + links: &[(usize, usize, f64)], + frame: Rect, + (node, padding): (f32, f32), +) -> (Vec, Vec) { + let mut column = vec![0usize; count]; + for _ in 0..count { + for (from, to, _) in links { + column[*to] = column[*to].max(column[*from] + 1); + } + } + assert!( + links + .iter() + .all(|(from, to, _)| column[*to] > column[*from]), + "a flow cannot loop back" + ); + let through: Vec = (0..count).map(|ix| passing(links, ix)).collect(); + let columns = column.iter().max().map_or(1, |last| last + 1); + let placed = &column; + let members = |c: usize| (0..count).filter(move |ix| placed[*ix] == c); + let scale = (0..columns) + .map(|c| { + let (total, gaps) = ( + members(c).map(|ix| through[ix]).sum::(), + members(c).count().saturating_sub(1), + ); + (frame.h - padding * gaps as f32).max(0.0) / total.max(f64::EPSILON) as f32 + }) + .fold(f32::MAX, f32::min); + let mut nodes = vec![Rect::default(); count]; + for c in 0..columns { + let x = if columns > 1 { + frame.x + (frame.w - node) * c as f32 / (columns - 1) as f32 + } else { + frame.x + }; + let tall: f32 = members(c).map(|ix| through[ix] as f32 * scale).sum::() + + padding * members(c).count().saturating_sub(1) as f32; + let mut y = frame.y + (frame.h - tall) / 2.0; + for ix in members(c) { + nodes[ix] = Rect { + x, + y, + w: node, + h: through[ix] as f32 * scale, + }; + y += nodes[ix].h + padding; + } + } + let mut ribbons: Vec = links + .iter() + .map(|link| Ribbon { + from: (0.0, 0.0), + to: (0.0, 0.0), + thick: link.2 as f32 * scale, + }) + .collect(); + let mut order: Vec = (0..links.len()).collect(); + let mut stacked = vec![0.0f32; count]; + order.sort_by(|a, b| nodes[links[*a].1].y.total_cmp(&nodes[links[*b].1].y)); + for ix in &order { + let from = links[*ix].0; + ribbons[*ix].from = (nodes[from].x + node, nodes[from].y + stacked[from]); + stacked[from] += ribbons[*ix].thick; + } + stacked.fill(0.0); + order.sort_by(|a, b| nodes[links[*a].0].y.total_cmp(&nodes[links[*b].0].y)); + for ix in &order { + let to = links[*ix].1; + ribbons[*ix].to = (nodes[to].x, nodes[to].y + stacked[to]); + stacked[to] += ribbons[*ix].thick; + } + (nodes, ribbons) +} + +/// How things flow from where they start to where they end: nodes in columns, ribbons as thick as what passes. Hover a node or a ribbon to read it. +#[derive(IntoElement)] +pub struct SankeyChart { + base: Div, + id: ElementId, + nodes: Vec, + links: Vec<(usize, usize, f64)>, + format: Format, +} + +impl SankeyChart { + pub fn new( + id: impl Into, + nodes: impl IntoIterator>, + ) -> Self { + let nodes = nodes.into_iter().map(Into::into).collect(); + Self { + base: div(), + id: id.into(), + nodes, + links: Vec::new(), + format: Rc::new(compact), + } + } + + /// A flow of `value` from node `from` to node `to`, by their places in the list. + pub fn link(mut self, from: usize, to: usize, value: f64) -> Self { + assert!( + from < self.nodes.len() && to < self.nodes.len() && from != to, + "a link joins two known nodes" + ); + assert!( + value.is_finite() && value > 0.0, + "a link carries a positive value" + ); + self.links.push((from, to, value)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for SankeyChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for SankeyChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let flows: Entity = + window.use_keyed_state((self.id.clone(), "flows"), cx, |_, _| Flows::default()); + let (bounds, hover) = (flows.read(cx).bounds, flows.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let room = pixels(sizes.label) * 1.5; + let frame = Rect { + x: room, + y: pixels(sizes.inset), + w: (f32::from(bounds.size.width) - room * 2.0).max(0.0), + h: (f32::from(bounds.size.height) - pixels(sizes.inset) * 2.0).max(0.0), + }; + let (nodes, ribbons) = if frame.w > 0.0 { + sankey( + self.nodes.len(), + &self.links, + frame, + (pixels(sizes.inset), pixels(sizes.inset) * 1.5), + ) + } else { + (vec![Rect::default(); self.nodes.len()], Vec::new()) + }; + let lit = |link: usize| match hover { + Some(Hover::Link(ix)) => ix == link, + Some(Hover::Node(node)) => self.links[link].0 == node || self.links[link].1 == node, + None => true, + }; + let strengths: Vec = (0..ribbons.len()) + .map(|ix| { + if hover.is_none() { + 0.28 + } else if lit(ix) { + 0.5 + } else { + 0.08 + } + }) + .collect(); + let format = self.format.clone(); + let text = |content: String, strong: bool| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if strong { colors.fg } else { colors.fg_muted }) + .child(content) + }; + let middle = frame.x + frame.w / 2.0; + let labels: Vec
= nodes + .iter() + .zip(&self.nodes) + .enumerate() + .map(|(ix, (rect, name))| { + let right = rect.x < middle; + let x = if right { + rect.x - pixels(sizes.inset) + } else { + rect.x + rect.w + pixels(sizes.inset) + }; + let label = div() + .flex() + .flex_col() + .when(right, |label| label.items_end()) + .child(text(name.to_string(), hover == Some(Hover::Node(ix)))) + .child(tabular(text(format(passing(&self.links, ix)), false))); + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(rect.y + rect.h / 2.0)) + .w_0() + .h_0() + .flex() + .items_center() + .when(right, |anchor| anchor.justify_end()) + .child(label) + }) + .collect(); + let tooltip = hover.map(|hover| match hover { + Hover::Link(ix) => { + let (from, to, value) = self.links[ix]; + let place = ribbons[ix].top(0.5); + let card = ChartTooltip::new(format!("{} → {}", self.nodes[from], self.nodes[to])) + .row(Some(tint(&colors, from)), "Flow", format(value)); + anchored( + (place.0, place.1 + ribbons[ix].thick / 2.0), + place.0 > f32::from(bounds.size.width) * 0.6, + card, + ) + } + Hover::Node(ix) => { + let (inward, outward) = flows_at(&self.links, ix); + let rect = nodes[ix]; + let card = ChartTooltip::new(self.nodes[ix].clone()) + .row(None, "In", format(inward)) + .row(None, "Out", format(outward)); + anchored( + (rect.x + rect.w, rect.y + rect.h / 2.0), + rect.x > middle, + card, + ) + } + }); + let (painted, woven, corner) = ( + nodes.clone(), + ribbons.clone(), + theme.radius(Radius::Sm).to_pixels(rem), + ); + let sources: Vec = self.links.iter().map(|link| link.0).collect(); + let palette = colors.clone(); + let (hit_nodes, hit_ribbons) = (nodes, ribbons); + let (moved, left) = (flows.clone(), flows.clone()); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + root.id((self.id.clone(), "sankey")) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let offset = event.position - moved.read(cx).bounds.origin; + let place = (f32::from(offset.x), f32::from(offset.y)); + let next = hit_nodes + .iter() + .position(|rect| rect.contains(place)) + .map(Hover::Node) + .or_else(|| { + hit_ribbons + .iter() + .position(|ribbon| ribbon.holds(place)) + .map(Hover::Link) + }); + moved.update(cx, |flows, cx| { + if flows.hover != next { + flows.hover = next; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |flows, cx| { + flows.hover = None; + cx.notify(); + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for (ix, ribbon) in woven.iter().enumerate() { + let mut path = PathBuilder::fill(); + path.move_to(at(origin, ribbon.from)); + let middle = (ribbon.from.0 + ribbon.to.0) / 2.0; + path.cubic_bezier_to( + at(origin, ribbon.to), + at(origin, (middle, ribbon.from.1)), + at(origin, (middle, ribbon.to.1)), + ); + path.line_to(at(origin, (ribbon.to.0, ribbon.to.1 + ribbon.thick))); + let low = |(x, y): (f32, f32)| (x, y + ribbon.thick); + path.cubic_bezier_to( + at(origin, low(ribbon.from)), + at(origin, low((middle, ribbon.to.1))), + at(origin, low((middle, ribbon.from.1))), + ); + path.close(); + finish( + path, + tint(&palette, sources[ix]).opacity(strengths[ix]), + window, + ); + } + for (ix, rect) in painted.iter().enumerate() { + window.paint_quad( + fill(place(origin, *rect), tint(&palette, ix)).corner_radii(corner), + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(flows, |flows| &mut flows.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_flow_stacks_columns_and_links_meet_their_ends() { + let links = [(0, 2, 30.0), (1, 2, 10.0), (2, 3, 25.0), (2, 4, 15.0)]; + let frame = Rect { + x: 0.0, + y: 0.0, + w: 300.0, + h: 100.0, + }; + let (nodes, ribbons) = sankey(5, &links, frame, (10.0, 20.0)); + assert_eq!( + (nodes[0].x, nodes[2].x, nodes[3].x), + (0.0, 145.0, 290.0), + "three columns edge to edge" + ); + assert_eq!( + (nodes[2].h, nodes[0].h), + (80.0, 60.0), + "the tightest column sets the scale for all" + ); + assert_eq!( + ribbons[1].to.1, + nodes[2].y + ribbons[0].thick, + "the second inflow lands under the first" + ); + assert!(ribbons[0].holds((ribbons[0].top(0.5).0, ribbons[0].top(0.5).1 + 1.0))); + assert!(!ribbons[0].holds((5.0, 50.0)), "left of the ribbon"); + } +} diff --git a/src/charts/funnel.rs b/src/charts/funnel.rs new file mode 100644 index 0000000..95d89ec --- /dev/null +++ b/src/charts/funnel.rs @@ -0,0 +1,261 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseMoveEvent, ParentElement, + PathBuilder, Pixels, Refineable, RenderOnce, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Window, canvas, div, fill, +}; + +use super::{ + axes::anchored, + geometry::Rect, + paint::{at, finish, measure, place, tint}, + parts::ChartTooltip, + plot::Format, + scale::compact, + tiles::Tiles, +}; +use crate::{ + theme::{ActiveTheme, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// Stages a group passes through, each bar as wide as those who reached it, centered, with how many carried on from the stage before. Hover reads a stage. +#[derive(IntoElement)] +pub struct FunnelChart { + base: Div, + id: ElementId, + stages: Vec<(SharedString, f64)>, + format: Format, +} + +impl FunnelChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + stages: Vec::new(), + format: Rc::new(compact), + } + } + + pub fn stage(mut self, name: impl Into, value: f64) -> Self { + assert!( + value.is_finite() && value >= 0.0, + "a stage needs a value of zero or more" + ); + self.stages.push((name.into(), value)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for FunnelChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +/// Each stage's bar inside `frame`: centered, as wide as its share of the widest, a row each with a gap under. +pub(crate) fn funnel(values: &[f64], frame: Rect) -> Vec { + let widest = values.iter().copied().fold(0.0, f64::max); + let row = frame.h / values.len().max(1) as f32; + values + .iter() + .enumerate() + .map(|(ix, value)| { + let w = if widest > 0.0 { + frame.w * (*value / widest) as f32 + } else { + 0.0 + }; + Rect { + x: frame.x + (frame.w - w) / 2.0, + y: frame.y + row * ix as f32, + w, + h: row * 0.62, + } + }) + .collect() +} + +impl RenderOnce for FunnelChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let flows: Entity = + window.use_keyed_state((self.id.clone(), "flows"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (flows.read(cx).bounds, flows.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (names, room) = (pixels(theme.label_width()), pixels(sizes.label) * 1.5); + let frame = Rect { + x: names, + y: 0.0, + w: (f32::from(bounds.size.width) - names - room).max(0.0), + h: f32::from(bounds.size.height), + }; + let values: Vec = self.stages.iter().map(|(_, value)| *value).collect(); + let bars = funnel(&values, frame); + let first = values.first().copied().unwrap_or(0.0); + let share = |part: f64, whole: f64| { + if whole > 0.0 { + format::percent(part / whole, 1, false) + } else { + "—".to_string() + } + }; + let format = self.format.clone(); + let text = |content: String, color| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(color) + .child(content) + }; + let names: Vec
= bars + .iter() + .zip(&self.stages) + .map(|(bar, (name, _))| { + div() + .absolute() + .left_0() + .w(Pixels::from(names)) + .top(Pixels::from(bar.y + bar.h / 2.0)) + .h_0() + .flex() + .items_center() + .justify_end() + .pr_3() + .child(text(name.to_string(), colors.fg)) + }) + .collect(); + let readings: Vec
= bars + .iter() + .zip(&self.stages) + .map(|(bar, (_, value))| { + let reading = div() + .flex() + .gap_1p5() + .child(tabular(text(format(*value), colors.fg))) + .child(tabular(text(share(*value, first), colors.fg_subtle))); + div() + .absolute() + .left(Pixels::from(frame.x + frame.w)) + .top(Pixels::from(bar.y + bar.h / 2.0)) + .h_0() + .flex() + .items_center() + .pl_3() + .child(reading) + }) + .collect(); + let tooltip = match hover { + Some(ix) => { + let bar = bars[ix]; + let before = if ix > 0 { values[ix - 1] } else { values[ix] }; + let card = ChartTooltip::new(self.stages[ix].0.clone()) + .row(Some(tint(&colors, 0)), "Reached", format(values[ix])) + .row(None, "Of the first", share(values[ix], first)) + .row(None, "From the stage before", share(values[ix], before)); + Some(anchored( + (frame.x + frame.w / 2.0 + bar.w / 2.0, bar.y + bar.h / 2.0), + false, + card, + )) + } + _ => None, + }; + let (painted, corner, ink) = ( + bars.clone(), + theme.radius(Radius::Sm).to_pixels(rem), + tint(&colors, 0), + ); + let (moved, left, rows) = ( + flows.clone(), + flows.clone(), + frame.h / self.stages.len().max(1) as f32, + ); + let count = self.stages.len(); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + root.id((self.id.clone(), "funnel")) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let offset = event.position - moved.read(cx).bounds.origin; + let row = (f32::from(offset.y) / rows).floor(); + let next = (row >= 0.0 && (row as usize) < count).then_some(row as usize); + moved.update(cx, |flows, cx| { + if flows.hover != next { + flows.hover = next; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |flows, cx| { + flows.hover = None; + cx.notify(); + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for pair in painted.windows(2) { + let (upper, lower) = (pair[0], pair[1]); + let mut neck = PathBuilder::fill(); + neck.move_to(at(origin, (upper.x, upper.y + upper.h))); + neck.line_to(at(origin, (upper.x + upper.w, upper.y + upper.h))); + neck.line_to(at(origin, (lower.x + lower.w, lower.y))); + neck.line_to(at(origin, (lower.x, lower.y))); + neck.close(); + finish(neck, ink.opacity(0.1), window); + } + for (ix, bar) in painted.iter().enumerate() { + let strength = match hover { + Some(on) if on != ix => 0.45, + _ => 0.9, + }; + window.paint_quad( + fill(place(origin, *bar), ink.opacity(strength)) + .corner_radii(corner), + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(names) + .children(readings) + .children(tooltip) + .child(measure(flows, |flows| &mut flows.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn funnel_bars_center_and_shrink_by_share() { + let bars = funnel( + &[100.0, 50.0], + Rect { + x: 0.0, + y: 0.0, + w: 200.0, + h: 100.0, + }, + ); + assert_eq!((bars[0].x, bars[0].w), (0.0, 200.0)); + assert_eq!((bars[1].x, bars[1].w, bars[1].y), (50.0, 100.0, 50.0)); + } +} diff --git a/src/charts/geometry.rs b/src/charts/geometry.rs new file mode 100644 index 0000000..da18018 --- /dev/null +++ b/src/charts/geometry.rs @@ -0,0 +1,410 @@ +use std::collections::HashSet; + +use gpui::SharedString; + +use super::{ + scale::{Band, Linear, nice}, + series::{Points, extent, stacked}, +}; + +/// How a category series draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mark { + Line, + Area, + Bar, +} + +/// What a mark paints with: a series' palette color, a rise or a fall, or the chart's own ink. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Ink { + Series(usize), + Rise, + Fall, + /// Connectors and whiskers, quiet. + Rule, + /// Medians and marks that read over a fill. + Strong, +} + +/// A place in a box's own pixels: across, then down. +pub(crate) type Place = (f32, f32); + +/// A rectangle in a box's own pixels: left, top, width, height. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub(crate) fn contains(&self, (x, y): (f32, f32)) -> bool { + x >= self.x && x <= self.x + self.w && y >= self.y && y <= self.y + self.h + } +} + +/// A run of points drawn as a line, filled down to its floor when it is an area. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Line { + pub ink: Ink, + pub points: Vec<(f32, f32)>, + pub floor: Option>, +} + +/// A dot, and the index its tooltip reads. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Dot { + pub ink: Ink, + pub center: (f32, f32), + pub radius: f32, + pub key: usize, +} + +/// What a chart draws, in its box's own pixels, for the canvas and for SVG alike. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Geometry { + /// Value ticks: where each sits along the value axis, and its value. + pub ticks: Vec<(f32, f64)>, + /// Labels along the other axis and where each sits: categories, or a scatter's x ticks. + pub labels: Vec<(f32, SharedString)>, + pub lines: Vec, + pub bars: Vec<(Ink, Rect)>, + pub dots: Vec, + /// Straight strokes from one point to another. + pub strokes: Vec<(Ink, Place, Place)>, + /// Closed outlines, filled. + pub shapes: Vec<(Ink, Vec<(f32, f32)>)>, +} + +/// The plot inside a box: past the gutter on the left and the foot below, the inset above and to the right. +pub(crate) fn frame((width, height): (f32, f32), gutter: f32, foot: f32, inset: f32) -> Rect { + Rect { + x: gutter, + y: inset, + w: (width - gutter - inset).max(0.0), + h: (height - foot - inset).max(0.0), + } +} + +/// What a category chart asks of its geometry. +pub(crate) struct Categories<'a> { + pub labels: &'a [SharedString], + pub names: &'a [SharedString], + pub values: &'a [Vec], + pub mark: Mark, + pub stacked: bool, + pub horizontal: bool, + /// The share of each band bars leave empty. + pub gap: f32, + pub hidden: &'a HashSet, + pub span: (usize, usize), +} + +/// Lays out categories and their series inside `frame`: bands along one axis, values along the other. +pub(crate) fn categories(chart: &Categories, frame: Rect) -> Geometry { + let (first, last) = chart.span; + let shown: Vec<(usize, &[f64])> = chart + .names + .iter() + .zip(chart.values) + .enumerate() + .filter(|(_, (name, _))| !chart.hidden.contains(*name)) + .map(|(ix, (_, values))| (ix, &values[first..=last])) + .collect(); + let runs: Vec<&[f64]> = shown.iter().map(|(_, values)| *values).collect(); + let stacks = chart.stacked.then(|| stacked(&runs)); + let (low, high) = match &stacks { + Some(stacks) => extent( + stacks + .iter() + .flatten() + .flat_map(|(bottom, top)| [*bottom, *top]), + ), + None => extent(runs.iter().flat_map(|values| values.iter().copied())), + }; + let ((low, high), ticks) = nice(low, high, 5); + let count = last - first + 1; + let (value_range, band_range) = if chart.horizontal { + ((frame.x, frame.x + frame.w), (frame.y, frame.y + frame.h)) + } else { + ((frame.y + frame.h, frame.y), (frame.x, frame.x + frame.w)) + }; + let scale = Linear::new((low, high), value_range); + let bars = chart.mark == Mark::Bar; + let band = Band { + count, + range: band_range, + padding: if bars { chart.gap } else { 0.0 }, + }; + let along = |ix: usize| { + if bars { + band.middle(ix) + } else { + point_at(&band, ix, count) + } + }; + let place = |band_at: f32, value_at: f32| { + if chart.horizontal { + (value_at, band_at) + } else { + (band_at, value_at) + } + }; + let mut geometry = Geometry { + ticks: ticks.iter().map(|tick| (scale.at(*tick), *tick)).collect(), + labels: (0..count) + .map(|ix| (along(ix), chart.labels[first + ix].clone())) + .collect(), + ..Geometry::default() + }; + for (lane, (color, values)) in shown.iter().enumerate() { + let spans: Vec<(f64, f64)> = match &stacks { + Some(stacks) => stacks[lane].clone(), + None => values.iter().map(|value| (0.0, *value)).collect(), + }; + let ink = Ink::Series(*color); + if bars { + for (ix, (bottom, top)) in spans.iter().enumerate() { + let (start, width) = band.slot(ix); + let (start, width) = if chart.stacked || shown.len() == 1 { + (start, width) + } else { + let each = width / shown.len() as f32; + (start + each * lane as f32 + each * 0.07, each * 0.86) + }; + let (a, b) = (scale.at(*bottom), scale.at(*top)); + let (from, to) = (a.min(b), a.max(b)); + let rect = if chart.horizontal { + Rect { + x: from, + y: start, + w: to - from, + h: width, + } + } else { + Rect { + x: start, + y: from, + w: width, + h: to - from, + } + }; + geometry.bars.push((ink, rect)); + } + continue; + } + let points = spans + .iter() + .enumerate() + .map(|(ix, (_, top))| place(along(ix), scale.at(*top))) + .collect(); + let floor = (chart.mark == Mark::Area).then(|| { + spans + .iter() + .enumerate() + .map(|(ix, (bottom, _))| place(along(ix), scale.at(*bottom))) + .collect() + }); + geometry.lines.push(Line { ink, points, floor }); + } + geometry +} + +/// Where point `ix` of `count` sits: spread edge to edge, or centered when alone. +pub(crate) fn point_at(band: &Band, ix: usize, count: usize) -> f32 { + if count < 2 { + return (band.range.0 + band.range.1) / 2.0; + } + band.range.0 + (band.range.1 - band.range.0) * ix as f32 / (count - 1) as f32 +} + +/// Lays out clouds of points: both axes linear, bubbles sized by area up to `widest`; x ticks read through `format`. +pub(crate) fn scatter( + clouds: &[Points], + hidden: &HashSet, + frame: Rect, + (widest, dot): (f32, f32), + format: &dyn Fn(f64) -> String, +) -> Geometry { + let shown = || clouds.iter().filter(|cloud| !hidden.contains(&cloud.name)); + let every = || shown().flat_map(|cloud| cloud.points.iter()); + let (x_low, x_high) = every().fold((f64::MAX, f64::MIN), |(low, high), (x, _)| { + (low.min(*x), high.max(*x)) + }); + let (y_low, y_high) = every().fold((f64::MAX, f64::MIN), |(low, high), (_, y)| { + (low.min(*y), high.max(*y)) + }); + if x_low > x_high { + return Geometry::default(); + } + let ((x_low, x_high), x_ticks) = nice(x_low, x_high, 5); + let ((y_low, y_high), y_ticks) = nice(y_low, y_high, 5); + let xs = Linear::new((x_low, x_high), (frame.x, frame.x + frame.w)); + let ys = Linear::new((y_low, y_high), (frame.y + frame.h, frame.y)); + let largest = shown() + .filter_map(|cloud| cloud.sizes.as_ref()) + .flatten() + .copied() + .fold(0.0, f64::max); + let mut geometry = Geometry { + ticks: y_ticks.iter().map(|tick| (ys.at(*tick), *tick)).collect(), + labels: x_ticks + .iter() + .map(|tick| (xs.at(*tick), format(*tick).into())) + .collect(), + ..Geometry::default() + }; + let mut key = 0; + for (color, cloud) in clouds.iter().enumerate() { + let skip = hidden.contains(&cloud.name); + for (ix, (x, y)) in cloud.points.iter().enumerate() { + let radius = match &cloud.sizes { + Some(sizes) if largest > 0.0 => widest * (sizes[ix] / largest).sqrt() as f32, + _ => dot, + }; + if !skip { + let center = (xs.at(*x), ys.at(*y)); + geometry.dots.push(Dot { + ink: Ink::Series(color), + center, + radius: radius.max(dot / 2.0), + key, + }); + } + key += 1; + } + } + geometry +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chart<'a>( + labels: &'a [SharedString], + values: &'a [Vec], + hidden: &'a HashSet, + mark: Mark, + stacked: bool, + ) -> Categories<'a> { + static NAMES: [SharedString; 2] = [ + SharedString::new_static("North"), + SharedString::new_static("South"), + ]; + Categories { + labels, + names: &NAMES, + values, + mark, + stacked, + horizontal: false, + gap: 0.28, + hidden, + span: (0, 2), + } + } + + fn sales() -> Vec> { + vec![vec![10.0, 20.0, 30.0], vec![5.0, 5.0, 10.0]] + } + + #[test] + fn stacked_bars_share_a_band_and_grouped_bars_split_it() { + let labels = ["Jan", "Feb", "Mar"].map(SharedString::from); + let (values, hidden) = (sales(), HashSet::new()); + let frame = Rect { + x: 0.0, + y: 0.0, + w: 300.0, + h: 100.0, + }; + let stack = categories(&chart(&labels, &values, &hidden, Mark::Bar, true), frame); + assert_eq!(stack.bars.len(), 6); + assert_eq!( + stack.bars[0].1.x, stack.bars[3].1.x, + "a stack shares its band" + ); + assert_eq!( + stack.bars[3].1.y + stack.bars[3].1.h, + stack.bars[0].1.y, + "the second sits on the first" + ); + let group = categories(&chart(&labels, &values, &hidden, Mark::Bar, false), frame); + assert!( + group.bars[3].1.x > group.bars[0].1.x + group.bars[0].1.w, + "grouped bars stand apart" + ); + assert_eq!(group.ticks.first().map(|tick| tick.1), Some(0.0)); + } + + #[test] + fn lines_run_edge_to_edge_and_hidden_series_drop_out() { + let labels = ["Jan", "Feb", "Mar"].map(SharedString::from); + let values = sales(); + let hidden: HashSet = ["South".into()].into_iter().collect(); + let frame = Rect { + x: 10.0, + y: 0.0, + w: 200.0, + h: 100.0, + }; + let lines = categories(&chart(&labels, &values, &hidden, Mark::Line, false), frame); + assert_eq!(lines.lines.len(), 1); + let xs: Vec = lines.lines[0].points.iter().map(|point| point.0).collect(); + assert_eq!(xs, [10.0, 110.0, 210.0]); + assert_eq!( + lines.lines[0].points[2].1, 0.0, + "the highest value meets the top of the rounded scale" + ); + assert_eq!(lines.lines[0].floor, None, "a line has no floor"); + } + + #[test] + fn bubbles_grow_by_area_and_keep_their_keys_past_a_hidden_cloud() { + let first = Points::new("Towns", [(1.0, 1.0)]); + let cloud = Points::new("Cities", [(0.0, 0.0), (10.0, 10.0)]).sizes([1.0, 4.0]); + let hidden: HashSet = ["Towns".into()].into_iter().collect(); + let frame = Rect { + x: 0.0, + y: 0.0, + w: 100.0, + h: 100.0, + }; + let geometry = scatter(&[first, cloud], &hidden, frame, (20.0, 4.0), &|value| { + value.to_string() + }); + assert_eq!(geometry.dots[1].radius, 20.0); + assert_eq!( + geometry.dots[0].radius, 10.0, + "a quarter of the area, half the radius" + ); + assert_eq!((geometry.dots[0].key, geometry.dots[1].key), (1, 2)); + assert_eq!( + geometry.labels.first().map(|label| label.1.clone()), + Some("0".into()) + ); + } + + #[test] + fn a_frame_leaves_its_gutters() { + let rect = frame((400.0, 200.0), 48.0, 24.0, 8.0); + assert_eq!( + rect, + Rect { + x: 48.0, + y: 8.0, + w: 344.0, + h: 168.0 + } + ); + assert!(rect.contains((48.0, 8.0)) && !rect.contains((40.0, 50.0))); + assert_eq!( + frame((10.0, 10.0), 48.0, 24.0, 8.0).w, + 0.0, + "a small box leaves no room" + ); + } +} diff --git a/src/charts/glide.rs b/src/charts/glide.rs new file mode 100644 index 0000000..c445f2a --- /dev/null +++ b/src/charts/glide.rs @@ -0,0 +1,93 @@ +use std::time::{Duration, Instant}; + +use crate::motion; + +/// Values gliding from where they were to where the owner put them. +#[derive(Default)] +pub(crate) struct Glide { + from: Vec>, + to: Vec>, + since: Option, +} + +impl Glide { + /// The values at this moment, and whether they still move: a change glides from wherever the last glide stood; a new shape snaps. + pub(crate) fn follow( + &mut self, + values: &[Vec], + length: Duration, + ) -> (Vec>, bool) { + let share = |since: Option| { + since.map_or(1.0, |since| { + (since.elapsed().as_secs_f32() / length.as_secs_f32()).min(1.0) + }) + }; + if self.to != values { + let now = if same_shape(&self.from, &self.to) { + mix( + &self.from, + &self.to, + motion::ease_out_cubic(share(self.since)), + ) + } else { + self.to.clone() + }; + let glides = !self.to.is_empty() && same_shape(&now, values); + self.from = if glides { now } else { values.to_vec() }; + self.to = values.to_vec(); + self.since = glides.then(Instant::now); + } + let t = share(self.since); + if t < 1.0 { + (mix(&self.from, &self.to, motion::ease_out_cubic(t)), true) + } else { + (self.to.clone(), false) + } + } +} + +/// Values `share` of the way from `from` to `to`. +fn mix(from: &[Vec], to: &[Vec], share: f32) -> Vec> { + let share = f64::from(share); + to.iter() + .zip(from) + .map(|(to, from)| { + to.iter() + .zip(from) + .map(|(b, a)| a + (b - a) * share) + .collect() + }) + .collect() +} + +fn same_shape(a: &[Vec], b: &[Vec]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.len() == b.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_glide_starts_still_then_moves_toward_a_change() { + let (from, to) = (vec![vec![0.0, 10.0]], vec![vec![10.0, 20.0]]); + assert_eq!(mix(&from, &to, 0.5), [vec![5.0, 15.0]]); + let mut glide = Glide::default(); + let long = Duration::from_secs(60); + assert_eq!( + glide.follow(&from, long), + (from.clone(), false), + "the first values hold still" + ); + let (now, moving) = glide.follow(&to, long); + assert!( + moving && now[0][0] < 1.0, + "a change starts from the old values, got {now:?}" + ); + assert_eq!( + glide.follow(&[vec![1.0]], long), + (vec![vec![1.0]], false), + "a new shape snaps" + ); + } +} diff --git a/src/charts/layout.rs b/src/charts/layout.rs new file mode 100644 index 0000000..9e35d50 --- /dev/null +++ b/src/charts/layout.rs @@ -0,0 +1,135 @@ +use super::geometry::Rect; + +/// Rectangles for values inside `frame`, laid in rows that keep each close to square (Bruls, Huizing and van Wijk); values in the order given, larger first reads best. +pub(crate) fn squarify(values: &[f64], frame: Rect) -> Vec { + let total: f64 = values.iter().filter(|value| **value > 0.0).sum(); + let mut out = vec![Rect::default(); values.len()]; + if total <= 0.0 || frame.w <= 0.0 || frame.h <= 0.0 { + return out; + } + let scale = f64::from(frame.w * frame.h) / total; + let areas: Vec<(usize, f64)> = values + .iter() + .enumerate() + .filter(|(_, value)| **value > 0.0) + .map(|(ix, value)| (ix, value * scale)) + .collect(); + let mut rest = frame; + let mut start = 0; + while start < areas.len() { + let side = f64::from(rest.w.min(rest.h)); + let worst = |row: &[(usize, f64)]| { + let sum: f64 = row.iter().map(|(_, area)| area).sum(); + let (least, most) = row + .iter() + .fold((f64::MAX, 0.0f64), |(least, most), (_, area)| { + (least.min(*area), most.max(*area)) + }); + (side * side * most / (sum * sum)).max(sum * sum / (side * side * least)) + }; + let mut end = start + 1; + while end < areas.len() && worst(&areas[start..=end]) <= worst(&areas[start..end]) { + end += 1; + } + let row = &areas[start..end]; + let sum: f64 = row.iter().map(|(_, area)| area).sum(); + let thick = (sum / side) as f32; + let mut along = 0.0f32; + for (ix, area) in row { + let length = (*area / f64::from(thick)) as f32; + out[*ix] = if rest.w >= rest.h { + Rect { + x: rest.x, + y: rest.y + along, + w: thick, + h: length, + } + } else { + Rect { + x: rest.x + along, + y: rest.y, + w: length, + h: thick, + } + }; + along += length; + } + rest = if rest.w >= rest.h { + Rect { + x: rest.x + thick, + w: rest.w - thick, + ..rest + } + } else { + Rect { + y: rest.y + thick, + h: rest.h - thick, + ..rest + } + }; + start = end; + } + out +} + +/// Days of a year of counts as a calendar grid: each day's week column and weekday row, Monday first, and each month's first column. +pub(crate) fn calendar(first_weekday: usize, days: usize) -> (Vec<(usize, usize)>, usize) { + let places = (0..days) + .map(|day| ((day + first_weekday) / 7, (day + first_weekday) % 7)) + .collect(); + (places, (days + first_weekday).div_ceil(7)) +} + +/// A count's step on a scale of five, zero apart: none, then quartiles of the highest. +pub(crate) fn level(count: f64, highest: f64) -> usize { + if count <= 0.0 || highest <= 0.0 { + return 0; + } + (((count / highest) * 4.0).ceil() as usize).clamp(1, 4) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_treemap_fills_its_frame_with_areas_that_match() { + let frame = Rect { + x: 0.0, + y: 0.0, + w: 600.0, + h: 400.0, + }; + let rects = squarify(&[6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0], frame); + let area: f32 = rects.iter().map(|rect| rect.w * rect.h).sum(); + assert!( + (area - 240_000.0).abs() < 1.0, + "the rectangles fill the frame, got {area}" + ); + assert!( + (rects[0].w * rects[0].h - 60_000.0).abs() < 1.0, + "a quarter of the total is a quarter of the area" + ); + assert!(rects.iter().all(|rect| rect.x >= -0.01 + && rect.y >= -0.01 + && rect.x + rect.w <= 600.01 + && rect.y + rect.h <= 400.01)); + let worst = rects + .iter() + .map(|rect| (rect.w / rect.h).max(rect.h / rect.w)) + .fold(0.0f32, f32::max); + assert!(worst < 3.0, "no sliver: the worst aspect is {worst}"); + } + + #[test] + fn a_calendar_starts_on_its_weekday_and_counts_weeks() { + let (places, weeks) = calendar(2, 10); + assert_eq!(places[0], (0, 2), "the first day sits on its weekday"); + assert_eq!(places[5], (1, 0), "the sixth day starts the next week"); + assert_eq!(weeks, 2); + assert_eq!( + (level(0.0, 9.0), level(1.0, 9.0), level(9.0, 9.0)), + (0, 1, 4) + ); + } +} diff --git a/src/charts/mod.rs b/src/charts/mod.rs new file mode 100644 index 0000000..08cb625 --- /dev/null +++ b/src/charts/mod.rs @@ -0,0 +1,47 @@ +mod axes; +mod calendar; +mod cartesian; +mod distribution; +mod export; +mod flow; +mod funnel; +mod geometry; +mod glide; +mod layout; +mod network; +mod paint; +mod parallel; +mod parts; +mod pie; +mod plot; +mod pointer; +mod radar; +mod realtime; +mod relations; +mod scale; +mod schedule; +mod series; +mod stats; +mod sunburst; +mod targets; +#[cfg(all(test, feature = "test-support"))] +mod tests; +mod tiles; + +pub use calendar::CalendarHeatmap; +pub use cartesian::{AreaChart, BarChart, LineChart, ScatterChart}; +pub use distribution::{BoxPlot, Histogram, ViolinPlot, WaterfallChart}; +pub use flow::SankeyChart; +pub use funnel::FunnelChart; +pub use network::NetworkGraph; +pub use parallel::ParallelCoordinates; +pub use parts::{ChartLegend, ChartTooltip}; +pub use pie::PieChart; +pub use radar::RadarChart; +pub use realtime::RealtimeChart; +pub use relations::ChordDiagram; +pub use schedule::{GanttChart, Task}; +pub use series::{Points, Series}; +pub use sunburst::{Slice, Sunburst}; +pub use targets::{Bullet, BulletChart, ProgressChart}; +pub use tiles::{HeatmapChart, Treemap}; diff --git a/src/charts/network.rs b/src/charts/network.rs new file mode 100644 index 0000000..6f9e6b2 --- /dev/null +++ b/src/charts/network.rs @@ -0,0 +1,399 @@ +use std::{collections::HashSet, f32::consts::TAU}; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, PathBuilder, Pixels, Refineable, + RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, + div, prelude::*, +}; + +use super::{ + axes::anchored, + paint::{at, finish, measure, ring, tint}, + parts::ChartTooltip, +}; +use crate::theme::{ActiveTheme, TextSize}; + +/// Places for a graph's nodes in a unit square, pushed apart by every other node and pulled along their edges (Fruchterman and Reingold), cooling a step at a time. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Force { + pub places: Vec<(f32, f32)>, + heat: f32, + pinned: HashSet, +} + +impl Force { + /// Nodes on a circle, hot enough to move. + pub(crate) fn new(count: usize) -> Self { + let places = (0..count) + .map(|ix| { + let angle = TAU * ix as f32 / count.max(1) as f32; + (0.5 + 0.3 * angle.cos(), 0.5 + 0.3 * angle.sin()) + }) + .collect(); + Self { + places, + heat: 0.08, + pinned: HashSet::new(), + } + } + + pub(crate) fn settled(&self) -> bool { + self.heat < 0.001 + } + + /// Holds a node where the pointer put it and warms the rest to settle around it. + pub(crate) fn pin(&mut self, ix: usize, place: (f32, f32)) { + self.places[ix] = (place.0.clamp(0.0, 1.0), place.1.clamp(0.0, 1.0)); + self.pinned.insert(ix); + self.heat = self.heat.max(0.02); + } + + /// One step: every pair pushes apart, every edge pulls together, a little gravity keeps it centered, and each node moves no farther than the heat. + pub(crate) fn step(&mut self, edges: &[(usize, usize)]) { + let count = self.places.len(); + let k = 0.6 / (count.max(1) as f32).sqrt(); + let mut moves = vec![(0.0f32, 0.0f32); count]; + let apart = |a: (f32, f32), b: (f32, f32)| { + let (dx, dy) = (a.0 - b.0, a.1 - b.1); + let distance = dx.hypot(dy).max(1e-3); + (dx / distance, dy / distance, distance) + }; + for a in 0..count { + for b in a + 1..count { + let (x, y, distance) = apart(self.places[a], self.places[b]); + let push = k * k / distance; + moves[a] = (moves[a].0 + x * push, moves[a].1 + y * push); + moves[b] = (moves[b].0 - x * push, moves[b].1 - y * push); + } + } + for (a, b) in edges { + let (x, y, distance) = apart(self.places[*a], self.places[*b]); + let pull = distance * distance / k; + moves[*a] = (moves[*a].0 - x * pull, moves[*a].1 - y * pull); + moves[*b] = (moves[*b].0 + x * pull, moves[*b].1 + y * pull); + } + for (ix, place) in self.places.iter_mut().enumerate() { + if self.pinned.contains(&ix) { + continue; + } + let (x, y) = ( + moves[ix].0 + (0.5 - place.0) * k, + moves[ix].1 + (0.5 - place.1) * k, + ); + let length = x.hypot(y).max(1e-6); + let reach = length.min(self.heat); + *place = ( + (place.0 + x / length * reach).clamp(0.0, 1.0), + (place.1 + y / length * reach).clamp(0.0, 1.0), + ); + } + self.heat *= 0.96; + } +} + +/// A graph's own state: its box, the forces for the shape it was built for, the node under the pointer and the one being dragged. +#[derive(Default)] +struct Graph { + bounds: Bounds, + shape: (usize, Vec<(usize, usize)>), + force: Option, + hover: Option, + drag: Option, +} + +/// Nodes and the edges between them, placed by forces: edges pull, nodes push apart, and the layout settles as you watch. Drag a node to move it; hover one to light its neighbors. +#[derive(IntoElement)] +pub struct NetworkGraph { + base: Div, + id: ElementId, + nodes: Vec<(SharedString, usize)>, + edges: Vec<(usize, usize)>, +} + +impl NetworkGraph { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + nodes: Vec::new(), + edges: Vec::new(), + } + } + + /// A node, colored by its group. + pub fn node(mut self, name: impl Into, group: usize) -> Self { + self.nodes.push((name.into(), group)); + self + } + + /// An edge between two nodes, by their places in the list. + pub fn edge(mut self, a: usize, b: usize) -> Self { + assert!( + a < self.nodes.len() && b < self.nodes.len() && a != b, + "an edge joins two known nodes" + ); + self.edges.push((a, b)); + self + } +} + +impl Styled for NetworkGraph { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for NetworkGraph { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let graph: Entity = + window.use_keyed_state((self.id.clone(), "graph"), cx, |_, _| Graph::default()); + let shape = (self.nodes.len(), self.edges.clone()); + let still = cx.theme().reduced_motion; + let places = graph.update(cx, |graph, _| { + if graph.shape != shape || graph.force.is_none() { + log::info!( + "network graph: {} nodes, {} edges; settling", + shape.0, + shape.1.len() + ); + graph.force = Some(Force::new(shape.0)); + graph.shape = shape; + } + let force = graph.force.as_mut().expect("forces were just set"); + for _ in 0..if still { 400 } else { 3 } { + if force.settled() { + break; + } + force.step(&graph.shape.1); + } + (force.places.clone(), force.settled()) + }); + let (places, settled) = places; + if !settled { + window.request_animation_frame(); + } + let (bounds, hover) = (graph.read(cx).bounds, graph.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let margin = pixels(sizes.gutter); + let (width, height) = ( + (f32::from(bounds.size.width) - margin * 2.0).max(0.0), + (f32::from(bounds.size.height) - margin * 2.0).max(0.0), + ); + let spot = move |(x, y): (f32, f32)| (margin + x * width, margin + y * height); + let degree: Vec = (0..self.nodes.len()) + .map(|ix| { + self.edges + .iter() + .filter(|(a, b)| *a == ix || *b == ix) + .count() + }) + .collect(); + let dot = pixels(theme.status_dot()); + let radii: Vec = degree + .iter() + .map(|degree| dot * (1.0 + (*degree as f32).sqrt() * 0.35)) + .collect(); + let near: HashSet = hover.map_or_else(HashSet::new, |on| { + self.edges + .iter() + .filter_map(|(a, b)| { + if *a == on { + Some(*b) + } else if *b == on { + Some(*a) + } else { + None + } + }) + .chain([on]) + .collect() + }); + let lit = move |ix: usize| hover.is_none() || near.contains(&ix); + let labels: Vec
= self + .nodes + .iter() + .enumerate() + .map(|(ix, (name, _))| { + let (x, y) = spot(places[ix]); + let label = div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if hover == Some(ix) { + colors.fg + } else { + colors.fg_muted + }); + div() + .absolute() + .left(Pixels::from(x + radii[ix])) + .top(Pixels::from(y)) + .h_0() + .flex() + .items_center() + .pl_1() + .when(!lit(ix), |label| label.opacity(0.35)) + .child(label.child(name.clone())) + }) + .collect(); + let tooltip = hover.map(|ix| { + let card = ChartTooltip::new(self.nodes[ix].0.clone()).row( + Some(tint(&colors, self.nodes[ix].1)), + "Links", + degree[ix].to_string(), + ); + let (x, y) = spot(places[ix]); + anchored((x, y), x > f32::from(bounds.size.width) * 0.6, card) + }); + let edges = self.edges.clone(); + let groups: Vec = self.nodes.iter().map(|(_, group)| *group).collect(); + let (stroke, hairline, palette) = + (sizes.stroke.to_pixels(rem), sizes.hairline, colors.clone()); + let (points, sized) = (places.clone(), radii.clone()); + let hit = move |place: (f32, f32)| { + (0..points.len()) + .map(|ix| { + (ix, { + let (x, y) = spot(points[ix]); + (x - place.0).hypot(y - place.1) + }) + }) + .filter(|(ix, far)| *far <= sized[*ix] + dot) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(ix, _)| ix) + }; + let unit = move |(x, y): (f32, f32)| { + ( + (x - margin) / width.max(1.0), + (y - margin) / height.max(1.0), + ) + }; + let local = |position: gpui::Point, bounds: Bounds| { + let offset = position - bounds.origin; + (f32::from(offset.x), f32::from(offset.y)) + }; + let (moved, pressed, released, left, measured) = ( + graph.clone(), + graph.clone(), + graph.clone(), + graph.clone(), + graph.clone(), + ); + let press_hit = hit.clone(); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + root.id((self.id.clone(), "network")) + .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { + let place = local(event.position, pressed.read(cx).bounds); + if let Some(ix) = press_hit(place) { + log::info!("network graph: dragging node {ix}"); + pressed.update(cx, |graph, cx| { + graph.drag = Some(ix); + cx.notify(); + }) + } + }) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let place = local(event.position, moved.read(cx).bounds); + moved.update(cx, |graph, cx| { + if let (Some(ix), Some(MouseButton::Left)) = (graph.drag, event.pressed_button) + { + graph + .force + .as_mut() + .expect("a dragged graph has forces") + .pin(ix, unit(place)); + cx.notify(); + return; + } + let next = hit(place); + if graph.hover != next { + graph.hover = next; + cx.notify(); + } + }) + }) + .on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, _, cx| { + released.update(cx, |graph, cx| { + if graph.drag.take().is_some() { + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |graph, cx| { + (graph.hover, graph.drag) = (None, None); + cx.notify(); + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for (a, b) in &edges { + let mut line = PathBuilder::stroke(hairline); + line.move_to(at(origin, spot(places[*a]))); + line.line_to(at(origin, spot(places[*b]))); + let strong = + hover.is_some() && (hover == Some(*a) || hover == Some(*b)); + let color = if strong { + palette.fg_muted + } else if hover.is_some() { + palette.border.opacity(0.4) + } else { + palette.border_strong + }; + finish(line, color, window); + } + for (ix, place) in places.iter().enumerate() { + let ink = tint(&palette, groups[ix]); + let fill = if lit(ix) { ink } else { ink.opacity(0.3) }; + ring( + at(origin, spot(*place)), + Pixels::from(radii[ix]), + (fill, palette.bg), + stroke / 2.0, + window, + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(measured, |graph| &mut graph.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::Force; + + #[test] + fn edges_pull_their_nodes_closer_than_strangers() { + let mut force = Force::new(4); + let edges = [(0, 1), (1, 2)]; + while !force.settled() { + force.step(&edges); + } + let far = |a: usize, b: usize| { + (force.places[a].0 - force.places[b].0).hypot(force.places[a].1 - force.places[b].1) + }; + assert!( + far(0, 1) < far(0, 3) && far(1, 2) < far(2, 3), + "linked nodes sit nearer than the loner: {:?}", + force.places + ); + force.pin(3, (0.1, 0.1)); + force.step(&edges); + assert_eq!(force.places[3], (0.1, 0.1), "a pinned node stays put"); + } +} diff --git a/src/charts/paint.rs b/src/charts/paint.rs new file mode 100644 index 0000000..fed743b --- /dev/null +++ b/src/charts/paint.rs @@ -0,0 +1,232 @@ +use gpui::{ + Bounds, Entity, Hsla, IntoElement, PathBuilder, Pixels, Point, Styled, Window, canvas, fill, + point, size, +}; + +use super::{ + geometry::{Geometry, Ink, Rect}, + series::tangents, +}; +use crate::theme::Palette; + +/// A place in a box's own pixels, moved to where the box sits. +pub(crate) fn at(origin: Point, (x, y): (f32, f32)) -> Point { + origin + point(Pixels::from(x), Pixels::from(y)) +} + +/// A rectangle in a box's own pixels, moved to where the box sits. +pub(crate) fn place(origin: Point, rect: Rect) -> Bounds { + Bounds::new( + at(origin, (rect.x, rect.y)), + size(Pixels::from(rect.w), Pixels::from(rect.h)), + ) +} + +/// A canvas over its parent that keeps the parent's bounds in `state` and draws once more when they change. +pub(crate) fn measure( + state: Entity, + slot: fn(&mut T) -> &mut Bounds, +) -> impl IntoElement { + canvas( + move |bounds, window, cx| { + let changed = state.update(cx, |state, cx| { + let held = slot(state); + let changed = *held != bounds; + if changed { + *held = bounds; + cx.notify(); + } + changed + }); + if changed { + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +/// The color series `ix` takes from the chart palette. +pub(crate) fn tint(colors: &Palette, ix: usize) -> Hsla { + colors.chart[ix % colors.chart.len()] +} + +impl Ink { + pub(crate) fn color(self, colors: &Palette) -> Hsla { + match self { + Ink::Series(ix) => tint(colors, ix), + Ink::Rise => tint(colors, 1), + Ink::Fall => tint(colors, 3), + Ink::Rule => colors.fg_subtle, + Ink::Strong => colors.fg, + } + } +} + +/// How a geometry paints: its colors and measures in pixels, and whether lines curve. +#[derive(Clone)] +pub(crate) struct Pen { + pub colors: Palette, + pub stroke: Pixels, + pub hairline: Pixels, + pub corner: Pixels, + pub smooth: bool, + pub horizontal: bool, +} + +/// Follows `points`, straight or through curves that never overshoot. +pub(crate) fn trace( + path: &mut PathBuilder, + points: &[(f32, f32)], + smooth: bool, + origin: Point, +) { + let Some(first) = points.first() else { + return; + }; + path.move_to(at(origin, *first)); + if !smooth || points.len() < 3 { + points[1..] + .iter() + .for_each(|next| path.line_to(at(origin, *next))); + return; + } + let slopes = tangents(points); + for ix in 0..points.len() - 1 { + let ((x0, y0), (x1, y1)) = (points[ix], points[ix + 1]); + let third = (x1 - x0) / 3.0; + path.cubic_bezier_to( + at(origin, (x1, y1)), + at(origin, (x0 + third, y0 + slopes[ix] * third)), + at(origin, (x1 - third, y1 - slopes[ix + 1] * third)), + ); + } +} + +/// Paints a path, or logs why it would not build. +pub(crate) fn finish(path: PathBuilder, color: Hsla, window: &mut Window) { + match path.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!("chart: a path failed to build: {error:#}"), + } +} + +/// A round dot filled with one color and edged with another. +pub(crate) fn ring( + center: Point, + radius: Pixels, + (inside, edge): (Hsla, Hsla), + width: Pixels, + window: &mut Window, +) { + let bounds = Bounds::new( + center - point(radius, radius), + size(radius * 2.0, radius * 2.0), + ); + window.paint_quad( + fill(bounds, inside) + .corner_radii(radius) + .border_widths(width) + .border_color(edge), + ); +} + +/// Paints a geometry laid out in its box's own pixels: gridlines, areas, lines, bars, shapes, strokes and dots. +pub(crate) fn paint( + geometry: &Geometry, + frame: Rect, + pen: &Pen, + origin: Point, + window: &mut Window, +) { + let colors = &pen.colors; + let grid = colors.border.opacity(0.6); + for (tick, _) in &geometry.ticks { + let line = if pen.horizontal { + Bounds::new( + at(origin, (*tick, frame.y)), + size(pen.hairline, Pixels::from(frame.h)), + ) + } else { + Bounds::new( + at(origin, (frame.x, *tick)), + size(Pixels::from(frame.w), pen.hairline), + ) + }; + window.paint_quad(fill(line, grid)); + } + for line in &geometry.lines { + let Some(floor) = &line.floor else { + continue; + }; + let mut area = PathBuilder::fill(); + trace(&mut area, &line.points, pen.smooth, origin); + floor + .iter() + .rev() + .for_each(|place| area.line_to(at(origin, *place))); + area.close(); + finish(area, line.ink.color(colors).opacity(0.14), window); + } + for line in geometry.lines.iter().filter(|line| line.points.len() > 1) { + let mut path = PathBuilder::stroke(pen.stroke); + trace(&mut path, &line.points, pen.smooth, origin); + finish(path, line.ink.color(colors), window); + } + let seam = f32::from(pen.hairline); + for (ink, rect) in &geometry.bars { + let rect = if pen.horizontal { + Rect { + w: (rect.w - seam).max(0.0), + ..*rect + } + } else { + Rect { + y: rect.y + seam, + h: (rect.h - seam).max(0.0), + ..*rect + } + }; + let corner = Pixels::from(f32::from(pen.corner).min(rect.w.min(rect.h) / 2.0)); + window.paint_quad(fill(place(origin, rect), ink.color(colors)).corner_radii(corner)); + } + for (ink, outline) in geometry + .shapes + .iter() + .filter(|(_, outline)| outline.len() > 2) + { + let mut shape = PathBuilder::fill(); + let mut edge = PathBuilder::stroke(pen.hairline); + trace(&mut shape, outline, false, origin); + trace(&mut edge, outline, false, origin); + shape.close(); + edge.close(); + finish(shape, ink.color(colors).opacity(0.3), window); + finish(edge, ink.color(colors), window); + } + for (ink, from, to) in &geometry.strokes { + let width = if *ink == Ink::Rule { + pen.hairline + } else { + pen.stroke + }; + let mut path = PathBuilder::stroke(width); + path.move_to(at(origin, *from)); + path.line_to(at(origin, *to)); + finish(path, ink.color(colors), window); + } + for dot in &geometry.dots { + let color = dot.ink.color(colors); + ring( + at(origin, dot.center), + Pixels::from(dot.radius), + (color.opacity(0.6), color), + pen.hairline, + window, + ); + } +} diff --git a/src/charts/parallel.rs b/src/charts/parallel.rs new file mode 100644 index 0000000..2763fd1 --- /dev/null +++ b/src/charts/parallel.rs @@ -0,0 +1,295 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, + RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, +}; + +use super::{ + axes::anchored, + geometry::Rect, + paint::{at, finish, measure, tint}, + parts::ChartTooltip, + plot::Format, + scale::{Linear, compact, nice}, + tiles::{Tiles, tracked}, +}; +use crate::theme::{ActiveTheme, TextSize}; + +/// Parallel axes: where each stands, its scale, and each record's line across them. +pub(crate) struct Parallel { + pub xs: Vec, + pub scales: Vec, + pub lines: Vec>, +} + +/// Records as lines across parallel axes, one axis per measure, each with its own rounded scale. +pub(crate) fn parallel(records: &[Vec], axes: usize, frame: Rect) -> Parallel { + let xs: Vec = (0..axes) + .map(|ix| frame.x + frame.w * ix as f32 / (axes - 1).max(1) as f32) + .collect(); + let scales: Vec = (0..axes) + .map(|axis| { + let (low, high) = records + .iter() + .map(|record| record[axis]) + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(value), high.max(value)) + }); + let (domain, _) = if low <= high { + nice(low, high, 4) + } else { + ((0.0, 1.0), Vec::new()) + }; + Linear::new(domain, (frame.y + frame.h, frame.y)) + }) + .collect(); + let lines = records + .iter() + .map(|record| { + record + .iter() + .enumerate() + .map(|(axis, value)| (xs[axis], scales[axis].at(*value))) + .collect() + }) + .collect(); + Parallel { xs, scales, lines } +} + +/// How far a place lies from a line through points. +fn distance((x, y): (f32, f32), line: &[(f32, f32)]) -> f32 { + line.windows(2) + .map(|pair| { + let ((ax, ay), (bx, by)) = (pair[0], pair[1]); + let (dx, dy) = (bx - ax, by - ay); + let share = (((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy).max(f32::EPSILON)) + .clamp(0.0, 1.0); + (x - ax - dx * share).hypot(y - ay - dy * share) + }) + .fold(f32::MAX, f32::min) +} + +/// Records with many measures as lines across parallel axes, each axis on its own scale. Hover a line to read its record. +#[derive(IntoElement)] +pub struct ParallelCoordinates { + base: Div, + id: ElementId, + axes: Vec, + records: Vec<(SharedString, Vec)>, + format: Format, +} + +impl ParallelCoordinates { + pub fn new( + id: impl Into, + axes: impl IntoIterator>, + ) -> Self { + let axes: Vec = axes.into_iter().map(Into::into).collect(); + assert!(axes.len() >= 2, "parallel coordinates need two axes"); + Self { + base: div(), + id: id.into(), + axes, + records: Vec::new(), + format: Rc::new(compact), + } + } + + /// A record, a value for each axis. + pub fn record( + mut self, + name: impl Into, + values: impl IntoIterator, + ) -> Self { + let values: Vec = values.into_iter().collect(); + assert_eq!( + values.len(), + self.axes.len(), + "a record needs a value per axis" + ); + assert!( + values.iter().all(|value| value.is_finite()), + "a record needs finite values" + ); + self.records.push((name.into(), values)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for ParallelCoordinates { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for ParallelCoordinates { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "lines"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (side, top) = (pixels(sizes.gutter), pixels(sizes.foot)); + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let frame = Rect { + x: side, + y: top, + w: (width - side * 2.0).max(0.0), + h: (height - top * 2.0).max(0.0), + }; + let values: Vec> = self + .records + .iter() + .map(|(_, values)| values.clone()) + .collect(); + let Parallel { xs, scales, lines } = parallel(&values, self.axes.len(), frame); + let format = self.format.clone(); + let text = |content: String, color| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(color) + .child(content) + }; + let axis_labels: Vec
= xs + .iter() + .zip(&self.axes) + .map(|(x, name)| { + div() + .absolute() + .left(Pixels::from(*x)) + .top_0() + .w_0() + .h(Pixels::from(top)) + .flex() + .justify_center() + .items_center() + .child(text(name.to_string(), colors.fg_muted)) + }) + .collect(); + let ends: Vec
= xs + .iter() + .zip(&scales) + .flat_map(|(x, scale)| { + [ + (scale.domain.1, frame.y), + (scale.domain.0, frame.y + frame.h), + ] + .map(|(value, y)| (*x, value, y)) + }) + .map(|(x, value, y)| { + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(y)) + .h_0() + .flex() + .items_center() + .pl_1p5() + .child(text(format(value), colors.fg_subtle)) + }) + .collect(); + let tooltip = hover.map(|ix| { + let card = self.axes.iter().zip(&self.records[ix].1).fold( + ChartTooltip::new(self.records[ix].0.clone()), + |card, (axis, value)| card.row(None, axis.clone(), format(*value)), + ); + let (x, y) = lines[ix][lines[ix].len() / 2]; + anchored((x, y), x > width * 0.6, card) + }); + let (stroke, hairline, palette, painted) = ( + sizes.stroke.to_pixels(rem), + sizes.hairline, + colors.clone(), + lines.clone(), + ); + let reach = pixels(sizes.inset); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "parallel").into(), + &tiles, + move |place| { + lines + .iter() + .enumerate() + .map(|(ix, line)| (ix, distance(place, line))) + .filter(|(_, far)| *far <= reach) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(ix, _)| ix) + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for x in &xs { + let mut axis = PathBuilder::stroke(hairline); + axis.move_to(at(origin, (*x, frame.y))); + axis.line_to(at(origin, (*x, frame.y + frame.h))); + finish(axis, palette.border_strong, window); + } + let order = (0..painted.len()) + .filter(|ix| Some(*ix) != hover) + .chain(hover); + for ix in order { + let ink = tint(&palette, ix); + let (color, width) = match hover { + None => (ink.opacity(0.7), stroke / 1.5), + Some(on) if on == ix => (ink, stroke), + Some(_) => (ink.opacity(0.15), stroke / 1.5), + }; + let mut path = PathBuilder::stroke(width); + path.move_to(at(origin, painted[ix][0])); + painted[ix][1..] + .iter() + .for_each(|point| path.line_to(at(origin, *point))); + finish(path, color, window); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(axis_labels) + .children(ends) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parallel_axes_share_the_frame_and_lines_meet_their_values() { + let frame = Rect { + x: 0.0, + y: 0.0, + w: 100.0, + h: 100.0, + }; + let Parallel { xs, scales, lines } = + parallel(&[vec![0.0, 10.0], vec![100.0, 0.0]], 2, frame); + assert_eq!(xs, [0.0, 100.0]); + assert_eq!(scales[0].domain, (0.0, 100.0)); + assert_eq!( + lines[0], + [(0.0, 100.0), (100.0, 0.0)], + "the least sits low, the most high" + ); + assert_eq!(distance((50.0, 50.0), &lines[0]), 0.0); + assert!(distance((0.0, 0.0), &lines[0]) > 60.0); + } +} diff --git a/src/charts/parts.rs b/src/charts/parts.rs new file mode 100644 index 0000000..d6efa5c --- /dev/null +++ b/src/charts/parts.rs @@ -0,0 +1,138 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use crate::{ + theme::{ActiveTheme, Elevation, Radius, TextSize}, + typography::tabular, +}; + +type OnToggle = Rc; + +/// A chart's key: a dot in each series' color and its name; with `on_toggle`, a press hides or shows one. +#[derive(IntoElement)] +pub struct ChartLegend { + id: ElementId, + entries: Vec<(Hsla, SharedString, bool)>, + on_toggle: Option, +} + +impl ChartLegend { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + entries: Vec::new(), + on_toggle: None, + } + } + + /// An entry; a hidden one shows faint. + pub fn entry(mut self, color: Hsla, name: impl Into, hidden: bool) -> Self { + self.entries.push((color, name.into(), hidden)); + self + } + + pub fn on_toggle( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ChartLegend { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .flex_wrap() + .gap_x_4() + .gap_y_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .children( + self.entries + .into_iter() + .enumerate() + .map(|(ix, (color, name, hidden))| { + let (on_toggle, key) = (self.on_toggle.clone(), name.clone()); + div() + .id((self.id.clone(), format!("entry-{ix}"))) + .flex() + .items_center() + .gap_1p5() + .when(hidden, |entry| entry.opacity(0.4)) + .when_some(on_toggle, |entry, on_toggle| { + entry + .cursor_pointer() + .on_click(move |_, window, cx| on_toggle(&key, window, cx)) + }) + .child(div().size(theme.status_dot()).rounded_full().bg(color)) + .child(name) + }), + ) + } +} + +/// A chart's reading at one place: a title, then a row for each series with its dot, name and value. +#[derive(IntoElement)] +pub struct ChartTooltip { + title: SharedString, + rows: Vec<(Option, SharedString, SharedString)>, +} + +impl ChartTooltip { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + rows: Vec::new(), + } + } + + /// A row: its dot's color if any, its name, and its value as read. + pub fn row( + mut self, + color: Option, + name: impl Into, + value: impl Into, + ) -> Self { + self.rows.push((color, name.into(), value.into())); + self + } +} + +impl RenderOnce for ChartTooltip { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .flex_col() + .gap_0p5() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Md)) + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Floating)) + .text_size(theme.text_size(TextSize::Xs)) + .whitespace_nowrap() + .child(div().text_color(colors.fg_muted).child(self.title)) + .children(self.rows.into_iter().map(|(color, name, value)| { + div() + .flex() + .items_center() + .gap_1p5() + .children( + color.map(|color| div().size(theme.status_dot()).rounded_full().bg(color)), + ) + .child(div().flex_1().text_color(colors.fg_muted).child(name)) + .child(tabular(div()).pl_3().text_color(colors.fg).child(value)) + })) + } +} diff --git a/src/charts/pie.rs b/src/charts/pie.rs new file mode 100644 index 0000000..de11c3b --- /dev/null +++ b/src/charts/pie.rs @@ -0,0 +1,397 @@ +use std::{ + f32::consts::{FRAC_PI_2, PI, TAU}, + time::{Duration, Instant}, +}; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, MouseMoveEvent, + ParentElement, PathBuilder, Pixels, Point, Refineable, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, point, +}; + +use super::{ + axes::anchored, + glide::Glide, + paint::{finish, measure, tint}, + parts::{ChartLegend, ChartTooltip}, + scale::compact, +}; +use crate::{ + motion, + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// Arcs for shares of a whole, clockwise from twelve o'clock: each a start and a sweep in radians. +pub(crate) fn sweeps(values: &[f64]) -> Vec<(f32, f32)> { + let total: f64 = values.iter().filter(|value| **value > 0.0).sum(); + let mut start = -FRAC_PI_2; + values + .iter() + .map(|value| { + let sweep = if total > 0.0 { + (value.max(0.0) / total) as f32 * TAU + } else { + 0.0 + }; + let arc = (start, sweep); + start += sweep; + arc + }) + .collect() +} + +/// The angle toward `at` from `center`, from twelve o'clock on, and how far it lies. +pub(crate) fn bearing(center: (f32, f32), at: (f32, f32)) -> (f32, f32) { + let (dx, dy) = (at.0 - center.0, at.1 - center.1); + ( + (dy.atan2(dx) + FRAC_PI_2).rem_euclid(TAU) - FRAC_PI_2, + dx.hypot(dy), + ) +} + +/// Which arc holds the direction of `at` from `center`, between radii `inner` and `outer`. +pub(crate) fn arc_at( + arcs: &[(f32, f32)], + center: (f32, f32), + at: (f32, f32), + inner: f32, + outer: f32, +) -> Option { + let (angle, distance) = bearing(center, at); + if distance < inner || distance > outer { + return None; + } + arcs.iter() + .position(|(start, sweep)| angle >= *start && angle < start + sweep) +} + +/// A point `radius` from `center` toward `angle`. +pub(crate) fn toward(center: Point, radius: f32, angle: f32) -> Point { + center + + point( + Pixels::from(radius * angle.cos()), + Pixels::from(radius * angle.sin()), + ) +} + +/// A ring segment's outline with `gap` taken evenly off both sides: out along the outer arc and back along the inner one, or to where the sides meet; none when too thin to draw. +pub(crate) fn wedge( + center: Point, + (inner, outer): (f32, f32), + (start, sweep): (f32, f32), + gap: f32, +) -> Option { + let half = gap / 2.0; + let meet = if sweep < PI { + half / (sweep / 2.0).sin().max(f32::EPSILON) + } else { + half + }; + let inner = inner.max(meet); + if sweep <= 0.0 || inner >= outer { + return None; + } + let arc = |radius: f32| { + let inset = if radius > 0.0 { + (half / radius).min(1.0).asin().min(sweep / 2.0) + } else { + sweep / 2.0 + }; + (start + inset, sweep - inset * 2.0) + }; + let steps = ((sweep / TAU) * 120.0).ceil().max(2.0) as usize; + let along = |step: usize| step as f32 / steps as f32; + let mut path = PathBuilder::fill(); + let (from, span) = arc(outer); + path.move_to(toward(center, outer, from)); + (1..=steps).for_each(|step| path.line_to(toward(center, outer, from + span * along(step)))); + let (from, span) = arc(inner); + (0..=steps) + .rev() + .for_each(|step| path.line_to(toward(center, inner, from + span * along(step)))); + path.close(); + Some(path) +} + +/// The slice under the pointer, the one it left, and when it moved: the first lifts out as the second settles. +#[derive(Default)] +struct Lift { + on: Option, + off: Option, + since: Option, +} + +impl Lift { + /// Moves to `next`; whether anything changed. + fn point(&mut self, next: Option) -> bool { + if self.on == next { + return false; + } + (self.off, self.on, self.since) = (self.on, next, Some(Instant::now())); + true + } + + /// How far each of `count` slices has lifted, zero to one, and whether any still moves. + fn shares(&self, count: usize, length: Duration) -> (Vec, bool) { + let share = self.since.map_or(1.0, |since| { + (since.elapsed().as_secs_f32() / length.as_secs_f32()).min(1.0) + }); + let eased = motion::ease_out_cubic(share); + let lift = |ix: usize| match (self.on == Some(ix), self.off == Some(ix)) { + (true, _) => eased, + (_, true) => 1.0 - eased, + _ => 0.0, + }; + ((0..count).map(lift).collect(), share < 1.0) + } +} + +/// How much each slice fades while another lifts: by the most any other has lifted, less as it lifts itself. +fn fades(lifts: &[f32]) -> Vec { + (0..lifts.len()) + .map(|ix| { + let others = lifts + .iter() + .enumerate() + .filter(|(other, _)| *other != ix) + .fold(0.0f32, |most, (_, lift)| most.max(*lift)); + others * (1.0 - lifts[ix]) + }) + .collect() +} + +/// A ring chart's own state: its box, the lift under the pointer, and a glide between values. +#[derive(Default)] +struct Ring { + bounds: Bounds, + lift: Lift, + glide: Glide, +} + +/// Parts of a whole as slices, clockwise from twelve in the order given; `donut` hollows the middle for a total. Hover lifts a slice and reads its share. +#[derive(IntoElement)] +pub struct PieChart { + base: Div, + id: ElementId, + slices: Vec<(SharedString, f64)>, + center: Option<(SharedString, SharedString)>, +} + +impl PieChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + slices: Vec::new(), + center: None, + } + } + + pub fn slice(mut self, name: impl Into, value: f64) -> Self { + assert!( + value.is_finite() && value >= 0.0, + "a slice needs a value of zero or more" + ); + self.slices.push((name.into(), value)); + self + } + + /// Hollows the middle, which reads `value` over `label` until a slice is hovered. + pub fn donut(mut self, value: impl Into, label: impl Into) -> Self { + self.center = Some((value.into(), label.into())); + self + } +} + +impl Styled for PieChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for PieChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let ring: Entity = + window.use_keyed_state((self.id.clone(), "ring"), cx, |_, _| Ring::default()); + let raw: Vec = self.slices.iter().map(|(_, value)| *value).collect(); + let (slow, fast) = ( + motion::duration(motion::SLOW, cx), + motion::duration(motion::FAST, cx), + ); + let (glided, gliding) = ring.update(cx, |ring, _| { + ring.glide.follow(std::slice::from_ref(&raw), slow) + }); + let arcs = sweeps(&glided.into_iter().next().expect("one ring of values")); + let (lifts, lifting) = ring.read(cx).lift.shares(arcs.len(), fast); + if gliding || lifting { + window.request_animation_frame(); + } + let hover = ring.read(cx).lift.on.filter(|ix| *ix < arcs.len()); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let bounds = ring.read(cx).bounds; + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let lift = pixels(sizes.lift); + let outer = (width.min(height) / 2.0 - lift).max(0.0); + let inner = if self.center.is_some() { + outer * 0.64 + } else { + 0.0 + }; + let gap = if raw.iter().filter(|value| **value > 0.0).count() > 1 { + pixels(sizes.stroke) + } else { + 0.0 + }; + let total: f64 = raw.iter().sum(); + let share = |ix: usize| if total > 0.0 { raw[ix] / total } else { 0.0 }; + let fills: Vec = fades(&lifts) + .iter() + .enumerate() + .map(|(ix, fade)| tint(&colors, ix).opacity(1.0 - 0.45 * fade)) + .collect(); + let tooltip = hover.filter(|_| self.center.is_none()).map(|ix| { + let (start, sweep) = arcs[ix]; + let middle = start + sweep / 2.0; + let reach = outer + lift; + let place = ( + width / 2.0 + reach * middle.cos(), + height / 2.0 + reach * middle.sin(), + ); + let card = ChartTooltip::new(self.slices[ix].0.clone()) + .row( + Some(tint(&colors, ix)), + "Share", + format::percent(share(ix), 1, false), + ) + .row(None, "Value", compact(raw[ix])); + anchored(place, middle.cos() < 0.0, card) + }); + let middle = self.center.clone().map(|(value, label)| { + let (big, small) = match hover { + Some(ix) => ( + format::percent(share(ix), 1, false).into(), + self.slices[ix].0.clone(), + ), + None => (value, label), + }; + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xl)) + .text_color(colors.fg) + .child(big), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(small), + ) + }); + let legend = self.slices.iter().enumerate().fold( + ChartLegend::new((self.id.clone(), "legend")), + |legend, (ix, (name, _))| legend.entry(tint(&colors, ix), name.clone(), false), + ); + let (moved, left, measured, hit) = (ring.clone(), ring.clone(), ring.clone(), arcs.clone()); + let mut frame = div().w_full().h(sizes.height); + frame.style().refine(self.base.style()); + frame.flex().flex_col().gap_2().child(legend).child( + div() + .id((self.id.clone(), "ring")) + .relative() + .flex_1() + .min_h_0() + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let center = moved.read(cx).bounds.center(); + let at = (f32::from(event.position.x), f32::from(event.position.y)); + let next = arc_at( + &hit, + (f32::from(center.x), f32::from(center.y)), + at, + inner, + outer + lift, + ); + moved.update(cx, |ring, cx| { + if ring.lift.point(next) { + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |ring, cx| { + if ring.lift.point(None) { + cx.notify(); + } + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let center = bounds.center(); + for (ix, (start, sweep)) in arcs.iter().enumerate() { + let shifted = toward(center, lift * lifts[ix], start + sweep / 2.0); + if let Some(path) = + wedge(shifted, (inner, outer), (*start, *sweep), gap) + { + finish(path, fills[ix], window); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .children(middle) + .children(tooltip) + .child(measure(measured, |ring| &mut ring.bounds)), + ) + } +} + +#[cfg(test)] +mod tests { + use std::f32::consts::{FRAC_PI_2, TAU}; + + use super::*; + + #[test] + fn slices_sweep_by_share_from_twelve_oclock() { + let arcs = sweeps(&[1.0, 3.0]); + assert_eq!(arcs[0], (-FRAC_PI_2, TAU / 4.0)); + assert_eq!(arcs[1].1, TAU * 0.75); + assert_eq!( + arc_at(&arcs, (0.0, 0.0), (5.0, -5.0), 0.0, 20.0), + Some(0), + "up and right is the first quarter" + ); + assert_eq!(arc_at(&arcs, (0.0, 0.0), (-5.0, 5.0), 0.0, 20.0), Some(1)); + assert_eq!( + arc_at(&arcs, (0.0, 0.0), (50.0, 0.0), 0.0, 20.0), + None, + "outside the ring" + ); + } + + #[test] + fn a_lifted_slice_fades_the_others() { + assert_eq!(fades(&[0.0, 1.0, 0.0]), [1.0, 0.0, 1.0]); + assert_eq!( + fades(&[0.5, 0.5]), + [0.25, 0.25], + "a slice settling as another lifts" + ); + assert_eq!(fades(&[0.0, 0.0]), [0.0, 0.0]); + } +} diff --git a/src/charts/plot.rs b/src/charts/plot.rs new file mode 100644 index 0000000..8307f21 --- /dev/null +++ b/src/charts/plot.rs @@ -0,0 +1,237 @@ +use std::{collections::HashSet, rc::Rc}; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, InteractiveElement, ParentElement, Pixels, Rems, + SharedString, Styled, Window, div, +}; + +use super::{ + axes::{anchored, marks}, + geometry::{Geometry, Ink, Rect, frame}, + glide::Glide, + paint::{Pen, measure, tint}, + parts::{ChartLegend, ChartTooltip}, + pointer::{Focus, answer, drawing, focus}, + scale::compact, +}; +use crate::{ + buttons::{Button, ButtonVariant}, + motion, + theme::{ActiveTheme, ControlSize, Radius}, +}; + +pub(crate) type Format = Rc String>; +/// Lays out a plot's marks inside its frame. +pub(crate) type Layout = Rc Geometry>; +/// A picked index's tooltip: its title, then each row's dot if any, name and value. +pub(crate) type Tips = + Rc (SharedString, Vec<(Option, SharedString, String)>)>; + +/// What a layout lays out: the frame, the values at this moment of a glide, the series the legend hid, and the zoomed span. +pub(crate) struct Scene<'a> { + pub frame: Rect, + pub values: &'a [Vec], + pub hidden: &'a HashSet, + pub span: (usize, usize), +} + +/// What the pointer picks in a plot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Pick { + /// A category's band, as bars take. + Band, + /// The nearest category, as lines take. + Point, + /// The nearest dot within reach, as a scatter takes. + Dot, +} + +/// What a plot draws and how it answers the pointer. +pub(crate) struct Plot { + pub id: ElementId, + pub labels: Vec, + /// The series the legend lists and can hide; one or none shows no legend. + pub names: Vec, + /// Values that glide when the owner changes them; the layout reads them from its scene. + pub values: Vec>, + pub layout: Layout, + pub tips: Tips, + pub pick: Pick, + pub horizontal: bool, + pub smooth: bool, + pub rules: Vec<(f64, SharedString)>, + pub notes: Vec<(usize, SharedString)>, + pub zoom: bool, + pub format: Format, +} + +impl Plot { + pub(crate) fn new(id: ElementId, pick: Pick, layout: Layout, tips: Tips) -> Self { + Self { + id, + labels: Vec::new(), + names: Vec::new(), + values: Vec::new(), + layout, + tips, + pick, + horizontal: false, + smooth: false, + rules: Vec::new(), + notes: Vec::new(), + zoom: false, + format: Rc::new(compact), + } + } +} + +/// A plot's own state: its box, what the pointer picks and where it is, the series the legend hid, the zoomed span, a brush in progress, and a glide. +#[derive(Default)] +pub(crate) struct State { + pub bounds: Bounds, + pub hover: Option, + pub pointer: (f32, f32), + pub hidden: HashSet, + pub span: Option<(usize, usize)>, + pub brush: Option<(f32, f32)>, + glide: Glide, +} + +/// Draws a plot: legend, grid, marks and axes, crosshair and tooltip, rules and notes, and a brush that zooms. +pub(crate) fn plot(plot: Plot, base: Div, window: &mut Window, cx: &mut App) -> Div { + let id = plot.id.clone(); + let state: Entity = + window.use_keyed_state((id.clone(), "plot"), cx, |_, _| State::default()); + let length = motion::duration(motion::SLOW, cx); + let (values, moving) = state.update(cx, |state, _| state.glide.follow(&plot.values, length)); + if moving { + window.request_animation_frame(); + } + let (bounds, hover, pointer, hidden, brush, zoomed) = { + let state = state.read(cx); + let held = (state.hidden.clone(), state.brush, state.span); + ( + state.bounds, + state.hover, + state.pointer, + held.0, + held.1, + held.2, + ) + }; + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: Rems| f32::from(length.to_pixels(rem)); + let count = plot.labels.len(); + let span = zoomed + .filter(|(_, to)| *to < count) + .unwrap_or((0, count.saturating_sub(1))); + let box_size = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let rect = frame( + box_size, + pixels(sizes.gutter), + pixels(sizes.foot), + pixels(sizes.inset), + ); + let scene = Scene { + frame: rect, + values: &values, + hidden: &hidden, + span, + }; + let drawn = rect.w > 0.0 && rect.h > 0.0 && (count > 0 || plot.pick == Pick::Dot); + let geometry = Rc::new(if drawn { + (plot.layout)(&scene) + } else { + Geometry::default() + }); + let hover = + hover.filter(|ix| drawn && (plot.pick == Pick::Dot || (span.0..=span.1).contains(ix))); + let marks = marks(&plot, &geometry, (rect, span), window, cx); + let lit = focus(&plot, &geometry, hover, (rect, span)); + let tooltip = hover.zip(lit.as_ref()).map(|(ix, lit)| { + let (x, y) = match lit { + Focus::Dot(dot) => dot.center, + Focus::Band(band) if plot.horizontal => (pointer.0, band.y + band.h / 2.0), + Focus::Band(band) => (band.x + band.w / 2.0, pointer.1), + Focus::Line(x, _) => (*x, pointer.1), + }; + let (title, rows) = (plot.tips)(ix, &scene); + let card = rows + .into_iter() + .fold(ChartTooltip::new(title), |card, (ink, name, value)| { + card.row(ink.map(|ink| ink.color(&colors)), name, value) + }); + anchored((x, y), x > box_size.0 * 0.6, card) + }); + let legend = (plot.names.len() > 1).then(|| { + let toggled = state.clone(); + let legend = plot.names.iter().enumerate().fold( + ChartLegend::new((id.clone(), "legend")), + |legend, (ix, name)| { + legend.entry(tint(&colors, ix), name.clone(), hidden.contains(name)) + }, + ); + legend.on_toggle(move |name, _, cx| { + toggled.update(cx, |state, cx| { + if !state.hidden.remove(name) { + state.hidden.insert(name.clone()); + } + let now = if state.hidden.contains(name) { + "hidden" + } else { + "shown" + }; + log::info!("chart: {name} {now}"); + cx.notify(); + }) + }) + }); + let zoomable = plot.zoom && !plot.horizontal && plot.pick != Pick::Dot && count >= 3; + let show_all = zoomed.filter(|_| zoomable).map(|_| { + let reset = state.clone(); + let button = Button::new((id.clone(), "show-all"), "Show all") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm); + div() + .absolute() + .top_0() + .right_0() + .child(button.on_click(move |_, _, cx| { + reset.update(cx, |state, cx| { + state.span = None; + log::info!("chart: zoom reset"); + cx.notify(); + }) + })) + }); + let pen = Pen { + colors: colors.clone(), + stroke: sizes.stroke.to_pixels(rem), + hairline: sizes.hairline, + corner: theme.radius(Radius::Sm).to_pixels(rem), + smooth: plot.smooth, + horizontal: plot.horizontal, + }; + let root = div().id((id.clone(), "plot")).relative().flex_1().min_h_0(); + let ways = (plot.pick, plot.horizontal, zoomable); + let root = answer( + root, + &state, + ways, + (rect, span, pixels(sizes.inset)), + geometry.clone(), + ) + .child(drawing( + geometry, + pen, + rect, + (lit, brush), + (marks.rules, marks.notes), + )) + .children(marks.labels) + .children(tooltip) + .children(show_all) + .child(measure(state, |state| &mut state.bounds)); + base.flex().flex_col().gap_2().children(legend).child(root) +} diff --git a/src/charts/pointer.rs b/src/charts/pointer.rs new file mode 100644 index 0000000..4e6539a --- /dev/null +++ b/src/charts/pointer.rs @@ -0,0 +1,324 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Div, Entity, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Point, Stateful, StatefulInteractiveElement, + Styled, Window, canvas, fill, prelude::*, size, +}; + +use super::{ + geometry::{Dot, Geometry, Ink, Rect}, + paint::{Pen, at, finish, paint, place, ring}, + plot::{Pick, Plot, State}, + scale::Band, +}; + +/// What the pointer at a place picks: a category's band, the nearest category, or the nearest dot within reach. +pub(crate) fn pick_at( + pick: Pick, + place: (f32, f32), + frame: Rect, + horizontal: bool, + span: (usize, usize), + dots: &[Dot], + reach: f32, +) -> Option { + if !frame.contains(place) { + return None; + } + let count = span.1 - span.0 + 1; + let (along, start, length) = if horizontal { + (place.1, frame.y, frame.h) + } else { + (place.0, frame.x, frame.w) + }; + match pick { + Pick::Band => Band { + count, + range: (start, start + length), + padding: 0.0, + } + .index(along) + .map(|ix| ix + span.0), + Pick::Point if count < 2 => Some(span.0), + Pick::Point => { + let ix = ((along - start) / (length / (count - 1) as f32)).round() as usize; + Some(ix.min(count - 1) + span.0) + } + Pick::Dot => dots + .iter() + .map(|dot| (dot, (dot.center.0 - place.0).hypot(dot.center.1 - place.1))) + .filter(|(dot, far)| *far <= dot.radius.max(reach)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(dot, _)| dot.key), + } +} + +/// What the picked index lights: its band, its points on each line, or its dot. +pub(crate) enum Focus { + Band(Rect), + Line(f32, Vec<(Ink, (f32, f32))>), + Dot(Dot), +} + +/// What the picked index lights: its band, its points on each line, or its dot. +pub(crate) fn focus( + plot: &Plot, + geometry: &Geometry, + hover: Option, + (rect, span): (Rect, (usize, usize)), +) -> Option { + let ix = hover?; + match plot.pick { + Pick::Dot => geometry + .dots + .iter() + .find(|dot| dot.key == ix) + .map(|dot| Focus::Dot(*dot)), + Pick::Band => { + let range = if plot.horizontal { + (rect.y, rect.y + rect.h) + } else { + (rect.x, rect.x + rect.w) + }; + let lanes = Band { + count: span.1 - span.0 + 1, + range, + padding: 0.0, + }; + let (start, width) = lanes.slot(ix - span.0); + Some(Focus::Band(if plot.horizontal { + Rect { + x: rect.x, + y: start, + w: rect.w, + h: width, + } + } else { + Rect { + x: start, + y: rect.y, + w: width, + h: rect.h, + } + })) + } + Pick::Point => { + let x = geometry.labels.get(ix - span.0)?.0; + let points = geometry + .lines + .iter() + .filter_map(|line| Some((line.ink, *line.points.get(ix - span.0)?))) + .collect(); + Some(Focus::Line(x, points)) + } + } +} + +/// The plot's canvas: the band under the pointer, the marks, dashed rules and note lines, the crosshair or the lit dot, and a brush. +pub(crate) fn drawing( + geometry: Rc, + pen: Pen, + rect: Rect, + (focus, brush): (Option, Option<(f32, f32)>), + (rules, notes): (Vec, Vec), +) -> impl IntoElement { + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + let colors = &pen.colors; + if let Some(Focus::Band(band)) = &focus { + window.paint_quad(fill(place(origin, *band), colors.hover)); + } + paint(&geometry, rect, &pen, origin, window); + let dash = [pen.stroke * 2.0, pen.stroke * 2.0]; + for level in &rules { + let mut rule = PathBuilder::stroke(pen.hairline).dash_array(&dash); + let (from, to) = if pen.horizontal { + ((*level, rect.y), (*level, rect.y + rect.h)) + } else { + ((rect.x, *level), (rect.x + rect.w, *level)) + }; + rule.move_to(at(origin, from)); + rule.line_to(at(origin, to)); + finish(rule, colors.fg_muted, window); + } + let upright = |x: f32| { + Bounds::new( + at(origin, (x, rect.y)), + size(pen.hairline, Pixels::from(rect.h)), + ) + }; + for x in ¬es { + window.paint_quad(fill(upright(*x), colors.fg_subtle.opacity(0.6))); + } + match &focus { + Some(Focus::Line(x, points)) => { + window.paint_quad(fill(upright(*x), colors.border_strong)); + for (ink, point) in points { + let ends = (colors.bg, ink.color(colors)); + ring( + at(origin, *point), + pen.stroke * 2.0, + ends, + pen.stroke, + window, + ); + } + } + Some(Focus::Dot(dot)) => { + let ends = (dot.ink.color(colors), colors.bg); + ring( + at(origin, dot.center), + Pixels::from(dot.radius), + ends, + pen.stroke, + window, + ); + } + _ => {} + } + if let Some((a, b)) = brush { + let band = Rect { + x: a.min(b), + y: rect.y, + w: (a - b).abs(), + h: rect.h, + }; + window.paint_quad(fill(place(origin, band), colors.selection)); + } + }, + ) + .absolute() + .inset_0() +} + +/// A place in window pixels as the plot's own. +fn local(position: Point, bounds: Bounds) -> (f32, f32) { + let offset = position - bounds.origin; + (f32::from(offset.x), f32::from(offset.y)) +} + +/// Wires the pointer: hover picks, and on a zoomable plot a drag brushes a span and a double press shows all. +pub(crate) fn answer( + root: Stateful
, + state: &Entity, + (pick, horizontal, zoomable): (Pick, bool, bool), + (rect, span, reach): (Rect, (usize, usize), f32), + geometry: Rc, +) -> Stateful
{ + let (moved, left, pressed, released) = + (state.clone(), state.clone(), state.clone(), state.clone()); + let end_brush = move |_: &MouseUpEvent, _: &mut Window, cx: &mut App| { + released.update(cx, |state, cx| { + let Some((a, b)) = state.brush.take() else { + return; + }; + let middle = rect.y + rect.h / 2.0; + let end = |x: f32| { + let place = (x.clamp(rect.x, rect.x + rect.w), middle); + pick_at(pick, place, rect, false, span, &[], 0.0) + }; + if let (Some(from), Some(to)) = (end(a.min(b)), end(a.max(b))) + && to > from + { + state.span = Some((from, to)); + log::info!("chart: zoomed to {from}..={to}"); + } + cx.notify(); + }) + }; + root.on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let place = local(event.position, moved.read(cx).bounds); + moved.update(cx, |state, cx| { + if event.pressed_button == Some(MouseButton::Left) + && let Some(brush) = state.brush.as_mut() + { + brush.1 = place.0.clamp(rect.x, rect.x + rect.w); + cx.notify(); + return; + } + let next = pick_at(pick, place, rect, horizontal, span, &geometry.dots, reach); + if state.hover != next || (next.is_some() && pick != Pick::Dot) { + state.hover = next; + state.pointer = place; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |state, cx| { + state.hover = None; + cx.notify(); + }) + } + }) + .when(zoomable, |root| { + root.cursor_crosshair() + .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { + let place = local(event.position, pressed.read(cx).bounds); + pressed.update(cx, |state, cx| { + if event.click_count == 2 { + state.span = None; + log::info!("chart: zoom reset"); + } else if rect.contains(place) { + state.brush = Some((place.0, place.0)); + } + cx.notify(); + }) + }) + .on_mouse_up(MouseButton::Left, end_brush.clone()) + .on_mouse_up_out(MouseButton::Left, end_brush) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pointer_picks_bands_points_and_dots() { + let frame = Rect { + x: 0.0, + y: 0.0, + w: 100.0, + h: 50.0, + }; + assert_eq!( + pick_at(Pick::Band, (30.0, 10.0), frame, false, (0, 3), &[], 0.0), + Some(1) + ); + assert_eq!( + pick_at(Pick::Point, (40.0, 10.0), frame, false, (2, 4), &[], 0.0), + Some(3), + "the nearest of three, past the zoomed start" + ); + assert_eq!( + pick_at(Pick::Band, (30.0, 60.0), frame, false, (0, 3), &[], 0.0), + None, + "below the frame" + ); + assert_eq!( + pick_at(Pick::Band, (10.0, 30.0), frame, true, (0, 4), &[], 0.0), + Some(3), + "sideways bands run down" + ); + let dots = [Dot { + ink: Ink::Series(0), + center: (10.0, 10.0), + radius: 2.0, + key: 7, + }]; + assert_eq!( + pick_at(Pick::Dot, (14.0, 10.0), frame, false, (0, 0), &dots, 6.0), + Some(7) + ); + assert_eq!( + pick_at(Pick::Dot, (30.0, 10.0), frame, false, (0, 0), &dots, 6.0), + None, + "out of reach" + ); + } +} diff --git a/src/charts/radar.rs b/src/charts/radar.rs new file mode 100644 index 0000000..5ce98ad --- /dev/null +++ b/src/charts/radar.rs @@ -0,0 +1,362 @@ +use std::{ + collections::HashSet, + f32::consts::{FRAC_PI_2, TAU}, + rc::Rc, +}; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseMoveEvent, + ParentElement, PathBuilder, Pixels, Point, Refineable, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, point, prelude::*, +}; + +use super::{ + axes::anchored, + glide::Glide, + paint::{finish, measure, ring, tint}, + parts::{ChartLegend, ChartTooltip}, + plot::Format, + scale::{compact, nice}, + series::Series, +}; +use crate::{ + motion, + theme::{ActiveTheme, TextSize}, + typography::tabular, +}; + +/// A radar's own state: its box, the spoke under the pointer, the series the legend hid, and a glide between values. +#[derive(Default)] +struct Radar { + bounds: Bounds, + hover: Option, + hidden: HashSet, + glide: Glide, +} + +/// The spoke nearest the direction of `at` from `center`, of `count` spread clockwise from twelve o'clock. +fn spoke_at(count: usize, center: (f32, f32), at: (f32, f32)) -> usize { + let angle = ((at.1 - center.1).atan2(at.0 - center.0) + FRAC_PI_2).rem_euclid(TAU); + ((angle / (TAU / count as f32)).round() as usize) % count +} + +/// Values on spokes around a center, a shape per series: rings mark the scale, spokes the axes. Hover a spoke to read every series on it; a press on a legend name hides it. +#[derive(IntoElement)] +pub struct RadarChart { + base: Div, + id: ElementId, + axes: Vec, + series: Vec, + format: Format, +} + +impl RadarChart { + pub fn new( + id: impl Into, + axes: impl IntoIterator>, + ) -> Self { + let axes: Vec = axes.into_iter().map(Into::into).collect(); + assert!(axes.len() >= 3, "a radar needs three axes"); + Self { + base: div(), + id: id.into(), + axes, + series: Vec::new(), + format: Rc::new(compact), + } + } + + pub fn series(mut self, series: Series) -> Self { + assert_eq!( + series.values.len(), + self.axes.len(), + "series {} needs a value per axis", + series.name + ); + assert!( + series.values.iter().all(|value| *value >= 0.0), + "a radar reads values of zero or more" + ); + self.series.push(series); + self + } + + /// How values read on the rings and in the tooltip. + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for RadarChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for RadarChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let radar: Entity = + window.use_keyed_state((self.id.clone(), "radar"), cx, |_, _| Radar::default()); + let values: Vec> = self + .series + .iter() + .map(|series| series.values.clone()) + .collect(); + let slow = motion::duration(motion::SLOW, cx); + let (glided, moving) = radar.update(cx, |radar, _| radar.glide.follow(&values, slow)); + if moving { + window.request_animation_frame(); + } + let (bounds, hover, hidden) = { + let radar = radar.read(cx); + (radar.bounds, radar.hover, radar.hidden.clone()) + }; + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let (room, gap) = (pixels(sizes.foot), pixels(sizes.inset)); + let radius = (width.min(height) / 2.0 - room).max(0.0); + let shown: Vec = (0..self.series.len()) + .filter(|ix| !hidden.contains(&self.series[*ix].name)) + .collect(); + let most = shown + .iter() + .flat_map(|ix| glided[*ix].iter()) + .fold(0.0f64, |most, value| most.max(*value)); + let ((_, top), ticks) = nice(0.0, most.max(f64::EPSILON), 4); + let count = self.axes.len(); + let angle = move |ix: usize| -FRAC_PI_2 + TAU * ix as f32 / count as f32; + let text = |content: String, strong: bool| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if strong { colors.fg } else { colors.fg_muted }) + .child(content) + }; + let axis_labels = self.axes.iter().enumerate().map(|(ix, name)| { + let (dx, dy) = (angle(ix).cos(), angle(ix).sin()); + let (x, y) = ( + width / 2.0 + (radius + gap) * dx, + height / 2.0 + (radius + gap) * dy, + ); + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(y)) + .w_0() + .h_0() + .flex() + .map(|anchor| match dx { + dx if dx > 0.3 => anchor.justify_start(), + dx if dx < -0.3 => anchor.justify_end(), + _ => anchor.justify_center(), + }) + .map(|anchor| match dy { + dy if dy > 0.3 => anchor.items_start(), + dy if dy < -0.3 => anchor.items_end(), + _ => anchor.items_center(), + }) + .child(text(name.to_string(), hover == Some(ix))) + }); + let format = self.format.clone(); + let ring_labels = ticks.iter().skip(1).map(|tick| { + let y = height / 2.0 - radius * (*tick / top) as f32; + div() + .absolute() + .left(Pixels::from(width / 2.0)) + .top(Pixels::from(y)) + .h_0() + .flex() + .items_center() + .pl_1p5() + .child(tabular(text(format(*tick), false)).text_color(colors.fg_subtle)) + }); + let tooltip = hover.map(|ix| { + let card = + shown + .iter() + .fold(ChartTooltip::new(self.axes[ix].clone()), |card, series| { + card.row( + Some(tint(&colors, *series)), + self.series[*series].name.clone(), + format(glided[*series][ix]), + ) + }); + let reach = radius + gap; + anchored( + ( + width / 2.0 + reach * angle(ix).cos(), + height / 2.0 + reach * angle(ix).sin(), + ), + angle(ix).cos() < 0.0, + card, + ) + }); + let legend = (self.series.len() > 1).then(|| { + let toggled = radar.clone(); + let legend = self.series.iter().enumerate().fold( + ChartLegend::new((self.id.clone(), "legend")), + |legend, (ix, series)| { + legend.entry( + tint(&colors, ix), + series.name.clone(), + hidden.contains(&series.name), + ) + }, + ); + legend.on_toggle(move |name, _, cx| { + toggled.update(cx, |radar, cx| { + if !radar.hidden.remove(name) { + radar.hidden.insert(name.clone()); + } + log::info!( + "radar: {name} {}", + if radar.hidden.contains(name) { + "hidden" + } else { + "shown" + } + ); + cx.notify(); + }) + }) + }); + let shapes: Vec<(usize, Vec)> = shown + .iter() + .map(|ix| { + ( + *ix, + glided[*ix] + .iter() + .map(|value| (*value / top) as f32) + .collect(), + ) + }) + .collect(); + let levels: Vec = ticks + .iter() + .skip(1) + .map(|tick| (*tick / top) as f32) + .collect(); + let (stroke, hairline) = (sizes.stroke.to_pixels(rem), sizes.hairline); + let palette = colors.clone(); + let (moved, left, measured) = (radar.clone(), radar.clone(), radar.clone()); + let mut frame = div().w_full().h(sizes.height); + frame.style().refine(self.base.style()); + frame.flex().flex_col().gap_2().children(legend).child( + div() + .id((self.id.clone(), "radar")) + .relative() + .flex_1() + .min_h_0() + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let center = moved.read(cx).bounds.center(); + let (from, at) = ( + (f32::from(center.x), f32::from(center.y)), + (f32::from(event.position.x), f32::from(event.position.y)), + ); + let near = (at.0 - from.0).hypot(at.1 - from.1) <= radius + room; + let next = near.then(|| spoke_at(count, from, at)); + moved.update(cx, |radar, cx| { + if radar.hover != next { + radar.hover = next; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |radar, cx| { + radar.hover = None; + cx.notify(); + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let center = bounds.center(); + let at = |share: f32, ix: usize| -> Point { + center + + point( + Pixels::from(radius * share * angle(ix).cos()), + Pixels::from(radius * share * angle(ix).sin()), + ) + }; + let outline = + |shares: &dyn Fn(usize) -> f32, path: &mut PathBuilder| { + path.move_to(at(shares(0), 0)); + (1..count).for_each(|ix| path.line_to(at(shares(ix), ix))); + path.close(); + }; + for level in &levels { + let mut path = PathBuilder::stroke(hairline); + outline(&|_| *level, &mut path); + finish(path, palette.border, window); + } + for ix in 0..count { + let mut spoke = PathBuilder::stroke(hairline); + spoke.move_to(center); + spoke.line_to(at(1.0, ix)); + finish( + spoke, + if hover == Some(ix) { + palette.border_strong + } else { + palette.border + }, + window, + ); + } + for (color, shares) in &shapes { + let ink = tint(&palette, *color); + let (mut area, mut edge) = + (PathBuilder::fill(), PathBuilder::stroke(stroke)); + outline(&|ix| shares[ix], &mut area); + outline(&|ix| shares[ix], &mut edge); + finish(area, ink.opacity(0.14), window); + finish(edge, ink, window); + } + if let Some(spoke) = hover { + for (color, shares) in &shapes { + ring( + at(shares[spoke], spoke), + stroke * 2.0, + (palette.bg, tint(&palette, *color)), + stroke, + window, + ); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .children(ring_labels) + .children(axis_labels) + .children(tooltip) + .child(measure(measured, |radar| &mut radar.bounds)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::spoke_at; + + #[test] + fn the_pointer_picks_the_nearest_spoke() { + assert_eq!(spoke_at(4, (0.0, 0.0), (0.0, -10.0)), 0, "twelve o'clock"); + assert_eq!(spoke_at(4, (0.0, 0.0), (10.0, 1.0)), 1, "three o'clock"); + assert_eq!( + spoke_at(4, (0.0, 0.0), (-1.0, -10.0)), + 0, + "just left of twelve wraps to the first" + ); + } +} diff --git a/src/charts/realtime.rs b/src/charts/realtime.rs new file mode 100644 index 0000000..a97d441 --- /dev/null +++ b/src/charts/realtime.rs @@ -0,0 +1,181 @@ +use std::time::Instant; + +use gpui::{ + App, Bounds, ContentMask, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, + Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, size, +}; + +use super::{ + axes::beside, + geometry::{Rect, frame}, + paint::{at, finish, measure, place, ring, tint, trace}, + scale::{Linear, compact, nice}, +}; +use crate::{ + motion, + theme::{ActiveTheme, TextSize}, + typography::tabular, +}; + +/// A live chart's own state: its box, how many values it has seen, and when the latest arrived. +#[derive(Default)] +struct Live { + bounds: Bounds, + seen: Option, + since: Option, +} + +/// The latest values of a stream as a line that slides left as each new one arrives, the newest marked with a dot. The owner keeps the values and counts what it has pushed. +#[derive(IntoElement)] +pub struct RealtimeChart { + base: Div, + id: ElementId, + values: Vec, + pushed: u64, +} + +impl RealtimeChart { + /// `values` are the latest, oldest first; `pushed` counts every value so far, so the chart knows when a new one came. + pub fn new( + id: impl Into, + values: impl IntoIterator, + pushed: u64, + ) -> Self { + let values: Vec = values.into_iter().collect(); + assert!( + values.iter().all(|value| value.is_finite()), + "a stream needs finite values" + ); + Self { + base: div(), + id: id.into(), + values, + pushed, + } + } +} + +impl Styled for RealtimeChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for RealtimeChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let live: Entity = + window.use_keyed_state((self.id.clone(), "live"), cx, |_, _| Live::default()); + let seen = live.read(cx).seen; + if seen != Some(self.pushed) { + live.update(cx, |live, _| { + live.seen = Some(self.pushed); + live.since = seen.map(|_| Instant::now()); + }); + } + let length = motion::duration(motion::BASE, cx).as_secs_f32(); + let share = live.read(cx).since.map_or(1.0, |since| { + (since.elapsed().as_secs_f32() / length).min(1.0) + }); + if share < 1.0 { + window.request_animation_frame(); + } + let slide = 1.0 - motion::ease_out_cubic(share); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let bounds = live.read(cx).bounds; + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let rect = frame( + (width, height), + pixels(sizes.gutter), + pixels(sizes.inset), + pixels(sizes.inset), + ); + let (low, high) = self + .values + .iter() + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let ((low, high), ticks) = if self.values.is_empty() { + ((0.0, 1.0), vec![0.0, 1.0]) + } else { + nice(low, high, 4) + }; + let scale = Linear::new((low, high), (rect.y + rect.h, rect.y)); + let step = rect.w / (self.values.len().max(2) - 1) as f32; + let points: Vec<(f32, f32)> = self + .values + .iter() + .enumerate() + .map(|(ix, value)| (rect.x + step * (ix as f32 + slide), scale.at(*value))) + .collect(); + let labels = ticks.iter().map(|tick| { + let label = tabular(div()) + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle); + beside(scale.at(*tick), sizes.gutter, label.child(compact(*tick))) + }); + let (ink, levels) = ( + tint(&colors, 0), + ticks.iter().map(|tick| scale.at(*tick)).collect::>(), + ); + let (grid, back) = (colors.border.opacity(0.6), colors.bg); + let (stroke, hairline, inset) = ( + sizes.stroke.to_pixels(rem), + sizes.hairline, + pixels(sizes.inset), + ); + let measured = live.clone(); + let mut chart = div().relative().w_full().h(sizes.height * 0.6); + chart.style().refine(self.base.style()); + chart + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for level in &levels { + let line = Bounds::new( + at(origin, (rect.x, *level)), + size(Pixels::from(rect.w), hairline), + ); + window.paint_quad(fill(line, grid)); + } + let (Some(first), Some(last)) = (points.first(), points.last()) else { + return; + }; + let floor = rect.y + rect.h; + let room = Rect { + x: rect.x, + y: 0.0, + w: rect.w + inset, + h: floor + inset, + }; + window.with_content_mask( + Some(ContentMask { + bounds: place(origin, room), + }), + |window| { + let mut area = PathBuilder::fill(); + trace(&mut area, &points, false, origin); + area.line_to(at(origin, (last.0, floor))); + area.line_to(at(origin, (first.0, floor))); + area.close(); + finish(area, ink.opacity(0.12), window); + let mut line = PathBuilder::stroke(stroke); + trace(&mut line, &points, false, origin); + finish(line, ink, window); + ring(at(origin, *last), stroke * 2.0, (ink, back), stroke, window); + }, + ); + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .child(measure(measured, |live| &mut live.bounds)) + } +} diff --git a/src/charts/relations.rs b/src/charts/relations.rs new file mode 100644 index 0000000..a78cf5b --- /dev/null +++ b/src/charts/relations.rs @@ -0,0 +1,294 @@ +use std::{ + f32::consts::{FRAC_PI_2, TAU}, + rc::Rc, +}; + +use gpui::{ + App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Point, + Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + axes::anchored, + paint::{finish, measure, tint}, + parts::ChartTooltip, + pie::{bearing, toward, wedge}, + plot::Format, + scale::compact, + tiles::{Tiles, tracked}, +}; +use crate::theme::{ActiveTheme, TextSize}; + +/// Arcs around a circle: each a start and a sweep. +type Arcs = Vec<(f32, f32)>; + +/// A chord layout: each group's arc as long as all it sends, split in order into a part per group it sends to, with `pad` between groups. +pub(crate) fn chords(matrix: &[Vec], pad: f32) -> (Arcs, Vec) { + let grand: f64 = matrix.iter().flatten().sum(); + let room = (TAU - pad * matrix.len() as f32).max(0.0); + let scale = if grand > 0.0 { + room / grand as f32 + } else { + 0.0 + }; + let mut angle = -FRAC_PI_2 + pad / 2.0; + let (mut groups, mut parts) = (Vec::new(), Vec::new()); + for row in matrix { + let start = angle; + parts.push( + row.iter() + .map(|value| { + let part = (angle, *value as f32 * scale); + angle += part.1; + part + }) + .collect(), + ); + groups.push((start, angle - start)); + angle += pad; + } + (groups, parts) +} + +/// A ribbon between two parts of the circle, bowing through the center. +fn ribbon( + center: Point, + radius: f32, + (from, reach): (f32, f32), + (to, span): (f32, f32), +) -> PathBuilder { + let arc = |path: &mut PathBuilder, start: f32, sweep: f32| { + let steps = ((sweep / TAU) * 120.0).ceil().max(1.0) as usize; + (1..=steps).for_each(|step| { + path.line_to(toward( + center, + radius, + start + sweep * step as f32 / steps as f32, + )) + }); + }; + let mut path = PathBuilder::fill(); + path.move_to(toward(center, radius, from)); + arc(&mut path, from, reach); + path.curve_to(toward(center, radius, to), center); + arc(&mut path, to, span); + path.curve_to(toward(center, radius, from), center); + path.close(); + path +} + +/// A label set outside a circle toward `angle`, leaning away from the center. +fn outside(center: (f32, f32), radius: f32, angle: f32, label: Div) -> Div { + let (dx, dy) = (angle.cos(), angle.sin()); + div() + .absolute() + .left(Pixels::from(center.0 + radius * dx)) + .top(Pixels::from(center.1 + radius * dy)) + .w_0() + .h_0() + .flex() + .map(|anchor| match dx { + dx if dx > 0.3 => anchor.justify_start(), + dx if dx < -0.3 => anchor.justify_end(), + _ => anchor.justify_center(), + }) + .map(|anchor| match dy { + dy if dy > 0.3 => anchor.items_start(), + dy if dy < -0.3 => anchor.items_end(), + _ => anchor.items_center(), + }) + .child(label) +} + +/// Flows between groups around a circle: each group's arc as long as all it sends, each ribbon as wide at each end as what goes that way. Hover a group to follow its ribbons. +#[derive(IntoElement)] +pub struct ChordDiagram { + base: Div, + id: ElementId, + names: Vec, + matrix: Vec>, + format: Format, +} + +impl ChordDiagram { + /// Groups and what each sends to each: `matrix[from][to]`. + pub fn new( + id: impl Into, + names: impl IntoIterator>, + matrix: Vec>, + ) -> Self { + let names: Vec = names.into_iter().map(Into::into).collect(); + assert!( + matrix.len() == names.len() && matrix.iter().all(|row| row.len() == names.len()), + "a chord diagram needs a square matrix, a row per group" + ); + assert!( + matrix + .iter() + .flatten() + .all(|value| value.is_finite() && *value >= 0.0), + "flows are zero or more" + ); + Self { + base: div(), + id: id.into(), + names, + matrix, + format: Rc::new(compact), + } + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for ChordDiagram { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for ChordDiagram { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "chords"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let center = (width / 2.0, height / 2.0); + let outer = (width.min(height) / 2.0 - pixels(sizes.foot)).max(0.0); + let thick = outer * 0.06; + let (groups, parts) = chords(&self.matrix, pixels(sizes.stroke) * 2.0 / outer.max(1.0)); + let count = self.names.len(); + let pairs: Vec<(usize, usize)> = (0..count) + .flat_map(|a| (a..count).map(move |b| (a, b))) + .filter(|(a, b)| self.matrix[*a][*b] + self.matrix[*b][*a] > 0.0) + .collect(); + let text = |content: SharedString, strong: bool| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if strong { colors.fg } else { colors.fg_muted }) + .child(content) + }; + let labels: Vec
= groups + .iter() + .zip(&self.names) + .enumerate() + .map(|(ix, ((start, sweep), name))| { + outside( + center, + outer + pixels(sizes.inset), + start + sweep / 2.0, + text(name.clone(), hover == Some(ix)), + ) + }) + .collect(); + let format = self.format.clone(); + let tooltip = hover.map(|ix| { + let (sends, gets) = ( + self.matrix[ix].iter().sum::(), + self.matrix.iter().map(|row| row[ix]).sum::(), + ); + let card = ChartTooltip::new(self.names[ix].clone()) + .row(Some(tint(&colors, ix)), "Sends", format(sends)) + .row(None, "Receives", format(gets)); + let middle = groups[ix].0 + groups[ix].1 / 2.0; + anchored( + ( + center.0 + outer * middle.cos(), + center.1 + outer * middle.sin(), + ), + middle.cos() < 0.0, + card, + ) + }); + let senders: Vec = pairs + .iter() + .map(|(a, b)| { + if self.matrix[*a][*b] >= self.matrix[*b][*a] { + *a + } else { + *b + } + }) + .collect(); + let (palette, arcs, hit) = (colors.clone(), groups.clone(), groups); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "chord").into(), + &tiles, + move |place| { + let (angle, distance) = bearing(center, place); + (distance <= outer) + .then(|| { + hit.iter() + .position(|(start, sweep)| angle >= *start && angle < start + sweep) + }) + .flatten() + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let middle = bounds.center(); + for ((a, b), sender) in pairs.iter().zip(&senders) { + let strength = match hover { + None => 0.3, + Some(on) if on == *a || on == *b => 0.55, + Some(_) => 0.06, + }; + finish( + ribbon(middle, outer - thick * 1.5, parts[*a][*b], parts[*b][*a]), + tint(&palette, *sender).opacity(strength), + window, + ); + } + for (ix, arc) in arcs.iter().enumerate() { + if let Some(path) = wedge(middle, (outer - thick, outer), *arc, 0.0) { + finish(path, tint(&palette, ix), window); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +#[cfg(test)] +mod tests { + use std::f32::consts::{FRAC_PI_2, TAU}; + + use super::*; + + #[test] + fn chords_split_each_arc_by_where_it_sends() { + let (groups, parts) = chords(&[vec![1.0, 3.0], vec![2.0, 2.0]], 0.0); + let near = + |(a, b): (f32, f32), (c, d): (f32, f32)| (a - c).abs() < 1e-5 && (b - d).abs() < 1e-5; + assert!( + near(groups[0], (-FRAC_PI_2, TAU / 2.0)), + "half of all that is sent: {:?}", + groups[0] + ); + assert!( + near(parts[0][1], (-FRAC_PI_2 + TAU / 8.0, TAU * 3.0 / 8.0)), + "{:?}", + parts[0][1] + ); + assert!(near((groups[1].0, 0.0), (-FRAC_PI_2 + TAU / 2.0, 0.0))); + } +} diff --git a/src/charts/scale.rs b/src/charts/scale.rs new file mode 100644 index 0000000..0192428 --- /dev/null +++ b/src/charts/scale.rs @@ -0,0 +1,163 @@ +/// A linear map from a domain of values onto a range of pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Linear { + pub domain: (f64, f64), + pub range: (f32, f32), +} + +impl Linear { + pub(crate) fn new(domain: (f64, f64), range: (f32, f32)) -> Self { + assert!( + domain.0.is_finite() && domain.1.is_finite(), + "a scale needs a finite domain" + ); + Self { domain, range } + } + + /// Where `value` lands; a flat domain lands in the middle. + pub(crate) fn at(&self, value: f64) -> f32 { + let span = self.domain.1 - self.domain.0; + let share = if span == 0.0 { + 0.5 + } else { + (value - self.domain.0) / span + }; + self.range.0 + (self.range.1 - self.range.0) * share as f32 + } +} + +/// A round step near `span / count`: 1, 2 or 5 times a power of ten. +pub(crate) fn nice_step(span: f64, count: usize) -> f64 { + let raw = span / count.max(1) as f64; + if raw <= 0.0 || !raw.is_finite() { + return 1.0; + } + let power = 10f64.powf(raw.log10().floor()); + let fraction = raw / power; + let nice = if fraction <= 1.0 { + 1.0 + } else if fraction <= 2.0 { + 2.0 + } else if fraction <= 5.0 { + 5.0 + } else { + 10.0 + }; + nice * power +} + +/// A domain widened to round steps that hold `low..=high`, and the ticks along it. +pub(crate) fn nice(low: f64, high: f64, count: usize) -> ((f64, f64), Vec) { + let (low, high) = if low == high { + (low - 1.0, high + 1.0) + } else { + (low.min(high), low.max(high)) + }; + let step = nice_step(high - low, count); + let (start, end) = ((low / step).floor() * step, (high / step).ceil() * step); + let ticks = (0..) + .map(|ix| start + step * ix as f64) + .take_while(|tick| *tick <= end + step * 1e-9) + .map(|tick| if tick.abs() < step * 1e-9 { 0.0 } else { tick }) + .collect(); + ((start, end), ticks) +} + +/// Numbers as a chart reads them: thousands as k, millions as M, billions as B, to two places at most. +pub(crate) fn compact(value: f64) -> String { + let (scaled, unit) = match value.abs() { + size if size >= 1e9 => (value / 1e9, "B"), + size if size >= 1e6 => (value / 1e6, "M"), + size if size >= 1e3 => (value / 1e3, "k"), + _ => (value, ""), + }; + let text = format!("{scaled:.2}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + format!("{}{unit}", if text == "-0" { "0" } else { text }) +} + +/// Slots for categories along a range, each with its start and width, a share of each left as padding. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Band { + pub count: usize, + pub range: (f32, f32), + pub padding: f32, +} + +impl Band { + /// Slot `ix`'s start and its width, less padding. + pub(crate) fn slot(&self, ix: usize) -> (f32, f32) { + let step = (self.range.1 - self.range.0) / self.count.max(1) as f32; + let width = step * (1.0 - self.padding); + ( + self.range.0 + step * ix as f32 + (step - width) / 2.0, + width, + ) + } + + /// The middle of slot `ix`. + pub(crate) fn middle(&self, ix: usize) -> f32 { + let (start, width) = self.slot(ix); + start + width / 2.0 + } + + /// The slot under pixel `at`, if any. + pub(crate) fn index(&self, at: f32) -> Option { + let step = (self.range.1 - self.range.0) / self.count.max(1) as f32; + let ix = ((at - self.range.0) / step).floor(); + (ix >= 0.0 && (ix as usize) < self.count).then_some(ix as usize) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_scale_maps_values_to_pixels() { + let scale = Linear::new((0.0, 50.0), (100.0, 0.0)); + assert_eq!(scale.at(25.0), 50.0); + assert_eq!(scale.at(50.0), 0.0); + assert_eq!( + Linear::new((3.0, 3.0), (0.0, 10.0)).at(3.0), + 5.0, + "a flat domain sits in the middle" + ); + } + + #[test] + fn ticks_step_round_and_cover_the_data() { + assert_eq!(nice_step(100.0, 5), 20.0); + assert_eq!(nice_step(7.0, 5), 2.0); + assert_eq!(nice_step(0.3, 3), 0.1); + let (domain, ticks) = nice(3.0, 97.0, 5); + assert_eq!(domain, (0.0, 100.0)); + assert_eq!(ticks, [0.0, 20.0, 40.0, 60.0, 80.0, 100.0]); + let (domain, ticks) = nice(-12.0, 8.0, 4); + assert_eq!(domain, (-15.0, 10.0)); + assert_eq!(ticks.first(), Some(&-15.0)); + assert_eq!(nice(5.0, 5.0, 4).0, (4.0, 6.0), "a single value widens"); + } + + #[test] + fn numbers_read_compact() { + assert_eq!(compact(1250.0), "1.25k"); + assert_eq!(compact(2_000_000.0), "2M"); + assert_eq!(compact(0.5), "0.5"); + assert_eq!(compact(-3400.0), "-3.4k"); + assert_eq!((compact(40.0), compact(-0.001)), ("40".into(), "0".into())); + } + + #[test] + fn bands_slot_categories_with_padding() { + let band = Band { + count: 4, + range: (0.0, 400.0), + padding: 0.2, + }; + assert_eq!(band.slot(0), (10.0, 80.0)); + assert_eq!(band.middle(3), 350.0); + assert_eq!(band.index(399.0), Some(3)); + assert_eq!(band.index(-1.0), None); + } +} diff --git a/src/charts/schedule.rs b/src/charts/schedule.rs new file mode 100644 index 0000000..cc6210a --- /dev/null +++ b/src/charts/schedule.rs @@ -0,0 +1,408 @@ +use gpui::{ + App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, + Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, size, +}; +use jiff::{ + Span, + civil::{Date, Weekday}, +}; + +use super::{ + axes::anchored, + geometry::Rect, + paint::{at, finish, measure, place, tint}, + parts::ChartTooltip, + tiles::{Tiles, tracked}, +}; +use crate::{ + theme::{ActiveTheme, Density, Radius, TextSize}, + typography::format, +}; + +/// A task on a schedule: its name, first and last day, how much is done, and the tasks it waits for. +#[derive(Clone, Debug, PartialEq)] +pub struct Task { + name: SharedString, + start: Date, + end: Date, + done: f32, + after: Vec, +} + +impl Task { + /// A task from its first day through its last. + pub fn new(name: impl Into, start: Date, end: Date) -> Self { + assert!(end >= start, "a task ends on or after its first day"); + Self { + name: name.into(), + start, + end, + done: 0.0, + after: Vec::new(), + } + } + + /// How much is done, zero to one. + pub fn done(mut self, share: f32) -> Self { + assert!((0.0..=1.0).contains(&share), "done is a share, zero to one"); + self.done = share; + self + } + + /// Waits for the task at `ix`; an arrow runs from its end. + pub fn after(mut self, ix: usize) -> Self { + self.after.push(ix); + self + } +} + +/// Whole days from `from` to `to`. +fn days(from: Date, to: Date) -> i32 { + to.since(from) + .expect("dates within jiff's range") + .get_days() +} + +/// Days to mark along a span: each month's first day when the span is long, each Monday when it is short. +fn marks(first: Date, count: i32) -> Vec<(i32, String)> { + let long = count > 62; + (0..count) + .filter_map(|offset| { + let day = first + .checked_add(Span::new().days(offset)) + .expect("a day within the schedule"); + let marked = if long { + day.day() == 1 + } else { + day.weekday() == Weekday::Monday + }; + marked.then(|| { + ( + offset, + day.strftime(if long { "%b" } else { "%b %-d" }).to_string(), + ) + }) + }) + .collect() +} + +/// Tasks on a timeline, a row each: bars from first day to last, filled as far as they are done, with arrows from the tasks they wait for and a line at today. Hover a row to read it. +#[derive(IntoElement)] +pub struct GanttChart { + base: Div, + id: ElementId, + tasks: Vec, + today: Option, +} + +impl GanttChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + tasks: Vec::new(), + today: None, + } + } + + /// A task; the ones it waits for come before it. + pub fn task(mut self, task: Task) -> Self { + assert!( + task.after.iter().all(|ix| *ix < self.tasks.len()), + "task {} waits for one not yet given", + task.name + ); + self.tasks.push(task); + self + } + + /// Marks this day with a line. + pub fn today(mut self, today: Date) -> Self { + self.today = Some(today); + self + } +} + +impl Styled for GanttChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for GanttChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let count = self.tasks.len(); + let first = self.tasks.iter().map(|task| task.start).min(); + let last = self.tasks.iter().map(|task| task.end).max(); + let span = first + .zip(last) + .map_or(1, |(first, last)| days(first, last) + 1); + let (names, axis, row, inset) = ( + pixels(theme.label_width()), + pixels(sizes.foot), + pixels(theme.table_row(Density::Compact)), + pixels(sizes.inset), + ); + let day = (f32::from(bounds.size.width) - names - inset).max(0.0) / span as f32; + let x_of = move |offset: i32| names + day * offset as f32; + let bars: Vec = self + .tasks + .iter() + .enumerate() + .map(|(ix, task)| { + let from = first.map_or(0, |first| days(first, task.start)); + Rect { + x: x_of(from), + y: axis + row * ix as f32 + row * 0.22, + w: day * (days(task.start, task.end) + 1) as f32, + h: row * 0.56, + } + }) + .collect(); + let text = |content: String, color| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(color) + .child(content) + }; + let ticks = first.map_or_else(Vec::new, |first| marks(first, span)); + let tick_labels: Vec
= ticks + .iter() + .map(|(offset, label)| { + div() + .absolute() + .left(Pixels::from(x_of(*offset))) + .top_0() + .h(Pixels::from(axis)) + .flex() + .items_center() + .pl_1() + .child(text(label.clone(), colors.fg_subtle)) + }) + .collect(); + let name_labels: Vec
= self + .tasks + .iter() + .enumerate() + .map(|(ix, task)| { + let label = div() + .truncate() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if hover == Some(ix) { + colors.fg + } else { + colors.fg_muted + }) + .child(task.name.clone()); + div() + .absolute() + .left_0() + .top(Pixels::from(axis + row * ix as f32)) + .w(Pixels::from(names)) + .h(Pixels::from(row)) + .flex() + .items_center() + .pr_3() + .child(label) + }) + .collect(); + let today = self + .today + .zip(first) + .map(|(today, first)| x_of(days(first, today)) + day / 2.0) + .filter(|x| *x >= names); + let tooltip = hover.map(|ix| { + let (task, bar) = (&self.tasks[ix], bars[ix]); + let dates = format!( + "{} – {}", + task.start.strftime("%b %-d"), + task.end.strftime("%b %-d") + ); + let card = ChartTooltip::new(task.name.clone()) + .row(Some(tint(&colors, 0)), "Dates", dates) + .row(None, "Days", (days(task.start, task.end) + 1).to_string()) + .row( + None, + "Done", + format::percent(f64::from(task.done), 0, false), + ); + let x = (bar.x + bar.w).min(f32::from(bounds.size.width)); + anchored( + (x, bar.y + bar.h / 2.0), + x > f32::from(bounds.size.width) * 0.6, + card, + ) + }); + let links: Vec<(Rect, Rect)> = self + .tasks + .iter() + .enumerate() + .flat_map(|(ix, task)| task.after.iter().map(move |before| (*before, ix))) + .map(|(before, ix)| (bars[before], bars[ix])) + .collect(); + let done: Vec = self.tasks.iter().map(|task| task.done).collect(); + let (stroke, hairline, corner, palette) = ( + sizes.stroke.to_pixels(rem), + sizes.hairline, + theme.radius(Radius::Sm).to_pixels(rem), + colors.clone(), + ); + let (width, grid) = ( + f32::from(bounds.size.width), + ticks + .iter() + .map(|(offset, _)| x_of(*offset)) + .collect::>(), + ); + let painted = bars.clone(); + let mut root = div() + .relative() + .w_full() + .h(sizes.foot + theme.table_row(Density::Compact) * count as f32); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "gantt").into(), + &tiles, + move |(_, y)| { + let at = ((y - axis) / row).floor(); + (at >= 0.0 && (at as usize) < count).then_some(at as usize) + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + let bottom = axis + row * count as f32; + if let Some(ix) = hover { + window.paint_quad(fill( + place( + origin, + Rect { + x: 0.0, + y: axis + row * ix as f32, + w: width, + h: row, + }, + ), + palette.hover, + )); + } + for x in &grid { + window.paint_quad(fill( + Bounds::new( + at(origin, (*x, axis)), + size(hairline, Pixels::from(bottom - axis)), + ), + palette.border.opacity(0.5), + )); + } + window.paint_quad(fill( + Bounds::new( + at(origin, (names, axis)), + size(Pixels::from(width - names), hairline), + ), + palette.border, + )); + let ink = tint(&palette, 0); + for (bar, share) in painted.iter().zip(&done) { + window.paint_quad( + fill(place(origin, *bar), ink.opacity(0.22)).corner_radii(corner), + ); + window.paint_quad( + fill( + place( + origin, + Rect { + w: bar.w * share, + ..*bar + }, + ), + ink, + ) + .corner_radii(corner), + ); + } + let tip = f32::from(stroke) * 2.0; + for (from, to) in &links { + let (start, end) = ( + (from.x + from.w, from.y + from.h / 2.0), + (to.x, to.y + to.h / 2.0), + ); + let turn = start.0 + tip * 2.0; + let mut path = PathBuilder::stroke(hairline); + path.move_to(at(origin, start)); + if turn + tip <= end.0 { + path.line_to(at(origin, (turn, start.1))); + path.line_to(at(origin, (turn, end.1))); + } else { + let between = from.y + from.h + (to.y - from.y - from.h) / 2.0; + path.line_to(at(origin, (turn, start.1))); + path.line_to(at(origin, (turn, between))); + path.line_to(at(origin, (end.0 - tip * 2.0, between))); + path.line_to(at(origin, (end.0 - tip * 2.0, end.1))); + } + path.line_to(at(origin, (end.0 - tip, end.1))); + finish(path, palette.fg_subtle, window); + let mut head = PathBuilder::fill(); + head.move_to(at(origin, end)); + head.line_to(at(origin, (end.0 - tip, end.1 - tip / 1.5))); + head.line_to(at(origin, (end.0 - tip, end.1 + tip / 1.5))); + head.close(); + finish(head, palette.fg_subtle, window); + } + if let Some(x) = today { + window.paint_quad(fill( + Bounds::new( + at(origin, (x, axis)), + size(stroke / 2.0, Pixels::from(bottom - axis)), + ), + tint(&palette, 3), + )); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(tick_labels) + .children(name_labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +#[cfg(test)] +mod tests { + use jiff::civil::date; + + use super::{days, marks}; + + #[test] + fn short_spans_mark_mondays_and_long_ones_months() { + assert_eq!(days(date(2026, 3, 1), date(2026, 3, 15)), 14); + let weekly = marks(date(2026, 3, 1), 14); + assert_eq!( + weekly.iter().map(|mark| mark.0).collect::>(), + [1, 8], + "the first of March 2026 is a Sunday" + ); + let monthly = marks(date(2026, 1, 15), 90); + assert_eq!( + monthly + .iter() + .map(|mark| mark.1.as_str()) + .collect::>(), + ["Feb", "Mar", "Apr"] + ); + } +} diff --git a/src/charts/series.rs b/src/charts/series.rs new file mode 100644 index 0000000..9cea4a9 --- /dev/null +++ b/src/charts/series.rs @@ -0,0 +1,222 @@ +use gpui::SharedString; + +/// A named run of values, one for each of the chart's categories. +#[derive(Clone, Debug, PartialEq)] +pub struct Series { + pub(crate) name: SharedString, + pub(crate) values: Vec, +} + +impl Series { + pub fn new(name: impl Into, values: impl IntoIterator) -> Self { + let values: Vec = values.into_iter().collect(); + assert!( + values.iter().all(|value| value.is_finite()), + "a series needs finite values" + ); + Self { + name: name.into(), + values, + } + } +} + +/// A named cloud of points, each an x and a y, sized for bubbles when `sizes` are given. +#[derive(Clone, Debug, PartialEq)] +pub struct Points { + pub(crate) name: SharedString, + pub(crate) points: Vec<(f64, f64)>, + pub(crate) sizes: Option>, +} + +impl Points { + pub fn new( + name: impl Into, + points: impl IntoIterator, + ) -> Self { + let points: Vec<(f64, f64)> = points.into_iter().collect(); + assert!( + points.iter().all(|(x, y)| x.is_finite() && y.is_finite()), + "points need finite values" + ); + Self { + name: name.into(), + points, + sizes: None, + } + } + + /// A size for each point, drawn as its area; makes a bubble chart. + pub fn sizes(mut self, sizes: impl IntoIterator) -> Self { + let sizes: Vec = sizes.into_iter().collect(); + assert_eq!( + sizes.len(), + self.points.len(), + "a bubble needs a size for each point" + ); + assert!( + sizes.iter().all(|size| size.is_finite() && *size >= 0.0), + "bubble sizes are not negative" + ); + self.sizes = Some(sizes); + self + } +} + +/// Each series' bottom and top at each category when stacked on the ones before it; negatives stack down from zero. +pub(crate) fn stacked(series: &[&[f64]]) -> Vec> { + let count = series.first().map_or(0, |values| values.len()); + let mut up = vec![0.0; count]; + let mut down = vec![0.0; count]; + series + .iter() + .map(|values| { + values + .iter() + .enumerate() + .map(|(ix, value)| { + let base = if *value >= 0.0 { + &mut up[ix] + } else { + &mut down[ix] + }; + let bottom = *base; + *base += value; + (bottom, *base) + }) + .collect() + }) + .collect() +} + +/// The lowest and highest a chart must show: zero, and every value or stacked total. +pub(crate) fn extent(values: impl Iterator) -> (f64, f64) { + values.fold((0.0, 0.0), |(low, high), value| { + (low.min(value), high.max(value)) + }) +} + +/// Tangents for a smooth line through `points` that never overshoots between them (Fritsch–Carlson). +pub(crate) fn tangents(points: &[(f32, f32)]) -> Vec { + let count = points.len(); + if count < 2 { + return vec![0.0; count]; + } + let slopes: Vec = points + .windows(2) + .map(|pair| (pair[1].1 - pair[0].1) / (pair[1].0 - pair[0].0)) + .collect(); + let mut tangents: Vec = (0..count) + .map(|ix| match ix { + 0 => slopes[0], + last if last == count - 1 => slopes[count - 2], + ix if slopes[ix - 1] * slopes[ix] <= 0.0 => 0.0, + ix => (slopes[ix - 1] + slopes[ix]) / 2.0, + }) + .collect(); + for (ix, slope) in slopes.iter().enumerate() { + if *slope == 0.0 { + tangents[ix] = 0.0; + tangents[ix + 1] = 0.0; + continue; + } + let (a, b) = (tangents[ix] / slope, tangents[ix + 1] / slope); + let length = a.hypot(b); + if length > 3.0 { + tangents[ix] = 3.0 * a / length * slope; + tangents[ix + 1] = 3.0 * b / length * slope; + } + } + tangents +} + +/// A field as CSV reads it: quoted when it holds a comma, a quote or a line break. +fn field(text: &str) -> String { + if text.contains([',', '"', '\n']) { + format!("\"{}\"", text.replace('"', "\"\"")) + } else { + text.to_string() + } +} + +/// Rows of comma-separated text: a header of `labels`, then each series' name and values. +pub(crate) fn csv(labels: &[SharedString], series: &[Series]) -> String { + let head = std::iter::once(String::new()) + .chain(labels.iter().map(|label| field(label))) + .collect::>() + .join(","); + let rows = series.iter().map(|series| { + std::iter::once(field(&series.name)) + .chain(series.values.iter().map(|value| value.to_string())) + .collect::>() + .join(",") + }); + std::iter::once(head) + .chain(rows) + .collect::>() + .join("\n") +} + +/// Rows of comma-separated text: a header, then each point's series, x, y and size when it has one. +pub(crate) fn points_csv(clouds: &[Points]) -> String { + let rows = clouds.iter().flat_map(|cloud| { + cloud.points.iter().enumerate().map(move |(ix, (x, y))| { + let size = cloud + .sizes + .as_ref() + .map_or(String::new(), |sizes| sizes[ix].to_string()); + format!("{},{x},{y},{size}", field(&cloud.name)) + }) + }); + std::iter::once("series,x,y,size".to_string()) + .chain(rows) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stacks_rise_from_zero_and_negatives_fall() { + let stacks = stacked(&[&[2.0, -1.0], &[3.0, -2.0]]); + assert_eq!( + stacks, + [ + vec![(0.0, 2.0), (0.0, -1.0)], + vec![(2.0, 5.0), (-1.0, -3.0)] + ] + ); + assert_eq!(extent([3.0, -2.0, 7.0].into_iter()), (-2.0, 7.0)); + assert_eq!( + extent([3.0, 4.0].into_iter()), + (0.0, 4.0), + "zero stays in view" + ); + } + + #[test] + fn a_smooth_line_flattens_at_peaks_and_never_overshoots() { + let points = [(0.0, 0.0), (1.0, 2.0), (2.0, 1.0), (3.0, 1.0)]; + let tangents = tangents(&points); + assert_eq!(tangents[1], 0.0, "a peak is flat"); + assert_eq!( + (tangents[2], tangents[3]), + (0.0, 0.0), + "a level run stays level" + ); + } + + #[test] + fn csv_has_a_header_and_a_row_per_series() { + let labels = ["Jan", "Feb"].map(SharedString::from); + let text = csv(&labels, &[Series::new("Sales, net", [1.0, 2.5])]); + assert_eq!(text, ",Jan,Feb\n\"Sales, net\",1,2.5"); + let points = points_csv(&[ + Points::new("Paid", [(1.0, 2.0)]), + Points::new("Cities", [(3.0, 4.0)]).sizes([9.0]), + ]); + assert_eq!(points, "series,x,y,size\nPaid,1,2,\nCities,3,4,9"); + } +} diff --git a/src/charts/stats.rs b/src/charts/stats.rs new file mode 100644 index 0000000..2d9930b --- /dev/null +++ b/src/charts/stats.rs @@ -0,0 +1,200 @@ +use super::scale::nice_step; + +/// Bins of a round width near a `count`th of the data's range, each with its start, its end, and how many values fall in it; the top edge counts in the last. +pub(crate) fn bins(values: &[f64], count: usize) -> Vec<(f64, f64, usize)> { + assert!(count > 0, "a histogram needs a bin"); + if values.is_empty() { + return Vec::new(); + } + let (low, high) = values + .iter() + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let width = nice_step(high - low, count); + let start = (low / width).floor() * width; + let many = (((high - start) / width).ceil() as usize).max(1); + let mut bins: Vec<(f64, f64, usize)> = (0..many) + .map(|ix| { + ( + start + width * ix as f64, + start + width * (ix + 1) as f64, + 0, + ) + }) + .collect(); + for value in values { + let ix = (((value - start) / width) as usize).min(many - 1); + bins[ix].2 += 1; + } + bins +} + +/// A spread in five numbers: the whiskers' ends within 1.5 spreads of the box, the quartiles, the median; and the values past the whiskers. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Five { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub outliers: Vec, +} + +/// The value `share` of the way through sorted `values`, between neighbors. +fn quantile(sorted: &[f64], share: f64) -> f64 { + let at = share * (sorted.len() - 1) as f64; + let (below, above) = (at.floor() as usize, at.ceil() as usize); + sorted[below] + (sorted[above] - sorted[below]) * (at - below as f64) +} + +pub(crate) fn five(values: &[f64]) -> Five { + assert!(!values.is_empty(), "a box needs values"); + let mut sorted = values.to_vec(); + sorted.sort_by(f64::total_cmp); + let (q1, median, q3) = ( + quantile(&sorted, 0.25), + quantile(&sorted, 0.5), + quantile(&sorted, 0.75), + ); + let reach = (q3 - q1) * 1.5; + let inside = |value: &&f64| **value >= q1 - reach && **value <= q3 + reach; + let low = *sorted + .iter() + .find(inside) + .expect("the quartiles lie inside"); + let high = *sorted + .iter() + .rev() + .find(inside) + .expect("the quartiles lie inside"); + let outliers = sorted + .iter() + .copied() + .filter(|value| *value < low || *value > high) + .collect(); + Five { + low, + q1, + median, + q3, + high, + outliers, + } +} + +/// A smooth estimate of where values gather, sampled at `count` points across a little past their range: a Gaussian kernel with Silverman's bandwidth. +pub(crate) fn density(values: &[f64], count: usize) -> Vec<(f64, f64)> { + assert!( + values.len() >= 2 && count >= 2, + "a density needs two values and two samples" + ); + let n = values.len() as f64; + let mean = values.iter().sum::() / n; + let spread = (values + .iter() + .map(|value| (value - mean).powi(2)) + .sum::() + / (n - 1.0)) + .sqrt(); + let bandwidth = (1.06 * spread * n.powf(-0.2)).max(f64::EPSILON); + let (low, high) = values + .iter() + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let (from, to) = (low - bandwidth, high + bandwidth); + let norm = 1.0 / (n * bandwidth * (2.0 * std::f64::consts::PI).sqrt()); + (0..count) + .map(|ix| { + let at = from + (to - from) * ix as f64 / (count - 1) as f64; + let sum: f64 = values + .iter() + .map(|value| (-0.5 * ((at - value) / bandwidth).powi(2)).exp()) + .sum(); + (at, sum * norm) + }) + .collect() +} + +/// A waterfall bar's kind: a rise, a fall, or a total that stands on zero. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Step { + Up, + Down, + Total, +} + +/// Each change floating from the running total, bottom and top; a total stands on zero at the sum so far. +pub(crate) fn steps(changes: &[(f64, bool)]) -> Vec<(f64, f64, Step)> { + let mut running = 0.0; + changes + .iter() + .map(|(value, total)| { + if *total { + return (0.0, running, Step::Total); + } + let from = running; + running += value; + ( + from.min(running), + from.max(running), + if *value >= 0.0 { Step::Up } else { Step::Down }, + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_fall_into_equal_bins() { + let bins = bins(&[1.0, 2.0, 2.5, 9.0, 10.0], 5); + assert_eq!(bins.len(), 5); + assert_eq!((bins[0].0, bins[4].1), (0.0, 10.0)); + let counts: Vec = bins.iter().map(|bin| bin.2).collect(); + assert_eq!( + counts, + [1, 2, 0, 0, 2], + "the top edge counts in the last bin" + ); + } + + #[test] + fn five_numbers_and_outliers() { + let spread = five(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 100.0]); + assert_eq!((spread.q1, spread.median, spread.q3), (3.0, 5.0, 7.0)); + assert_eq!((spread.low, spread.high), (1.0, 8.0)); + assert_eq!(spread.outliers, [100.0]); + } + + #[test] + fn density_peaks_where_values_gather() { + let samples = density(&[0.0, 0.1, 0.2, 5.0], 50); + let peak = samples + .iter() + .max_by(|a, b| a.1.total_cmp(&b.1)) + .expect("samples"); + assert!( + peak.0 < 1.0, + "the peak sits near the cluster, at {}", + peak.0 + ); + } + + #[test] + fn a_waterfall_floats_changes_and_stands_totals_on_zero() { + let bars = steps(&[(100.0, false), (-30.0, false), (0.0, true), (20.0, false)]); + assert_eq!( + bars, + [ + (0.0, 100.0, Step::Up), + (70.0, 100.0, Step::Down), + (0.0, 70.0, Step::Total), + (70.0, 90.0, Step::Up) + ] + ); + } +} diff --git a/src/charts/sunburst.rs b/src/charts/sunburst.rs new file mode 100644 index 0000000..a59142d --- /dev/null +++ b/src/charts/sunburst.rs @@ -0,0 +1,276 @@ +use std::f32::consts::{FRAC_PI_2, TAU}; + +use gpui::{ + App, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, MouseMoveEvent, + ParentElement, Pixels, Refineable, RenderOnce, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Window, canvas, div, +}; + +use super::{ + paint::{finish, measure, tint}, + pie::{bearing, wedge}, + scale::compact, + tiles::Tiles, +}; +use crate::{ + theme::{ActiveTheme, TextSize}, + typography::{format, tabular}, +}; + +/// A node of a sunburst: its name, its own value, and the nodes inside it. +#[derive(Clone, Debug, PartialEq)] +pub struct Slice { + name: SharedString, + value: f64, + children: Vec, +} + +impl Slice { + pub fn new(name: impl Into, value: f64) -> Self { + assert!( + value.is_finite() && value >= 0.0, + "a slice needs a value of zero or more" + ); + Self { + name: name.into(), + value, + children: Vec::new(), + } + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } + + /// Its value, or its children's total when that is larger. + fn weight(&self) -> f64 { + self.value + .max(self.children.iter().map(Slice::weight).sum()) + } +} + +/// An arc of a sunburst: its ring, start and sweep, the top branch it belongs to, and its path of names. +#[derive(Clone, Debug, PartialEq)] +struct Arc { + depth: usize, + start: f32, + sweep: f32, + branch: usize, + path: Vec, + weight: f64, +} + +/// Arcs for a hierarchy, each child inside its parent's sweep. +fn rings(slices: &[Slice]) -> Vec { + fn walk( + slices: &[Slice], + (depth, start, sweep): (usize, f32, f32), + branch: Option, + trail: &[SharedString], + out: &mut Vec, + ) { + let total: f64 = slices.iter().map(Slice::weight).sum(); + let mut at = start; + for (ix, slice) in slices.iter().enumerate() { + let part = if total > 0.0 { + (slice.weight() / total) as f32 * sweep + } else { + 0.0 + }; + let (branch, mut path) = (branch.unwrap_or(ix), trail.to_vec()); + path.push(slice.name.clone()); + walk( + &slice.children, + (depth + 1, at, part), + Some(branch), + &path, + out, + ); + out.push(Arc { + depth, + start: at, + sweep: part, + branch, + path, + weight: slice.weight(), + }); + at += part; + } + } + let mut out = Vec::new(); + walk(slices, (0, -FRAC_PI_2, TAU), None, &[], &mut out); + out.sort_by_key(|arc| arc.depth); + out +} + +/// Parts of parts as rings around a center that holds the whole, each arc as wide as its share. Hover reads an arc's path and share. +#[derive(IntoElement)] +pub struct Sunburst { + base: Div, + id: ElementId, + slices: Vec, +} + +impl Sunburst { + pub fn new(id: impl Into, slices: impl IntoIterator) -> Self { + Self { + base: div(), + id: id.into(), + slices: slices.into_iter().collect(), + } + } +} + +impl Styled for Sunburst { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Sunburst { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let ring: Entity = + window.use_keyed_state((self.id.clone(), "ring"), cx, |_, _| Tiles::default()); + let arcs = rings(&self.slices); + let depths = arcs.iter().map(|arc| arc.depth + 1).max().unwrap_or(1); + let total: f64 = self.slices.iter().map(Slice::weight).sum(); + let hover = ring.read(cx).hover.filter(|ix| *ix < arcs.len()); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let bounds = ring.read(cx).bounds; + let outer = + (f32::from(bounds.size.width).min(f32::from(bounds.size.height)) / 2.0).max(0.0); + let hole = outer * 0.34; + let band = (outer - hole) / depths as f32; + let gap = f32::from(sizes.stroke.to_pixels(rem)); + let color = |arc: &Arc, lit: bool| { + let fade = if lit { + 1.0 + } else { + (1.0 - 0.22 * arc.depth as f32).max(0.4) + }; + tint(&colors, arc.branch).opacity(fade) + }; + let fills: Vec = arcs + .iter() + .enumerate() + .map(|(ix, arc)| color(arc, hover == Some(ix))) + .collect(); + let (big, small) = match hover.map(|ix| &arcs[ix]) { + Some(arc) => ( + format::percent(if total > 0.0 { arc.weight / total } else { 0.0 }, 1, false), + arc.path.join(" › "), + ), + None => (compact(total), "Total".to_string()), + }; + let middle = div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Lg)) + .text_color(colors.fg) + .child(big), + ) + .child( + div() + .max_w(Pixels::from(hole * 1.6)) + .truncate() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(small), + ); + let (moved, left, measured, hit) = (ring.clone(), ring.clone(), ring.clone(), arcs.clone()); + let painted = arcs.clone(); + let mut frame = div().relative().w_full().h(sizes.height); + frame.style().refine(self.base.style()); + frame + .id((self.id.clone(), "ring")) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let center = moved.read(cx).bounds.center(); + let at = (f32::from(event.position.x), f32::from(event.position.y)); + let (angle, distance) = bearing((f32::from(center.x), f32::from(center.y)), at); + let depth = ((distance - hole) / band).floor(); + let next = (depth >= 0.0 && distance <= outer).then(|| { + hit.iter().position(|arc| { + arc.depth == depth as usize + && angle >= arc.start + && angle < arc.start + arc.sweep + }) + }); + moved.update(cx, |ring, cx| { + let next = next.flatten(); + if ring.hover != next { + ring.hover = next; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |ring, cx| { + ring.hover = None; + cx.notify(); + }) + } + }) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let center = bounds.center(); + for (ix, arc) in painted.iter().enumerate() { + let from = hole + band * arc.depth as f32; + if let Some(path) = wedge( + center, + (from, from + band - gap), + (arc.start, arc.sweep), + gap, + ) { + finish(path, fills[ix], window); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .child(middle) + .child(measure(measured, |ring| &mut ring.bounds)) + } +} + +#[cfg(test)] +mod tests { + use std::f32::consts::{FRAC_PI_2, TAU}; + + use super::*; + + #[test] + fn rings_nest_children_inside_their_parent() { + let tree = [ + Slice::new("Design", 0.0).children([Slice::new("Brand", 1.0), Slice::new("Web", 3.0)]), + Slice::new("Ops", 4.0), + ]; + let arcs = rings(&tree); + assert_eq!(arcs.len(), 4); + assert_eq!( + (arcs[0].depth, arcs[0].sweep, arcs[0].weight), + (0, TAU / 2.0, 4.0), + "Design weighs its children" + ); + let web = arcs + .iter() + .find(|arc| arc.path.last() == Some(&"Web".into())) + .expect("Web"); + assert_eq!( + (web.depth, web.start, web.sweep, web.branch), + (1, -FRAC_PI_2 + TAU / 8.0, TAU * 3.0 / 8.0, 0) + ); + } +} diff --git a/src/charts/targets.rs b/src/charts/targets.rs new file mode 100644 index 0000000..d14b57f --- /dev/null +++ b/src/charts/targets.rs @@ -0,0 +1,447 @@ +use std::{ + f32::consts::{FRAC_PI_2, TAU}, + rc::Rc, +}; + +use gpui::{ + App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, Pixels, Refineable, + RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, point, + prelude::*, size, +}; + +use super::{ + axes::anchored, + geometry::Rect, + glide::Glide, + paint::{at, finish, measure, place, ring, tint}, + parts::ChartTooltip, + pie::{bearing, toward, wedge}, + plot::Format, + scale::{compact, nice}, + tiles::{Tiles, tracked}, +}; +use crate::{ + motion, + theme::{ActiveTheme, Density, Radius, TextSize}, + typography::{format, tabular}, +}; + +/// A measure against its target: its value, the target it aims for, and the bands from poor to good it falls in. +#[derive(Clone, Debug, PartialEq)] +pub struct Bullet { + label: SharedString, + value: f64, + target: f64, + bands: Vec, +} + +impl Bullet { + pub fn new(label: impl Into, value: f64, target: f64) -> Self { + assert!( + value.is_finite() && target.is_finite() && value >= 0.0 && target >= 0.0, + "a bullet reads values of zero or more" + ); + Self { + label: label.into(), + value, + target, + bands: Vec::new(), + } + } + + /// Where each band ends, rising: poor, then fair, then good. + pub fn bands(mut self, ends: impl IntoIterator) -> Self { + self.bands = ends.into_iter().collect(); + assert!( + self.bands.windows(2).all(|pair| pair[0] < pair[1]) + && self.bands.iter().all(|end| *end > 0.0), + "bands end in rising order" + ); + self + } + + /// The top of this bullet's scale: past its value, its target and its last band. + fn top(&self) -> f64 { + let most = self + .bands + .iter() + .copied() + .fold(self.value.max(self.target), f64::max); + nice(0.0, most.max(f64::EPSILON), 4).0.1 + } +} + +/// Measures against their targets, a row each: a thin bar for the value over bands from poor to good, and a mark at the target. Hover a row to read it. +#[derive(IntoElement)] +pub struct BulletChart { + base: Div, + id: ElementId, + bullets: Vec, + format: Format, +} + +impl BulletChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + bullets: Vec::new(), + format: Rc::new(compact), + } + } + + pub fn bullet(mut self, bullet: Bullet) -> Self { + self.bullets.push(bullet); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for BulletChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for BulletChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let (names, row, inset, count) = ( + pixels(theme.label_width()), + pixels(theme.table_row(Density::Comfortable)), + pixels(sizes.inset), + self.bullets.len(), + ); + let length = (f32::from(bounds.size.width) - names - inset).max(0.0); + let format = self.format.clone(); + let labels: Vec
= self + .bullets + .iter() + .enumerate() + .map(|(ix, bullet)| { + let reading = format!("{} / {}", format(bullet.value), format(bullet.target)); + div() + .absolute() + .left_0() + .top(Pixels::from(row * ix as f32)) + .w(Pixels::from(names)) + .h(Pixels::from(row)) + .flex() + .flex_col() + .justify_center() + .pr_3() + .child( + div() + .truncate() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(bullet.label.clone()), + ) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(reading), + ) + }) + .collect(); + let tooltip = hover.map(|ix| { + let bullet = &self.bullets[ix]; + let reach = if bullet.target > 0.0 { + format::percent(bullet.value / bullet.target, 0, false) + } else { + "—".into() + }; + let card = ChartTooltip::new(bullet.label.clone()) + .row(Some(tint(&colors, 0)), "Value", format(bullet.value)) + .row(None, "Target", format(bullet.target)) + .row(None, "Of target", reach); + let x = names + length * (bullet.value / bullet.top()) as f32; + anchored( + (x, row * ix as f32 + row / 2.0), + x > f32::from(bounds.size.width) * 0.6, + card, + ) + }); + let rows: Vec<(Vec, f32, f32)> = self + .bullets + .iter() + .map(|bullet| { + let share = |value: f64| (value / bullet.top()) as f32; + ( + bullet.bands.iter().map(|end| share(*end)).collect(), + share(bullet.value), + share(bullet.target), + ) + }) + .collect(); + let (stroke, corner, palette) = ( + sizes.stroke.to_pixels(rem), + theme.radius(Radius::Sm).to_pixels(rem), + colors.clone(), + ); + let mut root = div() + .relative() + .w_full() + .h(theme.table_row(Density::Comfortable) * count as f32); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "bullets").into(), + &tiles, + move |(_, y)| { + let at = (y / row).floor(); + (at >= 0.0 && (at as usize) < count).then_some(at as usize) + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let origin = bounds.origin; + for (ix, (bands, value, target)) in rows.iter().enumerate() { + let top = row * ix as f32; + let band = |share: f32| Rect { + x: names, + y: top + row * 0.2, + w: length * share, + h: row * 0.6, + }; + for (step, end) in bands.iter().enumerate().rev() { + let strength = 0.04 + 0.04 * (bands.len() - 1 - step) as f32; + window.paint_quad( + fill(place(origin, band(*end)), palette.fg.opacity(strength)) + .corner_radii(corner), + ); + } + let bar = Rect { + x: names, + y: top + row * 0.38, + w: length * value, + h: row * 0.24, + }; + let ink = if hover.is_none_or(|on| on == ix) { + tint(&palette, 0) + } else { + tint(&palette, 0).opacity(0.5) + }; + window.paint_quad(fill(place(origin, bar), ink).corner_radii(corner)); + let mark = Bounds::new( + at( + origin, + ( + names + length * target - f32::from(stroke) / 2.0, + top + row * 0.14, + ), + ), + size(stroke, Pixels::from(row * 0.72)), + ); + window.paint_quad(fill(mark, palette.fg)); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +/// Goals as rings around one center, each filling clockwise toward its target; the center reads the goal under the pointer, or the first. +#[derive(IntoElement)] +pub struct ProgressChart { + base: Div, + id: ElementId, + goals: Vec<(SharedString, f64, f64)>, + format: Format, +} + +impl ProgressChart { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + goals: Vec::new(), + format: Rc::new(compact), + } + } + + /// A goal: how far it has come of its target. + pub fn goal(mut self, name: impl Into, value: f64, target: f64) -> Self { + assert!( + value.is_finite() && value >= 0.0 && target.is_finite() && target > 0.0, + "a goal has a positive target and a value of zero or more" + ); + self.goals.push((name.into(), value, target)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for ProgressChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for ProgressChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let rings: Entity = + window.use_keyed_state((self.id.clone(), "rings"), cx, |_, _| Tiles::default()); + let glide: Entity = + window.use_keyed_state((self.id.clone(), "glide"), cx, |_, _| Glide::default()); + let shares: Vec = self + .goals + .iter() + .map(|(_, value, target)| value / target) + .collect(); + let slow = motion::duration(motion::SLOW, cx); + let (glided, moving) = glide.update(cx, |glide, _| { + glide.follow(std::slice::from_ref(&shares), slow) + }); + if moving { + window.request_animation_frame(); + } + let glided = glided.into_iter().next().expect("one row of shares"); + let (bounds, hover) = (rings.read(cx).bounds, rings.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let side = f32::from(bounds.size.width).min(f32::from(bounds.size.height)); + let count = self.goals.len(); + let gap = f32::from(sizes.stroke.to_pixels(rem)) * 2.0; + let thick = (side / 2.0 / count.max(1) as f32 - gap) + .min(side * 0.09) + .max(0.0); + let radius = move |ix: usize| side / 2.0 - thick / 2.0 - (thick + gap) * ix as f32; + let shown = hover.unwrap_or(0); + let format = self.format.clone(); + let middle = self.goals.get(shown).map(|(name, _, _)| { + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Lg)) + .text_color(colors.fg) + .child(format::percent(shares[shown], 0, false)), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(name.clone()), + ) + }); + let legend = self + .goals + .iter() + .enumerate() + .map(|(ix, (name, value, target))| { + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .when(hover.is_some_and(|on| on != ix), |row| row.opacity(0.5)) + .child( + div() + .size(theme.status_dot()) + .rounded_full() + .bg(tint(&colors, ix)), + ) + .child(div().text_color(colors.fg).child(name.clone())) + .child(tabular(div()).text_color(colors.fg_muted).child(format!( + "{} / {}", + format(*value), + format(*target) + ))) + }); + let tooltip = hover.map(|ix| { + let (name, value, target) = &self.goals[ix]; + let card = ChartTooltip::new(name.clone()) + .row( + Some(tint(&colors, ix)), + "Done", + format::percent(value / target, 0, false), + ) + .row(None, "Of", format(*target)); + anchored((side / 2.0 + radius(ix), side / 2.0), false, card) + }); + let palette = colors.clone(); + let mut root = div().flex().items_center().gap_8(); + root.style().refine(self.base.style()); + let dial = tracked( + div().relative().flex_none().size(sizes.height * 0.7), + (self.id.clone(), "rings").into(), + &rings, + move |place| { + let (_, distance) = bearing((side / 2.0, side / 2.0), place); + (0..count).find(|ix| (distance - radius(*ix)).abs() <= thick / 2.0 + gap / 2.0) + }, + ); + root.child( + dial.child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let center = bounds.origin + + point(Pixels::from(side / 2.0), Pixels::from(side / 2.0)); + for (ix, share) in glided.iter().enumerate() { + let (middle, ink) = (radius(ix), tint(&palette, ix)); + let ink = if hover.is_some_and(|on| on != ix) { + ink.opacity(0.35) + } else { + ink + }; + let band = (middle - thick / 2.0, middle + thick / 2.0); + if let Some(track) = wedge(center, band, (-FRAC_PI_2, TAU), 0.0) { + finish(track, palette.fg.opacity(0.06), window); + } + let sweep = (*share as f32).clamp(0.0, 1.0) * TAU; + if sweep <= 0.0 { + continue; + } + if let Some(arc) = wedge(center, band, (-FRAC_PI_2, sweep), 0.0) { + finish(arc, ink, window); + } + for angle in [-FRAC_PI_2, -FRAC_PI_2 + sweep] { + ring( + toward(center, middle, angle), + Pixels::from(thick / 2.0), + (ink, ink), + Pixels::ZERO, + window, + ); + } + } + }, + ) + .absolute() + .inset_0(), + ) + .children(middle) + .children(tooltip) + .child(measure(rings, |rings| &mut rings.bounds)), + ) + .child(div().flex().flex_col().gap_2().children(legend)) + } +} diff --git a/src/charts/tests.rs b/src/charts/tests.rs new file mode 100644 index 0000000..8f102f9 --- /dev/null +++ b/src/charts/tests.rs @@ -0,0 +1,29 @@ +use gpui::TestAppContext; + +use super::{BarChart, LineChart, Series}; +use crate::theme::Theme; + +#[gpui::test] +fn a_chart_exports_as_svg_and_csv(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + let line = LineChart::new("sales", ["Jan", "Feb", "Mar"]) + .series(Series::new("Sales, net", [1.0, 3.0, 2.0])) + .smooth(); + let svg = line.svg(400.0, 200.0, cx); + assert!(svg.starts_with("")); + assert_eq!(svg.matches("Feb"), "categories label the axis"); + assert_eq!(line.csv(), ",Jan,Feb,Mar\n\"Sales, net\",1,3,2"); + let bars = BarChart::new("share", ["A", "B"]) + .series(Series::new("Now", [2.0, 4.0])) + .series(Series::new("Before", [1.0, 3.0])); + let svg = bars.svg(400.0, 200.0, cx); + assert_eq!( + svg.matches(", + pub hover: Option, +} + +/// Keeps `tiles` answering the pointer: `find` names the tile at a place in the box. +pub(crate) fn tracked( + root: Div, + id: ElementId, + tiles: &Entity, + find: impl Fn((f32, f32)) -> Option + 'static, +) -> gpui::Stateful
{ + let (moved, left) = (tiles.clone(), tiles.clone()); + root.id(id) + .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { + let offset = event.position - moved.read(cx).bounds.origin; + let next = find((f32::from(offset.x), f32::from(offset.y))); + moved.update(cx, |tiles, cx| { + if tiles.hover != next { + tiles.hover = next; + cx.notify(); + } + }) + }) + .on_hover(move |inside, _, cx| { + if !*inside { + left.update(cx, |tiles, cx| { + tiles.hover = None; + cx.notify(); + }) + } + }) +} + +/// A key of five steps from none to most, between two words. +pub(crate) fn key(steps: [Hsla; 5], (less, more): (&'static str, &'static str), cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_end() + .gap_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(less) + .children(steps.map(|step| { + div() + .size(theme.status_dot() * 1.5) + .rounded(theme.radius(Radius::Sm)) + .bg(step) + })) + .child(more) +} + +/// Parts of a whole as tiles whose areas compare, laid out close to square, largest first. Hover reads one. +#[derive(IntoElement)] +pub struct Treemap { + base: Div, + id: ElementId, + tiles: Vec<(SharedString, f64)>, + format: Format, +} + +impl Treemap { + pub fn new(id: impl Into) -> Self { + Self { + base: div(), + id: id.into(), + tiles: Vec::new(), + format: Rc::new(compact), + } + } + + pub fn tile(mut self, name: impl Into, value: f64) -> Self { + assert!( + value.is_finite() && value >= 0.0, + "a tile needs a value of zero or more" + ); + self.tiles.push((name.into(), value)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for Treemap { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Treemap { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "tiles"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let mut order: Vec = (0..self.tiles.len()).collect(); + order.sort_by(|a, b| self.tiles[*b].1.total_cmp(&self.tiles[*a].1)); + let whole = Rect { + x: 0.0, + y: 0.0, + w: f32::from(bounds.size.width), + h: f32::from(bounds.size.height), + }; + let laid = squarify( + &order.iter().map(|ix| self.tiles[*ix].1).collect::>(), + whole, + ); + let mut rects = vec![Rect::default(); self.tiles.len()]; + let gap = pixels(sizes.stroke) / 2.0; + for (slot, ix) in order.iter().enumerate() { + let rect = laid[slot]; + rects[*ix] = Rect { + x: rect.x + gap, + y: rect.y + gap, + w: (rect.w - gap * 2.0).max(0.0), + h: (rect.h - gap * 2.0).max(0.0), + }; + } + let total: f64 = self.tiles.iter().map(|(_, value)| value).sum(); + let (room, format) = (pixels(sizes.label), self.format.clone()); + let labels: Vec
= rects + .iter() + .zip(&self.tiles) + .filter(|(rect, _)| rect.w >= room && rect.h >= room / 2.0) + .map(|(rect, (name, value))| { + div() + .absolute() + .left(Pixels::from(rect.x)) + .top(Pixels::from(rect.y)) + .w(Pixels::from(rect.w)) + .h(Pixels::from(rect.h)) + .overflow_hidden() + .p_2() + .text_size(theme.text_size(TextSize::Xs)) + .child(div().truncate().text_color(colors.fg).child(name.clone())) + .child( + tabular(div()) + .text_color(colors.fg_muted) + .child(format(*value)), + ) + }) + .collect(); + let tooltip = hover.map(|ix| { + let rect = rects[ix]; + let share = if total > 0.0 { + self.tiles[ix].1 / total + } else { + 0.0 + }; + let card = ChartTooltip::new(self.tiles[ix].0.clone()) + .row( + Some(tint(&colors, ix)), + "Share", + format::percent(share, 1, false), + ) + .row(None, "Value", format(self.tiles[ix].1)); + let x = rect.x + rect.w; + anchored( + (x.min(whole.w), rect.y + rect.h / 2.0), + x > whole.w * 0.6, + card, + ) + }); + let (corner, hairline, painted, hit) = ( + theme.radius(Radius::Sm).to_pixels(rem), + sizes.hairline, + rects.clone(), + rects, + ); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + tracked( + root, + (self.id.clone(), "treemap").into(), + &tiles, + move |at| hit.iter().position(|rect| rect.contains(at)), + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for (ix, rect) in painted.iter().enumerate() { + let ink = tint(&colors, ix); + let strength = if hover == Some(ix) { 0.34 } else { 0.2 }; + let tile = fill(place(bounds.origin, *rect), ink.opacity(strength)) + .corner_radii(corner); + window.paint_quad( + tile.border_widths(hairline).border_color(ink.opacity(0.6)), + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)) + } +} + +/// A grid of values, a row per label down the side and a column per label along the bottom, each cell as strong as its value. Hover reads a cell. +#[derive(IntoElement)] +pub struct HeatmapChart { + base: Div, + id: ElementId, + columns: Vec, + rows: Vec<(SharedString, Vec)>, + format: Format, +} + +impl HeatmapChart { + pub fn new( + id: impl Into, + columns: impl IntoIterator>, + ) -> Self { + let columns = columns.into_iter().map(Into::into).collect(); + Self { + base: div(), + id: id.into(), + columns, + rows: Vec::new(), + format: Rc::new(compact), + } + } + + /// A row of values, one for each column. + pub fn row( + mut self, + label: impl Into, + values: impl IntoIterator, + ) -> Self { + let values: Vec = values.into_iter().collect(); + assert_eq!( + values.len(), + self.columns.len(), + "a heatmap row needs a value per column" + ); + assert!( + values.iter().all(|value| value.is_finite()), + "a heatmap needs finite values" + ); + self.rows.push((label.into(), values)); + self + } + + pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { + self.format = Rc::new(format); + self + } +} + +impl Styled for HeatmapChart { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +/// Five steps of one ink, faint to full, for a key. +fn steps(ink: Hsla) -> [Hsla; 5] { + [0.08, 0.3, 0.52, 0.76, 1.0].map(|strength| ink.opacity(strength)) +} + +impl RenderOnce for HeatmapChart { + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tiles: Entity = + window.use_keyed_state((self.id.clone(), "tiles"), cx, |_, _| Tiles::default()); + let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover); + let theme = cx.theme(); + let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size()); + let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem)); + let size = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let rect = frame( + size, + pixels(sizes.gutter), + pixels(sizes.foot), + pixels(sizes.inset), + ); + let (columns, rows) = (self.columns.len().max(1), self.rows.len().max(1)); + let (cell_w, cell_h) = (rect.w / columns as f32, rect.h / rows as f32); + let cell = |row: usize, column: usize| Rect { + x: rect.x + cell_w * column as f32, + y: rect.y + cell_h * row as f32, + w: cell_w, + h: cell_h, + }; + let (low, high) = self + .rows + .iter() + .flat_map(|(_, values)| values) + .fold((f64::MAX, f64::MIN), |(low, high), value| { + (low.min(*value), high.max(*value)) + }); + let ink = tint(&colors, 0); + let strength = move |value: f64| { + if high > low { + 0.08 + 0.92 * ((value - low) / (high - low)) as f32 + } else { + 0.5 + } + }; + let text = |content: SharedString| { + div() + .flex_none() + .whitespace_nowrap() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(content) + }; + let every = ((self.columns.len() as f32 * pixels(sizes.label) / rect.w.max(1.0)).ceil() + as usize) + .max(1); + let column_labels = self + .columns + .iter() + .enumerate() + .filter(|(ix, _)| ix % every == 0) + .map(|(ix, label)| { + below( + rect.x + cell_w * (ix as f32 + 0.5), + rect, + text(label.clone()), + ) + }); + let row_labels = self.rows.iter().enumerate().map(|(ix, (label, _))| { + beside( + rect.y + cell_h * (ix as f32 + 0.5), + sizes.gutter, + text(label.clone()), + ) + }); + let format = self.format.clone(); + let tooltip = hover.map(|at| { + let (row, column) = (at / columns, at % columns); + let place = cell(row, column); + let card = + ChartTooltip::new(format!("{} · {}", self.rows[row].0, self.columns[column])).row( + Some(ink.opacity(strength(self.rows[row].1[column]))), + "Value", + format(self.rows[row].1[column]), + ); + anchored( + (place.x + place.w, place.y + place.h / 2.0), + place.x > size.0 * 0.6, + card, + ) + }); + let cells: Vec<(Rect, Hsla)> = self + .rows + .iter() + .enumerate() + .flat_map(|(row, (_, values))| { + values + .iter() + .enumerate() + .map(move |(column, value)| (row, column, *value)) + }) + .map(|(row, column, value)| (cell(row, column), ink.opacity(strength(value)))) + .collect(); + let (gap, corner, edge, hairline) = ( + pixels(sizes.stroke) / 2.0, + theme.radius(Radius::Sm).to_pixels(rem), + colors.fg, + sizes.hairline, + ); + let legend = key(steps(ink), ("Less", "More"), cx); + let mut root = div().relative().w_full().h(sizes.height); + root.style().refine(self.base.style()); + let chart = tracked( + div().relative().flex_1().min_h_0(), + (self.id.clone(), "heatmap").into(), + &tiles, + move |at| { + rect.contains(at).then(|| { + let (row, column) = ( + ((at.1 - rect.y) / cell_h) as usize, + ((at.0 - rect.x) / cell_w) as usize, + ); + row.min(rows - 1) * columns + column.min(columns - 1) + }) + }, + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for (ix, (cell, color)) in cells.iter().enumerate() { + let inner = Rect { + x: cell.x + gap, + y: cell.y + gap, + w: (cell.w - gap * 2.0).max(0.0), + h: (cell.h - gap * 2.0).max(0.0), + }; + let quad = fill(place(bounds.origin, inner), *color).corner_radii(corner); + window.paint_quad(if hover == Some(ix) { + quad.border_widths(hairline).border_color(edge) + } else { + quad + }); + } + }, + ) + .absolute() + .inset_0(), + ) + .children(column_labels) + .children(row_labels) + .children(tooltip) + .child(measure(tiles, |tiles| &mut tiles.bounds)); + root.flex().flex_col().gap_2().child(chart).child(legend) + } +} diff --git a/src/lib.rs b/src/lib.rs index 60c9aa5..02cff06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod assets; pub mod buttons; +pub mod charts; pub mod data_display; pub mod feedback; pub mod forms; diff --git a/src/theme/chart.rs b/src/theme/chart.rs new file mode 100644 index 0000000..09fa2bc --- /dev/null +++ b/src/theme/chart.rs @@ -0,0 +1,41 @@ +use gpui::{Pixels, Rems, px}; + +use super::{Theme, tokens::px_to_rems}; + +/// A chart's measures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChartSizes { + /// A chart's height, legend and axes included. + pub height: Rems, + /// Room beside a plot for its value labels. + pub gutter: Rems, + /// Room under a plot for its category labels. + pub foot: Rems, + /// Room a plot keeps above and to its right, so dots and strokes stay whole. + pub inset: Rems, + /// The least room a category label takes along its axis; closer labels thin out. + pub label: Rems, + pub stroke: Rems, + /// Widest a bubble's radius grows. + pub bubble: Rems, + /// How far a slice under the pointer lifts out. + pub lift: Rems, + /// Gridlines and rules. + pub hairline: Pixels, +} + +impl Theme { + pub fn chart(&self) -> ChartSizes { + ChartSizes { + height: px_to_rems(280.0), + gutter: px_to_rems(48.0), + foot: px_to_rems(28.0), + inset: px_to_rems(8.0), + label: px_to_rems(64.0), + stroke: px_to_rems(2.0), + bubble: px_to_rems(24.0), + lift: px_to_rems(6.0), + hairline: px(1.0), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index fe76f3d..4262726 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -1,3 +1,4 @@ +mod chart; mod palette; mod tokens; @@ -6,6 +7,7 @@ use std::time::{Duration, Instant}; use gpui::{App, Global, SharedString, WindowAppearance}; use crate::motion; +pub use chart::ChartSizes; pub use palette::{Mix, Palette, Syntax}; pub use tokens::{ AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize, diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 1a1cc28..4ba19dd 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -75,7 +75,7 @@ pub enum Elevation { Modal, } -fn px_to_rems(value: f32) -> Rems { +pub(super) fn px_to_rems(value: f32) -> Rems { rems(value / 16.0) } diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 3c896bf..51f09b0 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -149,40 +149,46 @@ - [x] TreeTable → `tables::TreeTable`, `TreeRow` - [x] PivotTable → `tables::PivotTable` -## T15 Charts — `src/charts` +## T15a Charts · Cartesian — `src/charts` -- [ ] LineChart -- [ ] AreaChart / StackedArea -- [ ] BarChart -- [ ] PieChart / DonutChart -- [ ] ScatterChart -- [ ] BubbleChart -- [ ] RadarChart -- [ ] HeatmapChart -- [ ] CalendarHeatmap -- [ ] Treemap -- [ ] Sunburst -- [ ] SankeyChart -- [ ] FunnelChart -- [ ] WaterfallChart -- [ ] BoxPlot -- [ ] Histogram -- [ ] ViolinPlot -- [ ] GanttChart -- [ ] NetworkGraph / ForceGraph -- [ ] ChordDiagram -- [ ] ParallelCoordinates +- [x] LineChart +- [x] AreaChart / StackedArea → `charts::AreaChart::stacked` +- [x] BarChart +- [x] ScatterChart +- [x] BubbleChart → `charts::ScatterChart` with `Points::sizes` - [x] Sparkline / SparkBar / SparkArea → `data_display::Sparkline`, landed with T14a for table cells -- [ ] Bullet Chart -- [ ] ProgressChart -- [ ] RealtimeChart -- [ ] ChartLegend -- [ ] ChartTooltip -- [ ] ChartCrosshair -- [ ] ChartAxis / ChartGrid -- [ ] ChartZoom / Brush -- [ ] ChartAnnotation -- [ ] ChartExport — SVG and CSV +- [x] RealtimeChart +- [x] ChartLegend +- [x] ChartTooltip +- [x] ChartCrosshair → the hover of `LineChart`, `AreaChart` and `BarChart` +- [x] ChartAxis / ChartGrid → the axes every cartesian chart draws +- [x] ChartZoom / Brush → `LineChart::zoom` +- [x] ChartAnnotation → `rule` and `note` +- [x] ChartExport — SVG and CSV → `svg` and `csv` on each cartesian chart + +## T15b Charts · Parts and spreads + +- [x] PieChart / DonutChart → `charts::PieChart::donut` +- [x] RadarChart +- [x] HeatmapChart +- [x] CalendarHeatmap +- [x] Treemap +- [x] Sunburst +- [x] WaterfallChart +- [x] BoxPlot +- [x] Histogram +- [x] ViolinPlot + +## T15c Charts · Flows and relations + +- [x] SankeyChart +- [x] FunnelChart +- [x] GanttChart +- [x] NetworkGraph / ForceGraph → `charts::NetworkGraph` +- [x] ChordDiagram +- [x] ParallelCoordinates +- [x] Bullet Chart → `charts::BulletChart` +- [x] ProgressChart ## T16a Finance · Market charts — `src/finance`