plotters_canvas/
canvas.rs

1use js_sys::JSON;
2use wasm_bindgen::{JsCast, JsValue};
3use web_sys::{window, CanvasRenderingContext2d, HtmlCanvasElement};
4
5use plotters_backend::text_anchor::{HPos, VPos};
6use plotters_backend::{
7    BackendColor, BackendCoord, BackendStyle, BackendTextStyle, DrawingBackend, DrawingErrorKind,
8    FontTransform,
9};
10
11/// The backend that is drawing on the HTML canvas
12/// TODO: Support double buffering
13pub struct CanvasBackend {
14    canvas: HtmlCanvasElement,
15    context: CanvasRenderingContext2d,
16}
17
18pub struct CanvasError(String);
19
20impl std::fmt::Display for CanvasError {
21    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
22        return write!(fmt, "Canvas Error: {}", self.0);
23    }
24}
25
26impl std::fmt::Debug for CanvasError {
27    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
28        return write!(fmt, "CanvasError({})", self.0);
29    }
30}
31
32fn error_cast(e: JsValue) -> DrawingErrorKind<CanvasError> {
33    DrawingErrorKind::DrawingError(CanvasError(
34        JSON::stringify(&e)
35            .map(|s| Into::<String>::into(&s))
36            .unwrap_or_else(|_| "Unknown".to_string()),
37    ))
38}
39
40impl std::error::Error for CanvasError {}
41
42impl CanvasBackend {
43    fn init_backend(canvas: HtmlCanvasElement) -> Option<Self> {
44        let context: CanvasRenderingContext2d = canvas.get_context("2d").ok()??.dyn_into().ok()?;
45        Some(CanvasBackend { canvas, context })
46    }
47
48    /// Create a new drawing backend backed with an HTML5 canvas object with given Id
49    /// - `elem_id` The element id for the canvas
50    /// - Return either some drawing backend has been created, or none in error case
51    pub fn new(elem_id: &str) -> Option<Self> {
52        let document = window()?.document()?;
53        let canvas = document.get_element_by_id(elem_id)?;
54        let canvas: HtmlCanvasElement = canvas.dyn_into().ok()?;
55        Self::init_backend(canvas)
56    }
57
58    /// Create a new drawing backend backend with a HTML5 canvas object passed in
59    /// - `canvas` The object we want to use as backend
60    /// - Return either the drawing backend or None for error
61    pub fn with_canvas_object(canvas: HtmlCanvasElement) -> Option<Self> {
62        Self::init_backend(canvas)
63    }
64}
65
66fn make_canvas_color(color: BackendColor) -> JsValue {
67    let (r, g, b) = color.rgb;
68    let a = color.alpha;
69    format!("rgba({},{},{},{})", r, g, b, a).into()
70}
71
72impl DrawingBackend for CanvasBackend {
73    type ErrorType = CanvasError;
74
75    fn get_size(&self) -> (u32, u32) {
76        (self.canvas.width(), self.canvas.height())
77    }
78
79    fn ensure_prepared(&mut self) -> Result<(), DrawingErrorKind<CanvasError>> {
80        Ok(())
81    }
82
83    fn present(&mut self) -> Result<(), DrawingErrorKind<CanvasError>> {
84        Ok(())
85    }
86
87    fn draw_pixel(
88        &mut self,
89        point: BackendCoord,
90        style: BackendColor,
91    ) -> Result<(), DrawingErrorKind<CanvasError>> {
92        if style.color().alpha == 0.0 {
93            return Ok(());
94        }
95
96        self.context
97            .set_fill_style(&make_canvas_color(style.color()));
98        self.context
99            .fill_rect(f64::from(point.0), f64::from(point.1), 1.0, 1.0);
100        Ok(())
101    }
102
103    fn draw_line<S: BackendStyle>(
104        &mut self,
105        from: BackendCoord,
106        to: BackendCoord,
107        style: &S,
108    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
109        if style.color().alpha == 0.0 {
110            return Ok(());
111        }
112
113        self.context
114            .set_stroke_style(&make_canvas_color(style.color()));
115        self.context.set_line_width(style.stroke_width() as f64);
116        self.context.begin_path();
117        self.context.move_to(f64::from(from.0), f64::from(from.1));
118        self.context.line_to(f64::from(to.0), f64::from(to.1));
119        self.context.stroke();
120        Ok(())
121    }
122
123    fn draw_rect<S: BackendStyle>(
124        &mut self,
125        upper_left: BackendCoord,
126        bottom_right: BackendCoord,
127        style: &S,
128        fill: bool,
129    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
130        if style.color().alpha == 0.0 {
131            return Ok(());
132        }
133        if fill {
134            self.context
135                .set_fill_style(&make_canvas_color(style.color()));
136            self.context.fill_rect(
137                f64::from(upper_left.0),
138                f64::from(upper_left.1),
139                f64::from(bottom_right.0 - upper_left.0),
140                f64::from(bottom_right.1 - upper_left.1),
141            );
142        } else {
143            self.context
144                .set_stroke_style(&make_canvas_color(style.color()));
145            self.context.stroke_rect(
146                f64::from(upper_left.0),
147                f64::from(upper_left.1),
148                f64::from(bottom_right.0 - upper_left.0),
149                f64::from(bottom_right.1 - upper_left.1),
150            );
151        }
152        Ok(())
153    }
154
155    fn draw_path<S: BackendStyle, I: IntoIterator<Item = BackendCoord>>(
156        &mut self,
157        path: I,
158        style: &S,
159    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
160        if style.color().alpha == 0.0 {
161            return Ok(());
162        }
163        let mut path = path.into_iter();
164        self.context.begin_path();
165        if let Some(start) = path.next() {
166            self.context
167                .set_stroke_style(&make_canvas_color(style.color()));
168            self.context.move_to(f64::from(start.0), f64::from(start.1));
169            for next in path {
170                self.context.line_to(f64::from(next.0), f64::from(next.1));
171            }
172        }
173        self.context.stroke();
174        Ok(())
175    }
176
177    fn fill_polygon<S: BackendStyle, I: IntoIterator<Item = BackendCoord>>(
178        &mut self,
179        path: I,
180        style: &S,
181    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
182        if style.color().alpha == 0.0 {
183            return Ok(());
184        }
185        let mut path = path.into_iter();
186        self.context.begin_path();
187        if let Some(start) = path.next() {
188            self.context
189                .set_fill_style(&make_canvas_color(style.color()));
190            self.context.move_to(f64::from(start.0), f64::from(start.1));
191            for next in path {
192                self.context.line_to(f64::from(next.0), f64::from(next.1));
193            }
194            self.context.close_path();
195        }
196        self.context.fill();
197        Ok(())
198    }
199
200    fn draw_circle<S: BackendStyle>(
201        &mut self,
202        center: BackendCoord,
203        radius: u32,
204        style: &S,
205        fill: bool,
206    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
207        if style.color().alpha == 0.0 {
208            return Ok(());
209        }
210        if fill {
211            self.context
212                .set_fill_style(&make_canvas_color(style.color()));
213        } else {
214            self.context
215                .set_stroke_style(&make_canvas_color(style.color()));
216        }
217        self.context.begin_path();
218        self.context
219            .arc(
220                f64::from(center.0),
221                f64::from(center.1),
222                f64::from(radius),
223                0.0,
224                std::f64::consts::PI * 2.0,
225            )
226            .map_err(error_cast)?;
227        if fill {
228            self.context.fill();
229        } else {
230            self.context.stroke();
231        }
232        Ok(())
233    }
234
235    fn draw_text<S: BackendTextStyle>(
236        &mut self,
237        text: &str,
238        style: &S,
239        pos: BackendCoord,
240    ) -> Result<(), DrawingErrorKind<Self::ErrorType>> {
241        let color = style.color();
242        if color.alpha == 0.0 {
243            return Ok(());
244        }
245
246        let (mut x, mut y) = (pos.0, pos.1);
247
248        let degree = match style.transform() {
249            FontTransform::None => 0.0,
250            FontTransform::Rotate90 => 90.0,
251            FontTransform::Rotate180 => 180.0,
252            FontTransform::Rotate270 => 270.0,
253        } / 180.0
254            * std::f64::consts::PI;
255
256        if degree != 0.0 {
257            self.context.save();
258            self.context
259                .translate(f64::from(x), f64::from(y))
260                .map_err(error_cast)?;
261            self.context.rotate(degree).map_err(error_cast)?;
262            x = 0;
263            y = 0;
264        }
265
266        let text_baseline = match style.anchor().v_pos {
267            VPos::Top => "top",
268            VPos::Center => "middle",
269            VPos::Bottom => "bottom",
270        };
271        self.context.set_text_baseline(text_baseline);
272
273        let text_align = match style.anchor().h_pos {
274            HPos::Left => "start",
275            HPos::Right => "end",
276            HPos::Center => "center",
277        };
278        self.context.set_text_align(text_align);
279
280        self.context
281            .set_fill_style(&make_canvas_color(color.clone()));
282        self.context.set_font(&format!(
283            "{} {}px {}",
284            style.style().as_str(),
285            style.size(),
286            style.family().as_str(),
287        ));
288        self.context
289            .fill_text(text, f64::from(x), f64::from(y))
290            .map_err(error_cast)?;
291
292        if degree != 0.0 {
293            self.context.restore();
294        }
295
296        Ok(())
297    }
298}
299
300#[cfg(test)]
301mod test {
302    use super::*;
303    use plotters::element::Circle;
304    use plotters::prelude::*;
305    use plotters_backend::text_anchor::Pos;
306    use wasm_bindgen_test::wasm_bindgen_test_configure;
307    use wasm_bindgen_test::*;
308    use web_sys::Document;
309
310    wasm_bindgen_test_configure!(run_in_browser);
311
312    fn create_canvas(document: &Document, id: &str, width: u32, height: u32) -> HtmlCanvasElement {
313        let canvas = document
314            .create_element("canvas")
315            .unwrap()
316            .dyn_into::<HtmlCanvasElement>()
317            .unwrap();
318        let div = document.create_element("div").unwrap();
319        div.append_child(&canvas).unwrap();
320        document.body().unwrap().append_child(&div).unwrap();
321        canvas.set_attribute("id", id).unwrap();
322        canvas.set_width(width);
323        canvas.set_height(height);
324        canvas
325    }
326
327    fn check_content(document: &Document, id: &str) {
328        let canvas = document
329            .get_element_by_id(id)
330            .unwrap()
331            .dyn_into::<HtmlCanvasElement>()
332            .unwrap();
333        let data_uri = canvas.to_data_url().unwrap();
334        let prefix = "data:image/png;base64,";
335        assert!(&data_uri.starts_with(prefix));
336    }
337
338    fn draw_mesh_with_custom_ticks(tick_size: i32, test_name: &str) {
339        let document = window().unwrap().document().unwrap();
340        let canvas = create_canvas(&document, test_name, 500, 500);
341        let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas");
342        let root = backend.into_drawing_area();
343
344        let mut chart = ChartBuilder::on(&root)
345            .caption("This is a test", ("sans-serif", 20))
346            .set_all_label_area_size(40)
347            .build_ranged(0..10, 0..10)
348            .unwrap();
349
350        chart
351            .configure_mesh()
352            .set_all_tick_mark_size(tick_size)
353            .draw()
354            .unwrap();
355
356        check_content(&document, test_name);
357    }
358
359    #[wasm_bindgen_test]
360    fn test_draw_mesh_no_ticks() {
361        draw_mesh_with_custom_ticks(0, "test_draw_mesh_no_ticks");
362    }
363
364    #[wasm_bindgen_test]
365    fn test_draw_mesh_negative_ticks() {
366        draw_mesh_with_custom_ticks(-10, "test_draw_mesh_negative_ticks");
367    }
368
369    #[wasm_bindgen_test]
370    fn test_text_draw() {
371        let document = window().unwrap().document().unwrap();
372        let canvas = create_canvas(&document, "test_text_draw", 1500, 800);
373        let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas");
374        let root = backend.into_drawing_area();
375        let root = root
376            .titled("Image Title", ("sans-serif", 60).into_font())
377            .unwrap();
378
379        let mut chart = ChartBuilder::on(&root)
380            .caption("All anchor point positions", ("sans-serif", 20))
381            .set_all_label_area_size(40)
382            .build_ranged(0..100, 0..50)
383            .unwrap();
384
385        chart
386            .configure_mesh()
387            .disable_x_mesh()
388            .disable_y_mesh()
389            .x_desc("X Axis")
390            .y_desc("Y Axis")
391            .draw()
392            .unwrap();
393
394        let ((x1, y1), (x2, y2), (x3, y3)) = ((-30, 30), (0, -30), (30, 30));
395
396        for (dy, trans) in [
397            FontTransform::None,
398            FontTransform::Rotate90,
399            FontTransform::Rotate180,
400            FontTransform::Rotate270,
401        ]
402        .iter()
403        .enumerate()
404        {
405            for (dx1, h_pos) in [HPos::Left, HPos::Right, HPos::Center].iter().enumerate() {
406                for (dx2, v_pos) in [VPos::Top, VPos::Center, VPos::Bottom].iter().enumerate() {
407                    let x = 150_i32 + (dx1 as i32 * 3 + dx2 as i32) * 150;
408                    let y = 120 + dy as i32 * 150;
409                    let draw = |x, y, text| {
410                        root.draw(&Circle::new((x, y), 3, &BLACK.mix(0.5))).unwrap();
411                        let style = TextStyle::from(("sans-serif", 20).into_font())
412                            .pos(Pos::new(*h_pos, *v_pos))
413                            .transform(trans.clone());
414                        root.draw_text(text, &style, (x, y)).unwrap();
415                    };
416                    draw(x + x1, y + y1, "dood");
417                    draw(x + x2, y + y2, "dog");
418                    draw(x + x3, y + y3, "goog");
419                }
420            }
421        }
422        check_content(&document, "test_text_draw");
423    }
424
425    #[wasm_bindgen_test]
426    fn test_text_clipping() {
427        let (width, height) = (500_i32, 500_i32);
428        let document = window().unwrap().document().unwrap();
429        let canvas = create_canvas(&document, "test_text_clipping", width as u32, height as u32);
430        let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas");
431        let root = backend.into_drawing_area();
432
433        let style = TextStyle::from(("sans-serif", 20).into_font())
434            .pos(Pos::new(HPos::Center, VPos::Center));
435        root.draw_text("TOP LEFT", &style, (0, 0)).unwrap();
436        root.draw_text("TOP CENTER", &style, (width / 2, 0))
437            .unwrap();
438        root.draw_text("TOP RIGHT", &style, (width, 0)).unwrap();
439
440        root.draw_text("MIDDLE LEFT", &style, (0, height / 2))
441            .unwrap();
442        root.draw_text("MIDDLE RIGHT", &style, (width, height / 2))
443            .unwrap();
444
445        root.draw_text("BOTTOM LEFT", &style, (0, height)).unwrap();
446        root.draw_text("BOTTOM CENTER", &style, (width / 2, height))
447            .unwrap();
448        root.draw_text("BOTTOM RIGHT", &style, (width, height))
449            .unwrap();
450
451        check_content(&document, "test_text_clipping");
452    }
453
454    #[wasm_bindgen_test]
455    fn test_series_labels() {
456        let (width, height) = (500, 500);
457        let document = window().unwrap().document().unwrap();
458        let canvas = create_canvas(&document, "test_series_labels", width, height);
459        let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas");
460        let root = backend.into_drawing_area();
461
462        let mut chart = ChartBuilder::on(&root)
463            .caption("All series label positions", ("sans-serif", 20))
464            .set_all_label_area_size(40)
465            .build_ranged(0..50, 0..50)
466            .unwrap();
467
468        chart
469            .configure_mesh()
470            .disable_x_mesh()
471            .disable_y_mesh()
472            .draw()
473            .unwrap();
474
475        chart
476            .draw_series(std::iter::once(Circle::new((5, 15), 5, &RED)))
477            .expect("Drawing error")
478            .label("Series 1")
479            .legend(|(x, y)| Circle::new((x, y), 3, RED.filled()));
480
481        chart
482            .draw_series(std::iter::once(Circle::new((5, 15), 10, &BLUE)))
483            .expect("Drawing error")
484            .label("Series 2")
485            .legend(|(x, y)| Circle::new((x, y), 3, BLUE.filled()));
486
487        for pos in vec![
488            SeriesLabelPosition::UpperLeft,
489            SeriesLabelPosition::MiddleLeft,
490            SeriesLabelPosition::LowerLeft,
491            SeriesLabelPosition::UpperMiddle,
492            SeriesLabelPosition::MiddleMiddle,
493            SeriesLabelPosition::LowerMiddle,
494            SeriesLabelPosition::UpperRight,
495            SeriesLabelPosition::MiddleRight,
496            SeriesLabelPosition::LowerRight,
497            SeriesLabelPosition::Coordinate(70, 70),
498        ]
499        .into_iter()
500        {
501            chart
502                .configure_series_labels()
503                .border_style(&BLACK.mix(0.5))
504                .position(pos)
505                .draw()
506                .expect("Drawing error");
507        }
508
509        check_content(&document, "test_series_labels");
510    }
511
512    #[wasm_bindgen_test]
513    fn test_draw_pixel_alphas() {
514        let (width, height) = (100_i32, 100_i32);
515        let document = window().unwrap().document().unwrap();
516        let canvas = create_canvas(
517            &document,
518            "test_draw_pixel_alphas",
519            width as u32,
520            height as u32,
521        );
522        let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas");
523        let root = backend.into_drawing_area();
524
525        for i in -20..20 {
526            let alpha = i as f64 * 0.1;
527            root.draw_pixel((50 + i, 50 + i), &BLACK.mix(alpha))
528                .unwrap();
529        }
530
531        check_content(&document, "test_draw_pixel_alphas");
532    }
533}