Skip to main content

kithe_plot/view/
app.rs

1//! Main editor window composition.
2//!
3//! Dataflow: render from controller state, collect user interactions, emit actions.
4//!
5//! Architecture notes:
6//! - This module is the egui-based View (no direct mutation of the Model). It reads from
7//!   PlotController and emits Action values which the Controller translates to Commands.
8//! - Plot drawing uses egui_plotter with Plotters backend. We build Chart in a callback
9//!   to ensure consistent layout both on-screen and in export.
10//! - Label areas (x/y) are computed with heuristics to prevent overlap between tick labels
11//!   and axis titles. See effective_y_label_area calculation and draw_axis_titles.
12
13use std::ops::Range;
14
15use crate::controller::action::Action;
16use crate::controller::{NotificationLevel, PlotController};
17use crate::model::{
18    AxisKind, LegendPosition, LineStyle, MarkerShape, RangePolicy, ScaleType, TickConfig,
19};
20use crate::view::FilesMenu;
21use eframe::egui::{self, Color32, RichText, SidePanel};
22use egui_plotter::{Chart, MouseConfig};
23use plotters::coord::Shift;
24use plotters::coord::types::RangedCoordf32;
25use plotters::prelude::*;
26use plotters::style::Color as PlottersColor;
27
28/// EN: Main editor view. Contains menu, control panels and plot area.
29/// RU: Osnovnoe predstavlenie. Soderzhit menyu, paneli upravleniya i oblast grafa.
30pub struct PlotEditorView;
31
32impl PlotEditorView {
33    /// Creates editor view state.
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// Renders full UI and collects actions requested by user interactions.
39    pub fn draw(&mut self, ctx: &egui::Context, controller: &PlotController) -> Vec<Action> {
40        let mut actions = FilesMenu::draw(ctx);
41        self.draw_controls(ctx, controller, &mut actions);
42        self.draw_plot(ctx, controller);
43        actions
44    }
45
46    fn draw_controls(
47        &mut self,
48        ctx: &egui::Context,
49        controller: &PlotController,
50        actions: &mut Vec<Action>,
51    ) {
52        SidePanel::right("control_panel")
53            .resizable(true)
54            .default_width(360.0)
55            .show(ctx, |ui| {
56                ui.heading("Controls");
57                ui.separator();
58
59                if let Some(n) = controller.notification() {
60                    let color = match n.level {
61                        NotificationLevel::Info => Color32::DARK_GREEN,
62                        NotificationLevel::Error => Color32::RED,
63                    };
64                    ui.colored_label(color, &n.message);
65                    ui.separator();
66                }
67
68                ui.label(RichText::new("Axis").strong());
69                axis_editor(ui, "X Axis", AxisKind::X, controller, actions);
70                axis_editor(ui, "Y Axis", AxisKind::Y, controller, actions);
71
72                ui.separator();
73                ui.label(RichText::new("Legend").strong());
74                let mut legend_visible = controller.model.legend.visible;
75                if ui.checkbox(&mut legend_visible, "Visible").changed() {
76                    actions.push(Action::SetLegendVisible(legend_visible));
77                }
78                let mut legend_title = controller.model.legend.title.clone().unwrap_or_default();
79                if ui.text_edit_singleline(&mut legend_title).changed() {
80                    actions.push(Action::SetLegendTitle(if legend_title.trim().is_empty() {
81                        None
82                    } else {
83                        Some(legend_title)
84                    }));
85                }
86                let mut legend_pos = controller.model.legend.position;
87                egui::ComboBox::from_label("Position")
88                    .selected_text(legend_position_text(legend_pos))
89                    .show_ui(ui, |ui| {
90                        ui.selectable_value(&mut legend_pos, LegendPosition::TopLeft, "Top Left");
91                        ui.selectable_value(
92                            &mut legend_pos,
93                            LegendPosition::TopRight,
94                            "Top Right",
95                        );
96                        ui.selectable_value(
97                            &mut legend_pos,
98                            LegendPosition::BottomLeft,
99                            "Bottom Left",
100                        );
101                        ui.selectable_value(
102                            &mut legend_pos,
103                            LegendPosition::BottomRight,
104                            "Bottom Right",
105                        );
106                    });
107                if legend_pos != controller.model.legend.position {
108                    actions.push(Action::SetLegendPosition(legend_pos));
109                }
110                let mut legend_font_size = controller.model.legend.font_size;
111                if ui
112                    .add(
113                        egui::Slider::new(&mut legend_font_size, 8..=64)
114                            .text("Legend font size"),
115                    )
116                    .changed()
117                {
118                    actions.push(Action::SetLegendFontSize(legend_font_size));
119                }
120                let mut legend_color = Color32::from_rgba_premultiplied(
121                    controller.model.legend.font_color.r,
122                    controller.model.legend.font_color.g,
123                    controller.model.legend.font_color.b,
124                    controller.model.legend.font_color.a,
125                );
126                if ui.color_edit_button_srgba(&mut legend_color).changed() {
127                    actions.push(Action::SetLegendFontColor(crate::model::Color {
128                        r: legend_color.r(),
129                        g: legend_color.g(),
130                        b: legend_color.b(),
131                        a: legend_color.a(),
132                    }));
133                }
134
135                ui.separator();
136                ui.label(RichText::new("Label").strong());
137                let mut title = controller.model.layout.title.clone();
138                if ui.text_edit_singleline(&mut title).changed() {
139                    actions.push(Action::SetChartTitle(title));
140                }
141                let mut title_font_size = controller.model.layout.title_font_size;
142                if ui
143                    .add(egui::Slider::new(&mut title_font_size, 8..=72).text("Title font size"))
144                    .changed()
145                {
146                    actions.push(Action::SetLabelFontSize(title_font_size));
147                }
148                let mut title_color = Color32::from_rgba_premultiplied(
149                    controller.model.layout.title_font_color.r,
150                    controller.model.layout.title_font_color.g,
151                    controller.model.layout.title_font_color.b,
152                    controller.model.layout.title_font_color.a,
153                );
154                if ui.color_edit_button_srgba(&mut title_color).changed() {
155                    actions.push(Action::SetLabelFontColor(crate::model::Color {
156                        r: title_color.r(),
157                        g: title_color.g(),
158                        b: title_color.b(),
159                        a: title_color.a(),
160                    }));
161                }
162
163                ui.separator();
164                ui.label(RichText::new("Series").strong());
165                let columns = controller.available_columns();
166
167                for series in &controller.model.series {
168                    ui.push_id(series.id.0, |ui| {
169                        ui.group(|ui| {
170                        ui.horizontal(|ui| {
171                            ui.label(format!("ID {}", series.id.0));
172                            if ui.button("Remove").clicked() {
173                                actions.push(Action::RemoveSeries {
174                                    series_id: series.id,
175                                });
176                            }
177                        });
178
179                        let mut name = series.name.clone();
180                        if ui.text_edit_singleline(&mut name).changed() {
181                            actions.push(Action::RenameSeries {
182                                series_id: series.id,
183                                name,
184                            });
185                        }
186
187                        let mut visible = series.visible;
188                        if ui.checkbox(&mut visible, "Visible").changed() {
189                            actions.push(Action::SetSeriesVisibility {
190                                series_id: series.id,
191                                visible,
192                            });
193                        }
194
195                        if columns.is_empty() {
196                            ui.label("Load CSV/TXT to select X/Y columns");
197                        } else {
198                            let mut x_col = if series.x_column.is_empty() {
199                                columns[0].clone()
200                            } else {
201                                series.x_column.clone()
202                            };
203                            egui::ComboBox::from_label("X column")
204                                .selected_text(x_col.clone())
205                                .show_ui(ui, |ui| {
206                                    for col in &columns {
207                                        ui.selectable_value(&mut x_col, col.clone(), col);
208                                    }
209                                });
210                            if x_col != series.x_column {
211                                actions.push(Action::SetSeriesXColumn {
212                                    series_id: series.id,
213                                    x_column: x_col,
214                                });
215                            }
216
217                            let default_y =
218                                columns.get(1).cloned().unwrap_or_else(|| columns[0].clone());
219                            let mut y_col = if series.y_column.is_empty() {
220                                default_y
221                            } else {
222                                series.y_column.clone()
223                            };
224                            egui::ComboBox::from_label("Y column")
225                                .selected_text(y_col.clone())
226                                .show_ui(ui, |ui| {
227                                    for col in &columns {
228                                        ui.selectable_value(&mut y_col, col.clone(), col);
229                                    }
230                                });
231                            if y_col != series.y_column {
232                                actions.push(Action::SetSeriesYColumn {
233                                    series_id: series.id,
234                                    y_column: y_col,
235                                });
236                            }
237                        }
238
239                        let mut width = series.style.line_width;
240                        if ui
241                            .add(egui::Slider::new(&mut width, 1.0..=10.0).text("Width"))
242                            .changed()
243                        {
244                            actions.push(Action::SetSeriesLineWidth {
245                                series_id: series.id,
246                                width,
247                            });
248                        }
249
250                        let mut style = series.style.line_style;
251                        egui::ComboBox::from_label("Line style")
252                            .selected_text(line_style_text(style))
253                            .show_ui(ui, |ui| {
254                                ui.selectable_value(&mut style, LineStyle::Solid, "Solid");
255                                ui.selectable_value(&mut style, LineStyle::Dashed, "Dashed");
256                                ui.selectable_value(&mut style, LineStyle::Dotted, "Dotted");
257                            });
258                        if style != series.style.line_style {
259                            actions.push(Action::SetSeriesLineStyle {
260                                series_id: series.id,
261                                line_style: style,
262                            });
263                        }
264
265                        let mut color = Color32::from_rgba_premultiplied(
266                            series.style.color.r,
267                            series.style.color.g,
268                            series.style.color.b,
269                            series.style.color.a,
270                        );
271                        if ui.color_edit_button_srgba(&mut color).changed() {
272                            actions.push(Action::SetSeriesColor {
273                                series_id: series.id,
274                                color: crate::model::Color {
275                                    r: color.r(),
276                                    g: color.g(),
277                                    b: color.b(),
278                                    a: color.a(),
279                                },
280                            });
281                        }
282
283                        let mut marker_enabled = series.style.marker.is_some();
284                        if ui.checkbox(&mut marker_enabled, "Marker").changed() {
285                            actions.push(Action::SetSeriesMarker {
286                                series_id: series.id,
287                                marker: if marker_enabled {
288                                    Some(MarkerShape::Circle)
289                                } else {
290                                    None
291                                },
292                                size: series.style.marker.as_ref().map(|m| m.size).unwrap_or(3.0),
293                            });
294                        }
295                        });
296                    });
297                    ui.add_space(8.0);
298                }
299
300                if ui.button("Add series").clicked() {
301                    actions.push(Action::AddSeries {
302                        name: String::new(),
303                        x_column: String::new(),
304                        y_column: String::new(),
305                    });
306                }
307                ui.horizontal(|ui| {
308                    if ui.button("Undo").clicked() {
309                        actions.push(Action::Undo);
310                    }
311                    if ui.button("Redo").clicked() {
312                        actions.push(Action::Redo);
313                    }
314                });
315            });
316    }
317
318    fn draw_plot(&mut self, ctx: &egui::Context, controller: &PlotController) {
319        egui::CentralPanel::default().show(ctx, |ui| {
320            if !controller.has_data() {
321                ui.heading("No data loaded");
322                ui.label("Use Files > From CSV or Files > From TXT");
323                return;
324            }
325
326            let mut rendered = Vec::new();
327            for series in &controller.model.series {
328                if !series.visible {
329                    continue;
330                }
331                if let Ok(points) = controller.points_for_series(series.id) {
332                    let scaled = points
333                        .iter()
334                        .copied()
335                        .filter_map(|(x, y)| {
336                            apply_scale(
337                                x,
338                                y,
339                                controller.model.axes.x.scale,
340                                controller.model.axes.y.scale,
341                            )
342                        })
343                        .collect::<Vec<_>>();
344                    rendered.push((series.clone(), scaled));
345                }
346            }
347
348            let x_range = resolve_range(
349                &controller.model.axes.x.range,
350                &rendered,
351                true,
352                -1.0..1.0,
353            );
354            let y_range = resolve_range(
355                &controller.model.axes.y.range,
356                &rendered,
357                false,
358                -1.0..1.0,
359            );
360
361            let title = controller.model.layout.title.clone();
362            let x_label = format!(
363                "{}{}",
364                controller.model.axes.x.label,
365                scale_suffix(controller.model.axes.x.scale)
366            );
367            let y_label = format!(
368                "{}{}",
369                controller.model.axes.y.label,
370                scale_suffix(controller.model.axes.y.scale)
371            );
372            let x_ticks = controller.model.axes.x.ticks.clone();
373            let y_ticks = controller.model.axes.y.ticks.clone();
374            let x_axis_title_font_size = controller.model.axes.x.axis_title_font_size;
375            let y_axis_title_font_size = controller.model.axes.y.axis_title_font_size;
376            let x_label_font_size = controller.model.axes.x.label_font_size;
377            let y_label_font_size = controller.model.axes.y.label_font_size;
378            let legend_visible = controller.model.legend.visible;
379            let legend_pos = controller.model.legend.position;
380            let legend_font_size = controller.model.legend.font_size;
381            let legend_font_color = RGBColor(
382                controller.model.legend.font_color.r,
383                controller.model.legend.font_color.g,
384                controller.model.legend.font_color.b,
385            );
386            let margin = controller.model.layout.margin;
387            let x_label_area = controller.model.layout.x_label_area_size;
388            let y_label_area = controller.model.layout.y_label_area_size;
389            let effective_x_label_area = x_label_area
390                .max(x_label_font_size + 18)
391                .max(x_axis_title_font_size + 20);
392            // Compute Y label area with extra padding to avoid overlap between tick labels and Y-axis title.
393            // Heuristic: leave at least 0.6x of max tick label font + title font + baseline padding.
394            // This mirrors export path in controller but adds a bit more room for runtime rendering differences.
395            let effective_y_label_area = y_label_area
396                .max((y_label_font_size as f32 * 1.6) as u32 + 16)
397                .max(y_axis_title_font_size + 28)
398                .max(y_label_font_size + y_axis_title_font_size + 28);
399            let title_font_size = controller.model.layout.title_font_size;
400            let title_font_color = RGBColor(
401                controller.model.layout.title_font_color.r,
402                controller.model.layout.title_font_color.g,
403                controller.model.layout.title_font_color.b,
404            );
405
406            let mut chart = Chart::new((x_range.clone(), y_range.clone()))
407                .mouse(MouseConfig::enabled())
408                .builder_cb(Box::new(move |area, _t, _ranges| {
409                    let mut chart = ChartBuilder::on(area)
410                        .caption(
411                            title.clone(),
412                            ("sans-serif", title_font_size)
413                                .into_font()
414                                .color(&title_font_color),
415                        )
416                        .margin(margin)
417                        .x_label_area_size(effective_x_label_area)
418                        .y_label_area_size(effective_y_label_area)
419                        .build_cartesian_2d(x_range.clone(), y_range.clone())
420                        .expect("build chart failed");
421
422                    configure_mesh(
423                        &mut chart,
424                        x_label_font_size,
425                        y_label_font_size,
426                        &x_ticks,
427                        &y_ticks,
428                        x_range.clone(),
429                        y_range.clone(),
430                    );
431                    draw_axis_titles(
432                        area,
433                        &x_label,
434                        &y_label,
435                        x_axis_title_font_size,
436                        y_axis_title_font_size,
437                        effective_x_label_area,
438                        effective_y_label_area,
439                        title_font_size,
440                        margin,
441                    );
442
443                    for (series, points) in &rendered {
444                        if points.is_empty() {
445                            continue;
446                        }
447                        let color = RGBColor(
448                            series.style.color.r,
449                            series.style.color.g,
450                            series.style.color.b,
451                        );
452                        let style = ShapeStyle::from(&color)
453                            .stroke_width(series.style.line_width.max(1.0) as u32);
454
455                        if series.style.line_style == LineStyle::Dotted {
456                            let _ = chart.draw_series(
457                                points
458                                    .iter()
459                                    .map(|(x, y)| Circle::new((*x, *y), 2, style.filled())),
460                            );
461                        } else {
462                            let drawn = chart
463                                .draw_series(LineSeries::new(points.iter().copied(), style))
464                                .expect("draw series failed");
465                            if legend_visible {
466                                drawn.label(series.name.clone()).legend(move |(x, y)| {
467                                    PathElement::new(vec![(x, y), (x + 20, y)], color)
468                                });
469                            }
470                        }
471                    }
472
473                    if legend_visible {
474                        chart
475                            .configure_series_labels()
476                            .label_font(
477                                ("sans-serif", legend_font_size)
478                                    .into_font()
479                                    .color(&legend_font_color),
480                            )
481                            .position(series_label_position(legend_pos))
482                            .background_style(WHITE.mix(0.8))
483                            .border_style(BLACK)
484                            .draw()
485                            .expect("draw legend failed");
486                    }
487                }));
488
489            chart.draw(ui);
490        });
491    }
492}
493
494fn axis_editor(
495    ui: &mut egui::Ui,
496    title: &str,
497    axis: AxisKind,
498    controller: &PlotController,
499    actions: &mut Vec<Action>,
500) {
501    ui.push_id(title, |ui| {
502        ui.collapsing(title, |ui| {
503        let axis_ref = match axis {
504            AxisKind::X => &controller.model.axes.x,
505            AxisKind::Y => &controller.model.axes.y,
506        };
507
508        let mut label = axis_ref.label.clone();
509        if ui.text_edit_singleline(&mut label).changed() {
510            actions.push(Action::SetAxisLabel { axis, label });
511        }
512        ui.horizontal(|ui| {
513            ui.label("Label font");
514            let mut label_font = axis_ref.axis_title_font_size;
515            let slider_changed = ui
516                .add(egui::Slider::new(&mut label_font, 8..=200).show_value(false))
517                .changed();
518            let input_changed = ui
519                .add(egui::DragValue::new(&mut label_font).range(8..=200))
520                .changed();
521            if (slider_changed || input_changed) && label_font != axis_ref.axis_title_font_size {
522                actions.push(Action::SetAxisTitleFontSize {
523                    axis,
524                    font_size: label_font,
525                });
526            }
527        });
528
529        ui.horizontal(|ui| {
530            ui.label("Tick font");
531            let mut tick_font = axis_ref.label_font_size;
532            let slider_changed = ui
533                .add(egui::Slider::new(&mut tick_font, 8..=200).show_value(false))
534                .changed();
535            let input_changed = ui
536                .add(egui::DragValue::new(&mut tick_font).range(8..=200))
537                .changed();
538            if (slider_changed || input_changed) && tick_font != axis_ref.label_font_size {
539                actions.push(Action::SetAxisLabelFontSize {
540                    axis,
541                    font_size: tick_font,
542                });
543            }
544        });
545
546        let mut scale = axis_ref.scale;
547        egui::ComboBox::from_label("Scale")
548            .selected_text(scale_text(scale))
549            .show_ui(ui, |ui| {
550                ui.selectable_value(&mut scale, ScaleType::Linear, "Linear");
551                ui.selectable_value(&mut scale, ScaleType::Log10, "Log10");
552                ui.selectable_value(&mut scale, ScaleType::LogE, "LogE");
553            });
554        if scale != axis_ref.scale {
555            actions.push(Action::SetAxisScale { axis, scale });
556        }
557
558        let mut auto = matches!(axis_ref.range, RangePolicy::Auto);
559        ui.horizontal(|ui| {
560            if ui.radio_value(&mut auto, true, "Auto").clicked() {
561                actions.push(Action::SetAxisRange {
562                    axis,
563                    range: RangePolicy::Auto,
564                });
565            }
566            if ui.radio_value(&mut auto, false, "Manual").clicked()
567                && !matches!(axis_ref.range, RangePolicy::Manual { .. })
568            {
569                actions.push(Action::SetAxisRange {
570                    axis,
571                    range: RangePolicy::Manual {
572                        min: -1.0,
573                        max: 1.0,
574                    },
575                });
576            }
577        });
578
579        let (mut min, mut max) = match axis_ref.range {
580            RangePolicy::Auto => (-1.0, 1.0),
581            RangePolicy::Manual { min, max } => (min, max),
582        };
583        ui.horizontal(|ui| {
584            ui.label("Min");
585            let min_changed = ui.add(egui::DragValue::new(&mut min).speed(0.1)).changed();
586            ui.label("Max");
587            let max_changed = ui.add(egui::DragValue::new(&mut max).speed(0.1)).changed();
588            if (min_changed || max_changed) && !auto {
589                actions.push(Action::SetAxisRange {
590                    axis,
591                    range: RangePolicy::Manual { min, max },
592                });
593            }
594        });
595
596        ui.separator();
597        ui.label("Ticks");
598
599        let mut major_auto = axis_ref.ticks.major_step.is_none();
600        if ui.checkbox(&mut major_auto, "Auto major step").changed() {
601            actions.push(Action::SetAxisMajorTickStep {
602                axis,
603                step: if major_auto { None } else { Some(1.0) },
604            });
605        }
606        if !major_auto {
607            let mut step = axis_ref.ticks.major_step.unwrap_or(1.0);
608            if ui
609                .add(
610                    egui::DragValue::new(&mut step)
611                        .speed(0.1)
612                        .range(0.01..=1_000.0),
613                )
614                .changed()
615            {
616                actions.push(Action::SetAxisMajorTickStep {
617                    axis,
618                    step: Some(step),
619                });
620            }
621        }
622
623        let mut minor = axis_ref.ticks.minor_per_major;
624        ui.horizontal(|ui| {
625            ui.label("Minor per major");
626            if ui
627                .add(egui::DragValue::new(&mut minor).range(0..=20))
628                .changed()
629            {
630                actions.push(Action::SetAxisMinorTicks {
631                    axis,
632                    per_major: minor,
633                });
634            }
635        });
636        });
637    });
638}
639
640fn resolve_range(
641    policy: &RangePolicy,
642    data: &[(crate::model::SeriesModel, Vec<(f32, f32)>)],
643    is_x: bool,
644    fallback: Range<f32>,
645) -> Range<f32> {
646    match policy {
647        RangePolicy::Manual { min, max } => (*min as f32)..(*max as f32),
648        RangePolicy::Auto => {
649            let mut min_v = f32::INFINITY;
650            let mut max_v = f32::NEG_INFINITY;
651            for (_, points) in data {
652                for (x, y) in points {
653                    let v = if is_x { *x } else { *y };
654                    min_v = min_v.min(v);
655                    max_v = max_v.max(v);
656                }
657            }
658            if !min_v.is_finite() || !max_v.is_finite() || min_v >= max_v {
659                return fallback;
660            }
661            let pad = ((max_v - min_v) * 0.05).max(0.1);
662            (min_v - pad)..(max_v + pad)
663        }
664    }
665}
666
667fn apply_scale(x: f32, y: f32, x_scale: ScaleType, y_scale: ScaleType) -> Option<(f32, f32)> {
668    let sx = match x_scale {
669        ScaleType::Linear => Some(x),
670        ScaleType::Log10 => (x > 0.0).then(|| x.log10()),
671        ScaleType::LogE => (x > 0.0).then(|| x.ln()),
672    }?;
673    let sy = match y_scale {
674        ScaleType::Linear => Some(y),
675        ScaleType::Log10 => (y > 0.0).then(|| y.log10()),
676        ScaleType::LogE => (y > 0.0).then(|| y.ln()),
677    }?;
678    Some((sx, sy))
679}
680
681fn configure_mesh<DB: DrawingBackend>(
682    chart: &mut ChartContext<'_, DB, Cartesian2d<RangedCoordf32, RangedCoordf32>>,
683    x_label_font_size: u32,
684    y_label_font_size: u32,
685    x_ticks: &TickConfig,
686    y_ticks: &TickConfig,
687    x_range: Range<f32>,
688    y_range: Range<f32>,
689) {
690    let x_labels = labels_from_step(x_range, x_ticks.major_step).unwrap_or(10);
691    let y_labels = labels_from_step(y_range, y_ticks.major_step).unwrap_or(10);
692
693    chart
694        .configure_mesh()
695        .x_desc("")
696        .y_desc("")
697        .x_label_style(("sans-serif", x_label_font_size))
698        .y_label_style(("sans-serif", y_label_font_size))
699        .x_labels(x_labels)
700        .y_labels(y_labels)
701        .max_light_lines(x_ticks.minor_per_major.max(y_ticks.minor_per_major) as usize)
702        .draw()
703        .expect("draw mesh failed");
704}
705
706fn draw_axis_titles<DB: DrawingBackend>(
707    area: &DrawingArea<DB, Shift>,
708    x_label: &str,
709    y_label: &str,
710    x_font_size: u32,
711    y_font_size: u32,
712    x_label_area: u32,
713    y_label_area: u32,
714    title_font_size: u32,
715    margin: u32,
716) {
717    // Draw axis titles anchored to the plotting area's geometry, not the full canvas,
718    // to prevent vertical drift when font sizes change.
719    let (w, h) = area.dim_in_pixel();
720    let x_style = ("sans-serif", x_font_size.max(8)).into_font().color(&BLACK);
721    let y_style = ("sans-serif", y_font_size.max(8))
722        .into_font()
723        .transform(plotters::style::FontTransform::Rotate270)
724        .color(&BLACK);
725
726    // Approximate caption (top) height based on title font plus padding, consistent with ChartBuilder::caption
727    let cap_h = (title_font_size as i32 + 10).max(12);
728    let m = margin as i32;
729
730    // Compute vertical center of the plotting area between top (caption + margin) and bottom (x label area + margin)
731    let top_y = cap_h + m;
732    let bottom_y = h as i32 - (x_label_area as i32) - m;
733    let plot_center_y = (top_y + bottom_y) / 2;
734
735    // Center X label within bottom label area
736    let _ = area.draw(&Text::new(
737        x_label.to_owned(),
738        (w as i32 / 2, h as i32 - (x_label_area as i32 / 2).max(10)),
739        x_style,
740    ));
741
742    // Place Y title safely away from tick numbers: bias to left quarter of label area
743    let y_x = (y_label_area as i32 / 4).max(12);
744    let _ = area.draw(&Text::new(
745        y_label.to_owned(),
746        (y_x, plot_center_y),
747        y_style,
748    ));
749}
750
751fn labels_from_step(range: Range<f32>, step: Option<f64>) -> Option<usize> {
752    let step = step?;
753    if step <= 0.0 {
754        return None;
755    }
756    let span = (range.end - range.start).abs() as f64;
757    if span <= 0.0 {
758        return None;
759    }
760    Some(((span / step).round() as usize + 1).clamp(2, 100))
761}
762
763fn line_style_text(value: LineStyle) -> &'static str {
764    match value {
765        LineStyle::Solid => "Solid",
766        LineStyle::Dashed => "Dashed",
767        LineStyle::Dotted => "Dotted",
768    }
769}
770
771fn scale_text(value: ScaleType) -> &'static str {
772    match value {
773        ScaleType::Linear => "Linear",
774        ScaleType::Log10 => "Log10",
775        ScaleType::LogE => "LogE",
776    }
777}
778
779fn scale_suffix(value: ScaleType) -> &'static str {
780    match value {
781        ScaleType::Linear => "",
782        ScaleType::Log10 => " [log10]",
783        ScaleType::LogE => " [ln]",
784    }
785}
786
787fn legend_position_text(value: LegendPosition) -> &'static str {
788    match value {
789        LegendPosition::TopLeft => "Top Left",
790        LegendPosition::TopRight => "Top Right",
791        LegendPosition::BottomLeft => "Bottom Left",
792        LegendPosition::BottomRight => "Bottom Right",
793    }
794}
795
796fn series_label_position(value: LegendPosition) -> SeriesLabelPosition {
797    match value {
798        LegendPosition::TopLeft => SeriesLabelPosition::UpperLeft,
799        LegendPosition::TopRight => SeriesLabelPosition::UpperRight,
800        LegendPosition::BottomLeft => SeriesLabelPosition::LowerLeft,
801        LegendPosition::BottomRight => SeriesLabelPosition::LowerRight,
802    }
803}