Skip to main content

plotly/
plot.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::{SystemTime, UNIX_EPOCH};
3use std::{fs::File, io::Write, path::Path};
4
5use askama::Template;
6use dyn_clone::DynClone;
7use erased_serde::Serialize as ErasedSerialize;
8#[cfg(feature = "kaleido")]
9use plotly_kaleido::ImageFormat;
10#[cfg(feature = "plotly_static")]
11use plotly_static::ImageFormat;
12use rand::{
13    distr::{Alphanumeric, SampleString},
14    rngs::SmallRng,
15    SeedableRng,
16};
17use serde::Serialize;
18
19use crate::{layout::Frame, Configuration, Layout};
20
21static SEED_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Template)]
24#[template(path = "plot.html", escape = "none")]
25struct PlotTemplate<'a> {
26    plot: &'a Plot,
27    js_scripts: &'a str,
28}
29
30#[cfg(any(feature = "kaleido", feature = "plotly_static"))]
31#[derive(Template)]
32#[template(path = "static_plot.html", escape = "none")]
33#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
34struct StaticPlotTemplate<'a> {
35    plot: &'a Plot,
36    format: ImageFormat,
37    js_scripts: &'a str,
38    width: usize,
39    height: usize,
40}
41
42#[derive(Template)]
43#[template(path = "inline_plot.html", escape = "none")]
44struct InlinePlotTemplate<'a> {
45    plot: &'a Plot,
46    plot_div_id: &'a str,
47}
48
49#[derive(Template)]
50#[template(path = "jupyter_notebook_plot.html", escape = "none")]
51struct JupyterNotebookPlotTemplate<'a> {
52    plot: &'a Plot,
53    plot_div_id: &'a str,
54}
55
56#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
57const DEFAULT_HTML_APP_NOT_FOUND: &str = r#"Could not find default application for HTML files.
58Consider using the `to_html` method obtain a string representation instead. If using the `kaleido` or `plotly_static` feature the
59`write_image` method can be used to produce a static image in one of the following formats:
60- ImageFormat::PNG
61- ImageFormat::JPEG
62- ImageFormat::WEBP
63- ImageFormat::SVG
64- ImageFormat::PDF
65- ImageFormat::EPS // will be removed in version 0.15.0
66
67Used as follows:
68let plot = Plot::new();
69...
70let width = 1024;
71let height = 680;
72let scale = 1.0;
73plot.write_image("filename", ImageFormat::PNG, width, height, scale);
74
75See https://plotly.github.io/plotly.rs/content/getting_started.html for further details.
76"#;
77
78/// A struct that implements `Trace` can be serialized to json format that is
79/// understood by Plotly.js.
80pub trait Trace: DynClone + ErasedSerialize {
81    fn to_json(&self) -> String;
82}
83
84dyn_clone::clone_trait_object!(Trace);
85erased_serde::serialize_trait_object!(Trace);
86
87#[derive(Default, Serialize, Clone)]
88#[serde(transparent)]
89pub struct Traces {
90    traces: Vec<Box<dyn Trace>>,
91}
92
93impl Traces {
94    pub fn new() -> Self {
95        Self {
96            traces: Vec::with_capacity(1),
97        }
98    }
99
100    pub fn push(&mut self, trace: Box<dyn Trace>) {
101        self.traces.push(trace)
102    }
103
104    pub fn len(&self) -> usize {
105        self.traces.len()
106    }
107
108    pub fn is_empty(&self) -> bool {
109        self.traces.is_empty()
110    }
111
112    pub fn iter(&self) -> std::slice::Iter<'_, Box<dyn Trace>> {
113        self.traces.iter()
114    }
115
116    pub fn to_json(&self) -> String {
117        serde_json::to_string(self).unwrap()
118    }
119}
120
121/// Plot is a container for structs that implement the `Trace` trait. Optionally
122/// a `Layout` can also be specified. Its function is to serialize `Trace`s and
123/// the `Layout` in html format and display and/or persist the resulting plot.
124///
125/// # Examples
126///
127/// ```rust
128/// use plotly::common::Mode;
129/// use plotly::{Layout, Plot, Scatter};
130///
131/// fn line_and_scatter_plot() {
132///     let trace1 = Scatter::new(vec![1, 2, 3, 4], vec![10, 15, 13, 17])
133///         .name("trace1")
134///         .mode(Mode::Markers);
135///     let trace2 = Scatter::new(vec![2, 3, 4, 5], vec![16, 5, 11, 9])
136///         .name("trace2")
137///         .mode(Mode::Lines);
138///     let trace3 = Scatter::new(vec![1, 2, 3, 4], vec![12, 9, 15, 12])
139///         .name("trace3");
140///
141///     let mut plot = Plot::new();
142///     plot.add_trace(trace1);
143///     plot.add_trace(trace2);
144///     plot.add_trace(trace3);
145///
146///     let layout = Layout::new().title("<b>Line and Scatter Plot</b>");
147///     plot.set_layout(layout);
148///
149///     # if false {  // We don't actually want to try and display the plot in a browser when running a doctest.
150///     plot.show();
151///     # }
152/// }
153///
154/// fn main() -> std::io::Result<()> {
155///     line_and_scatter_plot();
156///     Ok(())
157/// }
158/// ```
159#[derive(Serialize, Clone)]
160pub struct Plot {
161    #[serde(rename = "data")]
162    traces: Traces,
163    layout: Layout,
164    #[serde(rename = "config")]
165    configuration: Configuration,
166    /// Animation frames
167    frames: Option<Vec<Frame>>,
168    #[serde(skip)]
169    js_scripts: String,
170}
171
172impl Default for Plot {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl Plot {
179    /// Create a new `Plot`.
180    pub fn new() -> Plot {
181        Plot {
182            traces: Traces::new(),
183            layout: Layout::default(),
184            configuration: Configuration::default(),
185            frames: None,
186            js_scripts: Self::js_scripts(),
187        }
188    }
189
190    /// Switch to CDN for `plotly.js` and `MathJax` components in the standalone
191    /// HTML plots rather than using the default local copies of the
192    /// Javascript libraries. Method is only available when the feature
193    /// `plotly_embed_js` is enabled since without this feature the default
194    /// versions used are always the CDN versions.
195    #[cfg(feature = "plotly_embed_js")]
196    pub fn use_cdn_js(&mut self) {
197        self.js_scripts = Self::online_cdn_js();
198    }
199
200    /// Add a `Trace` to the `Plot`.
201    pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
202        self.traces.push(trace);
203    }
204
205    /// Add multiple `Trace`s to the `Plot`.
206    pub fn add_traces(&mut self, traces: Vec<Box<dyn Trace>>) {
207        for trace in traces {
208            self.add_trace(trace);
209        }
210    }
211
212    /// Set the `Layout` to be used by `Plot`.
213    pub fn set_layout(&mut self, layout: Layout) {
214        self.layout = layout;
215    }
216
217    /// Set the `Configuration` to be used by `Plot`.
218    pub fn set_configuration(&mut self, configuration: Configuration) {
219        self.configuration = configuration;
220    }
221
222    /// Get the contained data elements.
223    pub fn data(&self) -> &Traces {
224        &self.traces
225    }
226
227    /// Get the layout specification of the plot.
228    pub fn layout(&self) -> &Layout {
229        &self.layout
230    }
231
232    /// Get the configuration specification of the plot.
233    pub fn configuration(&self) -> &Configuration {
234        &self.configuration
235    }
236
237    /// Add a single frame to the animation sequence.
238    pub fn add_frame(&mut self, frame: Frame) -> &mut Self {
239        if self.frames.is_none() {
240            self.frames = Some(Vec::new());
241        }
242        self.frames.as_mut().unwrap().push(frame);
243        self
244    }
245
246    /// Add multiple frames to the animation sequence.
247    pub fn add_frames(&mut self, frames: &[Frame]) -> &mut Self {
248        if self.frames.is_none() {
249            self.frames = Some(frames.to_vec());
250        }
251        self.frames.as_mut().unwrap().extend(frames.iter().cloned());
252        self
253    }
254
255    pub fn clear_frames(&mut self) -> &mut Self {
256        self.frames = None;
257        self
258    }
259
260    pub fn frame_count(&self) -> usize {
261        self.frames.as_ref().map(|f| f.len()).unwrap_or(0)
262    }
263
264    /// Get the animation frames as mutable reference
265    pub fn frames_mut(&mut self) -> Option<&mut Vec<Frame>> {
266        self.frames.as_mut()
267    }
268
269    /// Get the animation frames.
270    pub fn frames(&self) -> Option<&[Frame]> {
271        self.frames.as_deref()
272    }
273
274    /// Display the fully rendered HTML `Plot` in the default system browser.
275    ///
276    /// The HTML file is saved in a temp file, from which it is read and
277    /// displayed by the browser.
278    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
279    pub fn show(&self) {
280        use std::env;
281        let rendered = self.render();
282
283        // Set up the temp file with a unique filename.
284        let mut temp = env::temp_dir();
285        let mut plot_name =
286            Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 22);
287        plot_name.push_str(".html");
288        plot_name = format!("plotly_{plot_name}");
289        temp.push(plot_name);
290
291        // Save the rendered plot to the temp file.
292        let temp_path = temp.to_str().unwrap();
293
294        {
295            let mut file = File::create(temp_path).unwrap();
296            file.write_all(rendered.as_bytes())
297                .expect("failed to write html output");
298            file.flush().unwrap();
299        }
300
301        // Hand off the job of opening the browser to an OS-specific implementation.
302        Plot::show_with_default_app(temp_path);
303    }
304
305    /// Display the fully rendered HTML `Plot` in the default system browser.
306    ///
307    /// The HTML file is generated and saved in the provided filename as long as
308    /// the path already exists, after the file is saved, it is read and
309    /// displayed by the browser.
310    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
311    pub fn show_html<P: AsRef<Path> + std::clone::Clone>(&self, filename: P) {
312        let path = filename.as_ref().to_str().unwrap();
313        self.write_html(filename.clone());
314        // Hand off the job of opening the browser to an OS-specific implementation.
315        Plot::show_with_default_app(path);
316    }
317
318    /// Display the fully rendered `Plot` as a static image of the given format
319    /// in the default system browser.
320    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
321    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
322    pub fn show_image(&self, format: ImageFormat, width: usize, height: usize) {
323        use std::env;
324
325        let rendered = self.render_static(&format, width, height);
326
327        // Set up the temp file with a unique filename.
328        let mut temp = env::temp_dir();
329        let mut plot_name =
330            Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 22);
331        plot_name.push_str(".html");
332        plot_name = format!("plotly_{plot_name}");
333        temp.push(plot_name);
334
335        // Save the rendered plot to the temp file.
336        let temp_path = temp.to_str().unwrap();
337
338        {
339            let mut file = File::create(temp_path).unwrap();
340            file.write_all(rendered.as_bytes())
341                .expect("failed to write html output");
342            file.flush().unwrap();
343        }
344
345        // Hand off the job of opening the browser to an OS-specific implementation.
346        Plot::show_with_default_app(temp_path);
347    }
348
349    /// Save the rendered `Plot` to a file at the given location.
350    ///
351    /// This method will render the plot to a full, standalone HTML document,
352    /// before saving it to the given location.
353    pub fn write_html<P: AsRef<Path>>(&self, filename: P) {
354        let rendered = self.to_html();
355
356        let mut file =
357            File::create(filename).expect("Provided filepath does not exist or is not accessible");
358        file.write_all(rendered.as_bytes())
359            .expect("failed to write html output");
360        file.flush().unwrap();
361    }
362
363    /// Convert a `Plot` to an HTML string representation.
364    ///
365    /// This method will generate a full, standalone HTML document. To generate
366    /// a minimal HTML string which can be embedded within an existing HTML
367    /// page, use `Plot::to_inline_html()`.
368    pub fn to_html(&self) -> String {
369        self.render()
370    }
371
372    /// Renders the contents of the `Plot` and returns it as a String suitable
373    /// for embedding within web pages or Jupyter notebooks.
374    ///
375    /// A `div` is generated with the supplied id followed by the `script` block
376    /// that generates the plot. The assumption is that `plotly.js` is
377    /// available within the HTML page that this element is embedded. If
378    /// that assumption is violated then the plot will not be displayed.
379    ///
380    /// If `plot_div_id` is `None` the plot div id will be randomly generated,
381    /// otherwise the user-supplied `plot_div_id` is used.
382    ///
383    /// To generate a full, standalone HTML string or file, use
384    /// `Plot::to_html()` and `Plot::write_html()`, respectively.
385    pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
386        let plot_div_id = match plot_div_id {
387            Some(id) => id.to_string(),
388            None => {
389                Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 20)
390            }
391        };
392        self.render_inline(&plot_div_id)
393    }
394
395    fn to_jupyter_notebook_html(&self) -> String {
396        let plot_div_id =
397            Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 20);
398
399        let tmpl = JupyterNotebookPlotTemplate {
400            plot: self,
401            plot_div_id: &plot_div_id,
402        };
403        tmpl.render().unwrap()
404    }
405
406    /// Display plot in Jupyter Notebook.
407    pub fn notebook_display(&self) {
408        let plot_data = self.to_jupyter_notebook_html();
409        println!("EVCXR_BEGIN_CONTENT text/html\n{plot_data}\nEVCXR_END_CONTENT");
410    }
411
412    /// Display plot in Jupyter Lab.
413    pub fn lab_display(&self) {
414        let plot_data = self.to_json();
415        println!(
416            "EVCXR_BEGIN_CONTENT application/vnd.plotly.v1+json\n{plot_data}\nEVCXR_END_CONTENT"
417        );
418    }
419
420    /// Displays the plot in Jupyter Lab; if running a Jupyter Notebook then use
421    /// the `notebook_display()` method instead.
422    pub fn evcxr_display(&self) {
423        self.lab_display();
424    }
425
426    /// Convert the `Plot` to a static image of the given image format and save
427    /// at the given location using kaleido.
428    ///
429    /// This function is deprecated since version 0.13.0. The kaleido-based
430    /// implementation will be removed in version 0.15.0. Use
431    /// `plotly_static` feature instead for static image export functionality.
432    #[deprecated(
433        since = "0.13.0",
434        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.15.0"
435    )]
436    #[cfg(feature = "kaleido")]
437    pub fn write_image<P: AsRef<Path>>(
438        &self,
439        filename: P,
440        format: ImageFormat,
441        width: usize,
442        height: usize,
443        scale: f64,
444    ) {
445        let kaleido = plotly_kaleido::Kaleido::new();
446        kaleido
447            .save(
448                filename.as_ref(),
449                &serde_json::to_value(self).unwrap(),
450                format,
451                width,
452                height,
453                scale,
454            )
455            .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
456    }
457
458    /// Convert the `Plot` to a static image and return the image as a `base64`
459    /// String using kaleido. Supported formats are [ImageFormat::JPEG],
460    /// [ImageFormat::PNG] and [ImageFormat::WEBP]
461    ///
462    /// This function is deprecated since version 0.13.0. The kaleido-based
463    /// implementation will be removed in version 0.15.0. Use
464    /// `plotly_static` feature instead for static image export functionality.
465    #[deprecated(
466        since = "0.13.0",
467        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.15.0"
468    )]
469    #[cfg(feature = "kaleido")]
470    pub fn to_base64(
471        &self,
472        format: ImageFormat,
473        width: usize,
474        height: usize,
475        scale: f64,
476    ) -> String {
477        match format {
478            ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
479                let kaleido = plotly_kaleido::Kaleido::new();
480                kaleido
481                    .image_to_string(
482                        &serde_json::to_value(self).unwrap(),
483                        format,
484                        width,
485                        height,
486                        scale,
487                    )
488                    .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
489            }
490            _ => {
491                eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP");
492                String::default()
493            }
494        }
495    }
496
497    /// Convert the `Plot` to SVG and return it as a String using kaleido.
498    ///
499    /// This function is deprecated since version 0.13.0. The kaleido-based
500    /// implementation will be removed in version 0.15.0. Use
501    /// `plotly_static` feature instead for static image export functionality.
502    #[deprecated(
503        since = "0.13.0",
504        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.15.0"
505    )]
506    #[cfg(feature = "kaleido")]
507    pub fn to_svg(&self, width: usize, height: usize, scale: f64) -> String {
508        let kaleido = plotly_kaleido::Kaleido::new();
509        kaleido
510            .image_to_string(
511                &serde_json::to_value(self).unwrap(),
512                ImageFormat::SVG,
513                width,
514                height,
515                scale,
516            )
517            .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
518    }
519
520    /// Convert the `Plot` to a static image of the given image format and save
521    /// at the given location.
522    ///
523    /// This method requires the usage of the `plotly_static` crate using one of
524    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
525    ///
526    /// **Note:** This method creates a new `StaticExporter` (and thus a new
527    /// WebDriver instance) for each call, which is not performant for
528    /// repeated operations. For better performance and resource management,
529    /// consider using the [`ExporterSyncExt`] or [`ExporterAsyncExt`] extension
530    /// methods to reuse a single `StaticExporter` instance across multiple
531    /// operations.
532    #[cfg(feature = "plotly_static")]
533    pub fn write_image<P: AsRef<Path>>(
534        &self,
535        filename: P,
536        format: ImageFormat,
537        width: usize,
538        height: usize,
539        scale: f64,
540    ) -> Result<(), Box<dyn std::error::Error>> {
541        use crate::prelude::*;
542        let mut exporter = plotly_static::StaticExporterBuilder::default()
543            .build()
544            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
545        let result = exporter.write_image(self, filename, format, width, height, scale);
546        exporter.close();
547        result
548    }
549
550    /// Convert the `Plot` to a static image and return the image as a `base64`
551    /// String. Supported formats are [ImageFormat::JPEG],
552    /// [ImageFormat::PNG] and [ImageFormat::WEBP].
553    ///
554    /// This method uses the [plotly_static](https://docs.rs/plotly_static/) crate and requires a WebDriver-compatible browser (Chrome or Firefox) to be available on the system.
555    ///
556    /// For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
557    ///
558    ///
559    /// **Note:** This method creates a new `StaticExporter` (and thus a new
560    /// WebDriver instance) for each call, which is not performant for repeated
561    /// operations. For better performance and resource management, consider
562    /// using the [`ExporterSyncExt`] or [`ExporterAsyncExt`] extension methods
563    /// to reuse a single `StaticExporter` instance across multiple
564    /// operations.
565    #[cfg(feature = "plotly_static")]
566    pub fn to_base64(
567        &self,
568        format: ImageFormat,
569        width: usize,
570        height: usize,
571        scale: f64,
572    ) -> Result<String, Box<dyn std::error::Error>> {
573        use crate::prelude::*;
574        let mut exporter = plotly_static::StaticExporterBuilder::default()
575            .build()
576            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
577        let result = exporter.to_base64(self, format, width, height, scale);
578        exporter.close();
579        result
580    }
581
582    /// Convert the `Plot` to SVG and return it as a String using plotly_static.
583    ///
584    /// This method requires the usage of the `plotly_static` crate using one of
585    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
586    ///
587    /// **Note:** This method creates a new `StaticExporter` (and thus a new
588    /// WebDriver instance) for each call, which is not performant for
589    /// repeated operations. For better performance and resource management,
590    /// consider using the [`ExporterSyncExt`] or [`ExporterAsyncExt`] extension
591    /// methods to reuse a single `StaticExporter` instance across multiple
592    /// operations.
593    #[cfg(feature = "plotly_static")]
594    pub fn to_svg(
595        &self,
596        width: usize,
597        height: usize,
598        scale: f64,
599    ) -> Result<String, Box<dyn std::error::Error>> {
600        use crate::prelude::*;
601        let mut exporter = plotly_static::StaticExporterBuilder::default()
602            .build()
603            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
604        let result = exporter.to_svg(self, width, height, scale);
605        exporter.close();
606        result
607    }
608
609    /// Deprecated: use [crate::export::sync::ExporterSyncExt::write_image].
610    #[deprecated(
611        note = "Use exporter.write_image(&plot, ...) from plotly::export::sync::ExporterSyncExt"
612    )]
613    #[cfg(feature = "plotly_static")]
614    pub fn write_image_with_exporter<P: AsRef<Path>>(
615        &self,
616        exporter: &mut plotly_static::StaticExporter,
617        filename: P,
618        format: ImageFormat,
619        width: usize,
620        height: usize,
621        scale: f64,
622    ) -> Result<(), Box<dyn std::error::Error>> {
623        exporter.write_fig(
624            filename.as_ref(),
625            &serde_json::to_value(self)?,
626            format,
627            width,
628            height,
629            scale,
630        )
631    }
632
633    /// Deprecated: use [crate::export::sync::ExporterSyncExt::to_base64].
634    #[deprecated(
635        note = "Use exporter.to_base64(&plot, ...) from plotly::export::sync::ExporterSyncExt"
636    )]
637    #[cfg(feature = "plotly_static")]
638    pub fn to_base64_with_exporter(
639        &self,
640        exporter: &mut plotly_static::StaticExporter,
641        format: ImageFormat,
642        width: usize,
643        height: usize,
644        scale: f64,
645    ) -> Result<String, Box<dyn std::error::Error>> {
646        match format {
647            ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
648                exporter.write_to_string(
649                    &serde_json::to_value(self)?,
650                    format,
651                    width,
652                    height,
653                    scale,
654                )
655            }
656            _ => {
657                Err(format!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP").into())
658            }
659        }
660    }
661
662    /// Deprecated: use [crate::export::sync::ExporterSyncExt::to_svg].
663    #[deprecated(
664        note = "Use exporter.to_svg(&plot, ...) from plotly::export::sync::ExporterSyncExt"
665    )]
666    #[cfg(feature = "plotly_static")]
667    pub fn to_svg_with_exporter(
668        &self,
669        exporter: &mut plotly_static::StaticExporter,
670        width: usize,
671        height: usize,
672        scale: f64,
673    ) -> Result<String, Box<dyn std::error::Error>> {
674        exporter.write_to_string(
675            &serde_json::to_value(self)?,
676            ImageFormat::SVG,
677            width,
678            height,
679            scale,
680        )
681    }
682
683    fn render(&self) -> String {
684        let tmpl = PlotTemplate {
685            plot: self,
686            js_scripts: &self.js_scripts,
687        };
688        tmpl.render().unwrap()
689    }
690
691    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
692    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
693    pub fn render_static(&self, format: &ImageFormat, width: usize, height: usize) -> String {
694        let tmpl = StaticPlotTemplate {
695            plot: self,
696            format: format.clone(),
697            js_scripts: &self.js_scripts,
698            width,
699            height,
700        };
701        tmpl.render().unwrap()
702    }
703
704    fn render_inline(&self, plot_div_id: &str) -> String {
705        let tmpl = InlinePlotTemplate {
706            plot: self,
707            plot_div_id,
708        };
709        tmpl.render().unwrap()
710    }
711
712    fn js_scripts() -> String {
713        if cfg!(feature = "plotly_embed_js") {
714            Self::offline_js_sources()
715        } else {
716            Self::online_cdn_js()
717        }
718    }
719
720    /// Returns HTML script tags containing embedded JavaScript sources for
721    /// offline use.
722    ///
723    /// This function embeds the Plotly.js library and MathJax (tex-svg)
724    /// JavaScript directly into the HTML output, allowing plots to work
725    /// without an internet connection. The embedded sources include:
726    /// - Plotly.js library for interactive plotting
727    /// - MathJax tex-svg for rendering mathematical expressions
728    ///
729    /// This is used when the `plotly_embed_js` feature is enabled, providing
730    /// self-contained HTML files that don't require external CDN resources.
731    pub fn offline_js_sources() -> String {
732        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
733        // Latex Titles we no longer include it.
734        let local_tex_svg_js = include_str!("../resource/tex-svg-3.2.2.js");
735        let local_plotly_js = include_str!("../resource/plotly.min.js");
736
737        format!(
738            "<script type=\"text/javascript\">{local_plotly_js}</script>\n
739            <script type=\"text/javascript\">{local_tex_svg_js}</script>\n",
740        )
741        .to_string()
742    }
743
744    /// Returns HTML script tags that reference external CDN resources for
745    /// online use.
746    ///
747    /// This function provides HTML script tags that load JavaScript libraries
748    /// from external CDN sources, requiring an internet connection to
749    /// function. The referenced sources include:
750    /// - Plotly.js library from CDN (version 3.0.1)
751    /// - MathJax tex-svg from jsDelivr CDN (version 3.2.2)
752    ///
753    /// This is the default behavior when the `plotly_embed_js` feature is
754    /// disabled, providing smaller HTML files that rely on external
755    /// resources.
756    pub fn online_cdn_js() -> String {
757        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
758        // Latex Titles we no longer include it.
759        r##"<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js"></script>
760        <script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
761        "##
762        .to_string()
763    }
764
765    pub fn to_json(&self) -> String {
766        serde_json::to_string(self).unwrap()
767    }
768
769    #[cfg(target_family = "wasm")]
770    /// Convert a `Plot` to a native JavaScript `js_sys::Object`.
771    pub fn to_js_object(&self) -> wasm_bindgen_futures::js_sys::Object {
772        use wasm_bindgen_futures::js_sys;
773        use wasm_bindgen_futures::wasm_bindgen::JsCast;
774        // The only reason this could fail is if to_json() produces structurally
775        // incorrect JSON. That would be a bug, and would require fixing in the
776        // to_json()/serialization methods, rather than here
777        js_sys::JSON::parse(&self.to_json())
778            .expect("Invalid JSON")
779            .dyn_into::<js_sys::Object>()
780            .expect("Invalid JSON structure - expected a top-level Object")
781    }
782
783    #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
784    fn show_with_default_app(temp_path: &str) {
785        use std::process::Command;
786        Command::new("xdg-open")
787            .args([temp_path])
788            .output()
789            .expect(DEFAULT_HTML_APP_NOT_FOUND);
790    }
791
792    #[cfg(target_os = "macos")]
793    fn show_with_default_app(temp_path: &str) {
794        use std::process::Command;
795        Command::new("open")
796            .args([temp_path])
797            .output()
798            .expect(DEFAULT_HTML_APP_NOT_FOUND);
799    }
800
801    #[cfg(target_os = "windows")]
802    fn show_with_default_app(temp_path: &str) {
803        use std::process::Command;
804        Command::new("explorer")
805            .arg(temp_path)
806            .spawn()
807            .expect(DEFAULT_HTML_APP_NOT_FOUND);
808    }
809
810    /// Generate unique seeds for SmallRng such that file names and div names
811    /// are unique random for each call
812    pub(crate) fn generate_seed() -> u64 {
813        let time = SystemTime::now()
814            .duration_since(UNIX_EPOCH)
815            .unwrap_or_default()
816            .as_nanos() as u64;
817        let counter = SEED_COUNTER.fetch_add(1, Ordering::Relaxed);
818        time ^ counter
819    }
820}
821
822impl PartialEq for Plot {
823    fn eq(&self, other: &Self) -> bool {
824        self.to_json() == other.to_json()
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use std::path::PathBuf;
831
832    #[cfg(feature = "kaleido")]
833    use plotly_kaleido::ImageFormat;
834    #[cfg(feature = "plotly_static")]
835    use plotly_static::ImageFormat;
836    use serde_json::{json, to_value};
837    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
838    use {base64::engine::general_purpose, base64::Engine};
839
840    use super::*;
841    #[cfg(feature = "plotly_static")]
842    use crate::export::sync::ExporterSyncExt;
843    use crate::Scatter;
844
845    fn create_test_plot() -> Plot {
846        let trace1 = Scatter::new(vec![0, 1, 2], vec![6, 10, 2]).name("trace1");
847        let mut plot = Plot::new();
848        plot.add_trace(trace1);
849        plot
850    }
851
852    #[test]
853    fn inline_plot() {
854        let plot = create_test_plot();
855        let inline_plot_data = plot.to_inline_html(Some("replace_this_with_the_div_id"));
856        assert!(inline_plot_data.contains("replace_this_with_the_div_id"));
857        plot.to_inline_html(None);
858    }
859
860    #[test]
861    fn jupyter_notebook_plot() {
862        let plot = create_test_plot();
863        plot.to_jupyter_notebook_html();
864    }
865
866    #[test]
867    fn notebook_display() {
868        let plot = create_test_plot();
869        plot.notebook_display();
870    }
871
872    #[test]
873    fn lab_display() {
874        let plot = create_test_plot();
875        plot.lab_display();
876    }
877
878    #[test]
879    fn plot_serialize_simple() {
880        let plot = create_test_plot();
881        let expected = json!({
882            "data": [
883                {
884                    "type": "scatter",
885                    "name": "trace1",
886                    "x": [0, 1, 2],
887                    "y": [6, 10, 2]
888                }
889            ],
890            "layout": {},
891            "config": {},
892            "frames": null,
893        });
894
895        assert_eq!(to_value(plot).unwrap(), expected);
896    }
897
898    #[test]
899    fn plot_serialize_with_layout() {
900        let mut plot = create_test_plot();
901        let layout = Layout::new().title("Title");
902        plot.set_layout(layout);
903
904        let expected = json!({
905            "data": [
906                {
907                    "type": "scatter",
908                    "name": "trace1",
909                    "x": [0, 1, 2],
910                    "y": [6, 10, 2]
911                }
912            ],
913            "layout": {
914                "title": {
915                    "text": "Title"
916                }
917            },
918            "config": {},
919            "frames": null,
920        });
921
922        assert_eq!(to_value(plot).unwrap(), expected);
923    }
924
925    #[test]
926    fn data_to_json() {
927        let plot = create_test_plot();
928        let expected = json!([
929            {
930                "type": "scatter",
931                "name": "trace1",
932                "x": [0, 1, 2],
933                "y": [6, 10, 2]
934            }
935        ]);
936
937        assert_eq!(to_value(plot.data()).unwrap(), expected);
938    }
939
940    #[test]
941    fn empty_layout_to_json() {
942        let plot = create_test_plot();
943        let expected = json!({});
944
945        assert_eq!(to_value(plot.layout()).unwrap(), expected);
946    }
947
948    #[test]
949    fn layout_to_json() {
950        let mut plot = create_test_plot();
951        let layout = Layout::new().title("TestTitle");
952        plot.set_layout(layout);
953
954        let expected = json!({
955            "title": {"text": "TestTitle"}
956        });
957
958        assert_eq!(to_value(plot.layout()).unwrap(), expected);
959    }
960
961    #[test]
962    fn plot_eq() {
963        let plot1 = create_test_plot();
964        let plot2 = create_test_plot();
965
966        assert!(plot1 == plot2);
967    }
968
969    #[test]
970    fn plot_neq() {
971        let plot1 = create_test_plot();
972        let trace2 = Scatter::new(vec![10, 1, 2], vec![6, 10, 2]).name("trace2");
973        let mut plot2 = Plot::new();
974        plot2.add_trace(trace2);
975
976        assert!(plot1 != plot2);
977    }
978
979    #[test]
980    fn plot_clone() {
981        let plot1 = create_test_plot();
982        let plot2 = plot1.clone();
983
984        assert!(plot1 == plot2);
985    }
986
987    #[test]
988    fn save_html() {
989        let plot = create_test_plot();
990        let dst = PathBuf::from("plotly_example.html");
991        plot.write_html(&dst);
992        assert!(dst.exists());
993        #[cfg(not(feature = "debug"))]
994        assert!(std::fs::remove_file(&dst).is_ok());
995    }
996
997    // Helper to generate unique ports for parallel tests
998    #[cfg(feature = "plotly_static")]
999    fn get_unique_port() -> u32 {
1000        use std::sync::atomic::{AtomicU32, Ordering};
1001        static PORT_COUNTER: AtomicU32 = AtomicU32::new(5144);
1002        PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
1003    }
1004
1005    #[test]
1006    #[cfg(feature = "plotly_static")]
1007    fn save_to_png() {
1008        let plot = create_test_plot();
1009        let dst = PathBuf::from("plotly_example.png");
1010        let mut exporter = plotly_static::StaticExporterBuilder::default()
1011            .webdriver_port(get_unique_port())
1012            .build()
1013            .unwrap();
1014        exporter
1015            .write_image(&plot, &dst, ImageFormat::PNG, 1024, 680, 1.0)
1016            .unwrap();
1017        assert!(dst.exists());
1018        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1019        let file_size = metadata.len();
1020        assert!(file_size > 0,);
1021        #[cfg(not(feature = "debug"))]
1022        assert!(std::fs::remove_file(&dst).is_ok());
1023        exporter.close();
1024    }
1025
1026    #[test]
1027    #[cfg(feature = "plotly_static")]
1028    fn save_to_jpeg() {
1029        let plot = create_test_plot();
1030        let dst = PathBuf::from("plotly_example.jpeg");
1031        let mut exporter = plotly_static::StaticExporterBuilder::default()
1032            .webdriver_port(get_unique_port())
1033            .build()
1034            .unwrap();
1035        exporter
1036            .write_image(&plot, &dst, ImageFormat::JPEG, 1024, 680, 1.0)
1037            .unwrap();
1038        assert!(dst.exists());
1039        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1040        let file_size = metadata.len();
1041        assert!(file_size > 0,);
1042        #[cfg(not(feature = "debug"))]
1043        assert!(std::fs::remove_file(&dst).is_ok());
1044        exporter.close();
1045    }
1046
1047    #[test]
1048    #[cfg(feature = "plotly_static")]
1049    fn save_to_svg() {
1050        let plot = create_test_plot();
1051        let dst = PathBuf::from("plotly_example.svg");
1052        let mut exporter = plotly_static::StaticExporterBuilder::default()
1053            .webdriver_port(get_unique_port())
1054            .build()
1055            .unwrap();
1056        exporter
1057            .write_image(&plot, &dst, ImageFormat::SVG, 1024, 680, 1.0)
1058            .unwrap();
1059        assert!(dst.exists());
1060        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1061        let file_size = metadata.len();
1062        assert!(file_size > 0,);
1063        #[cfg(not(feature = "debug"))]
1064        assert!(std::fs::remove_file(&dst).is_ok());
1065        exporter.close();
1066    }
1067
1068    #[test]
1069    #[cfg(feature = "plotly_static")]
1070    fn save_to_pdf() {
1071        let plot = create_test_plot();
1072        let dst = PathBuf::from("plotly_example.pdf");
1073        #[cfg(feature = "debug")]
1074        let mut exporter = plotly_static::StaticExporterBuilder::default()
1075            .spawn_webdriver(true)
1076            .webdriver_port(get_unique_port())
1077            .pdf_export_timeout(750)
1078            .build()
1079            .unwrap();
1080        #[cfg(not(feature = "debug"))]
1081        let mut exporter = plotly_static::StaticExporterBuilder::default()
1082            .webdriver_port(get_unique_port())
1083            .build()
1084            .unwrap();
1085        exporter
1086            .write_image(&plot, &dst, ImageFormat::PDF, 1024, 680, 1.0)
1087            .unwrap();
1088        assert!(dst.exists());
1089        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1090        let file_size = metadata.len();
1091        assert!(file_size > 0,);
1092        #[cfg(not(feature = "debug"))]
1093        assert!(std::fs::remove_file(&dst).is_ok());
1094        exporter.close();
1095    }
1096
1097    #[test]
1098    #[cfg(feature = "plotly_static")]
1099    fn save_to_webp() {
1100        let plot = create_test_plot();
1101        let dst = PathBuf::from("plotly_example.webp");
1102        let mut exporter = plotly_static::StaticExporterBuilder::default()
1103            .webdriver_port(get_unique_port())
1104            .build()
1105            .unwrap();
1106        exporter
1107            .write_image(&plot, &dst, ImageFormat::WEBP, 1024, 680, 1.0)
1108            .unwrap();
1109        assert!(dst.exists());
1110        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1111        let file_size = metadata.len();
1112        assert!(file_size > 0,);
1113        #[cfg(not(feature = "debug"))]
1114        assert!(std::fs::remove_file(&dst).is_ok());
1115        exporter.close();
1116    }
1117
1118    #[test]
1119    #[cfg(feature = "plotly_static")]
1120    fn image_to_base64() {
1121        let plot = create_test_plot();
1122        let mut exporter = plotly_static::StaticExporterBuilder::default()
1123            .webdriver_port(get_unique_port())
1124            .build()
1125            .unwrap();
1126
1127        let image_base64 = exporter
1128            .to_base64(&plot, ImageFormat::PNG, 200, 150, 1.0)
1129            .unwrap();
1130
1131        assert!(!image_base64.is_empty());
1132
1133        let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap();
1134        let expected = "iVBORw0KGgoAAAANSUhEUgAAAMgAAACWCAYAAACb3McZAAAH0klEQVR4Xu2bSWhVZxiGv2gC7SKJWrRWxaGoULsW7L7gXlAMKApiN7pxI46ggnNQcDbOoAZUcCG4CCiIQ4MSkWKFLNSCihTR2ESTCNVb/lMTEmvu8OYuTN/nQBHb895zv+f9H+6ZWpHL5XLBBgEIfJZABYKwMiAwMAEEYXVAIA8BBGF5QABBWAMQ0AjwC6JxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCKJxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCPKR26NHj+LUqVNx69atuHDhQtTW1vYSvX37dhw4cCC6u7tj4sSJsXr16hg5cqRGnNSQIoAgH+vavHlzzJ49O9auXRvnzp3rFeTNmzdRV1cXHz58yP7J5XIxbdq02Lt375Aqmi+rEUCQT7glSfoKcunSpdizZ0+MGDEik+PVq1cxfPjwuHz5clRVVWnUSQ0ZAghSQJA1a9ZEOsVqaGiIHTt2xLNnz6Krqys7HRs/fvyQKZovqhFAkAKCpFOuO3fuxOjRo+Pdu3fR3t6e/ZIcPHgwpk6dqlEnNWQIIEgBQTZu3Bg3b96MioqKmDBhQjx58iQT5OTJk/1+QX599DLqGpr/U3wuF1FRUb71MOv7b6Lmq8qYMa42Hjz/K5p+/7Pfh6f/9tuG2eU7oPknIUgBQbZu3RpXrlyJ7du3Z9ceK1euzAQ5c+ZMjBkzpjc9kCDVaTF/V5PtlxZ3z1bzdVXMGPfvv69vao2WP9r6fZMfx9XEzz98G0/buuJpW2c8eN4eHd1/99tnIPkaf5kVP/U5lvkaH9T4CFJAkBUrVsT9+/dj6dKlkS7YOzo6It3ZOnr0aEyePHlQ8Al/+QQQJCJb9EmAtL18+TJGjRqVnVIdOnQo6uvro7m5Ofv7sGHDslu9aduyZUvMnDnzy2+YbzgoAghSAN/bt29j/vz58f79++zUKv2ZZJo7d+6gwBMeGgQQpEBPTU1NsWvXruw5SNra2tqiuro6Tpw4kf3J9v8mgCBl7Hcwr6Tke9Ul31e8evVqnD59OrsFnW4apGum9DoMW3kIIEh5OGYX7osWLYp012v69OnZon38+HGsX7++qCMM9KpLvnB6aLl8+fLYt29fdsu5sbEx7t69Gzt37izqmOxUmACCFGZU1B7Xrl2LdDqWFnraOjs7Y968eXHx4sWSXkn59FWXfAdP10cvXrzovZv28OHDWLduXSYKW3kIIEh5OGbPRV6/fh3Lli3r/cQkyO7du0t6JaUUQT796ufPn4/W1tZMErbyEECQ8nCM48eP997h6vnIBQsWxIYNG0p6JUUV5N69e9mpVRKy7wPMMo1n+zEIUqbqz549m93h6vsLMmfOnOy1+FJealQEuXHjRhw+fDg2bdoUU6ZMKdNEfEwigCBlWgfXr1/PXoFPF+lpS6dbCxcuzK5BKisriz5KqYKkFyn3798f27Zti7FjxxZ9HHYsjgCCFMep4F7pgnnx4sXZRXq6i3Xs2LHsqXx6d6uUrRRB0jGXLFmSvSc2adKkUg7DvkUSQJAiQRWzW0tLS3ZKle5gpf/rcNWqVUU9TMz3qkvPA8rPHf/Th5g9+xw5cqSo4xYzk/s+COK+Apg/LwEEYYFAIA8BBGF5QABBWAMQ0AjwC6JxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCKJxI2VC4B+Ci/5sJeSfvgAAAABJRU5ErkJggg==";
1135        let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
1136
1137        // Comparing the result seems to end up being a flaky test.
1138        // Limit the comparison to the first characters;
1139        // As image contents seem to be slightly inconsistent across platforms
1140        assert_eq!(expected_decoded[..2], result_decoded[..2]);
1141        exporter.close();
1142    }
1143
1144    #[test]
1145    #[cfg(feature = "plotly_static")]
1146    fn image_to_svg_string() {
1147        let plot = create_test_plot();
1148        let mut exporter = plotly_static::StaticExporterBuilder::default()
1149            .webdriver_port(get_unique_port())
1150            .build()
1151            .unwrap();
1152        let image_svg = exporter.to_svg(&plot, 200, 150, 1.0).unwrap();
1153
1154        assert!(!image_svg.is_empty());
1155
1156        let expected = "<svg class=\"main-svg\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"200\" height=\"150\" style=\"\" viewBox=\"0 0 200 150\"><rect x=\"0\" y=\"0\" width=\"200\" height=\"150\" style=\"fill: rgb(255, 255, 255); fill-opacity: 1;\"/><defs id=\"defs-2dc70a\"><g class=\"clips\"><clipPath id=\"clip2dc70axyplot\" class=\"plotclip\"><rect width=\"40\" height=\"2\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70ax\"><rect x=\"80\" y=\"0\" width=\"40\" height=\"150\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70ay\"><rect x=\"0\" y=\"82\" width=\"200\" height=\"2\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70axy\"><rect x=\"80\" y=\"82\" width=\"40\" height=\"2\"/></clipPath></g><g class=\"gradients\"/></defs><g class=\"bglayer\"/><g class=\"layer-below\"><g class=\"imagelayer\"/><g class=\"shapelayer\"/></g><g class=\"cartesianlayer\"><g class=\"subplot xy\"><g class=\"layer-subplot\"><g class=\"shapelayer\"/><g class=\"imagelayer\"/></g><g class=\"gridlayer\"><g class=\"x\"><path class=\"xgrid crisp\" transform=\"translate(100,0)\" d=\"M0,82v2\" style=\"stroke: rgb(238, 238, 238); stroke-opacity: 1; stroke-width: 1px;\"/><path class=\"xgrid crisp\" transform=\"translate(114.25,0)\" d=\"M0,82v2\" style=\"stroke: rgb(238, 238, 238); stroke-opacity: 1; stroke-width: 1px;\"/></g><g class=\"y\"/></g><g class=\"zerolinelayer\"><path class=\"xzl zl crisp\" transform=\"translate(85.75,0)\" d=\"M0,82v2\" style=\"stroke: rgb(68, 68, 68); stroke-opacity: 1; stroke-width: 1px;\"/></g><path class=\"xlines-below\"/><path class=\"ylines-below\"/><g class=\"overlines-below\"/><g class=\"xaxislayer-below\"/><g class=\"yaxislayer-below\"/><g class=\"overaxes-below\"/><g class=\"plot\" transform=\"translate(80,82)\" clip-path=\"url('#clip2dc70axyplot')\"><g class=\"scatterlayer mlayer\"><g class=\"trace scatter trace86f735\" style=\"stroke-miterlimit: 2; opacity: 1;\"><g class=\"fills\"/><g class=\"errorbars\"/><g class=\"lines\"><path class=\"js-line\" d=\"M5.75,1L20,0L34.25,2\" style=\"vector-effect: non-scaling-stroke; fill: none; stroke: rgb(31, 119, 180); stroke-opacity: 1; stroke-width: 2px; opacity: 1;\"/></g><g class=\"points\"><path class=\"point\" transform=\"translate(5.75,1)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/><path class=\"point\" transform=\"translate(20,0)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/><path class=\"point\" transform=\"translate(34.25,2)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/></g><g class=\"text\"/></g></g></g><g class=\"overplot\"/><path class=\"xlines-above crisp\" d=\"M0,0\" style=\"fill: none;\"/><path class=\"ylines-above crisp\" d=\"M0,0\" style=\"fill: none;\"/><g class=\"overlines-above\"/><g class=\"xaxislayer-above\"><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(85.75,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">0</text></g><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(100,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">1</text></g><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(114.25,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">2</text></g></g><g class=\"yaxislayer-above\"><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,84)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">2</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,83.5)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">4</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,83)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">6</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,82.5)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">8</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,82)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">10</text></g></g><g class=\"overaxes-above\"/></g></g><g class=\"polarlayer\"/><g class=\"ternarylayer\"/><g class=\"geolayer\"/><g class=\"funnelarealayer\"/><g class=\"pielayer\"/><g class=\"treemaplayer\"/><g class=\"sunburstlayer\"/><g class=\"glimages\"/><defs id=\"topdefs-2dc70a\"><g class=\"clips\"/></defs><g class=\"layer-above\"><g class=\"imagelayer\"/><g class=\"shapelayer\"/></g><g class=\"infolayer\"><g class=\"g-gtitle\"/><g class=\"g-xtitle\"/><g class=\"g-ytitle\"/></g></svg>";
1157        // Limit the test to the first LEN characters as generated SVGs
1158        // seem to contain uniquely generated IDs
1159        const LEN: usize = 10;
1160        assert_eq!(expected[..LEN], image_svg[..LEN]);
1161        exporter.close();
1162    }
1163
1164    #[test]
1165    #[cfg(feature = "plotly_static")]
1166    fn save_surface_to_png() {
1167        use crate::Surface;
1168        let mut plot = Plot::new();
1169        let z_matrix = vec![
1170            vec![1.0, 2.0, 3.0],
1171            vec![4.0, 5.0, 6.0],
1172            vec![7.0, 8.0, 9.0],
1173        ];
1174        let x_unique = vec![1.0, 2.0, 3.0];
1175        let y_unique = vec![4.0, 5.0, 6.0];
1176        let surface = Surface::new(z_matrix)
1177            .x(x_unique)
1178            .y(y_unique)
1179            .name("Surface");
1180
1181        plot.add_trace(surface);
1182        let dst = PathBuf::from("plotly_example_surface.png");
1183        let mut exporter = plotly_static::StaticExporterBuilder::default()
1184            .webdriver_port(get_unique_port())
1185            .build()
1186            .unwrap();
1187
1188        assert!(!exporter
1189            .to_base64(&plot, ImageFormat::PNG, 1024, 680, 1.0)
1190            .unwrap()
1191            .is_empty());
1192
1193        exporter
1194            .write_image(&plot, &dst, ImageFormat::PNG, 800, 600, 1.0)
1195            .unwrap();
1196        assert!(dst.exists());
1197
1198        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1199        let file_size = metadata.len();
1200        assert!(file_size > 0,);
1201        #[cfg(not(feature = "debug"))]
1202        assert!(std::fs::remove_file(&dst).is_ok());
1203        exporter.close();
1204    }
1205}