1use crate::text::TextExtents;
2
3use super::{
4 Config, LABEL_GAP, Side, TitleBand, axis_mut, axis_offset, axis_ref,
5 axis_title_offset_to_screen, axis_title_placement, chart_title_placement, colorbar_rect,
6 colorbar_title_placement, legend_rect, point_on_rect_side, screen_offset_to_axis_title,
7};
8
9#[derive(Debug, Clone, PartialEq)]
12pub enum Element {
13 ChartTitle,
14 AxisTitle(Side),
15 AxisLabel(Side),
16 Axis(Side),
21 DataAreaEdge(Side),
25 DataArea,
30 Legend,
33 ColorBar,
36 ColorBarAxis,
39 ColorBarLabel,
41 ColorBarTitle,
44 ColorBarEdge(Side),
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub enum NudgeResult {
58 Moved,
59 Rejected(NudgeReject),
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum NudgeReject {
64 OutOfBounds,
65}
66
67const LABEL_EXTENT_EST: f32 = 10.0;
71const ZERO_EXTENTS: TextExtents = TextExtents {
72 width: 0.0,
73 ascent: 0.0,
74 descent: 0.0,
75};
76
77fn element_anchor(cfg: &Config, element: &Element) -> Option<(f32, f32)> {
80 let ca = &cfg.chart_area;
81 let da = cfg.data_area().ok()?;
82
83 let anchor = match element {
84 Element::ChartTitle => {
85 chart_title_placement(ca, cfg.chart_title.top_margin, (0.0, 0.0), ZERO_EXTENTS).origin
86 }
87 Element::AxisTitle(side) => {
88 let axis = axis_ref(cfg, side);
89 axis_title_placement(
90 side.clone(),
91 ca,
92 &da,
93 TitleBand {
94 out_margin: axis.out_margin,
95 chart_title_margin: cfg.chart_title.top_margin,
96 edge_inset: cfg.colorbar_band(side),
97 },
98 (0.0, 0.0),
99 ZERO_EXTENTS,
100 )
101 .origin
102 }
103 Element::AxisLabel(Side::Top) => (
105 da.x as f32 + da.width as f32 * 0.5,
106 da.y as f32 - cfg.top_x.major_tick_length - LABEL_GAP - LABEL_EXTENT_EST,
107 ),
108 Element::AxisLabel(Side::Bottom) => (
109 da.x as f32 + da.width as f32 * 0.5,
110 (da.y + da.height) as f32
111 + cfg.bottom_x.major_tick_length
112 + LABEL_GAP
113 + LABEL_EXTENT_EST,
114 ),
115 Element::AxisLabel(Side::Left) => (
116 da.x as f32 - cfg.left_y.major_tick_length - LABEL_GAP - LABEL_EXTENT_EST,
117 da.y as f32 + da.height as f32 * 0.5,
118 ),
119 Element::AxisLabel(Side::Right) => (
120 (da.x + da.width) as f32 + cfg.right_y.major_tick_length + LABEL_GAP + LABEL_EXTENT_EST,
121 da.y as f32 + da.height as f32 * 0.5,
122 ),
123 Element::Legend => {
127 let rect = legend_rect(&da, cfg.legend.corner, 0.0, (0.0, 0.0), ZERO_EXTENTS);
128 (rect.x, rect.y)
129 }
130 Element::ColorBar => {
133 let bar = cfg.colorbar.as_ref()?;
134 let mut anchored = bar.clone();
135 anchored.offset_x = 0.0;
136 anchored.offset_y = 0.0;
137 let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, &anchored);
138 (rect.x, rect.y)
139 }
140 Element::ColorBarLabel => {
141 let bar = cfg.colorbar.as_ref()?;
142 let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, bar);
143 let pos = point_on_rect_side(0.5, &bar.side, &rect);
144 let (axis_dx, axis_dy) = axis_offset(bar.side.clone(), bar.axis.line_offset);
145 let reach = bar.axis.major_tick_length + LABEL_GAP + LABEL_EXTENT_EST;
146 let (out_x, out_y) = match bar.side {
147 Side::Top => (0.0, -reach),
148 Side::Bottom => (0.0, reach),
149 Side::Left => (-reach, 0.0),
150 Side::Right => (reach, 0.0),
151 };
152 (pos.0 + axis_dx + out_x, pos.1 + axis_dy + out_y)
153 }
154 Element::ColorBarTitle => {
155 let bar = cfg.colorbar.as_ref()?;
156 let rect = colorbar_rect(ca, &da, cfg.chart_title.top_margin, bar);
157 colorbar_title_placement(bar.side.clone(), &rect, &bar.axis, (0.0, 0.0), ZERO_EXTENTS)
158 .origin
159 }
160 Element::Axis(_)
163 | Element::ColorBarAxis
164 | Element::DataAreaEdge(_)
165 | Element::DataArea
166 | Element::ColorBarEdge(_) => return None,
167 };
168
169 Some(anchor)
170}
171
172fn current_offset(cfg: &Config, element: &Element) -> (f32, f32) {
173 match element {
174 Element::ChartTitle => (cfg.chart_title.offset_x, cfg.chart_title.offset_y),
175 Element::AxisTitle(side) => {
176 let a = axis_ref(cfg, side);
177 (a.title_option.offset_x, a.title_option.offset_y)
178 }
179 Element::AxisLabel(side) => {
180 let a = axis_ref(cfg, side);
181 (a.label_style.label_offset_x, a.label_style.label_offset_y)
182 }
183 Element::Legend => (cfg.legend.offset_x, cfg.legend.offset_y),
184 Element::ColorBar => cfg
185 .colorbar
186 .as_ref()
187 .map_or((0.0, 0.0), |bar| (bar.offset_x, bar.offset_y)),
188 Element::ColorBarLabel => cfg.colorbar.as_ref().map_or((0.0, 0.0), |bar| {
189 (
190 bar.axis.label_style.label_offset_x,
191 bar.axis.label_style.label_offset_y,
192 )
193 }),
194 Element::ColorBarTitle => cfg.colorbar.as_ref().map_or((0.0, 0.0), |bar| {
195 (
196 bar.axis.title_option.offset_x,
197 bar.axis.title_option.offset_y,
198 )
199 }),
200 Element::Axis(_)
202 | Element::ColorBarAxis
203 | Element::DataAreaEdge(_)
204 | Element::DataArea
205 | Element::ColorBarEdge(_) => (0.0, 0.0),
206 }
207}
208
209impl Config {
212 pub fn nudge(&mut self, element: Element, dx: f32, dy: f32) -> NudgeResult {
216 if let Element::Axis(side) = element {
219 return self.nudge_axis(side, dx, dy);
220 }
221 if let Element::ColorBarAxis = element {
222 return self.nudge_colorbar_axis(dx, dy);
223 }
224 if let Element::DataAreaEdge(side) = element {
225 return self.nudge_data_area_edge(side, dx, dy);
226 }
227 if let Element::DataArea = element {
228 return self.nudge_data_area(dx, dy);
229 }
230 if let Element::ColorBarEdge(side) = element {
231 return self.nudge_colorbar_edge(side, dx, dy);
232 }
233 let anchor = match element_anchor(self, &element) {
234 Some(a) => a,
235 None => return NudgeResult::Rejected(NudgeReject::OutOfBounds),
236 };
237 let (ox, oy) = current_offset(self, &element);
240 let (screen_ox, screen_oy) = match &element {
241 Element::AxisTitle(side) => axis_title_offset_to_screen(side.clone(), (ox, oy)),
242 Element::ColorBarTitle => {
243 let Some(side) = self.colorbar.as_ref().map(|bar| bar.side.clone()) else {
244 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
245 };
246 axis_title_offset_to_screen(side, (ox, oy))
247 }
248 _ => (ox, oy),
249 };
250 let new_x = anchor.0 + screen_ox + dx;
251 let new_y = anchor.1 + screen_oy + dy;
252
253 let ca = &self.chart_area;
254 let x_min = ca.x as f32;
255 let y_min = ca.y as f32;
256 let x_max = (ca.x + ca.width) as f32;
257 let y_max = (ca.y + ca.height) as f32;
258
259 if new_x < x_min || new_x > x_max || new_y < y_min || new_y > y_max {
260 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
261 }
262
263 match element {
264 Element::ChartTitle => {
265 self.chart_title.offset_x += dx;
266 self.chart_title.offset_y += dy;
267 }
268 Element::AxisTitle(side) => {
269 let (local_dx, local_dy) = screen_offset_to_axis_title(side.clone(), (dx, dy));
273 let a = axis_mut(self, &side);
274 a.title_option.offset_x += local_dx;
275 a.title_option.offset_y += local_dy;
276 }
277 Element::AxisLabel(side) => {
278 let a = axis_mut(self, &side);
281 a.label_style.label_offset_x += dx;
282 a.label_style.label_offset_y += dy;
283 }
284 Element::Legend => {
285 self.legend.offset_x += dx;
286 self.legend.offset_y += dy;
287 }
288 Element::ColorBar => {
289 if let Some(bar) = self.colorbar.as_mut() {
292 bar.offset_x += dx;
293 bar.offset_y += dy;
294 }
295 }
296 Element::ColorBarLabel => {
297 if let Some(bar) = self.colorbar.as_mut() {
298 bar.axis.label_style.label_offset_x += dx;
299 bar.axis.label_style.label_offset_y += dy;
300 }
301 }
302 Element::ColorBarTitle => {
303 let Some(side) = self.colorbar.as_ref().map(|bar| bar.side.clone()) else {
304 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
305 };
306 let (local_dx, local_dy) = screen_offset_to_axis_title(side, (dx, dy));
307 if let Some(bar) = self.colorbar.as_mut() {
308 bar.axis.title_option.offset_x += local_dx;
309 bar.axis.title_option.offset_y += local_dy;
310 }
311 }
312 Element::Axis(_)
314 | Element::ColorBarAxis
315 | Element::DataAreaEdge(_)
316 | Element::DataArea
317 | Element::ColorBarEdge(_) => {}
318 }
319 NudgeResult::Moved
320 }
321
322 fn nudge_data_area(&mut self, dx: f32, dy: f32) -> NudgeResult {
328 let mut moved = false;
329
330 if dx != 0.0 {
331 let new_left = self.left_y.out_margin + dx;
332 let new_right = self.right_y.out_margin - dx;
333 if new_left >= 0.0 && new_right >= 0.0 {
334 self.left_y.out_margin = new_left;
335 self.right_y.out_margin = new_right;
336 moved = true;
337 }
338 }
339 if dy != 0.0 {
340 let new_top = self.top_x.out_margin + dy;
341 let new_bottom = self.bottom_x.out_margin - dy;
342 if new_top >= 0.0 && new_bottom >= 0.0 {
343 self.top_x.out_margin = new_top;
344 self.bottom_x.out_margin = new_bottom;
345 moved = true;
346 }
347 }
348
349 if moved {
350 NudgeResult::Moved
351 } else {
352 NudgeResult::Rejected(NudgeReject::OutOfBounds)
353 }
354 }
355
356 fn nudge_axis(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
364 let d = match side {
365 Side::Left | Side::Right => dx,
366 Side::Top | Side::Bottom => dy,
367 };
368 if d == 0.0 {
369 return NudgeResult::Moved;
371 }
372 let Ok(da) = self.data_area() else {
373 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
374 };
375 let new_offset = axis_ref(self, &side).line_offset + d;
376
377 let line_pos = match side {
379 Side::Left => da.x as f32 + new_offset,
380 Side::Right => (da.x + da.width) as f32 + new_offset,
381 Side::Top => da.y as f32 + new_offset,
382 Side::Bottom => (da.y + da.height) as f32 + new_offset,
383 };
384 let ca = &self.chart_area;
385 let (lo, hi) = match side {
386 Side::Left | Side::Right => (ca.x as f32, (ca.x + ca.width) as f32),
387 Side::Top | Side::Bottom => (ca.y as f32, (ca.y + ca.height) as f32),
388 };
389 if line_pos < lo || line_pos > hi {
390 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
391 }
392 axis_mut(self, &side).line_offset = new_offset;
393 NudgeResult::Moved
394 }
395
396 fn nudge_colorbar_axis(&mut self, dx: f32, dy: f32) -> NudgeResult {
401 let Some(bar) = self.colorbar.as_ref() else {
402 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
403 };
404 let side = bar.side.clone();
405 let d = match side {
406 Side::Left | Side::Right => dx,
407 Side::Top | Side::Bottom => dy,
408 };
409 if d == 0.0 {
410 return NudgeResult::Moved;
411 }
412 let Ok(da) = self.data_area() else {
413 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
414 };
415 let rect = colorbar_rect(&self.chart_area, &da, self.chart_title.top_margin, bar);
416 let new_offset = bar.axis.line_offset + d;
417 let line_pos = match side {
418 Side::Left => rect.x + new_offset,
419 Side::Right => rect.x + rect.width + new_offset,
420 Side::Top => rect.y + new_offset,
421 Side::Bottom => rect.y + rect.height + new_offset,
422 };
423 let ca = &self.chart_area;
424 let (lo, hi) = match side {
425 Side::Left | Side::Right => (ca.x as f32, (ca.x + ca.width) as f32),
426 Side::Top | Side::Bottom => (ca.y as f32, (ca.y + ca.height) as f32),
427 };
428 if line_pos < lo || line_pos > hi {
429 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
430 }
431 self.colorbar.as_mut().expect("colourbar").axis.line_offset = new_offset;
432 NudgeResult::Moved
433 }
434
435 fn nudge_colorbar_edge(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
457 let Some(bar) = self.colorbar.as_ref() else {
458 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
459 };
460 let growth = match side {
462 Side::Left => -dx,
463 Side::Right => dx,
464 Side::Top => -dy,
465 Side::Bottom => dy,
466 };
467 if growth == 0.0 {
468 return NudgeResult::Moved;
470 }
471 let bar_vertical = matches!(bar.side, Side::Left | Side::Right);
472 let edge_vertical = matches!(side, Side::Left | Side::Right);
473 let edge_is_thickness = bar_vertical == edge_vertical;
476
477 if edge_is_thickness {
478 let old_thickness = bar.thickness_px;
479 let old_offset_x = bar.offset_x;
480 let old_offset_y = bar.offset_y;
481 let new = old_thickness + growth;
482 if new < 0.0 {
483 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
484 }
485 let dragged_outer_edge = bar.side == side;
486 let new_offset_x = if dragged_outer_edge && bar_vertical {
487 old_offset_x + dx
488 } else {
489 old_offset_x
490 };
491 let new_offset_y = if dragged_outer_edge && !bar_vertical {
492 old_offset_y + dy
493 } else {
494 old_offset_y
495 };
496 {
502 let bar = self.colorbar.as_mut().expect("colourbar");
503 bar.thickness_px = new;
504 bar.offset_x = new_offset_x;
505 bar.offset_y = new_offset_y;
506 }
507 if self.data_area().is_err() {
508 let bar = self.colorbar.as_mut().expect("colourbar");
509 bar.thickness_px = old_thickness;
510 bar.offset_x = old_offset_x;
511 bar.offset_y = old_offset_y;
512 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
513 }
514 return NudgeResult::Moved;
515 }
516
517 let Ok(da) = self.data_area() else {
518 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
519 };
520 let span = if bar_vertical {
521 da.height as f32
522 } else {
523 da.width as f32
524 };
525 if span <= 0.0 {
526 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
527 }
528 let factor = match bar.align {
529 crate::config::BarAlign::Center => 2.0,
530 crate::config::BarAlign::Start | crate::config::BarAlign::End => 1.0,
531 };
532 let new = bar.length_frac + factor * growth / span;
533 if !(new > 0.0 && new <= 1.0) {
537 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
538 }
539 self.colorbar.as_mut().expect("colourbar").length_frac = new;
540 NudgeResult::Moved
541 }
542
543 fn nudge_data_area_edge(&mut self, side: Side, dx: f32, dy: f32) -> NudgeResult {
548 let dm = match side {
551 Side::Left => dx,
552 Side::Right => -dx,
553 Side::Top => dy,
554 Side::Bottom => -dy,
555 };
556 if dm == 0.0 {
557 return NudgeResult::Moved;
558 }
559 let old = axis_ref(self, &side).out_margin;
560 let new = old + dm;
561 if new < 0.0 {
562 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
563 }
564 axis_mut(self, &side).out_margin = new;
565 if self.data_area().is_err() {
567 axis_mut(self, &side).out_margin = old;
568 return NudgeResult::Rejected(NudgeReject::OutOfBounds);
569 }
570 NudgeResult::Moved
571 }
572
573 pub fn nudge_x(&mut self, element: Element, dx: f32) -> NudgeResult {
574 self.nudge(element, dx, 0.0)
575 }
576
577 pub fn nudge_y(&mut self, element: Element, dy: f32) -> NudgeResult {
578 self.nudge(element, 0.0, dy)
579 }
580}
581
582#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::default::default_config;
588
589 #[test]
591 fn nudge_preserves_data_area() {
592 let mut cfg = default_config();
593 let da_before = cfg.data_area().unwrap();
594 let r = cfg.nudge(Element::ChartTitle, 5.0, 3.0);
595 assert_eq!(r, NudgeResult::Moved);
596 let da_after = cfg.data_area().unwrap();
597 assert_eq!(da_before, da_after);
598 }
599
600 #[test]
602 fn nudge_rejected_preserves_state() {
603 let mut cfg = default_config();
604 let before = cfg.clone();
605 let r = cfg.nudge(Element::ChartTitle, -1e6, 0.0);
606 assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
607 assert_eq!(cfg, before);
608 }
609
610 #[test]
612 fn nudge_moved_changes_only_target_offset() {
613 let mut cfg = default_config();
614 let before = cfg.clone();
615 cfg.nudge(Element::AxisLabel(Side::Left), 1.0, 0.0);
616 let mut expected = before.clone();
617 expected.left_y.label_style.label_offset_x += 1.0;
618 assert_eq!(cfg, expected);
619 }
620
621 #[test]
626 fn axis_nudge_detaches_axis_and_keeps_data_area() {
627 let mut cfg = default_config();
628 let da_before = cfg.data_area().unwrap();
629 let margin_before = cfg.left_y.out_margin;
630
631 let r = cfg.nudge(Element::Axis(Side::Left), 10.0, 999.0);
632 assert_eq!(r, NudgeResult::Moved);
633 assert_eq!(cfg.left_y.line_offset, 10.0);
634 assert_eq!(cfg.left_y.out_margin, margin_before);
636 assert_eq!(cfg.data_area().unwrap(), da_before);
637 }
638
639 #[test]
641 fn bottom_axis_nudge_uses_dy_only() {
642 let mut cfg = default_config();
643 let r = cfg.nudge(Element::Axis(Side::Bottom), 999.0, -8.0);
644 assert_eq!(r, NudgeResult::Moved);
645 assert_eq!(cfg.bottom_x.line_offset, -8.0);
646 assert_eq!(
647 cfg.bottom_x.out_margin,
648 default_config().bottom_x.out_margin
649 );
650 }
651
652 #[test]
655 fn axis_nudge_allows_crossing_into_data_area() {
656 let mut cfg = default_config();
657 let r = cfg.nudge(Element::Axis(Side::Left), 50.0, 0.0);
658 assert_eq!(r, NudgeResult::Moved);
659 assert_eq!(cfg.left_y.line_offset, 50.0);
660 }
661
662 #[test]
663 fn axis_nudge_rejects_leaving_chart_area() {
664 let mut cfg = default_config();
665 let before = cfg.clone();
666 let r = cfg.nudge(Element::Axis(Side::Left), -1e6, 0.0);
667 assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
668 assert_eq!(cfg, before);
669 }
670
671 #[test]
673 fn data_area_edge_nudge_moves_margin_and_data_area() {
674 let mut cfg = default_config();
675 let da_before = cfg.data_area().unwrap();
676 let margin_before = cfg.left_y.out_margin;
677
678 let r = cfg.nudge(Element::DataAreaEdge(Side::Left), 10.0, 999.0);
679 assert_eq!(r, NudgeResult::Moved);
680 assert_eq!(cfg.left_y.out_margin, margin_before + 10.0);
681 let da_after = cfg.data_area().unwrap();
682 assert_eq!(da_after.x, da_before.x + 10);
683 assert_eq!(da_after.width, da_before.width - 10);
684 }
685
686 #[test]
687 fn data_area_edge_rejects_negative_margin() {
688 let mut cfg = default_config();
689 let before = cfg.clone();
690 let r = cfg.nudge(Element::DataAreaEdge(Side::Left), -1e6, 0.0);
691 assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
692 assert_eq!(cfg, before);
693 }
694
695 #[test]
696 fn data_area_edge_rejects_margin_overflow() {
697 let mut cfg = default_config();
698 let before = cfg.clone();
699 let w = cfg.chart_area.width as f32;
700 let r = cfg.nudge(Element::DataAreaEdge(Side::Left), w * 2.0, 0.0);
701 assert_eq!(r, NudgeResult::Rejected(NudgeReject::OutOfBounds));
702 assert_eq!(cfg, before);
703 }
704}