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        // `coord_flip` (#491): the horizontal axis reads the Y scale and the
623        // vertical one the X scale, on every panel as on a single one — so a
624        // free data-x scale varies the vertical (left) axes.
625        let flipped = built.coord.is_flipped();
626        let (h_aes, v_aes) = if flipped {
627            (Aesthetic::Y, Aesthetic::X)
628        } else {
629            (Aesthetic::X, Aesthetic::Y)
630        };
631        let (free_x, free_y) = if flipped {
632            (free_y, free_x)
633        } else {
634            (free_x, free_y)
635        };
636        let all_x = built.facet_axes.all_x || free_x;
637        let all_y = built.facet_axes.all_y || free_y;
638        // A free scale's inner axes are always labelled (they differ).
639        let inner_labels_x = !built.facet_axes.margin_labels_x || free_x;
640        let inner_labels_y = !built.facet_axes.margin_labels_y || free_y;
641        let unlabelled = |x: bool| {
642            let mut t = theme.clone();
643            if x {
644                t.axis_text_x.visible = false;
645            } else {
646                t.axis_text_y.visible = false;
647            }
648            t
649        };
650        let theme_no_x_labels = unlabelled(true);
651        let theme_no_y_labels = unlabelled(false);
652        let gap_x = theme.get_panel_spacing_x()
653            + if all_y && built.panels.len() > 1 {
654                theme.axis_ticks_length
655                    + if inner_labels_y && theme.axis_text_y.visible {
656                        built
657                            .panel_scales
658                            .iter()
659                            .chain(std::iter::once(&built.scales))
660                            .filter_map(|s| s.get(&v_aes))
661                            .map(|ys| axis::y_label_reserve(theme, &ys.breaks()))
662                            .fold(0.0_f64, f64::max)
663                    } else {
664                        0.0
665                    }
666            } else {
667                0.0
668            };
669        let gap_y = theme.get_panel_spacing_y()
670            + if all_x && built.panels.len() > 1 {
671                if inner_labels_x {
672                    super::layout::PlotLayout::x_axis_band(theme, None)
673                } else {
674                    theme.axis_ticks_length
675                }
676            } else {
677                0.0
678            };
679        let strip_height = thickness(
680            strip_lines(&col_strip),
681            if wrap_vertical { size_y } else { size_x },
682        );
683
684        // Figure-level layers (`GeomAttribution::figure`): one band under
685        // the whole grid, which shrinks by its height.
686        let is_figure = |li: usize| {
687            built.layers[li]
688                .geom
689                .figure_band_height(plot_area.width, theme)
690                .is_some()
691        };
692        let band_h: f64 = built
693            .layers
694            .iter()
695            .filter_map(|l| l.geom.figure_band_height(plot_area.width, theme))
696            .sum::<f64>()
697            .min(plot_area.height * 0.5);
698        let band_rect = crate::render::Rect {
699            x: plot_area.x,
700            y: plot_area.y + plot_area.height - band_h,
701            width: plot_area.width,
702            height: band_h,
703        };
704        let plot_area = crate::render::Rect {
705            height: plot_area.height - band_h,
706            ..plot_area
707        };
708
709        // `facet_wrap` under a fixed-aspect coord (`coord_sf` maps): equal
710        // panels shaped like the data, in the column count that makes them
711        // largest (or the given `ncol`), centred in the plot area — instead
712        // of `sqrt(n)` arbitrary cells each letterboxing its map.
713        let fixed_grid = match (&built.facet, built.coord.fixed_aspect()) {
714            (Facet::Wrap { ncol: want, .. }, Some(aspect)) => Some(crate::facet::wrap::fit_grid(
715                built.panels.len(),
716                *want,
717                aspect,
718                plot_area.width
719                    - if wrap_vertical {
720                        strip_height * want.unwrap_or(1) as f64
721                    } else {
722                        0.0
723                    },
724                plot_area.height,
725                if wrap_vertical { 0.0 } else { strip_height },
726                gap_x,
727                gap_y,
728            )),
729            _ => None,
730        };
731        let ncol = if let Some(g) = fixed_grid {
732            nrow = g.nrow;
733            g.ncol
734        } else {
735            ncol
736        };
737        // Each panel's (row, col): from its index under the fixed grid (the
738        // build-time row/col assumed the `sqrt(n)` default), else as built.
739        let cell: Vec<(usize, usize)> = built
740            .panels
741            .iter()
742            .enumerate()
743            .map(|(i, p)| {
744                if fixed_grid.is_some() {
745                    match &built.facet {
746                        Facet::Wrap {
747                            vertical, as_table, ..
748                        } => crate::facet::wrap::wrap_cell(
749                            i,
750                            built.panels.len(),
751                            ncol,
752                            *vertical,
753                            *as_table,
754                        ),
755                        _ => (i / ncol, i % ncol),
756                    }
757                } else {
758                    (p.row, p.col)
759                }
760            })
761            .collect();
762
763        // Each panel's strips (#412): (side, label, thickness). The bands a
764        // row / column reserves for them are the thickest strip it carries
765        // on each side.
766        use crate::facet::StripPosition as Side;
767        // #412: `strip.placement = "outside"` — a bottom / left strip of a
768        // panel that draws that axis sits beyond the axis (+ the pad).
769        let outside = theme.strip_placement_outside;
770        let x_axis_off =
771            super::layout::PlotLayout::x_axis_band(theme, None) + theme.strip_switch_pad;
772        let y_axis_off = theme.axis_ticks_length
773            + built
774                .panel_scales
775                .iter()
776                .chain(std::iter::once(&built.scales))
777                .filter_map(|s| s.get(&v_aes))
778                .map(|ys| axis::y_label_reserve(theme, &ys.breaks()))
779                .fold(0.0_f64, f64::max)
780            + theme.strip_switch_pad;
781        let n_panels = built.panels.len();
782        let draws_x = |pi: usize, r: usize| r == nrow - 1 || pi + ncol >= n_panels || all_x;
783        let draws_y = |c: usize| c == 0 || all_y;
784        let off = |side: Side, pi: usize, r: usize, c: usize| match side {
785            Side::Bottom if outside && draws_x(pi, r) => x_axis_off,
786            Side::Left if outside && draws_y(c) => y_axis_off,
787            _ => 0.0,
788        };
789        let strips: Vec<Vec<(Side, String, f64)>> = built
790            .panels
791            .iter()
792            .enumerate()
793            .map(|(pi, p)| {
794                let (r, c) = cell[pi];
795                let mut v = Vec::new();
796                if is_grid {
797                    if let Some(l) = &p.col_label {
798                        if switch.x() && r == nrow - 1 {
799                            v.push((Side::Bottom, l.clone(), strip_height));
800                        } else if !switch.x() && r == 0 {
801                            v.push((Side::Top, l.clone(), strip_height));
802                        }
803                    }
804                    if let Some(l) = &p.row_label {
805                        if switch.y() && c == 0 {
806                            v.push((Side::Left, l.clone(), row_strip_width));
807                        } else if !switch.y() && c == ncol - 1 {
808                            v.push((Side::Right, l.clone(), row_strip_width));
809                        }
810                    }
811                } else {
812                    v.push((wrap_side, p.label.clone(), strip_height));
813                }
814                v
815            })
816            .collect();
817        let mut band_top = vec![0.0_f64; nrow.max(1)];
818        let mut band_bottom = vec![0.0_f64; nrow.max(1)];
819        let mut band_left = vec![0.0_f64; ncol.max(1)];
820        let mut band_right = vec![0.0_f64; ncol.max(1)];
821        for (pi, list) in strips.iter().enumerate() {
822            let (r, c) = cell[pi];
823            for (side, _, t) in list {
824                let o = off(*side, pi, r, c);
825                let b = match side {
826                    Side::Top => &mut band_top[r],
827                    Side::Bottom => &mut band_bottom[r],
828                    Side::Left => &mut band_left[c],
829                    Side::Right => &mut band_right[c],
830                };
831                *b = b.max(*t + o);
832            }
833        }
834        // Every row / column of a wrap keeps its strip band, even one whose
835        // cells are all empty slots (a top band per row, as before #412).
836        if !is_grid {
837            match wrap_side {
838                Side::Top => band_top.fill(strip_height),
839                Side::Bottom => band_bottom
840                    .iter_mut()
841                    .for_each(|b| *b = b.max(strip_height)),
842                Side::Left => band_left.iter_mut().for_each(|b| *b = b.max(strip_height)),
843                Side::Right => band_right.fill(strip_height),
844            }
845        }
846
847        // Proportional panel sizing (R's `space =`): free_x sizes columns to
848        // their data x-range, free_y sizes rows to their y-range. Fixed = equal.
849        let space = match &built.facet {
850            Facet::Grid { space, .. } => space.clone(),
851            // #412: a wrap sizes only along its single row / column.
852            Facet::Wrap { space, .. } if fixed_grid.is_none() => {
853                use crate::facet::FacetSpace as S;
854                match (space.free_x() && nrow == 1, space.free_y() && ncol == 1) {
855                    (true, true) => S::Free,
856                    (true, false) => S::FreeX,
857                    (false, true) => S::FreeY,
858                    _ => S::Fixed,
859                }
860            }
861            _ => crate::facet::FacetSpace::Fixed,
862        };
863        let extent = |pi: usize, col: &str| -> Option<f64> {
864            let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
865            for df in &built.panels_data[pi] {
866                if let Some(c) = df.column(col) {
867                    for v in c {
868                        if let Some(f) = v.as_f64() {
869                            lo = lo.min(f);
870                            hi = hi.max(f);
871                        }
872                    }
873                }
874            }
875            (lo <= hi).then_some(hi - lo)
876        };
877        let avail_w = plot_area.width
878            - gap_x * (ncol as f64 - 1.0)
879            - band_left.iter().sum::<f64>()
880            - band_right.iter().sum::<f64>();
881        let col_widths: Vec<f64> = if let Some(g) = fixed_grid {
882            vec![g.panel_w; ncol]
883        } else if space.free_x() && ncol > 0 {
884            let mut ranges = vec![0.0f64; ncol];
885            for (pi, panel) in built.panels.iter().enumerate() {
886                if let Some(r) = extent(pi, "x") {
887                    ranges[panel.col] = ranges[panel.col].max(r);
888                }
889            }
890            let total: f64 = ranges.iter().sum();
891            if total > 0.0 {
892                ranges.iter().map(|r| avail_w * (r / total)).collect()
893            } else {
894                vec![avail_w / ncol as f64; ncol]
895            }
896        } else {
897            vec![avail_w / ncol.max(1) as f64; ncol.max(1)]
898        };
899        let mut col_x = vec![plot_area.x; ncol.max(1)];
900        {
901            let used_w: f64 = col_widths.iter().sum::<f64>()
902                + gap_x * (ncol as f64 - 1.0)
903                + band_left.iter().sum::<f64>()
904                + band_right.iter().sum::<f64>();
905            let mut acc = if fixed_grid.is_some() {
906                plot_area.x + ((plot_area.width - used_w) / 2.0).max(0.0)
907            } else {
908                plot_area.x
909            };
910            for c in 0..ncol {
911                col_x[c] = acc + band_left[c];
912                acc += band_left[c] + col_widths[c] + band_right[c] + gap_x;
913            }
914        }
915
916        let avail_h = plot_area.height
917            - gap_y * (nrow as f64 - 1.0)
918            - band_top.iter().sum::<f64>()
919            - band_bottom.iter().sum::<f64>();
920        let row_heights: Vec<f64> = if let Some(g) = fixed_grid {
921            vec![g.panel_h; nrow]
922        } else if space.free_y() && nrow > 0 {
923            let mut ranges = vec![0.0f64; nrow];
924            for (pi, panel) in built.panels.iter().enumerate() {
925                if let Some(r) = extent(pi, "y") {
926                    ranges[panel.row] = ranges[panel.row].max(r);
927                }
928            }
929            let total: f64 = ranges.iter().sum();
930            if total > 0.0 {
931                ranges.iter().map(|r| avail_h * (r / total)).collect()
932            } else {
933                vec![avail_h / nrow as f64; nrow]
934            }
935        } else {
936            vec![avail_h / nrow.max(1) as f64; nrow.max(1)]
937        };
938        let mut row_y = vec![plot_area.y; nrow.max(1)];
939        {
940            let used_h: f64 = row_heights.iter().sum::<f64>()
941                + gap_y * (nrow as f64 - 1.0)
942                + band_top.iter().sum::<f64>()
943                + band_bottom.iter().sum::<f64>();
944            let mut acc = if fixed_grid.is_some() {
945                plot_area.y + ((plot_area.height - used_h) / 2.0).max(0.0)
946            } else {
947                plot_area.y
948            };
949            for r in 0..nrow {
950                // `row_y` is the panel's own top edge.
951                row_y[r] = acc + band_top[r];
952                acc += band_top[r] + row_heights[r] + band_bottom[r] + gap_y;
953            }
954        }
955
956        // #348: one x / y axis title for the whole grid (ggplot2), drawn
957        // after the panels over the union of the panels carrying that axis.
958        // The y title also needs the widest of those panels' tick labels.
959        let mut x_title_area: Option<super::Rect> = None;
960        let mut y_title_area: Option<(super::Rect, f64)> = None;
961        let union = |a: &Option<super::Rect>, b: &super::Rect| match a {
962            None => b.clone(),
963            Some(a) => {
964                let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
965                let x1 = (a.x + a.width).max(b.x + b.width);
966                let y1 = (a.y + a.height).max(b.y + b.height);
967                super::Rect {
968                    x: x0,
969                    y: y0,
970                    width: x1 - x0,
971                    height: y1 - y0,
972                }
973            }
974        };
975
976        for (pi, panel) in built.panels.iter().enumerate() {
977            let (prow, pcol) = cell[pi];
978            let panel_width = col_widths[pcol];
979            let panel_height = row_heights[prow];
980            let px = col_x[pcol];
981            let py = row_y[prow];
982
983            let panel_rect = crate::render::Rect {
984                x: px,
985                y: py,
986                width: panel_width,
987                height: panel_height,
988            };
989            // #273: fresh panel-corner claim registry per facet panel —
990            // see `render_single`'s identical reset. A faceted plot's
991            // inside legend (if any) is drawn once globally, not per
992            // panel, so it isn't pre-claimed here the way `render_single`
993            // pre-claims it — map furniture geoms under faceting still
994            // stack clear of each other within one panel, just not of a
995            // faceted inside legend.
996            backend.clear_corner_claims();
997
998            // Strips (#412): each on its side of the panel, the side ones
999            // with their text turned to read along them.
1000            // An inside strip pushes its axis out; an outside one sits
1001            // beyond the axis and only moves the axis title.
1002            let mut bottom_strip = 0.0_f64;
1003            let mut left_strip = 0.0_f64;
1004            let mut bottom_title = 0.0_f64;
1005            let mut left_title = 0.0_f64;
1006            for (side, label, t) in &strips[pi] {
1007                let o = off(*side, pi, prow, pcol);
1008                let (rect, angle) = match side {
1009                    Side::Top => ((px, py - t, panel_width, *t), 0.0),
1010                    Side::Bottom => {
1011                        if o > 0.0 {
1012                            bottom_title = *t + o;
1013                        } else {
1014                            bottom_strip = *t;
1015                        }
1016                        ((px, py + panel_height + o, panel_width, *t), 0.0)
1017                    }
1018                    Side::Left => {
1019                        if o > 0.0 {
1020                            left_title = *t + o;
1021                        } else {
1022                            left_strip = *t;
1023                        }
1024                        ((px - t - o, py, *t, panel_height), 270.0)
1025                    }
1026                    Side::Right => ((px + panel_width, py, *t, panel_height), 90.0),
1027                };
1028                draw_strip(backend, theme, rect, label, angle)?;
1029            }
1030
1031            // Panel background
1032            if theme.panel_background.visible {
1033                if let Some(fill) = theme.panel_background.fill {
1034                    backend.set_element_class(Some("gg-panel-background"));
1035                    backend.draw_rect(
1036                        (panel_rect.x, panel_rect.y),
1037                        (
1038                            panel_rect.x + panel_rect.width,
1039                            panel_rect.y + panel_rect.height,
1040                        ),
1041                        &RectStyle {
1042                            fill: Some(fill),
1043                            stroke: theme.panel_background.color,
1044                            stroke_width: theme.panel_background.width,
1045                            alpha: 1.0,
1046                            clip: false,
1047                            corner_radius: 0.0,
1048                        },
1049                    )?;
1050                }
1051            }
1052
1053            // Panel border
1054            if theme.panel_border.visible {
1055                let style = LineStyle {
1056                    color: theme.panel_border.color,
1057                    width: theme.panel_border.width,
1058                    alpha: 1.0,
1059                    linetype: Linetype::Solid,
1060                    ..Default::default()
1061                };
1062                let x0 = panel_rect.x;
1063                let y0 = panel_rect.y;
1064                let x1 = panel_rect.x + panel_rect.width;
1065                let y1 = panel_rect.y + panel_rect.height;
1066                backend.set_element_class(Some("gg-panel-border"));
1067                backend.draw_line(&[(x0, y0), (x1, y0)], &style)?;
1068                backend.draw_line(&[(x1, y0), (x1, y1)], &style)?;
1069                backend.draw_line(&[(x1, y1), (x0, y1)], &style)?;
1070                backend.draw_line(&[(x0, y1), (x0, y0)], &style)?;
1071            }
1072
1073            // Use per-panel scales if free facets, otherwise global scales
1074            let panel_scale_set = if pi < built.panel_scales.len() {
1075                &built.panel_scales[pi]
1076            } else {
1077                &built.scales
1078            };
1079
1080            // Gridlines + axes for edge panels
1081            let x_scale = panel_scale_set.get(&h_aes);
1082            let y_scale = panel_scale_set.get(&v_aes);
1083
1084            if let (true, Some(xs), Some(ys)) = (
1085                built.coord.is_polar() && !built.coord.is_radar(),
1086                x_scale,
1087                y_scale,
1088            ) {
1089                // Polar facets (#411): each panel its own rings / spokes.
1090                crate::guide::polar::draw_polar_guides(
1091                    xs,
1092                    ys,
1093                    built.coord.as_ref(),
1094                    theme,
1095                    &panel_rect,
1096                    backend,
1097                )?;
1098            } else if let (Some(xs), Some(ys)) = (x_scale, y_scale) {
1099                if built.coord.gridlines() {
1100                    axis::draw_gridlines(
1101                        xs,
1102                        ys,
1103                        built.coord.as_ref(),
1104                        theme,
1105                        &panel_rect,
1106                        backend,
1107                    )?;
1108                }
1109
1110                // Bottom row gets x axis
1111                // A strip below / left of the panel sits between it and its
1112                // axis (ggplot2's `strip.placement = "inside"`, #412).
1113                let bottom_edge = prow == nrow - 1 || pi + ncol >= built.panels.len();
1114                if bottom_edge || all_x {
1115                    let axis_rect = crate::render::Rect {
1116                        height: panel_rect.height + bottom_strip,
1117                        ..panel_rect.clone()
1118                    };
1119                    let t = if bottom_edge || inner_labels_x {
1120                        theme
1121                    } else {
1122                        &theme_no_x_labels
1123                    };
1124                    axis::draw_x_axis_with(
1125                        xs,
1126                        built.coord.as_ref(),
1127                        t,
1128                        &axis_rect,
1129                        &total_area,
1130                        false,
1131                        backend,
1132                    )?;
1133                    if bottom_edge {
1134                        let title_rect = crate::render::Rect {
1135                            height: axis_rect.height + bottom_title,
1136                            ..axis_rect.clone()
1137                        };
1138                        x_title_area = Some(union(&x_title_area, &title_rect));
1139                    }
1140                }
1141
1142                // Left column gets y axis (every column with `all_y`).
1143                if pcol == 0 || all_y {
1144                    let axis_rect = crate::render::Rect {
1145                        x: panel_rect.x - left_strip,
1146                        width: panel_rect.width + left_strip,
1147                        ..panel_rect.clone()
1148                    };
1149                    let t = if pcol == 0 || inner_labels_y {
1150                        theme
1151                    } else {
1152                        &theme_no_y_labels
1153                    };
1154                    axis::draw_y_axis_with(
1155                        ys,
1156                        built.coord.as_ref(),
1157                        t,
1158                        &axis_rect,
1159                        false,
1160                        backend,
1161                    )?;
1162                    if pcol == 0 {
1163                        let reserve = axis::y_label_reserve(theme, &ys.breaks());
1164                        let prev = y_title_area.as_ref().map(|(r, _)| r.clone());
1165                        let widest = y_title_area.as_ref().map_or(0.0, |(_, w)| *w);
1166                        let title_rect = crate::render::Rect {
1167                            x: axis_rect.x - left_title,
1168                            width: axis_rect.width + left_title,
1169                            ..axis_rect.clone()
1170                        };
1171                        y_title_area = Some((union(&prev, &title_rect), widest.max(reserve)));
1172                    }
1173                }
1174            }
1175
1176            // Draw layers for this panel — under a fixed-aspect wrap (maps)
1177            // in their own clipped group, so one panel's features never
1178            // spill into the next.
1179            if fixed_grid.is_some() {
1180                let data = built
1181                    .coord
1182                    .data_rect(&panel_rect)
1183                    .unwrap_or_else(|| panel_rect.clone());
1184                backend.begin_panel(pi, &panel.label, &panel_rect, &data);
1185            }
1186            // #411: as for a single panel — clip this panel's layers when
1187            // they reach past it (a fixed-aspect grid's panel group already
1188            // clips), or let them draw past it with `clip = "off"`.
1189            backend.set_element_class(None);
1190            let clip_group = built.clip
1191                && fixed_grid.is_none()
1192                && built.panels_data.get(pi).is_some_and(|layers| {
1193                    layers.iter().any(|d| {
1194                        layer_spills(d, panel_scale_set, built.coord.as_ref(), &panel_rect)
1195                    })
1196                });
1197            if clip_group {
1198                backend.begin_clip(&panel_rect);
1199            }
1200            if !built.clip {
1201                backend.set_clipping(false);
1202            }
1203            if pi < built.panels_data.len() {
1204                for (li, layer_data) in built.panels_data[pi].iter().enumerate() {
1205                    if li < built.layers.len() && layer_data.nrows() > 0 && !is_figure(li) {
1206                        let mut panel_backend = PanelBackendAdapter {
1207                            inner: backend,
1208                            panel_rect: panel_rect.clone(),
1209                        };
1210                        let geom = &built.layers[li].geom;
1211                        panel_backend.set_mark_geom(Some(geom.name()));
1212                        let drawn = geom.draw(
1213                            layer_data,
1214                            built.coord.as_ref(),
1215                            panel_scale_set,
1216                            theme,
1217                            &mut panel_backend,
1218                        );
1219                        panel_backend.set_mark_geom(None);
1220                        drawn?;
1221                    }
1222                }
1223            }
1224            if !built.clip {
1225                backend.set_clipping(true);
1226            }
1227            if clip_group {
1228                backend.end_clip();
1229            }
1230            if fixed_grid.is_some() {
1231                backend.end_panel();
1232            }
1233        }
1234
1235        // The grid's single axis titles (#348), against the first panel's
1236        // scales (the titles are the same on every panel).
1237        let title_scales = built.panel_scales.first().unwrap_or(&built.scales);
1238        if let (Some(area), Some(xs)) = (&x_title_area, title_scales.get(&h_aes)) {
1239            axis::draw_x_axis_title(xs, theme, area, backend)?;
1240        }
1241        if let (Some((area, reserve)), Some(ys)) = (&y_title_area, title_scales.get(&v_aes)) {
1242            axis::draw_y_axis_title(ys, theme, area, *reserve, backend)?;
1243        }
1244
1245        // Figure-level layers, once, in the band under the grid.
1246        if band_h > 0.0 {
1247            backend.clear_corner_claims();
1248            for li in (0..built.layers.len()).filter(|&li| is_figure(li)) {
1249                let empty = crate::data::DataFrame::new();
1250                let data = built
1251                    .panels_data
1252                    .iter()
1253                    .filter_map(|p| p.get(li))
1254                    .find(|d| d.nrows() > 0)
1255                    .unwrap_or(&empty);
1256                let mut band_backend = PanelBackendAdapter {
1257                    inner: backend,
1258                    panel_rect: band_rect.clone(),
1259                };
1260                let geom = &built.layers[li].geom;
1261                band_backend.set_mark_geom(Some(geom.name()));
1262                let drawn = geom.draw(
1263                    data,
1264                    built.coord.as_ref(),
1265                    &built.scales,
1266                    theme,
1267                    &mut band_backend,
1268                );
1269                band_backend.set_mark_geom(None);
1270                drawn?;
1271            }
1272        }
1273
1274        // Draw title
1275        let title_ref = Self::title_ref_rect(theme, &plot_area, &total_area);
1276        let caption_ref = Self::ref_rect(
1277            theme.caption_position.unwrap_or(theme.title_position),
1278            theme,
1279            &plot_area,
1280            &total_area,
1281        );
1282        // #233: see render_single's identical lift — a Top legend must not
1283        // overlap the title/subtitle (zero when there's no Top legend).
1284        let legend_lift = legend::top_legend_lift(
1285            theme,
1286            &built.scales,
1287            &built.suppressed_aes,
1288            &built.legend_plans,
1289            &built.guide_legend,
1290        );
1291        if let Some(ref title) = built.labels.title {
1292            let (tx, anchor) = Self::hjust_pos(&theme.title, &title_ref);
1293            let (title_y, _) = Self::title_subtitle_y(
1294                theme,
1295                plot_area.y,
1296                legend_lift,
1297                built.labels.subtitle.is_some(),
1298            );
1299            let family = if theme.title.family.is_empty() {
1300                None
1301            } else {
1302                Some(theme.title.family.clone())
1303            };
1304            backend.set_element_class(Some("gg-title"));
1305            crate::render::rich::draw(
1306                backend,
1307                theme.rich_text,
1308                title,
1309                (tx, title_y),
1310                &TextStyle {
1311                    color: theme.title.color,
1312                    size: theme.title.size,
1313                    anchor,
1314                    angle: 0.0,
1315                    family,
1316                    face: theme.title.face,
1317                    bg_colour: None,
1318                    bg_r: 0.0,
1319                },
1320            )?;
1321        }
1322
1323        // Draw subtitle
1324        if let Some(ref subtitle) = built.labels.subtitle {
1325            let (sx, anchor) = Self::hjust_pos(&theme.subtitle, &title_ref);
1326            let (_, subtitle_y) = Self::title_subtitle_y(theme, plot_area.y, legend_lift, true);
1327            let family = if theme.subtitle.family.is_empty() {
1328                None
1329            } else {
1330                Some(theme.subtitle.family.clone())
1331            };
1332            backend.set_element_class(Some("gg-subtitle"));
1333            crate::render::rich::draw(
1334                backend,
1335                theme.rich_text,
1336                subtitle,
1337                (sx, subtitle_y),
1338                &TextStyle {
1339                    color: theme.subtitle.color,
1340                    size: theme.subtitle.size,
1341                    anchor,
1342                    angle: 0.0,
1343                    family,
1344                    face: theme.subtitle.face,
1345                    bg_colour: None,
1346                    bg_r: 0.0,
1347                },
1348            )?;
1349        }
1350
1351        // Draw caption
1352        if let Some(ref caption) = built.labels.caption {
1353            let (cx, anchor) = Self::hjust_pos(&theme.caption, &caption_ref);
1354            // Inside the layout's `caption_area`: the bottom of the
1355            // canvas less `plot_margin.bottom` (#286).
1356            let caption_y = total_area.y + total_area.height
1357                - theme.plot_margin.bottom.max(0.0)
1358                - theme.caption.size * 0.5;
1359            let family = if theme.caption.family.is_empty() {
1360                None
1361            } else {
1362                Some(theme.caption.family.clone())
1363            };
1364            backend.set_element_class(Some("gg-caption"));
1365            crate::render::rich::draw(
1366                backend,
1367                theme.rich_text,
1368                caption,
1369                (cx, caption_y),
1370                &TextStyle {
1371                    color: theme.caption.color,
1372                    size: theme.caption.size,
1373                    anchor,
1374                    angle: 0.0,
1375                    family,
1376                    face: theme.caption.face,
1377                    bg_colour: None,
1378                    bg_r: 0.0,
1379                },
1380            )?;
1381        }
1382
1383        backend.set_element_class(None);
1384        // Draw annotations
1385        Self::draw_annotations(
1386            &built.annotations,
1387            &built.scales,
1388            built.coord.as_ref(),
1389            &plot_area,
1390            theme,
1391            backend,
1392        )?;
1393
1394        // Draw legend
1395        legend::draw_legend_planned(
1396            &built.scales,
1397            theme,
1398            &plot_area,
1399            plot_area.width,
1400            backend,
1401            &built.guide_legend,
1402            &built.suppressed_aes,
1403            &built.legend_plans,
1404        )?;
1405
1406        // Draw corner tag (labs(tag = ...))
1407        // #413: `plot.tag.location = "panel"` puts the tag in the panel's
1408        // corner instead of the plot's.
1409        let tag_area = if theme.tag_location_panel {
1410            &plot_area
1411        } else {
1412            &total_area
1413        };
1414        Self::draw_tag(&built.labels, theme, tag_area, backend)?;
1415
1416        Ok(())
1417    }
1418
1419    /// `annotate_text`/`annotate_segment` under theme ink/paper (#157,
1420    /// follow-up to #142): `Annotation`'s fields are all `pub` with no
1421    /// `#[non_exhaustive]`, so it can't gain an "explicitly styled" flag
1422    /// without a semver break — the same `value == hard-coded default`
1423    /// gate `apply_element_geom`'s other callers already use (e.g.
1424    /// `geom/refline.rs`'s `(50, 50, 50)` → `ink`) is applied here instead.
1425    /// Default theme (`geom.ink` unset) is byte-identical.
1426    ///
1427    /// `annotate_rect`'s grey fill is deliberately excluded: checked
1428    /// against ggplot2 4.0.3 (`theme(geom = element_geom(ink = "white",
1429    /// paper = "black"))`), `annotate("text")`/`annotate("segment")`
1430    /// recolour to `ink` but `annotate("rect")`'s fill stays the same
1431    /// hard-coded grey (`#A6A6A6`) regardless — ggplot2 itself doesn't
1432    /// route a rect annotation's fill through ink/paper.
1433    fn draw_annotations(
1434        annotations: &[Annotation],
1435        scales: &crate::scale::ScaleSet,
1436        coord: &dyn crate::coord::Coord,
1437        plot_area: &crate::render::Rect,
1438        theme: &crate::theme::Theme,
1439        backend: &mut dyn DrawBackend,
1440    ) -> Result<(), RenderError> {
1441        use crate::data::Value;
1442
1443        let x_scale = scales.get(&Aesthetic::X);
1444        let y_scale = scales.get(&Aesthetic::Y);
1445
1446        for ann in annotations {
1447            match ann {
1448                Annotation::Text {
1449                    label,
1450                    x,
1451                    y,
1452                    size,
1453                    color,
1454                } => {
1455                    let nx = x_scale.map(|s| s.map(&Value::Float(*x))).unwrap_or(0.0);
1456                    let ny = y_scale.map(|s| s.map(&Value::Float(*y))).unwrap_or(0.0);
1457                    let pos = coord.transform((nx, ny), plot_area);
1458                    backend.draw_text(
1459                        label,
1460                        pos,
1461                        &TextStyle {
1462                            color: annotation_ink(*color, theme),
1463                            size: *size,
1464                            anchor: TextAnchor::Middle,
1465                            angle: 0.0,
1466                            family: crate::geom::resolve_font_family("", theme),
1467                            face: crate::render::backend::FontFace::Plain,
1468                            bg_colour: None,
1469                            bg_r: 0.0,
1470                        },
1471                    )?;
1472                }
1473                Annotation::Rect {
1474                    xmin,
1475                    xmax,
1476                    ymin,
1477                    ymax,
1478                    fill,
1479                    alpha,
1480                } => {
1481                    let nx0 = x_scale.map(|s| s.map(&Value::Float(*xmin))).unwrap_or(0.0);
1482                    let nx1 = x_scale.map(|s| s.map(&Value::Float(*xmax))).unwrap_or(1.0);
1483                    let ny0 = y_scale.map(|s| s.map(&Value::Float(*ymin))).unwrap_or(0.0);
1484                    let ny1 = y_scale.map(|s| s.map(&Value::Float(*ymax))).unwrap_or(1.0);
1485                    let tl = coord.transform((nx0, ny1), plot_area);
1486                    let br = coord.transform((nx1, ny0), plot_area);
1487                    backend.draw_rect(
1488                        tl,
1489                        br,
1490                        &RectStyle {
1491                            fill: Some(*fill),
1492                            stroke: None,
1493                            stroke_width: 0.0,
1494                            alpha: *alpha,
1495                            clip: false,
1496                            corner_radius: 0.0,
1497                        },
1498                    )?;
1499                }
1500                Annotation::Segment {
1501                    x,
1502                    y,
1503                    xend,
1504                    yend,
1505                    color,
1506                    width,
1507                } => {
1508                    let nx0 = x_scale.map(|s| s.map(&Value::Float(*x))).unwrap_or(0.0);
1509                    let ny0 = y_scale.map(|s| s.map(&Value::Float(*y))).unwrap_or(0.0);
1510                    let nx1 = x_scale.map(|s| s.map(&Value::Float(*xend))).unwrap_or(1.0);
1511                    let ny1 = y_scale.map(|s| s.map(&Value::Float(*yend))).unwrap_or(1.0);
1512                    let p0 = coord.transform((nx0, ny0), plot_area);
1513                    let p1 = coord.transform((nx1, ny1), plot_area);
1514                    backend.draw_line(
1515                        &[p0, p1],
1516                        &LineStyle {
1517                            color: annotation_ink(*color, theme),
1518                            alpha: 1.0,
1519                            width: *width,
1520                            linetype: Linetype::Solid,
1521                            ..Default::default()
1522                        },
1523                    )?;
1524                }
1525                Annotation::LogTicks {
1526                    sides,
1527                    outside,
1528                    lengths,
1529                    color,
1530                    width,
1531                } => {
1532                    let style = LineStyle {
1533                        color: annotation_ink(*color, theme),
1534                        alpha: 1.0,
1535                        width: *width,
1536                        linetype: Linetype::Solid,
1537                        ..Default::default()
1538                    };
1539                    let dir = if *outside { -1.0 } else { 1.0 };
1540                    for side in sides.chars() {
1541                        let horizontal = matches!(side, 'b' | 't');
1542                        let scale = if horizontal { x_scale } else { y_scale };
1543                        let Some((lo, hi)) = scale.and_then(|s| s.expanded_domain()) else {
1544                            continue;
1545                        };
1546                        for (t, kind) in log_ticks(lo, hi) {
1547                            let n = scale.map(|s| s.map(&Value::Float(t))).unwrap_or(0.0);
1548                            let len = dir
1549                                * match kind {
1550                                    0 => lengths.2,
1551                                    1 => lengths.1,
1552                                    _ => lengths.0,
1553                                };
1554                            // The tick's anchor on the panel edge, then inward.
1555                            let (edge, step) = match side {
1556                                'b' => ((n, 0.0), (0.0, -len)),
1557                                't' => ((n, 1.0), (0.0, len)),
1558                                'l' => ((0.0, n), (len, 0.0)),
1559                                'r' => ((1.0, n), (-len, 0.0)),
1560                                _ => continue,
1561                            };
1562                            let p0 = coord.transform(edge, plot_area);
1563                            let p1 = (p0.0 + step.0, p0.1 + step.1);
1564                            backend.draw_line(&[p0, p1], &style)?;
1565                        }
1566                    }
1567                }
1568                Annotation::Raster {
1569                    image,
1570                    xmin,
1571                    xmax,
1572                    ymin,
1573                    ymax,
1574                    alpha,
1575                } => {
1576                    let rect = data_rect(
1577                        x_scale,
1578                        y_scale,
1579                        coord,
1580                        plot_area,
1581                        [*xmin, *xmax, *ymin, *ymax],
1582                    );
1583                    backend.draw_image(image, &rect, plot_area, *alpha)?;
1584                }
1585                Annotation::Inset {
1586                    plot,
1587                    xmin,
1588                    xmax,
1589                    ymin,
1590                    ymax,
1591                } => {
1592                    let rect = data_rect(
1593                        x_scale,
1594                        y_scale,
1595                        coord,
1596                        plot_area,
1597                        [*xmin, *xmax, *ymin, *ymax],
1598                    );
1599                    let (w, h) = (rect.width.round().max(1.0), rect.height.round().max(1.0));
1600                    let mut guard = plot.0.lock().unwrap_or_else(|e| e.into_inner());
1601                    match &mut *guard {
1602                        Ok((built, meta)) => {
1603                            let layout =
1604                                crate::GGPlot::layout_built(built, meta, w as u32, h as u32);
1605                            let mut off = crate::render::offset::OffsetBackend::new(
1606                                backend,
1607                                rect.x,
1608                                rect.y,
1609                                layout.plot_area.clone(),
1610                                layout.total.clone(),
1611                            );
1612                            Self::render(built, &mut off)?;
1613                        }
1614                        Err(e) => {
1615                            backend.warn(format!("annotation_custom: inset plot failed: {e}"))
1616                        }
1617                    }
1618                }
1619            }
1620        }
1621        Ok(())
1622    }
1623}
1624
1625/// Log-10 tick positions inside `[lo, hi]` (log10 units): `(position, kind)`
1626/// with kind 0 = a power of ten (long), 1 = 5× (mid), 2 = the rest (short).
1627/// One facet strip: its background over `(x, y, w, h)` and its label lines
1628/// centred in it, turned by `angle` (90 = reads top to bottom, a right-hand
1629/// row strip; 270 = bottom to top, a left one) (#412).
1630fn draw_strip(
1631    backend: &mut dyn DrawBackend,
1632    theme: &crate::theme::Theme,
1633    (x, y, w, h): (f64, f64, f64, f64),
1634    label: &str,
1635    angle: f64,
1636) -> Result<(), RenderError> {
1637    // #413: a horizontal strip uses `strip_*_x`, a turned one `strip_*_y`.
1638    let horizontal = angle == 0.0;
1639    let (bg, text) = if horizontal {
1640        (&theme.strip_background_x, &theme.strip_text_x)
1641    } else {
1642        (&theme.strip_background_y, &theme.strip_text_y)
1643    };
1644    let bg = bg.as_ref().unwrap_or(&theme.strip_background);
1645    let text = text.as_ref().unwrap_or(&theme.strip_text);
1646    if bg.visible {
1647        backend.set_element_class(Some("gg-strip gg-strip-background"));
1648        backend.draw_rect(
1649            (x, y),
1650            (x + w, y + h),
1651            &RectStyle {
1652                fill: bg.fill,
1653                stroke: bg.color,
1654                stroke_width: bg.width,
1655                alpha: 1.0,
1656                clip: false,
1657                corner_radius: 0.0,
1658            },
1659        )?;
1660    }
1661    if !text.visible {
1662        return Ok(());
1663    }
1664    let family = if text.family.is_empty() {
1665        None
1666    } else {
1667        Some(text.family.clone())
1668    };
1669    let lines: Vec<&str> = label.lines().collect();
1670    let n = lines.len().max(1) as f64;
1671    for (k, line) in lines.iter().enumerate() {
1672        let d = (k as f64 - (n - 1.0) / 2.0) * text.size * 1.2;
1673        // Successive lines stack downwards when horizontal, and away from
1674        // the panel's reading direction when turned.
1675        let (dx, dy) = if angle == 90.0 {
1676            (-d, 0.0)
1677        } else if angle == 270.0 {
1678            (d, 0.0)
1679        } else {
1680            (0.0, d)
1681        };
1682        backend.set_element_class(Some("gg-strip gg-strip-text"));
1683        crate::render::rich::draw(
1684            backend,
1685            theme.rich_text,
1686            line,
1687            (x + w / 2.0 + dx, y + h / 2.0 + dy),
1688            &TextStyle {
1689                color: text.color,
1690                size: text.size,
1691                anchor: TextAnchor::Middle,
1692                angle,
1693                family: family.clone(),
1694                face: text.face,
1695                bg_colour: None,
1696                bg_r: 0.0,
1697            },
1698        )?;
1699    }
1700    Ok(())
1701}
1702
1703fn log_ticks(lo: f64, hi: f64) -> Vec<(f64, u8)> {
1704    let mut out = Vec::new();
1705    if !(lo.is_finite() && hi.is_finite()) || hi - lo > 300.0 {
1706        return out;
1707    }
1708    for k in (lo.floor() as i64)..=(hi.ceil() as i64) {
1709        for m in 1..=9 {
1710            let t = k as f64 + (m as f64).log10();
1711            if t >= lo - 1e-9 && t <= hi + 1e-9 {
1712                out.push((
1713                    t,
1714                    if m == 1 {
1715                        0
1716                    } else if m == 5 {
1717                        1
1718                    } else {
1719                        2
1720                    },
1721                ));
1722            }
1723        }
1724    }
1725    out
1726}
1727
1728/// The pixel rectangle of a data-space `[xmin, xmax, ymin, ymax]`
1729/// (non-finite bounds = the panel edges, as in ggplot2).
1730fn data_rect(
1731    x_scale: Option<&dyn crate::scale::Scale>,
1732    y_scale: Option<&dyn crate::scale::Scale>,
1733    coord: &dyn crate::coord::Coord,
1734    plot_area: &crate::render::Rect,
1735    [xmin, xmax, ymin, ymax]: [f64; 4],
1736) -> crate::render::Rect {
1737    use crate::data::Value;
1738    let m = |s: Option<&dyn crate::scale::Scale>, v: f64, edge: f64| {
1739        if v.is_finite() {
1740            s.map(|s| s.map(&Value::Float(v))).unwrap_or(edge)
1741        } else if v > 0.0 {
1742            1.0
1743        } else {
1744            0.0
1745        }
1746    };
1747    let a = coord.transform((m(x_scale, xmin, 0.0), m(y_scale, ymax, 1.0)), plot_area);
1748    let b = coord.transform((m(x_scale, xmax, 1.0), m(y_scale, ymin, 0.0)), plot_area);
1749    crate::render::Rect {
1750        x: a.0.min(b.0),
1751        y: a.1.min(b.1),
1752        width: (a.0 - b.0).abs(),
1753        height: (a.1 - b.1).abs(),
1754    }
1755}
1756
1757/// `color`, recoloured from `theme.geom.ink` when it's still at
1758/// `annotate_text`/`annotate_segment`'s hard-coded default (`(50, 50,
1759/// 50)`) and the theme actually sets `ink` — see the doc comment on
1760/// [`PlotRenderer::draw_annotations`]. `None`/an explicitly-styled colour
1761/// (anything else) passes through unchanged.
1762fn annotation_ink(color: (u8, u8, u8), theme: &crate::theme::Theme) -> (u8, u8, u8) {
1763    match (color == (50, 50, 50), theme.geom.ink) {
1764        (true, Some(ink)) => ink,
1765        _ => color,
1766    }
1767}
1768
1769/// Does a layer's data reach past `area` (#411)? Its position columns
1770/// (`x`, `xmin`, …, `yend`), mapped and transformed like the geoms do,
1771/// tested against the panel with half a pixel of slack. A polar coord
1772/// never spills (its scales fill the circle).
1773fn layer_spills(
1774    data: &crate::data::DataFrame,
1775    scales: &crate::scale::ScaleSet,
1776    coord: &dyn crate::coord::Coord,
1777    area: &crate::render::Rect,
1778) -> bool {
1779    if coord.is_polar() || data.nrows() == 0 {
1780        return false;
1781    }
1782    const SLACK: f64 = 0.5;
1783    let outside = |(px, py): (f64, f64)| {
1784        px < area.x - SLACK
1785            || px > area.x + area.width + SLACK
1786            || py < area.y - SLACK
1787            || py > area.y + area.height + SLACK
1788    };
1789    let axes = [
1790        (Aesthetic::X, ["x", "xmin", "xmax", "xend"], true),
1791        (Aesthetic::Y, ["y", "ymin", "ymax", "yend"], false),
1792    ];
1793    for (aes, cols, is_x) in axes {
1794        let Some(scale) = scales.get(&aes) else {
1795            continue;
1796        };
1797        for col in cols {
1798            let Some(values) = data.column(col) else {
1799                continue;
1800            };
1801            for v in values {
1802                if !v.as_f64().is_some_and(f64::is_finite) {
1803                    continue;
1804                }
1805                let n = scale.map(v);
1806                if !n.is_finite() {
1807                    continue;
1808                }
1809                let p = if is_x { (n, 0.5) } else { (0.5, n) };
1810                if outside(coord.transform(p, area)) {
1811                    return true;
1812                }
1813            }
1814        }
1815    }
1816    false
1817}
1818
1819/// Wrapper that overrides plot_area() to return the panel rect.
1820struct PanelBackendAdapter<'a> {
1821    inner: &'a mut dyn DrawBackend,
1822    panel_rect: crate::render::Rect,
1823}
1824
1825impl<'a> DrawBackend for PanelBackendAdapter<'a> {
1826    fn set_clipping(&mut self, on: bool) {
1827        self.inner.set_clipping(on)
1828    }
1829    fn begin_clip(&mut self, rect: &crate::render::Rect) {
1830        self.inner.begin_clip(rect)
1831    }
1832    fn enable_element_classes(&mut self, on: bool) {
1833        self.inner.enable_element_classes(on)
1834    }
1835    fn draw_rich_text(
1836        &mut self,
1837        runs: &[crate::render::rich::RichRun],
1838        pos: (f64, f64),
1839        style: &crate::render::backend::TextStyle,
1840    ) -> Result<(), RenderError> {
1841        self.inner.draw_rich_text(runs, pos, style)
1842    }
1843    fn set_element_class(&mut self, class: Option<&'static str>) {
1844        self.inner.set_element_class(class)
1845    }
1846    fn end_clip(&mut self) {
1847        self.inner.end_clip()
1848    }
1849    fn draw_image(
1850        &mut self,
1851        bytes: &[u8],
1852        rect: &crate::render::Rect,
1853        clip: &crate::render::Rect,
1854        alpha: f64,
1855    ) -> Result<(), RenderError> {
1856        self.inner.draw_image(bytes, rect, clip, alpha)
1857    }
1858    fn draw_circle(
1859        &mut self,
1860        center: (f64, f64),
1861        radius: f64,
1862        style: &crate::render::backend::PointStyle,
1863    ) -> Result<(), RenderError> {
1864        self.inner.draw_circle(center, radius, style)
1865    }
1866    fn draw_line(
1867        &mut self,
1868        points: &[(f64, f64)],
1869        style: &crate::render::backend::LineStyle,
1870    ) -> Result<(), RenderError> {
1871        self.inner.draw_line(points, style)
1872    }
1873    fn draw_quadratic(
1874        &mut self,
1875        p0: (f64, f64),
1876        ctrl: (f64, f64),
1877        p1: (f64, f64),
1878        style: &crate::render::backend::LineStyle,
1879    ) -> Result<(), RenderError> {
1880        self.inner.draw_quadratic(p0, ctrl, p1, style)
1881    }
1882    fn draw_rect(
1883        &mut self,
1884        top_left: (f64, f64),
1885        bottom_right: (f64, f64),
1886        style: &RectStyle,
1887    ) -> Result<(), RenderError> {
1888        self.inner.draw_rect(top_left, bottom_right, style)
1889    }
1890    fn draw_text(
1891        &mut self,
1892        text: &str,
1893        pos: (f64, f64),
1894        style: &TextStyle,
1895    ) -> Result<(), RenderError> {
1896        self.inner.draw_text(text, pos, style)
1897    }
1898    fn draw_polygon(
1899        &mut self,
1900        points: &[(f64, f64)],
1901        style: &RectStyle,
1902    ) -> Result<(), RenderError> {
1903        self.inner.draw_polygon(points, style)
1904    }
1905    fn draw_polygon_rings(
1906        &mut self,
1907        rings: &[Vec<(f64, f64)>],
1908        style: &RectStyle,
1909    ) -> Result<(), RenderError> {
1910        self.inner.draw_polygon_rings(rings, style)
1911    }
1912    fn draw_shape(
1913        &mut self,
1914        center: (f64, f64),
1915        radius: f64,
1916        style: &crate::render::backend::PointStyle,
1917    ) -> Result<(), RenderError> {
1918        self.inner.draw_shape(center, radius, style)
1919    }
1920    fn plot_area(&self) -> crate::render::Rect {
1921        self.panel_rect.clone()
1922    }
1923    fn set_tooltip(&mut self, tooltip: Option<String>) {
1924        self.inner.set_tooltip(tooltip)
1925    }
1926    fn set_mark_axis(&mut self, key: Option<String>) {
1927        self.inner.set_mark_axis(key)
1928    }
1929    fn set_mark_series(&mut self, series: Option<String>) {
1930        self.inner.set_mark_series(series)
1931    }
1932    fn set_mark_value(&mut self, value: Option<String>) {
1933        self.inner.set_mark_value(value)
1934    }
1935    fn set_mark_geom(&mut self, geom: Option<&str>) {
1936        self.inner.set_mark_geom(geom)
1937    }
1938    fn warn(&mut self, message: String) {
1939        self.inner.warn(message)
1940    }
1941    fn set_mark_part(&mut self, part: Option<&str>) {
1942        self.inner.set_mark_part(part)
1943    }
1944    fn set_mark_attrs(&mut self, attrs: Vec<(String, String)>) {
1945        self.inner.set_mark_attrs(attrs)
1946    }
1947
1948    fn set_mark_class(&mut self, class: Option<String>) {
1949        self.inner.set_mark_class(class)
1950    }
1951
1952    fn set_mark_group(&mut self, group: Option<(String, String)>) {
1953        self.inner.set_mark_group(group)
1954    }
1955
1956    fn set_layer_attrs(&mut self, attrs: Vec<(String, String)>) {
1957        self.inner.set_layer_attrs(attrs)
1958    }
1959    fn wants_formatted_values(&self) -> bool {
1960        self.inner.wants_formatted_values()
1961    }
1962    fn set_mark_fvalue(&mut self, value: Option<String>) {
1963        self.inner.set_mark_fvalue(value)
1964    }
1965    fn set_mark_fx(&mut self, value: Option<String>) {
1966        self.inner.set_mark_fx(value)
1967    }
1968    fn total_area(&self) -> crate::render::Rect {
1969        self.inner.total_area()
1970    }
1971    fn corner_claims(&self) -> Vec<crate::render::Rect> {
1972        self.inner.corner_claims()
1973    }
1974    fn claim_corner_box(&mut self, rect: crate::render::Rect) {
1975        self.inner.claim_corner_box(rect)
1976    }
1977    fn clear_corner_claims(&mut self) {
1978        self.inner.clear_corner_claims()
1979    }
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984    use super::*;
1985    use crate::render::backend::LineStyle;
1986    use crate::render::svg_backend::SvgBackend;
1987
1988    /// `PanelBackendAdapter` (every geom draws through one — see the panel
1989    /// loop in `PlotRenderer::render`) forwards `draw_quadratic`,
1990    /// `set_mark_part` and `set_mark_attrs` to its inner backend, and
1991    /// reports the *panel* rect (not the inner backend's full area) as its
1992    /// `plot_area`.
1993    #[test]
1994    fn panel_backend_adapter_forwards_quadratic_and_mark_metadata() {
1995        let total = crate::render::Rect {
1996            x: 0.0,
1997            y: 0.0,
1998            width: 200.0,
1999            height: 200.0,
2000        };
2001        let panel_rect = crate::render::Rect {
2002            x: 10.0,
2003            y: 10.0,
2004            width: 80.0,
2005            height: 80.0,
2006        };
2007        let mut backend = SvgBackend::new(200, 200, total);
2008        let mut adapter = PanelBackendAdapter {
2009            inner: &mut backend,
2010            panel_rect: panel_rect.clone(),
2011        };
2012
2013        assert_eq!(adapter.plot_area().x, panel_rect.x);
2014        assert_eq!(adapter.plot_area().width, panel_rect.width);
2015
2016        adapter.set_mark_part(Some("arrow"));
2017        adapter.set_mark_attrs(vec![("data-x".to_string(), "v".to_string())]);
2018        adapter
2019            .draw_quadratic((0.0, 0.0), (5.0, 10.0), (10.0, 0.0), &LineStyle::default())
2020            .unwrap();
2021        adapter.set_mark_part(None);
2022        adapter.set_mark_attrs(Vec::new());
2023
2024        let svg = backend.finish();
2025        assert!(svg.contains("<path"));
2026        assert!(svg.contains("data-part=\"arrow\""));
2027        assert!(svg.contains("data-x=\"v\""));
2028    }
2029}