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,
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,
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 },
264 )?;
265 }
266 }
267
268 if theme.axis_minor_ticks && axis_ticks.visible {
270 for pos in minor_breaks(&breaks) {
271 let px = along_x(coord, pos, plot_area);
272 backend.draw_line(
273 &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
274 &LineStyle {
275 color: axis_ticks.color,
276 width: axis_ticks.width,
277 alpha: 1.0,
278 linetype: axis_ticks.linetype,
279 ..Default::default()
280 },
281 )?;
282 }
283 }
284
285 let title = scale.name();
287 if !title.is_empty() && theme.axis_title_x.visible {
288 let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
289 let center_x = plot_area.x + hjust * plot_area.width;
290 let anchor = if hjust <= 0.02 {
291 TextAnchor::Start
292 } else if hjust >= 0.98 {
293 TextAnchor::End
294 } else {
295 TextAnchor::Middle
296 };
297 let title_y = edge_y
298 + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
299 let family = if theme.axis_title_x.family.is_empty() {
300 None
301 } else {
302 Some(theme.axis_title_x.family.clone())
303 };
304 backend.draw_text(
305 title,
306 (center_x, title_y),
307 &TextStyle {
308 color: theme.axis_title_x.color,
309 size: theme.axis_title_x.size,
310 anchor,
311 angle: 0.0,
312 family,
313 face: theme.axis_title_x.face,
314 },
315 )?;
316 }
317
318 Ok(())
319}
320
321pub fn draw_y_axis(
323 scale: &dyn Scale,
324 coord: &dyn Coord,
325 theme: &Theme,
326 plot_area: &Rect,
327 backend: &mut dyn DrawBackend,
328) -> Result<(), RenderError> {
329 let breaks = scale.breaks();
330 let tick_len = theme.axis_ticks_length;
331 let axis_line = theme.get_axis_line_y();
332 let axis_ticks = theme.get_axis_ticks_y();
333
334 if axis_line.visible {
336 let top = (plot_area.x, plot_area.y);
337 let bottom = (plot_area.x, plot_area.y + plot_area.height);
338 backend.draw_line(
339 &[top, bottom],
340 &LineStyle {
341 color: axis_line.color,
342 width: axis_line.width,
343 alpha: 1.0,
344 linetype: axis_line.linetype,
345 ..Default::default()
346 },
347 )?;
348 }
349
350 for (pos, label) in &breaks {
352 let py = along_y(coord, *pos, plot_area);
353
354 if axis_ticks.visible {
355 backend.draw_line(
356 &[(plot_area.x - tick_len, py), (plot_area.x, py)],
357 &LineStyle {
358 color: axis_ticks.color,
359 width: axis_ticks.width,
360 alpha: 1.0,
361 linetype: axis_ticks.linetype,
362 ..Default::default()
363 },
364 )?;
365 }
366
367 if theme.axis_text_y.visible {
368 let family = if theme.axis_text_y.family.is_empty() {
369 None
370 } else {
371 Some(theme.axis_text_y.family.clone())
372 };
373 backend.draw_text(
374 label,
375 (plot_area.x - tick_len - theme.legend_spacing, py),
376 &TextStyle {
377 color: theme.axis_text_y.color,
378 size: theme.axis_text_y.size,
379 anchor: TextAnchor::End,
380 angle: theme.axis_text_y.angle,
381 family,
382 face: theme.axis_text_y.face,
383 },
384 )?;
385 }
386 }
387
388 if theme.axis_minor_ticks && axis_ticks.visible {
390 for pos in minor_breaks(&breaks) {
391 let py = along_y(coord, pos, plot_area);
392 backend.draw_line(
393 &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
394 &LineStyle {
395 color: axis_ticks.color,
396 width: axis_ticks.width,
397 alpha: 1.0,
398 linetype: axis_ticks.linetype,
399 ..Default::default()
400 },
401 )?;
402 }
403 }
404
405 let title = scale.name();
407 if !title.is_empty() && theme.axis_title_y.visible {
408 let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
409 let center_y = plot_area.y + plot_area.height / 2.0;
410 let family = if theme.axis_title_y.family.is_empty() {
411 None
412 } else {
413 Some(theme.axis_title_y.family.clone())
414 };
415 backend.draw_text(
416 title,
417 (title_x, center_y),
418 &TextStyle {
419 color: theme.axis_title_y.color,
420 size: theme.axis_title_y.size,
421 anchor: TextAnchor::Middle,
422 angle: 270.0,
423 family,
424 face: theme.axis_title_y.face,
425 },
426 )?;
427 }
428
429 Ok(())
430}
431
432pub fn draw_sec_y_axis(
434 primary_scale: &dyn Scale,
435 sec_axis: &crate::scale::sec_axis::SecAxis,
436 coord: &dyn Coord,
437 theme: &Theme,
438 plot_area: &Rect,
439 backend: &mut dyn DrawBackend,
440) -> Result<(), RenderError> {
441 let breaks = primary_scale.breaks();
442 let tick_len = theme.axis_ticks_length;
443 let axis_line = theme.get_axis_line_y();
444 let axis_ticks = theme.get_axis_ticks_y();
445
446 let right_x = plot_area.x + plot_area.width;
447
448 if axis_line.visible {
450 backend.draw_line(
451 &[
452 (right_x, plot_area.y),
453 (right_x, plot_area.y + plot_area.height),
454 ],
455 &LineStyle {
456 color: axis_line.color,
457 width: axis_line.width,
458 alpha: 1.0,
459 linetype: axis_line.linetype,
460 ..Default::default()
461 },
462 )?;
463 }
464
465 for (pos, label) in &breaks {
467 let py = along_y(coord, *pos, plot_area);
468
469 if axis_ticks.visible {
470 backend.draw_line(
471 &[(right_x, py), (right_x + tick_len, py)],
472 &LineStyle {
473 color: axis_ticks.color,
474 width: axis_ticks.width,
475 alpha: 1.0,
476 linetype: axis_ticks.linetype,
477 ..Default::default()
478 },
479 )?;
480 }
481
482 if theme.axis_text_y.visible {
483 let locale = primary_scale.locale();
485 let parsed = match locale {
486 Some(l) => l.parse_number(label),
487 None => label.parse::<f64>().ok(),
488 };
489 let sec_label = if let Some(v) = parsed {
490 let transformed = sec_axis.transform_value(v);
491 crate::scale::util::format_number_opt(transformed, locale)
492 } else {
493 label.clone()
494 };
495
496 let family = if theme.axis_text_y.family.is_empty() {
497 None
498 } else {
499 Some(theme.axis_text_y.family.clone())
500 };
501 backend.draw_text(
502 &sec_label,
503 (right_x + tick_len + theme.legend_spacing, py),
504 &TextStyle {
505 color: theme.axis_text_y.color,
506 size: theme.axis_text_y.size,
507 anchor: TextAnchor::Start,
508 angle: theme.axis_text_y.angle,
509 family,
510 face: theme.axis_text_y.face,
511 },
512 )?;
513 }
514 }
515
516 if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
518 let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
519 let center_y = plot_area.y + plot_area.height / 2.0;
520 let family = if theme.axis_title_y.family.is_empty() {
521 None
522 } else {
523 Some(theme.axis_title_y.family.clone())
524 };
525 backend.draw_text(
526 &sec_axis.name,
527 (title_x, center_y),
528 &TextStyle {
529 color: theme.axis_title_y.color,
530 size: theme.axis_title_y.size,
531 anchor: TextAnchor::Middle,
532 angle: 90.0,
533 family,
534 face: theme.axis_title_y.face,
535 },
536 )?;
537 }
538
539 Ok(())
540}
541
542fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
544 if major.len() < 2 {
545 return vec![];
546 }
547 let mut minors = Vec::with_capacity(major.len() - 1);
548 for pair in major.windows(2) {
549 minors.push((pair[0].0 + pair[1].0) / 2.0);
550 }
551 minors
552}
553
554pub fn draw_gridlines(
556 x_scale: &dyn Scale,
557 y_scale: &dyn Scale,
558 coord: &dyn Coord,
559 theme: &Theme,
560 plot_area: &Rect,
561 backend: &mut dyn DrawBackend,
562) -> Result<(), RenderError> {
563 let major_x = theme.get_panel_grid_major_x();
564 let major_y = theme.get_panel_grid_major_y();
565 let minor_x = theme.get_panel_grid_minor_x();
566 let minor_y = theme.get_panel_grid_minor_y();
567
568 let x_breaks = x_scale.breaks();
569 let y_breaks = y_scale.breaks();
570
571 if minor_x.visible {
573 for pos in minor_breaks(&x_breaks) {
574 let px = along_x(coord, pos, plot_area);
575 backend.draw_line(
576 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
577 &LineStyle {
578 color: minor_x.color,
579 width: minor_x.width,
580 alpha: 1.0,
581 linetype: minor_x.linetype,
582 ..Default::default()
583 },
584 )?;
585 }
586 }
587
588 if minor_y.visible {
590 for pos in minor_breaks(&y_breaks) {
591 let py = along_y(coord, pos, plot_area);
592 backend.draw_line(
593 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
594 &LineStyle {
595 color: minor_y.color,
596 width: minor_y.width,
597 alpha: 1.0,
598 linetype: minor_y.linetype,
599 ..Default::default()
600 },
601 )?;
602 }
603 }
604
605 if major_x.visible {
607 for (pos, _) in &x_breaks {
608 let px = along_x(coord, *pos, plot_area);
609 backend.draw_line(
610 &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
611 &LineStyle {
612 color: major_x.color,
613 width: major_x.width,
614 alpha: 1.0,
615 linetype: major_x.linetype,
616 ..Default::default()
617 },
618 )?;
619 }
620 }
621
622 if major_y.visible {
624 for (pos, _) in &y_breaks {
625 let py = along_y(coord, *pos, plot_area);
626 backend.draw_line(
627 &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
628 &LineStyle {
629 color: major_y.color,
630 width: major_y.width,
631 alpha: 1.0,
632 linetype: major_y.linetype,
633 ..Default::default()
634 },
635 )?;
636 }
637 }
638
639 Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::aes::Aesthetic;
646 use crate::coord::cartesian::CoordCartesian;
647 use crate::data::Value;
648 use crate::render::svg_backend::SvgBackend;
649 use crate::theme::presets::theme_gray;
650
651 struct FixedBreaksScale(Vec<(f64, String)>);
654
655 impl Scale for FixedBreaksScale {
656 fn aesthetic(&self) -> Aesthetic {
657 Aesthetic::X
658 }
659 fn train(&mut self, _values: &[Value]) {}
660 fn map(&self, _value: &Value) -> f64 {
661 0.0
662 }
663 fn breaks(&self) -> Vec<(f64, String)> {
664 self.0.clone()
665 }
666 fn name(&self) -> &str {
667 ""
668 }
669 fn set_name(&mut self, _name: &str) {}
670 fn clone_box(&self) -> Box<dyn Scale> {
671 Box::new(FixedBreaksScale(self.0.clone()))
672 }
673 }
674
675 fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
676 SvgBackend::new(w, h, plot_area)
677 }
678
679 #[test]
680 fn a_tick_outside_the_svg_is_dropped() {
681 let scale = FixedBreaksScale(vec![
684 (0.2, "kept".to_string()),
685 (5.0, "off_canvas".to_string()),
686 ]);
687 let coord = CoordCartesian::new();
688 let theme = theme_gray();
689 let plot_area = Rect {
690 x: 10.0,
691 y: 10.0,
692 width: 180.0,
693 height: 70.0,
694 };
695 let canvas = Rect {
696 x: 0.0,
697 y: 0.0,
698 width: 200.0,
699 height: 100.0,
700 };
701 let mut backend = backend(200, 100, plot_area.clone());
702 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
703 let svg = backend.finish();
704 assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
705 assert!(
706 !svg.contains(">off_canvas<"),
707 "off-canvas label was drawn: {svg}"
708 );
709 }
710
711 #[test]
712 fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
713 let scale = FixedBreaksScale(vec![
715 (0.0, "a".to_string()),
716 (1.0, "2021-01-01".to_string()),
717 ]);
718 let coord = CoordCartesian::new();
719 let mut theme = theme_gray();
720 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
721 let plot_area = Rect {
722 x: 0.0,
723 y: 0.0,
724 width: 60.0,
725 height: 50.0,
726 };
727 let canvas = plot_area.clone();
728 let mut backend = backend(60, 50, plot_area.clone());
729 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
730 let svg = backend.finish();
731 assert!(
734 svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
735 "clip mode should not shift the label: {svg}"
736 );
737 }
738
739 #[test]
740 fn shift_moves_the_same_label_inside_the_canvas() {
741 let scale = FixedBreaksScale(vec![
742 (0.0, "a".to_string()),
743 (1.0, "2021-01-01".to_string()),
744 ]);
745 let coord = CoordCartesian::new();
746 let mut theme = theme_gray();
747 theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
748 let plot_area = Rect {
749 x: 0.0,
750 y: 0.0,
751 width: 60.0,
752 height: 50.0,
753 };
754 let canvas = plot_area.clone();
755 let mut backend = backend(60, 50, plot_area.clone());
756 draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
757 let svg = backend.finish();
758 assert!(
759 !svg.contains("x=\"60.00\" y=\""),
760 "shift mode should move the label off the bare tick: {svg}"
761 );
762 let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
763 let needle = ">2021-01-01<";
765 let tag_end = svg.find(needle).unwrap();
766 let tag_start = svg[..tag_end].rfind("<text ").unwrap();
767 let tag = &svg[tag_start..tag_end];
768 let x_attr = tag.rsplit("x=\"").next().unwrap();
769 let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
770 assert!(
773 x + w / 2.0 <= 60.0 - 1.0 + 0.01,
774 "right edge still overflows: x={x} w={w}"
775 );
776 }
777}