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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user