1use 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
19const 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;
26const 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
33pub 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#[derive(Clone, PartialEq)]
68struct Key {
69 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
78struct Built {
81 key: Key,
82 plan: Plan,
83 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 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 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
308fn 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 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
488fn 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}