Skip to main content

datui_lib/chart/
chart_plot.rs

1//! What a chart plots. A worker prepares the data ([`PlotData`]), which the chart
2//! cache keeps; [`plot`] gives it its axes for the view, titled and numbered as the
3//! table prints the columns, and the screen and every export draw that [`Plot`].
4
5use std::borrow::Cow;
6
7use polars::prelude::Schema;
8
9use crate::chart::chart_data::{
10    self, BarData, BoxPlotData, ChartXRangeResult, HeatmapData, HistogramData, KdeData, RowsRead,
11    XAxisTemporalKind,
12};
13use crate::chart::chart_modal::{Aggregate, ChartModal, ChartSpec, Mark};
14use crate::numfmt;
15use crate::widgets::axis_numbers::AxisNumbers;
16
17/// A chart's data, as prepared off the UI thread. Each payload names the columns it
18/// was drawn from, since the axes are titled from it.
19#[derive(Debug, Clone)]
20pub enum PlotData {
21    Lines(LinesData),
22    /// A line or scatter chart's X alone: its range, for the axes to stand over.
23    XRange(ChartXRangeResult),
24    Histogram(HistogramData),
25    Box(BoxPlotData),
26    Kde(KdeData),
27    Heatmap(HeatmapData),
28    Bars(BarData),
29}
30
31/// A line or scatter chart's series.
32#[derive(Debug, Clone, Default)]
33pub struct LinesData {
34    /// One per series: its Y column, or its color group.
35    pub names: Vec<String>,
36    pub series: Vec<Vec<(f64, f64)>>,
37    /// The series as a log scale draws them. Only the chart on screen keeps one
38    /// (`ChartCache::touch`): it is as large as the series.
39    pub series_log: Option<Vec<Vec<(f64, f64)>>>,
40    /// Per series, where its line starts again after a gap (see `chart_data::segments`).
41    pub breaks: Vec<Vec<usize>>,
42    /// Every X of every series, in order, each once: where the crosshair stops.
43    pub xs: Vec<f64>,
44    /// The least and greatest X and Y over every point, before any log.
45    pub bounds: Option<[f64; 4]>,
46    pub x_axis_kind: XAxisTemporalKind,
47    pub rows: RowsRead,
48    /// What an aggregate over every row read, said in the title row.
49    pub rows_note: Option<String>,
50    /// The last series is Other: every value of a color without a series of its own.
51    pub other: bool,
52}
53
54/// One series of [`LinesData`] that has points.
55#[derive(Debug, Clone, Copy)]
56pub struct Drawn<'a> {
57    /// Its place among every series, which picks its color on screen: an empty
58    /// series before it keeps its color too.
59    pub index: usize,
60    pub name: &'a str,
61    pub points: &'a [(f64, f64)],
62    pub breaks: &'a [usize],
63    pub other: bool,
64}
65
66impl LinesData {
67    /// The series a worker grouped, with what is drawn from them worked out now:
68    /// their X values in order and their bounds.
69    pub fn new(grouped: chart_data::GroupedSeries, rows_note: Option<String>) -> Self {
70        Self {
71            series_log: None,
72            xs: crate::widgets::crosshair::xs(&grouped.series),
73            bounds: extent(&grouped.series),
74            names: grouped.names,
75            series: grouped.series,
76            breaks: grouped.breaks,
77            x_axis_kind: grouped.x_axis_kind,
78            rows: grouped.rows,
79            rows_note,
80            other: grouped.other,
81        }
82    }
83
84    /// Keep a log copy of the series when `log` wants one, and none otherwise.
85    pub fn keep_log(&mut self, log: bool) {
86        match (log, self.series_log.is_some()) {
87            (true, false) => self.series_log = Some(log_series(&self.series)),
88            (false, true) => self.series_log = None,
89            _ => {}
90        }
91    }
92
93    /// The series as drawn: logged on a log scale.
94    pub fn shown(&self, log: bool) -> &[Vec<(f64, f64)>] {
95        match (&self.series_log, log) {
96            (Some(logged), true) => logged,
97            _ => &self.series,
98        }
99    }
100
101    /// The least and greatest X and Y of the points as drawn. The log is monotone:
102    /// the bounds of the logged points are the logged bounds.
103    pub fn shown_bounds(&self, log: bool) -> Option<[f64; 4]> {
104        match self.bounds {
105            Some([x0, x1, y0, y1]) if log && self.series_log.is_some() => {
106                Some([x0, x1, log_y(y0), log_y(y1)])
107            }
108            Some(bounds) => Some(bounds),
109            None => extent(self.shown(log)),
110        }
111    }
112
113    /// The series with points, as drawn.
114    pub fn drawn(&self, log: bool) -> impl Iterator<Item = Drawn<'_>> {
115        let last = self.names.len().saturating_sub(1);
116        self.shown(log)
117            .iter()
118            .zip(self.names.iter())
119            .enumerate()
120            .filter(|(_, (points, _))| !points.is_empty())
121            .map(move |(index, (points, name))| Drawn {
122                index,
123                name,
124                points,
125                breaks: self.breaks.get(index).map_or(&[][..], Vec::as_slice),
126                other: self.other && index == last,
127            })
128    }
129}
130
131/// The least and greatest X and Y over every point; `None` with no points.
132pub fn extent(series: &[Vec<(f64, f64)>]) -> Option<[f64; 4]> {
133    let mut points = series.iter().flatten().peekable();
134    points.peek()?;
135    Some(points.fold(
136        [
137            f64::INFINITY,
138            f64::NEG_INFINITY,
139            f64::INFINITY,
140            f64::NEG_INFINITY,
141        ],
142        |[x0, x1, y0, y1], &(x, y)| [x0.min(x), x1.max(x), y0.min(y), y1.max(y)],
143    ))
144}
145
146/// A Y as the log scale draws it: `ln(1 + y)`, negatives at zero.
147fn log_y(y: f64) -> f64 {
148    y.max(0.0).ln_1p()
149}
150
151fn log_series(series: &[Vec<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
152    series
153        .iter()
154        .map(|points| points.iter().map(|&(x, y)| (x, log_y(y))).collect())
155        .collect()
156}
157
158impl PlotData {
159    /// Nothing to draw: no points, bins, boxes, cells or bars.
160    pub fn is_empty(&self) -> bool {
161        match self {
162            Self::Lines(lines) => lines.drawn(false).next().is_none(),
163            Self::XRange(_) => true,
164            Self::Bars(data) => data.bars.is_empty(),
165            Self::Histogram(data) => data.bins.is_empty(),
166            Self::Kde(data) => data.series.is_empty(),
167            Self::Box(data) => data.stats.is_empty(),
168            Self::Heatmap(data) => data.counts.is_empty() || data.max_count <= 0.0,
169        }
170    }
171
172    /// What the chart says under the plot about the rows and values it drew;
173    /// `middot` joins a sample's seed on.
174    pub fn notes(&self, middot: &str) -> Vec<String> {
175        let rows_of = |rows: usize| crate::home::discover::format_rows(rows);
176        match self {
177            Self::Bars(d) => {
178                let mut notes = chart_data::chart_notes(&d.rows, None, middot);
179                if let Some(note) = &d.rows_note {
180                    notes.push(note.clone());
181                } else if let Some(rows) = d.counted {
182                    notes.push(format!("counts of {} rows", rows_of(rows)));
183                } else if d.rows.sample_size.is_none() && !d.value_column.is_empty() {
184                    notes.push(format!(
185                        "all {} rows",
186                        numfmt::group_chrome(d.rows.total_rows)
187                    ));
188                }
189                if d.no_value > 0 {
190                    let noun = if d.no_value == 1 {
191                        "category"
192                    } else {
193                        "categories"
194                    };
195                    notes.push(format!(
196                        "{} {noun} without a value",
197                        numfmt::group_chrome(d.no_value)
198                    ));
199                }
200                notes
201            }
202            Self::Lines(c) if c.rows_note.is_some() => c.rows_note.iter().cloned().collect(),
203            Self::Lines(c) => chart_data::chart_notes(&c.rows, None, middot),
204            Self::XRange(c) => chart_data::chart_notes(&c.rows, None, middot),
205            Self::Histogram(d) => chart_data::chart_notes(&d.rows, d.clipped.as_ref(), middot),
206            Self::Box(d) => {
207                let mut notes = chart_data::chart_notes(&d.rows, d.clipped.as_ref(), middot);
208                if d.of > 0 {
209                    notes.push(format!(
210                        "the {} largest of {} categories",
211                        d.stats.len(),
212                        numfmt::group_chrome(d.of)
213                    ));
214                }
215                notes
216            }
217            Self::Kde(d) => chart_data::chart_notes(&d.rows, d.clipped.as_ref(), middot),
218            Self::Heatmap(d) => chart_data::chart_notes(&d.rows, None, middot),
219        }
220    }
221}
222
223/// One axis: its title, what its numbers are, and whether they are dates.
224#[derive(Debug, Clone, Default)]
225pub struct Axis {
226    pub title: String,
227    pub numbers: AxisNumbers,
228    pub kind: XAxisTemporalKind,
229    /// Values are `ln(1 + y)`; ticks name `y`.
230    pub log: bool,
231}
232
233/// A chart as the screen and every export draw it: its data, borrowed from the chart
234/// cache on screen and owned by an export, which outlives the frame, and its axes.
235/// A bar chart's value axis is its X.
236#[derive(Debug, Clone)]
237pub struct Plot<'a> {
238    pub data: Cow<'a, PlotData>,
239    pub x: Axis,
240    pub y: Axis,
241    pub scatter: bool,
242    pub y_from_zero: bool,
243}
244
245impl Plot<'_> {
246    /// The plot with its data its own.
247    pub fn into_owned(self) -> Plot<'static> {
248        Plot {
249            data: Cow::Owned(self.data.into_owned()),
250            ..self
251        }
252    }
253
254    /// A line or scatter chart's series, drawn on a log scale when the Y axis is one.
255    pub fn drawn(&self) -> impl Iterator<Item = Drawn<'_>> {
256        let lines = match &*self.data {
257            PlotData::Lines(lines) => Some(lines),
258            _ => None,
259        };
260        lines.into_iter().flat_map(|lines| lines.drawn(self.y.log))
261    }
262    /// The plot's curves, Other first so the rest are drawn over it: a line or
263    /// scatter chart's series with points, the density curves, or a histogram's
264    /// groups as the outlines of their bars.
265    pub fn curves(&self) -> Vec<Curve<'_>> {
266        let curves: Vec<Curve<'_>> = match &*self.data {
267            PlotData::Lines(_) => self
268                .drawn()
269                .enumerate()
270                .map(|(slot, s)| Curve {
271                    index: s.index,
272                    slot,
273                    points: Cow::Borrowed(s.points),
274                    breaks: s.breaks,
275                    other: s.other,
276                })
277                .collect(),
278            PlotData::Kde(data) => data
279                .series
280                .iter()
281                .enumerate()
282                .map(|(i, s)| Curve {
283                    index: i,
284                    slot: i,
285                    points: Cow::Borrowed(&s.points),
286                    breaks: &[],
287                    other: data.other && i + 1 == data.series.len(),
288                })
289                .collect(),
290            PlotData::Histogram(data) if !data.groups.is_empty() => data
291                .step_outlines()
292                .into_iter()
293                .enumerate()
294                .map(|(i, points)| Curve {
295                    index: i,
296                    slot: i,
297                    points: Cow::Owned(points),
298                    breaks: &[],
299                    other: data.other && i + 1 == data.groups.len(),
300                })
301                .collect(),
302            _ => Vec::new(),
303        };
304        let other = curves.iter().position(|c| c.other);
305        let mut curves: Vec<Option<Curve<'_>>> = curves.into_iter().map(Some).collect();
306        chart_data::drawing_order(curves.len(), other)
307            .filter_map(|i| curves[i].take())
308            .collect()
309    }
310
311    /// The names a legend lists, in order: a series, a color group or a curve each.
312    pub fn legend(&self) -> Vec<&str> {
313        match &*self.data {
314            PlotData::Lines(_) => self.drawn().map(|s| s.name).collect(),
315            PlotData::Bars(data) => data.groups.iter().map(String::as_str).collect(),
316            PlotData::Histogram(data) => data.groups.iter().map(|g| g.name.as_str()).collect(),
317            PlotData::Kde(data) => data.series.iter().map(|s| s.name.as_str()).collect(),
318            PlotData::Box(_) | PlotData::Heatmap(_) | PlotData::XRange(_) => Vec::new(),
319        }
320    }
321
322    /// Where Other is in the legend: last, when it has one.
323    pub fn other_at(&self) -> Option<usize> {
324        match &*self.data {
325            PlotData::Lines(_) => self.drawn().position(|s| s.other),
326            PlotData::Bars(data) => chart_data::other_at(data.other, data.groups.len()),
327            _ => self.curves().iter().find(|c| c.other).map(|c| c.slot),
328        }
329    }
330}
331
332/// A line a plot draws through its data, as every device draws it.
333#[derive(Debug, Clone)]
334pub struct Curve<'a> {
335    /// Its place among every series, which colors it on screen: an empty series
336    /// before it keeps its color too.
337    pub index: usize,
338    /// Its place in the legend, which names and colors it in a file.
339    pub slot: usize,
340    pub points: Cow<'a, [(f64, f64)]>,
341    /// Where it starts again after a gap.
342    pub breaks: &'a [usize],
343    /// Other: every value of a color without a series of its own, drawn under the rest.
344    pub other: bool,
345}
346
347/// What a plot is drawn from beside its data: the panel's options, the spec, and
348/// how the view's columns print.
349pub struct PlotContext<'a> {
350    pub modal: &'a ChartModal,
351    /// The spec drawn: on screen, with the picker's choice previewed.
352    pub spec: &'a ChartSpec,
353    pub numbers: &'a numfmt::NumberFormatSettings,
354    pub schema: Option<&'a Schema>,
355}
356
357/// The plot of `data` for the spec: a line or scatter chart's axes always, standing
358/// empty until its series arrive; any other kind once its data has.
359pub fn plot<'a>(data: Option<&'a PlotData>, context: &PlotContext<'_>) -> Option<Plot<'a>> {
360    let PlotContext {
361        modal,
362        spec,
363        numbers,
364        schema,
365    } = *context;
366    let column = |name: &str| AxisNumbers::column(numbers, schema, name);
367    let title = |name: &str| modal.axis_title(name);
368    let axis = |title: String, numbers: AxisNumbers| Axis {
369        title,
370        numbers,
371        ..Default::default()
372    };
373    let encoding = &spec.encoding;
374    let x_name = encoding.x.field.as_deref();
375    let ys = &encoding.y.field;
376    let (x, y) = match (spec.mark, data) {
377        (Mark::Line | Mark::Scatter, data) => return Some(lines(data, context)),
378        (Mark::Histogram, Some(PlotData::Histogram(data))) => (
379            axis(title(&data.column), column(&data.column)),
380            if data.share {
381                axis("Share".to_string(), AxisNumbers::measure(numbers, "Share"))
382            } else {
383                axis("Count".to_string(), AxisNumbers::count(numbers))
384            },
385        ),
386        (Mark::Kde, Some(PlotData::Kde(_))) => (
387            axis(
388                x_name.map(title).unwrap_or_default(),
389                x_name.map(column).unwrap_or_default().fractional(),
390            ),
391            axis(
392                "Density".to_string(),
393                AxisNumbers::measure(numbers, "Density"),
394            ),
395        ),
396        (Mark::Box, Some(PlotData::Box(_))) => (
397            axis(
398                x_name.unwrap_or_default().to_string(),
399                AxisNumbers::default(),
400            ),
401            axis(
402                ys.first().map(|y| title(y)).unwrap_or_default(),
403                AxisNumbers::columns(numbers, schema, ys),
404            ),
405        ),
406        (Mark::Heatmap, Some(PlotData::Heatmap(data))) => (
407            axis(title(&data.x_column), column(&data.x_column)),
408            axis(title(&data.y_column), column(&data.y_column)),
409        ),
410        (Mark::Bar, Some(PlotData::Bars(data))) => (
411            axis(
412                data.value_column.clone(),
413                AxisNumbers {
414                    format: data.value_format(numbers),
415                    whole: data.value_dtype.is_integer(),
416                },
417            ),
418            Axis::default(),
419        ),
420        _ => return None,
421    };
422    Some(Plot {
423        data: Cow::Borrowed(data?),
424        x,
425        y,
426        scatter: false,
427        y_from_zero: false,
428    })
429}
430
431/// A line or scatter chart's series and axes; the axes alone, over the X column's
432/// range or typed from the schema, while the series are on their way.
433fn lines<'a>(data: Option<&'a PlotData>, context: &PlotContext<'_>) -> Plot<'a> {
434    let PlotContext {
435        modal,
436        spec,
437        numbers,
438        schema,
439    } = *context;
440    let encoding = &spec.encoding;
441    let ys = &encoding.y.field;
442    let aggregate = encoding.y.aggregate;
443    let y_numbers = match aggregate {
444        Aggregate::Count | Aggregate::Distinct => AxisNumbers::count(numbers),
445        // A mean or median of whole numbers is not whole.
446        a if a.is_fractional() => AxisNumbers::columns(numbers, schema, ys).fractional(),
447        _ => AxisNumbers::columns(numbers, schema, ys),
448    };
449    let y_title = if aggregate == Aggregate::Count {
450        "count".to_string()
451    } else {
452        ys.iter()
453            .map(|y| modal.axis_title(y))
454            .collect::<Vec<_>>()
455            .join(", ")
456    };
457    let x_name = encoding.x.field.as_deref();
458    let kind = match data {
459        Some(PlotData::Lines(lines)) => lines.x_axis_kind,
460        Some(PlotData::XRange(range)) => range.x_axis_kind,
461        // Still on its way: typed from the schema so the labels are right.
462        _ => match (x_name, schema) {
463            (Some(x), Some(schema)) => chart_data::x_axis_temporal_kind_for_column(schema, x),
464            _ => XAxisTemporalKind::Numeric,
465        },
466    };
467    let data = match data {
468        // A chart standing in while another is prepared has no log copy: made here,
469        // for as long as it stands in.
470        Some(PlotData::Lines(lines)) if modal.log_scale && lines.series_log.is_none() => {
471            let mut lines = lines.clone();
472            lines.keep_log(true);
473            Cow::Owned(PlotData::Lines(lines))
474        }
475        Some(data @ (PlotData::Lines(_) | PlotData::XRange(_))) => Cow::Borrowed(data),
476        _ => Cow::Owned(PlotData::Lines(LinesData::default())),
477    };
478    Plot {
479        data,
480        x: Axis {
481            title: x_name.map(|x| modal.axis_title(x)).unwrap_or_default(),
482            numbers: x_name
483                .map(|x| AxisNumbers::column(numbers, schema, x))
484                .unwrap_or_default(),
485            kind,
486            log: false,
487        },
488        y: Axis {
489            title: y_title,
490            numbers: y_numbers,
491            log: modal.log_scale,
492            ..Default::default()
493        },
494        scatter: spec.mark == Mark::Scatter,
495        y_from_zero: modal.y_starts_at_zero,
496    }
497}