Skip to main content

eui_core/
chart.rs

1//! Round 1 (docs/round-1-contract.md §3, Chart): the numbers every painter
2//! must agree on — the value extent per kind and the "nice" axis ticks
3//! (Heckbert's nice numbers), the series colour order and the donut hole.
4//! Pure; geometry (bars, arcs) stays each painter's. Mirrors `src/chart.ts`.
5
6use serde::{Deserialize, Serialize};
7
8/// One series as the painter reads it off the Chart props.
9#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
10pub struct ChartSeries {
11    #[serde(default)]
12    pub name: String,
13    #[serde(default)]
14    pub values: Vec<f64>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub tone: Option<String>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct Extent {
21    pub min: f64,
22    pub max: f64,
23}
24
25/// The value range the y axis spans: `min` defaults to 0 (lower when a value
26/// is negative), `max` to the largest value — the largest category SUM for
27/// `stackedBar`; `sparkline` spans its own min..max.
28pub fn chart_extent(kind: &str, series: &[ChartSeries], min: Option<f64>, max: Option<f64>) -> Extent {
29    let values: Vec<f64> = series.iter().flat_map(|s| s.values.iter().copied().filter(|v| v.is_finite())).collect();
30    let mut lo = if kind == "sparkline" { values.iter().copied().fold(f64::INFINITY, f64::min) } else { values.iter().copied().fold(0.0, f64::min) };
31    let mut hi = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
32    if kind == "stackedBar" {
33        let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
34        hi = f64::NEG_INFINITY;
35        for i in 0..n {
36            let sum: f64 = series.iter().map(|s| s.values.get(i).copied().filter(|v| v.is_finite()).map(|v| v.max(0.0)).unwrap_or(0.0)).sum();
37            hi = hi.max(sum);
38        }
39    }
40    if !lo.is_finite() {
41        lo = 0.0;
42    }
43    if !hi.is_finite() {
44        hi = 0.0;
45    }
46    let lo = min.unwrap_or(lo);
47    let mut hi = max.unwrap_or(hi);
48    if hi <= lo {
49        hi = lo + 1.0;
50    }
51    Extent { min: lo, max: hi }
52}
53
54fn nice_num(x: f64, round: bool) -> f64 {
55    let exp = x.log10().floor();
56    let f = x / 10f64.powf(exp);
57    let nf = if round {
58        if f < 1.5 {
59            1.0
60        } else if f < 3.0 {
61            2.0
62        } else if f < 7.0 {
63            5.0
64        } else {
65            10.0
66        }
67    } else if f <= 1.0 {
68        1.0
69    } else if f <= 2.0 {
70        2.0
71    } else if f <= 5.0 {
72        5.0
73    } else {
74        10.0
75    };
76    nf * 10f64.powf(exp)
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Ticks {
81    pub min: f64,
82    pub max: f64,
83    pub step: f64,
84    pub ticks: Vec<f64>,
85}
86
87/// JS `Number(v.toFixed(decimals))`.
88fn fix(v: f64, decimals: i32) -> f64 {
89    let s = format!("{:.*}", decimals.max(0) as usize, v);
90    s.parse().unwrap_or(v)
91}
92
93/// Axis ticks over [min, max]: a nice step (1, 2 or 5 × 10^n) giving about
94/// `target` ticks, the range widened to whole steps, every tick rounded to
95/// the step's decimals.
96pub fn nice_ticks(min: f64, max: f64, target: usize) -> Ticks {
97    let lo = min.min(max);
98    let hi = if max == min { min + 1.0 } else { min.max(max) };
99    let range = nice_num(hi - lo, false);
100    let step = nice_num(range / (target.max(2) - 1) as f64, true);
101    let decimals = (-step.log10().floor()).max(0.0) as i32;
102    let nice_min = fix((lo / step).floor() * step, decimals);
103    let nice_max = fix((hi / step).ceil() * step, decimals);
104    let mut ticks = Vec::new();
105    let mut i = 0;
106    let mut v = nice_min;
107    while v <= nice_max + step / 2.0 && i < 100 {
108        ticks.push(fix(v, decimals));
109        i += 1;
110        v = nice_min + i as f64 * step;
111    }
112    Ticks { min: nice_min, max: nice_max, step, ticks }
113}
114
115/// The colour token of series (or slice) `index`: its tone's colour when
116/// set, else `$color.chart1..8` in order, wrapping.
117pub fn series_color(index: usize, tone: Option<&str>) -> String {
118    match tone {
119        Some("danger") => "$color.destructive".into(),
120        Some("neutral") => "$color.mutedForeground".into(),
121        Some(t) => format!("$color.{t}"),
122        None => format!("$color.chart{}", index % 8 + 1),
123    }
124}
125
126/// The donut's hole as a share of its radius (pie = 0).
127pub const DONUT_HOLE: f64 = 0.6;
128/// Rows past which Table and List window their children (`layout.json`).
129pub const WINDOW_THRESHOLD: usize = crate::list::WINDOW_THRESHOLD;
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn s(values: &[f64]) -> ChartSeries {
136        ChartSeries { name: "s".into(), values: values.to_vec(), tone: None }
137    }
138
139    #[test]
140    fn extents_per_kind() {
141        assert_eq!(chart_extent("bar", &[s(&[3.0, 8.0])], None, None), Extent { min: 0.0, max: 8.0 });
142        assert_eq!(chart_extent("bar", &[s(&[-2.0, 8.0])], None, None), Extent { min: -2.0, max: 8.0 });
143        assert_eq!(chart_extent("stackedBar", &[s(&[3.0, 8.0]), s(&[4.0, 1.0])], None, None), Extent { min: 0.0, max: 9.0 });
144        assert_eq!(chart_extent("sparkline", &[s(&[3.0, 8.0])], None, None), Extent { min: 3.0, max: 8.0 });
145        assert_eq!(chart_extent("line", &[], None, None), Extent { min: 0.0, max: 1.0 });
146        assert_eq!(chart_extent("line", &[s(&[3.0])], Some(10.0), None), Extent { min: 10.0, max: 11.0 });
147    }
148
149    #[test]
150    fn nice_ticks_are_1_2_5_steps() {
151        let t = nice_ticks(0.0, 8.0, 5);
152        assert_eq!((t.min, t.max, t.step), (0.0, 8.0, 2.0));
153        assert_eq!(t.ticks, vec![0.0, 2.0, 4.0, 6.0, 8.0]);
154        let t = nice_ticks(0.0, 1.0, 5);
155        assert_eq!(t.step, 0.2);
156        assert_eq!(t.ticks, vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]);
157        let t = nice_ticks(-3.0, 97.0, 5);
158        assert_eq!(t.ticks.first(), Some(&-20.0));
159        assert_eq!(series_color(9, None), "$color.chart2");
160        assert_eq!(series_color(0, Some("danger")), "$color.destructive");
161    }
162}