Skip to main content

datui_lib/chart/
chart_jobs.rs

1//! Chart preparation off the UI thread: what a spec asks for, what the worker
2//! prepared, the cache of both, and chart exports written from it.
3
4use std::path::Path;
5use std::sync::Arc;
6
7use color_eyre::Result;
8use polars::prelude::{LazyFrame, Schema};
9
10use crate::app::jobs::{Answer, ChartPrep, Job};
11use crate::chart::chart_data::{self, ColorSplit, ValueRange};
12use crate::chart::chart_modal::{Aggregate, ChartModal, ChartSpec, ColorCounts, Mark};
13use crate::chart::chart_plot::{LinesData, PlotContext, PlotData, plot};
14use crate::export::output_file::Overwrite;
15use crate::{
16    App, AppEvent, Overlay, analysis::sampling, chart::chart_export, export::output_file, numfmt,
17};
18use chart_export::{ChartExportFormat, ChartExportRequest, ExportOptions, Figure};
19
20/// The chart view, its export form, and the preparations it keeps or waits on.
21#[derive(Default)]
22pub struct Charts {
23    pub modal: ChartModal,
24    pub export_modal: crate::chart::chart_export_modal::ChartExportModal,
25    pub(crate) cache: ChartCache,
26    /// The selection the chart last asked for, and, when it stepped the aggregate of
27    /// the one before, until when it waits for the next step before it is prepared.
28    pub(crate) asked: Option<(ChartRequest, Option<std::time::Instant>)>,
29    /// A chart export that asked for data still being prepared. The preparation's end
30    /// picks it up; `busy` stays set until then.
31    pub(crate) export_waiting: Option<ChartExportRequest>,
32}
33
34/// Chart preparation outcomes by request, least recently used first. Failures are kept
35/// so they are not retried every event. Bounded so toggling a few selections does not
36/// recollect; line and scatter series (which grow with the row limit) are kept few, and
37/// only the current one has its log copy.
38#[derive(Default)]
39pub(crate) struct ChartCache {
40    pub(crate) entries: Vec<(ChartRequest, Result<PlotData, String>)>,
41    /// The rows read for these entries and which view (`len_generation`) they are
42    /// from, so another option re-draws from them rather than reading again.
43    held: chart_data::HeldRows,
44    held_view: Option<u64>,
45    /// The Color columns' values as counted for these entries, so going back to a
46    /// color whose chart is cached brings its value picker back too.
47    colors: Vec<ColorCounts>,
48}
49
50impl ChartCache {
51    pub(crate) const CAPACITY: usize = 8;
52    pub(crate) const XY_CAPACITY: usize = 2;
53
54    /// Forget every entry and the rows read. A worker still reading keeps the handle it
55    /// was given and fills that one, never the next view's.
56    pub(crate) fn clear(&mut self) {
57        self.entries.clear();
58        self.held = chart_data::HeldRows::default();
59        self.held_view = None;
60        self.colors.clear();
61    }
62
63    /// Keep a Color column's values, the last count of it.
64    pub(crate) fn hold_colors(&mut self, colors: ColorCounts) {
65        self.colors.retain(|c| c.column != colors.column);
66        self.colors.push(colors);
67    }
68
69    /// The values counted for Color column `column`.
70    pub(crate) fn colors(&self, column: &str) -> Option<&ColorCounts> {
71        self.colors.iter().find(|c| c.column == column)
72    }
73
74    /// The rows held for `view`, or a fresh holder when they belong to another.
75    pub(crate) fn held_rows(&mut self, view: Option<u64>) -> chart_data::HeldRows {
76        if self.held_view != view {
77            self.held = chart_data::HeldRows::default();
78            self.held_view = view;
79        }
80        self.held.clone()
81    }
82
83    pub(crate) fn get(&self, request: &ChartRequest) -> Option<&Result<PlotData, String>> {
84        self.entries
85            .iter()
86            .find(|(r, _)| r == request)
87            .map(|(_, outcome)| outcome)
88    }
89
90    /// The prepared data for `request`, if it has been prepared.
91    pub(crate) fn prepared(&self, request: &ChartRequest) -> Option<&PlotData> {
92        self.get(request).and_then(|outcome| outcome.as_ref().ok())
93    }
94
95    /// What stands in for `request` while it is prepared: the newest chart prepared of
96    /// the same columns, drawn under another option (sample size, bins, range).
97    pub(crate) fn standing_in(&self, request: &ChartRequest) -> Option<&PlotData> {
98        self.entries
99            .iter()
100            .rev()
101            .filter(|(r, _)| r.same_columns(request))
102            .find_map(|(_, outcome)| outcome.as_ref().ok())
103    }
104
105    /// Whether `request` has been prepared.
106    pub(crate) fn satisfies(&self, request: &ChartRequest) -> bool {
107        self.prepared(request).is_some()
108    }
109
110    pub(crate) fn insert(&mut self, request: ChartRequest, outcome: Result<PlotData, String>) {
111        self.entries.retain(|(r, _)| *r != request);
112        self.entries.push((request, outcome));
113        Self::evict(&mut self.entries, Self::XY_CAPACITY, |outcome| {
114            matches!(outcome, Ok(PlotData::Lines(_)))
115        });
116        Self::evict(&mut self.entries, Self::CAPACITY, |_| true);
117    }
118
119    /// Drop the least recently used of the entries `counts` selects until at most `cap`
120    /// remain.
121    fn evict(
122        entries: &mut Vec<(ChartRequest, Result<PlotData, String>)>,
123        cap: usize,
124        counts: impl Fn(&Result<PlotData, String>) -> bool,
125    ) {
126        let mut over = entries
127            .iter()
128            .filter(|(_, o)| counts(o))
129            .count()
130            .saturating_sub(cap);
131        entries.retain(|(_, o)| {
132            if over > 0 && counts(o) {
133                over -= 1;
134                false
135            } else {
136                true
137            }
138        });
139    }
140
141    /// Mark `request` as the selection on screen: last to be evicted, and the only one with
142    /// a log copy (made here if `log_scale`). An in-memory map over held points, fine on
143    /// the event thread, never in render.
144    pub(crate) fn touch(&mut self, request: &ChartRequest, log_scale: bool) {
145        let Some(i) = self.entries.iter().position(|(r, _)| r == request) else {
146            return;
147        };
148        let current = self.entries.remove(i);
149        self.entries.push(current);
150        let last = self.entries.len() - 1;
151        for (i, (_, outcome)) in self.entries.iter_mut().enumerate() {
152            if let Ok(PlotData::Lines(lines)) = outcome {
153                lines.keep_log(i == last && log_scale);
154            }
155        }
156    }
157}
158
159/// What the chart view needs prepared for the panel's spec, compared with the cache and
160/// the job in flight so each spec is prepared once off the UI thread. Only what the
161/// chart type reads takes part (bin count does not change a line chart's request).
162#[derive(Clone, Debug, PartialEq)]
163pub(crate) struct ChartRequest {
164    pub(crate) spec: ChartSpec,
165    pub(crate) bins: usize,
166    pub(crate) bandwidth: f64,
167    pub(crate) range: ValueRange,
168    pub(crate) order: chart_data::BarOrder,
169    pub(crate) share: bool,
170    /// Rows a chart that samples reads; `None` for one that aggregates every row.
171    pub(crate) row_limit: Option<usize>,
172    /// A line is drawn from each step's lowest and highest value rather than a
173    /// sample: see [`chart_data::prepare_chart_data`].
174    pub(crate) envelope: bool,
175    /// Only an X is picked: its range gives the empty axes their bounds.
176    pub(crate) x_only: bool,
177    /// Most series drawn: one per distinct series color on this terminal.
178    pub(crate) series_cap: usize,
179    /// First or last over a sorted view: the rows are read in its order.
180    pub(crate) sorted: bool,
181}
182
183impl ChartRequest {
184    /// Whether preparing `other` reads what this needs: another order of the same
185    /// bars is the same pass.
186    pub(crate) fn reads_as(&self, other: &Self) -> bool {
187        Self {
188            order: other.order,
189            ..self.clone()
190        } == *other
191    }
192
193    /// Whether this is `last` with another aggregate: a step through the aggregates.
194    pub(crate) fn steps_aggregate_from(&self, last: &Self) -> bool {
195        let mut spec = self.spec.clone();
196        if spec.encoding.y.aggregate == last.spec.encoding.y.aggregate {
197            return false;
198        }
199        spec.encoding.y.aggregate = last.spec.encoding.y.aggregate;
200        spec == last.spec
201    }
202
203    /// Whether `other` is the same chart of the same columns, whatever its options.
204    pub(crate) fn same_columns(&self, other: &Self) -> bool {
205        let (a, b) = (&self.spec, &other.spec);
206        // The bucket, the aggregate and cumulative are what the numbers are: a chart
207        // under another of them would show old numbers under new labels.
208        a.mark == b.mark
209            && a.encoding.x == b.encoding.x
210            && a.encoding.y == b.encoding.y
211            && a.encoding.color.field == b.encoding.color.field
212            && self.x_only == other.x_only
213    }
214
215    /// The request for what the panel shows, or `None` while a shelf the chart needs
216    /// is empty.
217    pub(crate) fn from_modal(modal: &ChartModal) -> Option<Self> {
218        let mut spec = modal.effective_spec();
219        let mark = spec.mark;
220        let x_only = mark.is_xy()
221            && spec.encoding.x.field.is_some()
222            && spec.encoding.y.field.is_empty()
223            && spec.encoding.y.aggregate != Aggregate::Count;
224        if !x_only && !ChartModal::is_complete(&spec) {
225            return None;
226        }
227        // Leave out what this chart does not read; color splitting is read from the charted
228        // spec, previewed Y included. No more series than colors to tell them apart.
229        let series_cap = modal.series_max();
230        spec.encoding.y.field.truncate(series_cap);
231        spec.encoding.color.values.truncate(series_cap);
232        let colored = ChartModal::colored_in(&spec);
233        if colored {
234            spec.encoding.color.other = Some(ChartModal::shows_other_in(&spec));
235        } else {
236            spec.encoding.color = Default::default();
237        }
238        if x_only {
239            spec.encoding.y = Default::default();
240            spec.encoding.color = Default::default();
241            spec.encoding.x.time_unit = Default::default();
242        }
243        let aggregates = modal.aggregates() && !x_only;
244        let bins = match mark {
245            Mark::Histogram => modal.hist_bins,
246            Mark::Heatmap => modal.heatmap_bins,
247            _ => 0,
248        };
249        Some(Self {
250            bins,
251            bandwidth: if mark == Mark::Kde {
252                modal.kde_bandwidth_factor
253            } else {
254                0.0
255            },
256            range: if matches!(mark, Mark::Histogram | Mark::Kde | Mark::Box) {
257                modal.value_range
258            } else {
259                ValueRange::All
260            },
261            order: if mark == Mark::Bar {
262                modal.bar_order
263            } else {
264                Default::default()
265            },
266            share: mark == Mark::Histogram && modal.share,
267            row_limit: if aggregates { None } else { modal.row_limit },
268            envelope: mark == Mark::Line && !aggregates && !colored,
269            x_only,
270            sorted: aggregates
271                && spec.encoding.y.aggregate.follows_row_order()
272                && modal.row_order.is_some(),
273            spec,
274            series_cap,
275        })
276    }
277
278    /// Whether this request groups every row of the view rather than sampling.
279    pub(crate) fn aggregates(&self) -> bool {
280        matches!(self.spec.mark, Mark::Line | Mark::Scatter | Mark::Bar)
281            && self.spec.encoding.y.aggregate != Aggregate::None
282            && !self.x_only
283    }
284
285    /// The Polars work. Runs on a worker thread. With a color, the color column's
286    /// values are counted first (and handed back for the value picker), and the
287    /// groups are the ones picked or the largest.
288    pub(crate) fn prepare(
289        &self,
290        lf: &LazyFrame,
291        schema: &Schema,
292        sampling: &chart_data::ChartSampling,
293    ) -> Result<(PlotData, Option<ColorCounts>)> {
294        let encoding = &self.spec.encoding;
295        let counts = encoding
296            .color
297            .field
298            .as_deref()
299            .map(|c| chart_data::value_rows(lf, c, sampling).map(|rows| (c, rows)))
300            .transpose()?;
301        let groups = counts.as_ref().map(|(_, rows)| {
302            chart_data::color_groups(rows, &encoding.color.values, self.series_cap)
303        });
304        // Some value without a series of its own: Other takes its rows, or they are
305        // left out and the note says the rows are the groups'.
306        let left_out = counts
307            .as_ref()
308            .zip(groups.as_ref())
309            .is_some_and(|((_, rows), groups)| {
310                rows.values.iter().any(|(v, _)| !groups.contains(v))
311            });
312        let other = encoding.color.other == Some(true) && left_out;
313        let split = counts
314            .as_ref()
315            .zip(groups.as_ref())
316            .map(|((column, _), groups)| ColorSplit {
317                column,
318                groups,
319                other,
320            });
321        let picker = counts.as_ref().map(|(column, rows)| ColorCounts {
322            column: column.to_string(),
323            values: rows.values.clone(),
324        });
325        let x = encoding.x.field.as_deref().unwrap_or_default();
326        let first_y = encoding.y.field.first().map(String::as_str);
327        let prepared = match self.spec.mark {
328            Mark::Line | Mark::Scatter if self.x_only => {
329                PlotData::XRange(chart_data::prepare_chart_x_range(lf, schema, x, sampling)?)
330            }
331            Mark::Line | Mark::Scatter => {
332                let aggregate = encoding.y.aggregate;
333                let grouped = if aggregate != Aggregate::None {
334                    chart_data::prepare_aggregate_xy(
335                        lf,
336                        schema,
337                        &chart_data::AggregateSpec {
338                            x,
339                            time_unit: encoding.x.time_unit,
340                            ys: &encoding.y.field,
341                            aggregate,
342                            quantile: encoding.y.quantile(),
343                            cumulative: encoding.y.cumulative,
344                            color: split,
345                        },
346                        sampling,
347                    )?
348                } else if let (Some(split), Some(y)) = (split, first_y) {
349                    chart_data::prepare_xy_by(lf, schema, x, y, split, sampling)?
350                } else {
351                    let r = chart_data::prepare_chart_data(
352                        lf,
353                        schema,
354                        x,
355                        &encoding.y.field,
356                        sampling,
357                        self.envelope,
358                    )?;
359                    chart_data::GroupedSeries {
360                        names: encoding.y.field.clone(),
361                        series: r.series,
362                        breaks: r.breaks,
363                        x_axis_kind: r.x_axis_kind,
364                        rows: r.rows,
365                        other: false,
366                    }
367                };
368                let rows = grouped.rows.total_rows;
369                PlotData::Lines(LinesData::new(
370                    grouped,
371                    (aggregate != Aggregate::None).then(|| {
372                        rows_note(rows, sampling.known_total == Some(rows), left_out && !other)
373                    }),
374                ))
375            }
376            Mark::Bar if encoding.y.aggregate != Aggregate::None => {
377                let mut data = chart_data::prepare_bar_aggregate(
378                    lf,
379                    schema,
380                    &chart_data::BarAggregate {
381                        category: x,
382                        value: first_y,
383                        aggregate: encoding.y.aggregate,
384                        quantile: encoding.y.quantile(),
385                        color: split,
386                        order: self.order,
387                        cap: chart_data::BAR_CAP,
388                    },
389                    sampling,
390                )?;
391                // Every category is a bar, a null one too: uncolored, it is every row.
392                data.rows_note = Some(rows_note(data.rows.total_rows, true, left_out && !other));
393                PlotData::Bars(data)
394            }
395            Mark::Bar => PlotData::Bars(chart_data::prepare_bar_data(
396                lf,
397                x,
398                first_y.unwrap_or_default(),
399                self.order,
400                chart_data::BAR_CAP,
401                sampling,
402            )?),
403            Mark::Histogram => PlotData::Histogram(chart_data::prepare_histogram_by(
404                lf, x, self.bins, self.range, self.share, split, sampling,
405            )?),
406            Mark::Kde => PlotData::Kde(match split {
407                Some(split) => {
408                    chart_data::prepare_kde_by(lf, x, self.bandwidth, self.range, split, sampling)?
409                }
410                None => chart_data::prepare_kde_data(lf, x, self.bandwidth, self.range, sampling)?,
411            }),
412            Mark::Box => {
413                let y = first_y.unwrap_or_default();
414                PlotData::Box(match encoding.x.field.as_deref() {
415                    // One box per category: the largest by rows.
416                    Some(by) => {
417                        let rows = chart_data::value_rows(lf, by, sampling)?;
418                        let groups = chart_data::color_groups(&rows, &[], self.series_cap);
419                        let mut data = chart_data::prepare_box_by(
420                            lf,
421                            y,
422                            ColorSplit {
423                                column: by,
424                                groups: &groups,
425                                other: false,
426                            },
427                            self.range,
428                            sampling,
429                        )?;
430                        // Counted only when some category was left without a box.
431                        if rows.values.len() > groups.len() {
432                            data.of = rows.values.len();
433                        }
434                        data
435                    }
436                    None => chart_data::prepare_box_plot_data(lf, y, self.range, sampling)?,
437                })
438            }
439            Mark::Heatmap => PlotData::Heatmap(chart_data::prepare_heatmap_data(
440                lf,
441                x,
442                first_y.unwrap_or_default(),
443                self.bins,
444                sampling,
445            )?),
446        };
447        Ok((prepared, picker))
448    }
449}
450
451/// A chart export with its figure built from the cache; `write` is the slow part and
452/// runs off the UI thread.
453pub(crate) struct ChartExportJob {
454    pub(crate) figure: Figure,
455    pub(crate) options: ExportOptions,
456}
457
458impl ChartExportJob {
459    /// Draw the chart into a temporary file and commit it over `path`.
460    pub(crate) fn write(
461        &self,
462        path: &Path,
463        format: ChartExportFormat,
464        overwrite: Overwrite,
465    ) -> Result<()> {
466        let bytes = chart_export::render(&self.figure, &self.options, format)?;
467        let out = output_file::OutputFile::create(path, overwrite)?;
468        std::fs::write(out.path(), bytes)?;
469        out.commit()?;
470        Ok(())
471    }
472}
473
474/// What an aggregate read, under the plot: every row of the view (`all 336,776
475/// rows`) when it counted them all, the rows of the groups a color drew, or the rows
476/// with an X.
477fn rows_note(counted: usize, whole: bool, grouped: bool) -> String {
478    let n = numfmt::group_chrome(counted);
479    if grouped {
480        format!("{n} rows in the groups shown")
481    } else if whole {
482        format!("all {n} rows")
483    } else {
484        format!("{n} rows")
485    }
486}
487
488impl App {
489    /// Whether chart data for the current view is being prepared: running, or waiting
490    /// behind a superseded preparation still reading (unstoppable, see `ChartPrep`).
491    pub fn chart_preparing(&self) -> bool {
492        if self.chart_prep().is_some() {
493            true
494        } else if self.jobs.running(is_chart_prep) {
495            self.chart_request_pending()
496        } else {
497            self.chart_settling()
498        }
499    }
500
501    /// The chart preparation running whose answer is still wanted.
502    fn chart_prep(&self) -> Option<&ChartPrep> {
503        match self.jobs.current(is_chart_prep)? {
504            (_, Job::ChartPrepare(prep)) => Some(prep),
505            _ => None,
506        }
507    }
508
509    /// Whether the selection on screen is a step through the aggregates still waiting
510    /// for the next step.
511    fn chart_settling(&self) -> bool {
512        self.chart
513            .asked
514            .as_ref()
515            .and_then(|(_, until)| *until)
516            .is_some_and(|until| std::time::Instant::now() < until)
517    }
518
519    /// Whether the chart view wants data it does not have and cannot be told it will
520    /// never get.
521    pub(crate) fn chart_request_pending(&self) -> bool {
522        if !self.overlay.shows(&Overlay::Chart) {
523            return false;
524        }
525        ChartRequest::from_modal(&self.chart.modal)
526            .is_some_and(|request| self.chart.cache.get(&request).is_none())
527    }
528
529    /// Forget chart state belonging to the outgoing view or dataset: the cache, a parked
530    /// export, and a running export write (its result ignored, `busy` released). A running
531    /// preparation is superseded and its answer dropped. Called when the chart view closes,
532    /// the dataset changes, or home is entered.
533    pub(crate) fn reset_chart_state(&mut self) {
534        self.chart.cache.clear();
535        self.chart.asked = None;
536        if let Some(prep) = self.chart_prep() {
537            prep.cancel
538                .store(true, std::sync::atomic::Ordering::Relaxed);
539        }
540        self.jobs.supersede(is_chart_prep);
541        // A failed export reopens its modal; it must not follow the user to the next
542        // dataset.
543        if self.overlay == Overlay::ChartExport {
544            self.step_back();
545        }
546        self.chart.export_modal.close();
547        let writing = self
548            .jobs
549            .supersede(|job| matches!(job, Job::ChartExport { .. }));
550        let waiting = self.chart.export_waiting.take().is_some();
551        if writing || waiting {
552            self.export_progress = None;
553            self.status_message = None;
554            self.busy = false;
555        }
556    }
557
558    /// What the chart being prepared is doing: an aggregate groups every row of the
559    /// view, and says how many where the table knows.
560    pub(crate) fn chart_status(&self) -> String {
561        let aggregating = self
562            .chart_prep()
563            .map(|prep| prep.request.aggregates())
564            .or_else(|| ChartRequest::from_modal(&self.chart.modal).map(|r| r.aggregates()))
565            .unwrap_or(false);
566        if !aggregating {
567            return "Preparing chart...".to_string();
568        }
569        match self
570            .data_table_state
571            .as_ref()
572            .and_then(|s| s.num_rows_if_valid())
573        {
574            Some(rows) => format!(
575                "Grouping {} rows...",
576                crate::home::discover::format_rows(rows)
577            ),
578            None => "Grouping every row...".to_string(),
579        }
580    }
581
582    /// The series of the line or scatter chart on screen, by name, once prepared:
583    /// its Y columns or its color groups.
584    pub fn chart_names(&self) -> Option<Vec<String>> {
585        let request = ChartRequest::from_modal(&self.chart.modal)?;
586        match self.chart.cache.prepared(&request)? {
587            PlotData::Lines(xy) => Some(xy.names.clone()),
588            _ => None,
589        }
590    }
591
592    /// True when the chart cache holds the data for the modal's current selection.
593    pub fn chart_data_ready(&self) -> bool {
594        ChartRequest::from_modal(&self.chart.modal).is_some_and(|r| self.chart.cache.satisfies(&r))
595    }
596
597    /// Start preparing the modal's current chart unless cached, known to fail, or another
598    /// preparation is running (the newest selection follows when it lands). Runs after every
599    /// event, so render only draws.
600    pub(crate) fn ensure_chart_data(&mut self) {
601        const CHART_AGGREGATE_SETTLE: std::time::Duration = std::time::Duration::from_millis(150);
602        if !self.overlay.shows(&Overlay::Chart) {
603            return;
604        }
605        // What Every row costs, as the table counted it.
606        self.chart.modal.view_rows = self
607            .data_table_state
608            .as_ref()
609            .and_then(|state| state.num_rows_if_valid());
610        let request = ChartRequest::from_modal(&self.chart.modal);
611        if let Some(prep) = self.chart_prep()
612            && !request.as_ref().is_some_and(|r| r.reads_as(&prep.request))
613        {
614            // A count streaming a large view for a selection the cursor has moved
615            // past would hold up the next chart for as long as it reads.
616            prep.cancel
617                .store(true, std::sync::atomic::Ordering::Relaxed);
618        }
619        let Some(request) = request else {
620            return;
621        };
622        // Stepping none, count, distinct, sum, mean grouped every row at each step, and
623        // drew each: a step waits a moment for the next, and only where it stops is
624        // prepared. A Wake when the wait ends prepares it.
625        let settle = match self.chart.asked.take() {
626            Some((asked, until)) if asked == request => until,
627            Some((asked, _)) if request.steps_aggregate_from(&asked) => {
628                let tx = self.events.clone();
629                std::thread::spawn(move || {
630                    std::thread::sleep(CHART_AGGREGATE_SETTLE);
631                    let _ = tx.send(AppEvent::Wake);
632                });
633                Some(std::time::Instant::now() + CHART_AGGREGATE_SETTLE)
634            }
635            _ => None,
636        };
637        self.chart.asked = Some((request.clone(), settle));
638        if self.chart.cache.get(&request).is_some() {
639            self.chart.cache.touch(&request, self.chart.modal.log_scale);
640            // A cached chart's colors were counted with it.
641            if let Some(colors) = request
642                .spec
643                .encoding
644                .color
645                .field
646                .as_deref()
647                .and_then(|c| self.chart.cache.colors(c))
648            {
649                self.chart.modal.color_counts = Some(colors.clone());
650            }
651            return;
652        }
653        if self.jobs.running(is_chart_prep) || self.chart_settling() {
654            return;
655        }
656        let Some(state) = self.data_table_state.as_ref() else {
657            return;
658        };
659        // Unsorted: the rows a chart draws do not depend on the table's order, a line
660        // is drawn in X order anyway, and a sort would make a sampled read read it all.
661        // First and last are the order's: they read the view as sorted.
662        let lf = if request.sorted {
663            state.lf().clone()
664        } else {
665            state.analysis_lf()
666        };
667        let schema = state.schema().clone();
668        let dataset = Some(state.len_generation());
669        let sampling = chart_data::ChartSampling {
670            // None for an aggregate: it reads every row.
671            limit: request.row_limit,
672            known_total: state.num_rows_if_valid(),
673            seed: self.analysis_modal.sample.seed,
674            streaming: self.app_config.performance.streaming,
675            full_passes: !state.is_remote_source(),
676            held: self.chart.cache.held_rows(dataset),
677            cancel: Arc::default(),
678        };
679        let prep = ChartPrep {
680            dataset,
681            request: request.clone(),
682            cancel: Arc::clone(&sampling.cancel),
683        };
684        self.spawn_job(Job::ChartPrepare(Box::new(prep)), None, move |_| {
685            request
686                .prepare(&lf, &schema, &sampling)
687                .map(|prepared| Answer::ChartPrepared(Box::new(prepared)))
688                .map_err(|e| crate::error_display::user_message_from_report(&e, None))
689        });
690    }
691
692    /// A chart preparation ended with `outcome`. It is installed only while the job is
693    /// `current` (a reset supersedes it when its view or dataset goes) and only into
694    /// the dataset it was read from. Its ending is what lets the next start.
695    pub(crate) fn chart_prepared(
696        &mut self,
697        prep: ChartPrep,
698        current: bool,
699        outcome: Result<(PlotData, Option<ColorCounts>), String>,
700    ) {
701        if !current {
702            return;
703        }
704        // A count stopped part way is no answer, and must not be remembered as a
705        // failure; the selection is prepared again when it comes back.
706        if outcome.is_err() && prep.cancel.load(std::sync::atomic::Ordering::Relaxed) {
707            return;
708        }
709        let dataset = self.data_table_state.as_ref().map(|s| s.len_generation());
710        if dataset != prep.dataset {
711            return;
712        }
713        let outcome = outcome.map(|(prepared, colors)| {
714            if let Some(colors) = colors {
715                self.chart.cache.hold_colors(colors.clone());
716                self.chart.modal.color_counts = Some(colors);
717            }
718            prepared
719        });
720        self.chart.cache.insert(prep.request, outcome);
721        // A parked export resumes against the current selection: written if prepared, failed
722        // if that one failed, else waiting for the next result.
723        if let Some(request) = self.chart.export_waiting.take() {
724            self.start_chart_export(request);
725        }
726    }
727
728    /// The figure to export from the prepared chart for the current spec. `Ok(None)`
729    /// means that chart is still being prepared and the caller should wait for it.
730    pub(crate) fn build_chart_figure(&self) -> Result<Option<Figure>> {
731        let Some(state) = self.data_table_state.as_ref() else {
732            return Err(color_eyre::eyre::eyre!("No data loaded"));
733        };
734        let modal = &self.chart.modal;
735        let Some(request) = ChartRequest::from_modal(modal).filter(|r| !r.x_only) else {
736            return Err(color_eyre::eyre::eyre!(
737                "Pick the columns the chart needs first"
738            ));
739        };
740        let prepared = match self.chart.cache.get(&request) {
741            Some(Ok(prepared)) => prepared,
742            // A selection known not to chart is never retried, so waiting for its data
743            // would wait forever: fail the export now with the reason.
744            Some(Err(message)) => return Err(color_eyre::eyre::eyre!("{}", message)),
745            None => return Ok(None),
746        };
747        let context = PlotContext {
748            modal,
749            spec: &request.spec,
750            numbers: &self.display.number_format,
751            schema: Some(state.schema().as_ref()),
752        };
753        // A single X column has nothing to export.
754        let plot = plot(Some(prepared), &context)
755            .filter(|plot| !plot.data.is_empty())
756            .ok_or_else(|| color_eyre::eyre::eyre!("No valid data points to export"))?;
757        let mut plot = plot.into_owned();
758        // The screen's title row says how the rows were made; a file has none, so
759        // its Y axis names the aggregate.
760        let y = &request.spec.encoding.y;
761        if matches!(*plot.data, PlotData::Lines(_))
762            && !matches!(y.aggregate, Aggregate::None | Aggregate::Count)
763        {
764            plot.y.title = format!("{} {}", y.aggregate_name(), plot.y.title);
765        }
766        Ok(Some(Figure {
767            plot,
768            // The file always has the middle dot; the terminal may be ASCII.
769            chart_notes: self.chart_notes_of(prepared, "ยท"),
770            grid: modal.grid,
771        }))
772    }
773
774    /// Write the chart from the prepared data off-thread, or park the export until that
775    /// data is ready. `busy` was set by `ChartExport` and stays set until the export ends.
776    pub(crate) fn start_chart_export(&mut self, mut request: ChartExportRequest) {
777        // How the chart was made, from the view and chart as they are now; none
778        // when the dialog says Omit.
779        request.options.recipe = if request.recipe {
780            self.chart_recipe()
781        } else {
782            None
783        };
784        match self.build_chart_figure() {
785            Ok(Some(figure)) => {
786                self.chart.export_waiting = None;
787                let write = Job::ChartExport {
788                    path: request.path.clone(),
789                    format: request.format,
790                };
791                self.spawn_job(write, Some("Exporting chart..."), move |_| {
792                    let ChartExportRequest {
793                        path,
794                        format,
795                        options,
796                        overwrite,
797                        ..
798                    } = request;
799                    ChartExportJob { figure, options }
800                        .write(&path, format, overwrite)
801                        .map_err(|e| Self::format_export_error(&e))?;
802                    Ok(Answer::ChartExported)
803                });
804            }
805            // Still being prepared; its job's end comes back here.
806            Ok(None) => self.chart.export_waiting = Some(request),
807            Err(e) => {
808                let message = Self::format_export_error(&e);
809                self.finish_chart_export(&request.path, request.format, Err(message));
810            }
811        }
812    }
813
814    pub(crate) fn finish_chart_export(
815        &mut self,
816        path: &Path,
817        format: ChartExportFormat,
818        result: Result<(), String>,
819    ) {
820        self.chart.export_waiting = None;
821        self.export_progress = None;
822        self.status_message = None;
823        self.busy = false;
824        match result {
825            Ok(()) => {
826                self.flash_path("Chart exported to ", path);
827                self.chart.export_modal.close();
828            }
829            // The form comes back as it was, the reason on its status line.
830            Err(message) => {
831                self.chart.export_modal.reopen_with_path(path, format);
832                self.chart.export_modal.error = Some(message);
833                if self.overlay == Overlay::Chart {
834                    self.open_overlay(Overlay::ChartExport);
835                }
836            }
837        }
838    }
839
840    /// What a chart says under its plot about the rows it drew. A view with a sample
841    /// is charted from all of it: the note says which sample, with its seed, so the
842    /// chart can be drawn again.
843    pub(crate) fn chart_notes_of(
844        &self,
845        prepared: &crate::chart::chart_jobs::PlotData,
846        middot: &str,
847    ) -> Vec<String> {
848        let mut notes = prepared.notes(middot);
849        let sampled = self.data_table_state.as_ref().and_then(|s| s.sampled());
850        if let Some(sampled) = sampled {
851            let mut note = sampled.label();
852            if matches!(
853                sampled.sample().method,
854                sampling::SampleMethod::Spread | sampling::SampleMethod::PerPartition { .. }
855            ) {
856                note.push_str(&format!(" {middot} seed {}", sampled.sample().seed));
857            }
858            notes.insert(0, note);
859        }
860        notes
861    }
862}
863
864/// A chart preparation's job.
865fn is_chart_prep(job: &Job) -> bool {
866    matches!(job, Job::ChartPrepare(_))
867}