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 std::sync::atomic::{AtomicU64, Ordering},
21};
22
23#[cfg(feature = "static_export")]
24use crate::visualization::native;
25
26pub trait Graph {
35 fn graph_data(&self) -> GraphData;
37
38 fn graph_config(&self) -> GraphConfig {
40 GraphConfig::default()
41 }
42
43 #[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 #[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 #[cfg(feature = "plotly")]
164 fn write_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
165 prepare_file_path(path)?;
166
167 let plot = self.to_plot();
169
170 let cfg = self.graph_config();
172
173 let plot_json = plot.to_json();
175
176 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 std::fs::write(path, html)
204 .map_err(|e| GraphError::Render(format!("Failed to write HTML file: {e}")))?;
205
206 Ok(())
207 }
208
209 #[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 #[cfg(feature = "plotly")]
239 fn show(&self) -> Result<(), GraphError> {
240 show_in_browser(&self.to_plot())
241 }
242
243 #[cfg(feature = "plotly")]
253 fn render(&self, output: OutputType) -> Result<(), GraphError> {
254 match output {
255 #[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 #[cfg(feature = "plotly")]
279 fn to_interactive_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
280 self.write_html(path)
281 }
282}
283
284#[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#[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#[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 #[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 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 let data = default_series();
470 let graph = TestGraph::new(data.clone());
471
472 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 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 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 assert_eq!(graph.graph_config(), custom_config);
508 }
509
510 #[test]
511 fn test_graph_config_fields() {
512 let data = default_series();
513
514 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 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}