#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::many_single_char_names,
clippy::too_many_lines,
clippy::cast_possible_wrap
)]
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget};
use crate::braille::{polar, Braille};
use crate::util::{block_inner, palette, sane, text_width, truncate};
#[derive(Debug, Clone, PartialEq)]
pub struct RadarSeries<'a> {
name: &'a str,
values: Vec<f64>,
color: Option<Color>,
}
impl<'a> RadarSeries<'a> {
#[must_use]
pub fn new(name: &'a str, values: Vec<f64>) -> Self {
Self {
name,
values,
color: None,
}
}
#[must_use]
pub const fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
#[derive(Debug, Clone)]
pub struct RadarChart<'a> {
axes: Vec<&'a str>,
series: Vec<RadarSeries<'a>>,
max: Option<f64>,
rings: usize,
legend: bool,
block: Option<Block<'a>>,
base: Style,
}
impl<'a> RadarChart<'a> {
#[must_use]
pub fn new(axes: Vec<&'a str>, series: Vec<RadarSeries<'a>>) -> Self {
Self {
axes,
series,
max: None,
rings: 4,
legend: true,
block: None,
base: Style::default(),
}
}
#[must_use]
pub fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
#[must_use]
pub fn rings(mut self, rings: usize) -> Self {
self.rings = rings.clamp(1, 10);
self
}
#[must_use]
pub const fn legend(mut self, show: bool) -> Self {
self.legend = show;
self
}
#[must_use]
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
#[must_use]
pub const fn base_style(mut self, style: Style) -> Self {
self.base = style;
self
}
fn scale(&self) -> f64 {
let m = self.max.map_or(0.0, sane);
if m > 0.0 {
return m;
}
let auto = self
.series
.iter()
.flat_map(|s| s.values.iter())
.map(|v| sane(*v))
.fold(0.0, f64::max);
if auto > 0.0 {
auto
} else {
1.0
}
}
}
const GRID: Color = Color::DarkGray;
impl Widget for RadarChart<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
buf.set_style(area, self.base);
let inner = block_inner(self.block.as_ref(), area, buf);
let n = self.axes.len();
if inner.is_empty() || n < 3 {
return;
}
let show_legend = self.legend && !self.series.is_empty() && inner.height >= 8;
let legend_rows = u16::from(show_legend);
let label_w = self
.axes
.iter()
.map(|a| text_width(a))
.max()
.unwrap_or(0)
.min(usize::from(inner.width) / 4) as u16;
let grid = Rect::new(
inner.x + label_w + 1,
inner.y + 1,
inner.width.saturating_sub(2 * (label_w + 1)),
inner.height.saturating_sub(2 + legend_rows),
);
if grid.is_empty() {
return;
}
let mut g = Braille::new(grid.width, grid.height);
let c = (g.dots_w() / 2.0, g.dots_h() / 2.0);
let r = c.0.min(c.1) - 1.0;
if r < 3.0 {
return;
}
let turn = |k: usize| k as f64 / n as f64;
for ring in 1..=self.rings {
let rr = r * ring as f64 / self.rings as f64;
for k in 0..n {
g.line(
polar(c.0, c.1, rr, turn(k)),
polar(c.0, c.1, rr, turn((k + 1) % n)),
GRID,
0,
);
}
}
for k in 0..n {
g.line(c, polar(c.0, c.1, r, turn(k)), GRID, 0);
}
let scale = self.scale();
for (si, s) in self.series.iter().enumerate() {
let color = s.color.unwrap_or_else(|| palette(si));
let prio = 1 + si.min(200) as u8;
let pts: Vec<(f64, f64)> = (0..n)
.map(|k| {
let v = (sane(s.values.get(k).copied().unwrap_or(0.0)) / scale).min(1.0);
polar(c.0, c.1, r * v, turn(k))
})
.collect();
for k in 0..n {
g.line(pts[k], pts[(k + 1) % n], color, prio);
}
for &(x, y) in &pts {
for (dx, dy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] {
g.plot(x + dx - 0.5, y + dy - 0.5, color, prio);
}
}
}
g.blit(grid, buf);
let bold = Style::default().add_modifier(Modifier::BOLD);
for (k, label) in self.axes.iter().enumerate() {
let (dx, dy) = polar(c.0, c.1, r + 2.0, turn(k));
let cx = i32::from(grid.x) + (dx / 2.0).floor() as i32;
let cy = i32::from(grid.y) + (dy / 4.0).floor() as i32;
let text = truncate(label, usize::from(label_w.max(1)));
let w = text_width(&text) as i32;
let s = (turn(k) * std::f64::consts::TAU).sin();
let x = if s > 0.3 {
cx + 1
} else if s < -0.3 {
cx - w
} else {
cx - w / 2
};
let y = cy.clamp(
i32::from(inner.y),
i32::from(inner.bottom()) - 1 - i32::from(legend_rows),
);
let x = x.clamp(
i32::from(inner.x),
(i32::from(inner.right()) - w).max(i32::from(inner.x)),
);
buf.set_string(x as u16, y as u16, &text, bold);
}
if show_legend {
let y = inner.bottom() - 1;
let mut x = inner.x;
for (si, s) in self.series.iter().enumerate() {
let color = s.color.unwrap_or_else(|| palette(si));
let name = format!("â– {} ", s.name);
let w = text_width(&name) as u16;
if x + w > inner.right() {
break;
}
buf.set_string(x, y, &name, Style::default().fg(color));
x += w;
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::assert_is_empty, clippy::float_cmp)]
use super::*;
fn chart() -> RadarChart<'static> {
RadarChart::new(
vec!["Speed", "Power", "Range", "Armor", "Agility"],
vec![
RadarSeries::new("Scout", vec![9.0, 3.0, 7.0, 2.0, 8.0]).color(Color::Red),
RadarSeries::new("Tank", vec![3.0, 8.0, 4.0, 9.0, 2.0]).color(Color::Blue),
],
)
}
fn draw(c: RadarChart<'_>, w: u16, h: u16) -> Buffer {
let a = Rect::new(0, 0, w, h);
let mut b = Buffer::empty(a);
c.render(a, &mut b);
b
}
fn text(b: &Buffer) -> String {
b.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[test]
fn renders_axes_series_and_legend() {
let b = draw(chart(), 60, 22);
let t = text(&b);
for l in [
"Speed", "Power", "Range", "Armor", "Agility", "Scout", "Tank",
] {
assert!(t.contains(l), "missing {l}");
}
for col in [Color::Red, Color::Blue] {
assert!(b.content().iter().any(|c| c.fg == col && c.symbol() != " "));
}
}
#[test]
fn needs_three_axes() {
let b = draw(RadarChart::new(vec!["a", "b"], vec![]), 40, 20);
assert_eq!(text(&b).trim(), "");
}
#[test]
fn values_change_picture_and_max_scales() {
let a = draw(chart(), 60, 22);
let b = draw(chart().max(100.0), 60, 22);
assert_ne!(a, b);
}
#[test]
fn legend_toggle() {
assert!(!text(&draw(chart().legend(false), 60, 22)).contains("Scout"));
}
#[test]
fn robust() {
for (w, h) in [(0, 0), (1, 1), (6, 3), (20, 8), (80, 40)] {
for axes in [vec![], vec!["a"; 3], vec!["long axis label"; 12]] {
for series in [
vec![],
vec![RadarSeries::new("s", vec![])],
vec![RadarSeries::new(
"s",
vec![f64::NAN, -1.0, f64::INFINITY, 5.0],
)],
] {
let _ = draw(
RadarChart::new(axes.clone(), series)
.rings(99)
.max(f64::NAN),
w,
h,
);
}
}
}
}
}