Skip to main content

plot_redactor/controller/
mod.rs

1//! Controller module (MVC "C").
2//!
3//! Responsibilities:
4//! - Accept `Action` values from the view.
5//! - Validate and translate actions into `Command`s.
6//! - Mutate `PlotModel` and data table state.
7//! - Maintain undo/redo stacks.
8//! - Handle import/export workflows.
9
10pub mod action;
11pub mod command;
12
13pub use action::*;
14pub use command::*;
15
16use std::fmt::{Display, Formatter};
17use std::path::Path;
18
19use crate::model::{
20    AxisConfig, AxisKind, AxesConfig, Color, DataSource, DataTable, LayoutConfig, LegendConfig,
21    LegendPosition, LineStyle, MarkerStyle, PlotModel, RangePolicy, ScaleType, SeriesId,
22    SeriesModel, SeriesStyle, TickConfig, ImageFormat, ImageSize,
23};
24use plotters::coord::Shift;
25use plotters::coord::types::RangedCoordf32;
26use plotters::prelude::*;
27use plotters::style::Color as PlottersColor;
28
29/// EN: UI notification severity.
30/// RU: Uroven uvedomleniya dlya UI.
31#[derive(Clone, Copy)]
32pub enum NotificationLevel {
33    Info,
34    Error,
35}
36
37/// EN: Notification shown in the status block of the editor.
38/// RU: Soobshchenie v statuse redaktora.
39#[derive(Clone)]
40pub struct Notification {
41    pub level: NotificationLevel,
42    pub message: String,
43}
44
45/// EN: Controller-level error for validation and user-safe feedback.
46/// RU: Oshibka kontrollera dlya validatsii i bezopasnoy obratnoy svyazi.
47#[derive(Debug)]
48pub enum ControllerError {
49    EmptyAxisLabel,
50    EmptyChartTitle,
51    InvalidRange { min: f64, max: f64 },
52    InvalidLineWidth(f32),
53    SeriesNotFound(SeriesId),
54    ColumnNotFound(String),
55    DataLoadFailed(String),
56    ExportFailed(String),
57    NoDataLoaded,
58    CannotRemoveLastSeries,
59    UnsupportedAction(&'static str),
60}
61
62impl Display for ControllerError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        match self {
65            ControllerError::EmptyAxisLabel => {
66                write!(f, "Axis label cannot be empty / Podpis osi ne mozhet byt pustoy")
67            }
68            ControllerError::EmptyChartTitle => {
69                write!(f, "Chart title cannot be empty / Zagolovok ne mozhet byt pustym")
70            }
71            ControllerError::InvalidRange { min, max } => write!(
72                f,
73                "Invalid range: min ({min}) must be < max ({max}) / Nekorrektnyy diapazon"
74            ),
75            ControllerError::InvalidLineWidth(width) => write!(
76                f,
77                "Invalid line width: {width} / Nekorrektnaya tolshchina linii"
78            ),
79            ControllerError::SeriesNotFound(id) => {
80                write!(f, "Series {:?} not found / Seriya ne naydena", id)
81            }
82            ControllerError::ColumnNotFound(name) => {
83                write!(f, "Column not found: {name} / Stolbets ne naiden")
84            }
85            ControllerError::DataLoadFailed(err) => {
86                write!(f, "Failed to load data: {err} / Ne udalos zagruzit dannye")
87            }
88            ControllerError::ExportFailed(err) => {
89                write!(f, "Failed to export: {err} / Ne udalos sohranit grafik")
90            }
91            ControllerError::NoDataLoaded => {
92                write!(f, "No data loaded / Dannye ne zagruzheny")
93            }
94            ControllerError::CannotRemoveLastSeries => write!(
95                f,
96                "Cannot remove last series / Nelyzya udalit poslednyuyu seriyu"
97            ),
98            ControllerError::UnsupportedAction(name) => write!(
99                f,
100                "Action is not implemented yet: {name} / Deystvie poka ne realizovano"
101            ),
102        }
103    }
104}
105
106impl std::error::Error for ControllerError {}
107
108/// EN: Main MVC controller. Owns model, data table, command history and notifications.
109/// RU: Glavnyy MVC-kontroller. Hranit model, tablitsu dannyh, istoriyu komand i uvedomleniya.
110pub struct PlotController {
111    pub model: PlotModel,
112    pub undo_stack: Vec<Command>,
113    pub redo_stack: Vec<Command>,
114    data_table: Option<DataTable>,
115    notification: Option<Notification>,
116    next_series_id: u64,
117}
118
119impl PlotController {
120    /// EN: Empty initial state; data is loaded via File menu.
121    /// RU: Pustoe nachalnoe sostoyanie; dannye zagruzhayutsya cherez File menu.
122    pub fn new() -> Self {
123        Self {
124            model: PlotModel::default(),
125            undo_stack: Vec::new(),
126            redo_stack: Vec::new(),
127            data_table: None,
128            notification: Some(Notification {
129                level: NotificationLevel::Info,
130                message: "Ready. Load CSV/TXT from Files menu / Gotovo. Zagruzite CSV/TXT".to_owned(),
131            }),
132            next_series_id: 2,
133        }
134    }
135
136    /// Returns latest notification for status banner.
137    pub fn notification(&self) -> Option<&Notification> {
138        self.notification.as_ref()
139    }
140
141    /// Returns names of all loaded numeric columns.
142    pub fn available_columns(&self) -> Vec<String> {
143        self.data_table
144            .as_ref()
145            .map(|t| t.column_names())
146            .unwrap_or_default()
147    }
148
149    /// Indicates whether any dataset has been loaded.
150    pub fn has_data(&self) -> bool {
151        self.data_table.is_some()
152    }
153
154    /// EN: Public API for embedding editor in another crate.
155    /// RU: Public API dlya vstraivaniya redaktora v drugoy crate.
156    pub fn load_from_data_source(
157        &mut self,
158        source: &dyn DataSource,
159    ) -> Result<(), ControllerError> {
160        let table = DataTable::from_data_source(source).map_err(ControllerError::DataLoadFailed)?;
161        self.set_table(table)
162    }
163
164    /// Returns currently selected `(x, y)` points for a series.
165    pub fn points_for_series(&self, series_id: SeriesId) -> Result<Vec<(f32, f32)>, ControllerError> {
166        let table = self.data_table.as_ref().ok_or(ControllerError::NoDataLoaded)?;
167        let series = self.find_series(series_id)?;
168        table
169            .points_for_columns(&series.x_column, &series.y_column)
170            .map_err(ControllerError::DataLoadFailed)
171    }
172
173    /// Main action dispatcher invoked from the view each frame.
174    pub fn dispatch(&mut self, action: Action) -> Result<(), ControllerError> {
175        let result = match action {
176            Action::ImportFromCsv { path } => self.import_csv(&path),
177            Action::ImportFromTxt { path } => self.import_txt(&path),
178            Action::SetChartTitle(new_title) => {
179                let trimmed = new_title.trim().to_owned();
180                if trimmed.is_empty() {
181                    return self.fail(ControllerError::EmptyChartTitle);
182                }
183                self.execute_command(Command::SetChartTitle {
184                    old: self.model.layout.title.clone(),
185                    new: trimmed,
186                })
187            }
188            Action::SetAxisLabel { axis, label } => {
189                let trimmed = label.trim().to_owned();
190                if trimmed.is_empty() {
191                    return self.fail(ControllerError::EmptyAxisLabel);
192                }
193                self.execute_command(Command::SetAxisLabel {
194                    axis,
195                    old: self.axis(axis).label.clone(),
196                    new: trimmed,
197                })
198            }
199            Action::SetAxisLabelFontSize { axis, font_size } => {
200                self.execute_command(Command::SetAxisLabelFontSize {
201                    axis,
202                    old: self.axis(axis).label_font_size,
203                    new: font_size.max(8),
204                })
205            }
206            Action::SetAxisScale { axis, scale } => self.execute_command(Command::SetAxisScale {
207                axis,
208                old: self.axis(axis).scale,
209                new: scale,
210            }),
211            Action::SetAxisRange { axis, range } => {
212                if let RangePolicy::Manual { min, max } = range {
213                    if min >= max {
214                        return self.fail(ControllerError::InvalidRange { min, max });
215                    }
216                }
217                self.execute_command(Command::SetAxisRange {
218                    axis,
219                    old: self.axis(axis).range.clone(),
220                    new: range,
221                })
222            }
223            Action::SetAxisMajorTickStep { axis, step } => {
224                self.execute_command(Command::SetAxisMajorTickStep {
225                    axis,
226                    old: self.axis(axis).ticks.major_step,
227                    new: step,
228                })
229            }
230            Action::SetAxisMinorTicks { axis, per_major } => {
231                self.execute_command(Command::SetAxisMinorTicks {
232                    axis,
233                    old: self.axis(axis).ticks.minor_per_major,
234                    new: per_major,
235                })
236            }
237            Action::SetLegendVisible(visible) => {
238                let mut next = self.model.legend.clone();
239                next.visible = visible;
240                self.execute_command(Command::ReplaceLegend {
241                    old: self.model.legend.clone(),
242                    new: next,
243                })
244            }
245            Action::SetLegendTitle(title) => {
246                let mut next = self.model.legend.clone();
247                next.title = title.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
248                self.execute_command(Command::ReplaceLegend {
249                    old: self.model.legend.clone(),
250                    new: next,
251                })
252            }
253            Action::SetLegendPosition(position) => {
254                let mut next = self.model.legend.clone();
255                next.position = position;
256                self.execute_command(Command::ReplaceLegend {
257                    old: self.model.legend.clone(),
258                    new: next,
259                })
260            }
261            Action::SetLegendFontSize(font_size) => {
262                let mut next = self.model.legend.clone();
263                next.font_size = font_size.max(8);
264                self.execute_command(Command::ReplaceLegend {
265                    old: self.model.legend.clone(),
266                    new: next,
267                })
268            }
269            Action::SetLegendFontColor(color) => {
270                let mut next = self.model.legend.clone();
271                next.font_color = color;
272                self.execute_command(Command::ReplaceLegend {
273                    old: self.model.legend.clone(),
274                    new: next,
275                })
276            }
277            Action::SetLayoutMargin(margin) => {
278                let mut next = self.model.layout.clone();
279                next.margin = margin;
280                self.execute_command(Command::ReplaceLayout {
281                    old: self.model.layout.clone(),
282                    new: next,
283                })
284            }
285            Action::SetXLabelAreaSize(size) => {
286                let mut next = self.model.layout.clone();
287                next.x_label_area_size = size;
288                self.execute_command(Command::ReplaceLayout {
289                    old: self.model.layout.clone(),
290                    new: next,
291                })
292            }
293            Action::SetYLabelAreaSize(size) => {
294                let mut next = self.model.layout.clone();
295                next.y_label_area_size = size;
296                self.execute_command(Command::ReplaceLayout {
297                    old: self.model.layout.clone(),
298                    new: next,
299                })
300            }
301            Action::SetLabelFontSize(size) => {
302                let mut next = self.model.layout.clone();
303                next.title_font_size = size.max(8);
304                self.execute_command(Command::ReplaceLayout {
305                    old: self.model.layout.clone(),
306                    new: next,
307                })
308            }
309            Action::SetLabelFontColor(color) => {
310                let mut next = self.model.layout.clone();
311                next.title_font_color = color;
312                self.execute_command(Command::ReplaceLayout {
313                    old: self.model.layout.clone(),
314                    new: next,
315                })
316            }
317            Action::AddSeries { name, x_column, y_column } => {
318                let (default_x, default_y) = self.default_xy_columns()?;
319                let x_final = if x_column.is_empty() { default_x } else { x_column };
320                let y_final = if y_column.is_empty() { default_y } else { y_column };
321                self.ensure_column_exists(&x_final)?;
322                self.ensure_column_exists(&y_final)?;
323
324                let series = SeriesModel {
325                    id: SeriesId(self.next_series_id),
326                    name: if name.trim().is_empty() {
327                        format!("Series {}", self.next_series_id)
328                    } else {
329                        name
330                    },
331                    x_column: x_final,
332                    y_column: y_final,
333                    style: SeriesStyle {
334                        color: self.color_for_series(self.next_series_id),
335                        line_width: 2.0,
336                        line_style: LineStyle::Solid,
337                        marker: None,
338                    },
339                    visible: true,
340                };
341                self.next_series_id += 1;
342                self.execute_command(Command::AddSeries {
343                    series,
344                    index: self.model.series.len(),
345                })
346            }
347            Action::RemoveSeries { series_id } => {
348                if self.model.series.len() <= 1 {
349                    return self.fail(ControllerError::CannotRemoveLastSeries);
350                }
351                let Some((index, series)) = self.find_series_index(series_id) else {
352                    return self.fail(ControllerError::SeriesNotFound(series_id));
353                };
354                self.execute_command(Command::RemoveSeries { series, index })
355            }
356            Action::RenameSeries { series_id, name } => {
357                let old = self.find_series(series_id)?.name.clone();
358                self.execute_command(Command::RenameSeries {
359                    series_id,
360                    old,
361                    new: name,
362                })
363            }
364            Action::SetSeriesVisibility { series_id, visible } => {
365                let old = self.find_series(series_id)?.visible;
366                self.execute_command(Command::SetSeriesVisibility {
367                    series_id,
368                    old,
369                    new: visible,
370                })
371            }
372            Action::SetSeriesXColumn { series_id, x_column } => {
373                self.ensure_column_exists(&x_column)?;
374                let old = self.find_series(series_id)?.x_column.clone();
375                self.execute_command(Command::SetSeriesXColumn {
376                    series_id,
377                    old,
378                    new: x_column,
379                })
380            }
381            Action::SetSeriesYColumn { series_id, y_column } => {
382                self.ensure_column_exists(&y_column)?;
383                let old = self.find_series(series_id)?.y_column.clone();
384                self.execute_command(Command::SetSeriesYColumn {
385                    series_id,
386                    old,
387                    new: y_column,
388                })
389            }
390            Action::SetSeriesColor { series_id, color } => {
391                let old = self.find_series(series_id)?.style.color;
392                self.execute_command(Command::SetSeriesColor {
393                    series_id,
394                    old,
395                    new: color,
396                })
397            }
398            Action::SetSeriesLineWidth { series_id, width } => {
399                if width <= 0.0 {
400                    return self.fail(ControllerError::InvalidLineWidth(width));
401                }
402                let old = self.find_series(series_id)?.style.line_width;
403                self.execute_command(Command::SetSeriesLineWidth {
404                    series_id,
405                    old,
406                    new: width,
407                })
408            }
409            Action::SetSeriesLineStyle { series_id, line_style } => {
410                let old = self.find_series(series_id)?.style.line_style;
411                self.execute_command(Command::SetSeriesLineStyle {
412                    series_id,
413                    old,
414                    new: line_style,
415                })
416            }
417            Action::SetSeriesMarker { series_id, marker, size } => {
418                let old = self.find_series(series_id)?.style.marker.clone();
419                let new = marker.map(|shape| MarkerStyle { shape, size });
420                self.execute_command(Command::SetSeriesMarker {
421                    series_id,
422                    old,
423                    new,
424                })
425            }
426            Action::RequestSaveAs { path, format, size } => self.export_plot(&path, format, size),
427            Action::Undo => self.undo(),
428            Action::Redo => self.redo(),
429            other => self.fail(ControllerError::UnsupportedAction(match other {
430                Action::ResetPlot => "ResetPlot",
431                _ => "UnknownAction",
432            })),
433        };
434
435        if result.is_ok() {
436            self.notification = Some(Notification {
437                level: NotificationLevel::Info,
438                message: "Updated / Obnovleno".to_owned(),
439            });
440        }
441
442        result
443    }
444
445    fn import_csv(&mut self, path: &str) -> Result<(), ControllerError> {
446        let table = DataTable::from_csv_path(Path::new(path)).map_err(ControllerError::DataLoadFailed)?;
447        self.set_table(table)
448    }
449
450    fn import_txt(&mut self, path: &str) -> Result<(), ControllerError> {
451        let table = DataTable::from_txt_path(Path::new(path)).map_err(ControllerError::DataLoadFailed)?;
452        self.set_table(table)
453    }
454
455    fn set_table(&mut self, table: DataTable) -> Result<(), ControllerError> {
456        let names = table.column_names();
457        if names.len() < 2 {
458            return self.fail(ControllerError::DataLoadFailed(
459                "Need at least two numeric columns".to_owned(),
460            ));
461        }
462        self.data_table = Some(table);
463        let x = names[0].clone();
464        let y = names[1].clone();
465
466        if self.model.series.is_empty() {
467            self.model.series.push(SeriesModel {
468                id: SeriesId(1),
469                name: "Series 1".to_owned(),
470                x_column: x,
471                y_column: y,
472                style: SeriesStyle {
473                    color: Color {
474                        r: 220,
475                        g: 50,
476                        b: 47,
477                        a: 255,
478                    },
479                    line_width: 2.0,
480                    line_style: LineStyle::Solid,
481                    marker: None,
482                },
483                visible: true,
484            });
485        } else {
486            self.model.series[0].x_column = x;
487            self.model.series[0].y_column = y;
488        }
489
490        self.notification = Some(Notification {
491            level: NotificationLevel::Info,
492            message: "Data imported / Dannye zagruzheny".to_owned(),
493        });
494        Ok(())
495    }
496
497    fn default_xy_columns(&self) -> Result<(String, String), ControllerError> {
498        let table = self.data_table.as_ref().ok_or(ControllerError::NoDataLoaded)?;
499        let names = table.column_names();
500        if names.len() < 2 {
501            return Err(ControllerError::DataLoadFailed(
502                "Need at least two columns".to_owned(),
503            ));
504        }
505        Ok((names[0].clone(), names[1].clone()))
506    }
507
508    fn ensure_column_exists(&self, name: &str) -> Result<(), ControllerError> {
509        let table = self.data_table.as_ref().ok_or(ControllerError::NoDataLoaded)?;
510        if table.has_column(name) {
511            Ok(())
512        } else {
513            Err(ControllerError::ColumnNotFound(name.to_owned()))
514        }
515    }
516
517    fn export_plot(
518        &self,
519        path: &str,
520        format: ImageFormat,
521        size: ImageSize,
522    ) -> Result<(), ControllerError> {
523        match format {
524            ImageFormat::Png => {
525                let backend = BitMapBackend::new(path, (size.width, size.height));
526                let root = backend.into_drawing_area();
527                self.draw_export_chart(&root)?;
528                root.present()
529                    .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
530            }
531            ImageFormat::Svg => {
532                let backend = SVGBackend::new(path, (size.width, size.height));
533                let root = backend.into_drawing_area();
534                self.draw_export_chart(&root)?;
535                root.present()
536                    .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
537            }
538        }
539        Ok(())
540    }
541
542    fn draw_export_chart<DB: DrawingBackend>(
543        &self,
544        root: &DrawingArea<DB, Shift>,
545    ) -> Result<(), ControllerError> {
546        root.fill(&WHITE)
547            .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
548
549        if !self.has_data() {
550            return Err(ControllerError::NoDataLoaded);
551        }
552
553        let mut rendered = Vec::new();
554        for series in &self.model.series {
555            if !series.visible {
556                continue;
557            }
558            let points = self.points_for_series(series.id)?;
559            let scaled = points
560                .iter()
561                .copied()
562                .filter_map(|(x, y)| apply_scale(x, y, self.model.axes.x.scale, self.model.axes.y.scale))
563                .collect::<Vec<_>>();
564            rendered.push((series, scaled));
565        }
566
567        let x_range = resolve_range(&self.model.axes.x.range, &rendered, true, -1.0..1.0);
568        let y_range = resolve_range(&self.model.axes.y.range, &rendered, false, -1.0..1.0);
569
570        let mut chart = ChartBuilder::on(root)
571            .caption(
572                self.model.layout.title.clone(),
573                ("sans-serif", self.model.layout.title_font_size)
574                    .into_font()
575                    .color(&RGBColor(
576                        self.model.layout.title_font_color.r,
577                        self.model.layout.title_font_color.g,
578                        self.model.layout.title_font_color.b,
579                    )),
580            )
581            .margin(self.model.layout.margin)
582            .x_label_area_size(self.model.layout.x_label_area_size)
583            .y_label_area_size(self.model.layout.y_label_area_size)
584            .build_cartesian_2d(x_range.clone(), y_range.clone())
585            .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
586
587        configure_mesh(
588            &mut chart,
589            &format!(
590                "{}{}",
591                self.model.axes.x.label,
592                scale_suffix(self.model.axes.x.scale)
593            ),
594            &format!(
595                "{}{}",
596                self.model.axes.y.label,
597                scale_suffix(self.model.axes.y.scale)
598            ),
599            self.model.axes.x.label_font_size,
600            self.model.axes.y.label_font_size,
601            &self.model.axes.x.ticks,
602            &self.model.axes.y.ticks,
603            x_range,
604            y_range,
605        )?;
606
607        for (series, points) in &rendered {
608            if points.is_empty() {
609                continue;
610            }
611            let color = RGBColor(series.style.color.r, series.style.color.g, series.style.color.b);
612            let style = ShapeStyle::from(&color).stroke_width(series.style.line_width.max(1.0) as u32);
613
614            if series.style.line_style == LineStyle::Dotted {
615                chart
616                    .draw_series(points.iter().map(|(x, y)| Circle::new((*x, *y), 2, style.filled())))
617                    .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
618            } else {
619                let drawn = chart
620                    .draw_series(LineSeries::new(points.iter().copied(), style))
621                    .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
622                if self.model.legend.visible {
623                    drawn
624                        .label(series.name.clone())
625                        .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
626                }
627            }
628        }
629
630        if self.model.legend.visible {
631            chart
632                .configure_series_labels()
633                .label_font(
634                    ("sans-serif", self.model.legend.font_size)
635                        .into_font()
636                        .color(&RGBColor(
637                            self.model.legend.font_color.r,
638                            self.model.legend.font_color.g,
639                            self.model.legend.font_color.b,
640                        )),
641                )
642                .position(series_label_position(self.model.legend.position))
643                .background_style(WHITE.mix(0.8))
644                .border_style(BLACK)
645                .draw()
646                .map_err(|e| ControllerError::ExportFailed(e.to_string()))?;
647        }
648
649        Ok(())
650    }
651
652    fn undo(&mut self) -> Result<(), ControllerError> {
653        if let Some(cmd) = self.undo_stack.pop() {
654            self.apply_inverse_command(&cmd);
655            self.redo_stack.push(cmd);
656            Ok(())
657        } else {
658            self.notification = Some(Notification {
659                level: NotificationLevel::Info,
660                message: "Nothing to undo / Nechego otmenyat".to_owned(),
661            });
662            Ok(())
663        }
664    }
665
666    fn redo(&mut self) -> Result<(), ControllerError> {
667        if let Some(cmd) = self.redo_stack.pop() {
668            self.apply_command(&cmd);
669            self.undo_stack.push(cmd);
670            Ok(())
671        } else {
672            self.notification = Some(Notification {
673                level: NotificationLevel::Info,
674                message: "Nothing to redo / Nechego povtoryat".to_owned(),
675            });
676            Ok(())
677        }
678    }
679
680    fn fail<T>(&mut self, error: ControllerError) -> Result<T, ControllerError> {
681        self.notification = Some(Notification {
682            level: NotificationLevel::Error,
683            message: error.to_string(),
684        });
685        Err(error)
686    }
687
688    fn execute_command(&mut self, command: Command) -> Result<(), ControllerError> {
689        self.apply_command(&command);
690        self.undo_stack.push(command);
691        self.redo_stack.clear();
692        Ok(())
693    }
694
695    fn apply_command(&mut self, command: &Command) {
696        match command {
697            Command::SetChartTitle { new, .. } => self.model.layout.title = new.clone(),
698            Command::SetAxisLabel { axis, new, .. } => self.axis_mut(*axis).label = new.clone(),
699            Command::SetAxisLabelFontSize { axis, new, .. } => {
700                self.axis_mut(*axis).label_font_size = *new
701            }
702            Command::SetAxisScale { axis, new, .. } => self.axis_mut(*axis).scale = *new,
703            Command::SetAxisRange { axis, new, .. } => self.axis_mut(*axis).range = new.clone(),
704            Command::SetAxisMajorTickStep { axis, new, .. } => {
705                self.axis_mut(*axis).ticks.major_step = *new
706            }
707            Command::SetAxisMinorTicks { axis, new, .. } => {
708                self.axis_mut(*axis).ticks.minor_per_major = *new
709            }
710            Command::ReplaceAxisConfig { axis, new, .. } => *self.axis_mut(*axis) = new.clone(),
711            Command::AddSeries { series, index } => self.model.series.insert(*index, series.clone()),
712            Command::RemoveSeries { index, .. } => {
713                self.model.series.remove(*index);
714            }
715            Command::RenameSeries { series_id, new, .. } => {
716                if let Some(s) = self.find_series_mut_raw(*series_id) {
717                    s.name = new.clone();
718                }
719            }
720            Command::SetSeriesVisibility { series_id, new, .. } => {
721                if let Some(s) = self.find_series_mut_raw(*series_id) {
722                    s.visible = *new;
723                }
724            }
725            Command::SetSeriesXColumn { series_id, new, .. } => {
726                if let Some(s) = self.find_series_mut_raw(*series_id) {
727                    s.x_column = new.clone();
728                }
729            }
730            Command::SetSeriesYColumn { series_id, new, .. } => {
731                if let Some(s) = self.find_series_mut_raw(*series_id) {
732                    s.y_column = new.clone();
733                }
734            }
735            Command::SetSeriesLineWidth { series_id, new, .. } => {
736                if let Some(s) = self.find_series_mut_raw(*series_id) {
737                    s.style.line_width = *new;
738                }
739            }
740            Command::SetSeriesLineStyle { series_id, new, .. } => {
741                if let Some(s) = self.find_series_mut_raw(*series_id) {
742                    s.style.line_style = *new;
743                }
744            }
745            Command::SetSeriesColor { series_id, new, .. } => {
746                if let Some(s) = self.find_series_mut_raw(*series_id) {
747                    s.style.color = *new;
748                }
749            }
750            Command::SetSeriesMarker { series_id, new, .. } => {
751                if let Some(s) = self.find_series_mut_raw(*series_id) {
752                    s.style.marker = new.clone();
753                }
754            }
755            Command::ReplaceLegend { new, .. } => self.model.legend = new.clone(),
756            Command::ReplaceLayout { new, .. } => self.model.layout = new.clone(),
757            Command::Batch { commands } => {
758                for c in commands {
759                    self.apply_command(c);
760                }
761            }
762        }
763    }
764
765    fn apply_inverse_command(&mut self, command: &Command) {
766        match command {
767            Command::SetChartTitle { old, .. } => self.model.layout.title = old.clone(),
768            Command::SetAxisLabel { axis, old, .. } => self.axis_mut(*axis).label = old.clone(),
769            Command::SetAxisLabelFontSize { axis, old, .. } => {
770                self.axis_mut(*axis).label_font_size = *old
771            }
772            Command::SetAxisScale { axis, old, .. } => self.axis_mut(*axis).scale = *old,
773            Command::SetAxisRange { axis, old, .. } => self.axis_mut(*axis).range = old.clone(),
774            Command::SetAxisMajorTickStep { axis, old, .. } => self.axis_mut(*axis).ticks.major_step = *old,
775            Command::SetAxisMinorTicks { axis, old, .. } => self.axis_mut(*axis).ticks.minor_per_major = *old,
776            Command::ReplaceAxisConfig { axis, old, .. } => *self.axis_mut(*axis) = old.clone(),
777            Command::AddSeries { index, .. } => {
778                self.model.series.remove(*index);
779            }
780            Command::RemoveSeries { series, index } => self.model.series.insert(*index, series.clone()),
781            Command::RenameSeries { series_id, old, .. } => {
782                if let Some(s) = self.find_series_mut_raw(*series_id) {
783                    s.name = old.clone();
784                }
785            }
786            Command::SetSeriesVisibility { series_id, old, .. } => {
787                if let Some(s) = self.find_series_mut_raw(*series_id) {
788                    s.visible = *old;
789                }
790            }
791            Command::SetSeriesXColumn { series_id, old, .. } => {
792                if let Some(s) = self.find_series_mut_raw(*series_id) {
793                    s.x_column = old.clone();
794                }
795            }
796            Command::SetSeriesYColumn { series_id, old, .. } => {
797                if let Some(s) = self.find_series_mut_raw(*series_id) {
798                    s.y_column = old.clone();
799                }
800            }
801            Command::SetSeriesLineWidth { series_id, old, .. } => {
802                if let Some(s) = self.find_series_mut_raw(*series_id) {
803                    s.style.line_width = *old;
804                }
805            }
806            Command::SetSeriesLineStyle { series_id, old, .. } => {
807                if let Some(s) = self.find_series_mut_raw(*series_id) {
808                    s.style.line_style = *old;
809                }
810            }
811            Command::SetSeriesColor { series_id, old, .. } => {
812                if let Some(s) = self.find_series_mut_raw(*series_id) {
813                    s.style.color = *old;
814                }
815            }
816            Command::SetSeriesMarker { series_id, old, .. } => {
817                if let Some(s) = self.find_series_mut_raw(*series_id) {
818                    s.style.marker = old.clone();
819                }
820            }
821            Command::ReplaceLegend { old, .. } => self.model.legend = old.clone(),
822            Command::ReplaceLayout { old, .. } => self.model.layout = old.clone(),
823            Command::Batch { commands } => {
824                for c in commands.iter().rev() {
825                    self.apply_inverse_command(c);
826                }
827            }
828        }
829    }
830
831    fn axis(&self, axis: AxisKind) -> &AxisConfig {
832        match axis {
833            AxisKind::X => &self.model.axes.x,
834            AxisKind::Y => &self.model.axes.y,
835        }
836    }
837
838    fn axis_mut(&mut self, axis: AxisKind) -> &mut AxisConfig {
839        match axis {
840            AxisKind::X => &mut self.model.axes.x,
841            AxisKind::Y => &mut self.model.axes.y,
842        }
843    }
844
845    fn find_series(&self, series_id: SeriesId) -> Result<&SeriesModel, ControllerError> {
846        self.model
847            .series
848            .iter()
849            .find(|s| s.id == series_id)
850            .ok_or(ControllerError::SeriesNotFound(series_id))
851    }
852
853    fn find_series_mut_raw(&mut self, series_id: SeriesId) -> Option<&mut SeriesModel> {
854        self.model.series.iter_mut().find(|s| s.id == series_id)
855    }
856
857    fn find_series_index(&self, series_id: SeriesId) -> Option<(usize, SeriesModel)> {
858        self.model
859            .series
860            .iter()
861            .enumerate()
862            .find(|(_, s)| s.id == series_id)
863            .map(|(idx, s)| (idx, s.clone()))
864    }
865
866    fn color_for_series(&self, n: u64) -> Color {
867        match n % 6 {
868            0 => Color { r: 220, g: 50, b: 47, a: 255 },
869            1 => Color { r: 38, g: 139, b: 210, a: 255 },
870            2 => Color { r: 133, g: 153, b: 0, a: 255 },
871            3 => Color { r: 203, g: 75, b: 22, a: 255 },
872            4 => Color { r: 42, g: 161, b: 152, a: 255 },
873            _ => Color { r: 108, g: 113, b: 196, a: 255 },
874        }
875    }
876}
877
878impl Default for PlotModel {
879    fn default() -> Self {
880        Self {
881            axes: AxesConfig {
882                x: AxisConfig {
883                    label: "X".to_owned(),
884                    label_font_size: 16,
885                    scale: ScaleType::Linear,
886                    range: RangePolicy::Auto,
887                    ticks: TickConfig {
888                        major_step: None,
889                        minor_per_major: 4,
890                    },
891                },
892                y: AxisConfig {
893                    label: "Y".to_owned(),
894                    label_font_size: 16,
895                    scale: ScaleType::Linear,
896                    range: RangePolicy::Auto,
897                    ticks: TickConfig {
898                        major_step: None,
899                        minor_per_major: 4,
900                    },
901                },
902            },
903            series: vec![SeriesModel {
904                id: SeriesId(1),
905                name: "Series 1".to_owned(),
906                x_column: String::new(),
907                y_column: String::new(),
908                style: SeriesStyle {
909                    color: Color {
910                        r: 220,
911                        g: 50,
912                        b: 47,
913                        a: 255,
914                    },
915                    line_width: 2.0,
916                    line_style: LineStyle::Solid,
917                    marker: None,
918                },
919                visible: true,
920            }],
921            legend: LegendConfig {
922                visible: true,
923                title: Some("Legend".to_owned()),
924                position: LegendPosition::TopRight,
925                font_size: 16,
926                font_color: Color {
927                    r: 20,
928                    g: 20,
929                    b: 20,
930                    a: 255,
931                },
932            },
933            layout: LayoutConfig {
934                title: "Plot".to_owned(),
935                x_label_area_size: 35,
936                y_label_area_size: 35,
937                margin: 8,
938                title_font_size: 24,
939                title_font_color: Color {
940                    r: 20,
941                    g: 20,
942                    b: 20,
943                    a: 255,
944                },
945            },
946        }
947    }
948}
949
950fn resolve_range(
951    policy: &RangePolicy,
952    data: &[(&SeriesModel, Vec<(f32, f32)>)],
953    is_x: bool,
954    fallback: std::ops::Range<f32>,
955) -> std::ops::Range<f32> {
956    match policy {
957        RangePolicy::Manual { min, max } => (*min as f32)..(*max as f32),
958        RangePolicy::Auto => {
959            let mut min_v = f32::INFINITY;
960            let mut max_v = f32::NEG_INFINITY;
961            for (_, points) in data {
962                for (x, y) in points {
963                    let v = if is_x { *x } else { *y };
964                    min_v = min_v.min(v);
965                    max_v = max_v.max(v);
966                }
967            }
968            if !min_v.is_finite() || !max_v.is_finite() || min_v >= max_v {
969                return fallback;
970            }
971            let pad = ((max_v - min_v) * 0.05).max(0.1);
972            (min_v - pad)..(max_v + pad)
973        }
974    }
975}
976
977fn apply_scale(x: f32, y: f32, x_scale: ScaleType, y_scale: ScaleType) -> Option<(f32, f32)> {
978    let sx = match x_scale {
979        ScaleType::Linear => Some(x),
980        ScaleType::Log10 => (x > 0.0).then(|| x.log10()),
981        ScaleType::LogE => (x > 0.0).then(|| x.ln()),
982    }?;
983    let sy = match y_scale {
984        ScaleType::Linear => Some(y),
985        ScaleType::Log10 => (y > 0.0).then(|| y.log10()),
986        ScaleType::LogE => (y > 0.0).then(|| y.ln()),
987    }?;
988    Some((sx, sy))
989}
990
991fn configure_mesh<DB: DrawingBackend>(
992    chart: &mut ChartContext<'_, DB, Cartesian2d<RangedCoordf32, RangedCoordf32>>,
993    x_label: &str,
994    y_label: &str,
995    x_label_font_size: u32,
996    y_label_font_size: u32,
997    x_ticks: &TickConfig,
998    y_ticks: &TickConfig,
999    x_range: std::ops::Range<f32>,
1000    y_range: std::ops::Range<f32>,
1001) -> Result<(), ControllerError> {
1002    let x_labels = labels_from_step(x_range, x_ticks.major_step).unwrap_or(10);
1003    let y_labels = labels_from_step(y_range, y_ticks.major_step).unwrap_or(10);
1004
1005    chart
1006        .configure_mesh()
1007        .x_desc(x_label)
1008        .y_desc(y_label)
1009        .axis_desc_style(("sans-serif", x_label_font_size.max(y_label_font_size)))
1010        .x_labels(x_labels)
1011        .y_labels(y_labels)
1012        .max_light_lines(x_ticks.minor_per_major.max(y_ticks.minor_per_major) as usize)
1013        .draw()
1014        .map_err(|e| ControllerError::ExportFailed(e.to_string()))
1015}
1016
1017fn labels_from_step(range: std::ops::Range<f32>, step: Option<f64>) -> Option<usize> {
1018    let step = step?;
1019    if step <= 0.0 {
1020        return None;
1021    }
1022    let span = (range.end - range.start).abs() as f64;
1023    if span <= 0.0 {
1024        return None;
1025    }
1026    Some(((span / step).round() as usize + 1).clamp(2, 100))
1027}
1028
1029fn scale_suffix(scale: ScaleType) -> &'static str {
1030    match scale {
1031        ScaleType::Linear => "",
1032        ScaleType::Log10 => " [log10]",
1033        ScaleType::LogE => " [ln]",
1034    }
1035}
1036
1037fn series_label_position(value: LegendPosition) -> SeriesLabelPosition {
1038    match value {
1039        LegendPosition::TopLeft => SeriesLabelPosition::UpperLeft,
1040        LegendPosition::TopRight => SeriesLabelPosition::UpperRight,
1041        LegendPosition::BottomLeft => SeriesLabelPosition::LowerLeft,
1042        LegendPosition::BottomRight => SeriesLabelPosition::LowerRight,
1043    }
1044}