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