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 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 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 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 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 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 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 if let Some(request) = self.chart.export_waiting.take() {
724 self.start_chart_export(request);
725 }
726 }
727
728 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 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 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 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 chart_notes: self.chart_notes_of(prepared, "ยท"),
770 grid: modal.grid,
771 }))
772 }
773
774 pub(crate) fn start_chart_export(&mut self, mut request: ChartExportRequest) {
777 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 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 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 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
864fn is_chart_prep(job: &Job) -> bool {
866 matches!(job, Job::ChartPrepare(_))
867}