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";