1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
use skia_safe::{Canvas, PaintStyle};
use rustmotion_core::engine::renderer::{
draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
parse_hex_color,
};
use super::Chart;
/// Returns "#000000" or "#FFFFFF" depending on the perceived luminance of `hex`.
pub(super) fn contrast_text_color(hex: &str) -> String {
let (r, g, b, _) = parse_hex_color(hex);
// Relative luminance (sRGB)
let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
if lum > 150.0 {
"#000000".to_string()
} else {
"#FFFFFF".to_string()
}
}
pub(super) fn format_number(val: f64) -> String {
if val.abs() >= 1_000_000.0 {
format!("{:.1}M", val / 1_000_000.0)
} else if val.abs() >= 1_000.0 {
format!("{:.1}K", val / 1_000.0)
} else if val.fract().abs() < 0.01 {
format!("{}", val as i64)
} else {
format!("{:.1}", val)
}
}
impl Chart {
/// Draw grid lines and axis labels for cartesian charts.
///
/// When `categorical` is true, x-labels are placed at the center of evenly
/// distributed slots (`(i+0.5)/n * chart_w`) — appropriate for bar charts
/// where labels belong to a discrete bar slot. When false, labels are
/// placed at proportional positions (`i/(n-1) * chart_w`), spanning the
/// full chart width — appropriate for line/scatter where labels mark
/// points on a continuous axis.
pub(super) fn draw_axes(
&self,
canvas: &Canvas,
chart_x: f32,
chart_y: f32,
chart_w: f32,
chart_h: f32,
min_val: f64,
max_val: f64,
x_labels: &[String],
categorical: bool,
) {
let Some(font) = self.make_label_font() else {
return;
};
let emoji_font =
emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size));
let (_, metrics) = font.metrics();
let ascent = -metrics.ascent;
// Grid lines + Y labels
let grid_steps = 5;
let range = max_val - min_val;
for i in 0..=grid_steps {
let frac = i as f32 / grid_steps as f32;
let y = chart_y + chart_h - frac * chart_h;
if self.show_grid {
let mut grid_paint = paint_from_hex(&self.grid_color);
grid_paint.set_style(PaintStyle::Stroke);
grid_paint.set_stroke_width(1.0);
grid_paint.set_anti_alias(true);
canvas.draw_line((chart_x, y), (chart_x + chart_w, y), &grid_paint);
}
if self.show_y_labels {
let val = min_val + range * frac as f64;
let label = format_number(val);
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
let label_w = measure_text_with_fallback(&label, &font, &emoji_font, 0.0);
let lx = chart_x - label_w - 6.0;
let ly = y + ascent / 2.0;
draw_text_with_fallback(
canvas,
&label,
&font,
&emoji_font,
0.0,
lx,
ly,
&label_paint,
);
}
}
// X labels
if self.show_x_labels && !x_labels.is_empty() {
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
let n = x_labels.len();
for (i, label) in x_labels.iter().enumerate() {
let x = if n == 1 {
chart_x + chart_w / 2.0
} else if categorical {
chart_x + ((i as f32 + 0.5) / n as f32) * chart_w
} else {
chart_x + (i as f32 / (n - 1).max(1) as f32) * chart_w
};
let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
// Centring the first/last label on the axis end pushes half of
// it outside the component's own box (measured: the last label
// of a 5-point line chart bled 5px past the right edge). Clamp
// into [0, box_width] — `chart_margins()` always returns a
// right margin of 8, so the box ends at chart_x + chart_w + 8.
let box_w = chart_x + chart_w + 8.0;
let lx = (x - label_w / 2.0).clamp(0.0, (box_w - label_w).max(0.0));
let ly = chart_y + chart_h + ascent + 6.0;
draw_text_with_fallback(
canvas,
label,
&font,
&emoji_font,
0.0,
lx,
ly,
&label_paint,
);
}
}
}
/// Draw the *value* axis of a transposed (horizontal) cartesian chart:
/// vertical grid lines and value tick labels along the bottom.
///
/// [`draw_axes`] assumes the value axis is vertical (grid lines
/// horizontal, values down the left gutter), which is wrong for
/// `horizontal_bar` — there the value axis runs left-to-right and the
/// category axis is the vertical one.
pub(super) fn draw_value_axis_x(
&self,
canvas: &Canvas,
chart_x: f32,
chart_y: f32,
chart_w: f32,
chart_h: f32,
min_val: f64,
max_val: f64,
) {
if !self.show_grid && !self.show_x_labels {
return;
}
let Some(font) = self.make_label_font() else {
return;
};
let emoji_font =
emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size));
let (_, metrics) = font.metrics();
let ascent = -metrics.ascent;
let grid_steps = 5;
let range = max_val - min_val;
for i in 0..=grid_steps {
let frac = i as f32 / grid_steps as f32;
let x = chart_x + frac * chart_w;
if self.show_grid {
let mut grid_paint = paint_from_hex(&self.grid_color);
grid_paint.set_style(PaintStyle::Stroke);
grid_paint.set_stroke_width(1.0);
grid_paint.set_anti_alias(true);
canvas.draw_line((x, chart_y), (x, chart_y + chart_h), &grid_paint);
}
if self.show_x_labels {
let label = format_number(min_val + range * frac as f64);
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
let label_w = measure_text_with_fallback(&label, &font, &emoji_font, 0.0);
let box_w = chart_x + chart_w + 8.0;
let lx = (x - label_w / 2.0).clamp(0.0, (box_w - label_w).max(0.0));
let ly = chart_y + chart_h + ascent + 6.0;
draw_text_with_fallback(
canvas,
&label,
&font,
&emoji_font,
0.0,
lx,
ly,
&label_paint,
);
}
}
}
}