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