Skip to main content

chart/
scale.rs

1//! Where values land along a side of the plot, and the ticks that label it.
2
3use gpui::SharedString;
4
5use crate::time;
6
7/// `domain` mapped linearly onto `range`. `range` may run backwards.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Linear {
10    pub domain: (f64, f64),
11    pub range: (f32, f32),
12}
13
14impl Linear {
15    pub fn at(&self, value: f64) -> f32 {
16        let (d0, d1) = self.domain;
17        let t = if d1 == d0 {
18            0.5
19        } else {
20            (value - d0) / (d1 - d0)
21        };
22        self.range.0 + (self.range.1 - self.range.0) * t as f32
23    }
24}
25
26/// `count` equal bands across `range`, which runs forwards. `padding` is the
27/// share of each band left empty, half on either side.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct Band {
30    pub count: usize,
31    pub range: (f32, f32),
32    pub padding: f32,
33}
34
35impl Band {
36    pub fn step(&self) -> f32 {
37        (self.range.1 - self.range.0) / self.count.max(1) as f32
38    }
39
40    /// Where band `index`'s filled part starts.
41    pub fn start(&self, index: usize) -> f32 {
42        self.range.0 + self.step() * (index as f32 + self.padding / 2.0)
43    }
44
45    /// The filled part's width.
46    pub fn width(&self) -> f32 {
47        self.step() * (1.0 - self.padding)
48    }
49
50    pub fn center(&self, index: usize) -> f32 {
51        self.range.0 + self.step() * (index as f32 + 0.5)
52    }
53
54    /// The band `position` falls in.
55    pub fn index(&self, position: f32) -> Option<usize> {
56        let index = ((position - self.range.0) / self.step()).floor();
57        (index >= 0.0 && (index as usize) < self.count).then_some(index as usize)
58    }
59}
60
61/// Tick values and their labels, and the domain they span.
62#[derive(Clone, Debug, PartialEq)]
63pub struct Ticks {
64    pub domain: (f64, f64),
65    pub values: Vec<f64>,
66    pub labels: Vec<SharedString>,
67}
68
69/// About `count` ticks at 1, 2 or 5 times a power of ten. `nice` widens the
70/// domain to the ticks either side of `lo..=hi`; otherwise the domain is
71/// `lo..=hi` and the ticks fall inside it.
72pub fn linear(lo: f64, hi: f64, count: usize, nice: bool) -> Ticks {
73    let (lo, hi) = widen(lo, hi, 1.0);
74    let (factor, power) = increment(lo, hi, count);
75    let value = |index: f64| match power < 0 {
76        true => index / (10f64.powi(-power) / factor),
77        false => index * factor * 10f64.powi(power),
78    };
79    let step = value(1.0);
80    let (first, last) = match nice {
81        true => ((lo / step).floor(), (hi / step).ceil()),
82        false => ((lo / step - 1e-9).ceil(), (hi / step + 1e-9).floor()),
83    };
84    let values: Vec<f64> = (first as i64..=last as i64)
85        .map(|index| value(index as f64))
86        .collect();
87    let magnitude = lo.abs().max(hi.abs());
88    let labels = values
89        .iter()
90        .map(|&v| number(v, power, magnitude).into())
91        .collect();
92    Ticks {
93        domain: match nice {
94            true => (value(first), value(last)),
95            false => (lo, hi),
96        },
97        values,
98        labels,
99    }
100}
101
102/// Calendar ticks inside `lo..=hi`, which is the domain as given.
103pub fn temporal(lo: f64, hi: f64, count: usize) -> Ticks {
104    let (lo, hi) = widen(lo, hi, time::DAY);
105    let (values, unit) = time::ticks(lo, hi, count);
106    let labels = values
107        .iter()
108        .map(|&ms| time::label(ms, unit).into())
109        .collect();
110    Ticks {
111        domain: (lo, hi),
112        values,
113        labels,
114    }
115}
116
117fn widen(lo: f64, hi: f64, unit: f64) -> (f64, f64) {
118    match (lo == hi, lo == 0.0) {
119        (false, _) => (lo, hi),
120        (true, true) => (0.0, unit),
121        (true, false) => (
122            lo - unit.max(lo.abs() / 10.0),
123            hi + unit.max(hi.abs() / 10.0),
124        ),
125    }
126}
127
128/// The tick step as `factor * 10^power`, `factor` 1, 2 or 5.
129fn increment(lo: f64, hi: f64, count: usize) -> (f64, i32) {
130    let raw = (hi - lo) / count.max(1) as f64;
131    let mut power = raw.log10().floor() as i32;
132    let error = raw / 10f64.powi(power);
133    let factor = if error >= 50f64.sqrt() {
134        10.0
135    } else if error >= 10f64.sqrt() {
136        5.0
137    } else if error >= 2f64.sqrt() {
138        2.0
139    } else {
140        1.0
141    };
142    if factor == 10.0 {
143        power += 1;
144        return (1.0, power);
145    }
146    (factor, power)
147}
148
149/// A tick on a step of `10^power`: as many decimals as the step needs, and
150/// k, M, G or T once the axis reaches ten thousand.
151fn number(value: f64, power: i32, magnitude: f64) -> String {
152    const UNITS: [(f64, &str, i32); 4] =
153        [(1e12, "T", 12), (1e9, "G", 9), (1e6, "M", 6), (1e3, "k", 3)];
154    if magnitude >= 1e4
155        && value != 0.0
156        && let Some(&(unit, suffix, exponent)) = UNITS.iter().find(|(unit, ..)| magnitude >= *unit)
157    {
158        let decimals = (exponent - power).max(0) as usize;
159        return format!("{}{suffix}", fixed(value / unit, decimals));
160    }
161    grouped(&fixed(value, (-power).max(0) as usize))
162}
163
164/// A value read out whole: integers grouped, others to two decimals or three
165/// significant digits, whichever shows more, trailing zeros dropped.
166pub fn value(value: f64) -> String {
167    if value.is_nan() {
168        return "–".into();
169    }
170    if value.fract() == 0.0 && value.abs() < 1e15 {
171        return grouped(&fixed(value, 0));
172    }
173    let significant = 3 - value.abs().log10().floor() as i32 - 1;
174    let decimals = significant.clamp(2, 12) as usize;
175    let text = fixed(value, decimals);
176    let text = text.trim_end_matches('0').trim_end_matches('.');
177    grouped(text)
178}
179
180fn fixed(value: f64, decimals: usize) -> String {
181    let text = format!("{value:.decimals$}");
182    match text
183        .trim_start_matches('-')
184        .chars()
185        .all(|c| c == '0' || c == '.')
186    {
187        true => text.trim_start_matches('-').to_string(),
188        false => text,
189    }
190}
191
192/// Commas between thousands in the integer part.
193fn grouped(text: &str) -> String {
194    let (sign, rest) = match text.strip_prefix('-') {
195        Some(rest) => ("-", rest),
196        None => ("", text),
197    };
198    let (whole, fraction) = rest.split_at(rest.find('.').unwrap_or(rest.len()));
199    let mut out = String::with_capacity(text.len() + whole.len() / 3);
200    out.push_str(sign);
201    for (index, digit) in whole.chars().enumerate() {
202        if index > 0 && (whole.len() - index) % 3 == 0 {
203            out.push(',');
204        }
205        out.push(digit);
206    }
207    out.push_str(fraction);
208    out
209}