ggplot_rs/render/backend.rs
1use super::{Rect, RenderError};
2
3/// Point shape types.
4#[derive(Clone, Debug, Copy, PartialEq, Eq)]
5pub enum PointShape {
6 Circle,
7 Triangle,
8 Square,
9 Diamond,
10 Cross,
11 Plus,
12}
13
14impl PointShape {
15 /// All shapes in order (for discrete scale mapping).
16 pub const ALL: &[PointShape] = &[
17 PointShape::Circle,
18 PointShape::Triangle,
19 PointShape::Square,
20 PointShape::Diamond,
21 PointShape::Cross,
22 PointShape::Plus,
23 ];
24}
25
26/// Line type patterns.
27///
28/// The named variants are ggplot2's fixed patterns; [`Linetype::Custom`]
29/// takes a caller-supplied dash/gap sequence (#220), e.g.
30/// `Linetype::Custom(vec![(0.0, 10.0)])` together with
31/// [`LineEnd::Round`] draws a row of round dots every 10 px (zero-length
32/// dashes are drawn as round caps), as flow-diagram edges use.
33///
34/// `Linetype` is no longer `Copy` (a `Custom` pattern owns its `Vec`) —
35/// clone it where a copy was taken before.
36#[derive(Clone, Debug, PartialEq)]
37#[non_exhaustive]
38pub enum Linetype {
39 Solid,
40 Dashed,
41 Dotted,
42 DashDot,
43 LongDash,
44 TwoDash,
45 /// Arbitrary `(dash, gap)` pairs in px, rendered through the same
46 /// `stroke-dasharray` / dashed-stroking path as the named variants.
47 ///
48 /// Degenerate input never panics: pairs with a non-finite value are
49 /// dropped, negative lengths are clamped to `0`, and a pattern with no
50 /// usable pairs (empty, all-NaN, or a cycle of zero total length) draws
51 /// solid. See [`Linetype::dash_pattern`].
52 Custom(Vec<(f64, f64)>),
53}
54
55impl Linetype {
56 /// All named linetypes in order (for discrete scale mapping).
57 /// [`Linetype::Custom`] is not part of the cycle.
58 pub const ALL: &[Linetype] = &[
59 Linetype::Solid,
60 Linetype::Dashed,
61 Linetype::Dotted,
62 Linetype::DashDot,
63 Linetype::LongDash,
64 Linetype::TwoDash,
65 ];
66
67 /// Get the dash pattern as (draw_len, gap_len) pairs in pixels, exactly
68 /// as stored. For [`Linetype::Custom`] this is the caller's raw input;
69 /// renderers use the sanitised [`Linetype::dash_pattern`] instead.
70 pub fn pattern(&self) -> &[(f64, f64)] {
71 match self {
72 Linetype::Solid => &[],
73 Linetype::Dashed => &[(6.0, 3.0)],
74 Linetype::Dotted => &[(2.0, 2.0)],
75 Linetype::DashDot => &[(6.0, 2.0), (2.0, 2.0)],
76 Linetype::LongDash => &[(10.0, 4.0)],
77 Linetype::TwoDash => &[(8.0, 3.0), (3.0, 3.0)],
78 Linetype::Custom(pairs) => pairs,
79 }
80 }
81
82 /// The dash pattern a renderer should draw: [`Linetype::pattern`] with
83 /// non-finite pairs dropped and negative lengths clamped to `0`. Returns
84 /// an empty vector (solid) when nothing usable is left or one full
85 /// dash+gap cycle is shorter than 0.01 px (or overflows to infinity), so no backend ever sees a
86 /// NaN, a negative length, or a zero-length cycle (which would never
87 /// advance a dashed stroker).
88 pub fn dash_pattern(&self) -> Vec<(f64, f64)> {
89 let pairs: Vec<(f64, f64)> = self
90 .pattern()
91 .iter()
92 .filter(|(d, g)| d.is_finite() && g.is_finite())
93 .map(|&(d, g)| (d.max(0.0), g.max(0.0)))
94 .collect();
95 let cycle: f64 = pairs.iter().map(|(d, g)| d + g).sum();
96 // No gap at all is a solid line too: emit nothing rather than a
97 // dasharray with only zero gaps.
98 let gaps: f64 = pairs.iter().map(|(_, g)| g).sum();
99 if pairs.is_empty() || !cycle.is_finite() || cycle < 0.01 || gaps <= 0.0 {
100 Vec::new()
101 } else {
102 pairs
103 }
104 }
105}
106
107/// Style for drawing points/circles.
108#[derive(Clone, Debug)]
109pub struct PointStyle {
110 pub color: (u8, u8, u8),
111 pub alpha: f64,
112 pub filled: bool,
113 pub shape: PointShape,
114}
115
116impl Default for PointStyle {
117 fn default() -> Self {
118 PointStyle {
119 color: (0, 0, 0),
120 alpha: 1.0,
121 filled: true,
122 shape: PointShape::Circle,
123 }
124 }
125}
126
127/// Line cap style (R grid's `lineend`), applied to the stroked ends of a
128/// polyline/path. `Butt` is SVG's own default, so it is written on the wire
129/// only when it differs (byte-identical default output).
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
131pub enum LineEnd {
132 #[default]
133 Butt,
134 Round,
135 Square,
136}
137
138/// Style for drawing lines.
139#[derive(Clone, Debug)]
140pub struct LineStyle {
141 pub color: (u8, u8, u8),
142 pub alpha: f64,
143 pub width: f64,
144 pub linetype: Linetype,
145 pub lineend: LineEnd,
146}
147
148impl Default for LineStyle {
149 fn default() -> Self {
150 LineStyle {
151 color: (0, 0, 0),
152 alpha: 1.0,
153 width: 1.0,
154 linetype: Linetype::Solid,
155 lineend: LineEnd::Butt,
156 }
157 }
158}
159
160/// Style for drawing rectangles/polygons.
161#[derive(Clone, Debug)]
162pub struct RectStyle {
163 pub fill: Option<(u8, u8, u8)>,
164 pub stroke: Option<(u8, u8, u8)>,
165 pub stroke_width: f64,
166 pub alpha: f64,
167 /// Whether to clip this rect to the plot area. Default `true` for data elements.
168 /// Set to `false` for non-data elements (backgrounds, strips, legends).
169 pub clip: bool,
170 /// Corner radius in px, for a rounded rectangle (ggplot2 `geom_label(label.r)`,
171 /// ggchicklet `radius`). `0.0` (the default) draws a plain rectangle; backends
172 /// that cannot draw rounded corners natively fall back to a plain rectangle.
173 pub corner_radius: f64,
174}
175
176impl Default for RectStyle {
177 fn default() -> Self {
178 RectStyle {
179 fill: Some((128, 128, 128)),
180 stroke: None,
181 stroke_width: 1.0,
182 alpha: 1.0,
183 clip: true,
184 corner_radius: 0.0,
185 }
186 }
187}
188
189/// A pattern fill for an area that has no single colour to draw — today
190/// just a scale's `na_pattern` (#206): `ScaleFillBinned::na_pattern`,
191/// drawn instead of `na_value`'s flat colour by `geom_sf`/`geom_polygon`/
192/// `geom_rect`/`geom_tile` and the legend's "no data" key, so a classed
193/// choropleth's no-data cells read as "we have no reading here" rather
194/// than a greyed-out *value* easily mistaken for one.
195///
196/// `fg` stripes run at `angle_deg` (0 = horizontal, measured the usual
197/// screen way, clockwise from the x axis), each `size_px` wide, over a
198/// `bg` field — `fg` fills the first 30% of each cycle, `bg` the rest, a
199/// thin hatch rather than an even 50/50 stripe (ggplot2/R's own
200/// `pattern_stripe(density = 0.3)` default). More pattern kinds can join
201/// this enum later without breaking callers that match it today only via
202/// [`FillPattern::bg`]/[`FillPattern::color_at`].
203#[derive(Clone, Copy, Debug, PartialEq)]
204pub enum FillPattern {
205 Stripe {
206 fg: (u8, u8, u8),
207 bg: (u8, u8, u8),
208 /// The width of one fg+bg cycle, in output pixels. `<= 0.0` is
209 /// treated as `1.0` (an unreasonably fine hatch rather than a
210 /// division by zero/negative-period wraparound).
211 size_px: f64,
212 /// Stripe direction in degrees (0 = horizontal).
213 angle_deg: f64,
214 },
215}
216
217impl FillPattern {
218 /// The fraction of each cycle `fg` covers (the rest is `bg`) — a thin
219 /// hatch, not a 50/50 stripe.
220 const STRIPE_FG_FRACTION: f64 = 0.3;
221
222 /// A reasonable default stripe: `fg`/`bg` as given, 4px period, 45°.
223 pub fn stripe(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> FillPattern {
224 FillPattern::Stripe {
225 fg,
226 bg,
227 size_px: 4.0,
228 angle_deg: 45.0,
229 }
230 }
231
232 /// The flat colour a backend with no pattern primitive falls back to
233 /// (`plotters` — "falls back to bg", #206) — the pattern's background,
234 /// so a no-data cell still reads as *muted*, just without the hatch.
235 pub fn bg(&self) -> (u8, u8, u8) {
236 match self {
237 FillPattern::Stripe { bg, .. } => *bg,
238 }
239 }
240
241 /// This pattern's colour at screen point `(x, y)` — a raster backend
242 /// (pixel/`canvas`) fills per pixel with this ("raster stripes", #206)
243 /// rather than approximating with [`FillPattern::bg`].
244 pub fn color_at(&self, x: f64, y: f64) -> (u8, u8, u8) {
245 match self {
246 FillPattern::Stripe {
247 fg,
248 bg,
249 size_px,
250 angle_deg,
251 } => {
252 let (s, c) = angle_deg.to_radians().sin_cos();
253 let rx = x * c + y * s;
254 let size = if *size_px > 0.0 { *size_px } else { 1.0 };
255 let frac = (rx / size).rem_euclid(1.0);
256 if frac < Self::STRIPE_FG_FRACTION {
257 *fg
258 } else {
259 *bg
260 }
261 }
262 }
263 }
264}
265
266/// Number of line segments approximating each 90° corner of a rounded rect in
267/// backends with no native rounded-rect primitive (pixel, plotters). Higher
268/// is smoother; 8 is visually indistinguishable from a true arc at the sizes
269/// these rects are drawn at (node/label boxes, a few tens to ~200px).
270///
271/// Only [`pixel_backend`](super::pixel_backend) (feature `canvas`) and
272/// [`plotters_backend`](super::plotters_backend) (feature `plotters`) call
273/// [`rounded_rect_points`] — `svg_backend` emits a native `rx`/`ry` instead —
274/// so both it and the function are cfg-gated the same way those backends
275/// are, rather than left for `--no-default-features` to flag as dead code.
276#[cfg(any(
277 feature = "canvas",
278 all(feature = "plotters", not(target_arch = "wasm32"))
279))]
280const ROUNDED_RECT_CORNER_SEGMENTS: usize = 8;
281
282/// A closed polygon approximating a rectangle with rounded corners, for
283/// backends that must rasterize/triangulate rather than emit a native rounded
284/// rect (see [`DrawBackend::draw_rect`]'s `corner_radius`, via [`RectStyle`]).
285///
286/// `radius` is clamped to half the shorter side, so an oversized radius draws
287/// a "stadium" (fully rounded) shape rather than overshooting. A
288/// non-positive radius returns the plain 4-corner rectangle.
289///
290/// Corners go clockwise in screen coordinates (y down), starting at the
291/// top-right, so the first and last points sit either side of the top-right
292/// corner — callers that stroke the result as a closed loop (last point back
293/// to the first) get a seamless boundary.
294#[cfg(any(
295 feature = "canvas",
296 all(feature = "plotters", not(target_arch = "wasm32"))
297))]
298pub(crate) fn rounded_rect_points(
299 x0: f64,
300 y0: f64,
301 x1: f64,
302 y1: f64,
303 radius: f64,
304) -> Vec<(f64, f64)> {
305 let (x0, x1) = (x0.min(x1), x0.max(x1));
306 let (y0, y1) = (y0.min(y1), y0.max(y1));
307 let r = radius.max(0.0).min((x1 - x0) / 2.0).min((y1 - y0) / 2.0);
308 if r <= 0.0 {
309 return vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
310 }
311 use std::f64::consts::{FRAC_PI_2, PI};
312 let n = ROUNDED_RECT_CORNER_SEGMENTS;
313 // (corner centre, start angle, end angle), clockwise, starting top-right.
314 let corners = [
315 (x1 - r, y0 + r, -FRAC_PI_2, 0.0), // top-right
316 (x1 - r, y1 - r, 0.0, FRAC_PI_2), // bottom-right
317 (x0 + r, y1 - r, FRAC_PI_2, PI), // bottom-left
318 (x0 + r, y0 + r, PI, 3.0 * FRAC_PI_2), // top-left
319 ];
320 let mut pts = Vec::with_capacity(4 * (n + 1));
321 for (cx, cy, a0, a1) in corners {
322 for i in 0..=n {
323 let t = a0 + (a1 - a0) * (i as f64 / n as f64);
324 pts.push((cx + r * t.cos(), cy + r * t.sin()));
325 }
326 }
327 pts
328}
329
330#[cfg(all(
331 test,
332 any(
333 feature = "canvas",
334 all(feature = "plotters", not(target_arch = "wasm32"))
335 )
336))]
337mod rounded_rect_tests {
338 use super::rounded_rect_points;
339
340 #[test]
341 fn zero_radius_is_a_plain_rectangle() {
342 let pts = rounded_rect_points(0.0, 0.0, 10.0, 20.0, 0.0);
343 assert_eq!(
344 pts,
345 vec![(0.0, 0.0), (10.0, 0.0), (10.0, 20.0), (0.0, 20.0)]
346 );
347 }
348
349 #[test]
350 fn negative_radius_also_falls_back_to_a_plain_rectangle() {
351 // `radius.max(0.0)` guards a caller passing a negative radius
352 // (e.g. a miscomputed value) — it must not panic or produce a
353 // garbage polygon, just the same plain-rect fallback as 0.0.
354 let pts = rounded_rect_points(0.0, 0.0, 10.0, 20.0, -5.0);
355 assert_eq!(
356 pts,
357 vec![(0.0, 0.0), (10.0, 0.0), (10.0, 20.0), (0.0, 20.0)]
358 );
359 }
360
361 #[test]
362 fn x_and_y_are_order_independent() {
363 // Callers may pass (x1, y1) before (x0, y0) (e.g. a box whose
364 // ymax < ymin before min/max normalisation elsewhere) — the
365 // function itself must normalise, not assume sorted input.
366 let forward = rounded_rect_points(0.0, 0.0, 10.0, 20.0, 3.0);
367 let swapped = rounded_rect_points(10.0, 20.0, 0.0, 0.0, 3.0);
368 assert_eq!(forward, swapped);
369 }
370
371 #[test]
372 fn radius_is_clamped_to_half_the_shorter_side() {
373 // A radius bigger than the box collapses to a "stadium": every point
374 // must stay within the rect's bounds.
375 let pts = rounded_rect_points(0.0, 0.0, 10.0, 20.0, 1000.0);
376 for (x, y) in &pts {
377 assert!((0.0..=10.0).contains(x), "x={x} out of [0,10]");
378 assert!((0.0..=20.0).contains(y), "y={y} out of [0,20]");
379 }
380 }
381
382 #[test]
383 fn corners_are_inset_by_radius_from_the_rect_corners() {
384 let pts = rounded_rect_points(0.0, 0.0, 100.0, 50.0, 10.0);
385 // The arc midpoint of the top-right corner sits at 45° in from the
386 // corner, inset by r*(1 - cos(45°)) from the straight edges.
387 assert!(pts.len() > 4);
388 for (x, y) in &pts {
389 assert!(*x >= 0.0 && *x <= 100.0);
390 assert!(*y >= 0.0 && *y <= 50.0);
391 }
392 }
393}
394
395/// Style for drawing text.
396#[derive(Clone, Debug)]
397pub struct TextStyle {
398 pub color: (u8, u8, u8),
399 pub size: f64,
400 pub anchor: TextAnchor,
401 pub angle: f64,
402 /// Font family (e.g., "serif", "monospace"). None defaults to "sans-serif".
403 pub family: Option<String>,
404 /// Font face (R's `element_text(face = ...)`).
405 pub face: FontFace,
406 /// Text halo / outline colour (#229, ggrepel's `bg.colour` /
407 /// `shadowtext`'s `bg.col`): `None` (the default) draws no halo, so
408 /// existing output is byte-identical. SVG: `stroke` on the `<text>`
409 /// itself (`stroke-linejoin="round"`, `paint-order="stroke"`, so the
410 /// stroke paints under the fill rather than over it). Raster: an
411 /// outline pass in this colour before the normal fill pass.
412 pub bg_colour: Option<(u8, u8, u8)>,
413 /// Text halo radius in px (#229, ggrepel's `bg.r`) — the SVG
414 /// `stroke-width` is `2 * bg_r`. Only matters when `bg_colour` is
415 /// `Some`; `0.0` (the default) draws no halo even if `bg_colour` is
416 /// set.
417 pub bg_r: f64,
418}
419
420/// Font face / weight (R's `element_text(face = ...)`).
421#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
422#[non_exhaustive]
423pub enum FontFace {
424 #[default]
425 Plain,
426 Bold,
427 Italic,
428}
429
430impl Default for TextStyle {
431 fn default() -> Self {
432 TextStyle {
433 color: (50, 50, 50),
434 size: 12.0,
435 anchor: TextAnchor::Middle,
436 angle: 0.0,
437 family: None,
438 face: crate::render::backend::FontFace::Plain,
439 bg_colour: None,
440 bg_r: 0.0,
441 }
442 }
443}
444
445#[derive(Clone, Debug)]
446pub enum TextAnchor {
447 Start,
448 Middle,
449 End,
450}
451
452/// Our rendering abstraction, independent of plotters details.
453pub trait DrawBackend {
454 fn draw_circle(
455 &mut self,
456 center: (f64, f64),
457 radius: f64,
458 style: &PointStyle,
459 ) -> Result<(), RenderError>;
460 fn draw_line(&mut self, points: &[(f64, f64)], style: &LineStyle) -> Result<(), RenderError>;
461 fn draw_rect(
462 &mut self,
463 top_left: (f64, f64),
464 bottom_right: (f64, f64),
465 style: &RectStyle,
466 ) -> Result<(), RenderError>;
467 fn draw_text(
468 &mut self,
469 text: &str,
470 pos: (f64, f64),
471 style: &TextStyle,
472 ) -> Result<(), RenderError>;
473 fn draw_polygon(&mut self, points: &[(f64, f64)], style: &RectStyle)
474 -> Result<(), RenderError>;
475
476 /// Draw a polygon with holes: `rings[0]` is the exterior, every other
477 /// ring a hole cut out of it under the even-odd fill rule (`geom_sf`'s
478 /// `Polygon`, OGC/`sf` convention — see
479 /// [`spatial::Geometry::Polygon`](crate::spatial::Geometry::Polygon)).
480 /// One `stroke`/`stroke_width` draws every ring's outline, exterior and
481 /// holes alike (as `ggplot2`'s `geom_sf` does).
482 ///
483 /// Default: no real hole cut-out (for backends that pre-date this
484 /// method) — the exterior ring filled and stroked, every other ring
485 /// stroked only. Backends with a resolution-independent fill (SVG) or a
486 /// scanline rasteriser (pixel/plotters) should override this to use
487 /// their fill rule across every ring at once.
488 fn draw_polygon_rings(
489 &mut self,
490 rings: &[Vec<(f64, f64)>],
491 style: &RectStyle,
492 ) -> Result<(), RenderError> {
493 for (i, ring) in rings.iter().enumerate() {
494 let ring_style = if i == 0 {
495 style.clone()
496 } else {
497 RectStyle {
498 fill: None,
499 ..style.clone()
500 }
501 };
502 self.draw_polygon(ring, &ring_style)?;
503 }
504 Ok(())
505 }
506
507 /// Like [`draw_rect`](Self::draw_rect), but filled with `pattern`
508 /// instead of `style.fill` (`geom_rect`/`geom_tile`'s no-data cells,
509 /// #206) — `style.stroke`/`stroke_width`/`clip`/`corner_radius` still
510 /// apply, only the fill differs.
511 ///
512 /// Default: flattens `pattern` to its [`FillPattern::bg`] and draws an
513 /// ordinary [`draw_rect`](Self::draw_rect) — the `plotters` backend
514 /// has no pattern-fill primitive and keeps this default ("falls back
515 /// to bg"). The native SVG backend overrides this to register a
516 /// `<defs><pattern>` (deduped, this backend's own id prefix) and fill
517 /// with its `url(#…)` reference; the pixel/`canvas` backend overrides
518 /// it to rasterise the actual stripes per pixel ("raster stripes").
519 fn draw_rect_pattern(
520 &mut self,
521 top_left: (f64, f64),
522 bottom_right: (f64, f64),
523 pattern: &FillPattern,
524 style: &RectStyle,
525 ) -> Result<(), RenderError> {
526 self.draw_rect(
527 top_left,
528 bottom_right,
529 &RectStyle {
530 fill: Some(pattern.bg()),
531 ..style.clone()
532 },
533 )
534 }
535
536 /// Like [`draw_polygon`](Self::draw_polygon), but filled with
537 /// `pattern` instead of `style.fill` (#206). Default/override split:
538 /// see [`draw_rect_pattern`](Self::draw_rect_pattern).
539 fn draw_polygon_pattern(
540 &mut self,
541 points: &[(f64, f64)],
542 pattern: &FillPattern,
543 style: &RectStyle,
544 ) -> Result<(), RenderError> {
545 self.draw_polygon(
546 points,
547 &RectStyle {
548 fill: Some(pattern.bg()),
549 ..style.clone()
550 },
551 )
552 }
553
554 /// Like [`draw_polygon_rings`](Self::draw_polygon_rings), but filled
555 /// with `pattern` instead of `style.fill` (`geom_sf`/`geom_polygon`'s
556 /// no-data features, #206) — holes still cut out under the even-odd
557 /// rule, exactly as a flat fill would. Default/override split: see
558 /// [`draw_rect_pattern`](Self::draw_rect_pattern).
559 fn draw_polygon_rings_pattern(
560 &mut self,
561 rings: &[Vec<(f64, f64)>],
562 pattern: &FillPattern,
563 style: &RectStyle,
564 ) -> Result<(), RenderError> {
565 self.draw_polygon_rings(
566 rings,
567 &RectStyle {
568 fill: Some(pattern.bg()),
569 ..style.clone()
570 },
571 )
572 }
573
574 /// Like [`draw_polygon_rings`](Self::draw_polygon_rings)/
575 /// [`draw_polygon_rings_pattern`](Self::draw_polygon_rings_pattern),
576 /// but lets the caller ask for a compact path encoding at a given
577 /// decimal `precision` — relative commands after the first `M`
578 /// instead of absolute `L` per point, geometrically identical within
579 /// that precision (#214). `geom_sf` ties `precision` to its own
580 /// `simplify_px` (more simplification tolerates coarser precision);
581 /// every other caller of the two methods above is untouched, so their
582 /// output doesn't move.
583 ///
584 /// Default: `precision` is ignored and this just dispatches to
585 /// [`draw_polygon_rings`](Self::draw_polygon_rings)/
586 /// [`draw_polygon_rings_pattern`](Self::draw_polygon_rings_pattern) —
587 /// correct (if not byte-compact) for every backend, including ones
588 /// that pre-date this method. Only the native SVG backend, which
589 /// actually writes path text, overrides it to shrink the encoding
590 /// when `precision` is `Some`.
591 fn draw_polygon_rings_precise(
592 &mut self,
593 rings: &[Vec<(f64, f64)>],
594 pattern: Option<&FillPattern>,
595 precision: Option<u8>,
596 style: &RectStyle,
597 ) -> Result<(), RenderError> {
598 let _ = precision;
599 match pattern {
600 Some(p) => self.draw_polygon_rings_pattern(rings, p, style),
601 None => self.draw_polygon_rings(rings, style),
602 }
603 }
604
605 /// Like [`draw_text`](Self::draw_text), but with an optional `<title>`
606 /// tooltip carrying the *full* text when `text` itself has been
607 /// shortened (e.g. a truncated-with-ellipsis legend label, #159).
608 /// Default: ignores `title` and draws `text` plain — backends without
609 /// native tooltip support (anything but SVG) have no better option.
610 /// SVG overrides this to emit the `<title>` child, escaped the same
611 /// way every other text run is.
612 fn draw_text_with_title(
613 &mut self,
614 text: &str,
615 pos: (f64, f64),
616 style: &TextStyle,
617 _title: Option<&str>,
618 ) -> Result<(), RenderError> {
619 self.draw_text(text, pos, style)
620 }
621
622 /// Draw one line as consecutive runs, each optionally an `http(s)`
623 /// hyperlink (ggspatial-style attribution: "© BKG" linked, a licence
624 /// code linked, plain connective text between them —
625 /// `annotation_attribution`'s `.link()`/`.runs()`, #196). `pos`/`style`
626 /// place and style the line as a whole, same as [`draw_text`](Self::draw_text)
627 /// — `style.anchor` aligns the *total* run sequence, not each run.
628 ///
629 /// Default: concatenates the runs' text and draws it plain with
630 /// [`draw_text`](Self::draw_text), dropping every link — the sensible
631 /// fallback for a backend with no link primitive (pixel/`canvas`,
632 /// `plotters`). The native SVG backend overrides this to wrap each
633 /// linked run in `<a href="…" target="_blank" rel="noopener">` with an
634 /// underline, inside one `<text>` element so the anchor still aligns
635 /// the whole line.
636 fn draw_text_runs(
637 &mut self,
638 runs: &[(&str, Option<&str>)],
639 pos: (f64, f64),
640 style: &TextStyle,
641 ) -> Result<(), RenderError> {
642 let joined: String = runs.iter().map(|(t, _)| *t).collect();
643 self.draw_text(&joined, pos, style)
644 }
645 fn plot_area(&self) -> Rect;
646 fn total_area(&self) -> Rect;
647
648 /// Attach a tooltip to subsequently-drawn marks until cleared with `None`.
649 /// Backends that produce interactive output (SVG) emit it as a `<title>`
650 /// for a native hover tooltip; others ignore it. Default: no-op.
651 fn set_tooltip(&mut self, _tooltip: Option<String>) {}
652
653 /// Attach an axis (x) key to subsequently-drawn marks — SVG emits it as a
654 /// `data-x` attribute so a host can group marks by x (axis-pointer tooltip).
655 /// Default: no-op.
656 fn set_mark_axis(&mut self, _key: Option<String>) {}
657
658 /// Attach a series key (the colour / fill / group level) to subsequently
659 /// drawn marks — SVG emits it as `data-series`, so a host can highlight or
660 /// filter one series without parsing the tooltip. Default: no-op.
661 fn set_mark_series(&mut self, _series: Option<String>) {}
662
663 /// Attach the raw measured value (the unformatted y) to subsequently drawn
664 /// marks — SVG emits it as `data-value`. Default: no-op.
665 fn set_mark_value(&mut self, _value: Option<String>) {}
666
667 /// Tag subsequently drawn elements as data marks of the named geom (the
668 /// layer's `Geom::name`) until cleared with `None`. SVG emits
669 /// `class="gg-mark gg-<kind>"` (kind from the primitive: `point`, `line`,
670 /// `band`, `bar`, `text`) plus `data-geom`, so a host can select data
671 /// marks without matching axes, grid lines or legends. Default: no-op.
672 fn set_mark_geom(&mut self, _geom: Option<&str>) {}
673
674 /// Report a non-fatal problem found while drawing (e.g. labels a repel
675 /// layout had to drop). Backends that collect warnings (the native SVG
676 /// backend) append it to the render warnings. Default: ignored.
677 fn warn(&mut self, _message: String) {}
678
679 /// Mark subsequently-drawn marks as belonging to a sub-part of a larger
680 /// geom (e.g. `"arrow"` for an arrowhead drawn alongside a segment/curve/
681 /// path/line). While set, the SVG backend writes `data-part="<part>"
682 /// aria-hidden="true"` and omits the `<title>` tooltip (the part is
683 /// decorative; the parent mark already carries the semantic info).
684 /// `data-x`/`data-series`/`data-value` (and any user `data-*` attrs) are
685 /// still written. Default: no-op.
686 fn set_mark_part(&mut self, _part: Option<&str>) {}
687
688 /// Attach arbitrary `data-*` attributes to subsequently-drawn marks
689 /// (B1-7's `Aes::data_attr`). The SVG backend writes each, escaped; an
690 /// attr with the same name as a built-in (`data-x`, `data-series`,
691 /// `data-value`) replaces it rather than duplicating it. An empty
692 /// `Vec` clears any previously set attrs. Default: no-op.
693 fn set_mark_attrs(&mut self, _attrs: Vec<(String, String)>) {}
694
695 /// Whether the caller asked for per-mark formatted values
696 /// ([`crate::plot::GGPlot::svg_formatted_values`], B2-4) — a query a
697 /// geom checks before doing the (otherwise pointless) work of resolving
698 /// a scale's formatter for `set_mark_fvalue`/`set_mark_fx`. Default
699 /// false, so the feature is byte-identical when never turned on.
700 fn wants_formatted_values(&self) -> bool {
701 false
702 }
703
704 /// `data-fvalue` on subsequently-drawn marks: the "value" aesthetic's
705 /// raw data value (y for most geoms, fill for hex/tile), formatted
706 /// through that aesthetic's scale (`Scale::format_value`) — e.g. a
707 /// de-DE currency string. Only meaningful while
708 /// [`wants_formatted_values`](Self::wants_formatted_values) is true;
709 /// `None` clears any previous mark's value, per row. Default: no-op.
710 fn set_mark_fvalue(&mut self, _value: Option<String>) {}
711
712 /// `data-fx` on subsequently-drawn marks: the x value formatted through
713 /// the x scale (e.g. a datetime axis's ISO/locale date). The SVG
714 /// backend omits it when it matches the already-written `data-x`, so a
715 /// plain numeric x doesn't get a redundant duplicate attribute. Default:
716 /// no-op.
717 fn set_mark_fx(&mut self, _value: Option<String>) {}
718
719 /// Extra CSS class tokens for subsequently-drawn marks (#221's
720 /// `Aes::class`), space-separated and already validated by the caller;
721 /// `None` clears them, per row. The SVG backend appends them to the
722 /// mark's own `class="gg-mark gg-<kind>"` (re-validating every token as
723 /// defence in depth). Default: no-op.
724 fn set_mark_class(&mut self, _class: Option<String>) {}
725
726 /// Put subsequently-drawn marks into the caller-named group
727 /// `(attribute name, value)` — e.g. `("data-of", "node-7")` — until
728 /// cleared with `None` (#221's `Aes::group_wrap`). The SVG backend
729 /// collects every mark with the same pair into one `<g name="value">`,
730 /// placed where the group's first mark was drawn. Default: no-op (a
731 /// raster backend has no grouping).
732 fn set_mark_group(&mut self, _group: Option<(String, String)>) {}
733
734 /// Per-layer SVG attributes for every mark the current layer draws
735 /// (#221's [`GGPlot::layer_svg_attr`](crate::plot::GGPlot::layer_svg_attr)):
736 /// `class` tokens, `aria-hidden`, `pointer-events`, `data-*`, already
737 /// validated by the caller. An empty `Vec` clears them (set before a
738 /// layer draws, cleared after). Default: no-op.
739 fn set_layer_attrs(&mut self, _attrs: Vec<(String, String)>) {}
740
741 /// Draw a point with a specific shape. Default delegates to draw_circle for Circle.
742 fn draw_shape(
743 &mut self,
744 center: (f64, f64),
745 radius: f64,
746 style: &PointStyle,
747 ) -> Result<(), RenderError> {
748 match style.shape {
749 PointShape::Circle => self.draw_circle(center, radius, style),
750 _ => {
751 // Default: fall back to circle for unsupported backends
752 self.draw_circle(center, radius, style)
753 }
754 }
755 }
756
757 /// Draw a quadratic Bézier curve from `p0` to `p1` with control point
758 /// `ctrl`, in pixel space. The default implementation flattens the curve
759 /// to 32 line segments and draws it with [`draw_line`](Self::draw_line);
760 /// backends that support real curves (SVG) should override this.
761 /// Non-finite input draws nothing.
762 fn draw_quadratic(
763 &mut self,
764 p0: (f64, f64),
765 ctrl: (f64, f64),
766 p1: (f64, f64),
767 style: &LineStyle,
768 ) -> Result<(), RenderError> {
769 if !(p0.0.is_finite()
770 && p0.1.is_finite()
771 && ctrl.0.is_finite()
772 && ctrl.1.is_finite()
773 && p1.0.is_finite()
774 && p1.1.is_finite())
775 {
776 return Ok(());
777 }
778 const STEPS: usize = 32;
779 let mut pts = Vec::with_capacity(STEPS + 1);
780 for i in 0..=STEPS {
781 let t = i as f64 / STEPS as f64;
782 let one_minus_t = 1.0 - t;
783 let x =
784 one_minus_t * one_minus_t * p0.0 + 2.0 * one_minus_t * t * ctrl.0 + t * t * p1.0;
785 let y =
786 one_minus_t * one_minus_t * p0.1 + 2.0 * one_minus_t * t * ctrl.1 + t * t * p1.1;
787 pts.push((x, y));
788 }
789 self.draw_line(&pts, style)
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use crate::render::{Rect, RenderError};
797 use std::cell::RefCell;
798
799 /// A minimal backend that does not override `draw_quadratic`, to exercise
800 /// the trait's default flattening implementation.
801 /// One `draw_polygon` call's recorded points and fill colour.
802 type RecordedPolygon = (Vec<(f64, f64)>, Option<(u8, u8, u8)>);
803 /// One `draw_rect` call's corners and fill colour.
804 type RecordedRect = ((f64, f64), (f64, f64), Option<(u8, u8, u8)>);
805
806 #[derive(Default)]
807 struct RecordingBackend {
808 lines: RefCell<Vec<Vec<(f64, f64)>>>,
809 circles: RefCell<Vec<((f64, f64), f64)>>,
810 polygons: RefCell<Vec<RecordedPolygon>>,
811 rects: RefCell<Vec<RecordedRect>>,
812 }
813
814 impl DrawBackend for RecordingBackend {
815 fn draw_circle(
816 &mut self,
817 center: (f64, f64),
818 radius: f64,
819 _style: &PointStyle,
820 ) -> Result<(), RenderError> {
821 self.circles.borrow_mut().push((center, radius));
822 Ok(())
823 }
824 fn draw_line(
825 &mut self,
826 points: &[(f64, f64)],
827 _style: &LineStyle,
828 ) -> Result<(), RenderError> {
829 self.lines.borrow_mut().push(points.to_vec());
830 Ok(())
831 }
832 fn draw_rect(
833 &mut self,
834 top_left: (f64, f64),
835 bottom_right: (f64, f64),
836 style: &RectStyle,
837 ) -> Result<(), RenderError> {
838 self.rects
839 .borrow_mut()
840 .push((top_left, bottom_right, style.fill));
841 Ok(())
842 }
843 fn draw_text(
844 &mut self,
845 _text: &str,
846 _pos: (f64, f64),
847 _style: &TextStyle,
848 ) -> Result<(), RenderError> {
849 Ok(())
850 }
851 fn draw_polygon(
852 &mut self,
853 points: &[(f64, f64)],
854 style: &RectStyle,
855 ) -> Result<(), RenderError> {
856 self.polygons
857 .borrow_mut()
858 .push((points.to_vec(), style.fill));
859 Ok(())
860 }
861 fn plot_area(&self) -> Rect {
862 Rect {
863 x: 0.0,
864 y: 0.0,
865 width: 100.0,
866 height: 100.0,
867 }
868 }
869 fn total_area(&self) -> Rect {
870 self.plot_area()
871 }
872 }
873
874 #[test]
875 fn default_draw_quadratic_flattens_to_33_points() {
876 let mut b = RecordingBackend::default();
877 b.draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &LineStyle::default())
878 .unwrap();
879 let lines = b.lines.borrow();
880 assert_eq!(lines.len(), 1);
881 assert_eq!(lines[0].len(), 33);
882 assert_eq!(lines[0][0], (0.0, 0.0));
883 assert_eq!(lines[0][32], (10.0, 0.0));
884 // Midpoint of a quadratic Bézier at t=0.5 is the average of the
885 // endpoints and the control point, each weighted 1/4, 1/2, 1/4.
886 let mid = lines[0][16];
887 assert!((mid.0 - 5.0).abs() < 1e-9);
888 assert!((mid.1 - 5.0).abs() < 1e-9);
889 }
890
891 #[test]
892 fn default_draw_quadratic_drops_non_finite() {
893 let mut b = RecordingBackend::default();
894 b.draw_quadratic(
895 (f64::NAN, 0.0),
896 (5.0, 10.0),
897 (10.0, 0.0),
898 &LineStyle::default(),
899 )
900 .unwrap();
901 assert!(b.lines.borrow().is_empty());
902 }
903
904 /// The default `draw_shape` falls back to `draw_circle` for every
905 /// `PointShape` (not just `Circle`) on a backend that doesn't override
906 /// it — e.g. a minimal backend that only implements the required
907 /// methods.
908 #[test]
909 fn default_draw_shape_falls_back_to_draw_circle_for_every_point_shape() {
910 let mut b = RecordingBackend::default();
911 for shape in PointShape::ALL {
912 b.draw_shape(
913 (1.0, 2.0),
914 3.0,
915 &PointStyle {
916 shape: *shape,
917 ..Default::default()
918 },
919 )
920 .unwrap();
921 }
922 assert_eq!(b.circles.borrow().len(), PointShape::ALL.len());
923 for (center, radius) in b.circles.borrow().iter() {
924 assert_eq!(*center, (1.0, 2.0));
925 assert_eq!(*radius, 3.0);
926 }
927 }
928
929 /// The default `draw_polygon_rings` (for a backend pre-dating #178, or
930 /// any third-party `DrawBackend` that never overrides it) falls back to
931 /// one `draw_polygon` call per ring: the exterior filled, every other
932 /// ring (a hole) with its fill cleared — the old approximation, not a
933 /// real cut-out, but it draws *something* rather than nothing.
934 #[test]
935 fn default_draw_polygon_rings_fills_only_the_exterior() {
936 let mut b = RecordingBackend::default();
937 let exterior = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
938 let hole = vec![(3.0, 3.0), (7.0, 3.0), (7.0, 7.0), (3.0, 7.0)];
939 b.draw_polygon_rings(
940 &[exterior.clone(), hole.clone()],
941 &RectStyle {
942 fill: Some((200, 50, 50)),
943 stroke: Some((0, 0, 0)),
944 stroke_width: 1.0,
945 alpha: 1.0,
946 clip: true,
947 corner_radius: 0.0,
948 },
949 )
950 .unwrap();
951 let polys = b.polygons.borrow();
952 assert_eq!(polys.len(), 2, "one draw_polygon call per ring");
953 assert_eq!(polys[0], (exterior, Some((200, 50, 50))), "exterior filled");
954 assert_eq!(polys[1].0, hole);
955 assert_eq!(polys[1].1, None, "hole not filled (no real cut-out)");
956 }
957
958 // #206: the default `draw_*_pattern` methods (a backend with no
959 // pattern-fill primitive — `plotters` is the real-world case, this
960 // mock stands in for it) flatten to the pattern's own `bg` and call
961 // the plain draw method, unchanged otherwise.
962
963 fn stripe() -> FillPattern {
964 FillPattern::stripe((10, 20, 30), (200, 210, 220))
965 }
966
967 #[test]
968 fn default_draw_rect_pattern_flattens_to_bg() {
969 let mut b = RecordingBackend::default();
970 b.draw_rect_pattern(
971 (0.0, 0.0),
972 (10.0, 10.0),
973 &stripe(),
974 &RectStyle {
975 stroke: Some((0, 0, 0)),
976 ..Default::default()
977 },
978 )
979 .unwrap();
980 let rects = b.rects.borrow();
981 assert_eq!(rects.len(), 1);
982 assert_eq!(rects[0], ((0.0, 0.0), (10.0, 10.0), Some(stripe().bg())));
983 }
984
985 #[test]
986 fn default_draw_polygon_pattern_flattens_to_bg() {
987 let mut b = RecordingBackend::default();
988 let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)];
989 b.draw_polygon_pattern(&pts, &stripe(), &RectStyle::default())
990 .unwrap();
991 let polys = b.polygons.borrow();
992 assert_eq!(polys.len(), 1);
993 assert_eq!(polys[0], (pts, Some(stripe().bg())));
994 }
995
996 #[test]
997 fn default_draw_polygon_rings_pattern_flattens_to_bg_per_ring() {
998 let mut b = RecordingBackend::default();
999 let exterior = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1000 let hole = vec![(3.0, 3.0), (7.0, 3.0), (7.0, 7.0), (3.0, 7.0)];
1001 b.draw_polygon_rings_pattern(&[exterior.clone(), hole], &stripe(), &RectStyle::default())
1002 .unwrap();
1003 let polys = b.polygons.borrow();
1004 // Same fallback shape as draw_polygon_rings's own default: the
1005 // exterior filled (with the pattern's bg), the hole not.
1006 assert_eq!(polys[0], (exterior, Some(stripe().bg())));
1007 assert_eq!(polys[1].1, None);
1008 }
1009
1010 #[test]
1011 fn recording_backend_areas_and_remaining_draw_methods_behave_as_declared() {
1012 let mut b = RecordingBackend::default();
1013 let pa = b.plot_area();
1014 let ta = b.total_area();
1015 assert_eq!(
1016 (pa.x, pa.y, pa.width, pa.height),
1017 (ta.x, ta.y, ta.width, ta.height)
1018 );
1019 assert_eq!((pa.x, pa.y, pa.width, pa.height), (0.0, 0.0, 100.0, 100.0));
1020 b.draw_rect((0.0, 0.0), (1.0, 1.0), &RectStyle::default())
1021 .unwrap();
1022 b.draw_text("label", (0.0, 0.0), &TextStyle::default())
1023 .unwrap();
1024 b.draw_polygon(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)], &RectStyle::default())
1025 .unwrap();
1026 }
1027}