1use crate::error::{Result, TimeSeriesError};
33use scirs2_core::ndarray::{Array1, Array2};
34use std::collections::HashMap;
35use std::path::Path;
36
37#[derive(Debug, Clone)]
39pub struct PlotStyle {
40 pub color: String,
42 pub line_width: f64,
44 pub line_style: LineStyle,
46 pub marker: MarkerStyle,
48 pub opacity: f64,
50 pub fill: bool,
52 pub fill_color: Option<String>,
54}
55
56impl Default for PlotStyle {
57 fn default() -> Self {
58 Self {
59 color: "#1f77b4".to_string(), line_width: 2.0,
61 line_style: LineStyle::Solid,
62 marker: MarkerStyle::None,
63 opacity: 1.0,
64 fill: false,
65 fill_color: None,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy)]
72pub enum LineStyle {
73 Solid,
75 Dashed,
77 Dotted,
79 DashDot,
81}
82
83#[derive(Debug, Clone, Copy)]
85pub enum MarkerStyle {
86 None,
88 Circle,
90 Square,
92 Triangle,
94 Cross,
96 Plus,
98}
99
100#[derive(Debug, Clone, Copy)]
102pub enum ExportFormat {
103 PNG,
105 SVG,
107 HTML,
109 PDF,
111}
112
113#[derive(Debug, Clone)]
115pub struct TimePoint {
116 pub time: f64,
118 pub value: f64,
120 pub metadata: Option<HashMap<String, String>>,
122}
123
124#[derive(Debug, Clone)]
126pub struct TimeSeries {
127 pub name: String,
129 pub data: Vec<TimePoint>,
131 pub style: PlotStyle,
133 pub series_type: SeriesType,
135}
136
137#[derive(Debug, Clone, Copy)]
139pub enum SeriesType {
140 Line,
142 Scatter,
144 Bar,
146 Area,
148 Candlestick,
150 ErrorBars,
152}
153
154#[derive(Debug)]
156pub struct TimeSeriesPlot {
157 pub title: String,
159 pub x_label: String,
161 pub y_label: String,
163 series: Vec<TimeSeries>,
165 config: PlotConfig,
167 annotations: Vec<Annotation>,
169}
170
171#[derive(Debug, Clone)]
173pub struct PlotConfig {
174 pub width: u32,
176 pub height: u32,
178 pub show_grid: bool,
180 pub show_legend: bool,
182 pub legend_position: LegendPosition,
184 pub interactive: bool,
186 pub background_color: String,
188 pub grid_color: String,
190 pub axis_color: String,
192}
193
194impl Default for PlotConfig {
195 fn default() -> Self {
196 Self {
197 width: 800,
198 height: 600,
199 show_grid: true,
200 show_legend: true,
201 legend_position: LegendPosition::TopRight,
202 interactive: true,
203 background_color: "#FFFFFF".to_string(),
204 grid_color: "#E0E0E0".to_string(),
205 axis_color: "#000000".to_string(),
206 }
207 }
208}
209
210#[derive(Debug, Clone, Copy)]
212pub enum LegendPosition {
213 TopLeft,
215 TopRight,
217 BottomLeft,
219 BottomRight,
221 Outside,
223}
224
225#[derive(Debug, Clone)]
227pub struct Annotation {
228 pub annotation_type: AnnotationType,
230 pub x: f64,
232 pub y: f64,
234 pub text: Option<String>,
236 pub style: AnnotationStyle,
238}
239
240#[derive(Debug, Clone)]
242pub enum AnnotationType {
243 Text,
245 Arrow {
247 target_x: f64,
249 target_y: f64,
251 },
252 Rectangle {
254 width: f64,
256 height: f64,
258 },
259 Circle {
261 radius: f64,
263 },
264 VerticalLine,
266 HorizontalLine,
268}
269
270#[derive(Debug, Clone)]
272pub struct AnnotationStyle {
273 pub color: String,
275 pub font_size: f64,
277 pub opacity: f64,
279}
280
281impl Default for AnnotationStyle {
282 fn default() -> Self {
283 Self {
284 color: "#000000".to_string(),
285 font_size: 12.0,
286 opacity: 1.0,
287 }
288 }
289}
290
291impl TimeSeriesPlot {
292 pub fn new(title: &str) -> Self {
294 Self {
295 title: title.to_string(),
296 x_label: "Time".to_string(),
297 y_label: "Value".to_string(),
298 series: Vec::new(),
299 config: PlotConfig::default(),
300 annotations: Vec::new(),
301 }
302 }
303
304 pub fn set_labels(&mut self, x_label: &str, ylabel: &str) {
306 self.x_label = x_label.to_string();
307 self.y_label = ylabel.to_string();
308 }
309
310 pub fn add_series(
312 &mut self,
313 name: &str,
314 time: &Array1<f64>,
315 values: &Array1<f64>,
316 style: PlotStyle,
317 ) -> Result<()> {
318 if time.len() != values.len() {
319 return Err(TimeSeriesError::InvalidInput(
320 "Time and value arrays must have the same length".to_string(),
321 ));
322 }
323
324 let data: Vec<TimePoint> = time
325 .iter()
326 .zip(values.iter())
327 .map(|(&t, &v)| TimePoint {
328 time: t,
329 value: v,
330 metadata: None,
331 })
332 .collect();
333
334 let series = TimeSeries {
335 name: name.to_string(),
336 data,
337 style,
338 series_type: SeriesType::Line,
339 };
340
341 self.series.push(series);
342 Ok(())
343 }
344
345 pub fn add_series_with_confidence(
347 &mut self,
348 name: &str,
349 time: &Array1<f64>,
350 values: &Array1<f64>,
351 lower: &Array1<f64>,
352 upper: &Array1<f64>,
353 style: PlotStyle,
354 ) -> Result<()> {
355 if time.len() != values.len() || values.len() != lower.len() || lower.len() != upper.len() {
356 return Err(TimeSeriesError::InvalidInput(
357 "All arrays must have the same length".to_string(),
358 ));
359 }
360
361 self.add_series(name, time, values, style.clone())?;
363
364 let mut confidence_style = style.clone();
366 confidence_style.fill = true;
367 confidence_style.opacity = 0.3;
368 confidence_style.line_style = LineStyle::Solid;
369
370 let upper_data: Vec<TimePoint> = time
372 .iter()
373 .zip(upper.iter())
374 .map(|(&t, &v)| TimePoint {
375 time: t,
376 value: v,
377 metadata: None,
378 })
379 .collect();
380
381 let lower_data: Vec<TimePoint> = time
383 .iter()
384 .zip(lower.iter())
385 .rev()
386 .map(|(&t, &v)| TimePoint {
387 time: t,
388 value: v,
389 metadata: None,
390 })
391 .collect();
392
393 let mut confidence_data = upper_data;
395 confidence_data.extend(lower_data);
396
397 let confidence_series = TimeSeries {
398 name: format!("{name}_confidence"),
399 data: confidence_data,
400 style: confidence_style,
401 series_type: SeriesType::Area,
402 };
403
404 self.series.push(confidence_series);
405 Ok(())
406 }
407
408 pub fn add_annotation(&mut self, annotation: Annotation) {
410 self.annotations.push(annotation);
411 }
412
413 pub fn highlight_anomalies(
415 &mut self,
416 time: &Array1<f64>,
417 anomaly_indices: &[usize],
418 ) -> Result<()> {
419 for &idx in anomaly_indices {
420 if idx < time.len() {
421 let annotation = Annotation {
422 annotation_type: AnnotationType::Circle { radius: 5.0 },
423 x: time[idx],
424 y: 0.0, text: Some("Anomaly".to_string()),
426 style: AnnotationStyle {
427 color: "#FF0000".to_string(), font_size: 10.0,
429 opacity: 0.7,
430 },
431 };
432 self.add_annotation(annotation);
433 }
434 }
435 Ok(())
436 }
437
438 pub fn highlight_change_points(&mut self, changepoints: &[f64]) {
440 for &cp in changepoints {
441 let annotation = Annotation {
442 annotation_type: AnnotationType::VerticalLine,
443 x: cp,
444 y: 0.0,
445 text: Some("Change Point".to_string()),
446 style: AnnotationStyle {
447 color: "#FFA500".to_string(), font_size: 10.0,
449 opacity: 0.8,
450 },
451 };
452 self.add_annotation(annotation);
453 }
454 }
455
456 pub fn configure(&mut self, config: PlotConfig) {
458 self.config = config;
459 }
460
461 pub fn to_html(&self) -> String {
463 let mut html = String::new();
464
465 html.push_str(&format!(
467 r#"
468<!DOCTYPE html>
469<html>
470<head>
471 <title>{}</title>
472 <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
473 <style>
474 body {{ font-family: Arial, sans-serif; margin: 20px; }}
475 .plot-container {{ width: {}px; height: {}px; margin: auto; }}
476 .title {{ text-align: center; font-size: 18px; margin-bottom: 20px; }}
477 </style>
478</head>
479<body>
480 <div class="title">{}</div>
481 <div id="plot" class="plot-container"></div>
482 <script>
483"#,
484 self.title, self.config.width, self.config.height, self.title
485 ));
486
487 html.push_str("var data = [\n");
489
490 for (i, series) in self.series.iter().enumerate() {
491 if i > 0 {
492 html.push_str(",\n");
493 }
494
495 let x_values: Vec<f64> = series.data.iter().map(|p| p.time).collect();
496 let y_values: Vec<f64> = series.data.iter().map(|p| p.value).collect();
497
498 html.push_str(&format!(
499 r#"
500 {{
501 x: {:?},
502 y: {:?},
503 type: '{}',
504 mode: '{}',
505 name: '{}',
506 line: {{ color: '{}', width: {} }},
507 opacity: {}
508 }}"#,
509 x_values,
510 y_values,
511 match series.series_type {
512 SeriesType::Line => "scatter",
513 SeriesType::Scatter => "scatter",
514 SeriesType::Bar => "bar",
515 SeriesType::Area => "scatter",
516 _ => "scatter",
517 },
518 match series.series_type {
519 SeriesType::Line => "lines",
520 SeriesType::Scatter => "markers",
521 SeriesType::Area => "lines",
522 _ => "lines+markers",
523 },
524 series.name,
525 series.style.color,
526 series.style.line_width,
527 series.style.opacity
528 ));
529 }
530
531 html.push_str("\n];\n");
532
533 html.push_str(&format!(
535 r#"
536var layout = {{
537 title: '{}',
538 xaxis: {{ title: '{}' }},
539 yaxis: {{ title: '{}' }},
540 showlegend: {},
541 plot_bgcolor: '{}',
542 paper_bgcolor: '{}',
543 font: {{ size: 12 }}
544}};
545
546var config = {{
547 responsive: true,
548 displayModeBar: {}
549}};
550
551Plotly.newPlot('plot', data, layout, config);
552"#,
553 self.title,
554 self.x_label,
555 self.y_label,
556 self.config.show_legend,
557 self.config.background_color,
558 self.config.background_color,
559 self.config.interactive
560 ));
561
562 html.push_str(
563 r#"
564 </script>
565</body>
566</html>
567"#,
568 );
569
570 html
571 }
572
573 pub fn save<P: AsRef<Path>>(&self, path: P, format: ExportFormat) -> Result<()> {
575 let path = path.as_ref();
576
577 match format {
578 ExportFormat::HTML => {
579 let html_content = self.to_html();
580 std::fs::write(path, html_content).map_err(|e| {
581 TimeSeriesError::IOError(format!("Failed to save HTML plot: {e}"))
582 })?;
583 }
584 ExportFormat::SVG => {
585 let svg_content = self.to_svg();
587 std::fs::write(path, svg_content).map_err(|e| {
588 TimeSeriesError::IOError(format!("Failed to save SVG plot: {e}"))
589 })?;
590 }
591 _ => {
592 return Err(TimeSeriesError::NotImplemented(format!(
593 "Export format {format:?} not yet implemented"
594 )));
595 }
596 }
597
598 Ok(())
599 }
600
601 fn to_svg(&self) -> String {
603 let mut svg = String::new();
604
605 svg.push_str(&format!(r#"<?xml version="1.0" encoding="UTF-8"?>
606<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg">
607 <rect width="100%" height="100%" fill="{}"/>
608 <text x="{}" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold">{}</text>
609"#,
610 self.config.width, self.config.height,
611 self.config.background_color,
612 self.config.width / 2,
613 self.title
614 ));
615
616 let margin = 60;
618 let plot_width = self.config.width as i32 - 2 * margin;
619 let plot_height = self.config.height as i32 - 2 * margin;
620
621 let mut min_x = f64::INFINITY;
623 let mut max_x = f64::NEG_INFINITY;
624 let mut min_y = f64::INFINITY;
625 let mut max_y = f64::NEG_INFINITY;
626
627 for series in &self.series {
628 for point in &series.data {
629 min_x = min_x.min(point.time);
630 max_x = max_x.max(point.time);
631 min_y = min_y.min(point.value);
632 max_y = max_y.max(point.value);
633 }
634 }
635
636 let x_range = max_x - min_x;
638 let y_range = max_y - min_y;
639 min_x -= x_range * 0.05;
640 max_x += x_range * 0.05;
641 min_y -= y_range * 0.05;
642 max_y += y_range * 0.05;
643
644 svg.push_str(&format!(
646 r#"
647 <!-- Axes -->
648 <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>
649 <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>
650"#,
651 margin,
652 self.config.height as i32 - margin, margin + plot_width,
654 self.config.height as i32 - margin,
655 self.config.axis_color,
656 margin,
657 margin, margin,
659 self.config.height as i32 - margin,
660 self.config.axis_color
661 ));
662
663 for series in &self.series {
665 if series.data.is_empty() {
666 continue;
667 }
668
669 let mut path_data = String::from("M");
670
671 for (i, point) in series.data.iter().enumerate() {
672 let x = margin as f64 + (point.time - min_x) / (max_x - min_x) * plot_width as f64;
673 let y = (self.config.height as f64 - margin as f64)
674 - (point.value - min_y) / (max_y - min_y) * plot_height as f64;
675
676 if i == 0 {
677 path_data.push_str(&format!(" {x:.2} {y:.2}"));
678 } else {
679 path_data.push_str(&format!(" L {x:.2} {y:.2}"));
680 }
681 }
682
683 svg.push_str(&format!(
684 r#"
685 <path d="{}" fill="none" stroke="{}" stroke-width="{}" opacity="{}"/>
686"#,
687 path_data, series.style.color, series.style.line_width, series.style.opacity
688 ));
689 }
690
691 svg.push_str(&format!(r#"
693 <text x="{}" y="{}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12">{}</text>
694 <text x="20" y="{}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" transform="rotate(-90 20 {})">{}</text>
695"#,
696 margin + plot_width / 2,
697 self.config.height as i32 - 10,
698 self.x_label,
699 margin + plot_height / 2,
700 margin + plot_height / 2,
701 self.y_label
702 ));
703
704 svg.push_str("</svg>");
705 svg
706 }
707
708 pub fn show(&self) -> Result<()> {
710 let temp_path = std::env::temp_dir().join("scirs2_plot.html");
711 self.save(&temp_path, ExportFormat::HTML)?;
712
713 #[cfg(target_os = "windows")]
715 std::process::Command::new("cmd")
716 .args(["/c", "start", "", &temp_path.to_string_lossy()])
717 .spawn()
718 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open plot: {e}")))?;
719
720 #[cfg(target_os = "macos")]
721 std::process::Command::new("open")
722 .arg(&temp_path)
723 .spawn()
724 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open plot: {e}")))?;
725
726 #[cfg(target_os = "linux")]
727 std::process::Command::new("xdg-open")
728 .arg(&temp_path)
729 .spawn()
730 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open plot: {e}")))?;
731
732 Ok(())
733 }
734}
735
736pub struct SpecializedPlots;
738
739impl SpecializedPlots {
740 pub fn plot_decomposition(
742 time: &Array1<f64>,
743 original: &Array1<f64>,
744 trend: &Array1<f64>,
745 seasonal: &Array1<f64>,
746 residual: &Array1<f64>,
747 title: &str,
748 ) -> Result<TimeSeriesPlot> {
749 let mut plot = TimeSeriesPlot::new(title);
750 plot.set_labels("Time", "Value");
751
752 let original_style = PlotStyle {
754 color: "#1f77b4".to_string(), ..Default::default()
756 };
757 plot.add_series("Original", time, original, original_style)?;
758
759 let trend_style = PlotStyle {
761 color: "#ff7f0e".to_string(), line_width: 3.0,
763 ..Default::default()
764 };
765 plot.add_series("Trend", time, trend, trend_style)?;
766
767 let seasonal_style = PlotStyle {
769 color: "#2ca02c".to_string(),
770 ..Default::default()
771 };
772 plot.add_series("Seasonal", time, seasonal, seasonal_style)?;
773
774 let residual_style = PlotStyle {
776 color: "#d62728".to_string(), opacity: 0.7,
778 ..Default::default()
779 };
780 plot.add_series("Residual", time, residual, residual_style)?;
781
782 Ok(plot)
783 }
784
785 pub fn plot_forecast(
787 historical_time: &Array1<f64>,
788 historical_data: &Array1<f64>,
789 forecast_time: &Array1<f64>,
790 forecast_values: &Array1<f64>,
791 confidence_lower: &Array1<f64>,
792 confidence_upper: &Array1<f64>,
793 title: &str,
794 ) -> Result<TimeSeriesPlot> {
795 let mut plot = TimeSeriesPlot::new(title);
796 plot.set_labels("Time", "Value");
797
798 let hist_style = PlotStyle {
800 color: "#1f77b4".to_string(), line_width: 2.0,
802 ..Default::default()
803 };
804 plot.add_series("Historical", historical_time, historical_data, hist_style)?;
805
806 let forecast_style = PlotStyle {
808 color: "#ff7f0e".to_string(), line_width: 2.5,
810 line_style: LineStyle::Dashed,
811 ..Default::default()
812 };
813 plot.add_series_with_confidence(
814 "Forecast",
815 forecast_time,
816 forecast_values,
817 confidence_lower,
818 confidence_upper,
819 forecast_style,
820 )?;
821
822 Ok(plot)
823 }
824
825 pub fn plot_seasonal_patterns(
827 _time: &Array1<f64>,
828 data: &Array1<f64>,
829 period: usize,
830 title: &str,
831 ) -> Result<TimeSeriesPlot> {
832 let mut plot = TimeSeriesPlot::new(title);
833 plot.set_labels("Time within Period", "Value");
834
835 let num_periods = data.len() / period;
837 let mut seasonal_data = Array2::<f64>::zeros((period, num_periods));
838
839 for i in 0..num_periods {
840 for j in 0..period {
841 let idx = i * period + j;
842 if idx < data.len() {
843 seasonal_data[[j, i]] = data[idx];
844 }
845 }
846 }
847
848 let period_time = Array1::linspace(0.0, period as f64 - 1.0, period);
850
851 for i in 0..num_periods.min(10) {
853 let period_values = seasonal_data.column(i).to_owned();
855 let style = PlotStyle {
856 opacity: 0.6,
857 color: "#1f77b4".to_string(), ..Default::default()
859 };
860 plot.add_series(
861 &format!("Period {}", i + 1),
862 &period_time,
863 &period_values,
864 style,
865 )?;
866 }
867
868 let mean_seasonal: Array1<f64> = seasonal_data
870 .mean_axis(scirs2_core::ndarray::Axis(1))
871 .unwrap();
872 let mean_style = PlotStyle {
873 color: "#d62728".to_string(), line_width: 3.0,
875 ..Default::default()
876 };
877 plot.add_series("Mean Pattern", &period_time, &mean_seasonal, mean_style)?;
878
879 Ok(plot)
880 }
881}
882
883pub struct Dashboard {
885 pub title: String,
887 plots: Vec<(String, TimeSeriesPlot)>,
889 layout: DashboardLayout,
891}
892
893#[derive(Debug, Clone)]
895pub struct DashboardLayout {
896 pub columns: usize,
898 pub spacing: u32,
900 pub width: u32,
902 pub height: u32,
904}
905
906impl Default for DashboardLayout {
907 fn default() -> Self {
908 Self {
909 columns: 2,
910 spacing: 20,
911 width: 1200,
912 height: 800,
913 }
914 }
915}
916
917impl Dashboard {
918 pub fn new(title: &str) -> Self {
920 Self {
921 title: title.to_string(),
922 plots: Vec::new(),
923 layout: DashboardLayout::default(),
924 }
925 }
926
927 pub fn add_plot(&mut self, sectiontitle: &str, plot: TimeSeriesPlot) {
929 self.plots.push((sectiontitle.to_string(), plot));
930 }
931
932 pub fn set_layout(&mut self, layout: DashboardLayout) {
934 self.layout = layout;
935 }
936
937 pub fn to_html(&self) -> String {
939 let mut html = String::new();
940
941 html.push_str(&format!(
942 r#"
943<!DOCTYPE html>
944<html>
945<head>
946 <title>{}</title>
947 <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
948 <style>
949 body {{
950 font-family: Arial, sans-serif;
951 margin: 20px;
952 background-color: #f5f5f5;
953 }}
954 .dashboard-title {{
955 text-align: center;
956 font-size: 24px;
957 margin-bottom: 30px;
958 color: #333;
959 }}
960 .dashboard-container {{
961 display: grid;
962 grid-template-columns: repeat({}, 1fr);
963 gap: {}px;
964 max-width: {}px;
965 margin: 0 auto;
966 }}
967 .plot-section {{
968 background: white;
969 border-radius: 8px;
970 padding: 20px;
971 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
972 }}
973 .plot-title {{
974 font-size: 16px;
975 margin-bottom: 15px;
976 color: #555;
977 border-bottom: 2px solid #e0e0e0;
978 padding-bottom: 10px;
979 }}
980 .plot-container {{
981 width: 100%;
982 height: 400px;
983 }}
984 </style>
985</head>
986<body>
987 <div class="dashboard-title">{}</div>
988 <div class="dashboard-container">
989"#,
990 self.title, self.layout.columns, self.layout.spacing, self.layout.width, self.title
991 ));
992
993 for (i, (section_title_plot, _plot)) in self.plots.iter().enumerate() {
995 html.push_str(&format!(
996 r#"
997 <div class="plot-section">
998 <div class="plot-title">{}</div>
999 <div id="plot_{i}" class="plot-container"></div>
1000 </div>
1001"#,
1002 section_title_plot
1003 ));
1004 }
1005
1006 html.push_str(" </div>\n");
1007
1008 html.push_str(" <script>\n");
1010
1011 for (i, (_, plot)) in self.plots.iter().enumerate() {
1012 html.push_str(&format!(" // Plot {i}\n"));
1014 html.push_str(&format!(" var data_{i} = [\n"));
1015
1016 for (j, series) in plot.series.iter().enumerate() {
1017 if j > 0 {
1018 html.push_str(",\n");
1019 }
1020
1021 let x_values: Vec<f64> = series.data.iter().map(|p| p.time).collect();
1022 let y_values: Vec<f64> = series.data.iter().map(|p| p.value).collect();
1023
1024 html.push_str(&format!(
1025 r#"
1026 {{
1027 x: {:?},
1028 y: {:?},
1029 type: 'scatter',
1030 mode: 'lines',
1031 name: '{}',
1032 line: {{ color: '{}', width: {} }}
1033 }}"#,
1034 x_values, y_values, series.name, series.style.color, series.style.line_width
1035 ));
1036 }
1037
1038 html.push_str("\n ];\n");
1039 html.push_str(&format!(
1040 r#"
1041 var layout_{} = {{
1042 title: '{}',
1043 xaxis: {{ title: '{}' }},
1044 yaxis: {{ title: '{}' }},
1045 margin: {{ l: 50, r: 20, t: 50, b: 50 }},
1046 showlegend: true
1047 }};
1048
1049 Plotly.newPlot('plot_{}', data_{}, layout_{}, {{responsive: true}});
1050"#,
1051 i, plot.title, plot.x_label, plot.y_label, i, i, i
1052 ));
1053 }
1054
1055 html.push_str(" </script>\n</body>\n</html>");
1056 html
1057 }
1058
1059 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1061 let html_content = self.to_html();
1062 std::fs::write(path, html_content)
1063 .map_err(|e| TimeSeriesError::IOError(format!("Failed to save dashboard: {e}")))?;
1064 Ok(())
1065 }
1066
1067 pub fn show(&self) -> Result<()> {
1069 let temp_path = std::env::temp_dir().join("scirs2_dashboard.html");
1070 self.save(&temp_path)?;
1071
1072 #[cfg(target_os = "windows")]
1074 std::process::Command::new("cmd")
1075 .args(["/c", "start", "", &temp_path.to_string_lossy()])
1076 .spawn()
1077 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open dashboard: {e}")))?;
1078
1079 #[cfg(target_os = "macos")]
1080 std::process::Command::new("open")
1081 .arg(&temp_path)
1082 .spawn()
1083 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open dashboard: {e}")))?;
1084
1085 #[cfg(target_os = "linux")]
1086 std::process::Command::new("xdg-open")
1087 .arg(&temp_path)
1088 .spawn()
1089 .map_err(|e| TimeSeriesError::IOError(format!("Failed to open dashboard: {e}")))?;
1090
1091 Ok(())
1092 }
1093}
1094
1095pub mod quick_plots {
1097 use super::*;
1098
1099 pub fn line_plot(x: &Array1<f64>, y: &Array1<f64>, title: &str) -> Result<TimeSeriesPlot> {
1101 let mut plot = TimeSeriesPlot::new(title);
1102 plot.add_series("data", x, y, PlotStyle::default())?;
1103 Ok(plot)
1104 }
1105
1106 pub fn scatter_plot(x: &Array1<f64>, y: &Array1<f64>, title: &str) -> Result<TimeSeriesPlot> {
1108 let mut plot = TimeSeriesPlot::new(title);
1109 let style = PlotStyle {
1110 marker: MarkerStyle::Circle,
1111 ..Default::default()
1112 };
1113 plot.add_series("data", x, y, style)?;
1114 Ok(plot)
1115 }
1116
1117 pub fn multi_plot(
1119 series_data: &[(String, Array1<f64>, Array1<f64>)],
1120 title: &str,
1121 ) -> Result<TimeSeriesPlot> {
1122 let mut plot = TimeSeriesPlot::new(title);
1123
1124 let colors = [
1125 "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b",
1126 ];
1127
1128 for (i, (name, x, y)) in series_data.iter().enumerate() {
1129 let style = PlotStyle {
1130 color: colors[i % colors.len()].to_string(),
1131 ..Default::default()
1132 };
1133 plot.add_series(name, x, y, style)?;
1134 }
1135
1136 Ok(plot)
1137 }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use scirs2_core::ndarray::Array1;
1144
1145 #[test]
1146 fn test_plot_creation() {
1147 let plot = TimeSeriesPlot::new("Test Plot");
1148 assert_eq!(plot.title, "Test Plot");
1149 assert_eq!(plot.x_label, "Time");
1150 assert_eq!(plot.y_label, "Value");
1151 }
1152
1153 #[test]
1154 fn test_add_series() {
1155 let mut plot = TimeSeriesPlot::new("Test Plot");
1156 let time = Array1::linspace(0.0, 10.0, 11);
1157 let values = time.mapv(|x: f64| x.sin());
1158
1159 let result = plot.add_series("sine", &time, &values, PlotStyle::default());
1160 assert!(result.is_ok());
1161 assert_eq!(plot.series.len(), 1);
1162 assert_eq!(plot.series[0].name, "sine");
1163 }
1164
1165 #[test]
1166 fn test_mismatched_arrays() {
1167 let mut plot = TimeSeriesPlot::new("Test Plot");
1168 let time = Array1::linspace(0.0, 10.0, 11);
1169 let values = Array1::linspace(0.0, 5.0, 6); let result = plot.add_series("test", &time, &values, PlotStyle::default());
1172 assert!(result.is_err());
1173 }
1174
1175 #[test]
1176 fn test_html_generation() {
1177 let mut plot = TimeSeriesPlot::new("Test Plot");
1178 let time = Array1::linspace(0.0, 10.0, 11);
1179 let values = time.mapv(|x: f64| x.sin());
1180
1181 plot.add_series("sine", &time, &values, PlotStyle::default())
1182 .unwrap();
1183 let html = plot.to_html();
1184
1185 assert!(html.contains("Test Plot"));
1186 assert!(html.contains("sine"));
1187 assert!(html.contains("Plotly.newPlot"));
1188 }
1189
1190 #[test]
1191 fn test_dashboard_creation() {
1192 let mut dashboard = Dashboard::new("Test Dashboard");
1193 let mut plot = TimeSeriesPlot::new("Sub Plot");
1194 let time = Array1::linspace(0.0, 10.0, 11);
1195 let values = time.mapv(|x: f64| x.sin());
1196
1197 plot.add_series("sine", &time, &values, PlotStyle::default())
1198 .unwrap();
1199 dashboard.add_plot("Section 1", plot);
1200
1201 assert_eq!(dashboard.plots.len(), 1);
1202 assert_eq!(dashboard.plots[0].0, "Section 1");
1203 }
1204}