Skip to main content

ggplot_rs/position/
mod.rs

1pub mod dodge;
2pub mod dodge2;
3pub mod fill;
4pub mod identity;
5pub mod jitter;
6pub mod jitterdodge;
7pub mod nudge;
8pub mod stack;
9
10use crate::data::DataFrame;
11
12/// Parameters for position adjustments.
13#[derive(Clone, Debug)]
14pub struct PositionParams {
15    pub width: f64,
16    pub height: f64,
17    /// The whole layer's group rank order (review #167 finding 2), set by
18    /// `build::compute_position_per_panel` from [`Position::group_order`]
19    /// *before* splitting the layer's rows into panels. A per-panel
20    /// position (`PositionDodge`/`PositionDodge2`) ranks groups from this
21    /// — the whole layer's first-appearance order — rather than
22    /// recomputing it from whatever order each panel's own rows happen to
23    /// list groups in, so every panel draws a given group on the same
24    /// side (matching 0.22.2 and ggplot2 4.0.3's global `group` id).
25    /// `None` outside the per-panel path (or for positions that don't
26    /// override `group_order`), in which case each position falls back
27    /// to its previous, locally-computed order.
28    pub group_order: Option<Vec<String>>,
29}
30
31impl Default for PositionParams {
32    fn default() -> Self {
33        PositionParams {
34            width: 0.9,
35            height: 0.0,
36            group_order: None,
37        }
38    }
39}
40
41/// Trait for position adjustments.
42pub trait Position: Send + Sync {
43    /// Adjust positions for data.
44    fn compute(&self, data: &mut DataFrame, params: &PositionParams);
45
46    fn name(&self) -> &str;
47
48    /// Whether this position must run separately per facet panel rather
49    /// than once on the pooled rows before facets are split (B3-3, D8).
50    /// Default `false`. `PositionStack`/`PositionFill` override this to
51    /// `true` — a shared x category's stack height must not mix rows from
52    /// different panels. `PositionDodge`/`PositionDodge2`/`PositionJitter`
53    /// also override this to `true` (sipemu/ggplot-rs#153): ggplot2
54    /// computes a dodge's group count/offset via `compute_panel` (per
55    /// panel), and `position_jitter`'s default width/height — 40% of
56    /// `resolution()` — is likewise measured from each panel's own x/y,
57    /// not the rows pooled across panels (verified against ggplot2 4.0.3
58    /// with `layer_data()` on faceted plots; see `tests/position_panels_r.rs`).
59    fn per_panel(&self) -> bool {
60        false
61    }
62
63    /// The whole layer's group rank order (first-appearance, pre-panel-
64    /// split), for a `per_panel` position whose group *ranking* must come
65    /// from the whole layer even though its per-x *counting* stays
66    /// per-panel (review #167 finding 2; sipemu/ggplot-rs#153).
67    /// `build::compute_position_per_panel` calls this on the pooled data
68    /// before splitting and threads the result through
69    /// [`PositionParams::group_order`] to every panel's `compute` call.
70    /// Default `None` — positions that don't need this (stack/fill/
71    /// jitter) are unaffected.
72    fn group_order(&self, _data: &DataFrame) -> Option<Vec<String>> {
73        None
74    }
75
76    /// Validate this position's own parameters once per layer (D2);
77    /// default no-op. Implementations push a warning and fall back to a
78    /// sane default rather than panicking or silently miscomputing.
79    fn validate(&self, _warnings: &mut Vec<String>) {}
80}
81
82/// Column in which stacking positions keep each row's original (pre-stack)
83/// `y`, so hover metadata (`data-value`, tooltips) can report the measured
84/// value rather than the cumulative stack top.
85pub const RAW_Y_COL: &str = ".y_raw";
86
87/// Remember the pre-position `y` in [`RAW_Y_COL`] (first adjustment wins).
88pub(crate) fn preserve_raw_y(data: &mut DataFrame, y: &[crate::data::Value]) {
89    if !data.has_column(RAW_Y_COL) {
90        data.add_column(RAW_Y_COL.to_string(), y.to_vec());
91    }
92}
93
94/// A row's `y`, classified for `PositionStack`/`PositionFill`'s #154
95/// positive/negative bucket split — shared so the two positions handle a
96/// missing value identically (sipemu/ggplot-rs#171 review, finding 3).
97///
98/// `value` is `None` for a *missing* row: `Value::Na`, or a `Value::Float`
99/// that isn't finite (NaN or an infinity). ggplot2's `pos_stack`/
100/// `position_fill` (verified against ggplot2 4.0.3's R source —
101/// `PositionStack$compute_panel` sends `negative[is.na(negative)] <-
102/// FALSE`, then `pos_stack`/`position_fill` use `y <- ifelse(is.na(df$y),
103/// 0, df$y)`) treat a missing row as joining the *positive* bucket but
104/// contributing **0** to its running total, and leave the row's own
105/// `y`/`ymin`/`ymax` as `NA` — so it draws nothing, but every *other* row
106/// at that x is unaffected. [`StackRow::is_positive_bucket`] and
107/// [`StackRow::contribution`] give exactly that: summing `contribution()`
108/// into a bucket's total never introduces a NaN (review #167 finding 2 on
109/// #171 — the pre-#171 code instead summed the raw, possibly-NaN `y`,
110/// poisoning every other row sharing that row's bucket at that x).
111#[derive(Clone, Copy)]
112pub(crate) struct StackRow {
113    value: Option<f64>,
114}
115
116/// Classify a row's `y` for stacking — see [`StackRow`].
117pub(crate) fn stack_row(y: &crate::data::Value) -> StackRow {
118    StackRow {
119        value: y.as_f64().filter(|v| v.is_finite()),
120    }
121}
122
123impl StackRow {
124    /// Which of the two independent buckets this row stacks into — a
125    /// missing row always joins the positive one, matching ggplot2.
126    pub(crate) fn is_positive_bucket(&self) -> bool {
127        self.value.is_none_or(|v| v >= 0.0)
128    }
129
130    /// What this row contributes to its bucket's running total: `0` for
131    /// a missing row, so summing it can never poison the total with NaN.
132    pub(crate) fn contribution(&self) -> f64 {
133        self.value.unwrap_or(0.0)
134    }
135
136    /// This row's own finite value, or `None` if it's missing — the
137    /// caller writes `f64::NAN` for `y`/`ymin`/`ymax` in the `None` case
138    /// (so the row draws nothing, matching ggplot2's `NA`).
139    pub(crate) fn value(&self) -> Option<f64> {
140        self.value
141    }
142}
143
144/// Column in which dodging positions store each row's offset (in category
145/// units) when x is *discrete*: the category itself can't move, so geoms add
146/// `offset / n_levels` to the mapped x (see `geom::support::XMapper`).
147pub const DODGE_OFFSET_COL: &str = ".x_dodge_offset";
148/// Number of dodged groups sharing a row's discrete x position (stored next to
149/// [`DODGE_OFFSET_COL`]); bar-like geoms divide their width by it.
150pub const DODGE_N_COL: &str = ".x_dodge_n";