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 let breaks = scale.breaks();
46 let tick_len = theme.axis_ticks_length;
47 let axis_line = theme.get_axis_line_x();
48 let axis_ticks = theme.get_axis_ticks_x();
49
50 let top = scale.axis_position_opposite();
52 let edge_y = if top {
53 plot_area.y
54 } else {
55 plot_area.y + plot_area.height
56 };
57 let dir = if top { -1.0 } else { 1.0 };
58
59 if axis_line.visible {
61 backend.draw_line(
62 &[
63 (plot_area.x, edge_y),
64 (plot_area.x + plot_area.width, edge_y),
65 ],
66 &LineStyle {
67 color: axis_line.color,
68 width: axis_line.width,
69 alpha: 1.0,
70 linetype: axis_line.linetype.clone(),
71 ..Default::default()
72 },
73 )?;
74 }
75
76 let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
81 let angle = theme.axis_text_x.angle;
82 let rotated = angle.abs() > 10.0;
83 let anchor_frac = if rotated {
84 crate::render::text::rotated_anchor_frac(angle, dir)
85 } else {
86 0.5
87 };
88 let fit = theme.axis_text_x_fit;
89 let canvas_lo = canvas.x;
90 let canvas_hi = canvas.x + canvas.width;
91 const EDGE: f64 = 1.0;
92 let size = theme.axis_text_x.size;
93
94 let onscreen: Vec<(usize, f64, &str)> = breaks
98 .iter()
99 .enumerate()
100 .map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
101 .filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
102 .collect();
103
104 let mut keep = vec![true; onscreen.len()];
108 if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
109 let n = onscreen.len();
110 let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); keep = vec![false; n];
112 let min_gap = text_width(" ", size);
126 for p in axis_label_priority(n) {
127 let idx = p - 1; let (_, px, label) = onscreen[idx];
129 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
130 let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
131 if !rotated {
132 bbox[0] < kx2 + min_gap && bbox[2] > kx0 - min_gap
133 } else {
134 (px - kpx).abs() * angle.to_radians().sin().abs() < size
138 }
139 });
140 if !overlaps {
141 keep[idx] = true;
142 kept.push((bbox[0], bbox[2], px));
143 }
144 }
145 }
146
147 let mut draw = vec![None; breaks.len()];
148 for (k, &(i, px, label)) in onscreen.iter().enumerate() {
149 if !keep[k] {
150 continue;
151 }
152 let lx = if fit == AxisLabelFit::Clip {
155 px
156 } else {
157 let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
159 let room_hi = canvas_hi - EDGE;
160 let room_lo = canvas_lo + EDGE;
161 if bbox[0] < room_lo {
162 px + (room_lo - bbox[0])
163 } else if bbox[2] > room_hi {
164 px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
165 } else {
166 px
167 }
168 };
169 draw[i] = Some(lx);
170 }
171 draw
172 } else {
173 Vec::new()
174 };
175
176 let dodge = theme.axis_text_x_dodge.max(1);
178 let family = if theme.axis_text_x.family.is_empty() {
179 None
180 } else {
181 Some(theme.axis_text_x.family.clone())
182 };
183 let rotated = theme.axis_text_x.angle.abs() > 10.0;
184 let draw_anchor_frac = if rotated {
189 crate::render::text::rotated_anchor_frac(theme.axis_text_x.angle, dir)
190 } else {
191 0.5
192 };
193 let anchor = if !rotated {
194 TextAnchor::Middle
195 } else if draw_anchor_frac >= 1.0 {
196 TextAnchor::End
197 } else {
198 TextAnchor::Start
199 };
200 let rotation_margin = if rotated {
210 let (rise, fall) = crate::render::text::rotated_label_rise_fall(
211 breaks
212 .iter()
213 .filter(|(_, l)| !l.is_empty())
214 .map(|(_, l)| l.as_str()),
215 theme.axis_text_x.size,
216 theme.axis_text_x.angle,
217 draw_anchor_frac,
218 );
219 if dir > 0.0 {
220 rise
221 } else {
222 fall
223 }
224 } else {
225 0.0
226 };
227 for (i, (pos, label)) in breaks.iter().enumerate() {
228 let px = along_x(coord, *pos, plot_area);
229
230 if axis_ticks.visible {
231 backend.draw_line(
232 &[(px, edge_y), (px, edge_y + dir * tick_len)],
233 &LineStyle {
234 color: axis_ticks.color,
235 width: axis_ticks.width,
236 alpha: 1.0,
237 linetype: axis_ticks.linetype.clone(),
238 ..Default::default()
239 },
240 )?;
241 }
242
243 if let Some(Some(lx)) = label_draw.get(i) {
244 let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
246 let ly = edge_y
247 + dir
248 * (tick_len
249 + theme.legend_spacing / 2.0
250 + theme.axis_text_x.size / 2.0
251 + row_offset
252 + rotation_margin);
253 backend.draw_text(
254 label,
255 (*lx, ly),
256 &TextStyle {
257 color: theme.axis_text_x.color,
258 size: theme.axis_text_x.size,
259 anchor: anchor.clone(),
260 angle: theme.axis_text_x.angle,
261 family: family.clone(),
262 face: theme.axis_text_x.face,
263 bg_colour: None,
264 bg_r: 0.0,
265 },
266 )?;
267 }
268 }
269
270 if theme.axis_minor_ticks && axis_ticks.visible {
272 for pos in minor_breaks(&breaks) {
273 let px = along_x(coord, pos, plot_area);
274 backend.draw_line(
275 &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
276 &LineStyle {
277 color: axis_ticks.color,
278 width: axis_ticks.width,
279 alpha: 1.0,
280 linetype: axis_ticks.linetype.clone(),
281 ..Default::default()
282 },
283 )?;
284 }
285 }
286
287 let title = scale.name();
289 if !title.is_empty() && theme.axis_title_x.visible {
290 let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
291 let center_x = plot_area.x + hjust * plot_area.width;
292 let anchor = if hjust <= 0.02 {
293 TextAnchor::Start
294 } else if hjust >= 0.98 {
295 TextAnchor::End
296 } else {
297 TextAnchor::Middle
298 };
299 let title_y = edge_y
300 + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
301 let family = if theme.axis_title_x.family.is_empty() {
302 None
303 } else {
304 Some(theme.axis_title_x.family.clone())
305 };
306 backend.draw_text(
307 title,
308 (center_x, title_y),
309 &TextStyle {
310 color: theme.axis_title_x.color,
311 size: theme.axis_title_x.size,
312 anchor,
313 angle: 0.0,
314 family,
315 face: theme.axis_title_x.face,
316 bg_colour: None,
317 bg_r: 0.0,
318 },
319 )?;
320 }
321
322 Ok(())
323}
324
325pub fn draw_y_axis(
327 scale: &dyn Scale,
328 coord: &dyn Coord,
329 theme: &Theme,
330 plot_area: &Rect,
331 backend: &mut dyn DrawBackend,
332) -> Result<(), RenderError> {
333 let breaks = scale.breaks();
334 let tick_len = theme.axis_ticks_length;
335 let axis_line = theme.get_axis_line_y();
336 let axis_ticks = theme.get_axis_ticks_y();
337
338 if axis_line.visible {
340 let top = (plot_area.x, plot_area.y);
341 let bottom = (plot_area.x, plot_area.y + plot_area.height);
342 backend.draw_line(
343 &[top, bottom],
344 &LineStyle {
345 color: axis_line.color,
346 width: axis_line.width,
347 alpha: 1.0,
348 linetype: axis_line.linetype.clone(),
349 ..Default::default()
350 },
351 )?;
352 }
353
354 for (pos, label) in &breaks {
356 let py = along_y(coord, *pos, plot_area);
357
358 if axis_ticks.visible {
359 backend.draw_line(
360 &[(plot_area.x - tick_len, py), (plot_area.x, py)],
361 &LineStyle {
362 color: axis_ticks.color,
363 width: axis_ticks.width,
364 alpha: 1.0,
365 linetype: axis_ticks.linetype.clone(),
366 ..Default::default()
367 },
368 )?;
369 }
370
371 if theme.axis_text_y.visible {
372 let family = if theme.axis_text_y.family.is_empty() {
373 None
374 } else {
375 Some(theme.axis_text_y.family.clone())
376 };
377 backend.draw_text(
378 label,
379 (plot_area.x - tick_len - theme.legend_spacing, py),
380 &TextStyle {
381 color: theme.axis_text_y.color,
382 size: theme.axis_text_y.size,
383 anchor: TextAnchor::End,
384 angle: theme.axis_text_y.angle,
385 family,
386 face: theme.axis_text_y.face,
387 bg_colour: None,
388 bg_r: 0.0,
389 },
390 )?;
391 }
392 }
393
394 if theme.axis_minor_ticks && axis_ticks.visible {
396 for pos in minor_breaks(&breaks) {
397 let py = along_y(coord, pos, plot_area);
398 backend.draw_line(
399 &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
400 &LineStyle {
401 color: axis_ticks.color,
402 width: axis_ticks.width,
403 alpha: 1.0,
404 linetype: axis_ticks.linetype.clone(),
405 ..Default::default()
406 },
407 )?;
408 }
409 }
410
411 let title = scale.name();
413 if !title.is_empty() && theme.axis_title_y.visible {
414 let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
415 let center_y = plot_area.y + plot_area.height / 2.0;
416 let family = if theme.axis_title_y.family.is_empty() {
417 None
418 } else {
419 Some(theme.axis_title_y.family.clone())
420 };
421 backend.draw_text(
422 title,
423 (title_x, center_y),
424 &TextStyle {
425 color: theme.axis_title_y.color,
426 size: theme.axis_title_y.size,
427 anchor: TextAnchor::Middle,
428 angle: 270.0,
429 family,
430 face: theme.axis_title_y.face,
431 bg_colour: None,
432 bg_r: 0.0,
433 },
434 )?;
435 }
436
437 Ok(())
438}
439
440pub fn draw_sec_y_axis(
442 primary_scale: &dyn Scale,
443 sec_axis: &crate::scale::sec_axis::SecAxis,
444 coord: &dyn Coord,
445 theme: &Theme,
446 plot_area: &Rect,
447 backend: &mut dyn DrawBackend,
448) -> Result<(), RenderError> {
449 let breaks = primary_scale.breaks();
450 let tick_len = theme.axis_ticks_length;
451 let axis_line = theme.get_axis_line_y();
452 let axis_ticks = theme.get_axis_ticks_y();
453
454 let right_x = plot_area.x + plot_area.width;
455
456 if axis_line.visible {
458 backend.draw_line(
459 &[
460 (right_x, plot_area.y),
461 (right_x, plot_area.y + plot_area.height),
462 ],
463 &LineStyle {
464 color: axis_line.color,
465 width: axis_line.width,
466 alpha: 1.0,
467 linetype: axis_line.linetype.clone(),
468 ..Default::default()
469 },
470 )?;
471 }
472
473 for (pos, label) in &breaks {
475 let py = along_y(coord, *pos, plot_area);
476
477 if axis_ticks.visible {
478 backend.draw_line(
479 &[(right_x, py), (right_x + tick_len, py)],
480 &LineStyle {
481 color: axis_ticks.color,
482 width: axis_ticks.width,
483 alpha: 1.0,
484 linetype: axis_ticks.linetype.clone(),
485 ..Default::default()
486 },
487 )?;
488 }
489
490 if theme.axis_text_y.visible {
491 let locale = primary_scale.locale();
493 let parsed = match locale {
494 Some(l) => l.parse_number(label),
495 None => label.parse::<f64>().ok(),
496 };
497 let sec_label = if let Some(v) = parsed {
498 let transformed = sec_axis.transform_value(v);
499 crate::scale::util::format_number_opt(transformed, locale)
500 } else {
501 label.clone()
502 };
503
504 let family = if theme.axis_text_y.family.is_empty() {
505 None
506 } else {
507 Some(theme.axis_text_y.family.clone())
508 };
509 backend.draw_text(
510 &sec_label,
511 (right_x + tick_len + theme.legend_spacing, py),
512 &TextStyle {
513 color: theme.axis_text_y.color,
514 size: theme.axis_text_y.size,
515 anchor: TextAnchor::Start,
516 angle: theme.axis_text_y.angle,
517 family,
518 face: theme.axis_text_y.face,
519 bg_colour: None,
520 bg_r: 0.0,
521 },
522 )?;
523 }
524 }
525
526 if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
528 let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
529 let center_y = plot_area.y + plot_area.height / 2.0;
530 let family = if theme.axis_title_y.family.is_empty() {
531 None
532 } else {
533 Some(theme.axis_title_y.family.clone())
534 };
535 backend.draw_text(
536 &sec_axis.name,
537 (title_x, center_y),
538 &TextStyle {
539 color: theme.axis_title_y.color,
540 size: theme.axis_title_y.size,
541 anchor: TextAnchor::Middle,
542 angle: 90.0,
543 family,
544 face: theme.axis_title_y.face,
545 bg_colour: None,
546 bg_r: 0.0,
547 },
548 )?;
549 }
550
551 Ok(())
552}
553
554fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
556 if major.len() < 2 {
557 return vec![];
558 }
559 let mut minors = Vec::with_capacity(major.len() - 1);
560 for pair in major.windows(2) {
561 minors.push((pair[0].0 + pair[1].0) / 2.0);
562 }
563 minors
564}
565
566pub fn draw_gridlines(
568 x_scale: &dyn Scale,
569 y_scale: &dyn Scale,
570 coord: &dyn Coord,
571 theme: &Theme,
572 plot_area: &Rect,
573 backend: &mut dyn DrawBackend,
574) -> Result<(), RenderError> {
575 let major_x = theme.get_panel_grid_major_x();
576 let major_y = theme.get_panel_grid_major_y();
577 let minor_x = theme.get_panel_grid_minor_x();
578 let minor_y = theme.get_panel_grid_minor_y();
579
580 let x_breaks = x_scale.breaks();
581 let y_breaks = y_scale.breaks();
582
583 if minor_x.visible {
585 for pos in minor_breaks(&x_breaks) {
586 let px = along_x(coord, pos, plot_area);
587 backend.draw_line(
588 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
589 &LineStyle {
590 color: minor_x.color,
591 width: minor_x.width,
592 alpha: 1.0,
593 linetype: minor_x.linetype.clone(),
594 ..Default::default()
595 },
596 )?;
597 }
598 }
599
600 if minor_y.visible {
602 for pos in minor_breaks(&y_breaks) {
603 let py = along_y(coord, pos, plot_area);
604 backend.draw_line(
605 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
606 &LineStyle {
607 color: minor_y.color,
608 width: minor_y.width,
609 alpha: 1.0,
610 linetype: minor_y.linetype.clone(),
611 ..Default::default()
612 },
613 )?;
614 }
615 }
616
617 if major_x.visible {
619 for (pos, _) in &x_breaks {
620 let px = along_x(coord, *pos, plot_area);
621 backend.draw_line(
622 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
623 &LineStyle {
624 color: major_x.color,
625 width: major_x.width,
626 alpha: 1.0,
627 linetype: major_x.linetype.clone(),
628 ..Default::default()
629 },
630 )?;
631 }
632 }
633
634 if major_y.visible {
636 for (pos, _) in &y_breaks {
637 let py = along_y(coord, *pos, plot_area);
638 backend.draw_line(
639 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
640 &LineStyle {
641 color: major_y.color,
642 width: major_y.width,
643 alpha: 1.0,
644 linetype: major_y.linetype.clone(),
645 ..Default::default()
646 },
647 )?;
648 }
649 }
650
651 Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use crate::aes::Aesthetic;
658 use crate::coord::cartesian::CoordCartesian;
659 use crate::data::Value;
660 use crate::render::svg_backend::SvgBackend;
661 use crate::theme::presets::theme_gray;
662
663 struct FixedBreaksScale(Vec<(f64, String)>);
666
667 impl Scale for FixedBreaksScale {
668 fn aesthetic(&self) -> Aesthetic {
669 Aesthetic::X
670 }
671 fn train(&mut self, _values: &[Value]) {}
672 fn map(&self, _value: &Value) -> f64 {
673 0.0
674 }
675 fn breaks(&self) -> Vec<(f64, String)> {
676 self.0.clone()
677 }
678 fn name(&self) -> &str {
679 ""
680 }
681 fn set_name(&mut self, _name: &str) {}
682 fn clone_box(&self) -> Box<dyn Scale> {
683 Box::new(FixedBreaksScale(self.0.clone()))
684 }
685 }
686
687 fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
688 SvgBackend::new(w, h, plot_area)
689 }
690
691 #[test]
692 fn a_tick_outside_the_svg_is_dropped() {
693 let scale = FixedBreaksScale(vec![
696 (0.2, "kept".to_string()),
697 (5.0, "off_canvas".to_string()),
698 ]);
699 let coord = CoordCartesian::new();
700 let theme = theme_gray();
701 let plot_area = Rect {
702 x: 10.0,
703 y: 10.0,
704 width: 180.0,
705 height: 70.0,
706 };
707 let canvas = Rect {
708 x: 0.0,
709 y: 0.0,
710 width: 200.0,
711 height: 100.0,
712 };
713 let mut backend = backend(200, 100, plot_area.clone());
714 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
715 let svg = backend.finish();
716 assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
717 assert!(
718 !svg.contains(">off_canvas<"),
719 "off-canvas label was drawn: {svg}"
720 );
721 }
722
723 #[test]
724 fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
725 let scale = FixedBreaksScale(vec![
727 (0.0, "a".to_string()),
728 (1.0, "2021-01-01".to_string()),
729 ]);
730 let coord = CoordCartesian::new();
731 let mut theme = theme_gray();
732 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
733 let plot_area = Rect {
734 x: 0.0,
735 y: 0.0,
736 width: 60.0,
737 height: 50.0,
738 };
739 let canvas = plot_area.clone();
740 let mut backend = backend(60, 50, plot_area.clone());
741 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
742 let svg = backend.finish();
743 assert!(
746 svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
747 "clip mode should not shift the label: {svg}"
748 );
749 }
750
751 #[test]
752 fn shift_moves_the_same_label_inside_the_canvas() {
753 let scale = FixedBreaksScale(vec![
754 (0.0, "a".to_string()),
755 (1.0, "2021-01-01".to_string()),
756 ]);
757 let coord = CoordCartesian::new();
758 let mut theme = theme_gray();
759 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
760 let plot_area = Rect {
761 x: 0.0,
762 y: 0.0,
763 width: 60.0,
764 height: 50.0,
765 };
766 let canvas = plot_area.clone();
767 let mut backend = backend(60, 50, plot_area.clone());
768 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
769 let svg = backend.finish();
770 assert!(
771 !svg.contains("x=\"60.00\" y=\""),
772 "shift mode should move the label off the bare tick: {svg}"
773 );
774 let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
775 let needle = ">2021-01-01<";
777 let tag_end = svg.find(needle).unwrap();
778 let tag_start = svg[..tag_end].rfind("<text ").unwrap();
779 let tag = &svg[tag_start..tag_end];
780 let x_attr = tag.rsplit("x=\"").next().unwrap();
781 let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
782 assert!(
785 x + w / 2.0 <= 60.0 - 1.0 + 0.01,
786 "right edge still overflows: x={x} w={w}"
787 );
788 }
789}