scirs2_series/
visualization.rs

1//! Comprehensive time series visualization module
2//!
3//! This module provides advanced visualization capabilities for time series data,
4//! including interactive plotting, forecasting visualization with uncertainty bands,
5//! and decomposition result visualization.
6//!
7//! # Features
8//!
9//! - Interactive time series plotting with zoom and pan
10//! - Forecasting visualization with confidence intervals
11//! - Decomposition result visualization (trend, seasonal, residual components)
12//! - Multi-series plotting and comparison
13//! - Seasonal pattern visualization
14//! - Anomaly and change point highlighting
15//! - Dashboard generation utilities
16//! - Export capabilities (PNG, SVG, HTML)
17//!
18//! # Examples
19//!
20//! ```rust,no_run
21//! use scirs2_series::visualization::{TimeSeriesPlot, PlotStyle, ExportFormat};
22//! use scirs2_core::ndarray::Array1;
23//!
24//! let data = Array1::linspace(0.0, 10.0, 100);
25//! let ts_data = data.mapv(|x| (x * 2.0 * std::f64::consts::PI).sin());
26//!
27//! let mut plot = TimeSeriesPlot::new("Sample Time Series");
28//! plot.add_series("sine_wave", &data, &ts_data, PlotStyle::default());
29//! plot.show();
30//! ```
31
32use crate::error::{Result, TimeSeriesError};
33use scirs2_core::ndarray::{Array1, Array2};
34use std::collections::HashMap;
35use std::path::Path;
36
37/// Configuration for plot styling and appearance
38#[derive(Debug, Clone)]
39pub struct PlotStyle {
40    /// Line color (RGB hex format, e.g., "#FF0000" for red)
41    pub color: String,
42    /// Line width in pixels
43    pub line_width: f64,
44    /// Line style (solid, dashed, dotted)
45    pub line_style: LineStyle,
46    /// Marker style for data points
47    pub marker: MarkerStyle,
48    /// Opacity (0.0 to 1.0)
49    pub opacity: f64,
50    /// Fill area under curve
51    pub fill: bool,
52    /// Fill color (if different from line color)
53    pub fill_color: Option<String>,
54}
55
56impl Default for PlotStyle {
57    fn default() -> Self {
58        Self {
59            color: "#1f77b4".to_string(), // Default blue
60            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/// Line style options
71#[derive(Debug, Clone, Copy)]
72pub enum LineStyle {
73    /// Solid line
74    Solid,
75    /// Dashed line
76    Dashed,
77    /// Dotted line
78    Dotted,
79    /// Dash-dot line
80    DashDot,
81}
82
83/// Marker style options
84#[derive(Debug, Clone, Copy)]
85pub enum MarkerStyle {
86    /// No marker
87    None,
88    /// Circle marker
89    Circle,
90    /// Square marker
91    Square,
92    /// Triangle marker
93    Triangle,
94    /// Cross marker
95    Cross,
96    /// Plus marker
97    Plus,
98}
99
100/// Export format options
101#[derive(Debug, Clone, Copy)]
102pub enum ExportFormat {
103    /// PNG image format
104    PNG,
105    /// SVG vector format
106    SVG,
107    /// HTML format
108    HTML,
109    /// PDF document format
110    PDF,
111}
112
113/// Time series data point for plotting
114#[derive(Debug, Clone)]
115pub struct TimePoint {
116    /// Time value (can be timestamp, index, etc.)
117    pub time: f64,
118    /// Data value
119    pub value: f64,
120    /// Optional metadata
121    pub metadata: Option<HashMap<String, String>>,
122}
123
124/// A single time series for plotting
125#[derive(Debug, Clone)]
126pub struct TimeSeries {
127    /// Series name
128    pub name: String,
129    /// Time points
130    pub data: Vec<TimePoint>,
131    /// Plot style
132    pub style: PlotStyle,
133    /// Series type
134    pub series_type: SeriesType,
135}
136
137/// Type of time series data
138#[derive(Debug, Clone, Copy)]
139pub enum SeriesType {
140    /// Regular time series data
141    Line,
142    /// Scatter plot points
143    Scatter,
144    /// Bar chart
145    Bar,
146    /// Filled area
147    Area,
148    /// Candlestick (OHLC data)
149    Candlestick,
150    /// Error bars with confidence intervals
151    ErrorBars,
152}
153
154/// Main time series plotting structure
155#[derive(Debug)]
156pub struct TimeSeriesPlot {
157    /// Plot title
158    pub title: String,
159    /// X-axis label
160    pub x_label: String,
161    /// Y-axis label
162    pub y_label: String,
163    /// Time series data
164    series: Vec<TimeSeries>,
165    /// Plot configuration
166    config: PlotConfig,
167    /// Annotations (text, arrows, shapes)
168    annotations: Vec<Annotation>,
169}
170
171/// Plot configuration
172#[derive(Debug, Clone)]
173pub struct PlotConfig {
174    /// Plot width in pixels
175    pub width: u32,
176    /// Plot height in pixels
177    pub height: u32,
178    /// Show grid
179    pub show_grid: bool,
180    /// Show legend
181    pub show_legend: bool,
182    /// Legend position
183    pub legend_position: LegendPosition,
184    /// Enable interactivity (zoom, pan)
185    pub interactive: bool,
186    /// Background color
187    pub background_color: String,
188    /// Grid color
189    pub grid_color: String,
190    /// Axis color
191    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/// Legend position options
211#[derive(Debug, Clone, Copy)]
212pub enum LegendPosition {
213    /// Top left position
214    TopLeft,
215    /// Top right position
216    TopRight,
217    /// Bottom left position
218    BottomLeft,
219    /// Bottom right position
220    BottomRight,
221    /// Outside the plot area
222    Outside,
223}
224
225/// Annotation for plots (text, arrows, shapes)
226#[derive(Debug, Clone)]
227pub struct Annotation {
228    /// Annotation type
229    pub annotation_type: AnnotationType,
230    /// X position
231    pub x: f64,
232    /// Y position  
233    pub y: f64,
234    /// Text content (for text annotations)
235    pub text: Option<String>,
236    /// Style
237    pub style: AnnotationStyle,
238}
239
240/// Types of annotations
241#[derive(Debug, Clone)]
242pub enum AnnotationType {
243    /// Text annotation
244    Text,
245    /// Arrow annotation pointing to a target
246    Arrow {
247        /// X coordinate of arrow target
248        target_x: f64,
249        /// Y coordinate of arrow target
250        target_y: f64,
251    },
252    /// Rectangle annotation with specified dimensions
253    Rectangle {
254        /// Width of rectangle
255        width: f64,
256        /// Height of rectangle
257        height: f64,
258    },
259    /// Circle annotation with specified radius
260    Circle {
261        /// Radius of circle
262        radius: f64,
263    },
264    /// Vertical line annotation
265    VerticalLine,
266    /// Horizontal line annotation
267    HorizontalLine,
268}
269
270/// Annotation styling
271#[derive(Debug, Clone)]
272pub struct AnnotationStyle {
273    /// Color of the annotation
274    pub color: String,
275    /// Font size for text annotations
276    pub font_size: f64,
277    /// Opacity/transparency level
278    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    /// Create a new time series plot
293    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    /// Set axis labels
305    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    /// Add a time series to the plot
311    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    /// Add a series with error bars (confidence intervals)
346    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        // Add main series
362        self.add_series(name, time, values, style.clone())?;
363
364        // Add confidence band as filled area
365        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        // Create upper bound series
371        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        // Create lower bound series (reversed for proper filling)
382        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        // Combine for filled area
394        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    /// Add annotation to the plot
409    pub fn add_annotation(&mut self, annotation: Annotation) {
410        self.annotations.push(annotation);
411    }
412
413    /// Highlight anomalies on the plot
414    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, // Will be adjusted to data value
425                    text: Some("Anomaly".to_string()),
426                    style: AnnotationStyle {
427                        color: "#FF0000".to_string(), // Red
428                        font_size: 10.0,
429                        opacity: 0.7,
430                    },
431                };
432                self.add_annotation(annotation);
433            }
434        }
435        Ok(())
436    }
437
438    /// Highlight change points on the plot
439    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(), // Orange
448                    font_size: 10.0,
449                    opacity: 0.8,
450                },
451            };
452            self.add_annotation(annotation);
453        }
454    }
455
456    /// Configure plot appearance
457    pub fn configure(&mut self, config: PlotConfig) {
458        self.config = config;
459    }
460
461    /// Generate HTML output for the plot
462    pub fn to_html(&self) -> String {
463        let mut html = String::new();
464
465        // HTML header with CSS and JavaScript
466        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        // Generate Plotly data
488        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        // Plot layout
534        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    /// Save plot to file
574    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                // Generate SVG output
586                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    /// Generate SVG output for the plot
602    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        // Plot area dimensions
617        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        // Find data ranges
622        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        // Add some padding
637        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        // Draw axes
645        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, // Y axis
653            margin + plot_width,
654            self.config.height as i32 - margin,
655            self.config.axis_color,
656            margin,
657            margin, // X axis
658            margin,
659            self.config.height as i32 - margin,
660            self.config.axis_color
661        ));
662
663        // Draw series
664        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        // Add axis labels
692        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    /// Display plot (opens in default browser)
709    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        // Try to open in browser
714        #[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
736/// Specialized plotting functions for time series analysis results
737pub struct SpecializedPlots;
738
739impl SpecializedPlots {
740    /// Plot decomposition results (trend, seasonal, residual)
741    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        // Original series
753        let original_style = PlotStyle {
754            color: "#1f77b4".to_string(), // Blue
755            ..Default::default()
756        };
757        plot.add_series("Original", time, original, original_style)?;
758
759        // Trend component
760        let trend_style = PlotStyle {
761            color: "#ff7f0e".to_string(), // Orange
762            line_width: 3.0,
763            ..Default::default()
764        };
765        plot.add_series("Trend", time, trend, trend_style)?;
766
767        // Seasonal component
768        let seasonal_style = PlotStyle {
769            color: "#2ca02c".to_string(),
770            ..Default::default()
771        };
772        plot.add_series("Seasonal", time, seasonal, seasonal_style)?;
773
774        // Residual component
775        let residual_style = PlotStyle {
776            color: "#d62728".to_string(), // Red
777            opacity: 0.7,
778            ..Default::default()
779        };
780        plot.add_series("Residual", time, residual, residual_style)?;
781
782        Ok(plot)
783    }
784
785    /// Plot forecasting results with confidence intervals
786    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        // Historical _data
799        let hist_style = PlotStyle {
800            color: "#1f77b4".to_string(), // Blue
801            line_width: 2.0,
802            ..Default::default()
803        };
804        plot.add_series("Historical", historical_time, historical_data, hist_style)?;
805
806        // Forecast with confidence intervals
807        let forecast_style = PlotStyle {
808            color: "#ff7f0e".to_string(), // Orange
809            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    /// Plot seasonal patterns
826    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        // Group data by seasonal period
836        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        // Create _time axis for one period
849        let period_time = Array1::linspace(0.0, period as f64 - 1.0, period);
850
851        // Plot each period as a separate series
852        for i in 0..num_periods.min(10) {
853            // Limit to 10 periods for clarity
854            let period_values = seasonal_data.column(i).to_owned();
855            let style = PlotStyle {
856                opacity: 0.6,
857                color: "#1f77b4".to_string(), // Use same color with varying opacity
858                ..Default::default()
859            };
860            plot.add_series(
861                &format!("Period {}", i + 1),
862                &period_time,
863                &period_values,
864                style,
865            )?;
866        }
867
868        // Add mean seasonal pattern
869        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(), // Red
874            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
883/// Dashboard generation utilities
884pub struct Dashboard {
885    /// Dashboard title
886    pub title: String,
887    /// Collection of plots
888    plots: Vec<(String, TimeSeriesPlot)>,
889    /// Layout configuration
890    layout: DashboardLayout,
891}
892
893/// Dashboard layout configuration
894#[derive(Debug, Clone)]
895pub struct DashboardLayout {
896    /// Number of columns
897    pub columns: usize,
898    /// Plot spacing
899    pub spacing: u32,
900    /// Overall width
901    pub width: u32,
902    /// Overall height
903    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    /// Create a new dashboard
919    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    /// Add a plot to the dashboard
928    pub fn add_plot(&mut self, sectiontitle: &str, plot: TimeSeriesPlot) {
929        self.plots.push((sectiontitle.to_string(), plot));
930    }
931
932    /// Configure dashboard layout
933    pub fn set_layout(&mut self, layout: DashboardLayout) {
934        self.layout = layout;
935    }
936
937    /// Generate HTML dashboard
938    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        // Add each plot section
994        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        // Add JavaScript for each plot
1009        html.push_str("    <script>\n");
1010
1011        for (i, (_, plot)) in self.plots.iter().enumerate() {
1012            // Generate plot data for each plot
1013            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    /// Save dashboard to file
1060    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    /// Display dashboard (opens in default browser)
1068    pub fn show(&self) -> Result<()> {
1069        let temp_path = std::env::temp_dir().join("scirs2_dashboard.html");
1070        self.save(&temp_path)?;
1071
1072        // Try to open in browser
1073        #[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
1095/// Convenience functions for quick plotting
1096pub mod quick_plots {
1097    use super::*;
1098
1099    /// Quick line plot
1100    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    /// Quick scatter plot
1107    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    /// Quick multi-series plot
1118    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); // Different length
1170
1171        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}