Skip to main content

ggplot_rs/render/
renderer.rs

1use crate::aes::Aesthetic;
2use crate::annotate::Annotation;
3use crate::build::BuiltPlot;
4use crate::facet::Facet;
5use crate::guide::{axis, legend};
6use crate::render::backend::{DrawBackend, LineStyle, Linetype, RectStyle, TextAnchor, TextStyle};
7use crate::render::RenderError;
8
9/// Orchestrates rendering of a built plot.
10pub struct PlotRenderer;
11
12impl PlotRenderer {
13    /// How far the title must rise to clear an axis above the panel: a
14    /// top primary x axis, or a secondary x axis (#409).
15    fn top_axis_lift(built: &BuiltPlot, theme: &crate::theme::Theme) -> f64 {
16        let Some(xs) = built.scales.get(&Aesthetic::X) else {
17            return 0.0;
18        };
19        if xs.axis_position_opposite() {
20            theme.axis_ticks_length + theme.axis_text_x.size + theme.legend_spacing
21        } else if let Some(sec) = xs.sec_axis().filter(|_| built.facet.is_none()) {
22            axis::sec_x_axis_height(theme, sec)
23        } else {
24            0.0
25        }
26    }
27
28    pub fn render(built: &BuiltPlot, backend: &mut dyn DrawBackend) -> Result<(), RenderError> {
29        // #465: semantic classes on the non-data elements, when asked for.
30        backend.enable_element_classes(built.svg_classes);
31        let drawn = if !built.facet.is_none() && !built.panels.is_empty() {
32            Self::render_faceted(built, backend)
33        } else {
34            Self::render_single(built, backend)
35        };
36        backend.set_element_class(None);
37        backend.enable_element_classes(false);
38        drawn
39    }
40
41    fn render_single(built: &BuiltPlot, backend: &mut dyn DrawBackend) -> Result<(), RenderError> {
42        let theme = &built.theme;
43        let plot_area = backend.plot_area();
44        let total_area = backend.total_area();
45        // #273: fresh panel-corner claim registry for this panel — see
46        // `render::corner_layout`. An inside legend draws *after* every
47        // layer (step 9 below), so its box is pre-claimed here, before
48        // any corner-furniture geom (annotation_scale/attribution/
49        // bivariate_legend) in the layer loop gets a chance to draw.
50        backend.clear_corner_claims();
51        if let Some(r) = built.coord.data_rect(&plot_area) {
52            backend.set_viewport(&r);
53        }
54        if let Some(rect) = legend::inside_legend_box(
55            &built.scales,
56            theme,
57            &plot_area,
58            &total_area,
59            &built.guide_legend,
60            &built.suppressed_aes,
61            &built.legend_plans,
62        ) {
63            backend.claim_corner_box(rect);
64        }
65
66        // 1. Draw plot background
67        if theme.plot_background.visible {
68            if let Some(fill) = theme.plot_background.fill {
69                backend.set_element_class(Some("gg-plot-background"));
70                backend.draw_rect(
71                    (total_area.x, total_area.y),
72                    (
73                        total_area.x + total_area.width,
74                        total_area.y + total_area.height,
75                    ),
76                    &RectStyle {
77                        fill: Some(fill),
78                        stroke: None,
79                        stroke_width: 0.0,
80                        alpha: 1.0,
81                        clip: false,
82                        corner_radius: 0.0,
83                    },
84                )?;
85            }
86        }
87
88        // 2. Draw panel background
89        if theme.panel_background.visible {
90            if let Some(fill) = theme.panel_background.fill {
91                backend.set_element_class(Some("gg-panel-background"));
92                backend.draw_rect(
93                    (plot_area.x, plot_area.y),
94                    (
95                        plot_area.x + plot_area.width,
96                        plot_area.y + plot_area.height,
97                    ),
98                    &RectStyle {
99                        fill: Some(fill),
100                        stroke: theme.panel_background.color,
101                        stroke_width: theme.panel_background.width,
102                        alpha: 1.0,
103                        clip: false,
104                        corner_radius: 0.0,
105                    },
106                )?;
107            }
108        }
109
110        // 2b. Draw panel border
111        if theme.panel_border.visible {
112            let style = LineStyle {
113                color: theme.panel_border.color,
114                width: theme.panel_border.width,
115                alpha: 1.0,
116                linetype: Linetype::Solid,
117                ..Default::default()
118            };
119            let x0 = plot_area.x;
120            let y0 = plot_area.y;
121            let x1 = plot_area.x + plot_area.width;
122            let y1 = plot_area.y + plot_area.height;
123            backend.set_element_class(Some("gg-panel-border"));
124            backend.draw_line(&[(x0, y0), (x1, y0)], &style)?;
125            backend.draw_line(&[(x1, y0), (x1, y1)], &style)?;
126            backend.draw_line(&[(x1, y1), (x0, y1)], &style)?;
127            backend.draw_line(&[(x0, y1), (x0, y0)], &style)?;
128        }
129
130        // 3. Draw gridlines
131        let x_scale = built.scales.get(&Aesthetic::X);
132        let y_scale = built.scales.get(&Aesthetic::Y);
133
134        let (h_scale, v_scale) = if built.coord.is_flipped() {
135            (y_scale, x_scale)
136        } else {
137            (x_scale, y_scale)
138        };
139
140        if let (true, Some(xs), Some(ys)) = (built.coord.is_radar(), x_scale, y_scale) {
141            // Radar: rings + spokes + labels replace cartesian grid/axes.
142            crate::guide::polar::draw_radar_guides(
143                xs,
144                ys,
145                built.coord.as_ref(),
146                theme,
147                &plot_area,
148                backend,
149            )?;
150        } else if let (true, Some(xs), Some(ys)) = (built.coord.is_polar(), x_scale, y_scale) {
151            // Polar / radial (#411): rings, spokes and labels around the
152            // circle; the axis titles stay at the bottom / left.
153            crate::guide::polar::draw_polar_guides(
154                xs,
155                ys,
156                built.coord.as_ref(),
157                theme,
158                &plot_area,
159                backend,
160            )?;
161            axis::draw_x_axis_title(xs, theme, &plot_area, backend)?;
162            let reserve = axis::y_label_reserve(theme, &ys.breaks());
163            axis::draw_y_axis_title(ys, theme, &plot_area, reserve, backend)?;
164        } else if let (Some(hs), Some(vs)) = (h_scale, v_scale) {
165            // Gridlines under the data, unless panel.ontop draws them over it.
166            if built.coord.gridlines() && !theme.panel_ontop {
167                axis::draw_gridlines(hs, vs, built.coord.as_ref(), theme, &plot_area, backend)?;
168            }
169
170            // 4. Draw axes
171            axis::draw_x_axis(
172                hs,
173                built.coord.as_ref(),
174                theme,
175                &plot_area,
176                &total_area,
177                backend,
178            )?;
179            axis::draw_y_axis(vs, built.coord.as_ref(), theme, &plot_area, backend)?;
180
181            // 4b. Draw secondary y axis if present
182            if let Some(sec) = built.scales.sec_axis(&Aesthetic::Y) {
183                axis::draw_sec_y_axis(vs, sec, built.coord.as_ref(), theme, &plot_area, backend)?;
184            }
185            // 4c. A secondary x axis along the top (#409) — unless the
186            // primary axis is already up there.
187            if let Some(sec) = hs.sec_axis().filter(|_| !hs.axis_position_opposite()) {
188                axis::draw_sec_x_axis(hs, sec, built.coord.as_ref(), theme, &plot_area, backend)?;
189            }
190        }
191
192        // 5. Draw each layer's geometry
193        // A layer whose data is empty (no input rows, or a stat that produced
194        // nothing) simply draws nothing. #411: layers reaching past the
195        // panel (a zoomed coord) are clipped to it (`clip = "on"`), or may
196        // draw past it everywhere (`clip = "off"`).
197        backend.set_element_class(None);
198        let clip_group = built.clip
199            && built
200                .layers
201                .iter()
202                .any(|l| layer_spills(&l.data, &built.scales, built.coord.as_ref(), &plot_area));
203        if clip_group {
204            backend.begin_clip(&plot_area);
205        }
206        if !built.clip {
207            backend.set_clipping(false);
208        }
209        for layer in built.layers.iter().filter(|l| l.data.nrows() > 0) {
210            backend.set_mark_geom(Some(layer.geom.name()));
211            let drawn = layer.geom.draw(
212                &layer.data,
213                built.coord.as_ref(),
214                &built.scales,
215                theme,
216                backend,
217            );
218            backend.set_mark_geom(None);
219            drawn?;
220        }
221        if !built.clip {
222            backend.set_clipping(true);
223        }
224        if clip_group {
225            backend.end_clip();
226        }
227
228        // 5b. Gridlines on top of the data (R's panel.ontop).
229        if theme.panel_ontop && built.coord.gridlines() {
230            if let (Some(hs), Some(vs)) = (h_scale, v_scale) {
231                axis::draw_gridlines(hs, vs, built.coord.as_ref(), theme, &plot_area, backend)?;
232            }
233        }
234
235        backend.set_element_class(None);
236        // 6. Draw annotations
237        Self::draw_annotations(
238            &built.annotations,
239            &built.scales,
240            built.coord.as_ref(),
241            &plot_area,
242            theme,
243            backend,
244        )?;
245
246        // 7. Draw title
247        let title_ref = Self::title_ref_rect(theme, &plot_area, &total_area);
248        let caption_ref = Self::ref_rect(
249            theme.caption_position.unwrap_or(theme.title_position),
250            theme,
251            &plot_area,
252            &total_area,
253        );
254        // #233: a Top legend reserves its own band just above the panel,
255        // same idea as a top x-axis's x_axis_lift below — lift title and
256        // subtitle above it too, or they'd land inside the legend's band
257        // instead of their own (zero when there's no Top legend to draw).
258        let legend_lift = legend::top_legend_lift(
259            theme,
260            &built.scales,
261            &built.suppressed_aes,
262            &built.legend_plans,
263            &built.guide_legend,
264        );
265        if let Some(ref title) = built.labels.title {
266            let (tx, anchor) = Self::hjust_pos(&theme.title, &title_ref);
267            // A top x-axis occupies the space just above the panel — lift the
268            // title above it so they don't overlap.
269            let x_axis_lift = Self::top_axis_lift(built, theme);
270            let (title_y, _) = Self::title_subtitle_y(
271                theme,
272                plot_area.y,
273                x_axis_lift + legend_lift,
274                built.labels.subtitle.is_some(),
275            );
276            let family = if theme.title.family.is_empty() {
277                None
278            } else {
279                Some(theme.title.family.clone())
280            };
281            backend.set_element_class(Some("gg-title"));
282            crate::render::rich::draw(
283                backend,
284                theme.rich_text,
285                title,
286                (tx, title_y),
287                &TextStyle {
288                    color: theme.title.color,
289                    size: theme.title.size,
290                    anchor,
291                    angle: 0.0,
292                    family,
293                    face: theme.title.face,
294                    bg_colour: None,
295                    bg_r: 0.0,
296                },
297            )?;
298        }
299
300        // 8. Draw subtitle
301        if let Some(ref subtitle) = built.labels.subtitle {
302            let (sx, anchor) = Self::hjust_pos(&theme.subtitle, &title_ref);
303            // #281: lifted above a Top legend's band; a top x-axis too.
304            let x_axis_lift = Self::top_axis_lift(built, theme);
305            let (_, subtitle_y) =
306                Self::title_subtitle_y(theme, plot_area.y, x_axis_lift + legend_lift, true);
307            let family = if theme.subtitle.family.is_empty() {
308                None
309            } else {
310                Some(theme.subtitle.family.clone())
311            };
312            backend.set_element_class(Some("gg-subtitle"));
313            crate::render::rich::draw(
314                backend,
315                theme.rich_text,
316                subtitle,
317                (sx, subtitle_y),
318                &TextStyle {
319                    color: theme.subtitle.color,
320                    size: theme.subtitle.size,
321                    anchor,
322                    angle: 0.0,
323                    family,
324                    face: theme.subtitle.face,
325                    bg_colour: None,
326                    bg_r: 0.0,
327                },
328            )?;
329        }
330
331        // 9. Draw legend
332        legend::draw_legend_planned(
333            &built.scales,
334            theme,
335            &plot_area,
336            plot_area.width,
337            backend,
338            &built.guide_legend,
339            &built.suppressed_aes,
340            &built.legend_plans,
341        )?;
342
343        // 10. Draw caption
344        if let Some(ref caption) = built.labels.caption {
345            let (cx, anchor) = Self::hjust_pos(&theme.caption, &caption_ref);
346            // Inside the layout's `caption_area`: the bottom of the
347            // canvas less `plot_margin.bottom` (#286).
348            let caption_y = total_area.y + total_area.height
349                - theme.plot_margin.bottom.max(0.0)
350                - theme.caption.size * 0.5;
351            let family = if theme.caption.family.is_empty() {
352                None
353            } else {
354                Some(theme.caption.family.clone())
355            };
356            backend.set_element_class(Some("gg-caption"));
357            crate::render::rich::draw(
358                backend,
359                theme.rich_text,
360                caption,
361                (cx, caption_y),
362                &TextStyle {
363                    color: theme.caption.color,
364                    size: theme.caption.size,
365                    anchor,
366                    angle: 0.0,
367                    family,
368                    face: theme.caption.face,
369                    bg_colour: None,
370                    bg_r: 0.0,
371                },
372            )?;
373        }
374
375        // 11. Draw corner tag (labs(tag = ...))
376        // #413: `plot.tag.location = "panel"` puts the tag in the panel's
377        // corner instead of the plot's.
378        let tag_area = if theme.tag_location_panel {
379            &plot_area
380        } else {
381            &total_area
382        };
383        Self::draw_tag(&built.labels, theme, tag_area, backend)?;
384
385        Ok(())
386    }
387
388    /// Horizontal position + anchor for a theme text element, from its `hjust`
389    /// (0 = left, 0.5 = center, 1 = right), relative to `area`.
390    fn hjust_pos(
391        el: &crate::theme::elements::ElementText,
392        area: &crate::render::Rect,
393    ) -> (f64, TextAnchor) {
394        let x = area.x + el.hjust.clamp(0.0, 1.0) * area.width;
395        let anchor = if el.hjust <= 0.02 {
396            TextAnchor::Start
397        } else if el.hjust >= 0.98 {
398            TextAnchor::End
399        } else {
400            TextAnchor::Middle
401        };
402        (x, anchor)
403    }
404
405    /// Vertical centres `(title_y, subtitle_y)` of the title and subtitle
406    /// (#384). Text is drawn `dominant-baseline="middle"`, so these are the
407    /// line centres, stacked bottom-up from the panel top inside the bands
408    /// `PlotLayout` reserves: the subtitle in its `1.5 × size` band directly
409    /// above the panel (less `lift` — a top x-axis and/or a Top legend,
410    /// #233/#281), the title above that. Without a subtitle the title keeps
411    /// its old place, `0.9 × size` above the panel. The title is floored at
412    /// `title.size` so it never leaves the canvas.
413    fn title_subtitle_y(
414        theme: &crate::theme::Theme,
415        panel_top: f64,
416        lift: f64,
417        has_subtitle: bool,
418    ) -> (f64, f64) {
419        let base = panel_top - lift;
420        let subtitle_band = if has_subtitle {
421            theme.subtitle.size * 1.5
422        } else {
423            0.0
424        };
425        let subtitle_y = base - subtitle_band / 2.0;
426        let title_y = (base - subtitle_band - theme.title.size * 0.9).max(theme.title.size);
427        (title_y, subtitle_y)
428    }
429
430    /// Horizontal reference rect for titles: the panel or the whole plot width
431    /// (R's `plot.title.position`).
432    fn title_ref_rect(
433        theme: &crate::theme::Theme,
434        plot_area: &crate::render::Rect,
435        total_area: &crate::render::Rect,
436    ) -> crate::render::Rect {
437        Self::ref_rect(theme.title_position, theme, plot_area, total_area)
438    }
439
440    /// The rectangle a title / caption aligns to under `pos` (#413: the
441    /// caption can differ, `plot.caption.position`).
442    fn ref_rect(
443        pos: crate::theme::TitlePosition,
444        theme: &crate::theme::Theme,
445        plot_area: &crate::render::Rect,
446        total_area: &crate::render::Rect,
447    ) -> crate::render::Rect {
448        match pos {
449            crate::theme::TitlePosition::Panel => plot_area.clone(),
450            crate::theme::TitlePosition::Plot => crate::render::Rect {
451                x: total_area.x + theme.plot_margin.left,
452                y: plot_area.y,
453                width: (total_area.width - theme.plot_margin.left - theme.plot_margin.right)
454                    .max(1.0),
455                height: plot_area.height,
456            },
457        }
458    }
459
460    /// Draw the corner tag label (position from `plot.tag.position`).
461    /// [`draw_tag`](Self::draw_tag) for a non-plot grid cell (#378).
462    pub(crate) fn draw_cell_tag(
463        labels: &crate::plot::Labels,
464        theme: &crate::theme::Theme,
465        area: &crate::render::Rect,
466        backend: &mut dyn DrawBackend,
467    ) -> Result<(), RenderError> {
468        Self::draw_tag(labels, theme, area, backend)
469    }
470
471    fn draw_tag(
472        labels: &crate::plot::Labels,
473        theme: &crate::theme::Theme,
474        total_area: &crate::render::Rect,
475        backend: &mut dyn DrawBackend,
476    ) -> Result<(), RenderError> {
477        if let Some(ref tag) = labels.tag {
478            let family = if theme.title.family.is_empty() {
479                None
480            } else {
481                Some(theme.title.family.clone())
482            };
483            use crate::theme::TagPosition::*;
484            let pad = theme.title.size;
485            let (left, right) = (total_area.x + pad, total_area.x + total_area.width - pad);
486            let (top, bottom) = (total_area.y + pad, total_area.y + total_area.height - pad);
487            let (x, y, anchor) = match theme.tag_position {
488                TopLeft => (left, top, TextAnchor::Start),
489                TopRight => (right, top, TextAnchor::End),
490                BottomLeft => (left, bottom, TextAnchor::Start),
491                BottomRight => (right, bottom, TextAnchor::End),
492            };
493            backend.set_element_class(Some("gg-tag"));
494            backend.draw_text(
495                tag,
496                (x, y),
497                &TextStyle {
498                    color: theme.title.color,
499                    size: theme.title.size,
500                    anchor,
501                    angle: 0.0,
502                    family,
503                    face: theme.title.face,
504                    bg_colour: None,
505                    bg_r: 0.0,
506                },
507            )?;
508        }
509        Ok(())
510    }
511
512    fn render_faceted(built: &BuiltPlot, backend: &mut dyn DrawBackend) -> Result<(), RenderError> {
513        let theme = &built.theme;
514        let plot_area = backend.plot_area();
515        let total_area = backend.total_area();
516
517        // Draw plot background
518        if theme.plot_background.visible {
519            if let Some(fill) = theme.plot_background.fill {
520                backend.set_element_class(Some("gg-plot-background"));
521                backend.draw_rect(
522                    (total_area.x, total_area.y),
523                    (
524                        total_area.x + total_area.width,
525                        total_area.y + total_area.height,
526                    ),
527                    &RectStyle {
528                        fill: Some(fill),
529                        stroke: None,
530                        stroke_width: 0.0,
531                        alpha: 1.0,
532                        clip: false,
533                        corner_radius: 0.0,
534                    },
535                )?;
536            }
537        }
538
539        // Compute panel grid dimensions
540        let ncol = match &built.facet {
541            Facet::Wrap { ncol, .. } => {
542                ncol.unwrap_or_else(|| (built.panels.len() as f64).sqrt().ceil() as usize)
543            }
544            Facet::Grid { .. } => built.panels.iter().map(|p| p.col).max().unwrap_or(0) + 1,
545            Facet::None => 1,
546        };
547        let mut nrow = built.panels.len().div_ceil(ncol);
548
549        // #412: a wrapped label (`FacetLabeller::Wrap`) takes several lines;
550        // the strip grows to the tallest one.
551        let strip_lines = |label: &dyn Fn(&crate::facet::Panel) -> Option<String>| {
552            built
553                .panels
554                .iter()
555                .filter_map(|p| label(p).map(|l| l.lines().count()))
556                .max()
557                .unwrap_or(1)
558                .max(1)
559        };
560        // #413: horizontal strips size to `strip_text_x`, turned ones to
561        // `strip_text_y` (each falling back to `strip_text`).
562        let size_x = theme
563            .strip_text_x
564            .as_ref()
565            .unwrap_or(&theme.strip_text)
566            .size;
567        let size_y = theme
568            .strip_text_y
569            .as_ref()
570            .unwrap_or(&theme.strip_text)
571            .size;
572        let thickness = |lines: usize, size: f64| size * (1.0 + 1.2 * (lines - 1) as f64) + 8.0;
573        // #412: `facet_grid` draws its column strips once (top, or bottom
574        // when switched) and its row strips once (right, or left); a
575        // `facet_wrap` panel carries its own strip on `strip_position`.
576        let is_grid = matches!(built.facet, Facet::Grid { .. });
577        let col_strip = |p: &crate::facet::Panel| {
578            if is_grid {
579                p.col_label.clone()
580            } else {
581                Some(p.label.clone())
582            }
583        };
584        let row_strip = |p: &crate::facet::Panel| {
585            if is_grid {
586                p.row_label.clone()
587            } else {
588                None
589            }
590        };
591        let row_strip_width = thickness(strip_lines(&row_strip), size_y);
592        let (wrap_side, switch) = match &built.facet {
593            Facet::Wrap { strip_position, .. } => {
594                (*strip_position, crate::facet::FacetSwitch::None)
595            }
596            Facet::Grid { switch, .. } => (crate::facet::StripPosition::Top, *switch),
597            Facet::None => (
598                crate::facet::StripPosition::Top,
599                crate::facet::FacetSwitch::None,
600            ),
601        };
602        // A wrap strip on the left / right is as thick as a top one is tall.
603        let wrap_vertical = matches!(
604            wrap_side,
605            crate::facet::StripPosition::Left | crate::facet::StripPosition::Right
606        );
607        // #412: axes on inner panels too (`facet_axes`, or a free scale
608        // under `facet_wrap`, as in ggplot2) widen the gaps to hold them.
609        let (free_x, free_y) = match &built.facet {
610            Facet::Wrap { scales, .. } => (
611                matches!(
612                    scales,
613                    crate::facet::FacetScales::FreeX | crate::facet::FacetScales::Free
614                ),
615                matches!(
616                    scales,
617                    crate::facet::FacetScales::FreeY | crate::facet::FacetScales::Free
618                ),
619            ),
620            _ => (false, false),
621        };
622        let all_x = built.facet_axes.all_x || free_x;
623        let all_y = built.facet_axes.all_y || free_y;
624        // A free scale's inner axes are always labelled (they differ).
625        let inner_labels_x = !built.facet_axes.margin_labels_x || free_x;
626        let inner_labels_y = !built.facet_axes.margin_labels_y || free_y;
627        let unlabelled = |x: bool| {
628            let mut t = theme.clone();
629            if x {
630                t.axis_text_x.visible = false;
631            } else {
632                t.axis_text_y.visible = false;
633            }
634            t
635        };
636        let theme_no_x_labels = unlabelled(true);
637        let theme_no_y_labels = unlabelled(false);
638        let gap_x = theme.get_panel_spacing_x()
639            + if all_y && built.panels.len() > 1 {
640                theme.axis_ticks_length
641                    + if inner_labels_y && theme.axis_text_y.visible {
642                        built
643                            .panel_scales
644                            .iter()
645                            .chain(std::iter::once(&built.scales))
646                            .filter_map(|s| s.get(&Aesthetic::Y))
647                            .map(|ys| axis::y_label_reserve(theme, &ys.breaks()))
648                            .fold(0.0_f64, f64::max)
649                    } else {
650                        0.0
651                    }
652            } else {
653                0.0
654            };
655        let gap_y = theme.get_panel_spacing_y()
656            + if all_x && built.panels.len() > 1 {
657                if inner_labels_x {
658                    super::layout::PlotLayout::x_axis_band(theme, None)
659                } else {
660                    theme.axis_ticks_length
661                }
662            } else {
663                0.0
664            };
665        let strip_height = thickness(
666            strip_lines(&col_strip),
667            if wrap_vertical { size_y } else { size_x },
668        );
669
670        // Figure-level layers (`GeomAttribution::figure`): one band under
671        // the whole grid, which shrinks by its height.
672        let is_figure = |li: usize| {
673            built.layers[li]
674                .geom
675                .figure_band_height(plot_area.width, theme)
676                .is_some()
677        };
678        let band_h: f64 = built
679            .layers
680            .iter()
681            .filter_map(|l| l.geom.figure_band_height(plot_area.width, theme))
682            .sum::<f64>()
683            .min(plot_area.height * 0.5);
684        let band_rect = crate::render::Rect {
685            x: plot_area.x,
686            y: plot_area.y + plot_area.height - band_h,
687            width: plot_area.width,
688            height: band_h,
689        };
690        let plot_area = crate::render::Rect {
691            height: plot_area.height - band_h,
692            ..plot_area
693        };
694
695        // `facet_wrap` under a fixed-aspect coord (`coord_sf` maps): equal
696        // panels shaped like the data, in the column count that makes them
697        // largest (or the given `ncol`), centred in the plot area — instead
698        // of `sqrt(n)` arbitrary cells each letterboxing its map.
699        let fixed_grid = match (&built.facet, built.coord.fixed_aspect()) {
700            (Facet::Wrap { ncol: want, .. }, Some(aspect)) => Some(crate::facet::wrap::fit_grid(
701                built.panels.len(),
702                *want,
703                aspect,
704                plot_area.width
705                    - if wrap_vertical {
706                        strip_height * want.unwrap_or(1) as f64
707                    } else {
708                        0.0
709                    },
710                plot_area.height,
711                if wrap_vertical { 0.0 } else { strip_height },
712                gap_x,
713                gap_y,
714            )),
715            _ => None,
716        };
717        let ncol = if let Some(g) = fixed_grid {
718            nrow = g.nrow;
719            g.ncol
720        } else {
721            ncol
722        };
723        // Each panel's (row, col): from its index under the fixed grid (the
724        // build-time row/col assumed the `sqrt(n)` default), else as built.
725        let cell: Vec<(usize, usize)> = built
726            .panels
727            .iter()
728            .enumerate()
729            .map(|(i, p)| {
730                if fixed_grid.is_some() {
731                    match &built.facet {
732                        Facet::Wrap {
733                            vertical, as_table, ..
734                        } => crate::facet::wrap::wrap_cell(
735                            i,
736                            built.panels.len(),
737                            ncol,
738                            *vertical,
739                            *as_table,
740                        ),
741                        _ => (i / ncol, i % ncol),
742                    }
743                } else {
744                    (p.row, p.col)
745                }
746            })
747            .collect();
748
749        // Each panel's strips (#412): (side, label, thickness). The bands a
750        // row / column reserves for them are the thickest strip it carries
751        // on each side.
752        use crate::facet::StripPosition as Side;
753        // #412: `strip.placement = "outside"` — a bottom / left strip of a
754        // panel that draws that axis sits beyond the axis (+ the pad).
755        let outside = theme.strip_placement_outside;
756        let x_axis_off =
757            super::layout::PlotLayout::x_axis_band(theme, None) + theme.strip_switch_pad;
758        let y_axis_off = theme.axis_ticks_length
759            + built
760                .panel_scales
761                .iter()
762                .chain(std::iter::once(&built.scales))
763                .filter_map(|s| s.get(&Aesthetic::Y))
764                .map(|ys| axis::y_label_reserve(theme, &ys.breaks()))
765                .fold(0.0_f64, f64::max)
766            + theme.strip_switch_pad;
767        let n_panels = built.panels.len();
768        let draws_x = |pi: usize, r: usize| r == nrow - 1 || pi + ncol >= n_panels || all_x;
769        let draws_y = |c: usize| c == 0 || all_y;
770        let off = |side: Side, pi: usize, r: usize, c: usize| match side {
771            Side::Bottom if outside && draws_x(pi, r) => x_axis_off,
772            Side::Left if outside && draws_y(c) => y_axis_off,
773            _ => 0.0,
774        };
775        let strips: Vec<Vec<(Side, String, f64)>> = built
776            .panels
777            .iter()
778            .enumerate()
779            .map(|(pi, p)| {
780                let (r, c) = cell[pi];
781                let mut v = Vec::new();
782                if is_grid {
783                    if let Some(l) = &p.col_label {
784                        if switch.x() && r == nrow - 1 {
785                            v.push((Side::Bottom, l.clone(), strip_height));
786                        } else if !switch.x() && r == 0 {
787                            v.push((Side::Top, l.clone(), strip_height));
788                        }
789                    }
790                    if let Some(l) = &p.row_label {
791                        if switch.y() && c == 0 {
792                            v.push((Side::Left, l.clone(), row_strip_width));
793                        } else if !switch.y() && c == ncol - 1 {
794                            v.push((Side::Right, l.clone(), row_strip_width));
795                        }
796                    }
797                } else {
798                    v.push((wrap_side, p.label.clone(), strip_height));
799                }
800                v
801            })
802            .collect();
803        let mut band_top = vec![0.0_f64; nrow.max(1)];
804        let mut band_bottom = vec![0.0_f64; nrow.max(1)];
805        let mut band_left = vec![0.0_f64; ncol.max(1)];
806        let mut band_right = vec![0.0_f64; ncol.max(1)];
807        for (pi, list) in strips.iter().enumerate() {
808            let (r, c) = cell[pi];
809            for (side, _, t) in list {
810                let o = off(*side, pi, r, c);
811                let b = match side {
812                    Side::Top => &mut band_top[r],
813                    Side::Bottom => &mut band_bottom[r],
814                    Side::Left => &mut band_left[c],
815                    Side::Right => &mut band_right[c],
816                };
817                *b = b.max(*t + o);
818            }
819        }
820        // Every row / column of a wrap keeps its strip band, even one whose
821        // cells are all empty slots (a top band per row, as before #412).
822        if !is_grid {
823            match wrap_side {
824                Side::Top => band_top.fill(strip_height),
825                Side::Bottom => band_bottom
826                    .iter_mut()
827                    .for_each(|b| *b = b.max(strip_height)),
828                Side::Left => band_left.iter_mut().for_each(|b| *b = b.max(strip_height)),
829                Side::Right => band_right.fill(strip_height),
830            }
831        }
832
833        // Proportional panel sizing (R's `space =`): free_x sizes columns to
834        // their data x-range, free_y sizes rows to their y-range. Fixed = equal.
835        let space = match &built.facet {
836            Facet::Grid { space, .. } => space.clone(),
837            // #412: a wrap sizes only along its single row / column.
838            Facet::Wrap { space, .. } if fixed_grid.is_none() => {
839                use crate::facet::FacetSpace as S;
840                match (space.free_x() && nrow == 1, space.free_y() && ncol == 1) {
841                    (true, true) => S::Free,
842                    (true, false) => S::FreeX,
843                    (false, true) => S::FreeY,
844                    _ => S::Fixed,
845                }
846            }
847            _ => crate::facet::FacetSpace::Fixed,
848        };
849        let extent = |pi: usize, col: &str| -> Option<f64> {
850            let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
851            for df in &built.panels_data[pi] {
852                if let Some(c) = df.column(col) {
853                    for v in c {
854                        if let Some(f) = v.as_f64() {
855                            lo = lo.min(f);
856                            hi = hi.max(f);
857                        }
858                    }
859                }
860            }
861            (lo <= hi).then_some(hi - lo)
862        };
863        let avail_w = plot_area.width
864            - gap_x * (ncol as f64 - 1.0)
865            - band_left.iter().sum::<f64>()
866            - band_right.iter().sum::<f64>();
867        let col_widths: Vec<f64> = if let Some(g) = fixed_grid {
868            vec![g.panel_w; ncol]
869        } else if space.free_x() && ncol > 0 {
870            let mut ranges = vec![0.0f64; ncol];
871            for (pi, panel) in built.panels.iter().enumerate() {
872                if let Some(r) = extent(pi, "x") {
873                    ranges[panel.col] = ranges[panel.col].max(r);
874                }
875            }
876            let total: f64 = ranges.iter().sum();
877            if total > 0.0 {
878                ranges.iter().map(|r| avail_w * (r / total)).collect()
879            } else {
880                vec![avail_w / ncol as f64; ncol]
881            }
882        } else {
883            vec![avail_w / ncol.max(1) as f64; ncol.max(1)]
884        };
885        let mut col_x = vec![plot_area.x; ncol.max(1)];
886        {
887            let used_w: f64 = col_widths.iter().sum::<f64>()
888                + gap_x * (ncol as f64 - 1.0)
889                + band_left.iter().sum::<f64>()
890                + band_right.iter().sum::<f64>();
891            let mut acc = if fixed_grid.is_some() {
892                plot_area.x + ((plot_area.width - used_w) / 2.0).max(0.0)
893            } else {
894                plot_area.x
895            };
896            for c in 0..ncol {
897                col_x[c] = acc + band_left[c];
898                acc += band_left[c] + col_widths[c] + band_right[c] + gap_x;
899            }
900        }
901
902        let avail_h = plot_area.height
903            - gap_y * (nrow as f64 - 1.0)
904            - band_top.iter().sum::<f64>()
905            - band_bottom.iter().sum::<f64>();
906        let row_heights: Vec<f64> = if let Some(g) = fixed_grid {
907            vec![g.panel_h; nrow]
908        } else if space.free_y() && nrow > 0 {
909            let mut ranges = vec![0.0f64; nrow];
910            for (pi, panel) in built.panels.iter().enumerate() {
911                if let Some(r) = extent(pi, "y") {
912                    ranges[panel.row] = ranges[panel.row].max(r);
913                }
914            }
915            let total: f64 = ranges.iter().sum();
916            if total > 0.0 {
917                ranges.iter().map(|r| avail_h * (r / total)).collect()
918            } else {
919                vec![avail_h / nrow as f64; nrow]
920            }
921        } else {
922            vec![avail_h / nrow.max(1) as f64; nrow.max(1)]
923        };
924        let mut row_y = vec![plot_area.y; nrow.max(1)];
925        {
926            let used_h: f64 = row_heights.iter().sum::<f64>()
927                + gap_y * (nrow as f64 - 1.0)
928                + band_top.iter().sum::<f64>()
929                + band_bottom.iter().sum::<f64>();
930            let mut acc = if fixed_grid.is_some() {
931                plot_area.y + ((plot_area.height - used_h) / 2.0).max(0.0)
932            } else {
933                plot_area.y
934            };
935            for r in 0..nrow {
936                // `row_y` is the panel's own top edge.
937                row_y[r] = acc + band_top[r];
938                acc += band_top[r] + row_heights[r] + band_bottom[r] + gap_y;
939            }
940        }
941
942        // #348: one x / y axis title for the whole grid (ggplot2), drawn
943        // after the panels over the union of the panels carrying that axis.
944        // The y title also needs the widest of those panels' tick labels.
945        let mut x_title_area: Option<super::Rect> = None;
946        let mut y_title_area: Option<(super::Rect, f64)> = None;
947        let union = |a: &Option<super::Rect>, b: &super::Rect| match a {
948            None => b.clone(),
949            Some(a) => {
950                let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
951                let x1 = (a.x + a.width).max(b.x + b.width);
952                let y1 = (a.y + a.height).max(b.y + b.height);
953                super::Rect {
954                    x: x0,
955                    y: y0,
956                    width: x1 - x0,
957                    height: y1 - y0,
958                }
959            }
960        };
961
962        for (pi, panel) in built.panels.iter().enumerate() {
963            let (prow, pcol) = cell[pi];
964            let panel_width = col_widths[pcol];
965            let panel_height = row_heights[prow];
966            let px = col_x[pcol];
967            let py = row_y[prow];
968
969            let panel_rect = crate::render::Rect {
970                x: px,
971                y: py,
972                width: panel_width,
973                height: panel_height,
974            };
975            // #273: fresh panel-corner claim registry per facet panel —
976            // see `render_single`'s identical reset. A faceted plot's
977            // inside legend (if any) is drawn once globally, not per
978            // panel, so it isn't pre-claimed here the way `render_single`
979            // pre-claims it — map furniture geoms under faceting still
980            // stack clear of each other within one panel, just not of a
981            // faceted inside legend.
982            backend.clear_corner_claims();
983
984            // Strips (#412): each on its side of the panel, the side ones
985            // with their text turned to read along them.
986            // An inside strip pushes its axis out; an outside one sits
987            // beyond the axis and only moves the axis title.
988            let mut bottom_strip = 0.0_f64;
989            let mut left_strip = 0.0_f64;
990            let mut bottom_title = 0.0_f64;
991            let mut left_title = 0.0_f64;
992            for (side, label, t) in &strips[pi] {
993                let o = off(*side, pi, prow, pcol);
994                let (rect, angle) = match side {
995                    Side::Top => ((px, py - t, panel_width, *t), 0.0),
996                    Side::Bottom => {
997                        if o > 0.0 {
998                            bottom_title = *t + o;
999                        } else {
1000                            bottom_strip = *t;
1001                        }
1002                        ((px, py + panel_height + o, panel_width, *t), 0.0)
1003                    }
1004                    Side::Left => {
1005                        if o > 0.0 {
1006                            left_title = *t + o;
1007                        } else {
1008                            left_strip = *t;
1009                        }
1010                        ((px - t - o, py, *t, panel_height), 270.0)
1011                    }
1012                    Side::Right => ((px + panel_width, py, *t, panel_height), 90.0),
1013                };
1014                draw_strip(backend, theme, rect, label, angle)?;
1015            }
1016
1017            // Panel background
1018            if theme.panel_background.visible {
1019                if let Some(fill) = theme.panel_background.fill {
1020                    backend.set_element_class(Some("gg-panel-background"));
1021                    backend.draw_rect(
1022                        (panel_rect.x, panel_rect.y),
1023                        (
1024                            panel_rect.x + panel_rect.width,
1025                            panel_rect.y + panel_rect.height,
1026                        ),
1027                        &RectStyle {
1028                            fill: Some(fill),
1029                            stroke: theme.panel_background.color,
1030                            stroke_width: theme.panel_background.width,
1031                            alpha: 1.0,
1032                            clip: false,
1033                            corner_radius: 0.0,
1034                        },
1035                    )?;
1036                }
1037            }
1038
1039            // Panel border
1040            if theme.panel_border.visible {
1041                let style = LineStyle {
1042                    color: theme.panel_border.color,
1043                    width: theme.panel_border.width,
1044                    alpha: 1.0,
1045                    linetype: Linetype::Solid,
1046                    ..Default::default()
1047                };
1048                let x0 = panel_rect.x;
1049                let y0 = panel_rect.y;
1050                let x1 = panel_rect.x + panel_rect.width;
1051                let y1 = panel_rect.y + panel_rect.height;
1052                backend.set_element_class(Some("gg-panel-border"));
1053                backend.draw_line(&[(x0, y0), (x1, y0)], &style)?;
1054                backend.draw_line(&[(x1, y0), (x1, y1)], &style)?;
1055                backend.draw_line(&[(x1, y1), (x0, y1)], &style)?;
1056                backend.draw_line(&[(x0, y1), (x0, y0)], &style)?;
1057            }
1058
1059            // Use per-panel scales if free facets, otherwise global scales
1060            let panel_scale_set = if pi < built.panel_scales.len() {
1061                &built.panel_scales[pi]
1062            } else {
1063                &built.scales
1064            };
1065
1066            // Gridlines + axes for edge panels
1067            let x_scale = panel_scale_set.get(&Aesthetic::X);
1068            let y_scale = panel_scale_set.get(&Aesthetic::Y);
1069
1070            if let (true, Some(xs), Some(ys)) = (
1071                built.coord.is_polar() && !built.coord.is_radar(),
1072                x_scale,
1073                y_scale,
1074            ) {
1075                // Polar facets (#411): each panel its own rings / spokes.
1076                crate::guide::polar::draw_polar_guides(
1077                    xs,
1078                    ys,
1079                    built.coord.as_ref(),
1080                    theme,
1081                    &panel_rect,
1082                    backend,
1083                )?;
1084            } else if let (Some(xs), Some(ys)) = (x_scale, y_scale) {
1085                if built.coord.gridlines() {
1086                    axis::draw_gridlines(
1087                        xs,
1088                        ys,
1089                        built.coord.as_ref(),
1090                        theme,
1091                        &panel_rect,
1092                        backend,
1093                    )?;
1094                }
1095
1096                // Bottom row gets x axis
1097                // A strip below / left of the panel sits between it and its
1098                // axis (ggplot2's `strip.placement = "inside"`, #412).
1099                let bottom_edge = prow == nrow - 1 || pi + ncol >= built.panels.len();
1100                if bottom_edge || all_x {
1101                    let axis_rect = crate::render::Rect {
1102                        height: panel_rect.height + bottom_strip,
1103                        ..panel_rect.clone()
1104                    };
1105                    let t = if bottom_edge || inner_labels_x {
1106                        theme
1107                    } else {
1108                        &theme_no_x_labels
1109                    };
1110                    axis::draw_x_axis_with(
1111                        xs,
1112                        built.coord.as_ref(),
1113                        t,
1114                        &axis_rect,
1115                        &total_area,
1116                        false,
1117                        backend,
1118                    )?;
1119                    if bottom_edge {
1120                        let title_rect = crate::render::Rect {
1121                            height: axis_rect.height + bottom_title,
1122                            ..axis_rect.clone()
1123                        };
1124                        x_title_area = Some(union(&x_title_area, &title_rect));
1125                    }
1126                }
1127
1128                // Left column gets y axis (every column with `all_y`).
1129                if pcol == 0 || all_y {
1130                    let axis_rect = crate::render::Rect {
1131                        x: panel_rect.x - left_strip,
1132                        width: panel_rect.width + left_strip,
1133                        ..panel_rect.clone()
1134                    };
1135                    let t = if pcol == 0 || inner_labels_y {
1136                        theme
1137                    } else {
1138                        &theme_no_y_labels
1139                    };
1140                    axis::draw_y_axis_with(
1141                        ys,
1142                        built.coord.as_ref(),
1143                        t,
1144                        &axis_rect,
1145                        false,
1146                        backend,
1147                    )?;
1148                    if pcol == 0 {
1149                        let reserve = axis::y_label_reserve(theme, &ys.breaks());
1150                        let prev = y_title_area.as_ref().map(|(r, _)| r.clone());
1151                        let widest = y_title_area.as_ref().map_or(0.0, |(_, w)| *w);
1152                        let title_rect = crate::render::Rect {
1153                            x: axis_rect.x - left_title,
1154                            width: axis_rect.width + left_title,
1155                            ..axis_rect.clone()
1156                        };
1157                        y_title_area = Some((union(&prev, &title_rect), widest.max(reserve)));
1158                    }
1159                }
1160            }
1161
1162            // Draw layers for this panel — under a fixed-aspect wrap (maps)
1163            // in their own clipped group, so one panel's features never
1164            // spill into the next.
1165            if fixed_grid.is_some() {
1166                let data = built
1167                    .coord
1168                    .data_rect(&panel_rect)
1169                    .unwrap_or_else(|| panel_rect.clone());
1170                backend.begin_panel(pi, &panel.label, &panel_rect, &data);
1171            }
1172            // #411: as for a single panel — clip this panel's layers when
1173            // they reach past it (a fixed-aspect grid's panel group already
1174            // clips), or let them draw past it with `clip = "off"`.
1175            backend.set_element_class(None);
1176            let clip_group = built.clip
1177                && fixed_grid.is_none()
1178                && built.panels_data.get(pi).is_some_and(|layers| {
1179                    layers.iter().any(|d| {
1180                        layer_spills(d, panel_scale_set, built.coord.as_ref(), &panel_rect)
1181                    })
1182                });
1183            if clip_group {
1184                backend.begin_clip(&panel_rect);
1185            }
1186            if !built.clip {
1187                backend.set_clipping(false);
1188            }
1189            if pi < built.panels_data.len() {
1190                for (li, layer_data) in built.panels_data[pi].iter().enumerate() {
1191                    if li < built.layers.len() && layer_data.nrows() > 0 && !is_figure(li) {
1192                        let mut panel_backend = PanelBackendAdapter {
1193                            inner: backend,
1194                            panel_rect: panel_rect.clone(),
1195                        };
1196                        let geom = &built.layers[li].geom;
1197                        panel_backend.set_mark_geom(Some(geom.name()));
1198                        let drawn = geom.draw(
1199                            layer_data,
1200                            built.coord.as_ref(),
1201                            panel_scale_set,
1202                            theme,
1203                            &mut panel_backend,
1204                        );
1205                        panel_backend.set_mark_geom(None);
1206                        drawn?;
1207                    }
1208                }
1209            }
1210            if !built.clip {
1211                backend.set_clipping(true);
1212            }
1213            if clip_group {
1214                backend.end_clip();
1215            }
1216            if fixed_grid.is_some() {
1217                backend.end_panel();
1218            }
1219        }
1220
1221        // The grid's single axis titles (#348), against the first panel's
1222        // scales (the titles are the same on every panel).
1223        let title_scales = built.panel_scales.first().unwrap_or(&built.scales);
1224        if let (Some(area), Some(xs)) = (&x_title_area, title_scales.get(&Aesthetic::X)) {
1225            axis::draw_x_axis_title(xs, theme, area, backend)?;
1226        }
1227        if let (Some((area, reserve)), Some(ys)) = (&y_title_area, title_scales.get(&Aesthetic::Y))
1228        {
1229            axis::draw_y_axis_title(ys, theme, area, *reserve, backend)?;
1230        }
1231
1232        // Figure-level layers, once, in the band under the grid.
1233        if band_h > 0.0 {
1234            backend.clear_corner_claims();
1235            for li in (0..built.layers.len()).filter(|&li| is_figure(li)) {
1236                let empty = crate::data::DataFrame::new();
1237                let data = built
1238                    .panels_data
1239                    .iter()
1240                    .filter_map(|p| p.get(li))
1241                    .find(|d| d.nrows() > 0)
1242                    .unwrap_or(&empty);
1243                let mut band_backend = PanelBackendAdapter {
1244                    inner: backend,
1245                    panel_rect: band_rect.clone(),
1246                };
1247                let geom = &built.layers[li].geom;
1248                band_backend.set_mark_geom(Some(geom.name()));
1249                let drawn = geom.draw(
1250                    data,
1251                    built.coord.as_ref(),
1252                    &built.scales,
1253                    theme,
1254                    &mut band_backend,
1255                );
1256                band_backend.set_mark_geom(None);
1257                drawn?;
1258            }
1259        }
1260
1261        // Draw title
1262        let title_ref = Self::title_ref_rect(theme, &plot_area, &total_area);
1263        let caption_ref = Self::ref_rect(
1264            theme.caption_position.unwrap_or(theme.title_position),
1265            theme,
1266            &plot_area,
1267            &total_area,
1268        );
1269        // #233: see render_single's identical lift — a Top legend must not
1270        // overlap the title/subtitle (zero when there's no Top legend).
1271        let legend_lift = legend::top_legend_lift(
1272            theme,
1273            &built.scales,
1274            &built.suppressed_aes,
1275            &built.legend_plans,
1276            &built.guide_legend,
1277        );
1278        if let Some(ref title) = built.labels.title {
1279            let (tx, anchor) = Self::hjust_pos(&theme.title, &title_ref);
1280            let (title_y, _) = Self::title_subtitle_y(
1281                theme,
1282                plot_area.y,
1283                legend_lift,
1284                built.labels.subtitle.is_some(),
1285            );
1286            let family = if theme.title.family.is_empty() {
1287                None
1288            } else {
1289                Some(theme.title.family.clone())
1290            };
1291            backend.set_element_class(Some("gg-title"));
1292            crate::render::rich::draw(
1293                backend,
1294                theme.rich_text,
1295                title,
1296                (tx, title_y),
1297                &TextStyle {
1298                    color: theme.title.color,
1299                    size: theme.title.size,
1300                    anchor,
1301                    angle: 0.0,
1302                    family,
1303                    face: theme.title.face,
1304                    bg_colour: None,
1305                    bg_r: 0.0,
1306                },
1307            )?;
1308        }
1309
1310        // Draw subtitle
1311        if let Some(ref subtitle) = built.labels.subtitle {
1312            let (sx, anchor) = Self::hjust_pos(&theme.subtitle, &title_ref);
1313            let (_, subtitle_y) = Self::title_subtitle_y(theme, plot_area.y, legend_lift, true);
1314            let family = if theme.subtitle.family.is_empty() {
1315                None
1316            } else {
1317                Some(theme.subtitle.family.clone())
1318            };
1319            backend.set_element_class(Some("gg-subtitle"));
1320            crate::render::rich::draw(
1321                backend,
1322                theme.rich_text,
1323                subtitle,
1324                (sx, subtitle_y),
1325                &TextStyle {
1326                    color: theme.subtitle.color,
1327                    size: theme.subtitle.size,
1328                    anchor,
1329                    angle: 0.0,
1330                    family,
1331                    face: theme.subtitle.face,
1332                    bg_colour: None,
1333                    bg_r: 0.0,
1334                },
1335            )?;
1336        }
1337
1338        // Draw caption
1339        if let Some(ref caption) = built.labels.caption {
1340            let (cx, anchor) = Self::hjust_pos(&theme.caption, &caption_ref);
1341            // Inside the layout's `caption_area`: the bottom of the
1342            // canvas less `plot_margin.bottom` (#286).
1343            let caption_y = total_area.y + total_area.height
1344                - theme.plot_margin.bottom.max(0.0)
1345                - theme.caption.size * 0.5;
1346            let family = if theme.caption.family.is_empty() {
1347                None
1348            } else {
1349                Some(theme.caption.family.clone())
1350            };
1351            backend.set_element_class(Some("gg-caption"));
1352            crate::render::rich::draw(
1353                backend,
1354                theme.rich_text,
1355                caption,
1356                (cx, caption_y),
1357                &TextStyle {
1358                    color: theme.caption.color,
1359                    size: theme.caption.size,
1360                    anchor,
1361                    angle: 0.0,
1362                    family,
1363                    face: theme.caption.face,
1364                    bg_colour: None,
1365                    bg_r: 0.0,
1366                },
1367            )?;
1368        }
1369
1370        backend.set_element_class(None);
1371        // Draw annotations
1372        Self::draw_annotations(
1373            &built.annotations,
1374            &built.scales,
1375            built.coord.as_ref(),
1376            &plot_area,
1377            theme,
1378            backend,
1379        )?;
1380
1381        // Draw legend
1382        legend::draw_legend_planned(
1383            &built.scales,
1384            theme,
1385            &plot_area,
1386            plot_area.width,
1387            backend,
1388            &built.guide_legend,
1389            &built.suppressed_aes,
1390            &built.legend_plans,
1391        )?;
1392
1393        // Draw corner tag (labs(tag = ...))
1394        // #413: `plot.tag.location = "panel"` puts the tag in the panel's
1395        // corner instead of the plot's.
1396        let tag_area = if theme.tag_location_panel {
1397            &plot_area
1398        } else {
1399            &total_area
1400        };
1401        Self::draw_tag(&built.labels, theme, tag_area, backend)?;
1402
1403        Ok(())
1404    }
1405
1406    /// `annotate_text`/`annotate_segment` under theme ink/paper (#157,
1407    /// follow-up to #142): `Annotation`'s fields are all `pub` with no
1408    /// `#[non_exhaustive]`, so it can't gain an "explicitly styled" flag
1409    /// without a semver break — the same `value == hard-coded default`
1410    /// gate `apply_element_geom`'s other callers already use (e.g.
1411    /// `geom/refline.rs`'s `(50, 50, 50)` → `ink`) is applied here instead.
1412    /// Default theme (`geom.ink` unset) is byte-identical.
1413    ///
1414    /// `annotate_rect`'s grey fill is deliberately excluded: checked
1415    /// against ggplot2 4.0.3 (`theme(geom = element_geom(ink = "white",
1416    /// paper = "black"))`), `annotate("text")`/`annotate("segment")`
1417    /// recolour to `ink` but `annotate("rect")`'s fill stays the same
1418    /// hard-coded grey (`#A6A6A6`) regardless — ggplot2 itself doesn't
1419    /// route a rect annotation's fill through ink/paper.
1420    fn draw_annotations(
1421        annotations: &[Annotation],
1422        scales: &crate::scale::ScaleSet,
1423        coord: &dyn crate::coord::Coord,
1424        plot_area: &crate::render::Rect,
1425        theme: &crate::theme::Theme,
1426        backend: &mut dyn DrawBackend,
1427    ) -> Result<(), RenderError> {
1428        use crate::data::Value;
1429
1430        let x_scale = scales.get(&Aesthetic::X);
1431        let y_scale = scales.get(&Aesthetic::Y);
1432
1433        for ann in annotations {
1434            match ann {
1435                Annotation::Text {
1436                    label,
1437                    x,
1438                    y,
1439                    size,
1440                    color,
1441                } => {
1442                    let nx = x_scale.map(|s| s.map(&Value::Float(*x))).unwrap_or(0.0);
1443                    let ny = y_scale.map(|s| s.map(&Value::Float(*y))).unwrap_or(0.0);
1444                    let pos = coord.transform((nx, ny), plot_area);
1445                    backend.draw_text(
1446                        label,
1447                        pos,
1448                        &TextStyle {
1449                            color: annotation_ink(*color, theme),
1450                            size: *size,
1451                            anchor: TextAnchor::Middle,
1452                            angle: 0.0,
1453                            family: crate::geom::resolve_font_family("", theme),
1454                            face: crate::render::backend::FontFace::Plain,
1455                            bg_colour: None,
1456                            bg_r: 0.0,
1457                        },
1458                    )?;
1459                }
1460                Annotation::Rect {
1461                    xmin,
1462                    xmax,
1463                    ymin,
1464                    ymax,
1465                    fill,
1466                    alpha,
1467                } => {
1468                    let nx0 = x_scale.map(|s| s.map(&Value::Float(*xmin))).unwrap_or(0.0);
1469                    let nx1 = x_scale.map(|s| s.map(&Value::Float(*xmax))).unwrap_or(1.0);
1470                    let ny0 = y_scale.map(|s| s.map(&Value::Float(*ymin))).unwrap_or(0.0);
1471                    let ny1 = y_scale.map(|s| s.map(&Value::Float(*ymax))).unwrap_or(1.0);
1472                    let tl = coord.transform((nx0, ny1), plot_area);
1473                    let br = coord.transform((nx1, ny0), plot_area);
1474                    backend.draw_rect(
1475                        tl,
1476                        br,
1477                        &RectStyle {
1478                            fill: Some(*fill),
1479                            stroke: None,
1480                            stroke_width: 0.0,
1481                            alpha: *alpha,
1482                            clip: false,
1483                            corner_radius: 0.0,
1484                        },
1485                    )?;
1486                }
1487                Annotation::Segment {
1488                    x,
1489                    y,
1490                    xend,
1491                    yend,
1492                    color,
1493                    width,
1494                } => {
1495                    let nx0 = x_scale.map(|s| s.map(&Value::Float(*x))).unwrap_or(0.0);
1496                    let ny0 = y_scale.map(|s| s.map(&Value::Float(*y))).unwrap_or(0.0);
1497                    let nx1 = x_scale.map(|s| s.map(&Value::Float(*xend))).unwrap_or(1.0);
1498                    let ny1 = y_scale.map(|s| s.map(&Value::Float(*yend))).unwrap_or(1.0);
1499                    let p0 = coord.transform((nx0, ny0), plot_area);
1500                    let p1 = coord.transform((nx1, ny1), plot_area);
1501                    backend.draw_line(
1502                        &[p0, p1],
1503                        &LineStyle {
1504                            color: annotation_ink(*color, theme),
1505                            alpha: 1.0,
1506                            width: *width,
1507                            linetype: Linetype::Solid,
1508                            ..Default::default()
1509                        },
1510                    )?;
1511                }
1512                Annotation::LogTicks {
1513                    sides,
1514                    outside,
1515                    lengths,
1516                    color,
1517                    width,
1518                } => {
1519                    let style = LineStyle {
1520                        color: annotation_ink(*color, theme),
1521                        alpha: 1.0,
1522                        width: *width,
1523                        linetype: Linetype::Solid,
1524                        ..Default::default()
1525                    };
1526                    let dir = if *outside { -1.0 } else { 1.0 };
1527                    for side in sides.chars() {
1528                        let horizontal = matches!(side, 'b' | 't');
1529                        let scale = if horizontal { x_scale } else { y_scale };
1530                        let Some((lo, hi)) = scale.and_then(|s| s.expanded_domain()) else {
1531                            continue;
1532                        };
1533                        for (t, kind) in log_ticks(lo, hi) {
1534                            let n = scale.map(|s| s.map(&Value::Float(t))).unwrap_or(0.0);
1535                            let len = dir
1536                                * match kind {
1537                                    0 => lengths.2,
1538                                    1 => lengths.1,
1539                                    _ => lengths.0,
1540                                };
1541                            // The tick's anchor on the panel edge, then inward.
1542                            let (edge, step) = match side {
1543                                'b' => ((n, 0.0), (0.0, -len)),
1544                                't' => ((n, 1.0), (0.0, len)),
1545                                'l' => ((0.0, n), (len, 0.0)),
1546                                'r' => ((1.0, n), (-len, 0.0)),
1547                                _ => continue,
1548                            };
1549                            let p0 = coord.transform(edge, plot_area);
1550                            let p1 = (p0.0 + step.0, p0.1 + step.1);
1551                            backend.draw_line(&[p0, p1], &style)?;
1552                        }
1553                    }
1554                }
1555                Annotation::Raster {
1556                    image,
1557                    xmin,
1558                    xmax,
1559                    ymin,
1560                    ymax,
1561                    alpha,
1562                } => {
1563                    let rect = data_rect(
1564                        x_scale,
1565                        y_scale,
1566                        coord,
1567                        plot_area,
1568                        [*xmin, *xmax, *ymin, *ymax],
1569                    );
1570                    backend.draw_image(image, &rect, plot_area, *alpha)?;
1571                }
1572                Annotation::Inset {
1573                    plot,
1574                    xmin,
1575                    xmax,
1576                    ymin,
1577                    ymax,
1578                } => {
1579                    let rect = data_rect(
1580                        x_scale,
1581                        y_scale,
1582                        coord,
1583                        plot_area,
1584                        [*xmin, *xmax, *ymin, *ymax],
1585                    );
1586                    let (w, h) = (rect.width.round().max(1.0), rect.height.round().max(1.0));
1587                    let mut guard = plot.0.lock().unwrap_or_else(|e| e.into_inner());
1588                    match &mut *guard {
1589                        Ok((built, meta)) => {
1590                            let layout =
1591                                crate::GGPlot::layout_built(built, meta, w as u32, h as u32);
1592                            let mut off = crate::render::offset::OffsetBackend::new(
1593                                backend,
1594                                rect.x,
1595                                rect.y,
1596                                layout.plot_area.clone(),
1597                                layout.total.clone(),
1598                            );
1599                            Self::render(built, &mut off)?;
1600                        }
1601                        Err(e) => {
1602                            backend.warn(format!("annotation_custom: inset plot failed: {e}"))
1603                        }
1604                    }
1605                }
1606            }
1607        }
1608        Ok(())
1609    }
1610}
1611
1612/// Log-10 tick positions inside `[lo, hi]` (log10 units): `(position, kind)`
1613/// with kind 0 = a power of ten (long), 1 = 5× (mid), 2 = the rest (short).
1614/// One facet strip: its background over `(x, y, w, h)` and its label lines
1615/// centred in it, turned by `angle` (90 = reads top to bottom, a right-hand
1616/// row strip; 270 = bottom to top, a left one) (#412).
1617fn draw_strip(
1618    backend: &mut dyn DrawBackend,
1619    theme: &crate::theme::Theme,
1620    (x, y, w, h): (f64, f64, f64, f64),
1621    label: &str,
1622    angle: f64,
1623) -> Result<(), RenderError> {
1624    // #413: a horizontal strip uses `strip_*_x`, a turned one `strip_*_y`.
1625    let horizontal = angle == 0.0;
1626    let (bg, text) = if horizontal {
1627        (&theme.strip_background_x, &theme.strip_text_x)
1628    } else {
1629        (&theme.strip_background_y, &theme.strip_text_y)
1630    };
1631    let bg = bg.as_ref().unwrap_or(&theme.strip_background);
1632    let text = text.as_ref().unwrap_or(&theme.strip_text);
1633    if bg.visible {
1634        backend.set_element_class(Some("gg-strip gg-strip-background"));
1635        backend.draw_rect(
1636            (x, y),
1637            (x + w, y + h),
1638            &RectStyle {
1639                fill: bg.fill,
1640                stroke: bg.color,
1641                stroke_width: bg.width,
1642                alpha: 1.0,
1643                clip: false,
1644                corner_radius: 0.0,
1645            },
1646        )?;
1647    }
1648    if !text.visible {
1649        return Ok(());
1650    }
1651    let family = if text.family.is_empty() {
1652        None
1653    } else {
1654        Some(text.family.clone())
1655    };
1656    let lines: Vec<&str> = label.lines().collect();
1657    let n = lines.len().max(1) as f64;
1658    for (k, line) in lines.iter().enumerate() {
1659        let d = (k as f64 - (n - 1.0) / 2.0) * text.size * 1.2;
1660        // Successive lines stack downwards when horizontal, and away from
1661        // the panel's reading direction when turned.
1662        let (dx, dy) = if angle == 90.0 {
1663            (-d, 0.0)
1664        } else if angle == 270.0 {
1665            (d, 0.0)
1666        } else {
1667            (0.0, d)
1668        };
1669        backend.set_element_class(Some("gg-strip gg-strip-text"));
1670        crate::render::rich::draw(
1671            backend,
1672            theme.rich_text,
1673            line,
1674            (x + w / 2.0 + dx, y + h / 2.0 + dy),
1675            &TextStyle {
1676                color: text.color,
1677                size: text.size,
1678                anchor: TextAnchor::Middle,
1679                angle,
1680                family: family.clone(),
1681                face: text.face,
1682                bg_colour: None,
1683                bg_r: 0.0,
1684            },
1685        )?;
1686    }
1687    Ok(())
1688}
1689
1690fn log_ticks(lo: f64, hi: f64) -> Vec<(f64, u8)> {
1691    let mut out = Vec::new();
1692    if !(lo.is_finite() && hi.is_finite()) || hi - lo > 300.0 {
1693        return out;
1694    }
1695    for k in (lo.floor() as i64)..=(hi.ceil() as i64) {
1696        for m in 1..=9 {
1697            let t = k as f64 + (m as f64).log10();
1698            if t >= lo - 1e-9 && t <= hi + 1e-9 {
1699                out.push((
1700                    t,
1701                    if m == 1 {
1702                        0
1703                    } else if m == 5 {
1704                        1
1705                    } else {
1706                        2
1707                    },
1708                ));
1709            }
1710        }
1711    }
1712    out
1713}
1714
1715/// The pixel rectangle of a data-space `[xmin, xmax, ymin, ymax]`
1716/// (non-finite bounds = the panel edges, as in ggplot2).
1717fn data_rect(
1718    x_scale: Option<&dyn crate::scale::Scale>,
1719    y_scale: Option<&dyn crate::scale::Scale>,
1720    coord: &dyn crate::coord::Coord,
1721    plot_area: &crate::render::Rect,
1722    [xmin, xmax, ymin, ymax]: [f64; 4],
1723) -> crate::render::Rect {
1724    use crate::data::Value;
1725    let m = |s: Option<&dyn crate::scale::Scale>, v: f64, edge: f64| {
1726        if v.is_finite() {
1727            s.map(|s| s.map(&Value::Float(v))).unwrap_or(edge)
1728        } else if v > 0.0 {
1729            1.0
1730        } else {
1731            0.0
1732        }
1733    };
1734    let a = coord.transform((m(x_scale, xmin, 0.0), m(y_scale, ymax, 1.0)), plot_area);
1735    let b = coord.transform((m(x_scale, xmax, 1.0), m(y_scale, ymin, 0.0)), plot_area);
1736    crate::render::Rect {
1737        x: a.0.min(b.0),
1738        y: a.1.min(b.1),
1739        width: (a.0 - b.0).abs(),
1740        height: (a.1 - b.1).abs(),
1741    }
1742}
1743
1744/// `color`, recoloured from `theme.geom.ink` when it's still at
1745/// `annotate_text`/`annotate_segment`'s hard-coded default (`(50, 50,
1746/// 50)`) and the theme actually sets `ink` — see the doc comment on
1747/// [`PlotRenderer::draw_annotations`]. `None`/an explicitly-styled colour
1748/// (anything else) passes through unchanged.
1749fn annotation_ink(color: (u8, u8, u8), theme: &crate::theme::Theme) -> (u8, u8, u8) {
1750    match (color == (50, 50, 50), theme.geom.ink) {
1751        (true, Some(ink)) => ink,
1752        _ => color,
1753    }
1754}
1755
1756/// Does a layer's data reach past `area` (#411)? Its position columns
1757/// (`x`, `xmin`, …, `yend`), mapped and transformed like the geoms do,
1758/// tested against the panel with half a pixel of slack. A polar coord
1759/// never spills (its scales fill the circle).
1760fn layer_spills(
1761    data: &crate::data::DataFrame,
1762    scales: &crate::scale::ScaleSet,
1763    coord: &dyn crate::coord::Coord,
1764    area: &crate::render::Rect,
1765) -> bool {
1766    if coord.is_polar() || data.nrows() == 0 {
1767        return false;
1768    }
1769    const SLACK: f64 = 0.5;
1770    let outside = |(px, py): (f64, f64)| {
1771        px < area.x - SLACK
1772            || px > area.x + area.width + SLACK
1773            || py < area.y - SLACK
1774            || py > area.y + area.height + SLACK
1775    };
1776    let axes = [
1777        (Aesthetic::X, ["x", "xmin", "xmax", "xend"], true),
1778        (Aesthetic::Y, ["y", "ymin", "ymax", "yend"], false),
1779    ];
1780    for (aes, cols, is_x) in axes {
1781        let Some(scale) = scales.get(&aes) else {
1782            continue;
1783        };
1784        for col in cols {
1785            let Some(values) = data.column(col) else {
1786                continue;
1787            };
1788            for v in values {
1789                if !v.as_f64().is_some_and(f64::is_finite) {
1790                    continue;
1791                }
1792                let n = scale.map(v);
1793                if !n.is_finite() {
1794                    continue;
1795                }
1796                let p = if is_x { (n, 0.5) } else { (0.5, n) };
1797                if outside(coord.transform(p, area)) {
1798                    return true;
1799                }
1800            }
1801        }
1802    }
1803    false
1804}
1805
1806/// Wrapper that overrides plot_area() to return the panel rect.
1807struct PanelBackendAdapter<'a> {
1808    inner: &'a mut dyn DrawBackend,
1809    panel_rect: crate::render::Rect,
1810}
1811
1812impl<'a> DrawBackend for PanelBackendAdapter<'a> {
1813    fn set_clipping(&mut self, on: bool) {
1814        self.inner.set_clipping(on)
1815    }
1816    fn begin_clip(&mut self, rect: &crate::render::Rect) {
1817        self.inner.begin_clip(rect)
1818    }
1819    fn enable_element_classes(&mut self, on: bool) {
1820        self.inner.enable_element_classes(on)
1821    }
1822    fn draw_rich_text(
1823        &mut self,
1824        runs: &[crate::render::rich::RichRun],
1825        pos: (f64, f64),
1826        style: &crate::render::backend::TextStyle,
1827    ) -> Result<(), RenderError> {
1828        self.inner.draw_rich_text(runs, pos, style)
1829    }
1830    fn set_element_class(&mut self, class: Option<&'static str>) {
1831        self.inner.set_element_class(class)
1832    }
1833    fn end_clip(&mut self) {
1834        self.inner.end_clip()
1835    }
1836    fn draw_image(
1837        &mut self,
1838        bytes: &[u8],
1839        rect: &crate::render::Rect,
1840        clip: &crate::render::Rect,
1841        alpha: f64,
1842    ) -> Result<(), RenderError> {
1843        self.inner.draw_image(bytes, rect, clip, alpha)
1844    }
1845    fn draw_circle(
1846        &mut self,
1847        center: (f64, f64),
1848        radius: f64,
1849        style: &crate::render::backend::PointStyle,
1850    ) -> Result<(), RenderError> {
1851        self.inner.draw_circle(center, radius, style)
1852    }
1853    fn draw_line(
1854        &mut self,
1855        points: &[(f64, f64)],
1856        style: &crate::render::backend::LineStyle,
1857    ) -> Result<(), RenderError> {
1858        self.inner.draw_line(points, style)
1859    }
1860    fn draw_quadratic(
1861        &mut self,
1862        p0: (f64, f64),
1863        ctrl: (f64, f64),
1864        p1: (f64, f64),
1865        style: &crate::render::backend::LineStyle,
1866    ) -> Result<(), RenderError> {
1867        self.inner.draw_quadratic(p0, ctrl, p1, style)
1868    }
1869    fn draw_rect(
1870        &mut self,
1871        top_left: (f64, f64),
1872        bottom_right: (f64, f64),
1873        style: &RectStyle,
1874    ) -> Result<(), RenderError> {
1875        self.inner.draw_rect(top_left, bottom_right, style)
1876    }
1877    fn draw_text(
1878        &mut self,
1879        text: &str,
1880        pos: (f64, f64),
1881        style: &TextStyle,
1882    ) -> Result<(), RenderError> {
1883        self.inner.draw_text(text, pos, style)
1884    }
1885    fn draw_polygon(
1886        &mut self,
1887        points: &[(f64, f64)],
1888        style: &RectStyle,
1889    ) -> Result<(), RenderError> {
1890        self.inner.draw_polygon(points, style)
1891    }
1892    fn draw_polygon_rings(
1893        &mut self,
1894        rings: &[Vec<(f64, f64)>],
1895        style: &RectStyle,
1896    ) -> Result<(), RenderError> {
1897        self.inner.draw_polygon_rings(rings, style)
1898    }
1899    fn draw_shape(
1900        &mut self,
1901        center: (f64, f64),
1902        radius: f64,
1903        style: &crate::render::backend::PointStyle,
1904    ) -> Result<(), RenderError> {
1905        self.inner.draw_shape(center, radius, style)
1906    }
1907    fn plot_area(&self) -> crate::render::Rect {
1908        self.panel_rect.clone()
1909    }
1910    fn set_tooltip(&mut self, tooltip: Option<String>) {
1911        self.inner.set_tooltip(tooltip)
1912    }
1913    fn set_mark_axis(&mut self, key: Option<String>) {
1914        self.inner.set_mark_axis(key)
1915    }
1916    fn set_mark_series(&mut self, series: Option<String>) {
1917        self.inner.set_mark_series(series)
1918    }
1919    fn set_mark_value(&mut self, value: Option<String>) {
1920        self.inner.set_mark_value(value)
1921    }
1922    fn set_mark_geom(&mut self, geom: Option<&str>) {
1923        self.inner.set_mark_geom(geom)
1924    }
1925    fn warn(&mut self, message: String) {
1926        self.inner.warn(message)
1927    }
1928    fn set_mark_part(&mut self, part: Option<&str>) {
1929        self.inner.set_mark_part(part)
1930    }
1931    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
1932        self.inner.set_mark_attrs(attrs)
1933    }
1934
1935    fn set_mark_class(&mut self, class: Option<String>) {
1936        self.inner.set_mark_class(class)
1937    }
1938
1939    fn set_mark_group(&mut self, group: Option<(String, String)>) {
1940        self.inner.set_mark_group(group)
1941    }
1942
1943    fn set_layer_attrs(&mut self, attrs: Vec<(String, String)>) {
1944        self.inner.set_layer_attrs(attrs)
1945    }
1946    fn wants_formatted_values(&self) -> bool {
1947        self.inner.wants_formatted_values()
1948    }
1949    fn set_mark_fvalue(&mut self, value: Option<String>) {
1950        self.inner.set_mark_fvalue(value)
1951    }
1952    fn set_mark_fx(&mut self, value: Option<String>) {
1953        self.inner.set_mark_fx(value)
1954    }
1955    fn total_area(&self) -> crate::render::Rect {
1956        self.inner.total_area()
1957    }
1958    fn corner_claims(&self) -> Vec<crate::render::Rect> {
1959        self.inner.corner_claims()
1960    }
1961    fn claim_corner_box(&mut self, rect: crate::render::Rect) {
1962        self.inner.claim_corner_box(rect)
1963    }
1964    fn clear_corner_claims(&mut self) {
1965        self.inner.clear_corner_claims()
1966    }
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971    use super::*;
1972    use crate::render::backend::LineStyle;
1973    use crate::render::svg_backend::SvgBackend;
1974
1975    /// `PanelBackendAdapter` (every geom draws through one — see the panel
1976    /// loop in `PlotRenderer::render`) forwards `draw_quadratic`,
1977    /// `set_mark_part` and `set_mark_attrs` to its inner backend, and
1978    /// reports the *panel* rect (not the inner backend's full area) as its
1979    /// `plot_area`.
1980    #[test]
1981    fn panel_backend_adapter_forwards_quadratic_and_mark_metadata() {
1982        let total = crate::render::Rect {
1983            x: 0.0,
1984            y: 0.0,
1985            width: 200.0,
1986            height: 200.0,
1987        };
1988        let panel_rect = crate::render::Rect {
1989            x: 10.0,
1990            y: 10.0,
1991            width: 80.0,
1992            height: 80.0,
1993        };
1994        let mut backend = SvgBackend::new(200, 200, total);
1995        let mut adapter = PanelBackendAdapter {
1996            inner: &mut backend,
1997            panel_rect: panel_rect.clone(),
1998        };
1999
2000        assert_eq!(adapter.plot_area().x, panel_rect.x);
2001        assert_eq!(adapter.plot_area().width, panel_rect.width);
2002
2003        adapter.set_mark_part(Some("arrow"));
2004        adapter.set_mark_attrs(vec![("data-x".to_string(), "v".to_string())]);
2005        adapter
2006            .draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &LineStyle::default())
2007            .unwrap();
2008        adapter.set_mark_part(None);
2009        adapter.set_mark_attrs(Vec::new());
2010
2011        let svg = backend.finish();
2012        assert!(svg.contains("<path"));
2013        assert!(svg.contains("data-part=\"arrow\""));
2014        assert!(svg.contains("data-x=\"v\""));
2015    }
2016}