1use 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#[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 pub(crate) asked: Option<(ChartRequest, Option<std::time::Instant>)>,
29 pub(crate) export_waiting: Option<ChartExportRequest>,
32}
33
34#[derive(Default)]
39pub(crate) struct ChartCache {
40 pub(crate) entries: Vec<(ChartRequest, Result<PlotData, String>)>,
41 held: chart_data::HeldRows,
44 held_view: Option<u64>,
45 colors: Vec<ColorCounts>,
48}
49
50impl ChartCache {
51 pub(crate) const CAPACITY: usize = 8;
52 pub(crate) const XY_CAPACITY: usize = 2;
53
54 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 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 pub(crate) fn colors(&self, column: &str) -> Option<&ColorCounts> {
71 self.colors.iter().find(|c| c.column == column)
72 }
73
74 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 pub(crate) fn prepared(&self, request: &ChartRequest) -> Option<&PlotData> {
92 self.get(request).and_then(|outcome| outcome.as_ref().ok())
93 }
94
95 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 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 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 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#[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 pub(crate) row_limit: Option<usize>,
172 pub(crate) envelope: bool,
175 pub(crate) x_only: bool,
177 pub(crate) series_cap: usize,
179 pub(crate) sorted: bool,
181}
182
183impl ChartRequest {
184 pub(crate) fn reads_as(&self, other: &Self) -> bool {
187 Self {
188 order: other.order,
189 ..self.clone()
190 } == *other
191 }
192
193 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 pub(crate) fn same_columns(&self, other: &Self) -> bool {
205 let (a, b) = (&self.spec, &other.spec);
206 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 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 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 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 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 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 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 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 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
451pub(crate) struct ChartExportJob {
454 pub(crate) figure: Figure,
455 pub(crate) options: ExportOptions,
456}
457
458impl ChartExportJob {
459 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
474fn 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 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 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 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 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 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 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 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 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 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 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 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 prep.cancel
617 .store(true, std::sync::atomic::Ordering::Relaxed);
618 }
619 let Some(request) = request else {
620 return;
621 };
622 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 until = std::time::Instant::now() + CHART_AGGREGATE_SETTLE;
631 let tx = self.events.clone();
632 std::thread::spawn(move || {
633 std::thread::sleep(until.saturating_duration_since(std::time::Instant::now()));
634 let _ = tx.send(AppEvent::Wake);
635 });
636 Some(until)
637 }
638 _ => None,
639 };
640 self.chart.asked = Some((request.clone(), settle));
641 if self.chart.cache.get(&request).is_some() {
642 self.chart.cache.touch(&request, self.chart.modal.log_scale);
643 if let Some(colors) = request
645 .spec
646 .encoding
647 .color
648 .field
649 .as_deref()
650 .and_then(|c| self.chart.cache.colors(c))
651 {
652 self.chart.modal.color_counts = Some(colors.clone());
653 }
654 return;
655 }
656 if self.jobs.running(is_chart_prep) || self.chart_settling() {
657 return;
658 }
659 let Some(state) = self.data_table_state.as_ref() else {
660 return;
661 };
662 let lf = if request.sorted {
666 state.lf().clone()
667 } else {
668 state.analysis_lf()
669 };
670 let schema = state.schema().clone();
671 let dataset = Some(state.len_generation());
672 let sampling = chart_data::ChartSampling {
673 limit: request.row_limit,
675 known_total: state.num_rows_if_valid(),
676 seed: self.analysis_modal.sample.seed,
677 streaming: self.app_config.performance.streaming,
678 full_passes: !state.is_remote_source(),
679 held: self.chart.cache.held_rows(dataset),
680 cancel: Arc::default(),
681 };
682 let prep = ChartPrep {
683 dataset,
684 request: request.clone(),
685 cancel: Arc::clone(&sampling.cancel),
686 };
687 self.spawn_job(Job::ChartPrepare(Box::new(prep)), None, move |_| {
688 request
689 .prepare(&lf, &schema, &sampling)
690 .map(|prepared| Answer::ChartPrepared(Box::new(prepared)))
691 .map_err(|e| crate::error_display::user_message_from_report(&e, None))
692 });
693 }
694
695 pub(crate) fn chart_prepared(
699 &mut self,
700 prep: ChartPrep,
701 current: bool,
702 outcome: Result<(PlotData, Option<ColorCounts>), String>,
703 ) {
704 if !current {
705 return;
706 }
707 if outcome.is_err() && prep.cancel.load(std::sync::atomic::Ordering::Relaxed) {
710 return;
711 }
712 let dataset = self.data_table_state.as_ref().map(|s| s.len_generation());
713 if dataset != prep.dataset {
714 return;
715 }
716 let outcome = outcome.map(|(prepared, colors)| {
717 if let Some(colors) = colors {
718 self.chart.cache.hold_colors(colors.clone());
719 self.chart.modal.color_counts = Some(colors);
720 }
721 prepared
722 });
723 self.chart.cache.insert(prep.request, outcome);
724 if let Some(request) = self.chart.export_waiting.take() {
727 self.start_chart_export(request);
728 }
729 }
730
731 pub(crate) fn build_chart_figure(&self) -> Result<Option<Figure>> {
734 let Some(state) = self.data_table_state.as_ref() else {
735 return Err(color_eyre::eyre::eyre!("No data loaded"));
736 };
737 let modal = &self.chart.modal;
738 let Some(request) = ChartRequest::from_modal(modal).filter(|r| !r.x_only) else {
739 return Err(color_eyre::eyre::eyre!(
740 "Pick the columns the chart needs first"
741 ));
742 };
743 let prepared = match self.chart.cache.get(&request) {
744 Some(Ok(prepared)) => prepared,
745 Some(Err(message)) => return Err(color_eyre::eyre::eyre!("{}", message)),
748 None => return Ok(None),
749 };
750 let context = PlotContext {
751 modal,
752 spec: &request.spec,
753 numbers: &self.display.number_format,
754 schema: Some(state.schema().as_ref()),
755 };
756 let plot = plot(Some(prepared), &context)
758 .filter(|plot| !plot.data.is_empty())
759 .ok_or_else(|| color_eyre::eyre::eyre!("No valid data points to export"))?;
760 let mut plot = plot.into_owned();
761 let y = &request.spec.encoding.y;
764 if matches!(*plot.data, PlotData::Lines(_))
765 && !matches!(y.aggregate, Aggregate::None | Aggregate::Count)
766 {
767 plot.y.title = format!("{} {}", y.aggregate_name(), plot.y.title);
768 }
769 Ok(Some(Figure {
770 plot,
771 chart_notes: self.chart_notes_of(prepared, "ยท"),
773 grid: modal.grid,
774 }))
775 }
776
777 pub(crate) fn start_chart_export(&mut self, mut request: ChartExportRequest) {
780 request.options.recipe = if request.recipe {
783 self.chart_recipe()
784 } else {
785 None
786 };
787 match self.build_chart_figure() {
788 Ok(Some(figure)) => {
789 self.chart.export_waiting = None;
790 let write = Job::ChartExport {
791 path: request.path.clone(),
792 format: request.format,
793 };
794 self.spawn_job(write, Some("Exporting chart..."), move |_| {
795 let ChartExportRequest {
796 path,
797 format,
798 options,
799 overwrite,
800 ..
801 } = request;
802 ChartExportJob { figure, options }
803 .write(&path, format, overwrite)
804 .map_err(|e| Self::format_export_error(&e))?;
805 Ok(Answer::ChartExported)
806 });
807 }
808 Ok(None) => self.chart.export_waiting = Some(request),
810 Err(e) => {
811 let message = Self::format_export_error(&e);
812 self.finish_chart_export(&request.path, request.format, Err(message));
813 }
814 }
815 }
816
817 pub(crate) fn finish_chart_export(
818 &mut self,
819 path: &Path,
820 format: ChartExportFormat,
821 result: Result<(), String>,
822 ) {
823 self.chart.export_waiting = None;
824 self.export_progress = None;
825 self.status_message = None;
826 self.busy = false;
827 match result {
828 Ok(()) => {
829 self.flash_path("Chart exported to ", path);
830 self.chart.export_modal.close();
831 }
832 Err(message) => {
834 self.chart.export_modal.reopen_with_path(path, format);
835 self.chart.export_modal.error = Some(message);
836 if self.overlay == Overlay::Chart {
837 self.open_overlay(Overlay::ChartExport);
838 }
839 }
840 }
841 }
842
843 pub(crate) fn chart_notes_of(
847 &self,
848 prepared: &crate::chart::chart_jobs::PlotData,
849 middot: &str,
850 ) -> Vec<String> {
851 let mut notes = prepared.notes(middot);
852 let sampled = self.data_table_state.as_ref().and_then(|s| s.sampled());
853 if let Some(sampled) = sampled {
854 let mut note = sampled.label();
855 if matches!(
856 sampled.sample().method,
857 sampling::SampleMethod::Spread | sampling::SampleMethod::PerPartition { .. }
858 ) {
859 note.push_str(&format!(" {middot} seed {}", sampled.sample().seed));
860 }
861 notes.insert(0, note);
862 }
863 notes
864 }
865}
866
867fn is_chart_prep(job: &Job) -> bool {
869 matches!(job, Job::ChartPrepare(_))
870}