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