ggplot_rs/render/corner_layout.rs
1//! Shared geometry for stacking panel-corner "furniture" clear of each
2//! other — `annotation_scale()`/`annotation_attribution()`'s scale bar and
3//! attribution text, `bivariate_legend()`'s square guide, and an ordinary
4//! legend placed with `legend_position_inside` — so two of them anchored
5//! to the same or adjacent corners don't draw on top of one another
6//! (sipemu/ggplot-rs#273).
7//!
8//! Each corner geom draws in plot-layer order (the order layers were added
9//! to the plot). Before drawing, it asks [`DrawBackend::corner_claims`]
10//! what boxes earlier geoms in this render pass already placed,
11//! [`stack_clear`]s its own measured box away from its own anchor edge
12//! until it no longer overlaps any of them, draws at the (possibly
13//! shifted) result, then registers that box with
14//! [`DrawBackend::claim_corner_box`] so a later geom in the same render
15//! pass sees it too.
16//!
17//! An ordinary inside legend (`legend_position_inside`) draws *after*
18//! every geom (`renderer.rs`'s draw order: layers, then the legend), so
19//! there's no later geom for it to defer to in the usual way — instead
20//! its box is pre-claimed *before* any geom draws, computed the same way
21//! `draw_legend_planned` itself positions it (see
22//! `guide::legend::inside_legend_box`), so attribution/scale-bar/bivariate
23//! geoms drawn first still see it and stack clear of it.
24//!
25//! [`DrawBackend::claim_corner_box`]: crate::render::backend::DrawBackend::claim_corner_box
26//! [`DrawBackend::corner_claims`]: crate::render::backend::DrawBackend::corner_claims
27
28use super::Rect;
29
30/// `true` if `a` and `b` overlap with positive area — touching edges
31/// (sharing a boundary with zero-width overlap) don't count, so two items
32/// placed flush against each other aren't flagged as colliding.
33pub fn rects_intersect(a: &Rect, b: &Rect) -> bool {
34 a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
35}
36
37/// Real breathing room left between two stacked boxes, beyond exact
38/// zero-gap touching (#273's real-world repro, DataZooDE/anofox-
39/// visualization#549 on delivery-coverage.sql: `GeomAttribution`'s own
40/// box height is only an *approximation* of its real ink extent — a
41/// fixed ascent/descent fraction of `text_size`, not the real font's
42/// metrics — so two boxes `stack_clear` leaves exactly touching can still
43/// render with a few visible pixels of overlap once real glyphs (a
44/// taller x-height, a letter with a true descender, …) are drawn.
45/// Shifting a little past exact contact absorbs that slack, the same
46/// reasoning `geom::annotation_scale::ATTRIBUTION_FIT_SAFETY_MARGIN`
47/// already applies to `fit`'s own width estimate.
48const CORNER_STACK_GAP: f64 = 4.0;
49
50/// Shifts `rect` along the y axis, away from its own anchor edge, just far
51/// enough to clear every rect in `claims` it would otherwise overlap, plus
52/// [`CORNER_STACK_GAP`] of real clearance — "stack them" (#273). `away_sign`
53/// is the direction away from the anchor edge: `-1.0` shifts up (decreases
54/// y), the right direction for a bottom-anchored corner's items (they
55/// stack upward, away from the bottom edge); any other value shifts down
56/// (increases y), for a top-anchored corner. A claim with no horizontal
57/// overlap with `rect` is never a reason to move (`rects_intersect`
58/// requires both axes).
59///
60/// Iterates a bounded number of passes (`claims.len() + 1`): clearing one
61/// claim can push `rect` into a *new* overlap with another claim it
62/// already cleared in an earlier pass (claims aren't assumed sorted, or
63/// non-overlapping with each other), so each pass re-checks every claim;
64/// one extra pass beyond the claim count is enough to reach a fixed point
65/// for any realistic claim count (a handful of corner items at once), and
66/// a pass that clears nothing stops early — the common case is one pass.
67///
68/// `bounds` (normally the panel's own `plot_area`) clamps the final
69/// result: stacking never pushes `rect` so far that it leaves `bounds`
70/// entirely (#273's DataZooDE/anofox-visualization#549 repro — a
71/// bivariate legend's tall rotated caption, in a panel too short for
72/// every corner item to stack fully clear of it — would otherwise walk
73/// the next item up past the panel's own top edge and off the map
74/// entirely, strictly worse than the residual overlap it was avoiding).
75/// When `rect` is taller than `bounds` itself, it's pinned to `bounds`'s
76/// own top rather than centred or otherwise guessed at.
77pub fn stack_clear(mut rect: Rect, away_sign: f64, claims: &[Rect], bounds: &Rect) -> Rect {
78 let away_sign = if away_sign < 0.0 { -1.0 } else { 1.0 };
79 for _ in 0..=claims.len() {
80 let mut shifted = false;
81 for claim in claims {
82 if !rects_intersect(&rect, claim) {
83 continue;
84 }
85 let delta = if away_sign < 0.0 {
86 // Shift up until rect's bottom edge sits CORNER_STACK_GAP
87 // above claim's top.
88 (rect.y + rect.height) - claim.y + CORNER_STACK_GAP
89 } else {
90 // Shift down until rect's top edge sits CORNER_STACK_GAP
91 // below claim's bottom.
92 (claim.y + claim.height) - rect.y + CORNER_STACK_GAP
93 };
94 if delta > 0.0 {
95 rect.y += away_sign * delta;
96 shifted = true;
97 }
98 }
99 if !shifted {
100 break;
101 }
102 }
103 let max_y = (bounds.y + bounds.height - rect.height).max(bounds.y);
104 rect.y = rect.y.clamp(bounds.y, max_y);
105 rect
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn r(x: f64, y: f64, w: f64, h: f64) -> Rect {
113 Rect {
114 x,
115 y,
116 width: w,
117 height: h,
118 }
119 }
120
121 /// A generous panel, big enough that none of the tests below that
122 /// don't specifically exercise clamping ever hit it.
123 fn bounds() -> Rect {
124 r(-1000.0, -1000.0, 2000.0, 2000.0)
125 }
126
127 #[test]
128 fn disjoint_rects_do_not_intersect() {
129 assert!(!rects_intersect(
130 &r(0.0, 0.0, 10.0, 10.0),
131 &r(20.0, 0.0, 10.0, 10.0)
132 ));
133 }
134
135 #[test]
136 fn touching_edges_do_not_count_as_intersecting() {
137 assert!(!rects_intersect(
138 &r(0.0, 0.0, 10.0, 10.0),
139 &r(10.0, 0.0, 10.0, 10.0)
140 ));
141 assert!(!rects_intersect(
142 &r(0.0, 0.0, 10.0, 10.0),
143 &r(0.0, 10.0, 10.0, 10.0)
144 ));
145 }
146
147 #[test]
148 fn overlapping_rects_intersect() {
149 assert!(rects_intersect(
150 &r(0.0, 0.0, 10.0, 10.0),
151 &r(5.0, 5.0, 10.0, 10.0)
152 ));
153 }
154
155 #[test]
156 fn stack_clear_with_no_claims_is_a_no_op() {
157 let rect = r(0.0, 90.0, 20.0, 10.0);
158 let out = stack_clear(rect.clone(), -1.0, &[], &bounds());
159 assert_eq!(
160 (out.x, out.y, out.width, out.height),
161 (rect.x, rect.y, rect.width, rect.height)
162 );
163 }
164
165 #[test]
166 fn stack_clear_shifts_up_away_from_a_bottom_claim() {
167 let rect = r(0.0, 90.0, 20.0, 10.0); // y in [90, 100]
168 let claim = r(0.0, 95.0, 20.0, 10.0); // y in [95, 105] — overlaps
169 let out = stack_clear(rect, -1.0, std::slice::from_ref(&claim), &bounds());
170 // A real gap (CORNER_STACK_GAP), not just zero-touching — see
171 // that constant's own docs for why.
172 assert!(
173 out.y + out.height <= claim.y - CORNER_STACK_GAP + 1e-9,
174 "out={out:?}"
175 );
176 assert!(!rects_intersect(&out, &claim));
177 }
178
179 #[test]
180 fn stack_clear_shifts_down_away_from_a_top_claim() {
181 let rect = r(0.0, 10.0, 20.0, 10.0);
182 let claim = r(0.0, 5.0, 20.0, 10.0);
183 let out = stack_clear(rect, 1.0, std::slice::from_ref(&claim), &bounds());
184 assert!(
185 out.y >= claim.y + claim.height + CORNER_STACK_GAP - 1e-9,
186 "out={out:?}"
187 );
188 assert!(!rects_intersect(&out, &claim));
189 }
190
191 #[test]
192 fn stack_clear_ignores_a_horizontally_disjoint_claim() {
193 let rect = r(0.0, 90.0, 20.0, 10.0);
194 let claim = r(100.0, 90.0, 20.0, 10.0); // same y band, no x overlap
195 let out = stack_clear(rect.clone(), -1.0, &[claim], &bounds());
196 assert_eq!((out.x, out.y), (rect.x, rect.y));
197 }
198
199 #[test]
200 fn stack_clear_clears_several_stacked_claims() {
201 let rect = r(0.0, 90.0, 20.0, 10.0);
202 let claims = [r(0.0, 95.0, 20.0, 10.0), r(0.0, 80.0, 20.0, 10.0)];
203 let out = stack_clear(rect, -1.0, &claims, &bounds());
204 for c in &claims {
205 assert!(!rects_intersect(&out, c), "out={out:?} still hits {c:?}");
206 }
207 }
208
209 #[test]
210 fn stack_clear_handles_overlapping_claims_in_one_pass_or_more() {
211 // The two claims themselves overlap each other — stacking past
212 // both must still terminate and clear both.
213 let rect = r(0.0, 90.0, 20.0, 10.0);
214 let claims = [r(0.0, 92.0, 20.0, 10.0), r(0.0, 85.0, 20.0, 12.0)];
215 let out = stack_clear(rect, -1.0, &claims, &bounds());
216 for c in &claims {
217 assert!(!rects_intersect(&out, c), "out={out:?} still hits {c:?}");
218 }
219 }
220
221 #[test]
222 fn stack_clear_never_pushes_the_box_above_the_bounds_top() {
223 // #273's DataZooDE/anofox-visualization#549 repro: a claim tall
224 // enough (relative to the panel) that fully clearing it would
225 // walk `rect` above the panel's own top edge — residual overlap
226 // with the claim is better than leaving the panel entirely.
227 let panel = r(0.0, 0.0, 100.0, 60.0);
228 let rect = r(0.0, 55.0, 20.0, 10.0); // near the panel's bottom
229 let claim = r(0.0, -500.0, 20.0, 600.0); // a huge claim filling the panel
230 let out = stack_clear(rect, -1.0, std::slice::from_ref(&claim), &panel);
231 assert!(out.y >= panel.y - 1e-9, "out={out:?} left the panel's top");
232 }
233
234 #[test]
235 fn stack_clear_pins_a_box_taller_than_bounds_to_the_top() {
236 let panel = r(0.0, 0.0, 100.0, 20.0);
237 let rect = r(0.0, 18.0, 20.0, 50.0); // taller than the panel itself
238 let out = stack_clear(rect, -1.0, &[], &panel);
239 assert_eq!(out.y, panel.y);
240 }
241
242 #[test]
243 fn stack_clear_without_claims_still_respects_bounds() {
244 // A box that starts outside its own bounds (shouldn't normally
245 // happen, but the clamp is unconditional) gets pulled back in
246 // even with nothing to stack against.
247 let panel = r(0.0, 0.0, 100.0, 60.0);
248 let rect = r(0.0, -40.0, 20.0, 10.0);
249 let out = stack_clear(rect, -1.0, &[], &panel);
250 assert!(out.y >= panel.y - 1e-9);
251 assert!(out.y + out.height <= panel.y + panel.height + 1e-9);
252 }
253}