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:
44 files changed
+8639
-35
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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}")),
|
||||
)))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use gpui::{App, Hsla, Rems, Rgba, SharedString};
|
||||
|
||||
use super::{
|
||||
geometry::{Geometry, Ink, Rect, frame},
|
||||
series::tangents,
|
||||
};
|
||||
use crate::theme::{ActiveTheme, Palette, Radius, TextSize};
|
||||
|
||||
/// An SVG page: its size, the plot's frame, and the theme's colors and measures at the base rem.
|
||||
pub(crate) struct Sheet {
|
||||
pub size: (f32, f32),
|
||||
pub frame: Rect,
|
||||
stroke: f32,
|
||||
hairline: f32,
|
||||
corner: f32,
|
||||
text: f32,
|
||||
font: SharedString,
|
||||
colors: Palette,
|
||||
horizontal: bool,
|
||||
smooth: bool,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
pub(crate) fn new(size: (f32, f32), horizontal: bool, smooth: bool, cx: &App) -> Self {
|
||||
let theme = cx.theme();
|
||||
let (sizes, rem) = (theme.chart(), theme.base_rem());
|
||||
let pixels = |length: Rems| f32::from(length.to_pixels(rem));
|
||||
Self {
|
||||
size,
|
||||
frame: frame(
|
||||
size,
|
||||
pixels(sizes.gutter),
|
||||
pixels(sizes.foot),
|
||||
pixels(sizes.inset),
|
||||
),
|
||||
stroke: pixels(sizes.stroke),
|
||||
hairline: f32::from(sizes.hairline),
|
||||
corner: pixels(theme.radius(Radius::Sm)),
|
||||
text: pixels(theme.text_size(TextSize::Xs)),
|
||||
font: theme.font_family.clone(),
|
||||
colors: theme.colors.clone(),
|
||||
horizontal,
|
||||
smooth,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A color as SVG writes it, and its opacity apart.
|
||||
fn hex(color: Hsla) -> (String, f32) {
|
||||
let rgba: Rgba = color.into();
|
||||
let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
(
|
||||
format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
byte(rgba.r),
|
||||
byte(rgba.g),
|
||||
byte(rgba.b)
|
||||
),
|
||||
rgba.a,
|
||||
)
|
||||
}
|
||||
|
||||
/// Text safe inside SVG.
|
||||
fn escaped(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// A path's commands through `points`, straight or smooth.
|
||||
fn path(points: &[(f32, f32)], smooth: bool) -> String {
|
||||
let mut d = String::new();
|
||||
let Some((x, y)) = points.first() else {
|
||||
return d;
|
||||
};
|
||||
write!(d, "M{x:.1},{y:.1}").expect("a string takes writes");
|
||||
if !smooth || points.len() < 3 {
|
||||
for (x, y) in &points[1..] {
|
||||
write!(d, " L{x:.1},{y:.1}").expect("a string takes writes");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
let slopes = tangents(points);
|
||||
for ix in 0..points.len() - 1 {
|
||||
let ((x0, y0), (x1, y1)) = (points[ix], points[ix + 1]);
|
||||
let third = (x1 - x0) / 3.0;
|
||||
let (ax, ay, bx, by) = (
|
||||
x0 + third,
|
||||
y0 + slopes[ix] * third,
|
||||
x1 - third,
|
||||
y1 - slopes[ix + 1] * third,
|
||||
);
|
||||
write!(d, " C{ax:.1},{ay:.1} {bx:.1},{by:.1} {x1:.1},{y1:.1}")
|
||||
.expect("a string takes writes");
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A chart as a standalone SVG drawing: gridlines, marks and axis labels.
|
||||
pub(crate) fn svg(geometry: &Geometry, sheet: &Sheet, format: &dyn Fn(f64) -> String) -> String {
|
||||
let (colors, frame, (width, height)) = (&sheet.colors, sheet.frame, sheet.size);
|
||||
let ink = |ink: Ink| hex(ink.color(colors)).0;
|
||||
let (grid, _) = hex(colors.border);
|
||||
let (label, _) = hex(colors.fg_subtle);
|
||||
let (back, _) = hex(colors.bg);
|
||||
let (gap, drop) = (sheet.text * 0.75, sheet.text * 1.6);
|
||||
let (below, beside) = (frame.y + frame.h + drop, frame.x - gap);
|
||||
let mut out = format!(
|
||||
r#"<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&D <net>");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ¬es {
|
||||
window.paint_quad(fill(upright(*x), colors.fg_subtle.opacity(0.6)));
|
||||
}
|
||||
match &focus {
|
||||
Some(Focus::Line(x, points)) => {
|
||||
window.paint_quad(fill(upright(*x), colors.border_strong));
|
||||
for (ink, point) in points {
|
||||
let ends = (colors.bg, ink.color(colors));
|
||||
ring(
|
||||
at(origin, *point),
|
||||
pen.stroke * 2.0,
|
||||
ends,
|
||||
pen.stroke,
|
||||
window,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Focus::Dot(dot)) => {
|
||||
let ends = (dot.ink.color(colors), colors.bg);
|
||||
ring(
|
||||
at(origin, dot.center),
|
||||
Pixels::from(dot.radius),
|
||||
ends,
|
||||
pen.stroke,
|
||||
window,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some((a, b)) = brush {
|
||||
let band = Rect {
|
||||
x: a.min(b),
|
||||
y: rect.y,
|
||||
w: (a - b).abs(),
|
||||
h: rect.h,
|
||||
};
|
||||
window.paint_quad(fill(place(origin, band), colors.selection));
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0()
|
||||
}
|
||||
|
||||
/// A place in window pixels as the plot's own.
|
||||
fn local(position: Point<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
mod assets;
|
||||
|
||||
pub mod buttons;
|
||||
pub mod charts;
|
||||
pub mod data_display;
|
||||
pub mod feedback;
|
||||
pub mod forms;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user