plotly_fork/
plot.rs

1use std::{fs::File, io::Write, path::Path};
2
3use askama::Template;
4use dyn_clone::DynClone;
5use erased_serde::Serialize as ErasedSerialize;
6use rand::{
7    distributions::{Alphanumeric, DistString},
8    thread_rng,
9};
10use serde::Serialize;
11
12use crate::{Configuration, Layout};
13
14#[derive(Template)]
15#[template(path = "plot.html", escape = "none")]
16struct PlotTemplate<'a> {
17    plot: &'a Plot,
18    remote_plotly_js: bool,
19}
20
21#[derive(Template)]
22#[template(path = "static_plot.html", escape = "none")]
23struct StaticPlotTemplate<'a> {
24    plot: &'a Plot,
25    format: ImageFormat,
26    remote_plotly_js: bool,
27    width: usize,
28    height: usize,
29}
30
31#[derive(Template)]
32#[template(path = "inline_plot.html", escape = "none")]
33struct InlinePlotTemplate<'a> {
34    plot: &'a Plot,
35    plot_div_id: &'a str,
36}
37
38#[derive(Template)]
39#[template(path = "jupyter_notebook_plot.html", escape = "none")]
40struct JupyterNotebookPlotTemplate<'a> {
41    plot: &'a Plot,
42    plot_div_id: &'a str,
43}
44
45#[cfg(not(target_family = "wasm"))]
46const DEFAULT_HTML_APP_NOT_FOUND: &str = r#"Could not find default application for HTML files.
47Consider using the `to_html` method obtain a string representation instead. If using the `kaleido` feature the
48`write_image` method can be used to produce a static image in one of the following formats:
49- ImageFormat::PNG
50- ImageFormat::JPEG
51- ImageFormat::WEBP
52- ImageFormat::SVG
53- ImageFormat::PDF
54- ImageFormat::EPS
55
56Used as follows:
57let plot = Plot::new();
58...
59let width = 1024;
60let height = 680;
61let scale = 1.0;
62plot.write_image("filename", ImageFormat::PNG, width, height, scale);
63
64See https://igiagkiozis.github.io/plotly/content/getting_started.html for further details.
65"#;
66
67/// Image format for static image export.
68#[derive(Debug)]
69pub enum ImageFormat {
70    PNG,
71    JPEG,
72    WEBP,
73    SVG,
74    PDF,
75    EPS,
76}
77
78impl std::fmt::Display for ImageFormat {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(
81            f,
82            "{}",
83            match self {
84                Self::PNG => "png",
85                Self::JPEG => "jpeg",
86                Self::WEBP => "webp",
87                Self::SVG => "svg",
88                Self::PDF => "pdf",
89                Self::EPS => "eps",
90            }
91        )
92    }
93}
94
95/// A struct that implements `Trace` can be serialized to json format that is
96/// understood by Plotly.js.
97pub trait Trace: DynClone + ErasedSerialize {
98    fn to_json(&self) -> String;
99}
100
101dyn_clone::clone_trait_object!(Trace);
102erased_serde::serialize_trait_object!(Trace);
103
104#[derive(Default, Serialize, Clone)]
105#[serde(transparent)]
106pub struct Traces {
107    traces: Vec<Box<dyn Trace>>,
108}
109
110impl Traces {
111    pub fn new() -> Self {
112        Self {
113            traces: Vec::with_capacity(1),
114        }
115    }
116
117    pub fn push(&mut self, trace: Box<dyn Trace>) {
118        self.traces.push(trace)
119    }
120
121    pub fn len(&self) -> usize {
122        self.traces.len()
123    }
124
125    pub fn is_empty(&self) -> bool {
126        self.traces.is_empty()
127    }
128
129    pub fn iter(&self) -> std::slice::Iter<'_, Box<dyn Trace>> {
130        self.traces.iter()
131    }
132
133    pub fn to_json(&self) -> String {
134        serde_json::to_string(self).unwrap()
135    }
136}
137
138/// Plot is a container for structs that implement the `Trace` trait. Optionally
139/// a `Layout` can also be specified. Its function is to serialize `Trace`s and
140/// the `Layout` in html format and display and/or persist the resulting plot.
141///
142/// # Examples
143///
144/// ```rust
145/// use plotly::common::Mode;
146/// use plotly::{Layout, Plot, Scatter};
147///
148/// fn line_and_scatter_plot() {
149///     let trace1 = Scatter::new(vec![1, 2, 3, 4], vec![10, 15, 13, 17])
150///         .name("trace1")
151///         .mode(Mode::Markers);
152///     let trace2 = Scatter::new(vec![2, 3, 4, 5], vec![16, 5, 11, 9])
153///         .name("trace2")
154///         .mode(Mode::Lines);
155///     let trace3 = Scatter::new(vec![1, 2, 3, 4], vec![12, 9, 15, 12])
156///         .name("trace3");
157///
158///     let mut plot = Plot::new();
159///     plot.add_trace(trace1);
160///     plot.add_trace(trace2);
161///     plot.add_trace(trace3);
162///
163///     let layout = Layout::new().title("<b>Line and Scatter Plot</b>".into());
164///     plot.set_layout(layout);
165///     
166///     # if false {  // We don't actually want to try and display the plot in a browser when running a doctest.
167///     plot.show();
168///     # }
169/// }
170///
171/// fn main() -> std::io::Result<()> {
172///     line_and_scatter_plot();
173///     Ok(())
174/// }
175/// ```
176#[derive(Default, Serialize, Clone)]
177pub struct Plot {
178    #[serde(rename = "data")]
179    traces: Traces,
180    layout: Layout,
181    #[serde(rename = "config")]
182    configuration: Configuration,
183    #[serde(skip)]
184    remote_plotly_js: bool,
185}
186
187impl Plot {
188    /// Create a new `Plot`.
189    pub fn new() -> Plot {
190        Plot {
191            traces: Traces::new(),
192            remote_plotly_js: true,
193            ..Default::default()
194        }
195    }
196
197    /// This option results in the plotly.js library being written directly in
198    /// the html output. The benefit is that the plot will load faster in
199    /// the browser and the downside is that the resulting html will be much
200    /// larger.
201    ///
202    /// Note that when using `Plot::to_inline_html()`, it is assumed that the
203    /// `plotly.js` library is already in scope, so setting this attribute
204    /// will have no effect.
205    pub fn use_local_plotly(&mut self) {
206        self.remote_plotly_js = false;
207    }
208
209    /// Add a `Trace` to the `Plot`.
210    pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
211        self.traces.push(trace);
212    }
213
214    /// Add multiple `Trace`s to the `Plot`.
215    pub fn add_traces(&mut self, traces: Vec<Box<dyn Trace>>) {
216        for trace in traces {
217            self.add_trace(trace);
218        }
219    }
220
221    /// Set the `Layout` to be used by `Plot`.
222    pub fn set_layout(&mut self, layout: Layout) {
223        self.layout = layout;
224    }
225
226    /// Set the `Configuration` to be used by `Plot`.
227    pub fn set_configuration(&mut self, configuration: Configuration) {
228        self.configuration = configuration;
229    }
230
231    /// Get the contained data elements.
232    pub fn data(&self) -> &Traces {
233        &self.traces
234    }
235
236    /// Get the layout specification of the plot.
237    pub fn layout(&self) -> &Layout {
238        &self.layout
239    }
240
241    /// Get the configuration specification of the plot.
242    pub fn configuration(&self) -> &Configuration {
243        &self.configuration
244    }
245
246    /// Display the fully rendered HTML `Plot` in the default system browser.
247    ///
248    /// The HTML file is saved in a temp file, from which it is read and
249    /// displayed by the browser.
250    #[cfg(not(target_family = "wasm"))]
251    pub fn show(&self) {
252        use std::env;
253
254        let rendered = self.render();
255
256        // Set up the temp file with a unique filename.
257        let mut temp = env::temp_dir();
258        let mut plot_name = Alphanumeric.sample_string(&mut thread_rng(), 22);
259        plot_name.push_str(".html");
260        plot_name = format!("plotly_{}", plot_name);
261        temp.push(plot_name);
262
263        // Save the rendered plot to the temp file.
264        let temp_path = temp.to_str().unwrap();
265
266        {
267            let mut file = File::create(temp_path).unwrap();
268            file.write_all(rendered.as_bytes())
269                .expect("failed to write html output");
270            file.flush().unwrap();
271        }
272
273        // Hand off the job of opening the browser to an OS-specific implementation.
274        Plot::show_with_default_app(temp_path);
275    }
276
277    /// Display the fully rendered `Plot` as a static image of the given format
278    /// in the default system browser.
279    #[cfg(not(target_family = "wasm"))]
280    pub fn show_image(&self, format: ImageFormat, width: usize, height: usize) {
281        use std::env;
282
283        let rendered = self.render_static(format, width, height);
284
285        // Set up the temp file with a unique filename.
286        let mut temp = env::temp_dir();
287        let mut plot_name = Alphanumeric.sample_string(&mut thread_rng(), 22);
288        plot_name.push_str(".html");
289        plot_name = format!("plotly_{}", plot_name);
290        temp.push(plot_name);
291
292        // Save the rendered plot to the temp file.
293        let temp_path = temp.to_str().unwrap();
294
295        {
296            let mut file = File::create(temp_path).unwrap();
297            file.write_all(rendered.as_bytes())
298                .expect("failed to write html output");
299            file.flush().unwrap();
300        }
301
302        // Hand off the job of opening the browser to an OS-specific implementation.
303        Plot::show_with_default_app(temp_path);
304    }
305
306    /// Save the rendered `Plot` to a file at the given location.
307    ///
308    /// This method will render the plot to a full, standalone HTML document,
309    /// before saving it to the given location.
310    pub fn write_html<P: AsRef<Path>>(&self, filename: P) {
311        let rendered = self.to_html();
312
313        let mut file = File::create(filename).unwrap();
314        file.write_all(rendered.as_bytes())
315            .expect("failed to write html output");
316        file.flush().unwrap();
317    }
318
319    /// Convert a `Plot` to an HTML string representation.
320    ///
321    /// This method will generate a full, standalone HTML document. To generate
322    /// a minimal HTML string which can be embedded within an existing HTML
323    /// page, use `Plot::to_inline_html()`.
324    pub fn to_html(&self) -> String {
325        self.render()
326    }
327
328    /// Renders the contents of the `Plot` and returns it as a String suitable
329    /// for embedding within web pages or Jupyter notebooks.
330    ///
331    /// A `div` is generated with the supplied id followed by the `script` block
332    /// that generates the plot. The assumption is that `plotly.js` is
333    /// available within the HTML page that this element is embedded. If
334    /// that assumption is violated then the plot will not be displayed.
335    ///
336    /// If `plot_div_id` is `None` the plot div id will be randomly generated,
337    /// otherwise the user-supplied `plot_div_id` is used.
338    ///
339    /// To generate a full, standalone HTML string or file, use
340    /// `Plot::to_html()` and `Plot::write_html()`, respectively.
341    pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
342        let plot_div_id = match plot_div_id {
343            Some(id) => id.to_string(),
344            None => Alphanumeric.sample_string(&mut thread_rng(), 20),
345        };
346        self.render_inline(&plot_div_id)
347    }
348
349    fn to_jupyter_notebook_html(&self) -> String {
350        let plot_div_id = Alphanumeric.sample_string(&mut thread_rng(), 20);
351
352        let tmpl = JupyterNotebookPlotTemplate {
353            plot: self,
354            plot_div_id: &plot_div_id,
355        };
356        tmpl.render().unwrap()
357    }
358
359    /// Display plot in Jupyter Notebook.
360    pub fn notebook_display(&self) {
361        let plot_data = self.to_jupyter_notebook_html();
362        println!(
363            "EVCXR_BEGIN_CONTENT text/html\n{}\nEVCXR_END_CONTENT",
364            plot_data
365        );
366    }
367
368    /// Display plot in Jupyter Lab.
369    pub fn lab_display(&self) {
370        let plot_data = self.to_json();
371        println!(
372            "EVCXR_BEGIN_CONTENT application/vnd.plotly.v1+json\n{}\nEVCXR_END_CONTENT",
373            plot_data
374        );
375    }
376
377    /// Displays the plot in Jupyter Lab; if running a Jupyter Notebook then use
378    /// the `notebook_display()` method instead.
379    pub fn evcxr_display(&self) {
380        self.lab_display();
381    }
382
383    /// Convert the `Plot` to a static image of the given image format and save
384    /// at the given location.
385    #[cfg(feature = "kaleido")]
386    pub fn write_image<P: AsRef<Path>>(
387        &self,
388        filename: P,
389        format: ImageFormat,
390        width: usize,
391        height: usize,
392        scale: f64,
393    ) {
394        let kaleido = plotly_kaleido::Kaleido::new();
395        kaleido
396            .save(
397                filename.as_ref(),
398                &serde_json::to_value(self).unwrap(),
399                &format.to_string(),
400                width,
401                height,
402                scale,
403            )
404            .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
405    }
406
407    fn render(&self) -> String {
408        let tmpl = PlotTemplate {
409            plot: self,
410            remote_plotly_js: self.remote_plotly_js,
411        };
412        tmpl.render().unwrap()
413    }
414
415    #[cfg(not(target_family = "wasm"))]
416    fn render_static(&self, format: ImageFormat, width: usize, height: usize) -> String {
417        let tmpl = StaticPlotTemplate {
418            plot: self,
419            format,
420            remote_plotly_js: self.remote_plotly_js,
421            width,
422            height,
423        };
424        tmpl.render().unwrap()
425    }
426
427    fn render_inline(&self, plot_div_id: &str) -> String {
428        let tmpl = InlinePlotTemplate {
429            plot: self,
430            plot_div_id,
431        };
432        tmpl.render().unwrap()
433    }
434
435    pub fn to_json(&self) -> String {
436        serde_json::to_string(self).unwrap()
437    }
438
439    #[cfg(feature = "wasm")]
440    /// Convert a `Plot` to a native Javasript `js_sys::Object`.
441    pub fn to_js_object(&self) -> js_sys::Object {
442        use wasm_bindgen::JsCast;
443        // The only reason this could fail is if to_json() produces structurally
444        // incorrect JSON. That would be a bug, and would require fixing in the
445        // to_json()/serialization methods, rather than here
446        js_sys::JSON::parse(&self.to_json())
447            .expect("Invalid JSON")
448            .dyn_into::<js_sys::Object>()
449            .expect("Invalid JSON structure - expected a top-level Object")
450    }
451
452    #[cfg(target_os = "linux")]
453    fn show_with_default_app(temp_path: &str) {
454        use std::process::Command;
455        Command::new("xdg-open")
456            .args([temp_path])
457            .output()
458            .expect(DEFAULT_HTML_APP_NOT_FOUND);
459    }
460
461    #[cfg(target_os = "macos")]
462    fn show_with_default_app(temp_path: &str) {
463        use std::process::Command;
464        Command::new("open")
465            .args(&[temp_path])
466            .output()
467            .expect(DEFAULT_HTML_APP_NOT_FOUND);
468    }
469
470    #[cfg(target_os = "windows")]
471    fn show_with_default_app(temp_path: &str) {
472        use std::process::Command;
473        Command::new("cmd")
474            .args(&["/C", "start", &format!(r#"{}"#, temp_path)])
475            .spawn()
476            .expect(DEFAULT_HTML_APP_NOT_FOUND);
477    }
478}
479
480impl PartialEq for Plot {
481    fn eq(&self, other: &Self) -> bool {
482        self.to_json() == other.to_json()
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use std::path::PathBuf;
489
490    use serde_json::{json, to_value};
491
492    use super::*;
493    use crate::Scatter;
494
495    fn create_test_plot() -> Plot {
496        let trace1 = Scatter::new(vec![0, 1, 2], vec![6, 10, 2]).name("trace1");
497        let mut plot = Plot::new();
498        plot.add_trace(trace1);
499        plot
500    }
501
502    #[test]
503    fn test_inline_plot() {
504        let plot = create_test_plot();
505        let inline_plot_data = plot.to_inline_html(Some("replace_this_with_the_div_id"));
506        assert!(inline_plot_data.contains("replace_this_with_the_div_id"));
507        plot.to_inline_html(None);
508    }
509
510    #[test]
511    fn test_jupyter_notebook_plot() {
512        let plot = create_test_plot();
513        plot.to_jupyter_notebook_html();
514    }
515
516    #[test]
517    fn test_notebook_display() {
518        let plot = create_test_plot();
519        plot.notebook_display();
520    }
521
522    #[test]
523    fn test_lab_display() {
524        let plot = create_test_plot();
525        plot.lab_display();
526    }
527
528    #[test]
529    fn test_plot_serialize_simple() {
530        let plot = create_test_plot();
531        let expected = json!({
532            "data": [
533                {
534                    "type": "scatter",
535                    "name": "trace1",
536                    "x": [0, 1, 2],
537                    "y": [6, 10, 2]
538                }
539            ],
540            "layout": {},
541            "config": {},
542        });
543
544        assert_eq!(to_value(plot).unwrap(), expected);
545    }
546
547    #[test]
548    fn test_plot_serialize_with_layout() {
549        let mut plot = create_test_plot();
550        let layout = Layout::new().title("Title".into());
551        plot.set_layout(layout);
552
553        let expected = json!({
554            "data": [
555                {
556                    "type": "scatter",
557                    "name": "trace1",
558                    "x": [0, 1, 2],
559                    "y": [6, 10, 2]
560                }
561            ],
562            "layout": {
563                "title": {
564                    "text": "Title"
565                }
566            },
567            "config": {},
568        });
569
570        assert_eq!(to_value(plot).unwrap(), expected);
571    }
572
573    #[test]
574    fn test_data_to_json() {
575        let plot = create_test_plot();
576        let expected = json!([
577            {
578                "type": "scatter",
579                "name": "trace1",
580                "x": [0, 1, 2],
581                "y": [6, 10, 2]
582            }
583        ]);
584
585        assert_eq!(to_value(plot.data()).unwrap(), expected);
586    }
587
588    #[test]
589    fn test_empty_layout_to_json() {
590        let plot = create_test_plot();
591        let expected = json!({});
592
593        assert_eq!(to_value(plot.layout()).unwrap(), expected);
594    }
595
596    #[test]
597    fn test_layout_to_json() {
598        let mut plot = create_test_plot();
599        let layout = Layout::new().title("TestTitle".into());
600        plot.set_layout(layout);
601
602        let expected = json!({
603            "title": {"text": "TestTitle"}
604        });
605
606        assert_eq!(to_value(plot.layout()).unwrap(), expected);
607    }
608
609    #[test]
610    fn test_plot_eq() {
611        let plot1 = create_test_plot();
612        let plot2 = create_test_plot();
613
614        assert!(plot1 == plot2);
615    }
616
617    #[test]
618    fn test_plot_neq() {
619        let plot1 = create_test_plot();
620        let trace2 = Scatter::new(vec![10, 1, 2], vec![6, 10, 2]).name("trace2");
621        let mut plot2 = Plot::new();
622        plot2.add_trace(trace2);
623
624        assert!(plot1 != plot2);
625    }
626
627    #[test]
628    fn test_plot_clone() {
629        let plot1 = create_test_plot();
630        let plot2 = plot1.clone();
631
632        assert!(plot1 == plot2);
633    }
634
635    #[test]
636    #[ignore] // Don't really want it to try and open a browser window every time we run a test.
637    #[cfg(not(feature = "wasm"))]
638    fn test_show_image() {
639        let plot = create_test_plot();
640        plot.show_image(ImageFormat::PNG, 1024, 680);
641    }
642
643    #[test]
644    fn test_save_html() {
645        let plot = create_test_plot();
646        let dst = PathBuf::from("example.html");
647        plot.write_html(&dst);
648        assert!(dst.exists());
649        assert!(std::fs::remove_file(&dst).is_ok());
650        assert!(!dst.exists());
651    }
652
653    #[test]
654    #[cfg(feature = "kaleido")]
655    fn test_save_to_png() {
656        let plot = create_test_plot();
657        let dst = PathBuf::from("example.png");
658        plot.write_image(&dst, ImageFormat::PNG, 1024, 680, 1.0);
659        assert!(dst.exists());
660        assert!(std::fs::remove_file(&dst).is_ok());
661        assert!(!dst.exists());
662    }
663
664    #[test]
665    #[cfg(feature = "kaleido")]
666    fn test_save_to_jpeg() {
667        let plot = create_test_plot();
668        let dst = PathBuf::from("example.jpeg");
669        plot.write_image(&dst, ImageFormat::JPEG, 1024, 680, 1.0);
670        assert!(dst.exists());
671        assert!(std::fs::remove_file(&dst).is_ok());
672        assert!(!dst.exists());
673    }
674
675    #[test]
676    #[cfg(feature = "kaleido")]
677    fn test_save_to_svg() {
678        let plot = create_test_plot();
679        let dst = PathBuf::from("example.svg");
680        plot.write_image(&dst, ImageFormat::SVG, 1024, 680, 1.0);
681        assert!(dst.exists());
682        assert!(std::fs::remove_file(&dst).is_ok());
683        assert!(!dst.exists());
684    }
685
686    #[test]
687    #[ignore] // This seems to fail unpredictably on MacOs.
688    #[cfg(feature = "kaleido")]
689    fn test_save_to_eps() {
690        let plot = create_test_plot();
691        let dst = PathBuf::from("example.eps");
692        plot.write_image(&dst, ImageFormat::EPS, 1024, 680, 1.0);
693        assert!(dst.exists());
694        assert!(std::fs::remove_file(&dst).is_ok());
695        assert!(!dst.exists());
696    }
697
698    #[test]
699    #[cfg(feature = "kaleido")]
700    fn test_save_to_pdf() {
701        let plot = create_test_plot();
702        let dst = PathBuf::from("example.pdf");
703        plot.write_image(&dst, ImageFormat::PDF, 1024, 680, 1.0);
704        assert!(dst.exists());
705        assert!(std::fs::remove_file(&dst).is_ok());
706        assert!(!dst.exists());
707    }
708
709    #[test]
710    #[cfg(feature = "kaleido")]
711    fn test_save_to_webp() {
712        let plot = create_test_plot();
713        let dst = PathBuf::from("example.webp");
714        plot.write_image(&dst, ImageFormat::WEBP, 1024, 680, 1.0);
715        assert!(dst.exists());
716        assert!(std::fs::remove_file(&dst).is_ok());
717        assert!(!dst.exists());
718    }
719}