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#[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
95pub 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#[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 pub fn new() -> Plot {
190 Plot {
191 traces: Traces::new(),
192 remote_plotly_js: true,
193 ..Default::default()
194 }
195 }
196
197 pub fn use_local_plotly(&mut self) {
206 self.remote_plotly_js = false;
207 }
208
209 pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
211 self.traces.push(trace);
212 }
213
214 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 pub fn set_layout(&mut self, layout: Layout) {
223 self.layout = layout;
224 }
225
226 pub fn set_configuration(&mut self, configuration: Configuration) {
228 self.configuration = configuration;
229 }
230
231 pub fn data(&self) -> &Traces {
233 &self.traces
234 }
235
236 pub fn layout(&self) -> &Layout {
238 &self.layout
239 }
240
241 pub fn configuration(&self) -> &Configuration {
243 &self.configuration
244 }
245
246 #[cfg(not(target_family = "wasm"))]
251 pub fn show(&self) {
252 use std::env;
253
254 let rendered = self.render();
255
256 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 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 Plot::show_with_default_app(temp_path);
275 }
276
277 #[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 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 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 Plot::show_with_default_app(temp_path);
304 }
305
306 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 pub fn to_html(&self) -> String {
325 self.render()
326 }
327
328 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 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 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 pub fn evcxr_display(&self) {
380 self.lab_display();
381 }
382
383 #[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 pub fn to_js_object(&self) -> js_sys::Object {
442 use wasm_bindgen::JsCast;
443 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] #[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] #[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}