optionstratlib_visualization/visualization/
graph.rs1use 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
25pub trait Graph {
34 fn graph_data(&self) -> GraphData;
36
37 fn graph_config(&self) -> GraphConfig {
39 GraphConfig::default()
40 }
41
42 #[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 #[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 #[cfg(feature = "plotly")]
163 fn write_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
164 prepare_file_path(path)?;
165
166 let plot = self.to_plot();
168
169 let cfg = self.graph_config();
171
172 let plot_json = plot.to_json();
174
175 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 std::fs::write(path, html)
203 .map_err(|e| GraphError::Render(format!("Failed to write HTML file: {e}")))?;
204
205 Ok(())
206 }
207
208 #[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 #[cfg(feature = "plotly")]
238 fn show(&self) -> Result<(), GraphError> {
239 show_in_browser(&self.to_plot())
240 }
241
242 #[cfg(feature = "plotly")]
252 fn render(&self, output: OutputType) -> Result<(), GraphError> {
253 match output {
254 #[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 #[cfg(feature = "plotly")]
278 fn to_interactive_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
279 self.write_html(path)
280 }
281}
282
283#[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 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#[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 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 let data = default_series();
401 let graph = TestGraph::new(data.clone());
402
403 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 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 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 assert_eq!(graph.graph_config(), custom_config);
439 }
440
441 #[test]
442 fn test_graph_config_fields() {
443 let data = default_series();
444
445 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 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}