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