454 lines
16 KiB
Rust
454 lines
16 KiB
Rust
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).pointed(self.bullets.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 (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).pointed(self.goals.len()),
|
|
);
|
|
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))
|
|
}
|
|
}
|