273 lines
9.6 KiB
Rust
273 lines
9.6 KiB
Rust
use gpui::{
|
|
App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels,
|
|
Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, size,
|
|
};
|
|
use jiff::{ToSpan, civil::Date};
|
|
|
|
use super::{
|
|
axes::{anchored, below, beside},
|
|
geometry::Rect,
|
|
paint::{at, finish, measure, tint, trace},
|
|
parts::ChartTooltip,
|
|
scale::{Linear, compact, nice},
|
|
schedule::{days, marks},
|
|
series::drawable,
|
|
tiles::{Tiles, tracked},
|
|
};
|
|
use crate::{
|
|
theme::{ActiveTheme, TextSize},
|
|
typography::tabular,
|
|
};
|
|
|
|
/// Where a burndown's lines and levels fall in its frame.
|
|
#[derive(Debug, PartialEq)]
|
|
pub(crate) struct Burn {
|
|
pub ideal: [(f32, f32); 2],
|
|
pub left: Vec<(f32, f32)>,
|
|
pub ticks: Vec<(f32, f64)>,
|
|
pub step: f32,
|
|
}
|
|
|
|
/// Lays a sprint of `count` days, `scope` at the start and `left` at the end of each day so far, into `frame`.
|
|
pub(crate) fn burn(count: usize, scope: f64, left: &[f64], frame: Rect) -> Burn {
|
|
let high = left.iter().copied().fold(scope, f64::max);
|
|
let ((low, high), ticks) = nice(0.0, high, 4);
|
|
let y = Linear::new((low, high), (frame.y + frame.h, frame.y));
|
|
let step = frame.w / (count - 1) as f32;
|
|
let x = |ix: usize| frame.x + step * ix as f32;
|
|
Burn {
|
|
ideal: [(x(0), y.at(scope)), (x(count - 1), y.at(0.0))],
|
|
left: left
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(ix, value)| (x(ix), y.at(*value)))
|
|
.collect(),
|
|
ticks: ticks.iter().map(|tick| (y.at(*tick), *tick)).collect(),
|
|
step,
|
|
}
|
|
}
|
|
|
|
/// Work left over a sprint, a day at a time: the ideal line from the scope down to nothing on the last day, what was left at each day's end so far, and a line at today. Hover a day to read it.
|
|
#[derive(IntoElement)]
|
|
pub struct BurndownChart {
|
|
base: Div,
|
|
id: ElementId,
|
|
first: Date,
|
|
last: Date,
|
|
scope: f64,
|
|
left: Vec<f64>,
|
|
today: Option<Date>,
|
|
}
|
|
|
|
impl BurndownChart {
|
|
/// A sprint from its first day through its last, with `scope` to do, in points or issues.
|
|
pub fn new(id: impl Into<ElementId>, first: Date, last: Date, scope: f64) -> Self {
|
|
assert!(last > first, "a sprint ends after its first day");
|
|
assert!(
|
|
drawable(scope) && scope > 0.0,
|
|
"a sprint has work to do, within charts::LIMIT"
|
|
);
|
|
Self {
|
|
base: div(),
|
|
id: id.into(),
|
|
first,
|
|
last,
|
|
scope,
|
|
left: Vec::new(),
|
|
today: None,
|
|
}
|
|
}
|
|
|
|
/// What was left at the end of each day from the first, as far as the sprint has gone.
|
|
pub fn left(mut self, left: impl IntoIterator<Item = f64>) -> Self {
|
|
self.left = left.into_iter().collect();
|
|
let count = days(self.first, self.last) as usize + 1;
|
|
assert!(
|
|
self.left.len() <= count,
|
|
"more days left than the sprint has"
|
|
);
|
|
assert!(
|
|
self.left
|
|
.iter()
|
|
.all(|value| drawable(*value) && *value >= 0.0),
|
|
"work left is a count, zero or more, within charts::LIMIT"
|
|
);
|
|
self
|
|
}
|
|
|
|
/// Marks this day with a line.
|
|
pub fn today(mut self, today: Date) -> Self {
|
|
self.today = Some(today);
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Styled for BurndownChart {
|
|
fn style(&mut self) -> &mut StyleRefinement {
|
|
self.base.style()
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for BurndownChart {
|
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let count = days(self.first, self.last) as usize + 1;
|
|
let tiles: Entity<Tiles> =
|
|
window.use_keyed_state((self.id.clone(), "days"), cx, |_, _| Tiles::default());
|
|
let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).pointed(count));
|
|
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 (gutter, foot, inset) = (
|
|
pixels(sizes.gutter),
|
|
pixels(sizes.foot),
|
|
pixels(sizes.inset),
|
|
);
|
|
let frame = Rect {
|
|
x: gutter,
|
|
y: inset,
|
|
w: (f32::from(bounds.size.width) - gutter - inset).max(0.0),
|
|
h: (pixels(sizes.height) - inset - foot).max(0.0),
|
|
};
|
|
let burn = burn(count, self.scope, &self.left, frame);
|
|
let label = |text: String| {
|
|
tabular(div())
|
|
.whitespace_nowrap()
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(colors.fg_subtle)
|
|
.child(text)
|
|
};
|
|
let levels: Vec<Div> = burn
|
|
.ticks
|
|
.iter()
|
|
.map(|(y, value)| beside(*y, sizes.gutter.to_pixels(rem), label(compact(*value))))
|
|
.collect();
|
|
let dates: Vec<Div> = marks(self.first, count as i32)
|
|
.into_iter()
|
|
.map(|(offset, text)| below(frame.x + burn.step * offset as f32, frame, label(text)))
|
|
.collect();
|
|
let x_of = |ix: usize| frame.x + burn.step * ix as f32;
|
|
let today = self
|
|
.today
|
|
.map(|today| days(self.first, today))
|
|
.filter(|offset| (0..count as i32).contains(offset))
|
|
.map(|offset| x_of(offset as usize));
|
|
let tooltip = hover.map(|ix| {
|
|
let day = self
|
|
.first
|
|
.checked_add((ix as i64).days())
|
|
.expect("a day of the sprint");
|
|
let ideal = self.scope * (1.0 - ix as f64 / (count - 1) as f64);
|
|
let mut card = ChartTooltip::new(day.strftime("%a %b %-d").to_string());
|
|
if let Some(left) = self.left.get(ix) {
|
|
card = card.row(Some(tint(&colors, 0)), "Left", compact(*left));
|
|
}
|
|
card = card.row(None, "Ideal", compact(ideal));
|
|
let y = burn
|
|
.left
|
|
.get(ix)
|
|
.map_or(frame.y + frame.h / 2.0, |point| point.1);
|
|
anchored((x_of(ix), y), x_of(ix) > frame.x + frame.w * 0.6, card)
|
|
});
|
|
let (stroke, hairline, palette) =
|
|
(sizes.stroke.to_pixels(rem), sizes.hairline, colors.clone());
|
|
let points = burn.left.clone();
|
|
let (ideal, levels_at) = (burn.ideal, burn.ticks.clone());
|
|
let pointed = hover.map(x_of);
|
|
let (step, first_x) = (burn.step, frame.x);
|
|
let mut root = div().relative().h(sizes.height);
|
|
root.style().refine(self.base.style());
|
|
tracked(
|
|
root,
|
|
(self.id.clone(), "burndown").into(),
|
|
&tiles,
|
|
move |(x, _)| {
|
|
let ix = ((x - first_x) / step).round();
|
|
(ix >= 0.0 && (ix as usize) < count).then_some(ix as usize)
|
|
},
|
|
)
|
|
.child(
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, _| {
|
|
let origin = bounds.origin;
|
|
for (y, _) in &levels_at {
|
|
window.paint_quad(fill(
|
|
Bounds::new(
|
|
at(origin, (frame.x, *y)),
|
|
size(Pixels::from(frame.w), hairline),
|
|
),
|
|
palette.border.opacity(0.5),
|
|
));
|
|
}
|
|
for x in pointed.iter().chain(today.iter()) {
|
|
let ink = match Some(*x) == today {
|
|
true => tint(&palette, 3),
|
|
false => palette.border_strong,
|
|
};
|
|
window.paint_quad(fill(
|
|
Bounds::new(
|
|
at(origin, (*x, frame.y)),
|
|
size(stroke / 2.0, Pixels::from(frame.h)),
|
|
),
|
|
ink,
|
|
));
|
|
}
|
|
let mut line = PathBuilder::stroke(stroke / 2.0);
|
|
trace(&mut line, &ideal, false, origin);
|
|
finish(line, palette.fg_subtle, window);
|
|
if points.len() > 1 {
|
|
let mut line = PathBuilder::stroke(stroke);
|
|
trace(&mut line, &points, false, origin);
|
|
finish(line, tint(&palette, 0), window);
|
|
}
|
|
},
|
|
)
|
|
.absolute()
|
|
.inset_0(),
|
|
)
|
|
.children(levels)
|
|
.children(dates)
|
|
.children(tooltip)
|
|
.child(measure(tiles, |tiles| &mut tiles.bounds))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Rect, burn};
|
|
|
|
#[test]
|
|
fn the_ideal_runs_from_the_scope_to_nothing_and_the_days_step_evenly() {
|
|
let frame = Rect {
|
|
x: 40.0,
|
|
y: 0.0,
|
|
w: 90.0,
|
|
h: 100.0,
|
|
};
|
|
let burn = burn(10, 20.0, &[20.0, 18.0, 18.0, 11.0], frame);
|
|
assert_eq!(burn.step, 10.0);
|
|
assert_eq!(burn.ideal, [(40.0, 0.0), (130.0, 100.0)]);
|
|
assert_eq!(
|
|
burn.left.iter().map(|point| point.0).collect::<Vec<_>>(),
|
|
[40.0, 50.0, 60.0, 70.0]
|
|
);
|
|
assert_eq!(
|
|
burn.left[3].1, 45.0,
|
|
"11 of 20 left sits at 55 from the floor"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn work_that_grew_past_the_scope_raises_the_top() {
|
|
let frame = Rect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
w: 10.0,
|
|
h: 100.0,
|
|
};
|
|
let burn = burn(2, 20.0, &[26.0], frame);
|
|
let top = burn.ticks.last().expect("levels").1;
|
|
assert!(top >= 26.0, "the scale reaches {top}");
|
|
}
|
|
}
|