Skip to main content

gpui_liveplot/
axis.rs

1//! Axis configuration, scaling, and formatting.
2//!
3//! Axes are configured at the plot level and shared across all series. This module provides:
4//! - scale types (linear),
5//! - formatting and tick generation,
6//! - layout metadata used by render backends.
7
8use std::sync::Arc;
9
10use crate::view::Range;
11
12/// Formatter for axis tick labels.
13///
14/// Use [`AxisFormatter::Custom`] to provide a locale-aware or domain-specific
15/// formatting function.
16#[derive(Clone, Default)]
17pub enum AxisFormatter {
18    /// Default numeric formatter.
19    #[default]
20    Default,
21    /// Custom formatter callback.
22    ///
23    /// The function must be thread-safe because plots can be rendered from
24    /// multiple contexts.
25    Custom(Arc<dyn Fn(f64) -> String + Send + Sync>),
26}
27
28impl AxisFormatter {
29    /// Format a value for display.
30    pub fn format(&self, value: f64) -> String {
31        match self {
32            Self::Default => format!("{value:.6}"),
33            Self::Custom(formatter) => formatter(value),
34        }
35    }
36}
37
38impl std::fmt::Debug for AxisFormatter {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Self::Default => write!(f, "AxisFormatter::Default"),
42            Self::Custom(_) => write!(f, "AxisFormatter::Custom(..)"),
43        }
44    }
45}
46
47/// Axis configuration shared across all series in a plot.
48///
49/// The axis configuration is owned by [`Plot`](crate::plot::Plot) and affects
50/// all series within the plot. Each series contributes data only; axes control
51/// scaling, ticks, formatting, and grid/border appearance.
52#[derive(Debug, Clone)]
53pub struct AxisConfig {
54    title: Option<String>,
55    units: Option<String>,
56    formatter: AxisFormatter,
57    tick_config: TickConfig,
58    show_grid: bool,
59    show_minor_grid: bool,
60    show_zero_line: bool,
61    show_border: bool,
62    label_size: f32,
63}
64
65impl AxisConfig {
66    /// Create a new axis configuration.
67    ///
68    /// Use [`AxisConfig::builder`] for a fluent configuration style.
69    pub fn new() -> Self {
70        Self {
71            title: None,
72            units: None,
73            formatter: AxisFormatter::default(),
74            tick_config: TickConfig::default(),
75            show_grid: true,
76            show_minor_grid: false,
77            show_zero_line: false,
78            show_border: true,
79            label_size: 12.0,
80        }
81    }
82
83    /// Start building an axis configuration.
84    pub fn builder() -> AxisConfigBuilder {
85        AxisConfigBuilder { axis: Self::new() }
86    }
87
88    /// Access the axis title.
89    pub fn title(&self) -> Option<&str> {
90        self.title.as_deref()
91    }
92
93    /// Access the axis units.
94    pub fn units(&self) -> Option<&str> {
95        self.units.as_deref()
96    }
97
98    /// Access the formatter.
99    pub fn formatter(&self) -> &AxisFormatter {
100        &self.formatter
101    }
102
103    /// Format a value for display using the configured formatter.
104    pub fn format_value(&self, value: f64) -> String {
105        self.formatter.format(value)
106    }
107
108    /// Access the tick configuration.
109    pub fn tick_config(&self) -> TickConfig {
110        self.tick_config
111    }
112
113    /// Check if major grid lines are enabled.
114    pub fn show_grid(&self) -> bool {
115        self.show_grid
116    }
117
118    /// Check if minor grid lines are enabled.
119    pub fn show_minor_grid(&self) -> bool {
120        self.show_minor_grid
121    }
122
123    /// Check if the zero line is enabled.
124    pub fn show_zero_line(&self) -> bool {
125        self.show_zero_line
126    }
127
128    /// Check if the axis border is enabled.
129    pub fn show_border(&self) -> bool {
130        self.show_border
131    }
132
133    /// Access the tick label font size.
134    pub fn label_size(&self) -> f32 {
135        self.label_size
136    }
137}
138
139/// Builder for [`AxisConfig`].
140#[derive(Debug, Clone)]
141pub struct AxisConfigBuilder {
142    axis: AxisConfig,
143}
144
145impl AxisConfigBuilder {
146    /// Set the axis title.
147    pub fn title(mut self, title: impl Into<String>) -> Self {
148        self.axis.title = Some(title.into());
149        self
150    }
151
152    /// Set the axis units.
153    pub fn units(mut self, units: impl Into<String>) -> Self {
154        self.axis.units = Some(units.into());
155        self
156    }
157
158    /// Set the axis formatter.
159    ///
160    /// Custom formatters override the default numeric formatting.
161    pub fn formatter(mut self, formatter: AxisFormatter) -> Self {
162        self.axis.formatter = formatter;
163        self
164    }
165
166    /// Set the tick configuration.
167    ///
168    /// The `pixel_spacing` hint determines how dense major ticks are.
169    pub fn tick_config(mut self, config: TickConfig) -> Self {
170        self.axis.tick_config = config;
171        self
172    }
173
174    /// Enable or disable major grid lines.
175    pub fn grid(mut self, enabled: bool) -> Self {
176        self.axis.show_grid = enabled;
177        self
178    }
179
180    /// Enable or disable minor grid lines.
181    pub fn minor_grid(mut self, enabled: bool) -> Self {
182        self.axis.show_minor_grid = enabled;
183        self
184    }
185
186    /// Enable or disable the zero line.
187    pub fn zero_line(mut self, enabled: bool) -> Self {
188        self.axis.show_zero_line = enabled;
189        self
190    }
191
192    /// Enable or disable the axis border.
193    pub fn border(mut self, enabled: bool) -> Self {
194        self.axis.show_border = enabled;
195        self
196    }
197
198    /// Set the tick label font size.
199    pub fn label_size(mut self, size: f32) -> Self {
200        self.axis.label_size = size;
201        self
202    }
203
204    /// Build the axis configuration.
205    pub fn build(self) -> AxisConfig {
206        self.axis
207    }
208}
209
210impl Default for AxisConfig {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// Tick generation configuration.
217///
218/// The tick generator uses `pixel_spacing` as a target distance between
219/// major ticks and inserts `minor_count` minor ticks in between.
220#[derive(Debug, Clone, Copy, PartialEq)]
221pub struct TickConfig {
222    /// Target pixel spacing between major ticks.
223    pub pixel_spacing: f32,
224    /// Number of minor ticks between major ticks.
225    pub minor_count: usize,
226}
227
228impl Default for TickConfig {
229    fn default() -> Self {
230        Self {
231            pixel_spacing: 80.0,
232            minor_count: 4,
233        }
234    }
235}
236
237/// Axis tick metadata.
238#[derive(Debug, Clone, PartialEq)]
239pub(crate) struct Tick {
240    /// Tick value in data space.
241    pub(crate) value: f64,
242    /// Tick label.
243    pub(crate) label: String,
244    /// Whether the tick is a major tick.
245    pub(crate) is_major: bool,
246}
247
248/// Layout information for axis labels and ticks.
249#[derive(Debug, Clone)]
250pub(crate) struct AxisLayout {
251    /// Ticks to render.
252    pub(crate) ticks: Vec<Tick>,
253    /// Maximum tick label size (width, height).
254    pub(crate) max_label_size: (f32, f32),
255}
256
257impl Default for AxisLayout {
258    fn default() -> Self {
259        Self {
260            ticks: Vec::new(),
261            max_label_size: (0.0, 0.0),
262        }
263    }
264}
265
266#[derive(Debug, Clone, PartialEq)]
267struct AxisLayoutKey {
268    range: Range,
269    pixels: u32,
270    tick_config: TickConfig,
271}
272
273/// Cached layout for axis ticks and labels.
274#[derive(Debug, Default, Clone)]
275pub(crate) struct AxisLayoutCache {
276    key: Option<AxisLayoutKey>,
277    layout: AxisLayout,
278}
279
280impl AxisLayoutCache {
281    /// Update the cache if inputs have changed.
282    pub(crate) fn update(
283        &mut self,
284        axis: &AxisConfig,
285        range: Range,
286        pixels: u32,
287        measurer: &impl TextMeasurer,
288    ) -> &AxisLayout {
289        let key = AxisLayoutKey {
290            range,
291            pixels,
292            tick_config: axis.tick_config(),
293        };
294        if self.key.as_ref() == Some(&key) {
295            return &self.layout;
296        }
297
298        let ticks = generate_ticks(axis, range, pixels as f32);
299        let mut max_size = (0.0_f32, 0.0_f32);
300        for tick in &ticks {
301            if tick.label.is_empty() {
302                continue;
303            }
304            let (w, h) = measurer.measure(&tick.label, axis.label_size());
305            max_size.0 = max_size.0.max(w);
306            max_size.1 = max_size.1.max(h);
307        }
308
309        self.layout = AxisLayout {
310            ticks,
311            max_label_size: max_size,
312        };
313        self.key = Some(key);
314        &self.layout
315    }
316}
317
318/// Text measurement interface for layout.
319pub(crate) trait TextMeasurer {
320    /// Measure a text label at the given size.
321    fn measure(&self, text: &str, size: f32) -> (f32, f32);
322}
323
324/// Generate axis ticks for a range and pixel length.
325fn generate_ticks(axis: &AxisConfig, range: Range, pixel_length: f32) -> Vec<Tick> {
326    if !range.is_valid() || pixel_length <= 0.0 {
327        return Vec::new();
328    }
329    generate_linear_ticks(axis, range, pixel_length)
330}
331
332fn generate_linear_ticks(axis: &AxisConfig, range: Range, pixel_length: f32) -> Vec<Tick> {
333    let target = (pixel_length / axis.tick_config().pixel_spacing).max(2.0);
334    let raw_step = range.span() / target as f64;
335    let step = nice_step(raw_step);
336    if !step.is_finite() || step <= 0.0 {
337        return Vec::new();
338    }
339
340    let minor_count = axis.tick_config().minor_count;
341    let minor_step = step / (minor_count as f64 + 1.0);
342
343    let mut ticks = Vec::new();
344    let mut value = (range.min / step).floor() * step;
345    if value == -0.0 {
346        value = 0.0;
347    }
348    let max_value = range.max + step * 0.5;
349
350    while value <= max_value {
351        if value >= range.min - step * 0.5 {
352            ticks.push(Tick {
353                value,
354                label: axis.format_value(value),
355                is_major: true,
356            });
357        }
358        for i in 1..=minor_count {
359            let minor = value + minor_step * i as f64;
360            if minor >= range.min && minor <= range.max {
361                ticks.push(Tick {
362                    value: minor,
363                    label: String::new(),
364                    is_major: false,
365                });
366            }
367        }
368        value += step;
369    }
370
371    ticks
372}
373
374fn nice_step(step: f64) -> f64 {
375    if step <= 0.0 {
376        return 0.0;
377    }
378    let exp = step.log10().floor();
379    let base = 10_f64.powf(exp);
380    let fraction = step / base;
381    let nice = if fraction <= 1.0 {
382        1.0
383    } else if fraction <= 2.0 {
384        2.0
385    } else if fraction <= 5.0 {
386        5.0
387    } else {
388        10.0
389    };
390    nice * base
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn linear_ticks_generate_major() {
399        let axis = AxisConfig::new();
400        let ticks = generate_ticks(&axis, Range::new(0.0, 10.0), 400.0);
401        assert!(ticks.iter().any(|tick| tick.is_major));
402    }
403}