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