ggplot_rs/geom/mod.rs
1#[cfg(feature = "sf")]
2pub mod annotation_scale;
3pub mod arcdiagram;
4pub mod area;
5pub mod arrow;
6pub mod bar;
7pub mod bin2d;
8#[cfg(feature = "sf")]
9pub mod bivariate_legend;
10pub mod blank;
11pub mod boxplot;
12pub mod bracket;
13pub mod bullet;
14pub mod candlestick;
15pub mod censor;
16pub mod chord;
17pub mod col;
18pub mod contour;
19pub mod cooks;
20pub mod count;
21pub mod crossbar;
22pub mod curve;
23pub(crate) mod decorative;
24pub mod density;
25pub mod density2d;
26pub mod density_ridges;
27pub mod dotplot;
28pub mod errorbar;
29pub mod errorbarh;
30pub mod flags;
31#[cfg(feature = "sf")]
32pub mod flow;
33pub mod freqpoly;
34pub mod funnel;
35pub mod hex;
36pub mod histogram;
37pub mod jitter;
38pub(crate) mod layer_attrs;
39pub mod line;
40pub mod linerange;
41pub mod lollipop;
42#[cfg(feature = "tiles")]
43pub mod map_tile;
44pub mod marimekko;
45pub mod network;
46pub mod pack;
47pub mod pareto;
48pub mod partition;
49pub mod path;
50pub mod point;
51pub mod pointrange;
52pub mod polygon;
53pub mod qq;
54pub mod raster;
55pub mod rect;
56pub mod refline;
57pub mod renko;
58pub mod repel;
59pub mod ribbon;
60pub mod rug;
61pub mod sankey;
62pub mod segment;
63#[cfg(feature = "sf")]
64pub mod sf;
65#[cfg(feature = "sf")]
66pub mod sf_text;
67pub mod smooth;
68pub mod spoke;
69pub mod step;
70pub(crate) mod support;
71pub mod text;
72pub mod tile;
73pub mod tilemap;
74pub mod timeline;
75pub mod tree;
76pub mod treemap;
77pub mod variablepie;
78pub mod venn;
79pub mod violin;
80pub mod waffle;
81pub mod waterfall;
82pub mod windbarb;
83pub mod wordcloud;
84pub mod xrange;
85
86use std::collections::HashMap;
87
88use crate::aes::Aesthetic;
89use crate::coord::Coord;
90use crate::data::DataFrame;
91use crate::position::Position;
92use crate::render::backend::DrawBackend;
93use crate::render::RenderError;
94use crate::scale::ScaleSet;
95use crate::stat::Stat;
96use crate::theme::Theme;
97
98/// Fixed (non-mapped) visual parameters for a geom.
99#[derive(Clone, Debug, Default)]
100pub struct GeomParams {
101 pub values: HashMap<String, f64>,
102 pub color: Option<(u8, u8, u8)>,
103 pub fill: Option<(u8, u8, u8)>,
104 pub alpha: Option<f64>,
105}
106
107/// Trait for geometric objects that draw data on the plot.
108pub trait Geom: Send + Sync {
109 /// Draw this geometry.
110 fn draw(
111 &self,
112 data: &DataFrame,
113 coord: &dyn Coord,
114 scales: &ScaleSet,
115 theme: &Theme,
116 backend: &mut dyn DrawBackend,
117 ) -> Result<(), RenderError>;
118
119 /// Required aesthetics.
120 fn required_aes(&self) -> Vec<Aesthetic>;
121
122 /// Default stat for this geom.
123 fn default_stat(&self) -> Box<dyn Stat>;
124
125 /// Default position adjustment.
126 fn default_position(&self) -> Box<dyn Position>;
127
128 /// Non-mapped visual defaults.
129 fn default_params(&self) -> GeomParams;
130
131 /// A figure-level layer on a faceted plot: the height of the band it
132 /// needs under the whole panel grid at `width`, when it draws once
133 /// there instead of in every panel (e.g. `GeomAttribution::figure`).
134 /// Default `None` (drawn per panel).
135 fn figure_band_height(&self, _width: f64, _theme: &Theme) -> Option<f64> {
136 None
137 }
138
139 /// Name for debug/display.
140 fn name(&self) -> &str;
141
142 /// Apply a brand/primary color to this geom's single-series default (its
143 /// `color` or `fill`). The build pipeline calls this only when the layer has
144 /// no color/fill aesthetic mapped, so an explicit mapping always wins. The
145 /// default is a no-op; series geoms override it.
146 fn set_series_color(&mut self, _color: (u8, u8, u8)) {}
147
148 /// Recolour this geom's *built-in default* colour/fill from the theme's
149 /// ink/paper/accent palette (B2-7's `element_geom`), called once per
150 /// layer before [`set_series_color`](Self::set_series_color) — and only
151 /// when the layer was *not* configured explicitly (`geom_*_with(...)`;
152 /// the build pipeline gates the call on the same `explicit_style` flag
153 /// `set_series_color`'s brand-colour application is gated on). That
154 /// gate, not a field-value comparison, is what keeps an explicit colour
155 /// untouched: a geom that honours this replaces a field still at its
156 /// own hard-coded default (black/`(50, 50, 50)` → `ink`, white →
157 /// `paper`) by comparing its *value*, which cannot by itself tell an
158 /// explicitly-set colour that happens to equal that default (e.g.
159 /// `GeomPoint { color: (0, 0, 0), .. }` under `geom_point_with`) apart
160 /// from one that was simply never touched — only skipping the call
161 /// entirely for an explicitly-configured layer closes that gap (review
162 /// PR#141 finding 4). Default no-op; `None` fields of `ElementGeom`
163 /// change nothing, so the default theme (no ink/paper/accent set)
164 /// stays byte-identical.
165 fn apply_element_geom(&mut self, _el: &crate::theme::elements::ElementGeom) {}
166
167 /// Whether this geom draws from a 0 baseline, so the Y scale should include
168 /// 0 even when `y` is explicitly mapped (bars/columns/area/histograms — as
169 /// in ggplot2). Default false.
170 fn include_zero_baseline(&self) -> bool {
171 false
172 }
173
174 /// Whether `-Inf`/`Inf` position values are meaningful for this geom
175 /// (ggplot2: "extend to the panel edge"). Rows with infinite positions are
176 /// otherwise dropped with a warning before stats run. `NaN` is always
177 /// dropped — unless [`Geom::keeps_na_as_break`] says otherwise. Default
178 /// false; `geom_rect` returns true.
179 fn allows_infinite(&self) -> bool {
180 false
181 }
182
183 /// Whether a row with a non-finite position is a *break* rather than a
184 /// removal (ggplot2's `GeomPath`/`GeomLine` NA handling): the row still
185 /// counts toward the "removed N rows" warning, but it is **not** deleted
186 /// from the built `DataFrame`, so the geom's own per-group point list
187 /// still contains it — as a non-finite pixel position after the scale
188 /// and coordinate transform, which [`crate::render::backend::DrawBackend::draw_line`]
189 /// splits on into separate polylines (ggplot2's NA-break semantics)
190 /// instead of silently joining the finite points across the gap.
191 /// Default false (the row is dropped, as for every other geom);
192 /// line/path/step return true.
193 fn keeps_na_as_break(&self) -> bool {
194 false
195 }
196
197 /// Whether this geom calls [`set_row_extras`] (so it reads
198 /// [`Aes::tooltip`](crate::aes::Aes::tooltip) and
199 /// [`Aes::data_attr`](crate::aes::Aes::data_attr), and a
200 /// [`GGPlot::tooltip`](crate::plot::GGPlot::tooltip) template on it has
201 /// a visible effect). Default false; the build pipeline warns when a
202 /// `tooltip()` template is set on a layer whose geom returns false
203 /// here (review PR#141 finding 6, LOW: a template silently did nothing
204 /// on e.g. `geom_area`/`geom_boxplot`/`geom_ribbon`). Point, segment,
205 /// curve, path, line, hex, polygon, sf, rect/tile and col/bar/
206 /// histogram return true.
207 fn consumes_tooltips(&self) -> bool {
208 false
209 }
210
211 /// The legend key this geom draws (ggplot2's per-geom `draw_key`, #410).
212 /// `None` = the plain per-aesthetic swatch (fill square, point shape,
213 /// line type …), which is also what a key falls back to for a geom not
214 /// listed here. Overridable per layer with
215 /// [`GGPlot::key_glyph`](crate::plot::GGPlot::key_glyph).
216 fn key_glyph(&self) -> Option<crate::guide::key::KeyGlyph> {
217 use crate::guide::key::KeyGlyph as K;
218 Some(match self.name() {
219 "point" | "jitter" | "count" | "qq" | "sf_point" => K::Point,
220 "line" | "path" | "step" | "freqpoly" | "qq_line" | "segment" | "curve" | "spoke"
221 | "errorbar" | "errorbarh" | "hline" | "density_2d" | "contour" | "rug" => K::Path,
222 "smooth" => K::Smooth,
223 "boxplot" => K::Boxplot,
224 "crossbar" => K::Crossbar,
225 "pointrange" => K::Pointrange,
226 "linerange" => K::Linerange,
227 "vline" => K::Vline,
228 "abline" => K::Abline,
229 "text" | "text_repel" => K::Text,
230 "label" | "label_repel" => K::Label,
231 "dotplot" => K::Dotplot,
232 "bar" | "col" | "histogram" | "area" | "ribbon" | "density" | "violin" | "polygon"
233 | "rect" | "tile" | "stepribbon" | "density_ridges" => K::Polygon,
234 "raster" | "hex" | "bin2d" => K::Rect,
235 _ => return None,
236 })
237 }
238
239 /// The layer's constant `fill` and `alpha` (its parameters, not mapped
240 /// aesthetics), which its legend key is drawn with where the legend
241 /// doesn't map them — ggplot2's draw_key_polygon reads the layer's
242 /// `fill` / `alpha` params the same way (#473). `None`: the key glyph's
243 /// own defaults (grey35, opaque).
244 fn key_fill(&self) -> Option<((u8, u8, u8), f64)> {
245 None
246 }
247
248 /// Geom-specific data preparation after the stat and position steps but
249 /// before scale training (ggplot2's `GeomX$setup_data`) — e.g. a tile adds
250 /// its `xmin`/`xmax`/`ymin`/`ymax` extents so continuous scales train on
251 /// them. `warnings` is the layer's own build-warnings sink (B3-5: added
252 /// so `GeomBoxplot` can warn on an unparsable outlier value here, the
253 /// same channel every other build warning uses — never `eprintln!`).
254 /// Default: no-op.
255 fn setup_data(&self, _data: &mut DataFrame, _warnings: &mut Vec<String>) {}
256
257 /// Whether the layer inherits the plot-level aesthetic mapping (ggplot2's
258 /// `inherit.aes`). Reference-line geoms return false, as in ggplot2, so a
259 /// plot-level `color`/`x`/`y` mapping does not leak into a `geom_hline`.
260 /// Default true.
261 fn inherit_aes(&self) -> bool {
262 true
263 }
264
265 /// Canonical column names this geom only reads when the user actually
266 /// mapped the corresponding aesthetic (D3, B3-5) — never from a raw
267 /// column that merely happens to share the name (e.g. a SQL alias
268 /// literally called `lower`). `build_layer` drops any of these columns
269 /// right after `resolve_mappings` unless the aesthetic was mapped.
270 /// Default empty; `GeomBoxplot` returns `["lower", "middle", "upper",
271 /// "outliers"]`.
272 fn mapped_only_columns(&self) -> &'static [&'static str] {
273 &[]
274 }
275
276 /// Stat-dependent layer validation beyond [`required_aes`](Self::required_aes)
277 /// (B3-5): called once per layer with the resolved (pre-stat, post-D3-drop)
278 /// column names and the layer's stat name, before the stat runs. Default
279 /// no-op. `GeomBoxplot` requires `lower`/`middle`/`upper` to be mapped
280 /// under `stat="identity"`, else `y`.
281 fn validate_layer(&self, _pre_stat_columns: &[String], _stat_name: &str) -> Result<(), String> {
282 Ok(())
283 }
284}
285
286/// Format a value for a hover tooltip — strings verbatim, numbers rounded short,
287/// `Na` empty.
288pub(crate) fn tip_value(v: &crate::data::Value) -> String {
289 crate::format::format_value(v)
290}
291
292/// Half-width (normalized panel units) for bars on a continuous / date x axis:
293/// `width` × the smallest gap between distinct mapped x positions, like
294/// ggplot2's `resolution(x)`. A fixed fraction would make 60 daily bars overlap
295/// and 3 bars look like slivers. Falls back to `fallback` with < 2 distinct xs.
296pub(crate) fn continuous_bar_half_width(
297 mapped_xs: impl Iterator<Item = f64>,
298 width: f64,
299 fallback: f64,
300) -> f64 {
301 let mut xs: Vec<f64> = mapped_xs.filter(|x| x.is_finite()).collect();
302 xs.sort_by(|a, b| a.total_cmp(b));
303 xs.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
304 let gap = xs
305 .windows(2)
306 .map(|w| w[1] - w[0])
307 .fold(f64::INFINITY, f64::min);
308 if gap.is_finite() && gap > 0.0 {
309 gap * width / 2.0
310 } else {
311 fallback
312 }
313}
314
315/// Raw (unformatted) value for a `data-value` attribute: numbers in shortest
316/// round-trip form, date-times as epoch seconds, strings verbatim. `None` for
317/// missing / non-finite values.
318pub(crate) fn raw_value(v: &crate::data::Value) -> Option<String> {
319 use crate::data::Value;
320 match v {
321 Value::Float(f) if f.is_finite() => Some(format!("{f}")),
322 Value::Float(_) | Value::Na => None,
323 Value::Integer(i) => Some(i.to_string()),
324 Value::DateTime(s) => Some(s.to_string()),
325 Value::Str(s) => Some(s.clone()),
326 Value::Bool(b) => Some(b.to_string()),
327 }
328}
329
330/// The series key of row `i` — its colour, else fill, else group level — for
331/// a `data-series` attribute.
332pub(crate) fn series_key(data: &DataFrame, i: usize) -> Option<String> {
333 ["color", "fill", "group"].iter().find_map(|c| {
334 data.column(c)
335 .and_then(|col| col.get(i))
336 .filter(|v| !v.is_na())
337 .map(tip_value)
338 .filter(|s| !s.is_empty())
339 })
340}
341
342/// The raw measured y of row `i`. Stacked/filled positions overwrite `y` with
343/// the cumulative top, so prefer the pre-position value they preserve.
344pub(crate) fn measured_value(data: &DataFrame, i: usize) -> Option<String> {
345 measured_raw(data, i).and_then(raw_value)
346}
347
348/// [`measured_value`], but the raw `Value` itself (for `Scale::format_value`,
349/// B2-4) instead of its display string.
350pub(crate) fn measured_raw(data: &DataFrame, i: usize) -> Option<&crate::data::Value> {
351 data.column(crate::position::RAW_Y_COL)
352 .or_else(|| data.column("y"))
353 .and_then(|c| c.get(i))
354}
355
356/// Set all per-mark metadata (tooltip, `data-x`, `data-series`, `data-value`)
357/// for the next drawn mark(s).
358pub(crate) fn set_mark(
359 backend: &mut dyn DrawBackend,
360 tooltip: Option<String>,
361 x: Option<String>,
362 series: Option<String>,
363 value: Option<String>,
364) {
365 backend.set_tooltip(tooltip);
366 backend.set_mark_axis(x);
367 backend.set_mark_series(series);
368 backend.set_mark_value(value);
369}
370
371/// Resolve the font family for a text-drawing geom (text, label, repel,
372/// bracket, annotations) — B2-6/B2-7's three-tier order: `geom_family`
373/// (the geom's own `fontfamily`, e.g. `GeomText::with_fontfamily`) when
374/// set, else `theme.geom.family` (B2-7's `element_geom`), else
375/// `theme.text.family` (read after `Theme::resolve_inheritance`, so an
376/// inherited value is already resolved), else `None` (the SVG backend's
377/// own `"sans-serif"` default). `ElementText::family` defaults to
378/// `"sans-serif"` itself and `theme.geom.family` defaults to `None`, so
379/// with nothing configured anywhere this already returns
380/// `Some("sans-serif")` — the same string the backend would have defaulted
381/// to, so default output stays byte-identical.
382pub(crate) fn resolve_font_family(geom_family: &str, theme: &Theme) -> Option<String> {
383 if !geom_family.is_empty() {
384 Some(geom_family.to_string())
385 } else if let Some(family) = &theme.geom.family {
386 Some(family.clone())
387 } else if theme.text.family.is_empty() {
388 None
389 } else {
390 Some(theme.text.family.clone())
391 }
392}
393
394/// Parse a per-row `Aes::fontface` (#228) string the same way `theme()`'s
395/// `face` is (`src/theme/config.rs::text`): `"plain"`/`"normal"` ->
396/// [`FontFace::Plain`], `"bold"` -> `Bold`, `"italic"`/`"oblique"` ->
397/// `Italic`, case-insensitive. `None` for anything else, so the caller falls
398/// back to the geom's own fixed fontface — a row's unparseable value is
399/// silently ignored rather than a build-time warning, since it's only known
400/// once the data is in hand.
401pub(crate) fn parse_fontface(raw: &str) -> Option<crate::render::backend::FontFace> {
402 use crate::render::backend::FontFace;
403 match raw.to_lowercase().as_str() {
404 "plain" | "normal" => Some(FontFace::Plain),
405 "bold" => Some(FontFace::Bold),
406 "italic" | "oblique" => Some(FontFace::Italic),
407 _ => None,
408 }
409}
410
411/// Clear all per-mark metadata after a geom has drawn its marks.
412pub(crate) fn clear_mark(backend: &mut dyn DrawBackend) {
413 set_mark(backend, None, None, None, None);
414 backend.set_mark_fvalue(None);
415 backend.set_mark_fx(None);
416}
417
418/// Set `data-fvalue`/`data-fx` (B2-4) for the mark `set_mark` just
419/// configured, formatted through the relevant scale — `value_aes`'s (Y for
420/// most geoms, Fill for hex/tile) for `data-fvalue`, X for `data-fx`.
421/// Always sets both (even to `None`), so a row without a formattable value
422/// doesn't inherit an earlier row's — the same per-row reset discipline as
423/// [`set_row_extras`]. A no-op query (`wants_formatted_values`) short-
424/// circuits the scale lookups entirely when
425/// [`crate::plot::GGPlot::svg_formatted_values`] is off (the default), so
426/// this costs nothing and changes nothing when never turned on.
427pub(crate) fn set_mark_formatted(
428 backend: &mut dyn DrawBackend,
429 scales: &crate::scale::ScaleSet,
430 x_raw: Option<&crate::data::Value>,
431 value_aes: &Aesthetic,
432 value_raw: Option<&crate::data::Value>,
433) {
434 if !backend.wants_formatted_values() {
435 backend.set_mark_fvalue(None);
436 backend.set_mark_fx(None);
437 return;
438 }
439 let fvalue = value_raw
440 .filter(|v| !v.is_na())
441 .and_then(|v| scales.get(value_aes).and_then(|s| s.format_value(v)));
442 backend.set_mark_fvalue(fvalue);
443 let fx = x_raw
444 .filter(|v| !v.is_na())
445 .and_then(|v| scales.get(&Aesthetic::X).and_then(|s| s.format_value(v)));
446 backend.set_mark_fx(fx);
447}
448
449/// Apply one row's interactive aesthetics (B1-7: `Aes::tooltip`,
450/// `Aes::data_attr`) to the backend.
451///
452/// A validated `Aes::tooltip` mapping replaces the geom's own `<title>`
453/// (call the geom's own `set_mark(...)` *before* this, so a custom tooltip
454/// wins) for *every* row — including when this row's value is NA, which
455/// explicitly resets the tooltip to `None` (no `<title>` at all, even if
456/// the geom has its own default): once `Aes::tooltip` is mapped, it owns
457/// the tooltip for the whole layer, the same row-by-row way `color` or
458/// `size` would. It never inherits an earlier row's. It's read from
459/// [`crate::aes::TOOLTIP_INTERNAL_NAME`], not a plain `"tooltip"` column
460/// name, so an unrelated raw source column literally called `"tooltip"`
461/// can never drive every mark's hover title (the same class of bug fixed
462/// for `data_attr` below). `set_tooltip` is only called at all when that
463/// internal column exists, so layers that never map `Aes::tooltip` don't
464/// touch whatever the geom already set.
465///
466/// Every validated `data_attr` column becomes a `data-<name>` attribute.
467/// These are stored under [`crate::aes::DATA_ATTR_INTERNAL_PREFIX`] rather
468/// than scanned by a `"data-"` name prefix: a raw source column that merely
469/// happens to be named like a data attribute (e.g. a SQL alias) was never
470/// validated and must never reach `push_mark` (see
471/// `aes::validated_data_attr_internal_name`, and `aes::mapping`'s defensive
472/// drop of any raw column that could forge an internal name).
473/// `set_mark_attrs` is *always* called, even with an empty list, so a row
474/// with no attrs doesn't inherit an earlier row's.
475pub(crate) fn set_row_extras(backend: &mut dyn DrawBackend, data: &DataFrame, row: usize) {
476 if let Some(col) = data.column(crate::aes::TOOLTIP_INTERNAL_NAME) {
477 let tip = col.get(row).filter(|v| !v.is_na()).map(tip_value);
478 backend.set_tooltip(tip);
479 }
480 let mut attrs = Vec::new();
481 for name in data.column_names() {
482 if let Some(target) = name.strip_prefix(crate::aes::DATA_ATTR_INTERNAL_PREFIX) {
483 if let Some(v) = data.column(name).and_then(|c| c.get(row)) {
484 if !v.is_na() {
485 attrs.push((target.to_string(), tip_value(v)));
486 }
487 }
488 }
489 }
490 backend.set_mark_attrs(attrs);
491 // #221: `Aes::class` tokens (sanitized at build time) and the
492 // `Aes::group_wrap` group — always set, so a row never inherits an
493 // earlier row's.
494 let class = data
495 .column(crate::aes::CLASS_INTERNAL_NAME)
496 .and_then(|c| c.get(row))
497 .filter(|v| !v.is_na())
498 .map(tip_value)
499 .filter(|s| !s.is_empty());
500 backend.set_mark_class(class);
501 let mut group = None;
502 for name in data.column_names() {
503 if let Some(target) = name.strip_prefix(crate::aes::GROUP_WRAP_INTERNAL_PREFIX) {
504 if let Some(v) = data.column(name).and_then(|c| c.get(row)) {
505 if !v.is_na() {
506 group = Some((target.to_string(), tip_value(v)));
507 break;
508 }
509 }
510 }
511 }
512 backend.set_mark_group(group);
513}
514
515/// Clear interactive-aesthetic attributes *and* the tooltip after a layer
516/// finishes drawing, so neither leaks into a later layer, an annotation, or
517/// the legend's own background/key marks (which share the same backend
518/// instance for the rest of the render).
519pub(crate) fn clear_row_extras(backend: &mut dyn DrawBackend) {
520 backend.set_tooltip(None);
521 backend.set_mark_attrs(Vec::new());
522 backend.set_mark_class(None);
523 backend.set_mark_group(None);
524}
525
526/// Draw one group's polyline (path/line), honouring a per-point `linewidth`.
527/// A constant width draws one polyline (byte-identical to the pre-linewidth
528/// behaviour); a varying width falls back to drawing segment-by-segment,
529/// since a single stroked polyline can't vary its width along its length.
530/// An optional arrowhead is drawn once per polyline, using the width at its
531/// last point (closest to where a default `ends(Last)` head sits).
532#[allow(clippy::too_many_arguments)]
533pub(crate) fn draw_polyline_with_linewidth(
534 backend: &mut dyn DrawBackend,
535 points: &[(f64, f64)],
536 widths: &[f64],
537 color: (u8, u8, u8),
538 alpha: f64,
539 linetype: crate::render::backend::Linetype,
540 lineend: crate::render::backend::LineEnd,
541 arrow: Option<&crate::geom::arrow::Arrow>,
542 fallback_width: f64,
543) -> Result<(), RenderError> {
544 if points.len() < 2 {
545 return Ok(());
546 }
547 let w0 = widths.first().copied().unwrap_or(fallback_width);
548 let varies = widths.len() == points.len() && widths.iter().any(|&w| (w - w0).abs() > 1e-9);
549 if varies {
550 for i in 0..points.len() - 1 {
551 backend.draw_line(
552 &points[i..=i + 1],
553 &crate::render::backend::LineStyle {
554 color,
555 alpha,
556 width: widths[i],
557 linetype: linetype.clone(),
558 lineend,
559 },
560 )?;
561 }
562 } else {
563 backend.draw_line(
564 points,
565 &crate::render::backend::LineStyle {
566 color,
567 alpha,
568 width: w0,
569 linetype,
570 lineend,
571 },
572 )?;
573 }
574 if let Some(arrow) = arrow {
575 let resolved_width = widths.last().copied().unwrap_or(fallback_width);
576 let hs = crate::geom::arrow::heads(points, arrow, resolved_width);
577 crate::geom::arrow::draw_heads(backend, &hs, arrow, color, alpha, resolved_width)?;
578 }
579 Ok(())
580}