T15 Charts: cartesian with axes, legend, crosshair, tooltip, zoom, annotations and export (T15a); pie, donut, radar, heatmaps, treemap, sunburst, waterfall and spreads (T15b); sankey, funnel, gantt, force graph, chord, parallel coordinates, bullet and progress (T15c)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 21:59:24 -04:00
1 parent e3f0ae8a1c
commit cbd49e99d3
44 files changed
+8639 -35

No files matched your search

+408
View File
@@ -0,0 +1,408 @@
use gpui::{
App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels,
Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, size,
};
use jiff::{
Span,
civil::{Date, Weekday},
};
use super::{
axes::anchored,
geometry::Rect,
paint::{at, finish, measure, place, tint},
parts::ChartTooltip,
tiles::{Tiles, tracked},
};
use crate::{
theme::{ActiveTheme, Density, Radius, TextSize},
typography::format,
};
/// A task on a schedule: its name, first and last day, how much is done, and the tasks it waits for.
#[derive(Clone, Debug, PartialEq)]
pub struct Task {
name: SharedString,
start: Date,
end: Date,
done: f32,
after: Vec<usize>,
}
impl Task {
/// A task from its first day through its last.
pub fn new(name: impl Into<SharedString>, start: Date, end: Date) -> Self {
assert!(end >= start, "a task ends on or after its first day");
Self {
name: name.into(),
start,
end,
done: 0.0,
after: Vec::new(),
}
}
/// How much is done, zero to one.
pub fn done(mut self, share: f32) -> Self {
assert!((0.0..=1.0).contains(&share), "done is a share, zero to one");
self.done = share;
self
}
/// Waits for the task at `ix`; an arrow runs from its end.
pub fn after(mut self, ix: usize) -> Self {
self.after.push(ix);
self
}
}
/// Whole days from `from` to `to`.
fn days(from: Date, to: Date) -> i32 {
to.since(from)
.expect("dates within jiff's range")
.get_days()
}
/// Days to mark along a span: each month's first day when the span is long, each Monday when it is short.
fn marks(first: Date, count: i32) -> Vec<(i32, String)> {
let long = count > 62;
(0..count)
.filter_map(|offset| {
let day = first
.checked_add(Span::new().days(offset))
.expect("a day within the schedule");
let marked = if long {
day.day() == 1
} else {
day.weekday() == Weekday::Monday
};
marked.then(|| {
(
offset,
day.strftime(if long { "%b" } else { "%b %-d" }).to_string(),
)
})
})
.collect()
}
/// Tasks on a timeline, a row each: bars from first day to last, filled as far as they are done, with arrows from the tasks they wait for and a line at today. Hover a row to read it.
#[derive(IntoElement)]
pub struct GanttChart {
base: Div,
id: ElementId,
tasks: Vec<Task>,
today: Option<Date>,
}
impl GanttChart {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
tasks: Vec::new(),
today: None,
}
}
/// A task; the ones it waits for come before it.
pub fn task(mut self, task: Task) -> Self {
assert!(
task.after.iter().all(|ix| *ix < self.tasks.len()),
"task {} waits for one not yet given",
task.name
);
self.tasks.push(task);
self
}
/// Marks this day with a line.
pub fn today(mut self, today: Date) -> Self {
self.today = Some(today);
self
}
}
impl Styled for GanttChart {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for GanttChart {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let tiles: Entity<Tiles> =
window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| Tiles::default());
let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).hover);
let theme = cx.theme();
let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size());
let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem));
let count = self.tasks.len();
let first = self.tasks.iter().map(|task| task.start).min();
let last = self.tasks.iter().map(|task| task.end).max();
let span = first
.zip(last)
.map_or(1, |(first, last)| days(first, last) + 1);
let (names, axis, row, inset) = (
pixels(theme.label_width()),
pixels(sizes.foot),
pixels(theme.table_row(Density::Compact)),
pixels(sizes.inset),
);
let day = (f32::from(bounds.size.width) - names - inset).max(0.0) / span as f32;
let x_of = move |offset: i32| names + day * offset as f32;
let bars: Vec<Rect> = self
.tasks
.iter()
.enumerate()
.map(|(ix, task)| {
let from = first.map_or(0, |first| days(first, task.start));
Rect {
x: x_of(from),
y: axis + row * ix as f32 + row * 0.22,
w: day * (days(task.start, task.end) + 1) as f32,
h: row * 0.56,
}
})
.collect();
let text = |content: String, color| {
div()
.flex_none()
.whitespace_nowrap()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color)
.child(content)
};
let ticks = first.map_or_else(Vec::new, |first| marks(first, span));
let tick_labels: Vec<Div> = ticks
.iter()
.map(|(offset, label)| {
div()
.absolute()
.left(Pixels::from(x_of(*offset)))
.top_0()
.h(Pixels::from(axis))
.flex()
.items_center()
.pl_1()
.child(text(label.clone(), colors.fg_subtle))
})
.collect();
let name_labels: Vec<Div> = self
.tasks
.iter()
.enumerate()
.map(|(ix, task)| {
let label = div()
.truncate()
.text_size(theme.text_size(TextSize::Sm))
.text_color(if hover == Some(ix) {
colors.fg
} else {
colors.fg_muted
})
.child(task.name.clone());
div()
.absolute()
.left_0()
.top(Pixels::from(axis + row * ix as f32))
.w(Pixels::from(names))
.h(Pixels::from(row))
.flex()
.items_center()
.pr_3()
.child(label)
})
.collect();
let today = self
.today
.zip(first)
.map(|(today, first)| x_of(days(first, today)) + day / 2.0)
.filter(|x| *x >= names);
let tooltip = hover.map(|ix| {
let (task, bar) = (&self.tasks[ix], bars[ix]);
let dates = format!(
"{} – {}",
task.start.strftime("%b %-d"),
task.end.strftime("%b %-d")
);
let card = ChartTooltip::new(task.name.clone())
.row(Some(tint(&colors, 0)), "Dates", dates)
.row(None, "Days", (days(task.start, task.end) + 1).to_string())
.row(
None,
"Done",
format::percent(f64::from(task.done), 0, false),
);
let x = (bar.x + bar.w).min(f32::from(bounds.size.width));
anchored(
(x, bar.y + bar.h / 2.0),
x > f32::from(bounds.size.width) * 0.6,
card,
)
});
let links: Vec<(Rect, Rect)> = self
.tasks
.iter()
.enumerate()
.flat_map(|(ix, task)| task.after.iter().map(move |before| (*before, ix)))
.map(|(before, ix)| (bars[before], bars[ix]))
.collect();
let done: Vec<f32> = self.tasks.iter().map(|task| task.done).collect();
let (stroke, hairline, corner, palette) = (
sizes.stroke.to_pixels(rem),
sizes.hairline,
theme.radius(Radius::Sm).to_pixels(rem),
colors.clone(),
);
let (width, grid) = (
f32::from(bounds.size.width),
ticks
.iter()
.map(|(offset, _)| x_of(*offset))
.collect::<Vec<_>>(),
);
let painted = bars.clone();
let mut root = div()
.relative()
.w_full()
.h(sizes.foot + theme.table_row(Density::Compact) * count as f32);
root.style().refine(self.base.style());
tracked(
root,
(self.id.clone(), "gantt").into(),
&tiles,
move |(_, y)| {
let at = ((y - axis) / row).floor();
(at >= 0.0 && (at as usize) < count).then_some(at as usize)
},
)
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let origin = bounds.origin;
let bottom = axis + row * count as f32;
if let Some(ix) = hover {
window.paint_quad(fill(
place(
origin,
Rect {
x: 0.0,
y: axis + row * ix as f32,
w: width,
h: row,
},
),
palette.hover,
));
}
for x in &grid {
window.paint_quad(fill(
Bounds::new(
at(origin, (*x, axis)),
size(hairline, Pixels::from(bottom - axis)),
),
palette.border.opacity(0.5),
));
}
window.paint_quad(fill(
Bounds::new(
at(origin, (names, axis)),
size(Pixels::from(width - names), hairline),
),
palette.border,
));
let ink = tint(&palette, 0);
for (bar, share) in painted.iter().zip(&done) {
window.paint_quad(
fill(place(origin, *bar), ink.opacity(0.22)).corner_radii(corner),
);
window.paint_quad(
fill(
place(
origin,
Rect {
w: bar.w * share,
..*bar
},
),
ink,
)
.corner_radii(corner),
);
}
let tip = f32::from(stroke) * 2.0;
for (from, to) in &links {
let (start, end) = (
(from.x + from.w, from.y + from.h / 2.0),
(to.x, to.y + to.h / 2.0),
);
let turn = start.0 + tip * 2.0;
let mut path = PathBuilder::stroke(hairline);
path.move_to(at(origin, start));
if turn + tip <= end.0 {
path.line_to(at(origin, (turn, start.1)));
path.line_to(at(origin, (turn, end.1)));
} else {
let between = from.y + from.h + (to.y - from.y - from.h) / 2.0;
path.line_to(at(origin, (turn, start.1)));
path.line_to(at(origin, (turn, between)));
path.line_to(at(origin, (end.0 - tip * 2.0, between)));
path.line_to(at(origin, (end.0 - tip * 2.0, end.1)));
}
path.line_to(at(origin, (end.0 - tip, end.1)));
finish(path, palette.fg_subtle, window);
let mut head = PathBuilder::fill();
head.move_to(at(origin, end));
head.line_to(at(origin, (end.0 - tip, end.1 - tip / 1.5)));
head.line_to(at(origin, (end.0 - tip, end.1 + tip / 1.5)));
head.close();
finish(head, palette.fg_subtle, window);
}
if let Some(x) = today {
window.paint_quad(fill(
Bounds::new(
at(origin, (x, axis)),
size(stroke / 2.0, Pixels::from(bottom - axis)),
),
tint(&palette, 3),
));
}
},
)
.absolute()
.inset_0(),
)
.children(tick_labels)
.children(name_labels)
.children(tooltip)
.child(measure(tiles, |tiles| &mut tiles.bounds))
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::{days, marks};
#[test]
fn short_spans_mark_mondays_and_long_ones_months() {
assert_eq!(days(date(2026, 3, 1), date(2026, 3, 15)), 14);
let weekly = marks(date(2026, 3, 1), 14);
assert_eq!(
weekly.iter().map(|mark| mark.0).collect::<Vec<_>>(),
[1, 8],
"the first of March 2026 is a Sunday"
);
let monthly = marks(date(2026, 1, 15), 90);
assert_eq!(
monthly
.iter()
.map(|mark| mark.1.as_str())
.collect::<Vec<_>>(),
["Feb", "Mar", "Apr"]
);
}
}