Skip to main content

optionstratlib_visualization/visualization/
graph.rs

1//! The `Graph` contract: one trait on every feature surface.
2//!
3//! An implementor supplies [`Graph::graph_data`] and may override
4//! [`Graph::graph_config`]; that is the whole contract, with or without a
5//! backend. `plotly` adds the provided rendering methods (`to_plot`,
6//! `write_html`, `show`, `render`, `to_interactive_html`) and
7//! `static_export` adds `write_png` and `write_svg`. Features only add
8//! provided methods, so an implementation never changes with them
9//! (ADR-0002 section 4).
10
11use crate::visualization::{GraphConfig, GraphData};
12#[cfg(feature = "plotly")]
13use {
14    crate::error::GraphError,
15    crate::visualization::OutputType,
16    crate::visualization::file::prepare_file_path,
17    crate::visualization::{make_scatter, make_surface, pick_color},
18    plotly::layout::Axis,
19    plotly::{Layout, Plot, common},
20};
21
22#[cfg(feature = "static_export")]
23use crate::visualization::native;
24
25/// Chart contract of every type the library can draw: strategies, option
26/// chains' curves and surfaces, options, positions and simulations.
27///
28/// The required items are the same on every feature surface: implementors
29/// supply `graph_data()` and may override `graph_config()`. The backend
30/// features add provided renderers on top: `to_plot`, `write_html`, `show`,
31/// `render` and `to_interactive_html` under `plotly`, and `write_png` and
32/// `write_svg` under `static_export`. None of them needs to be implemented.
33pub trait Graph {
34    /// Return the raw data ready for plotting.
35    fn graph_data(&self) -> GraphData;
36
37    /// Optional per‑object configuration overrides.
38    fn graph_config(&self) -> GraphConfig {
39        GraphConfig::default()
40    }
41
42    /// Build a `plotly::Plot` according to data + config.
43    #[cfg(feature = "plotly")]
44    fn to_plot(&self) -> Plot {
45        let cfg = self.graph_config();
46        let mut plot = Plot::new();
47
48        match self.graph_data() {
49            GraphData::Series(s) => {
50                let mut series = s.clone();
51                if let Some(legend) = &cfg.legend
52                    && let Some(label) = legend.first()
53                {
54                    series.name = label.clone();
55                }
56                plot.add_trace(make_scatter(&series));
57            }
58            GraphData::MultiSeries(list) => {
59                for (idx, s) in list.into_iter().enumerate() {
60                    let mut series = s;
61
62                    if series.line_color.is_none() {
63                        series.line_color = pick_color(&cfg, idx);
64                    }
65
66                    if let Some(label) = cfg.legend.as_ref().and_then(|legend| legend.get(idx)) {
67                        series.name = label.clone();
68                    }
69
70                    plot.add_trace(make_scatter(&series));
71                }
72            }
73            GraphData::GraphSurface(surf) => {
74                let mut surface = surf.clone();
75                if let Some(legend) = &cfg.legend
76                    && let Some(label) = legend.first()
77                {
78                    surface.name = label.clone();
79                }
80                plot.add_trace(make_surface(&surface));
81            }
82        }
83
84        let mut layout = Layout::new()
85            .width(cfg.width as usize)
86            .height(cfg.height as usize)
87            .title(common::Title::from(&cfg.title))
88            .show_legend(cfg.show_legend);
89
90        if let Some(label) = cfg.x_label {
91            layout = layout.x_axis(Axis::new().title(common::Title::from(&label)));
92        }
93        if let Some(label) = cfg.y_label {
94            layout = layout.y_axis(Axis::new().title(common::Title::from(&label)));
95        }
96        if let Some(label) = cfg.z_label {
97            layout = layout.z_axis(Axis::new().title(common::Title::from(&label)));
98        }
99
100        plot.set_layout(layout);
101        plot
102    }
103
104    /// Writes the chart to `path` as a PNG of `graph_config().width` x
105    /// `graph_config().height` pixels, creating the parent directory when it
106    /// is missing.
107    ///
108    /// The image is drawn natively from [`Graph::graph_data`] and
109    /// [`Graph::graph_config`]: no browser, WebDriver, network or installed
110    /// font is involved, and concurrent exports, in threads or processes,
111    /// are independent. Since 0.23 it no longer goes through
112    /// [`Graph::to_plot`], so an override of `to_plot` changes the HTML
113    /// outputs but not this image.
114    ///
115    /// # Errors
116    ///
117    /// * [`GraphError::Io`] when the file or its directory cannot be written
118    ///   or the image cannot be encoded into it.
119    /// * [`GraphError::InvalidDimensions`] for a width or height of zero or
120    ///   above 16384 pixels; [`GraphError::TooSmall`] or
121    ///   [`GraphError::NoRoom`] when the figure cannot hold the chart.
122    /// * [`GraphError::LengthMismatch`], [`GraphError::SurfaceLengthMismatch`],
123    ///   [`GraphError::DegenerateSurface`], [`GraphError::DuplicateSurfacePoint`],
124    ///   [`GraphError::IncompleteSurface`] or [`GraphError::NotRepresentable`]
125    ///   for data it cannot draw without inventing or dropping values.
126    /// * [`GraphError::InvalidLineWidth`] or [`GraphError::InvalidColor`] for
127    ///   a series style it cannot reproduce.
128    /// * [`GraphError::MissingGlyph`] or [`GraphError::UnsupportedText`] for
129    ///   a label the bundled font cannot draw.
130    /// * [`GraphError::Render`] when drawing fails.
131    #[cfg(feature = "static_export")]
132    fn write_png(&self, path: &std::path::Path) -> Result<(), GraphError> {
133        native::write_png(&self.graph_data(), &self.graph_config(), path)
134    }
135
136    /// Writes the graph data to an HTML file at the specified path.
137    ///
138    /// This method generates a plot representation of the graph and saves it
139    /// as an HTML document. It ensures that the provided file path is prepared
140    /// (i.e., directories are created if necessary) before writing the file.
141    ///
142    /// # Arguments
143    ///
144    /// * `path` - A reference to a `std::path::Path` specifying the file path where
145    ///   the HTML file will be written.
146    ///
147    /// # Returns
148    ///
149    /// * `Ok(())` if the HTML file is successfully written.
150    /// * `Err(GraphError)` if an error occurs during file preparation or writing.
151    ///
152    /// # Errors
153    ///
154    /// This method can return the following errors:
155    /// * A `GraphError` if the file path preparation fails.
156    /// * Any other error propagated from the `.to_plot().write_html()` method.
157    ///
158    /// # Notes
159    ///
160    /// Ensure that the directory specified in the file path exists or can be created
161    /// with appropriate permissions to avoid errors during file preparation.
162    #[cfg(feature = "plotly")]
163    fn write_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
164        prepare_file_path(path)?;
165
166        // Create a plot with the graph data
167        let plot = self.to_plot();
168
169        // Get the plot configuration
170        let cfg = self.graph_config();
171
172        // Get the JSON representation of the plot
173        let plot_json = plot.to_json();
174
175        // Create a complete HTML document with embedded Plotly.js
176        let html = format!(
177            "\
178<!DOCTYPE html>
179<html lang=\"en\">
180<head>
181    <meta charset=\"utf-8\">
182    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
183    <title>{}</title>
184    <script src=\"https://cdn.plot.ly/plotly-2.24.1.min.js\" charset=\"utf-8\"></script>
185    <style>
186        body {{ margin: 0; padding: 20px; font-family: Arial, sans-serif; }}
187        #plotly-graph {{ width: 100%; height: 600px; }}
188    </style>
189</head>
190<body>
191    <div id=\"plotly-graph\"></div>
192    <script>
193        var plotJson = {};
194        Plotly.newPlot('plotly-graph', plotJson);
195    </script>
196</body>
197</html>",
198            cfg.title, plot_json
199        );
200
201        // Write HTML content to file
202        std::fs::write(path, html)
203            .map_err(|e| GraphError::Render(format!("Failed to write HTML file: {e}")))?;
204
205        Ok(())
206    }
207
208    /// Writes the chart to `path` as an SVG of `graph_config().width` x
209    /// `graph_config().height` user units, creating the parent directory
210    /// when it is missing.
211    ///
212    /// Drawn natively like [`Graph::write_png`], with real vector shapes.
213    /// Every label is drawn as glyph outlines of the bundled Open Sans, so
214    /// the file looks the same in every viewer and needs no font. Each label
215    /// keeps its text as an accessible name (`<g role="img" aria-label>`)
216    /// and the document carries a `<title>` and `<desc>`; the text can no
217    /// longer be selected, searched or edited as text.
218    ///
219    /// # Errors
220    ///
221    /// Everything [`Graph::write_png`] returns, and
222    /// [`GraphError::NotXmlText`] for a label, title or series name that
223    /// contains a character XML cannot carry.
224    #[cfg(feature = "static_export")]
225    fn write_svg(&self, path: &std::path::Path) -> Result<(), GraphError> {
226        native::write_svg(&self.graph_data(), &self.graph_config(), path)
227    }
228
229    /// Show the plot in browser
230    ///
231    /// # Errors
232    ///
233    /// Returns [`GraphError::Io`] when the page cannot be written to the
234    /// system temp directory or the platform's opener (`open`, `xdg-open`,
235    /// `explorer`) cannot be started, as on a headless server, and
236    /// [`GraphError::Render`] on a platform with no default opener.
237    #[cfg(feature = "plotly")]
238    fn show(&self) -> Result<(), GraphError> {
239        show_in_browser(&self.to_plot())
240    }
241
242    /// One‑stop rendering with error propagation.
243    ///
244    /// # Errors
245    ///
246    /// A PNG or SVG target returns whatever `Graph::write_png` or
247    /// `Graph::write_svg` returns, unchanged; in a build without
248    /// `static_export` it is `GraphError::Render`. HTML and browser targets
249    /// return `GraphError::Render` when serialization fails and
250    /// `GraphError::Io` when the destination cannot be written.
251    #[cfg(feature = "plotly")]
252    fn render(&self, output: OutputType) -> Result<(), GraphError> {
253        match output {
254            // The export's own error, typed, not folded into `Render`.
255            #[cfg(feature = "static_export")]
256            OutputType::Png(path) => self.write_png(path)?,
257            #[cfg(feature = "static_export")]
258            OutputType::Svg(path) => self.write_svg(path)?,
259            #[cfg(not(feature = "static_export"))]
260            OutputType::Png(_) | OutputType::Svg(_) => {
261                return Err(static_export_disabled());
262            }
263            OutputType::Browser => self.show()?,
264            OutputType::Html(path) => self.to_interactive_html(path)?,
265        }
266        Ok(())
267    }
268
269    /// Generate interactive HTML with hover info + annotations.
270    ///
271    /// # Errors
272    ///
273    /// Propagates any [`GraphError`] returned by
274    /// `PlotlyChart::write_html`, typically
275    /// `GraphError::Io` when the target file cannot be
276    /// created or written.
277    #[cfg(feature = "plotly")]
278    fn to_interactive_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
279        self.write_html(path)
280    }
281}
282
283/// The error `Graph::render` returns for a PNG or SVG target in a build
284/// without the `static_export` feature, which is what writes images.
285/// Writes `plot` as a standalone page in the system temp directory and hands
286/// it to the platform's default opener.
287///
288/// `plotly`'s own `Plot::show` does the same, with the same page, but aborts
289/// the process when the temp file cannot be written or the opener cannot be
290/// started (a headless server has no `xdg-open`); here both come back as a
291/// [`GraphError`].
292#[cfg(feature = "plotly")]
293#[inline(never)]
294fn show_in_browser(plot: &Plot) -> Result<(), GraphError> {
295    use std::sync::atomic::{AtomicU64, Ordering};
296    use std::time::{SystemTime, UNIX_EPOCH};
297
298    // Process id, wall-clock nanoseconds and a per-process sequence number
299    // keep two pages shown at once from overwriting each other; the
300    // sequence only names files, so it is free to wrap.
301    static SEQUENCE: AtomicU64 = AtomicU64::new(0);
302    let nanos = SystemTime::now()
303        .duration_since(UNIX_EPOCH)
304        .map(|elapsed| elapsed.as_nanos())
305        .unwrap_or_default();
306    let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
307    let path = std::env::temp_dir().join(format!(
308        "plotly_optionstratlib_{}_{nanos}_{sequence}.html",
309        std::process::id()
310    ));
311    std::fs::write(&path, plot.to_html())?;
312    open_in_default_app(&path)
313}
314
315/// Opens `path` with the platform's default application, the way `plotly`
316/// does: `open` on macOS, `xdg-open` on the other Unix systems and
317/// `explorer` on Windows.
318#[cfg(feature = "plotly")]
319#[inline(never)]
320fn open_in_default_app(path: &std::path::Path) -> Result<(), GraphError> {
321    use std::process::Command;
322
323    #[cfg(target_os = "macos")]
324    Command::new("open").arg(path).output()?;
325    #[cfg(all(unix, not(target_os = "macos")))]
326    Command::new("xdg-open").arg(path).output()?;
327    #[cfg(target_os = "windows")]
328    Command::new("explorer").arg(path).spawn()?;
329    #[cfg(not(any(unix, target_os = "windows")))]
330    return Err(GraphError::Render(format!(
331        "no default application to open {} on this platform",
332        path.display()
333    )));
334    #[cfg(any(unix, target_os = "windows"))]
335    Ok(())
336}
337
338#[cfg(all(feature = "plotly", not(feature = "static_export")))]
339#[cold]
340#[inline(never)]
341fn static_export_disabled() -> GraphError {
342    GraphError::Render(
343        "png and svg export need the `static_export` feature of optionstratlib-visualization"
344            .to_string(),
345    )
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::visualization::{ColorScheme, LineStyle, Series2D};
352    use rust_decimal_macros::dec;
353
354    // A simple struct that implements the Graph trait for testing
355    struct TestGraph {
356        data: GraphData,
357        config: Option<GraphConfig>,
358    }
359
360    impl TestGraph {
361        fn new(data: GraphData) -> Self {
362            TestGraph { data, config: None }
363        }
364
365        fn with_config(data: GraphData, config: GraphConfig) -> Self {
366            TestGraph {
367                data,
368                config: Some(config),
369            }
370        }
371    }
372
373    impl Graph for TestGraph {
374        fn graph_data(&self) -> GraphData {
375            self.data.clone()
376        }
377
378        fn graph_config(&self) -> GraphConfig {
379            match &self.config {
380                Some(config) => config.clone(),
381                None => GraphConfig::default(),
382            }
383        }
384    }
385
386    fn default_series() -> GraphData {
387        GraphData::Series(Series2D {
388            x: vec![dec!(1.0), dec!(2.0)],
389            y: vec![dec!(3.0), dec!(4.0)],
390            name: "Test Series".to_string(),
391            mode: crate::visualization::TraceMode::Lines,
392            line_color: None,
393            line_width: None,
394        })
395    }
396
397    #[test]
398    fn test_graph_data() {
399        // Create some test data
400        let data = default_series();
401        let graph = TestGraph::new(data.clone());
402
403        // Verify that graph_data returns the expected data
404        assert_eq!(graph.graph_data(), data);
405    }
406
407    #[test]
408    fn test_default_graph_config() {
409        let data = default_series();
410        let graph = TestGraph::new(data);
411
412        // Verify that the default config is returned when none is specified
413        let default_config = GraphConfig::default();
414        assert_eq!(graph.graph_config(), default_config);
415    }
416
417    #[test]
418    fn test_custom_graph_config() {
419        let data = default_series();
420
421        // Create a custom config
422        let custom_config = GraphConfig {
423            title: "Custom Title".to_string(),
424            width: 800,
425            height: 600,
426            x_label: Some("X Axis".to_string()),
427            y_label: Some("Y Axis".to_string()),
428            z_label: None,
429            line_style: LineStyle::Dashed,
430            color_scheme: ColorScheme::Default,
431            legend: Some(vec!["Series 1".to_string(), "Series 2".to_string()]),
432            show_legend: true,
433        };
434
435        let graph = TestGraph::with_config(data, custom_config.clone());
436
437        // Verify that the custom config is returned
438        assert_eq!(graph.graph_config(), custom_config);
439    }
440
441    #[test]
442    fn test_graph_config_fields() {
443        let data = default_series();
444
445        // Create a custom config with specific properties to test
446        let custom_config = GraphConfig {
447            title: "Test Chart".to_string(),
448            width: 1024,
449            height: 768,
450            x_label: Some("Time".to_string()),
451            y_label: Some("Value".to_string()),
452            z_label: Some("Depth".to_string()),
453            line_style: LineStyle::Solid,
454            color_scheme: ColorScheme::Viridis,
455            legend: Some(vec!["Data A".to_string(), "Data B".to_string()]),
456            show_legend: true,
457        };
458
459        let graph = TestGraph::with_config(data, custom_config);
460        let config = graph.graph_config();
461
462        // Test individual fields
463        assert_eq!(config.title, "Test Chart");
464        assert_eq!(config.width, 1024);
465        assert_eq!(config.height, 768);
466        assert_eq!(config.x_label, Some("Time".to_string()));
467        assert_eq!(config.y_label, Some("Value".to_string()));
468        assert_eq!(config.z_label, Some("Depth".to_string()));
469        assert_eq!(config.line_style, LineStyle::Solid);
470        assert_eq!(config.color_scheme, ColorScheme::Viridis);
471        assert_eq!(
472            config.legend,
473            Some(vec!["Data A".to_string(), "Data B".to_string()])
474        );
475        assert!(config.show_legend);
476    }
477
478    #[cfg(all(feature = "plotly", not(feature = "static_export")))]
479    #[test]
480    fn test_render_png_and_svg_without_static_export_return_render_error() {
481        let graph = TestGraph::new(default_series());
482        let path = std::path::PathBuf::from("render_without_static_export.png");
483        for output in [OutputType::Png(&path), OutputType::Svg(&path)] {
484            match graph.render(output) {
485                Err(GraphError::Render(message)) => assert!(message.contains("static_export")),
486                other => panic!("expected GraphError::Render, got {other:?}"),
487            }
488        }
489        assert!(!path.exists());
490    }
491}