use crate::visualization::{GraphConfig, GraphData};
#[cfg(feature = "plotly")]
use {
crate::error::GraphError,
crate::visualization::OutputType,
crate::visualization::file::prepare_file_path,
crate::visualization::{make_scatter, make_surface, pick_color},
plotly::layout::Axis,
plotly::{Layout, Plot, common},
};
#[cfg(feature = "static_export")]
use crate::visualization::native;
pub trait Graph {
fn graph_data(&self) -> GraphData;
fn graph_config(&self) -> GraphConfig {
GraphConfig::default()
}
#[cfg(feature = "plotly")]
fn to_plot(&self) -> Plot {
let cfg = self.graph_config();
let mut plot = Plot::new();
match self.graph_data() {
GraphData::Series(s) => {
let mut series = s.clone();
if let Some(legend) = &cfg.legend
&& let Some(label) = legend.first()
{
series.name = label.clone();
}
plot.add_trace(make_scatter(&series));
}
GraphData::MultiSeries(list) => {
for (idx, s) in list.into_iter().enumerate() {
let mut series = s;
if series.line_color.is_none() {
series.line_color = pick_color(&cfg, idx);
}
if let Some(label) = cfg.legend.as_ref().and_then(|legend| legend.get(idx)) {
series.name = label.clone();
}
plot.add_trace(make_scatter(&series));
}
}
GraphData::GraphSurface(surf) => {
let mut surface = surf.clone();
if let Some(legend) = &cfg.legend
&& let Some(label) = legend.first()
{
surface.name = label.clone();
}
plot.add_trace(make_surface(&surface));
}
}
let mut layout = Layout::new()
.width(cfg.width as usize)
.height(cfg.height as usize)
.title(common::Title::from(&cfg.title))
.show_legend(cfg.show_legend);
if let Some(label) = cfg.x_label {
layout = layout.x_axis(Axis::new().title(common::Title::from(&label)));
}
if let Some(label) = cfg.y_label {
layout = layout.y_axis(Axis::new().title(common::Title::from(&label)));
}
if let Some(label) = cfg.z_label {
layout = layout.z_axis(Axis::new().title(common::Title::from(&label)));
}
plot.set_layout(layout);
plot
}
#[cfg(feature = "static_export")]
fn write_png(&self, path: &std::path::Path) -> Result<(), GraphError> {
native::write_png(&self.graph_data(), &self.graph_config(), path)
}
#[cfg(feature = "plotly")]
fn write_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
prepare_file_path(path)?;
let plot = self.to_plot();
let cfg = self.graph_config();
let plot_json = plot.to_json();
let html = format!(
"\
<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{}</title>
<script src=\"https://cdn.plot.ly/plotly-2.24.1.min.js\" charset=\"utf-8\"></script>
<style>
body {{ margin: 0; padding: 20px; font-family: Arial, sans-serif; }}
#plotly-graph {{ width: 100%; height: 600px; }}
</style>
</head>
<body>
<div id=\"plotly-graph\"></div>
<script>
var plotJson = {};
Plotly.newPlot('plotly-graph', plotJson);
</script>
</body>
</html>",
cfg.title, plot_json
);
std::fs::write(path, html)
.map_err(|e| GraphError::Render(format!("Failed to write HTML file: {e}")))?;
Ok(())
}
#[cfg(feature = "static_export")]
fn write_svg(&self, path: &std::path::Path) -> Result<(), GraphError> {
native::write_svg(&self.graph_data(), &self.graph_config(), path)
}
#[cfg(feature = "plotly")]
fn show(&self) -> Result<(), GraphError> {
show_in_browser(&self.to_plot())
}
#[cfg(feature = "plotly")]
fn render(&self, output: OutputType) -> Result<(), GraphError> {
match output {
#[cfg(feature = "static_export")]
OutputType::Png(path) => self.write_png(path)?,
#[cfg(feature = "static_export")]
OutputType::Svg(path) => self.write_svg(path)?,
#[cfg(not(feature = "static_export"))]
OutputType::Png(_) | OutputType::Svg(_) => {
return Err(static_export_disabled());
}
OutputType::Browser => self.show()?,
OutputType::Html(path) => self.to_interactive_html(path)?,
}
Ok(())
}
#[cfg(feature = "plotly")]
fn to_interactive_html(&self, path: &std::path::Path) -> Result<(), GraphError> {
self.write_html(path)
}
}
#[cfg(feature = "plotly")]
#[inline(never)]
fn show_in_browser(plot: &Plot) -> Result<(), GraphError> {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default();
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"plotly_optionstratlib_{}_{nanos}_{sequence}.html",
std::process::id()
));
std::fs::write(&path, plot.to_html())?;
open_in_default_app(&path)
}
#[cfg(feature = "plotly")]
#[inline(never)]
fn open_in_default_app(path: &std::path::Path) -> Result<(), GraphError> {
use std::process::Command;
#[cfg(target_os = "macos")]
Command::new("open").arg(path).output()?;
#[cfg(all(unix, not(target_os = "macos")))]
Command::new("xdg-open").arg(path).output()?;
#[cfg(target_os = "windows")]
Command::new("explorer").arg(path).spawn()?;
#[cfg(not(any(unix, target_os = "windows")))]
return Err(GraphError::Render(format!(
"no default application to open {} on this platform",
path.display()
)));
#[cfg(any(unix, target_os = "windows"))]
Ok(())
}
#[cfg(all(feature = "plotly", not(feature = "static_export")))]
#[cold]
#[inline(never)]
fn static_export_disabled() -> GraphError {
GraphError::Render(
"png and svg export need the `static_export` feature of optionstratlib-visualization"
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::visualization::{ColorScheme, LineStyle, Series2D};
use rust_decimal_macros::dec;
struct TestGraph {
data: GraphData,
config: Option<GraphConfig>,
}
impl TestGraph {
fn new(data: GraphData) -> Self {
TestGraph { data, config: None }
}
fn with_config(data: GraphData, config: GraphConfig) -> Self {
TestGraph {
data,
config: Some(config),
}
}
}
impl Graph for TestGraph {
fn graph_data(&self) -> GraphData {
self.data.clone()
}
fn graph_config(&self) -> GraphConfig {
match &self.config {
Some(config) => config.clone(),
None => GraphConfig::default(),
}
}
}
fn default_series() -> GraphData {
GraphData::Series(Series2D {
x: vec![dec!(1.0), dec!(2.0)],
y: vec![dec!(3.0), dec!(4.0)],
name: "Test Series".to_string(),
mode: crate::visualization::TraceMode::Lines,
line_color: None,
line_width: None,
})
}
#[test]
fn test_graph_data() {
let data = default_series();
let graph = TestGraph::new(data.clone());
assert_eq!(graph.graph_data(), data);
}
#[test]
fn test_default_graph_config() {
let data = default_series();
let graph = TestGraph::new(data);
let default_config = GraphConfig::default();
assert_eq!(graph.graph_config(), default_config);
}
#[test]
fn test_custom_graph_config() {
let data = default_series();
let custom_config = GraphConfig {
title: "Custom Title".to_string(),
width: 800,
height: 600,
x_label: Some("X Axis".to_string()),
y_label: Some("Y Axis".to_string()),
z_label: None,
line_style: LineStyle::Dashed,
color_scheme: ColorScheme::Default,
legend: Some(vec!["Series 1".to_string(), "Series 2".to_string()]),
show_legend: true,
};
let graph = TestGraph::with_config(data, custom_config.clone());
assert_eq!(graph.graph_config(), custom_config);
}
#[test]
fn test_graph_config_fields() {
let data = default_series();
let custom_config = GraphConfig {
title: "Test Chart".to_string(),
width: 1024,
height: 768,
x_label: Some("Time".to_string()),
y_label: Some("Value".to_string()),
z_label: Some("Depth".to_string()),
line_style: LineStyle::Solid,
color_scheme: ColorScheme::Viridis,
legend: Some(vec!["Data A".to_string(), "Data B".to_string()]),
show_legend: true,
};
let graph = TestGraph::with_config(data, custom_config);
let config = graph.graph_config();
assert_eq!(config.title, "Test Chart");
assert_eq!(config.width, 1024);
assert_eq!(config.height, 768);
assert_eq!(config.x_label, Some("Time".to_string()));
assert_eq!(config.y_label, Some("Value".to_string()));
assert_eq!(config.z_label, Some("Depth".to_string()));
assert_eq!(config.line_style, LineStyle::Solid);
assert_eq!(config.color_scheme, ColorScheme::Viridis);
assert_eq!(
config.legend,
Some(vec!["Data A".to_string(), "Data B".to_string()])
);
assert!(config.show_legend);
}
#[cfg(all(feature = "plotly", not(feature = "static_export")))]
#[test]
fn test_render_png_and_svg_without_static_export_return_render_error() {
let graph = TestGraph::new(default_series());
let path = std::path::PathBuf::from("render_without_static_export.png");
for output in [OutputType::Png(&path), OutputType::Svg(&path)] {
match graph.render(output) {
Err(GraphError::Render(message)) => assert!(message.contains("static_export")),
other => panic!("expected GraphError::Render, got {other:?}"),
}
}
assert!(!path.exists());
}
}