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 let transformed = sec_axis.transform_value(v);
591 crate::scale::util::format_number_opt(transformed, locale)
592 } else {
593 label.clone()
594 };
595
596 let family = if theme.axis_text_y.family.is_empty() {
597 None
598 } else {
599 Some(theme.axis_text_y.family.clone())
600 };
601 backend.draw_text(
602 &sec_label,
603 (right_x + tick_len + theme.legend_spacing, py),
604 &TextStyle {
605 color: theme.axis_text_y.color,
606 size: theme.axis_text_y.size,
607 anchor: TextAnchor::Start,
608 angle: theme.axis_text_y.angle,
609 family,
610 face: theme.axis_text_y.face,
611 bg_colour: None,
612 bg_r: 0.0,
613 },
614 )?;
615 }
616 }
617
618 if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
620 let max_ch = own_ticks
623 .as_ref()
624 .and_then(|t| t.iter().map(|(_, l)| l.chars().count()).max())
625 .unwrap_or(0);
626 let text_w =
627 (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5);
628 let title_x = right_x + tick_len + text_w + theme.legend_spacing;
629 let center_y = plot_area.y + plot_area.height / 2.0;
630 let family = if theme.axis_title_y.family.is_empty() {
631 None
632 } else {
633 Some(theme.axis_title_y.family.clone())
634 };
635 backend.draw_text(
636 &sec_axis.name,
637 (title_x, center_y),
638 &TextStyle {
639 color: theme.axis_title_y.color,
640 size: theme.axis_title_y.size,
641 anchor: TextAnchor::Middle,
642 angle: 90.0,
643 family,
644 face: theme.axis_title_y.face,
645 bg_colour: None,
646 bg_r: 0.0,
647 },
648 )?;
649 }
650
651 Ok(())
652}
653
654fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
656 if major.len() < 2 {
657 return vec![];
658 }
659 let mut minors = Vec::with_capacity(major.len() - 1);
660 for pair in major.windows(2) {
661 minors.push((pair[0].0 + pair[1].0) / 2.0);
662 }
663 minors
664}
665
666pub fn draw_gridlines(
668 x_scale: &dyn Scale,
669 y_scale: &dyn Scale,
670 coord: &dyn Coord,
671 theme: &Theme,
672 plot_area: &Rect,
673 backend: &mut dyn DrawBackend,
674) -> Result<(), RenderError> {
675 let major_x = theme.get_panel_grid_major_x();
676 let major_y = theme.get_panel_grid_major_y();
677 let minor_x = theme.get_panel_grid_minor_x();
678 let minor_y = theme.get_panel_grid_minor_y();
679
680 let x_breaks = x_scale.breaks();
681 let y_breaks = y_scale.breaks();
682
683 if minor_x.visible {
685 for pos in minor_breaks(&x_breaks) {
686 let px = along_x(coord, pos, plot_area);
687 backend.draw_line(
688 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
689 &LineStyle {
690 color: minor_x.color,
691 width: minor_x.width,
692 alpha: 1.0,
693 linetype: minor_x.linetype.clone(),
694 ..Default::default()
695 },
696 )?;
697 }
698 }
699
700 if minor_y.visible {
702 for pos in minor_breaks(&y_breaks) {
703 let py = along_y(coord, pos, plot_area);
704 backend.draw_line(
705 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
706 &LineStyle {
707 color: minor_y.color,
708 width: minor_y.width,
709 alpha: 1.0,
710 linetype: minor_y.linetype.clone(),
711 ..Default::default()
712 },
713 )?;
714 }
715 }
716
717 if major_x.visible {
719 for (pos, _) in &x_breaks {
720 let px = along_x(coord, *pos, plot_area);
721 backend.draw_line(
722 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
723 &LineStyle {
724 color: major_x.color,
725 width: major_x.width,
726 alpha: 1.0,
727 linetype: major_x.linetype.clone(),
728 ..Default::default()
729 },
730 )?;
731 }
732 }
733
734 if major_y.visible {
736 for (pos, _) in &y_breaks {
737 let py = along_y(coord, *pos, plot_area);
738 backend.draw_line(
739 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
740 &LineStyle {
741 color: major_y.color,
742 width: major_y.width,
743 alpha: 1.0,
744 linetype: major_y.linetype.clone(),
745 ..Default::default()
746 },
747 )?;
748 }
749 }
750
751 Ok(())
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use crate::aes::Aesthetic;
758 use crate::coord::cartesian::CoordCartesian;
759 use crate::data::Value;
760 use crate::render::svg_backend::SvgBackend;
761 use crate::theme::presets::theme_gray;
762
763 struct FixedBreaksScale(Vec<(f64, String)>);
766
767 impl Scale for FixedBreaksScale {
768 fn aesthetic(&self) -> Aesthetic {
769 Aesthetic::X
770 }
771 fn train(&mut self, _values: &[Value]) {}
772 fn map(&self, _value: &Value) -> f64 {
773 0.0
774 }
775 fn breaks(&self) -> Vec<(f64, String)> {
776 self.0.clone()
777 }
778 fn name(&self) -> &str {
779 ""
780 }
781 fn set_name(&mut self, _name: &str) {}
782 fn clone_box(&self) -> Box<dyn Scale> {
783 Box::new(FixedBreaksScale(self.0.clone()))
784 }
785 }
786
787 fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
788 SvgBackend::new(w, h, plot_area)
789 }
790
791 #[test]
792 fn a_tick_outside_the_svg_is_dropped() {
793 let scale = FixedBreaksScale(vec![
796 (0.2, "kept".to_string()),
797 (5.0, "off_canvas".to_string()),
798 ]);
799 let coord = CoordCartesian::new();
800 let theme = theme_gray();
801 let plot_area = Rect {
802 x: 10.0,
803 y: 10.0,
804 width: 180.0,
805 height: 70.0,
806 };
807 let canvas = Rect {
808 x: 0.0,
809 y: 0.0,
810 width: 200.0,
811 height: 100.0,
812 };
813 let mut backend = backend(200, 100, plot_area.clone());
814 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
815 let svg = backend.finish();
816 assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
817 assert!(
818 !svg.contains(">off_canvas<"),
819 "off-canvas label was drawn: {svg}"
820 );
821 }
822
823 #[test]
824 fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
825 let scale = FixedBreaksScale(vec![
827 (0.0, "a".to_string()),
828 (1.0, "2021-01-01".to_string()),
829 ]);
830 let coord = CoordCartesian::new();
831 let mut theme = theme_gray();
832 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
833 let plot_area = Rect {
834 x: 0.0,
835 y: 0.0,
836 width: 60.0,
837 height: 50.0,
838 };
839 let canvas = plot_area.clone();
840 let mut backend = backend(60, 50, plot_area.clone());
841 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
842 let svg = backend.finish();
843 assert!(
846 svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
847 "clip mode should not shift the label: {svg}"
848 );
849 }
850
851 #[test]
852 fn shift_moves_the_same_label_inside_the_canvas() {
853 let scale = FixedBreaksScale(vec![
854 (0.0, "a".to_string()),
855 (1.0, "2021-01-01".to_string()),
856 ]);
857 let coord = CoordCartesian::new();
858 let mut theme = theme_gray();
859 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
860 let plot_area = Rect {
861 x: 0.0,
862 y: 0.0,
863 width: 60.0,
864 height: 50.0,
865 };
866 let canvas = plot_area.clone();
867 let mut backend = backend(60, 50, plot_area.clone());
868 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
869 let svg = backend.finish();
870 assert!(
871 !svg.contains("x=\"60.00\" y=\""),
872 "shift mode should move the label off the bare tick: {svg}"
873 );
874 let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
875 let needle = ">2021-01-01<";
877 let tag_end = svg.find(needle).unwrap();
878 let tag_start = svg[..tag_end].rfind("<text ").unwrap();
879 let tag = &svg[tag_start..tag_end];
880 let x_attr = tag.rsplit("x=\"").next().unwrap();
881 let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
882 assert!(
885 x + w / 2.0 <= 60.0 - 1.0 + 0.01,
886 "right edge still overflows: x={x} w={w}"
887 );
888 }
889}