1#![allow(
4 clippy::cast_possible_truncation,
5 clippy::cast_precision_loss,
6 clippy::cast_sign_loss,
7 clippy::many_single_char_names,
8 clippy::too_many_lines,
9 clippy::cast_possible_wrap
10)]
11
12use ratatui::buffer::Buffer;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::widgets::{Block, Widget};
16
17use crate::braille::{polar, Braille};
18use crate::util::{block_inner, palette, sane, text_width, truncate};
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct RadarSeries<'a> {
23 name: &'a str,
24 values: Vec<f64>,
25 color: Option<Color>,
26}
27
28impl<'a> RadarSeries<'a> {
29 #[must_use]
31 pub fn new(name: &'a str, values: Vec<f64>) -> Self {
32 Self {
33 name,
34 values,
35 color: None,
36 }
37 }
38
39 #[must_use]
41 pub const fn color(mut self, color: Color) -> Self {
42 self.color = Some(color);
43 self
44 }
45}
46
47#[derive(Debug, Clone)]
63pub struct RadarChart<'a> {
64 axes: Vec<&'a str>,
65 series: Vec<RadarSeries<'a>>,
66 max: Option<f64>,
67 rings: usize,
68 legend: bool,
69 block: Option<Block<'a>>,
70 base: Style,
71}
72
73impl<'a> RadarChart<'a> {
74 #[must_use]
76 pub fn new(axes: Vec<&'a str>, series: Vec<RadarSeries<'a>>) -> Self {
77 Self {
78 axes,
79 series,
80 max: None,
81 rings: 4,
82 legend: true,
83 block: None,
84 base: Style::default(),
85 }
86 }
87
88 #[must_use]
90 pub fn max(mut self, max: f64) -> Self {
91 self.max = Some(max);
92 self
93 }
94
95 #[must_use]
97 pub fn rings(mut self, rings: usize) -> Self {
98 self.rings = rings.clamp(1, 10);
99 self
100 }
101
102 #[must_use]
104 pub const fn legend(mut self, show: bool) -> Self {
105 self.legend = show;
106 self
107 }
108
109 #[must_use]
111 pub fn block(mut self, block: Block<'a>) -> Self {
112 self.block = Some(block);
113 self
114 }
115
116 #[must_use]
118 pub const fn base_style(mut self, style: Style) -> Self {
119 self.base = style;
120 self
121 }
122
123 fn scale(&self) -> f64 {
124 let m = self.max.map_or(0.0, sane);
125 if m > 0.0 {
126 return m;
127 }
128 let auto = self
129 .series
130 .iter()
131 .flat_map(|s| s.values.iter())
132 .map(|v| sane(*v))
133 .fold(0.0, f64::max);
134 if auto > 0.0 {
135 auto
136 } else {
137 1.0
138 }
139 }
140}
141
142const GRID: Color = Color::DarkGray;
143
144impl Widget for RadarChart<'_> {
145 fn render(self, area: Rect, buf: &mut Buffer) {
146 buf.set_style(area, self.base);
147 let inner = block_inner(self.block.as_ref(), area, buf);
148 let n = self.axes.len();
149 if inner.is_empty() || n < 3 {
150 return;
151 }
152 let show_legend = self.legend && !self.series.is_empty() && inner.height >= 8;
153 let legend_rows = u16::from(show_legend);
154 let label_w = self
155 .axes
156 .iter()
157 .map(|a| text_width(a))
158 .max()
159 .unwrap_or(0)
160 .min(usize::from(inner.width) / 4) as u16;
161 let grid = Rect::new(
162 inner.x + label_w + 1,
163 inner.y + 1,
164 inner.width.saturating_sub(2 * (label_w + 1)),
165 inner.height.saturating_sub(2 + legend_rows),
166 );
167 if grid.is_empty() {
168 return;
169 }
170 let mut g = Braille::new(grid.width, grid.height);
171 let c = (g.dots_w() / 2.0, g.dots_h() / 2.0);
172 let r = c.0.min(c.1) - 1.0;
173 if r < 3.0 {
174 return;
175 }
176 let turn = |k: usize| k as f64 / n as f64;
177
178 for ring in 1..=self.rings {
180 let rr = r * ring as f64 / self.rings as f64;
181 for k in 0..n {
182 g.line(
183 polar(c.0, c.1, rr, turn(k)),
184 polar(c.0, c.1, rr, turn((k + 1) % n)),
185 GRID,
186 0,
187 );
188 }
189 }
190 for k in 0..n {
191 g.line(c, polar(c.0, c.1, r, turn(k)), GRID, 0);
192 }
193
194 let scale = self.scale();
196 for (si, s) in self.series.iter().enumerate() {
197 let color = s.color.unwrap_or_else(|| palette(si));
198 let prio = 1 + si.min(200) as u8;
199 let pts: Vec<(f64, f64)> = (0..n)
200 .map(|k| {
201 let v = (sane(s.values.get(k).copied().unwrap_or(0.0)) / scale).min(1.0);
202 polar(c.0, c.1, r * v, turn(k))
203 })
204 .collect();
205 for k in 0..n {
206 g.line(pts[k], pts[(k + 1) % n], color, prio);
207 }
208 for &(x, y) in &pts {
209 for (dx, dy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] {
210 g.plot(x + dx - 0.5, y + dy - 0.5, color, prio);
211 }
212 }
213 }
214 g.blit(grid, buf);
215
216 let bold = Style::default().add_modifier(Modifier::BOLD);
218 for (k, label) in self.axes.iter().enumerate() {
219 let (dx, dy) = polar(c.0, c.1, r + 2.0, turn(k));
220 let cx = i32::from(grid.x) + (dx / 2.0).floor() as i32;
221 let cy = i32::from(grid.y) + (dy / 4.0).floor() as i32;
222 let text = truncate(label, usize::from(label_w.max(1)));
223 let w = text_width(&text) as i32;
224 let s = (turn(k) * std::f64::consts::TAU).sin();
225 let x = if s > 0.3 {
226 cx + 1
227 } else if s < -0.3 {
228 cx - w
229 } else {
230 cx - w / 2
231 };
232 let y = cy.clamp(
233 i32::from(inner.y),
234 i32::from(inner.bottom()) - 1 - i32::from(legend_rows),
235 );
236 let x = x.clamp(
237 i32::from(inner.x),
238 (i32::from(inner.right()) - w).max(i32::from(inner.x)),
239 );
240 buf.set_string(x as u16, y as u16, &text, bold);
241 }
242
243 if show_legend {
244 let y = inner.bottom() - 1;
245 let mut x = inner.x;
246 for (si, s) in self.series.iter().enumerate() {
247 let color = s.color.unwrap_or_else(|| palette(si));
248 let name = format!("■ {} ", s.name);
249 let w = text_width(&name) as u16;
250 if x + w > inner.right() {
251 break;
252 }
253 buf.set_string(x, y, &name, Style::default().fg(color));
254 x += w;
255 }
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 #![allow(clippy::assert_is_empty, clippy::float_cmp)]
263 use super::*;
264
265 fn chart() -> RadarChart<'static> {
266 RadarChart::new(
267 vec!["Speed", "Power", "Range", "Armor", "Agility"],
268 vec![
269 RadarSeries::new("Scout", vec![9.0, 3.0, 7.0, 2.0, 8.0]).color(Color::Red),
270 RadarSeries::new("Tank", vec![3.0, 8.0, 4.0, 9.0, 2.0]).color(Color::Blue),
271 ],
272 )
273 }
274
275 fn draw(c: RadarChart<'_>, w: u16, h: u16) -> Buffer {
276 let a = Rect::new(0, 0, w, h);
277 let mut b = Buffer::empty(a);
278 c.render(a, &mut b);
279 b
280 }
281
282 fn text(b: &Buffer) -> String {
283 b.content()
284 .iter()
285 .map(ratatui::buffer::Cell::symbol)
286 .collect()
287 }
288
289 #[test]
290 fn renders_axes_series_and_legend() {
291 let b = draw(chart(), 60, 22);
292 let t = text(&b);
293 for l in [
294 "Speed", "Power", "Range", "Armor", "Agility", "Scout", "Tank",
295 ] {
296 assert!(t.contains(l), "missing {l}");
297 }
298 for col in [Color::Red, Color::Blue] {
299 assert!(b.content().iter().any(|c| c.fg == col && c.symbol() != " "));
300 }
301 }
302
303 #[test]
304 fn needs_three_axes() {
305 let b = draw(RadarChart::new(vec!["a", "b"], vec![]), 40, 20);
306 assert_eq!(text(&b).trim(), "");
307 }
308
309 #[test]
310 fn values_change_picture_and_max_scales() {
311 let a = draw(chart(), 60, 22);
312 let b = draw(chart().max(100.0), 60, 22);
313 assert_ne!(a, b);
314 }
315
316 #[test]
317 fn legend_toggle() {
318 assert!(!text(&draw(chart().legend(false), 60, 22)).contains("Scout"));
319 }
320
321 #[test]
322 fn robust() {
323 for (w, h) in [(0, 0), (1, 1), (6, 3), (20, 8), (80, 40)] {
324 for axes in [vec![], vec!["a"; 3], vec!["long axis label"; 12]] {
325 for series in [
326 vec![],
327 vec![RadarSeries::new("s", vec![])],
328 vec![RadarSeries::new(
329 "s",
330 vec![f64::NAN, -1.0, f64::INFINITY, 5.0],
331 )],
332 ] {
333 let _ = draw(
334 RadarChart::new(axes.clone(), series)
335 .rings(99)
336 .max(f64::NAN),
337 w,
338 h,
339 );
340 }
341 }
342 }
343 }
344}