1use crate::coord::Coord;
2use crate::render::backend::{DrawBackend, LineStyle, TextAnchor, TextStyle};
3use crate::render::text::{axis_label_priority, label_bbox, text_width};
4use crate::render::{Rect, RenderError};
5use crate::scale::Scale;
6use crate::theme::{AxisLabelFit, Theme};
7
8fn along_x(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
12 let p = if coord.is_flipped() {
13 (0.0, pos)
14 } else {
15 (pos, 0.0)
16 };
17 coord.transform(p, area).0
18}
19
20fn along_y(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
22 let p = if coord.is_flipped() {
23 (pos, 0.0)
24 } else {
25 (0.0, pos)
26 };
27 coord.transform(p, area).1
28}
29
30pub fn draw_x_axis(
38 scale: &dyn Scale,
39 coord: &dyn Coord,
40 theme: &Theme,
41 plot_area: &Rect,
42 canvas: &Rect,
43 backend: &mut dyn DrawBackend,
44) -> Result<(), RenderError> {
45 draw_x_axis_with(scale, coord, theme, plot_area, canvas, true, backend)
46}
47
48pub(crate) fn draw_x_axis_with(
51 scale: &dyn Scale,
52 coord: &dyn Coord,
53 theme: &Theme,
54 plot_area: &Rect,
55 canvas: &Rect,
56 with_title: bool,
57 backend: &mut dyn DrawBackend,
58) -> Result<(), RenderError> {
59 let breaks = scale.breaks();
60 let tick_len = theme.axis_ticks_length;
61 let axis_line = theme.get_axis_line_x();
62 let axis_ticks = theme.get_axis_ticks_x();
63
64 let top = scale.axis_position_opposite();
66 let edge_y = if top {
67 plot_area.y
68 } else {
69 plot_area.y + plot_area.height
70 };
71 let dir = if top { -1.0 } else { 1.0 };
72
73 if axis_line.visible {
75 backend.draw_line(
76 &[
77 (plot_area.x, edge_y),
78 (plot_area.x + plot_area.width, edge_y),
79 ],
80 &LineStyle {
81 color: axis_line.color,
82 width: axis_line.width,
83 alpha: 1.0,
84 linetype: axis_line.linetype.clone(),
85 ..Default::default()
86 },
87 )?;
88 }
89
90 let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
95 let angle = theme.axis_text_x.angle;
96 let rotated = angle.abs() > 10.0;
97 let anchor_frac = if rotated {
98 crate::render::text::rotated_anchor_frac(angle, dir)
99 } else {
100 0.5
101 };
102 let fit = theme.axis_text_x_fit;
103 let canvas_lo = canvas.x;
104 let canvas_hi = canvas.x + canvas.width;
105 const EDGE: f64 = 1.0;
106 let size = theme.axis_text_x.size;
107
108 let onscreen: Vec<(usize, f64, &str)> = breaks
112 .iter()
113 .enumerate()
114 .map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
115 .filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
116 .collect();
117
118 let mut keep = vec![true; onscreen.len()];
122 if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
123 let n = onscreen.len();
124 let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); keep = vec![false; n];
126 let min_gap = text_width(" ", size);
140 for p in axis_label_priority(n) {
141 let idx = p - 1; let (_, px, label) = onscreen[idx];
143 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
144 let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
145 if !rotated {
146 bbox[0] < kx2 + min_gap && bbox[2] > kx0 - min_gap
147 } else {
148 (px - kpx).abs() * angle.to_radians().sin().abs() < size
152 }
153 });
154 if !overlaps {
155 keep[idx] = true;
156 kept.push((bbox[0], bbox[2], px));
157 }
158 }
159 }
160
161 let mut draw = vec![None; breaks.len()];
162 for (k, &(i, px, label)) in onscreen.iter().enumerate() {
163 if !keep[k] {
164 continue;
165 }
166 let lx = if fit == AxisLabelFit::Clip {
169 px
170 } else {
171 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
173 let room_hi = canvas_hi - EDGE;
174 let room_lo = canvas_lo + EDGE;
175 if bbox[0] < room_lo {
176 px + (room_lo - bbox[0])
177 } else if bbox[2] > room_hi {
178 px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
179 } else {
180 px
181 }
182 };
183 draw[i] = Some(lx);
184 }
185 draw
186 } else {
187 Vec::new()
188 };
189
190 let dodge = theme.axis_text_x_dodge.max(1);
192 let family = if theme.axis_text_x.family.is_empty() {
193 None
194 } else {
195 Some(theme.axis_text_x.family.clone())
196 };
197 let rotated = theme.axis_text_x.angle.abs() > 10.0;
198 let draw_anchor_frac = if rotated {
203 crate::render::text::rotated_anchor_frac(theme.axis_text_x.angle, dir)
204 } else {
205 0.5
206 };
207 let anchor = if !rotated {
208 TextAnchor::Middle
209 } else if draw_anchor_frac >= 1.0 {
210 TextAnchor::End
211 } else {
212 TextAnchor::Start
213 };
214 let rotation_margin = if rotated {
224 let (rise, fall) = crate::render::text::rotated_label_rise_fall(
225 breaks
226 .iter()
227 .filter(|(_, l)| !l.is_empty())
228 .map(|(_, l)| l.as_str()),
229 theme.axis_text_x.size,
230 theme.axis_text_x.angle,
231 draw_anchor_frac,
232 );
233 if dir > 0.0 {
234 rise
235 } else {
236 fall
237 }
238 } else {
239 0.0
240 };
241 for (i, (pos, label)) in breaks.iter().enumerate() {
242 let px = along_x(coord, *pos, plot_area);
243
244 if axis_ticks.visible {
245 backend.draw_line(
246 &[(px, edge_y), (px, edge_y + dir * tick_len)],
247 &LineStyle {
248 color: axis_ticks.color,
249 width: axis_ticks.width,
250 alpha: 1.0,
251 linetype: axis_ticks.linetype.clone(),
252 ..Default::default()
253 },
254 )?;
255 }
256
257 if let Some(Some(lx)) = label_draw.get(i) {
258 let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
260 let ly = edge_y
261 + dir
262 * (tick_len
263 + theme.legend_spacing / 2.0
264 + theme.axis_text_x.size / 2.0
265 + row_offset
266 + rotation_margin);
267 backend.draw_text(
268 label,
269 (*lx, ly),
270 &TextStyle {
271 color: theme.axis_text_x.color,
272 size: theme.axis_text_x.size,
273 anchor: anchor.clone(),
274 angle: theme.axis_text_x.angle,
275 family: family.clone(),
276 face: theme.axis_text_x.face,
277 bg_colour: None,
278 bg_r: 0.0,
279 },
280 )?;
281 }
282 }
283
284 if theme.axis_minor_ticks && axis_ticks.visible {
286 for pos in minor_breaks(&breaks) {
287 let px = along_x(coord, pos, plot_area);
288 backend.draw_line(
289 &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
290 &LineStyle {
291 color: axis_ticks.color,
292 width: axis_ticks.width,
293 alpha: 1.0,
294 linetype: axis_ticks.linetype.clone(),
295 ..Default::default()
296 },
297 )?;
298 }
299 }
300
301 if with_title {
302 draw_x_axis_title(scale, theme, plot_area, backend)?;
303 }
304
305 Ok(())
306}
307
308pub(crate) fn draw_x_axis_title(
311 scale: &dyn Scale,
312 theme: &Theme,
313 area: &Rect,
314 backend: &mut dyn DrawBackend,
315) -> Result<(), RenderError> {
316 let plot_area = area;
317 let tick_len = theme.axis_ticks_length;
318 let top = scale.axis_position_opposite();
319 let edge_y = if top {
320 plot_area.y
321 } else {
322 plot_area.y + plot_area.height
323 };
324 let dir = if top { -1.0 } else { 1.0 };
325 let title = scale.name();
326 if !title.is_empty() && theme.axis_title_x.visible {
327 let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
328 let center_x = plot_area.x + hjust * plot_area.width;
329 let anchor = if hjust <= 0.02 {
330 TextAnchor::Start
331 } else if hjust >= 0.98 {
332 TextAnchor::End
333 } else {
334 TextAnchor::Middle
335 };
336 let title_y = edge_y
337 + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
338 let family = if theme.axis_title_x.family.is_empty() {
339 None
340 } else {
341 Some(theme.axis_title_x.family.clone())
342 };
343 backend.draw_text(
344 title,
345 (center_x, title_y),
346 &TextStyle {
347 color: theme.axis_title_x.color,
348 size: theme.axis_title_x.size,
349 anchor,
350 angle: 0.0,
351 family,
352 face: theme.axis_title_x.face,
353 bg_colour: None,
354 bg_r: 0.0,
355 },
356 )?;
357 }
358
359 Ok(())
360}
361
362pub fn draw_y_axis(
364 scale: &dyn Scale,
365 coord: &dyn Coord,
366 theme: &Theme,
367 plot_area: &Rect,
368 backend: &mut dyn DrawBackend,
369) -> Result<(), RenderError> {
370 draw_y_axis_with(scale, coord, theme, plot_area, true, backend)
371}
372
373pub(crate) fn draw_y_axis_with(
376 scale: &dyn Scale,
377 coord: &dyn Coord,
378 theme: &Theme,
379 plot_area: &Rect,
380 with_title: bool,
381 backend: &mut dyn DrawBackend,
382) -> Result<(), RenderError> {
383 let breaks = scale.breaks();
384 let tick_len = theme.axis_ticks_length;
385 let axis_line = theme.get_axis_line_y();
386 let axis_ticks = theme.get_axis_ticks_y();
387
388 if axis_line.visible {
390 let top = (plot_area.x, plot_area.y);
391 let bottom = (plot_area.x, plot_area.y + plot_area.height);
392 backend.draw_line(
393 &[top, bottom],
394 &LineStyle {
395 color: axis_line.color,
396 width: axis_line.width,
397 alpha: 1.0,
398 linetype: axis_line.linetype.clone(),
399 ..Default::default()
400 },
401 )?;
402 }
403
404 for (pos, label) in &breaks {
406 let py = along_y(coord, *pos, plot_area);
407
408 if axis_ticks.visible {
409 backend.draw_line(
410 &[(plot_area.x - tick_len, py), (plot_area.x, py)],
411 &LineStyle {
412 color: axis_ticks.color,
413 width: axis_ticks.width,
414 alpha: 1.0,
415 linetype: axis_ticks.linetype.clone(),
416 ..Default::default()
417 },
418 )?;
419 }
420
421 if theme.axis_text_y.visible {
422 let family = if theme.axis_text_y.family.is_empty() {
423 None
424 } else {
425 Some(theme.axis_text_y.family.clone())
426 };
427 backend.draw_text(
428 label,
429 (plot_area.x - tick_len - theme.legend_spacing, py),
430 &TextStyle {
431 color: theme.axis_text_y.color,
432 size: theme.axis_text_y.size,
433 anchor: TextAnchor::End,
434 angle: theme.axis_text_y.angle,
435 family,
436 face: theme.axis_text_y.face,
437 bg_colour: None,
438 bg_r: 0.0,
439 },
440 )?;
441 }
442 }
443
444 if theme.axis_minor_ticks && axis_ticks.visible {
446 for pos in minor_breaks(&breaks) {
447 let py = along_y(coord, pos, plot_area);
448 backend.draw_line(
449 &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
450 &LineStyle {
451 color: axis_ticks.color,
452 width: axis_ticks.width,
453 alpha: 1.0,
454 linetype: axis_ticks.linetype.clone(),
455 ..Default::default()
456 },
457 )?;
458 }
459 }
460
461 if with_title {
462 let reserve = y_label_reserve(theme, &breaks);
463 draw_y_axis_title(scale, theme, plot_area, reserve, backend)?;
464 }
465
466 Ok(())
467}
468
469const Y_TITLE_GAP: f64 = 4.0;
471
472pub(crate) fn y_label_reserve(theme: &Theme, breaks: &[(f64, String)]) -> f64 {
475 let max_ch = breaks
476 .iter()
477 .map(|(_, l)| l.chars().count())
478 .max()
479 .unwrap_or(0);
480 let measured = max_ch as f64 * theme.axis_text_y.size * 0.6 + 4.0;
481 let default = theme.axis_text_y.size * 3.5 + 4.0;
482 measured.max(default)
483}
484
485pub(crate) fn draw_y_axis_title(
488 scale: &dyn Scale,
489 theme: &Theme,
490 area: &Rect,
491 label_reserve: f64,
492 backend: &mut dyn DrawBackend,
493) -> Result<(), RenderError> {
494 let title = scale.name();
495 if !title.is_empty() && theme.axis_title_y.visible {
496 let size = theme.axis_title_y.size;
501 let right = area.x - theme.axis_ticks_length - label_reserve - Y_TITLE_GAP;
502 let min_x = backend.total_area().x + 0.75 * size;
503 let title_x = (right - 0.25 * size).max(min_x);
504 let center_y = area.y + area.height / 2.0;
505 let family = if theme.axis_title_y.family.is_empty() {
506 None
507 } else {
508 Some(theme.axis_title_y.family.clone())
509 };
510 backend.draw_text(
511 title,
512 (title_x, center_y),
513 &TextStyle {
514 color: theme.axis_title_y.color,
515 size: theme.axis_title_y.size,
516 anchor: TextAnchor::Middle,
517 angle: 270.0,
518 family,
519 face: theme.axis_title_y.face,
520 bg_colour: None,
521 bg_r: 0.0,
522 },
523 )?;
524 }
525
526 Ok(())
527}
528
529pub fn draw_sec_y_axis(
531 primary_scale: &dyn Scale,
532 sec_axis: &crate::scale::sec_axis::SecAxis,
533 coord: &dyn Coord,
534 theme: &Theme,
535 plot_area: &Rect,
536 backend: &mut dyn DrawBackend,
537) -> Result<(), RenderError> {
538 let own_ticks = sec_axis.own_ticks(primary_scale);
539 let breaks = own_ticks.clone().unwrap_or_else(|| primary_scale.breaks());
540 let tick_len = theme.axis_ticks_length;
541 let axis_line = theme.get_axis_line_y();
542 let axis_ticks = theme.get_axis_ticks_y();
543
544 let right_x = plot_area.x + plot_area.width;
545
546 if axis_line.visible {
548 backend.draw_line(
549 &[
550 (right_x, plot_area.y),
551 (right_x, plot_area.y + plot_area.height),
552 ],
553 &LineStyle {
554 color: axis_line.color,
555 width: axis_line.width,
556 alpha: 1.0,
557 linetype: axis_line.linetype.clone(),
558 ..Default::default()
559 },
560 )?;
561 }
562
563 for (pos, label) in &breaks {
565 let py = along_y(coord, *pos, plot_area);
566
567 if axis_ticks.visible {
568 backend.draw_line(
569 &[(right_x, py), (right_x + tick_len, py)],
570 &LineStyle {
571 color: axis_ticks.color,
572 width: axis_ticks.width,
573 alpha: 1.0,
574 linetype: axis_ticks.linetype.clone(),
575 ..Default::default()
576 },
577 )?;
578 }
579
580 if theme.axis_text_y.visible {
581 let locale = primary_scale.locale();
583 let parsed = match locale {
584 Some(l) => l.parse_number(label),
585 None => label.parse::<f64>().ok(),
586 };
587 let sec_label = if own_ticks.is_some() {
588 label.clone()
589 } else if let Some(v) = parsed {
590 sec_axis.label(sec_axis.transform_value(v), locale)
591 } else {
592 label.clone()
593 };
594
595 let family = if theme.axis_text_y.family.is_empty() {
596 None
597 } else {
598 Some(theme.axis_text_y.family.clone())
599 };
600 backend.draw_text(
601 &sec_label,
602 (right_x + tick_len + theme.legend_spacing, py),
603 &TextStyle {
604 color: theme.axis_text_y.color,
605 size: theme.axis_text_y.size,
606 anchor: TextAnchor::Start,
607 angle: theme.axis_text_y.angle,
608 family,
609 face: theme.axis_text_y.face,
610 bg_colour: None,
611 bg_r: 0.0,
612 },
613 )?;
614 }
615 }
616
617 if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
619 let max_ch = own_ticks
622 .as_ref()
623 .and_then(|t| t.iter().map(|(_, l)| l.chars().count()).max())
624 .unwrap_or(0);
625 let text_w =
626 (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5);
627 let title_x = right_x + tick_len + text_w + theme.legend_spacing;
628 let center_y = plot_area.y + plot_area.height / 2.0;
629 let family = if theme.axis_title_y.family.is_empty() {
630 None
631 } else {
632 Some(theme.axis_title_y.family.clone())
633 };
634 backend.draw_text(
635 &sec_axis.name,
636 (title_x, center_y),
637 &TextStyle {
638 color: theme.axis_title_y.color,
639 size: theme.axis_title_y.size,
640 anchor: TextAnchor::Middle,
641 angle: 90.0,
642 family,
643 face: theme.axis_title_y.face,
644 bg_colour: None,
645 bg_r: 0.0,
646 },
647 )?;
648 }
649
650 Ok(())
651}
652
653fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
655 if major.len() < 2 {
656 return vec![];
657 }
658 let mut minors = Vec::with_capacity(major.len() - 1);
659 for pair in major.windows(2) {
660 minors.push((pair[0].0 + pair[1].0) / 2.0);
661 }
662 minors
663}
664
665pub fn draw_gridlines(
667 x_scale: &dyn Scale,
668 y_scale: &dyn Scale,
669 coord: &dyn Coord,
670 theme: &Theme,
671 plot_area: &Rect,
672 backend: &mut dyn DrawBackend,
673) -> Result<(), RenderError> {
674 let major_x = theme.get_panel_grid_major_x();
675 let major_y = theme.get_panel_grid_major_y();
676 let minor_x = theme.get_panel_grid_minor_x();
677 let minor_y = theme.get_panel_grid_minor_y();
678
679 let x_breaks = x_scale.breaks();
680 let y_breaks = y_scale.breaks();
681
682 if minor_x.visible {
684 for pos in minor_breaks(&x_breaks) {
685 let px = along_x(coord, pos, plot_area);
686 backend.draw_line(
687 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
688 &LineStyle {
689 color: minor_x.color,
690 width: minor_x.width,
691 alpha: 1.0,
692 linetype: minor_x.linetype.clone(),
693 ..Default::default()
694 },
695 )?;
696 }
697 }
698
699 if minor_y.visible {
701 for pos in minor_breaks(&y_breaks) {
702 let py = along_y(coord, pos, plot_area);
703 backend.draw_line(
704 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
705 &LineStyle {
706 color: minor_y.color,
707 width: minor_y.width,
708 alpha: 1.0,
709 linetype: minor_y.linetype.clone(),
710 ..Default::default()
711 },
712 )?;
713 }
714 }
715
716 if major_x.visible {
718 for (pos, _) in &x_breaks {
719 let px = along_x(coord, *pos, plot_area);
720 backend.draw_line(
721 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
722 &LineStyle {
723 color: major_x.color,
724 width: major_x.width,
725 alpha: 1.0,
726 linetype: major_x.linetype.clone(),
727 ..Default::default()
728 },
729 )?;
730 }
731 }
732
733 if major_y.visible {
735 for (pos, _) in &y_breaks {
736 let py = along_y(coord, *pos, plot_area);
737 backend.draw_line(
738 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
739 &LineStyle {
740 color: major_y.color,
741 width: major_y.width,
742 alpha: 1.0,
743 linetype: major_y.linetype.clone(),
744 ..Default::default()
745 },
746 )?;
747 }
748 }
749
750 Ok(())
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::aes::Aesthetic;
757 use crate::coord::cartesian::CoordCartesian;
758 use crate::data::Value;
759 use crate::render::svg_backend::SvgBackend;
760 use crate::theme::presets::theme_gray;
761
762 struct FixedBreaksScale(Vec<(f64, String)>);
765
766 impl Scale for FixedBreaksScale {
767 fn aesthetic(&self) -> Aesthetic {
768 Aesthetic::X
769 }
770 fn train(&mut self, _values: &[Value]) {}
771 fn map(&self, _value: &Value) -> f64 {
772 0.0
773 }
774 fn breaks(&self) -> Vec<(f64, String)> {
775 self.0.clone()
776 }
777 fn name(&self) -> &str {
778 ""
779 }
780 fn set_name(&mut self, _name: &str) {}
781 fn clone_box(&self) -> Box<dyn Scale> {
782 Box::new(FixedBreaksScale(self.0.clone()))
783 }
784 }
785
786 fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
787 SvgBackend::new(w, h, plot_area)
788 }
789
790 #[test]
791 fn a_tick_outside_the_svg_is_dropped() {
792 let scale = FixedBreaksScale(vec![
795 (0.2, "kept".to_string()),
796 (5.0, "off_canvas".to_string()),
797 ]);
798 let coord = CoordCartesian::new();
799 let theme = theme_gray();
800 let plot_area = Rect {
801 x: 10.0,
802 y: 10.0,
803 width: 180.0,
804 height: 70.0,
805 };
806 let canvas = Rect {
807 x: 0.0,
808 y: 0.0,
809 width: 200.0,
810 height: 100.0,
811 };
812 let mut backend = backend(200, 100, plot_area.clone());
813 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
814 let svg = backend.finish();
815 assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
816 assert!(
817 !svg.contains(">off_canvas<"),
818 "off-canvas label was drawn: {svg}"
819 );
820 }
821
822 #[test]
823 fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
824 let scale = FixedBreaksScale(vec![
826 (0.0, "a".to_string()),
827 (1.0, "2021-01-01".to_string()),
828 ]);
829 let coord = CoordCartesian::new();
830 let mut theme = theme_gray();
831 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
832 let plot_area = Rect {
833 x: 0.0,
834 y: 0.0,
835 width: 60.0,
836 height: 50.0,
837 };
838 let canvas = plot_area.clone();
839 let mut backend = backend(60, 50, plot_area.clone());
840 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
841 let svg = backend.finish();
842 assert!(
845 svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
846 "clip mode should not shift the label: {svg}"
847 );
848 }
849
850 #[test]
851 fn shift_moves_the_same_label_inside_the_canvas() {
852 let scale = FixedBreaksScale(vec![
853 (0.0, "a".to_string()),
854 (1.0, "2021-01-01".to_string()),
855 ]);
856 let coord = CoordCartesian::new();
857 let mut theme = theme_gray();
858 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
859 let plot_area = Rect {
860 x: 0.0,
861 y: 0.0,
862 width: 60.0,
863 height: 50.0,
864 };
865 let canvas = plot_area.clone();
866 let mut backend = backend(60, 50, plot_area.clone());
867 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
868 let svg = backend.finish();
869 assert!(
870 !svg.contains("x=\"60.00\" y=\""),
871 "shift mode should move the label off the bare tick: {svg}"
872 );
873 let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
874 let needle = ">2021-01-01<";
876 let tag_end = svg.find(needle).unwrap();
877 let tag_start = svg[..tag_end].rfind("<text ").unwrap();
878 let tag = &svg[tag_start..tag_end];
879 let x_attr = tag.rsplit("x=\"").next().unwrap();
880 let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
881 assert!(
884 x + w / 2.0 <= 60.0 - 1.0 + 0.01,
885 "right edge still overflows: x={x} w={w}"
886 );
887 }
888}