Skip to main content

ggplot_rs/geom/
bullet.rs

1//! Bullet chart (Stephen Few): `geom_bullet`. Per category, qualitative
2//! range bands (dark = poor → light = good), a narrow bar for the actual
3//! value and a perpendicular tick for the target. Horizontal by default.
4
5use crate::aes::Aesthetic;
6use crate::coord::Coord;
7use crate::data::{DataFrame, Value};
8use crate::position::identity::PositionIdentity;
9use crate::position::Position;
10use crate::render::backend::{DrawBackend, LineStyle, Linetype, RectStyle};
11use crate::render::RenderError;
12use crate::scale::ScaleSet;
13use crate::stat::identity::StatIdentity;
14use crate::stat::Stat;
15use crate::theme::Theme;
16
17use super::{Geom, GeomParams};
18
19/// Bullet chart. Map `x` (KPI) and `y` (actual value); name the target
20/// column in `target` and give the range bands either as shared
21/// breakpoints (`ranges`) or per-row columns (`range_columns`).
22pub struct GeomBullet {
23    /// Column holding each row's target (`None` draws no target tick).
24    pub target: Option<String>,
25    /// Shared band upper limits, ascending (used when `range_columns` is
26    /// empty).
27    pub ranges: Vec<f64>,
28    /// Columns holding each row's band upper limits, ascending.
29    pub range_columns: Vec<String>,
30    /// Band colours from the first (poorest) band outward; recycled.
31    pub band_colors: Vec<(u8, u8, u8)>,
32    pub bar_color: (u8, u8, u8),
33    pub target_color: (u8, u8, u8),
34    /// Band, bar and target-tick widths as fractions of the category slot.
35    pub band_width: f64,
36    pub bar_width: f64,
37    pub target_width: f64,
38}
39
40impl Default for GeomBullet {
41    fn default() -> Self {
42        GeomBullet {
43            target: None,
44            ranges: Vec::new(),
45            range_columns: Vec::new(),
46            band_colors: vec![(150, 150, 150), (190, 190, 190), (225, 225, 225)],
47            bar_color: (40, 40, 40),
48            target_color: (200, 30, 30),
49            band_width: 0.75,
50            bar_width: 0.28,
51            target_width: 0.55,
52        }
53    }
54}
55
56impl GeomBullet {
57    /// Bullet chart with targets from `target_column`.
58    pub fn with_target(mut self, target_column: impl Into<String>) -> Self {
59        self.target = Some(target_column.into());
60        self
61    }
62
63    /// Shared band limits (ascending), e.g. `[50.0, 75.0, 100.0]`.
64    pub fn with_ranges(mut self, ranges: impl IntoIterator<Item = f64>) -> Self {
65        self.ranges = ranges.into_iter().collect();
66        self
67    }
68
69    /// Per-row band limits from these columns (ascending).
70    pub fn with_range_columns<S: Into<String>>(
71        mut self,
72        cols: impl IntoIterator<Item = S>,
73    ) -> Self {
74        self.range_columns = cols.into_iter().map(Into::into).collect();
75        self
76    }
77
78    fn row_ranges(&self, data: &DataFrame, i: usize) -> Vec<f64> {
79        let mut r: Vec<f64> = if self.range_columns.is_empty() {
80            self.ranges.clone()
81        } else {
82            self.range_columns
83                .iter()
84                .filter_map(|c| data.column(c).and_then(|col| col[i].as_f64()))
85                .collect()
86        };
87        r.retain(|v| v.is_finite());
88        r.sort_by(|a, b| a.total_cmp(b));
89        r
90    }
91}
92
93impl Geom for GeomBullet {
94    fn draw(
95        &self,
96        data: &DataFrame,
97        coord: &dyn Coord,
98        scales: &ScaleSet,
99        _theme: &Theme,
100        backend: &mut dyn DrawBackend,
101    ) -> Result<(), RenderError> {
102        let x_col = data
103            .column("x")
104            .ok_or(RenderError::MissingAesthetic("x".into()))?;
105        let y_col = data
106            .column("y")
107            .ok_or(RenderError::MissingAesthetic("y".into()))?;
108        let target_col = self.target.as_deref().and_then(|c| data.column(c));
109        let area = backend.plot_area();
110        let x_scale = scales.get(&Aesthetic::X);
111        let my = |v: f64| {
112            scales
113                .get(&Aesthetic::Y)
114                .map(|s| s.map(&Value::Float(v)))
115                .unwrap_or(0.0)
116        };
117        let slot = if x_scale.map(|s| s.is_discrete()).unwrap_or(false) {
118            let n = x_scale.map(|s| s.breaks().len()).unwrap_or(1).max(1) as f64;
119            1.0 / (n * if coord.discrete_expand() { 1.1 } else { 1.0 })
120        } else {
121            2.0 * super::continuous_bar_half_width(
122                x_col.iter().filter_map(|v| x_scale.map(|s| s.map(v))),
123                1.0,
124                0.05,
125            )
126        };
127        let rect = |b: &mut dyn DrawBackend, nx: f64, frac: f64, lo: f64, hi: f64, fill| {
128            let h = slot * frac / 2.0;
129            let (a, c) = (
130                coord.transform((nx - h, my(hi)), &area),
131                coord.transform((nx + h, my(lo)), &area),
132            );
133            b.draw_rect(
134                (a.0.min(c.0), a.1.min(c.1)),
135                (a.0.max(c.0), a.1.max(c.1)),
136                &RectStyle {
137                    fill: Some(fill),
138                    stroke: None,
139                    stroke_width: 0.0,
140                    alpha: 1.0,
141                    clip: true,
142                    corner_radius: 0.0,
143                },
144            )
145        };
146
147        for i in 0..data.nrows() {
148            let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.5);
149            let Some(actual) = y_col[i].as_f64() else {
150                continue;
151            };
152            let x_txt = super::tip_value(&x_col[i]);
153            super::clear_mark(backend);
154            let mut lo = 0.0;
155            for (k, hi) in self.row_ranges(data, i).into_iter().enumerate() {
156                let color = self.band_colors[k % self.band_colors.len().max(1)];
157                rect(backend, nx, self.band_width, lo, hi, color)?;
158                lo = hi;
159            }
160            let target = target_col.and_then(|c| c[i].as_f64());
161            let tip = match target {
162                Some(t) => format!(
163                    "{x_txt}: {} (target {})",
164                    super::tip_value(&y_col[i]),
165                    super::tip_value(&Value::Float(t))
166                ),
167                None => format!("{x_txt}: {}", super::tip_value(&y_col[i])),
168            };
169            super::set_mark(
170                backend,
171                Some(tip),
172                Some(x_txt),
173                super::series_key(data, i),
174                super::raw_value(&y_col[i]),
175            );
176            super::set_row_extras(backend, data, i);
177            rect(backend, nx, self.bar_width, 0.0, actual, self.bar_color)?;
178            if let Some(t) = target {
179                let h = slot * self.target_width / 2.0;
180                backend.draw_line(
181                    &[
182                        coord.transform((nx - h, my(t)), &area),
183                        coord.transform((nx + h, my(t)), &area),
184                    ],
185                    &LineStyle {
186                        color: self.target_color,
187                        alpha: 1.0,
188                        width: 3.0,
189                        linetype: Linetype::Solid,
190                        ..Default::default()
191                    },
192                )?;
193            }
194        }
195        super::clear_mark(backend);
196        super::clear_row_extras(backend);
197        Ok(())
198    }
199
200    fn required_aes(&self) -> Vec<Aesthetic> {
201        vec![Aesthetic::X, Aesthetic::Y]
202    }
203    fn default_stat(&self) -> Box<dyn Stat> {
204        Box::new(StatIdentity)
205    }
206    fn default_position(&self) -> Box<dyn Position> {
207        Box::new(PositionIdentity)
208    }
209    fn default_params(&self) -> GeomParams {
210        GeomParams::default()
211    }
212    fn name(&self) -> &str {
213        "bullet"
214    }
215    fn include_zero_baseline(&self) -> bool {
216        true
217    }
218    fn consumes_tooltips(&self) -> bool {
219        true
220    }
221    fn set_series_color(&mut self, color: (u8, u8, u8)) {
222        self.bar_color = color;
223    }
224
225    /// The y scale must hold the bands and targets, not just the actuals.
226    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
227        let n = data.nrows();
228        let target_col = self.target.as_deref().and_then(|c| data.column(c));
229        let y_col = data.column("y");
230        let ymax: Vec<Value> = (0..n)
231            .map(|i| {
232                let mut m = y_col.and_then(|c| c[i].as_f64()).unwrap_or(f64::NAN);
233                if let Some(t) = target_col.and_then(|c| c[i].as_f64()) {
234                    m = m.max(t);
235                }
236                for r in self.row_ranges(data, i) {
237                    m = m.max(r);
238                }
239                Value::Float(m)
240            })
241            .collect();
242        if !data.has_column("ymax") {
243            data.add_column("ymax".into(), ymax);
244        }
245    }
246}