Skip to main content

chart/
view.rs

1//! A [`Chart`] drawn as one element: laid out once per data generation, size
2//! and text style, then painted every frame from what was kept.
3
4use std::{cell::Cell, collections::HashMap, f32::consts::FRAC_PI_2, rc::Rc};
5
6use gpui::{
7    App, Bounds, ContentMask, Corners, DispatchPhase, Element, ElementId, Font, GlobalElementId,
8    Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId, MouseMoveEvent, Path,
9    PathBuilder, Pixels, Point, Refineable, ShapedLine, SharedString, Style, StyleRefinement,
10    Styled, TextAlign, TextRun, Window, fill, point, px, relative, size,
11};
12use theme::{TextStyle, Theme};
13
14use crate::{
15    model::{Chart, Encoding, Mark},
16    plan::{self, Marks, Plan, Rect, Side, Slice, Tip},
17};
18
19/// The height a chart takes when nothing sizes it.
20const HEIGHT: f32 = 240.0;
21const LINE_WIDTH: f32 = 2.0;
22const POINT: f32 = 6.0;
23const HOVER_POINT: f32 = 8.0;
24const AREA_FILL: f32 = 0.25;
25const BAR_RADIUS: f32 = 2.0;
26/// How many straight segments draw a full turn of a slice's rim.
27const TURN_SEGMENTS: f32 = 128.0;
28const TIP_PAD: f32 = 8.0;
29const TIP_OFFSET: f32 = 12.0;
30const TIP_RADIUS: f32 = 6.0;
31const BAND_WASH: f32 = 0.05;
32
33/// `chart` as an element, full width and [`HEIGHT`] tall unless styled.
34/// `id` keeps its layout and hover across frames, so it must be stable.
35pub fn view(id: impl Into<ElementId>, chart: Chart) -> ChartView {
36    let mut style = StyleRefinement::default();
37    style.size.width = Some(relative(1.0).into());
38    style.size.height = Some(px(HEIGHT).into());
39    ChartView {
40        id: id.into(),
41        chart,
42        style,
43    }
44}
45
46pub struct ChartView {
47    id: ElementId,
48    chart: Chart,
49    style: StyleRefinement,
50}
51
52impl Styled for ChartView {
53    fn style(&mut self) -> &mut StyleRefinement {
54        &mut self.style
55    }
56}
57
58impl IntoElement for ChartView {
59    type Element = Self;
60
61    fn into_element(self) -> Self::Element {
62        self
63    }
64}
65
66/// What a plan was made from. Equal keys make equal plans.
67#[derive(Clone, PartialEq)]
68struct Key {
69    /// Each layer's data generation, mark and encoding.
70    layers: Vec<(u64, Mark, Encoding)>,
71    title: Option<SharedString>,
72    size: [f32; 2],
73    font: Font,
74    font_size: f32,
75    colors: [Hsla; 2],
76}
77
78/// A plan and everything painting it needs that does not change with it:
79/// tessellated paths at the chart's own origin, and shaped labels.
80struct Built {
81    key: Key,
82    plan: Plan,
83    /// Series, path, and whether it is a fill.
84    paths: Vec<(usize, Path<Pixels>, bool)>,
85    labels: HashMap<SharedString, ShapedLine>,
86    title: Option<ShapedLine>,
87    line: f32,
88}
89
90#[derive(Default)]
91struct State {
92    built: Option<Rc<Built>>,
93    /// The pointer, from the chart's origin.
94    hover: Rc<Cell<Option<[f32; 2]>>>,
95}
96
97pub struct Prepainted {
98    built: Option<Rc<Built>>,
99    hover: Rc<Cell<Option<[f32; 2]>>>,
100    hitbox: Hitbox,
101}
102
103impl Element for ChartView {
104    type RequestLayoutState = ();
105    type PrepaintState = Prepainted;
106
107    fn id(&self) -> Option<ElementId> {
108        Some(self.id.clone())
109    }
110
111    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
112        None
113    }
114
115    fn request_layout(
116        &mut self,
117        _id: Option<&GlobalElementId>,
118        _inspector_id: Option<&InspectorElementId>,
119        window: &mut Window,
120        cx: &mut App,
121    ) -> (LayoutId, ()) {
122        let mut style = Style::default();
123        style.refine(&self.style);
124        (window.request_layout(style, [], cx), ())
125    }
126
127    fn prepaint(
128        &mut self,
129        id: Option<&GlobalElementId>,
130        _inspector_id: Option<&InspectorElementId>,
131        bounds: Bounds<Pixels>,
132        _request_layout: &mut (),
133        window: &mut Window,
134        cx: &mut App,
135    ) -> Prepainted {
136        let theme = Theme::of(cx);
137        let key = Key {
138            layers: self
139                .chart
140                .layers
141                .iter()
142                .map(|layer| (layer.data.generation(), layer.mark, layer.encoding.clone()))
143                .collect(),
144            title: self.chart.title.clone(),
145            size: [bounds.size.width.as_f32(), bounds.size.height.as_f32()],
146            font: window.text_style().font(),
147            font_size: TextStyle::Caption.painted(),
148            colors: [theme.text, theme.text_muted],
149        };
150        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
151        let id = id.expect("a chart has an id");
152        window.with_element_state(id, |state: Option<State>, window| {
153            let mut state = state.unwrap_or_default();
154            if state.built.as_ref().is_none_or(|built| built.key != key) {
155                state.built = build(&self.chart, key, window).map(Rc::new);
156            }
157            let prepainted = Prepainted {
158                built: state.built.clone(),
159                hover: state.hover.clone(),
160                hitbox,
161            };
162            (prepainted, state)
163        })
164    }
165
166    fn paint(
167        &mut self,
168        _id: Option<&GlobalElementId>,
169        _inspector_id: Option<&InspectorElementId>,
170        bounds: Bounds<Pixels>,
171        _request_layout: &mut (),
172        prepainted: &mut Prepainted,
173        window: &mut Window,
174        cx: &mut App,
175    ) {
176        let Some(built) = prepainted.built.clone() else {
177            return;
178        };
179        let origin = bounds.origin;
180        let hover = prepainted.hover.clone();
181        let hitbox = prepainted.hitbox.clone();
182        window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, _| {
183            if phase != DispatchPhase::Bubble {
184                return;
185            }
186            let at = hitbox.is_hovered(window).then(|| {
187                let local = event.position - origin;
188                [local.x.as_f32(), local.y.as_f32()]
189            });
190            if hover.replace(at) != at {
191                window.refresh();
192            }
193        });
194
195        let theme = Theme::of(cx).clone();
196        let tip = prepainted.hover.get().and_then(|at| built.plan.hit(at));
197        paint_chart(&built, origin, tip.as_ref(), &theme, window, cx);
198        if let (Some(tip), Some(at)) = (tip, prepainted.hover.get()) {
199            window.paint_layer(bounds, |window| {
200                paint_tip(&built, &tip, at, bounds, &theme, window, cx);
201            });
202        }
203    }
204}
205
206fn build(chart: &Chart, key: Key, window: &mut Window) -> Option<Built> {
207    let text_system = window.text_system().clone();
208    let font_size = px(key.font_size);
209    let line = TextStyle::Caption.painted_line_height();
210    let shape = |text: SharedString, color: Hsla| {
211        let run = TextRun {
212            len: text.len(),
213            font: key.font.clone(),
214            color,
215            background_color: None,
216            underline: None,
217            strikethrough: None,
218        };
219        text_system.shape_line(text, font_size, &[run], None)
220    };
221    let mut labels = HashMap::new();
222    let mut measure = |text: &str| {
223        let text = SharedString::from(text.to_string());
224        labels
225            .entry(text.clone())
226            .or_insert_with(|| shape(text, key.colors[1]))
227            .width()
228            .as_f32()
229    };
230    let plan = plan::plan(chart, key.size, line, &mut measure)?;
231    let title = plan
232        .title
233        .as_ref()
234        .map(|title| shape(title.text.clone(), key.colors[0]));
235
236    let mut paths = Vec::new();
237    for (s, series) in plan.series.iter().enumerate() {
238        match &series.marks {
239            Marks::Lines(runs) => {
240                paths.extend(runs.iter().filter_map(|run| Some((s, stroke(run)?, false))));
241            }
242            Marks::Areas(outlines) => {
243                for outline in outlines {
244                    let mut builder = PathBuilder::fill();
245                    builder.add_polygon(&points(outline), true);
246                    paths.extend(builder.build().ok().map(|path| (s, path, true)));
247                    paths
248                        .extend(stroke(&outline[..outline.len() - 2]).map(|path| (s, path, false)));
249                }
250            }
251            Marks::Slices(slices) => {
252                paths.extend(
253                    slices
254                        .iter()
255                        .filter_map(|slice| Some((s, wedge(slice)?, true))),
256                );
257            }
258            Marks::Bars(_) | Marks::Points(_) => {}
259        }
260    }
261    Some(Built {
262        key,
263        plan,
264        paths,
265        labels,
266        title,
267        line,
268    })
269}
270
271fn points(run: &[[f32; 2]]) -> Vec<Point<Pixels>> {
272    run.iter().map(|&[x, y]| point(px(x), px(y))).collect()
273}
274
275fn stroke(run: &[[f32; 2]]) -> Option<Path<Pixels>> {
276    let mut builder = PathBuilder::stroke(px(LINE_WIDTH));
277    match run {
278        [] => return None,
279        [only] => {
280            builder.move_to(point(px(only[0] - LINE_WIDTH / 2.0), px(only[1])));
281            builder.line_to(point(px(only[0] + LINE_WIDTH / 2.0), px(only[1])));
282        }
283        _ => builder.add_polygon(&points(run), false),
284    }
285    builder.build().ok()
286}
287
288fn wedge(slice: &Slice) -> Option<Path<Pixels>> {
289    let [cx, cy] = slice.center;
290    let span = slice.end - slice.start;
291    let steps = (span / std::f32::consts::TAU * TURN_SEGMENTS)
292        .ceil()
293        .max(1.0) as usize;
294    let mut outline = vec![point(px(cx), px(cy))];
295    outline.extend((0..=steps).map(|step| {
296        // Clockwise from twelve o'clock.
297        let angle = slice.start + span * step as f32 / steps as f32 - FRAC_PI_2;
298        point(
299            px(cx + slice.radius * angle.cos()),
300            px(cy + slice.radius * angle.sin()),
301        )
302    }));
303    let mut builder = PathBuilder::fill();
304    builder.add_polygon(&outline, true);
305    builder.build().ok()
306}
307
308/// `path`, built at the chart's origin, moved to `origin`.
309fn moved(path: &Path<Pixels>, origin: Point<Pixels>) -> Path<Pixels> {
310    let mut path = path.clone();
311    path.bounds.origin += origin;
312    for vertex in &mut path.vertices {
313        vertex.xy_position += origin;
314    }
315    path
316}
317
318fn bounds_of(rect: Rect, origin: Point<Pixels>) -> Bounds<Pixels> {
319    Bounds::new(
320        origin + point(px(rect.x), px(rect.y)),
321        size(px(rect.w), px(rect.h)),
322    )
323}
324
325fn paint_label(
326    built: &Built,
327    label: &plan::Label,
328    origin: Point<Pixels>,
329    window: &mut Window,
330    cx: &mut App,
331) {
332    if let Some(shaped) = built.labels.get(&label.text) {
333        let at = origin + point(px(label.at[0]), px(label.at[1]));
334        shaped
335            .paint(at, px(built.line), TextAlign::Left, None, window, cx)
336            .ok();
337    }
338}
339
340fn paint_chart(
341    built: &Built,
342    origin: Point<Pixels>,
343    tip: Option<&Tip>,
344    theme: &Theme,
345    window: &mut Window,
346    cx: &mut App,
347) {
348    let plan = &built.plan;
349
350    for axis in &plan.axes {
351        if axis.grid {
352            for tick in &axis.ticks {
353                let line = match axis.side {
354                    Side::Left => Rect {
355                        x: plan.plot.x,
356                        y: tick.at.round(),
357                        w: plan.plot.w,
358                        h: 1.0,
359                    },
360                    Side::Bottom => Rect {
361                        x: tick.at.round(),
362                        y: plan.plot.y,
363                        w: 1.0,
364                        h: plan.plot.h,
365                    },
366                };
367                window.paint_quad(fill(bounds_of(line, origin), theme.border_faint));
368            }
369        }
370        let labels = axis.ticks.iter().filter_map(|tick| tick.label.as_ref());
371        for label in labels.chain(&axis.title) {
372            paint_label(built, label, origin, window, cx);
373        }
374    }
375    if !plan.axes.is_empty() {
376        let base = Rect {
377            x: plan.plot.x,
378            y: plan.plot.bottom().round(),
379            w: plan.plot.w,
380            h: 1.0,
381        };
382        window.paint_quad(fill(bounds_of(base, origin), theme.border));
383    }
384    if let Some(band) = tip.and_then(|tip| tip.band) {
385        window.paint_quad(fill(bounds_of(band, origin), theme.ink(BAND_WASH)));
386    }
387
388    // A fixed domain can leave marks past the plot; they stop at its edge,
389    // short of half a point.
390    let clip = Rect {
391        x: plan.plot.x - POINT / 2.0,
392        y: plan.plot.y - POINT / 2.0,
393        w: plan.plot.w + POINT,
394        h: plan.plot.h + POINT,
395    };
396    let mask = ContentMask {
397        bounds: bounds_of(clip, origin),
398        ..ContentMask::default()
399    };
400    window.with_content_mask(Some(mask), |window| {
401        let mut paths = built.paths.iter().peekable();
402        for (s, series) in plan.series.iter().enumerate() {
403            let color = theme.categorical(series.color);
404            while let Some((_, path, filled)) = paths.next_if(|(of, ..)| *of == s) {
405                let color = if *filled && series.mark == Mark::Area {
406                    color.opacity(AREA_FILL)
407                } else {
408                    color
409                };
410                window.paint_path(moved(path, origin), color);
411            }
412            match &series.marks {
413                Marks::Bars(bars) => {
414                    for bar in bars {
415                        window.paint_quad(
416                            fill(bounds_of(*bar, origin), color)
417                                .corner_radii(Corners::all(px(BAR_RADIUS))),
418                        );
419                    }
420                }
421                Marks::Points(at) => {
422                    for &[x, y] in at {
423                        let dot = Rect {
424                            x: x - POINT / 2.0,
425                            y: y - POINT / 2.0,
426                            w: POINT,
427                            h: POINT,
428                        };
429                        window.paint_quad(
430                            fill(bounds_of(dot, origin), color)
431                                .corner_radii(Corners::all(px(POINT / 2.0))),
432                        );
433                    }
434                }
435                _ => {}
436            }
437        }
438    });
439
440    if let Some(tip) = tip
441        && let Some(&(_, [x, _])) = tip.points.first()
442    {
443        let rule = Rect {
444            x: x.round(),
445            y: plan.plot.y,
446            w: 1.0,
447            h: plan.plot.h,
448        };
449        window.paint_quad(fill(bounds_of(rule, origin), theme.border_strong));
450        for &(s, [x, y]) in &tip.points {
451            let half = HOVER_POINT / 2.0;
452            let dot = Rect {
453                x: x - half,
454                y: y - half,
455                w: HOVER_POINT,
456                h: HOVER_POINT,
457            };
458            window.paint_quad(
459                fill(bounds_of(dot, origin), theme.categorical(s))
460                    .corner_radii(Corners::all(px(half)))
461                    .border_widths(px(1.5))
462                    .border_color(theme.surface),
463            );
464        }
465    }
466
467    if let (Some(label), Some(shaped)) = (&plan.title, &built.title) {
468        let at = origin + point(px(label.at[0]), px(label.at[1]));
469        shaped
470            .paint(at, px(built.line), TextAlign::Left, None, window, cx)
471            .ok();
472    }
473    if let Some(label) = &plan.legend_title {
474        paint_label(built, label, origin, window, cx);
475    }
476    for swatch in &plan.legend {
477        window.paint_quad(
478            fill(
479                bounds_of(swatch.swatch, origin),
480                theme.categorical(swatch.series),
481            )
482            .corner_radii(Corners::all(px(2.0))),
483        );
484        paint_label(built, &swatch.label, origin, window, cx);
485    }
486}
487
488/// The tip's box beside the pointer: its title, then a swatch, series name
489/// and value per row. Kept inside the chart where it fits.
490fn paint_tip(
491    built: &Built,
492    tip: &Tip,
493    at: [f32; 2],
494    bounds: Bounds<Pixels>,
495    theme: &Theme,
496    window: &mut Window,
497    cx: &mut App,
498) {
499    let font = built.key.font.clone();
500    let font_size = px(built.key.font_size);
501    let line = built.line;
502    let shape = |text: SharedString, color: Hsla, window: &mut Window| {
503        let run = TextRun {
504            len: text.len(),
505            font: font.clone(),
506            color,
507            background_color: None,
508            underline: None,
509            strikethrough: None,
510        };
511        window
512            .text_system()
513            .shape_line(text, font_size, &[run], None)
514    };
515    let title = shape(tip.title.clone(), theme.text, window);
516    let rows: Vec<(usize, ShapedLine)> = tip
517        .rows
518        .iter()
519        .map(|(s, value)| {
520            let text = match &built.plan.colors[*s] {
521                Some(name) if *name != tip.title => format!("{name}  {value}"),
522                _ => value.to_string(),
523            };
524            (*s, shape(text.into(), theme.text_muted, window))
525        })
526        .collect();
527
528    let swatch = line * 0.6;
529    let width = rows
530        .iter()
531        .map(|(_, row)| swatch + TIP_PAD / 2.0 + row.width().as_f32())
532        .fold(title.width().as_f32(), f32::max)
533        + 2.0 * TIP_PAD;
534    let height = line * (1 + rows.len()) as f32 + 2.0 * TIP_PAD;
535    let [w, h] = [bounds.size.width.as_f32(), bounds.size.height.as_f32()];
536    let mut x = at[0] + TIP_OFFSET;
537    if x + width > w {
538        x = (at[0] - TIP_OFFSET - width).max(0.0);
539    }
540    let y = (at[1] + TIP_OFFSET).min(h - height).max(0.0);
541    let origin = bounds.origin;
542    let card = Rect {
543        x,
544        y,
545        w: width,
546        h: height,
547    };
548    window.paint_quad(
549        fill(bounds_of(card, origin), theme.surface_dialog)
550            .corner_radii(Corners::all(px(TIP_RADIUS)))
551            .border_widths(px(1.0))
552            .border_color(theme.border),
553    );
554    let mut row_at = origin + point(px(x + TIP_PAD), px(y + TIP_PAD));
555    title
556        .paint(row_at, px(line), TextAlign::Left, None, window, cx)
557        .ok();
558    for (s, row) in rows {
559        row_at.y += px(line);
560        let dot = Rect {
561            x: x + TIP_PAD,
562            y: (row_at.y - origin.y).as_f32() + (line - swatch) / 2.0,
563            w: swatch,
564            h: swatch,
565        };
566        window.paint_quad(
567            fill(bounds_of(dot, origin), theme.categorical(s)).corner_radii(Corners::all(px(2.0))),
568        );
569        let text_at = row_at + point(px(swatch + TIP_PAD / 2.0), px(0.0));
570        row.paint(text_at, px(line), TextAlign::Left, None, window, cx)
571            .ok();
572    }
573}