Skip to main content

malevich/plot/
mapping.rs

1//! `Mapping`: the resolved geometry of one render, as a queryable value.
2
3use super::layout::{Layout, Map};
4use crate::scale::{Scale, Unit};
5
6/// How a rendered plot maps cells onto data: the plot rectangle and the
7/// resolved scales, computed by the same layout pass rendering uses.
8///
9/// Obtained purely from [`Plot::mapping`](crate::Plot::mapping) — or cached by
10/// the ratatui `PlotState` after a stateful render. A mapping answers the
11/// questions interactive hosts ask: which data coordinates live under a cell
12/// ([`Mapping::data_at`]), which cell shows a data point ([`Mapping::cell_at`]),
13/// what window the axes resolved to ([`Mapping::x_domain`]), and how to write a
14/// value the way the axis itself would ([`Mapping::format_x`]).
15///
16/// Coordinates follow the conventions marks already use: band axes answer in
17/// band-index space (0 is the first band; on y, the top band), time axes in
18/// unix seconds, log axes in data values. Queries outside the plot rectangle —
19/// or on a layout so small the plot shed to nothing — return `None`.
20///
21/// A mapping is a plain value (`Clone + Send + Sync`) describing one
22/// `(plot, frame)` pair; render again after either changes and query the new
23/// mapping. It is derived state, deliberately not serializable.
24#[derive(Debug, Clone)]
25pub struct Mapping {
26    /// Plot rectangle: leftmost cell column, topmost cell row, then size.
27    left: usize,
28    top: usize,
29    columns: usize,
30    rows: usize,
31    /// Subpixels per cell for the charset the layout was computed at.
32    px: usize,
33    py: usize,
34    x: Map,
35    y: Map,
36    x_domain: (f64, f64),
37    y_domain: (f64, f64),
38    x_kind: AxisKind,
39    y_kind: AxisKind,
40    x_categories: Option<Vec<String>>,
41    y_categories: Option<Vec<String>>,
42    x_unit: Unit,
43    y_unit: Unit,
44}
45
46/// The plot panel's cell rectangle within its frame: where the data draws,
47/// chrome excluded. What [`Mapping::plot_area`] answers with — a named shape
48/// instead of an anonymous tuple, because four `usize`s in a row invite
49/// transposition bugs.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct Panel {
52    /// Leftmost cell column of the panel, in frame coordinates.
53    pub column: usize,
54    /// Topmost cell row of the panel, in frame coordinates.
55    pub row: usize,
56    /// Width in cells.
57    pub width: usize,
58    /// Height in cells.
59    pub height: usize,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(crate) enum AxisKind {
64    Linear,
65    Log,
66    Time,
67    Bands,
68}
69
70impl Mapping {
71    pub(crate) fn new(layout: &Layout<'_>, x_spec: &Scale, y_spec: &Scale) -> Mapping {
72        let x_kind = if layout.band.is_some() {
73            AxisKind::Bands
74        } else {
75            AxisKind::of(x_spec)
76        };
77        let y_kind = if layout.y_band.is_some() {
78            AxisKind::Bands
79        } else {
80            AxisKind::of(y_spec)
81        };
82        let y_categories = layout.y_categories.map(<[String]>::to_vec);
83        Mapping {
84            left: layout.gutter,
85            top: layout.plot_top,
86            columns: layout.plot_cols,
87            rows: layout.plot_rows,
88            px: layout.px.max(1),
89            py: layout.py.max(1),
90            x: layout.x_scale,
91            y: layout.y_scale,
92            x_domain: layout.x_domain,
93            y_domain: layout.y_domain,
94            x_kind,
95            y_kind,
96            x_categories: layout.categories.map(<[String]>::to_vec),
97            y_categories,
98            x_unit: layout.x_unit.clone(),
99            y_unit: layout.y_unit.clone(),
100        }
101    }
102
103    /// A mapping with an empty plot rectangle; every positional query is `None`.
104    pub(crate) fn empty() -> Mapping {
105        Mapping {
106            left: 0,
107            top: 0,
108            columns: 0,
109            rows: 0,
110            px: 1,
111            py: 1,
112            x: Map::build((0.0, 1.0), (0.0, 1.0), false),
113            y: Map::build((0.0, 1.0), (1.0, 0.0), false),
114            x_domain: (0.0, 1.0),
115            y_domain: (0.0, 1.0),
116            x_kind: AxisKind::Linear,
117            y_kind: AxisKind::Linear,
118            x_categories: None,
119            y_categories: None,
120            x_unit: Unit::Plain,
121            y_unit: Unit::Plain,
122        }
123    }
124
125    /// The plot panel in frame cells — the data rectangle only, chrome
126    /// excluded — or `None` when the frame was too small to draw one.
127    pub fn plot_area(&self) -> Option<Panel> {
128        (self.columns > 0 && self.rows > 0).then_some(Panel {
129            column: self.left,
130            row: self.top,
131            width: self.columns,
132            height: self.rows,
133        })
134    }
135
136    /// The data coordinates at the center of the frame cell `(column, row)`,
137    /// or `None` outside the plot rectangle.
138    ///
139    /// One cell spans an interval of data, not a point — [`Mapping::x_span_at`]
140    /// discloses how much. Band axes answer in fractional band-index space;
141    /// snap with `round()` and clamp to the band count.
142    pub fn data_at(&self, column: usize, row: usize) -> Option<(f64, f64)> {
143        if self.columns == 0 || self.rows == 0 {
144            return None;
145        }
146        let inside = (self.left..self.left + self.columns).contains(&column)
147            && (self.top..self.top + self.rows).contains(&row);
148        if !inside {
149            return None;
150        }
151        let sub_x = ((column - self.left) * self.px) as f64 + (self.px as f64 - 1.0) / 2.0;
152        let sub_y = ((row - self.top) * self.py) as f64 + (self.py as f64 - 1.0) / 2.0;
153        Some((self.x.unmap(sub_x), self.y.unmap(sub_y)))
154    }
155
156    /// The frame column where the data value `x` draws — the x-only half of
157    /// [`Mapping::cell_at`] — or `None` when it falls outside the plot
158    /// rectangle (or is not finite). This is what lets a passive pane mirror
159    /// another pane's cursor exactly, whatever their gutters: share the data
160    /// x, let each mapping place it. Band axes answer in band-index space.
161    pub fn column_at(&self, x: f64) -> Option<usize> {
162        if self.columns == 0 || self.rows == 0 {
163            return None;
164        }
165        let sub_x = self.x.map(x).round();
166        if !sub_x.is_finite() || sub_x < 0.0 {
167            return None;
168        }
169        let column = self.left + sub_x as usize / self.px;
170        (column < self.left + self.columns).then_some(column)
171    }
172
173    /// The frame cell `(column, row)` where the data point `(x, y)` draws, or
174    /// `None` when it falls outside the plot rectangle (or is not finite).
175    pub fn cell_at(&self, x: f64, y: f64) -> Option<(usize, usize)> {
176        if self.columns == 0 || self.rows == 0 {
177            return None;
178        }
179        let sub_x = self.x.map(x).round();
180        let sub_y = self.y.map(y).round();
181        if !(sub_x.is_finite() && sub_y.is_finite()) || sub_x < 0.0 || sub_y < 0.0 {
182            return None;
183        }
184        let column = self.left + sub_x as usize / self.px;
185        let row = self.top + sub_y as usize / self.py;
186        (column < self.left + self.columns && row < self.top + self.rows).then_some((column, row))
187    }
188
189    /// The data interval one plot column covers — the x resolution a cell-level
190    /// cursor honestly has — or `None` when `column` is outside the plot.
191    pub fn x_span_at(&self, column: usize) -> Option<(f64, f64)> {
192        if self.columns == 0 || !(self.left..self.left + self.columns).contains(&column) {
193            return None;
194        }
195        let start = ((column - self.left) * self.px) as f64 - 0.5;
196        let (a, b) = (self.x.unmap(start), self.x.unmap(start + self.px as f64));
197        Some((a.min(b), a.max(b)))
198    }
199
200    /// The data interval one plot row covers — [`Mapping::x_span_at`]'s
201    /// vertical counterpart.
202    pub fn y_span_at(&self, row: usize) -> Option<(f64, f64)> {
203        if self.rows == 0 || !(self.top..self.top + self.rows).contains(&row) {
204            return None;
205        }
206        let start = ((row - self.top) * self.py) as f64 - 0.5;
207        let (a, b) = (self.y.unmap(start), self.y.unmap(start + self.py as f64));
208        Some((a.min(b), a.max(b)))
209    }
210
211    /// The resolved x window: the manual domain if one was set, otherwise the
212    /// automatic domain grown to its ticks. A bands axis answers in band-index
213    /// space: `(0, count - 1)`.
214    pub fn x_domain(&self) -> (f64, f64) {
215        self.x_domain
216    }
217
218    /// The resolved y window; see [`Mapping::x_domain`].
219    pub fn y_domain(&self) -> (f64, f64) {
220        self.y_domain
221    }
222
223    /// The x axis's categories, in band order, when it is categorical —
224    /// `None` on a continuous axis. The labels themselves, not a bare count:
225    /// a host hit-testing a bands axis wants to name what it hit.
226    pub fn x_categories(&self) -> Option<&[String]> {
227        if matches!(self.x_kind, AxisKind::Bands) {
228            self.x_categories.as_deref()
229        } else {
230            None
231        }
232    }
233
234    /// The y axis's categories, in band order (band 0 is the top row), when
235    /// it is categorical.
236    pub fn y_categories(&self) -> Option<&[String]> {
237        if matches!(self.y_kind, AxisKind::Bands) {
238            self.y_categories.as_deref()
239        } else {
240            None
241        }
242    }
243
244    /// Formats an x value the way the x axis would: exact decimals at the
245    /// resolution one cell actually has (never `0.30000000000000004`, never
246    /// false precision), in the axis's unit, calendar instants on a time
247    /// axis, the category label on a bands axis.
248    pub fn format_x(&self, value: f64) -> String {
249        format_value(
250            value,
251            self.x_kind,
252            self.x_domain,
253            self.columns,
254            self.x_categories.as_deref(),
255            &self.x_unit,
256        )
257    }
258
259    /// Formats a y value the way the y axis would; see [`Mapping::format_x`].
260    pub fn format_y(&self, value: f64) -> String {
261        format_value(
262            value,
263            self.y_kind,
264            self.y_domain,
265            self.rows,
266            self.y_categories.as_deref(),
267            &self.y_unit,
268        )
269    }
270
271    /// A [`Viewport`](crate::Viewport) fixed to this mapping's resolved
272    /// domains — "the view I am looking at", the natural seed for zoom and pan.
273    /// Bands axes stay unfixed: a categorical axis has no continuous window.
274    pub fn viewport(&self) -> crate::plot::Viewport {
275        crate::plot::Viewport::seeded(
276            (self.x_kind != AxisKind::Bands).then_some(self.x_domain),
277            (self.y_kind != AxisKind::Bands).then_some(self.y_domain),
278            self.x_kind == AxisKind::Log,
279            self.y_kind == AxisKind::Log,
280        )
281    }
282}
283
284impl AxisKind {
285    fn of(spec: &Scale) -> AxisKind {
286        match spec {
287            Scale::Log => AxisKind::Log,
288            Scale::Time => AxisKind::Time,
289            _ => AxisKind::Linear,
290        }
291    }
292}
293
294/// One value, formatted at the axis's honest resolution: the domain span over
295/// the cell count decides how many decimals a readout can truthfully carry.
296fn format_value(
297    value: f64,
298    kind: AxisKind,
299    domain: (f64, f64),
300    cells: usize,
301    categories: Option<&[String]>,
302    unit: &Unit,
303) -> String {
304    if !value.is_finite() {
305        return value.to_string();
306    }
307    match kind {
308        AxisKind::Bands => {
309            let index = value.round();
310            match categories {
311                Some(categories) if !categories.is_empty() => {
312                    let index = (index.max(0.0) as usize).min(categories.len() - 1);
313                    categories[index].clone()
314                }
315                _ => index.to_string(),
316            }
317        }
318        AxisKind::Time => {
319            crate::scale::time::readout(value, (domain.1 - domain.0) / cells.max(1) as f64)
320        }
321        AxisKind::Linear => {
322            let step = (domain.1 - domain.0) / cells.max(1) as f64;
323            match unit {
324                Unit::Plain => decimal_at(value, step),
325                Unit::Suffix(suffix) => format!("{}{suffix}", decimal_at(value, step)),
326                Unit::Si(name) => {
327                    // The prefix the value's own magnitude asks for, the
328                    // resolution scaled along with it.
329                    let magnitude = if value == 0.0 {
330                        0
331                    } else {
332                        value.abs().log10().floor() as i32
333                    };
334                    match crate::scale::format::si_prefix(magnitude) {
335                        Some((shift, prefix)) => {
336                            let factor = 10f64.powi(shift);
337                            format!(
338                                "{} {prefix}{name}",
339                                decimal_at(value / factor, step / factor)
340                            )
341                        }
342                        None => format!("{} {name}", decimal_at(value, step)),
343                    }
344                }
345                Unit::Bytes => {
346                    let (power, factor) = crate::scale::unit::binary_prefix(value.abs());
347                    format!(
348                        "{} {}",
349                        decimal_at(value / factor, step / factor),
350                        crate::scale::unit::BINARY_UNITS[power]
351                    )
352                }
353            }
354        }
355        AxisKind::Log => {
356            if value <= 0.0 || domain.0 <= 0.0 || domain.1 <= 0.0 {
357                return value.to_string();
358            }
359            let per_cell = (domain.1.log10() - domain.0.log10()) / cells.max(1) as f64;
360            decimal_at(value, value * std::f64::consts::LN_10 * per_cell)
361        }
362    }
363}
364
365/// Exact-decimal formatting of `value` rounded to the resolution `step`: the
366/// fraction digits are just enough to distinguish neighboring cells.
367fn decimal_at(value: f64, step: f64) -> String {
368    let decimals = if step.is_finite() && step > 0.0 {
369        (-step.log10()).ceil().clamp(0.0, 12.0) as i32
370    } else {
371        3
372    };
373    let scaled = value * 10f64.powi(decimals);
374    if scaled.abs() >= 1e15 {
375        // Beyond exact-integer range the decimal would lie: the set formatter
376        // writes the value at its budget instead, prefix or exponent form.
377        return crate::scale::NumberFormat::for_values(&[value]).format(value);
378    }
379    crate::scale::format::decimal(scaled.round() as i128, -decimals)
380}
381
382#[cfg(test)]
383#[path = "tests/mapping_tests.rs"]
384mod tests;