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)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 21:59:24 -04:00
1 parent e3f0ae8a1c
commit cbd49e99d3
44 files changed
+8639 -35

No files matched your search

+232
View File
@@ -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<f64> = (0..12)
.map(|month| 40.0 + month as f64 * 4.0 + rng() * 12.0)
.collect();
let costs: Vec<f64> = (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::<Vec<_>>(),
)
};
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::<Vec<_>>(),
)
};
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::<Vec<f64>>(),
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}")),
)))
}
+170
View File
@@ -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))
}
+83
View File
@@ -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()
}
+53
View File
@@ -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<f64> = (0..240).map(|_| normal(320.0, 120.0).max(40.0)).collect();
let groups: Vec<(&str, Vec<f64>)> = [("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"),
),
)
}
+142
View File
@@ -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::<Vec<_>>(),
)
});
let mut rng = noise(37);
let commits: Vec<f64> = (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))))
}
+2
View File
@@ -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,
];
+6
View File
@@ -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)?;