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

+3
View File
@@ -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
+6 -1
View File
@@ -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 |
+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)?;
+1 -1
View File
@@ -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
+175
View File
@@ -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<Div>,
pub rules: Vec<f32>,
pub notes: Vec<f32>,
}
/// 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<Div> = 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(),
}
}
+194
View File
@@ -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<f64>,
format: Format,
}
impl CalendarHeatmap {
/// A count for each day from `start` on.
pub fn new(
id: impl Into<ElementId>,
start: Date,
counts: impl IntoIterator<Item = f64>,
) -> Self {
let counts: Vec<f64> = 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<Tiles> =
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::<Vec<_>>();
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)
}
}
+412
View File
@@ -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<SharedString>,
pub series: Vec<Series>,
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<SharedString>, 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<SharedString> {
self.series
.iter()
.map(|series| series.name.clone())
.collect()
}
fn values(&self) -> Vec<Vec<f64>> {
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<ElementId>,
labels: impl IntoIterator<Item = impl Into<SharedString>>,
) -> 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<SharedString>) -> 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<SharedString>) -> 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<Points>,
axes: (SharedString, SharedString),
format: Format,
}
impl ScatterChart {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>, y: impl Into<SharedString>) -> 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));
}
}
+406
View File
@@ -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<SharedString>, 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<f64>,
bins: usize,
}
impl Histogram {
pub fn new(id: impl Into<ElementId>, values: impl IntoIterator<Item = f64>) -> Self {
let values: Vec<f64> = 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<ElementId>) -> 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<SharedString>, 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<SharedString>) -> Self {
self.steps.push((label.into(), 0.0, true));
self
}
fn plot(self) -> Plot {
let labels: Vec<SharedString> = self
.steps
.iter()
.map(|(label, _, _)| label.clone())
.collect();
let bars = Rc::new(steps(
&self
.steps
.iter()
.map(|(_, change, total)| (*change, *total))
.collect::<Vec<_>>(),
));
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<f64>)>,
}
/// 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<f64>)>,
}
macro_rules! grouped {
($name:ident) => {
impl $name {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
groups: Vec::new(),
}
}
/// A named group of values.
pub fn group(
mut self,
label: impl Into<SharedString>,
values: impl IntoIterator<Item = f64>,
) -> Self {
let values: Vec<f64> = 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, 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<Vec<(SharedString, Vec<f64>)>>) -> 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<SharedString> =
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<SharedString> =
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<Vec<(f64, f64)>> = 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);
+230
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" font-family="{}, sans-serif" font-size="{}">"#,
escaped(&sheet.font),
sheet.text
);
let mut put = |text: String| out.push_str(&text);
put(format!(
r#"<rect width="{width}" height="{height}" fill="{back}"/>"#
));
let text = |x: f32, y: f32, anchor: &str, words: &str| {
format!(
r#"<text x="{x:.1}" y="{y:.1}" fill="{label}" text-anchor="{anchor}">{}</text>"#,
escaped(words)
)
};
let middle = sheet.text * 0.35;
for (at, value) in &geometry.ticks {
let words = format(*value);
if sheet.horizontal {
put(format!(
r#"<line x1="{at:.1}" y1="{:.1}" x2="{at:.1}" y2="{:.1}" stroke="{grid}" stroke-width="{}"/>"#,
frame.y,
frame.y + frame.h,
sheet.hairline
));
put(text(*at, below, "middle", &words));
} else {
put(format!(
r#"<line x1="{:.1}" y1="{at:.1}" x2="{:.1}" y2="{at:.1}" stroke="{grid}" stroke-width="{}"/>"#,
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 d="{}{back} Z" fill="{}" fill-opacity="0.14"/>"#,
path(&line.points, sheet.smooth),
ink(line.ink)
));
}
put(format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="{}" stroke-linejoin="round"/>"#,
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="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="{radius:.1}" fill="{}"/>"#,
rect.x,
rect.y,
rect.w,
rect.h,
ink(*color)
));
}
for (color, outline) in &geometry.shapes {
put(format!(
r#"<path d="{} Z" fill="{fill}" fill-opacity="0.3" stroke="{fill}" stroke-width="{}"/>"#,
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#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{}" stroke-width="{width}"/>"#,
ink(*color)
));
}
for dot in &geometry.dots {
let (x, y) = dot.center;
put(format!(
r#"<circle cx="{x:.1}" cy="{y:.1}" r="{:.1}" fill="{fill}" fill-opacity="0.6" stroke="{fill}" stroke-width="{}"/>"#,
dot.radius,
sheet.hairline,
fill = ink(dot.ink)
));
}
out.push_str("</svg>");
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 <net>"), "R&amp;D &lt;net&gt;");
}
}
+448
View File
@@ -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<Pixels>,
hover: Option<Hover>,
}
#[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<Rect>, Vec<Ribbon>) {
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<f64> = (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::<f64>(),
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::<f32>()
+ 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<Ribbon> = 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<usize> = (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<SharedString>,
links: Vec<(usize, usize, f64)>,
format: Format,
}
impl SankeyChart {
pub fn new(
id: impl Into<ElementId>,
nodes: impl IntoIterator<Item = impl Into<SharedString>>,
) -> 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<Flows> =
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<f32> = (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<Div> = 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<usize> = 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");
}
}
+261
View File
@@ -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<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
stages: Vec::new(),
format: Rc::new(compact),
}
}
pub fn stage(mut self, name: impl Into<SharedString>, 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<Rect> {
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<Tiles> =
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<f64> = 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<Div> = 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<Div> = 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));
}
}
+410
View File
@@ -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<Vec<(f32, f32)>>,
}
/// 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<Line>,
pub bars: Vec<(Ink, Rect)>,
pub dots: Vec<Dot>,
/// 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<f64>],
pub mark: Mark,
pub stacked: bool,
pub horizontal: bool,
/// The share of each band bars leave empty.
pub gap: f32,
pub hidden: &'a HashSet<SharedString>,
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<SharedString>,
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<f64>],
hidden: &'a HashSet<SharedString>,
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<f64>> {
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<SharedString> = ["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<f32> = 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<SharedString> = ["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"
);
}
}
+93
View File
@@ -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<Vec<f64>>,
to: Vec<Vec<f64>>,
since: Option<Instant>,
}
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<f64>],
length: Duration,
) -> (Vec<Vec<f64>>, bool) {
let share = |since: Option<Instant>| {
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<f64>], to: &[Vec<f64>], share: f32) -> Vec<Vec<f64>> {
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<f64>], b: &[Vec<f64>]) -> 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"
);
}
}
+135
View File
@@ -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<Rect> {
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)
);
}
}
+47
View File
@@ -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};
+399
View File
@@ -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<usize>,
}
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<Pixels>,
shape: (usize, Vec<(usize, usize)>),
force: Option<Force>,
hover: Option<usize>,
drag: Option<usize>,
}
/// 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<ElementId>) -> 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<SharedString>, 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<Graph> =
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<usize> = (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<f32> = degree
.iter()
.map(|degree| dot * (1.0 + (*degree as f32).sqrt() * 0.35))
.collect();
let near: HashSet<usize> = 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<Div> = 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<usize> = 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<Pixels>, bounds: Bounds<Pixels>| {
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");
}
}
+232
View File
@@ -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<Pixels>, (x, y): (f32, f32)) -> Point<Pixels> {
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<Pixels>, rect: Rect) -> Bounds<Pixels> {
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<T: 'static>(
state: Entity<T>,
slot: fn(&mut T) -> &mut Bounds<Pixels>,
) -> 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<Pixels>,
) {
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<Pixels>,
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<Pixels>,
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,
);
}
}
+295
View File
@@ -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<f32>,
pub scales: Vec<Linear>,
pub lines: Vec<Vec<(f32, f32)>>,
}
/// Records as lines across parallel axes, one axis per measure, each with its own rounded scale.
pub(crate) fn parallel(records: &[Vec<f64>], axes: usize, frame: Rect) -> Parallel {
let xs: Vec<f32> = (0..axes)
.map(|ix| frame.x + frame.w * ix as f32 / (axes - 1).max(1) as f32)
.collect();
let scales: Vec<Linear> = (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<SharedString>,
records: Vec<(SharedString, Vec<f64>)>,
format: Format,
}
impl ParallelCoordinates {
pub fn new(
id: impl Into<ElementId>,
axes: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let axes: Vec<SharedString> = 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<SharedString>,
values: impl IntoIterator<Item = f64>,
) -> Self {
let values: Vec<f64> = 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<Tiles> =
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<Vec<f64>> = 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<Div> = 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<Div> = 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);
}
}
+138
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<OnToggle>,
}
impl ChartLegend {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>, 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<Hsla>, SharedString, SharedString)>,
}
impl ChartTooltip {
pub fn new(title: impl Into<SharedString>) -> 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<Hsla>,
name: impl Into<SharedString>,
value: impl Into<SharedString>,
) -> 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))
}))
}
}
+397
View File
@@ -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<usize> {
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<Pixels>, radius: f32, angle: f32) -> Point<Pixels> {
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<Pixels>,
(inner, outer): (f32, f32),
(start, sweep): (f32, f32),
gap: f32,
) -> Option<PathBuilder> {
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<usize>,
off: Option<usize>,
since: Option<Instant>,
}
impl Lift {
/// Moves to `next`; whether anything changed.
fn point(&mut self, next: Option<usize>) -> 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<f32>, 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<f32> {
(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<Pixels>,
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<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
slices: Vec::new(),
center: None,
}
}
pub fn slice(mut self, name: impl Into<SharedString>, 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<SharedString>, label: impl Into<SharedString>) -> 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<Ring> =
window.use_keyed_state((self.id.clone(), "ring"), cx, |_, _| Ring::default());
let raw: Vec<f64> = 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<Hsla> = 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]);
}
}
+237
View File
@@ -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<dyn Fn(f64) -> String>;
/// Lays out a plot's marks inside its frame.
pub(crate) type Layout = Rc<dyn Fn(&Scene) -> Geometry>;
/// A picked index's tooltip: its title, then each row's dot if any, name and value.
pub(crate) type Tips =
Rc<dyn Fn(usize, &Scene) -> (SharedString, Vec<(Option<Ink>, 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<f64>],
pub hidden: &'a HashSet<SharedString>,
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<SharedString>,
/// The series the legend lists and can hide; one or none shows no legend.
pub names: Vec<SharedString>,
/// Values that glide when the owner changes them; the layout reads them from its scene.
pub values: Vec<Vec<f64>>,
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<Pixels>,
pub hover: Option<usize>,
pub pointer: (f32, f32),
pub hidden: HashSet<SharedString>,
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<State> =
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)
}
+324
View File
@@ -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<usize> {
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<usize>,
(rect, span): (Rect, (usize, usize)),
) -> Option<Focus> {
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<Geometry>,
pen: Pen,
rect: Rect,
(focus, brush): (Option<Focus>, Option<(f32, f32)>),
(rules, notes): (Vec<f32>, Vec<f32>),
) -> 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 &notes {
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<Pixels>, bounds: Bounds<Pixels>) -> (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<Div>,
state: &Entity<State>,
(pick, horizontal, zoomable): (Pick, bool, bool),
(rect, span, reach): (Rect, (usize, usize), f32),
geometry: Rc<Geometry>,
) -> Stateful<Div> {
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"
);
}
}
+362
View File
@@ -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<Pixels>,
hover: Option<usize>,
hidden: HashSet<SharedString>,
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<SharedString>,
series: Vec<Series>,
format: Format,
}
impl RadarChart {
pub fn new(
id: impl Into<ElementId>,
axes: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let axes: Vec<SharedString> = 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<Radar> =
window.use_keyed_state((self.id.clone(), "radar"), cx, |_, _| Radar::default());
let values: Vec<Vec<f64>> = 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<usize> = (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<f32>)> = shown
.iter()
.map(|ix| {
(
*ix,
glided[*ix]
.iter()
.map(|value| (*value / top) as f32)
.collect(),
)
})
.collect();
let levels: Vec<f32> = 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<Pixels> {
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"
);
}
}
+181
View File
@@ -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<Pixels>,
seen: Option<u64>,
since: Option<Instant>,
}
/// 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<f64>,
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<ElementId>,
values: impl IntoIterator<Item = f64>,
pushed: u64,
) -> Self {
let values: Vec<f64> = 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<Live> =
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::<Vec<_>>(),
);
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))
}
}
+294
View File
@@ -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<f64>], pad: f32) -> (Arcs, Vec<Arcs>) {
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<Pixels>,
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<SharedString>,
matrix: Vec<Vec<f64>>,
format: Format,
}
impl ChordDiagram {
/// Groups and what each sends to each: `matrix[from][to]`.
pub fn new(
id: impl Into<ElementId>,
names: impl IntoIterator<Item = impl Into<SharedString>>,
matrix: Vec<Vec<f64>>,
) -> Self {
let names: Vec<SharedString> = 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<Tiles> =
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<Div> = 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::<f64>(),
self.matrix.iter().map(|row| row[ix]).sum::<f64>(),
);
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<usize> = 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)));
}
}
+163
View File
@@ -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<f64>) {
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<usize> {
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);
}
}
+408
View File
@@ -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<usize>,
}
impl Task {
/// A task from its first day through its last.
pub fn new(name: impl Into<SharedString>, 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<Task>,
today: Option<Date>,
}
impl GanttChart {
pub fn new(id: impl Into<ElementId>) -> 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<Tiles> =
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<Rect> = 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<Div> = 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<Div> = 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<f32> = 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::<Vec<_>>(),
);
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::<Vec<_>>(),
[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::<Vec<_>>(),
["Feb", "Mar", "Apr"]
);
}
}
+222
View File
@@ -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<f64>,
}
impl Series {
pub fn new(name: impl Into<SharedString>, values: impl IntoIterator<Item = f64>) -> Self {
let values: Vec<f64> = 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<Vec<f64>>,
}
impl Points {
pub fn new(
name: impl Into<SharedString>,
points: impl IntoIterator<Item = (f64, f64)>,
) -> 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<Item = f64>) -> Self {
let sizes: Vec<f64> = 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<Vec<(f64, f64)>> {
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<Item = f64>) -> (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<f32> {
let count = points.len();
if count < 2 {
return vec![0.0; count];
}
let slopes: Vec<f32> = points
.windows(2)
.map(|pair| (pair[1].1 - pair[0].1) / (pair[1].0 - pair[0].0))
.collect();
let mut tangents: Vec<f32> = (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::<Vec<_>>()
.join(",");
let rows = series.iter().map(|series| {
std::iter::once(field(&series.name))
.chain(series.values.iter().map(|value| value.to_string()))
.collect::<Vec<_>>()
.join(",")
});
std::iter::once(head)
.chain(rows)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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");
}
}
+200
View File
@@ -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<f64>,
}
/// 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::<f64>() / n;
let spread = (values
.iter()
.map(|value| (value - mean).powi(2))
.sum::<f64>()
/ (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<usize> = 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)
]
);
}
}
+276
View File
@@ -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<Slice>,
}
impl Slice {
pub fn new(name: impl Into<SharedString>, 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<Item = Slice>) -> 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<SharedString>,
weight: f64,
}
/// Arcs for a hierarchy, each child inside its parent's sweep.
fn rings(slices: &[Slice]) -> Vec<Arc> {
fn walk(
slices: &[Slice],
(depth, start, sweep): (usize, f32, f32),
branch: Option<usize>,
trail: &[SharedString],
out: &mut Vec<Arc>,
) {
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<Slice>,
}
impl Sunburst {
pub fn new(id: impl Into<ElementId>, slices: impl IntoIterator<Item = Slice>) -> 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<Tiles> =
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<Hsla> = 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)
);
}
}
+447
View File
@@ -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<f64>,
}
impl Bullet {
pub fn new(label: impl Into<SharedString>, 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<Item = f64>) -> 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<Bullet>,
format: Format,
}
impl BulletChart {
pub fn new(id: impl Into<ElementId>) -> 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<Tiles> =
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<Div> = 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, 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<ElementId>) -> 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<SharedString>, 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<Tiles> =
window.use_keyed_state((self.id.clone(), "rings"), cx, |_, _| Tiles::default());
let glide: Entity<Glide> =
window.use_keyed_state((self.id.clone(), "glide"), cx, |_, _| Glide::default());
let shares: Vec<f64> = 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))
}
}
+29
View File
@@ -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("<svg") && svg.ends_with("</svg>"));
assert_eq!(svg.matches("<path").count(), 1, "one line, no area");
assert!(svg.contains(" C"), "a smooth line curves");
assert!(svg.contains(">Feb</text>"), "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("<rect").count(),
5,
"the page, then a bar per value"
);
});
}
+443
View File
@@ -0,0 +1,443 @@
use std::rc::Rc;
use gpui::{
App, Bounds, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, MouseMoveEvent,
ParentElement, Pixels, Refineable, RenderOnce, SharedString, StatefulInteractiveElement,
StyleRefinement, Styled, Window, canvas, div, fill,
};
use super::{
axes::{anchored, below, beside},
geometry::{Rect, frame},
layout::squarify,
paint::{measure, place, tint},
parts::ChartTooltip,
plot::Format,
scale::compact,
};
use crate::{
theme::{ActiveTheme, Radius, TextSize},
typography::{format, tabular},
};
/// A chart's own state when the pointer picks one part: its box and the part under the pointer.
#[derive(Default)]
pub(crate) struct Tiles {
pub bounds: Bounds<Pixels>,
pub hover: Option<usize>,
}
/// 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<Tiles>,
find: impl Fn((f32, f32)) -> Option<usize> + 'static,
) -> gpui::Stateful<Div> {
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<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
tiles: Vec::new(),
format: Rc::new(compact),
}
}
pub fn tile(mut self, name: impl Into<SharedString>, 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<Tiles> =
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<usize> = (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::<Vec<_>>(),
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<Div> = 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<SharedString>,
rows: Vec<(SharedString, Vec<f64>)>,
format: Format,
}
impl HeatmapChart {
pub fn new(
id: impl Into<ElementId>,
columns: impl IntoIterator<Item = impl Into<SharedString>>,
) -> 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<SharedString>,
values: impl IntoIterator<Item = f64>,
) -> Self {
let values: Vec<f64> = 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<Tiles> =
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)
}
}
+1
View File
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
pub mod charts;
pub mod data_display;
pub mod feedback;
pub mod forms;
+41
View File
@@ -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),
}
}
}
+2
View File
@@ -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,
+1 -1
View File
@@ -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)
}
+38 -32
View File
@@ -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`