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 top = scale.axis_position_opposite();
61 let top_theme;
63 let theme = match (&theme.axis_text_x_top, top) {
64 (Some(el), true) => {
65 top_theme = Theme {
66 axis_text_x: el.clone(),
67 ..theme.clone()
68 };
69 &top_theme
70 }
71 _ => theme,
72 };
73 let breaks = scale.breaks();
74 let tick_len = theme.ticks_len_x();
75 let side = if top {
76 crate::theme::AxisSide::Top
77 } else {
78 crate::theme::AxisSide::Bottom
79 };
80 let axis_line = theme.axis_line_at(side);
81 let axis_ticks = theme.axis_ticks_at(side);
82
83 let edge_y = if top {
84 plot_area.y
85 } else {
86 plot_area.y + plot_area.height
87 };
88 let dir = if top { -1.0 } else { 1.0 };
89
90 if axis_line.visible {
92 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-line"));
93 backend.draw_line(
94 &[
95 (plot_area.x, edge_y),
96 (plot_area.x + plot_area.width, edge_y),
97 ],
98 &LineStyle {
99 color: axis_line.color,
100 width: axis_line.width,
101 alpha: 1.0,
102 linetype: axis_line.linetype.clone(),
103 ..Default::default()
104 },
105 )?;
106 }
107
108 let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
113 let angle = theme.axis_text_x.angle;
114 let rotated = angle.abs() > 10.0;
115 let anchor_frac = if rotated {
116 crate::render::text::rotated_anchor_frac(angle, dir)
117 } else {
118 0.5
119 };
120 let fit = theme.axis_text_x_fit;
121 let canvas_lo = canvas.x;
122 let canvas_hi = canvas.x + canvas.width;
123 const EDGE: f64 = 1.0;
124 let size = theme.axis_text_x.size;
125
126 let onscreen: Vec<(usize, f64, &str)> = breaks
130 .iter()
131 .enumerate()
132 .map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
133 .filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
134 .collect();
135
136 let mut keep = vec![true; onscreen.len()];
140 if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
141 let n = onscreen.len();
142 let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); keep = vec![false; n];
144 let min_gap = text_width(" ", size);
158 for p in axis_label_priority(n) {
159 let idx = p - 1; let (_, px, label) = onscreen[idx];
161 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
162 let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
163 if !rotated {
164 bbox[0] < kx2 + min_gap && bbox[2] > kx0 - min_gap
165 } else {
166 (px - kpx).abs() * angle.to_radians().sin().abs() < size
170 }
171 });
172 if !overlaps {
173 keep[idx] = true;
174 kept.push((bbox[0], bbox[2], px));
175 }
176 }
177 }
178
179 let mut draw = vec![None; breaks.len()];
180 for (k, &(i, px, label)) in onscreen.iter().enumerate() {
181 if !keep[k] {
182 continue;
183 }
184 let lx = if fit == AxisLabelFit::Clip {
187 px
188 } else {
189 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
191 let room_hi = canvas_hi - EDGE;
192 let room_lo = canvas_lo + EDGE;
193 if bbox[0] < room_lo {
194 px + (room_lo - bbox[0])
195 } else if bbox[2] > room_hi {
196 px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
197 } else {
198 px
199 }
200 };
201 draw[i] = Some(lx);
202 }
203 draw
204 } else {
205 Vec::new()
206 };
207
208 let dodge = theme.axis_text_x_dodge.max(1);
210 let family = if theme.axis_text_x.family.is_empty() {
211 None
212 } else {
213 Some(theme.axis_text_x.family.clone())
214 };
215 let rotated = theme.axis_text_x.angle.abs() > 10.0;
216 let draw_anchor_frac = if rotated {
221 crate::render::text::rotated_anchor_frac(theme.axis_text_x.angle, dir)
222 } else {
223 0.5
224 };
225 let anchor = if !rotated {
226 TextAnchor::Middle
227 } else if draw_anchor_frac >= 1.0 {
228 TextAnchor::End
229 } else {
230 TextAnchor::Start
231 };
232 let rotation_margin = if rotated {
242 let (rise, fall) = crate::render::text::rotated_label_rise_fall(
243 breaks
244 .iter()
245 .filter(|(_, l)| !l.is_empty())
246 .map(|(_, l)| l.as_str()),
247 theme.axis_text_x.size,
248 theme.axis_text_x.angle,
249 draw_anchor_frac,
250 );
251 if dir > 0.0 {
252 rise
253 } else {
254 fall
255 }
256 } else {
257 0.0
258 };
259 for (i, (pos, label)) in breaks.iter().enumerate() {
260 let px = along_x(coord, *pos, plot_area);
261
262 if axis_ticks.visible {
263 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-tick"));
264 backend.draw_line(
265 &[(px, edge_y), (px, edge_y + dir * tick_len)],
266 &LineStyle {
267 color: axis_ticks.color,
268 width: axis_ticks.width,
269 alpha: 1.0,
270 linetype: axis_ticks.linetype.clone(),
271 ..Default::default()
272 },
273 )?;
274 }
275
276 if let Some(Some(lx)) = label_draw.get(i) {
277 let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
279 let ly = edge_y
280 + dir
281 * (tick_len
282 + theme.legend_spacing / 2.0
283 + theme.axis_text_x.size / 2.0
284 + row_offset
285 + rotation_margin);
286 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-text"));
287 backend.draw_text(
288 label,
289 (*lx, ly),
290 &TextStyle {
291 color: theme.axis_text_x.color,
292 size: theme.axis_text_x.size,
293 anchor: anchor.clone(),
294 angle: theme.axis_text_x.angle,
295 family: family.clone(),
296 face: theme.axis_text_x.face,
297 bg_colour: None,
298 bg_r: 0.0,
299 },
300 )?;
301 }
302 }
303
304 if theme.axis_minor_ticks && axis_ticks.visible {
306 for pos in minor_breaks(&breaks) {
307 let px = along_x(coord, pos, plot_area);
308 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-tick"));
309 backend.draw_line(
310 &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
311 &LineStyle {
312 color: axis_ticks.color,
313 width: axis_ticks.width,
314 alpha: 1.0,
315 linetype: axis_ticks.linetype.clone(),
316 ..Default::default()
317 },
318 )?;
319 }
320 }
321
322 if axis_ticks.visible {
324 for (pos, kind) in scale.log_ticks() {
325 let px = along_x(coord, pos, plot_area);
326 let len = tick_len * [2.25, 1.5, 0.75][kind as usize];
327 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-tick"));
328 backend.draw_line(
329 &[(px, edge_y), (px, edge_y + dir * len)],
330 &LineStyle {
331 color: axis_ticks.color,
332 width: axis_ticks.width,
333 alpha: 1.0,
334 linetype: axis_ticks.linetype.clone(),
335 ..Default::default()
336 },
337 )?;
338 }
339 }
340
341 if with_title {
342 draw_x_axis_title(scale, theme, plot_area, backend)?;
343 }
344
345 Ok(())
346}
347
348pub(crate) fn draw_x_axis_title(
351 scale: &dyn Scale,
352 theme: &Theme,
353 area: &Rect,
354 backend: &mut dyn DrawBackend,
355) -> Result<(), RenderError> {
356 let plot_area = area;
357 let tick_len = theme.ticks_len_x();
358 let top = scale.axis_position_opposite();
359 let edge_y = if top {
360 plot_area.y
361 } else {
362 plot_area.y + plot_area.height
363 };
364 let dir = if top { -1.0 } else { 1.0 };
365 let title = scale.name();
366 if !title.is_empty() && theme.axis_title_x.visible {
367 let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
368 let center_x = plot_area.x + hjust * plot_area.width;
369 let anchor = if hjust <= 0.02 {
370 TextAnchor::Start
371 } else if hjust >= 0.98 {
372 TextAnchor::End
373 } else {
374 TextAnchor::Middle
375 };
376 let title_y = edge_y
377 + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
378 let family = if theme.axis_title_x.family.is_empty() {
379 None
380 } else {
381 Some(theme.axis_title_x.family.clone())
382 };
383 backend.set_element_class(Some("gg-axis-title gg-axis-title-x"));
384 crate::render::rich::draw(
385 backend,
386 theme.rich_text,
387 title,
388 (center_x, title_y),
389 &TextStyle {
390 color: theme.axis_title_x.color,
391 size: theme.axis_title_x.size,
392 anchor,
393 angle: 0.0,
394 family,
395 face: theme.axis_title_x.face,
396 bg_colour: None,
397 bg_r: 0.0,
398 },
399 )?;
400 }
401
402 Ok(())
403}
404
405pub fn draw_y_axis(
407 scale: &dyn Scale,
408 coord: &dyn Coord,
409 theme: &Theme,
410 plot_area: &Rect,
411 backend: &mut dyn DrawBackend,
412) -> Result<(), RenderError> {
413 draw_y_axis_with(scale, coord, theme, plot_area, true, backend)
414}
415
416pub(crate) fn draw_y_axis_with(
419 scale: &dyn Scale,
420 coord: &dyn Coord,
421 theme: &Theme,
422 plot_area: &Rect,
423 with_title: bool,
424 backend: &mut dyn DrawBackend,
425) -> Result<(), RenderError> {
426 let breaks = scale.breaks();
427 let tick_len = theme.ticks_len_y();
428 let axis_line = theme.axis_line_at(crate::theme::AxisSide::Left);
429 let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Left);
430
431 if axis_line.visible {
433 let top = (plot_area.x, plot_area.y);
434 let bottom = (plot_area.x, plot_area.y + plot_area.height);
435 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-line"));
436 backend.draw_line(
437 &[top, bottom],
438 &LineStyle {
439 color: axis_line.color,
440 width: axis_line.width,
441 alpha: 1.0,
442 linetype: axis_line.linetype.clone(),
443 ..Default::default()
444 },
445 )?;
446 }
447
448 for (pos, label) in &breaks {
450 let py = along_y(coord, *pos, plot_area);
451
452 if axis_ticks.visible {
453 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
454 backend.draw_line(
455 &[(plot_area.x - tick_len, py), (plot_area.x, py)],
456 &LineStyle {
457 color: axis_ticks.color,
458 width: axis_ticks.width,
459 alpha: 1.0,
460 linetype: axis_ticks.linetype.clone(),
461 ..Default::default()
462 },
463 )?;
464 }
465
466 if theme.axis_text_y.visible {
467 let family = if theme.axis_text_y.family.is_empty() {
468 None
469 } else {
470 Some(theme.axis_text_y.family.clone())
471 };
472 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-text"));
473 backend.draw_text(
474 label,
475 (plot_area.x - tick_len - theme.legend_spacing, py),
476 &TextStyle {
477 color: theme.axis_text_y.color,
478 size: theme.axis_text_y.size,
479 anchor: TextAnchor::End,
480 angle: theme.axis_text_y.angle,
481 family,
482 face: theme.axis_text_y.face,
483 bg_colour: None,
484 bg_r: 0.0,
485 },
486 )?;
487 }
488 }
489
490 if theme.axis_minor_ticks && axis_ticks.visible {
492 for pos in minor_breaks(&breaks) {
493 let py = along_y(coord, pos, plot_area);
494 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
495 backend.draw_line(
496 &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
497 &LineStyle {
498 color: axis_ticks.color,
499 width: axis_ticks.width,
500 alpha: 1.0,
501 linetype: axis_ticks.linetype.clone(),
502 ..Default::default()
503 },
504 )?;
505 }
506 }
507
508 if axis_ticks.visible {
510 for (pos, kind) in scale.log_ticks() {
511 let py = along_y(coord, pos, plot_area);
512 let len = tick_len * [2.25, 1.5, 0.75][kind as usize];
513 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
514 backend.draw_line(
515 &[(plot_area.x - len, py), (plot_area.x, py)],
516 &LineStyle {
517 color: axis_ticks.color,
518 width: axis_ticks.width,
519 alpha: 1.0,
520 linetype: axis_ticks.linetype.clone(),
521 ..Default::default()
522 },
523 )?;
524 }
525 }
526
527 if with_title {
528 let reserve = y_label_reserve(theme, &breaks);
529 draw_y_axis_title(scale, theme, plot_area, reserve, backend)?;
530 }
531
532 Ok(())
533}
534
535const Y_TITLE_GAP: f64 = 4.0;
537
538pub(crate) fn y_label_reserve(theme: &Theme, breaks: &[(f64, String)]) -> f64 {
541 let max_ch = breaks
542 .iter()
543 .map(|(_, l)| l.chars().count())
544 .max()
545 .unwrap_or(0);
546 let measured = max_ch as f64 * theme.axis_text_y.size * 0.6 + 4.0;
547 let default = theme.axis_text_y.size * 3.5 + 4.0;
548 measured.max(default)
549}
550
551pub(crate) fn draw_y_axis_title(
554 scale: &dyn Scale,
555 theme: &Theme,
556 area: &Rect,
557 label_reserve: f64,
558 backend: &mut dyn DrawBackend,
559) -> Result<(), RenderError> {
560 let title = scale.name();
561 if !title.is_empty() && theme.axis_title_y.visible {
562 let size = theme.axis_title_y.size;
567 let right = area.x - theme.ticks_len_y() - label_reserve - Y_TITLE_GAP;
568 let min_x = backend.total_area().x + 0.75 * size;
569 let title_x = (right - 0.25 * size).max(min_x);
570 let center_y = area.y + area.height / 2.0;
571 let family = if theme.axis_title_y.family.is_empty() {
572 None
573 } else {
574 Some(theme.axis_title_y.family.clone())
575 };
576 backend.set_element_class(Some("gg-axis-title gg-axis-title-y"));
577 crate::render::rich::draw(
578 backend,
579 theme.rich_text,
580 title,
581 (title_x, center_y),
582 &TextStyle {
583 color: theme.axis_title_y.color,
584 size: theme.axis_title_y.size,
585 anchor: TextAnchor::Middle,
586 angle: 270.0,
587 family,
588 face: theme.axis_title_y.face,
589 bg_colour: None,
590 bg_r: 0.0,
591 },
592 )?;
593 }
594
595 Ok(())
596}
597
598pub fn draw_sec_y_axis(
600 primary_scale: &dyn Scale,
601 sec_axis: &crate::scale::sec_axis::SecAxis,
602 coord: &dyn Coord,
603 theme: &Theme,
604 plot_area: &Rect,
605 backend: &mut dyn DrawBackend,
606) -> Result<(), RenderError> {
607 let right_theme;
609 let theme = match &theme.axis_text_y_right {
610 Some(el) => {
611 right_theme = Theme {
612 axis_text_y: el.clone(),
613 ..theme.clone()
614 };
615 &right_theme
616 }
617 None => theme,
618 };
619 let own_ticks = sec_axis.own_ticks(primary_scale);
620 let breaks = own_ticks.clone().unwrap_or_else(|| primary_scale.breaks());
621 let tick_len = theme.ticks_len_y();
622 let axis_line = theme.axis_line_at(crate::theme::AxisSide::Right);
623 let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Right);
624
625 let right_x = plot_area.x + plot_area.width;
626
627 if axis_line.visible {
629 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-line"));
630 backend.draw_line(
631 &[
632 (right_x, plot_area.y),
633 (right_x, plot_area.y + plot_area.height),
634 ],
635 &LineStyle {
636 color: axis_line.color,
637 width: axis_line.width,
638 alpha: 1.0,
639 linetype: axis_line.linetype.clone(),
640 ..Default::default()
641 },
642 )?;
643 }
644
645 for (pos, label) in &breaks {
647 let py = along_y(coord, *pos, plot_area);
648
649 if axis_ticks.visible {
650 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
651 backend.draw_line(
652 &[(right_x, py), (right_x + tick_len, py)],
653 &LineStyle {
654 color: axis_ticks.color,
655 width: axis_ticks.width,
656 alpha: 1.0,
657 linetype: axis_ticks.linetype.clone(),
658 ..Default::default()
659 },
660 )?;
661 }
662
663 if theme.axis_text_y.visible {
664 let locale = primary_scale.locale();
666 let parsed = match locale {
667 Some(l) => l.parse_number(label),
668 None => label.parse::<f64>().ok(),
669 };
670 let sec_label = if own_ticks.is_some() {
671 label.clone()
672 } else if let Some(v) = parsed {
673 sec_axis.label(sec_axis.transform_value(v), locale)
674 } else {
675 label.clone()
676 };
677
678 let family = if theme.axis_text_y.family.is_empty() {
679 None
680 } else {
681 Some(theme.axis_text_y.family.clone())
682 };
683 backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-text"));
684 backend.draw_text(
685 &sec_label,
686 (right_x + tick_len + theme.legend_spacing, py),
687 &TextStyle {
688 color: theme.axis_text_y.color,
689 size: theme.axis_text_y.size,
690 anchor: TextAnchor::Start,
691 angle: theme.axis_text_y.angle,
692 family,
693 face: theme.axis_text_y.face,
694 bg_colour: None,
695 bg_r: 0.0,
696 },
697 )?;
698 }
699 }
700
701 if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
703 let max_ch = own_ticks
706 .as_ref()
707 .and_then(|t| t.iter().map(|(_, l)| l.chars().count()).max())
708 .unwrap_or(0);
709 let text_w =
710 (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5);
711 let title_x = right_x + tick_len + text_w + theme.legend_spacing;
712 let center_y = plot_area.y + plot_area.height / 2.0;
713 let family = if theme.axis_title_y.family.is_empty() {
714 None
715 } else {
716 Some(theme.axis_title_y.family.clone())
717 };
718 backend.set_element_class(Some("gg-axis-title gg-axis-title-y"));
719 crate::render::rich::draw(
720 backend,
721 theme.rich_text,
722 &sec_axis.name,
723 (title_x, center_y),
724 &TextStyle {
725 color: theme.axis_title_y.color,
726 size: theme.axis_title_y.size,
727 anchor: TextAnchor::Middle,
728 angle: 90.0,
729 family,
730 face: theme.axis_title_y.face,
731 bg_colour: None,
732 bg_r: 0.0,
733 },
734 )?;
735 }
736
737 Ok(())
738}
739
740fn sec_labels(
743 primary_scale: &dyn Scale,
744 sec_axis: &crate::scale::sec_axis::SecAxis,
745 breaks: &[(f64, String)],
746 own: bool,
747) -> Vec<String> {
748 let locale = primary_scale.locale();
749 breaks
750 .iter()
751 .map(|(_, label)| {
752 if own {
753 return label.clone();
754 }
755 let parsed = match locale {
756 Some(l) => l.parse_number(label),
757 None => label.parse::<f64>().ok(),
758 };
759 match parsed {
760 Some(v) => sec_axis.label(sec_axis.transform_value(v), locale),
761 None => label.clone(),
762 }
763 })
764 .collect()
765}
766
767pub(crate) fn sec_x_axis_height(theme: &Theme, sec_axis: &crate::scale::sec_axis::SecAxis) -> f64 {
771 theme.ticks_len_x()
772 + if theme.axis_text_x.visible {
773 theme.axis_text_x.size + 4.0
774 } else {
775 0.0
776 }
777 + if !sec_axis.name.is_empty() && theme.axis_title_x.visible {
778 theme.axis_title_x.size + 6.0
779 } else {
780 0.0
781 }
782}
783
784pub fn draw_sec_x_axis(
787 primary_scale: &dyn Scale,
788 sec_axis: &crate::scale::sec_axis::SecAxis,
789 coord: &dyn Coord,
790 theme: &Theme,
791 plot_area: &Rect,
792 backend: &mut dyn DrawBackend,
793) -> Result<(), RenderError> {
794 let own_ticks = sec_axis.own_ticks(primary_scale);
795 let breaks = own_ticks.clone().unwrap_or_else(|| primary_scale.breaks());
796 let labels = sec_labels(primary_scale, sec_axis, &breaks, own_ticks.is_some());
797 let tick_len = theme.ticks_len_x();
798 let axis_line = theme.axis_line_at(crate::theme::AxisSide::Top);
799 let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Top);
800 let top_y = plot_area.y;
801 let line = |el: &crate::theme::elements::ElementLine| LineStyle {
802 color: el.color,
803 width: el.width,
804 alpha: 1.0,
805 linetype: el.linetype.clone(),
806 ..Default::default()
807 };
808 if axis_line.visible {
809 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-line"));
810 backend.draw_line(
811 &[(plot_area.x, top_y), (plot_area.x + plot_area.width, top_y)],
812 &line(axis_line),
813 )?;
814 }
815 let family = |f: &str| (!f.is_empty()).then(|| f.to_string());
816 for ((pos, _), label) in breaks.iter().zip(&labels) {
817 let px = along_x(coord, *pos, plot_area);
818 if axis_ticks.visible {
819 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-tick"));
820 backend.draw_line(&[(px, top_y), (px, top_y - tick_len)], &line(axis_ticks))?;
821 }
822 if theme.axis_text_x.visible {
823 backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-text"));
824 backend.draw_text(
825 label,
826 (px, top_y - tick_len - 2.0 - theme.axis_text_x.size / 2.0),
827 &TextStyle {
828 color: theme.axis_text_x.color,
829 size: theme.axis_text_x.size,
830 anchor: TextAnchor::Middle,
831 angle: 0.0,
832 family: family(&theme.axis_text_x.family),
833 face: theme.axis_text_x.face,
834 bg_colour: None,
835 bg_r: 0.0,
836 },
837 )?;
838 }
839 }
840 if !sec_axis.name.is_empty() && theme.axis_title_x.visible {
841 let y = top_y - sec_x_axis_height(theme, sec_axis) + theme.axis_title_x.size / 2.0;
842 backend.set_element_class(Some("gg-axis-title gg-axis-title-x"));
843 crate::render::rich::draw(
844 backend,
845 theme.rich_text,
846 &sec_axis.name,
847 (plot_area.x + plot_area.width / 2.0, y),
848 &TextStyle {
849 color: theme.axis_title_x.color,
850 size: theme.axis_title_x.size,
851 anchor: TextAnchor::Middle,
852 angle: 0.0,
853 family: family(&theme.axis_title_x.family),
854 face: theme.axis_title_x.face,
855 bg_colour: None,
856 bg_r: 0.0,
857 },
858 )?;
859 }
860 Ok(())
861}
862
863fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
865 if major.len() < 2 {
866 return vec![];
867 }
868 let mut minors = Vec::with_capacity(major.len() - 1);
869 for pair in major.windows(2) {
870 minors.push((pair[0].0 + pair[1].0) / 2.0);
871 }
872 minors
873}
874
875pub fn draw_gridlines(
877 x_scale: &dyn Scale,
878 y_scale: &dyn Scale,
879 coord: &dyn Coord,
880 theme: &Theme,
881 plot_area: &Rect,
882 backend: &mut dyn DrawBackend,
883) -> Result<(), RenderError> {
884 let major_x = theme.get_panel_grid_major_x();
885 let major_y = theme.get_panel_grid_major_y();
886 let minor_x = theme.get_panel_grid_minor_x();
887 let minor_y = theme.get_panel_grid_minor_y();
888
889 let x_breaks = x_scale.breaks();
890 let y_breaks = y_scale.breaks();
891
892 if minor_x.visible {
894 for pos in minor_breaks(&x_breaks) {
895 let px = along_x(coord, pos, plot_area);
896 backend.set_element_class(Some("gg-grid gg-grid-minor gg-grid-x"));
897 backend.draw_line(
898 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
899 &LineStyle {
900 color: minor_x.color,
901 width: minor_x.width,
902 alpha: 1.0,
903 linetype: minor_x.linetype.clone(),
904 ..Default::default()
905 },
906 )?;
907 }
908 }
909
910 if minor_y.visible {
912 for pos in minor_breaks(&y_breaks) {
913 let py = along_y(coord, pos, plot_area);
914 backend.set_element_class(Some("gg-grid gg-grid-minor gg-grid-y"));
915 backend.draw_line(
916 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
917 &LineStyle {
918 color: minor_y.color,
919 width: minor_y.width,
920 alpha: 1.0,
921 linetype: minor_y.linetype.clone(),
922 ..Default::default()
923 },
924 )?;
925 }
926 }
927
928 if major_x.visible {
930 for (pos, _) in &x_breaks {
931 let px = along_x(coord, *pos, plot_area);
932 backend.set_element_class(Some("gg-grid gg-grid-major gg-grid-x"));
933 backend.draw_line(
934 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
935 &LineStyle {
936 color: major_x.color,
937 width: major_x.width,
938 alpha: 1.0,
939 linetype: major_x.linetype.clone(),
940 ..Default::default()
941 },
942 )?;
943 }
944 }
945
946 if major_y.visible {
948 for (pos, _) in &y_breaks {
949 let py = along_y(coord, *pos, plot_area);
950 backend.set_element_class(Some("gg-grid gg-grid-major gg-grid-y"));
951 backend.draw_line(
952 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
953 &LineStyle {
954 color: major_y.color,
955 width: major_y.width,
956 alpha: 1.0,
957 linetype: major_y.linetype.clone(),
958 ..Default::default()
959 },
960 )?;
961 }
962 }
963
964 Ok(())
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use crate::aes::Aesthetic;
971 use crate::coord::cartesian::CoordCartesian;
972 use crate::data::Value;
973 use crate::render::svg_backend::SvgBackend;
974 use crate::theme::presets::theme_gray;
975
976 struct FixedBreaksScale(Vec<(f64, String)>);
979
980 impl Scale for FixedBreaksScale {
981 fn aesthetic(&self) -> Aesthetic {
982 Aesthetic::X
983 }
984 fn train(&mut self, _values: &[Value]) {}
985 fn map(&self, _value: &Value) -> f64 {
986 0.0
987 }
988 fn breaks(&self) -> Vec<(f64, String)> {
989 self.0.clone()
990 }
991 fn name(&self) -> &str {
992 ""
993 }
994 fn set_name(&mut self, _name: &str) {}
995 fn clone_box(&self) -> Box<dyn Scale> {
996 Box::new(FixedBreaksScale(self.0.clone()))
997 }
998 }
999
1000 fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
1001 SvgBackend::new(w, h, plot_area)
1002 }
1003
1004 #[test]
1005 fn a_tick_outside_the_svg_is_dropped() {
1006 let scale = FixedBreaksScale(vec![
1009 (0.2, "kept".to_string()),
1010 (5.0, "off_canvas".to_string()),
1011 ]);
1012 let coord = CoordCartesian::new();
1013 let theme = theme_gray();
1014 let plot_area = Rect {
1015 x: 10.0,
1016 y: 10.0,
1017 width: 180.0,
1018 height: 70.0,
1019 };
1020 let canvas = Rect {
1021 x: 0.0,
1022 y: 0.0,
1023 width: 200.0,
1024 height: 100.0,
1025 };
1026 let mut backend = backend(200, 100, plot_area.clone());
1027 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1028 let svg = backend.finish();
1029 assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
1030 assert!(
1031 !svg.contains(">off_canvas<"),
1032 "off-canvas label was drawn: {svg}"
1033 );
1034 }
1035
1036 #[test]
1037 fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
1038 let scale = FixedBreaksScale(vec![
1040 (0.0, "a".to_string()),
1041 (1.0, "2021-01-01".to_string()),
1042 ]);
1043 let coord = CoordCartesian::new();
1044 let mut theme = theme_gray();
1045 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
1046 let plot_area = Rect {
1047 x: 0.0,
1048 y: 0.0,
1049 width: 60.0,
1050 height: 50.0,
1051 };
1052 let canvas = plot_area.clone();
1053 let mut backend = backend(60, 50, plot_area.clone());
1054 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1055 let svg = backend.finish();
1056 assert!(
1059 svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
1060 "clip mode should not shift the label: {svg}"
1061 );
1062 }
1063
1064 #[test]
1065 fn shift_moves_the_same_label_inside_the_canvas() {
1066 let scale = FixedBreaksScale(vec![
1067 (0.0, "a".to_string()),
1068 (1.0, "2021-01-01".to_string()),
1069 ]);
1070 let coord = CoordCartesian::new();
1071 let mut theme = theme_gray();
1072 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
1073 let plot_area = Rect {
1074 x: 0.0,
1075 y: 0.0,
1076 width: 60.0,
1077 height: 50.0,
1078 };
1079 let canvas = plot_area.clone();
1080 let mut backend = backend(60, 50, plot_area.clone());
1081 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1082 let svg = backend.finish();
1083 assert!(
1084 !svg.contains("x=\"60.00\" y=\""),
1085 "shift mode should move the label off the bare tick: {svg}"
1086 );
1087 let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
1088 let needle = ">2021-01-01<";
1090 let tag_end = svg.find(needle).unwrap();
1091 let tag_start = svg[..tag_end].rfind("<text ").unwrap();
1092 let tag = &svg[tag_start..tag_end];
1093 let x_attr = tag.rsplit("x=\"").next().unwrap();
1094 let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
1095 assert!(
1098 x + w / 2.0 <= 60.0 - 1.0 + 0.01,
1099 "right edge still overflows: x={x} w={w}"
1100 );
1101 }
1102}