use std::rc::Rc; use gpui::{ App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, }; use super::{ axes::anchored, geometry::Rect, paint::{at, finish, measure, tint}, parts::ChartTooltip, plot::Format, scale::{Linear, compact, nice}, tiles::{Tiles, tracked}, }; use crate::theme::{ActiveTheme, TextSize}; /// Parallel axes: where each stands, its scale, and each record's line across them. pub(crate) struct Parallel { pub xs: Vec, pub scales: Vec, pub lines: Vec>, } /// Records as lines across parallel axes, one axis per measure, each with its own rounded scale. pub(crate) fn parallel(records: &[Vec], axes: usize, frame: Rect) -> Parallel { let xs: Vec = (0..axes) .map(|ix| frame.x + frame.w * ix as f32 / (axes - 1).max(1) as f32) .collect(); let scales: Vec = (0..axes) .map(|axis| { let (low, high) = records .iter() .map(|record| record[axis]) .fold((f64::MAX, f64::MIN), |(low, high), value| { (low.min(value), high.max(value)) }); let (domain, _) = if low <= high { nice(low, high, 4) } else { ((0.0, 1.0), Vec::new()) }; Linear::new(domain, (frame.y + frame.h, frame.y)) }) .collect(); let lines = records .iter() .map(|record| { record .iter() .enumerate() .map(|(axis, value)| (xs[axis], scales[axis].at(*value))) .collect() }) .collect(); Parallel { xs, scales, lines } } /// How far a place lies from a line through points. fn distance((x, y): (f32, f32), line: &[(f32, f32)]) -> f32 { line.windows(2) .map(|pair| { let ((ax, ay), (bx, by)) = (pair[0], pair[1]); let (dx, dy) = (bx - ax, by - ay); let share = (((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy).max(f32::EPSILON)) .clamp(0.0, 1.0); (x - ax - dx * share).hypot(y - ay - dy * share) }) .fold(f32::MAX, f32::min) } /// Records with many measures as lines across parallel axes, each axis on its own scale. Hover a line to read its record. #[derive(IntoElement)] pub struct ParallelCoordinates { base: Div, id: ElementId, axes: Vec, records: Vec<(SharedString, Vec)>, format: Format, } impl ParallelCoordinates { pub fn new( id: impl Into, axes: impl IntoIterator>, ) -> Self { let axes: Vec = axes.into_iter().map(Into::into).collect(); assert!(axes.len() >= 2, "parallel coordinates need two axes"); Self { base: div(), id: id.into(), axes, records: Vec::new(), format: Rc::new(compact), } } /// A record, a value for each axis. pub fn record( mut self, name: impl Into, values: impl IntoIterator, ) -> Self { let values: Vec = values.into_iter().collect(); assert_eq!( values.len(), self.axes.len(), "a record needs a value per axis" ); assert!( values.iter().all(|value| value.is_finite()), "a record needs finite values" ); self.records.push((name.into(), values)); self } pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self { self.format = Rc::new(format); self } } impl Styled for ParallelCoordinates { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for ParallelCoordinates { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tiles: Entity = window.use_keyed_state((self.id.clone(), "lines"), cx, |_, _| Tiles::default()); let (bounds, hover) = ( tiles.read(cx).bounds, tiles.read(cx).pointed(self.records.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 (side, top) = (pixels(sizes.gutter), pixels(sizes.foot)); let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); let frame = Rect { x: side, y: top, w: (width - side * 2.0).max(0.0), h: (height - top * 2.0).max(0.0), }; let values: Vec> = self .records .iter() .map(|(_, values)| values.clone()) .collect(); let Parallel { xs, scales, lines } = parallel(&values, self.axes.len(), frame); let format = self.format.clone(); let text = |content: String, color| { div() .flex_none() .whitespace_nowrap() .text_size(theme.text_size(TextSize::Xs)) .text_color(color) .child(content) }; let axis_labels: Vec
= xs .iter() .zip(&self.axes) .map(|(x, name)| { div() .absolute() .left(Pixels::from(*x)) .top_0() .w_0() .h(Pixels::from(top)) .flex() .justify_center() .items_center() .child(text(name.to_string(), colors.fg_muted)) }) .collect(); let ends: Vec
= xs .iter() .zip(&scales) .flat_map(|(x, scale)| { [ (scale.domain.1, frame.y), (scale.domain.0, frame.y + frame.h), ] .map(|(value, y)| (*x, value, y)) }) .map(|(x, value, y)| { div() .absolute() .left(Pixels::from(x)) .top(Pixels::from(y)) .h_0() .flex() .items_center() .pl_1p5() .child(text(format(value), colors.fg_subtle)) }) .collect(); let tooltip = hover.map(|ix| { let card = self.axes.iter().zip(&self.records[ix].1).fold( ChartTooltip::new(self.records[ix].0.clone()), |card, (axis, value)| card.row(None, axis.clone(), format(*value)), ); let (x, y) = lines[ix][lines[ix].len() / 2]; anchored((x, y), x > width * 0.6, card) }); let (stroke, hairline, palette, painted) = ( sizes.stroke.to_pixels(rem), sizes.hairline, colors.clone(), lines.clone(), ); let reach = pixels(sizes.inset); let mut root = div().relative().w_full().h(sizes.height); root.style().refine(self.base.style()); tracked( root, (self.id.clone(), "parallel").into(), &tiles, move |place| { lines .iter() .enumerate() .map(|(ix, line)| (ix, distance(place, line))) .filter(|(_, far)| *far <= reach) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(ix, _)| ix) }, ) .child( canvas( |_, _, _| {}, move |bounds, _, window, _| { let origin = bounds.origin; for x in &xs { let mut axis = PathBuilder::stroke(hairline); axis.move_to(at(origin, (*x, frame.y))); axis.line_to(at(origin, (*x, frame.y + frame.h))); finish(axis, palette.border_strong, window); } let order = (0..painted.len()) .filter(|ix| Some(*ix) != hover) .chain(hover); for ix in order { let ink = tint(&palette, ix); let (color, width) = match hover { None => (ink.opacity(0.7), stroke / 1.5), Some(on) if on == ix => (ink, stroke), Some(_) => (ink.opacity(0.15), stroke / 1.5), }; let mut path = PathBuilder::stroke(width); path.move_to(at(origin, painted[ix][0])); painted[ix][1..] .iter() .for_each(|point| path.line_to(at(origin, *point))); finish(path, color, window); } }, ) .absolute() .inset_0(), ) .children(axis_labels) .children(ends) .children(tooltip) .child(measure(tiles, |tiles| &mut tiles.bounds)) } } #[cfg(test)] mod tests { use super::*; #[test] fn parallel_axes_share_the_frame_and_lines_meet_their_values() { let frame = Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0, }; let Parallel { xs, scales, lines } = parallel(&[vec![0.0, 10.0], vec![100.0, 0.0]], 2, frame); assert_eq!(xs, [0.0, 100.0]); assert_eq!(scales[0].domain, (0.0, 100.0)); assert_eq!( lines[0], [(0.0, 100.0), (100.0, 0.0)], "the least sits low, the most high" ); assert_eq!(distance((50.0, 50.0), &lines[0]), 0.0); assert!(distance((0.0, 0.0), &lines[0]) > 60.0); } }