Skip to main content

flowsurface_data/chart/ticks/
y.rs

1use super::MAX_GRID_LINES;
2use crate::util::abbr_large_numbers;
3use exchange::unit::{MinTicksize, Price, PriceStep};
4
5/// The family of Y-axis scales.
6///
7/// A scale owns both how tick *values* are chosen and how values map to
8/// normalized positions.
9#[derive(Debug, Clone, Copy)]
10pub enum YAxisScale {
11    /// Row-aligned price scale: grid steps are snapped to multiples of
12    /// `row_step` so every label lands on a real price row. Values are exact
13    /// atomic units; positions are linear in price.
14    Price { row_step: PriceStep },
15    /// Plain continuous linear scale (indicator values, etc.). Steps are
16    /// "nice"; positions are linear.
17    Linear,
18    /// Linear scale with tick values aligned to a positive step.
19    Quantized { step: f64 },
20    /// Percentage scale (comparison chart). Shares the linear grid with
21    /// [`YAxisScale::Linear`] for now, kept distinct so percent-specific step or
22    /// formatting policy has a home.
23    Percent,
24}
25
26/// A concrete grid over a visible range, unified across scale kinds.
27///
28/// Two concrete grids exist: a row-aligned price grid (exact atomic units)
29/// and a plain float grid (continuous values). [`Grid`] exposes the interface
30/// they share ([`Grid::ticks`]) and is what [`YAxisScale`] hands to
31/// [`YTicks::from_grid`] to produce tick lists.
32#[derive(Debug, Clone)]
33enum Grid {
34    /// Row-aligned price grid.
35    Row(RowGrid),
36    /// Plain float grid.
37    Float(FloatGrid),
38}
39
40impl Grid {
41    /// Every grid line as `(value, normalized position)`, ascending by value
42    /// (`0.0` = bottom of the range, `1.0` = top).
43    fn ticks(&self) -> Vec<(TickValue, f64)> {
44        match self {
45            Grid::Row(g) => {
46                let mut out: Vec<_> = g
47                    .values()
48                    .map(|units| (TickValue::PriceUnits(units), g.ratio_of(units)))
49                    .collect();
50                // `RowGrid` iterates top-down; standardize on ascending.
51                out.reverse();
52                out
53            }
54            Grid::Float(g) => g
55                .ticks
56                .iter()
57                .map(|(v, r)| (TickValue::Float(*v), *r))
58                .collect(),
59        }
60    }
61}
62
63/// A row-aligned tick grid over a price range.
64#[derive(Debug, Clone)]
65struct RowGrid {
66    /// Grid step in atomic units, always a positive multiple of `row_step`.
67    step: i64,
68    /// Bottom of the range in atomic units.
69    low: i64,
70    /// Top of the range in atomic units.
71    high: i64,
72    /// `high - low` in atomic units, always positive. Saturates to
73    /// `i64::MAX` when the true range exceeds `i64` (only reachable with
74    /// extreme, saturating prices); exact for every real range.
75    range: i64,
76}
77
78impl RowGrid {
79    fn new(lowest: f64, highest: f64, row_step: PriceStep, labels_can_fit: i32) -> Option<Self> {
80        let row = row_step.units.max(1);
81        let low = Price::from_f64(lowest).units;
82        let high = Price::from_f64(highest).units;
83
84        if high <= low {
85            return None;
86        }
87
88        let range_rows = (high as f64 - low as f64) / row as f64;
89        let budget = labels_can_fit.max(1) as f64;
90        let span_rows = tick_span_min(0.0, range_rows, range_rows / budget, 1.0);
91        let step = (span_rows.ceil() as i64).max(1).saturating_mul(row);
92
93        Self::from_units(low, high, step)
94    }
95
96    /// A grid whose step is exactly `row_step`. Used as a fallback when no
97    /// coarser "nice" grid fits the range (e.g. the visible range is narrower
98    /// than one row step). Returns `None` when the range is empty.
99    fn from_row(lowest: f64, highest: f64, row_step: PriceStep) -> Option<Self> {
100        let row = row_step.units.max(1);
101        let low = Price::from_f64(lowest).units;
102        let high = Price::from_f64(highest).units;
103        if high <= low {
104            return None;
105        }
106        Self::from_units(low, high, row)
107    }
108
109    /// Build a grid from raw bounds and an `i64` step. Returns `None` when no
110    /// grid line can fit the range.
111    fn from_units(low: i64, high: i64, step: i64) -> Option<Self> {
112        let range = high.saturating_sub(low);
113
114        // No grid can fit a range narrower than a single step.
115        if step > range {
116            return None;
117        }
118
119        Some(Self {
120            step,
121            low,
122            high,
123            range,
124        })
125    }
126
127    /// The topmost grid value: the largest multiple of `step` not above
128    /// `high`. `high - (high % step)` cannot overflow for `high >= 0` (all
129    /// real prices); the saturating form keeps pathological inputs from
130    /// panicking.
131    fn top(&self) -> i64 {
132        self.high.saturating_sub(self.high.rem_euclid(self.step))
133    }
134
135    /// Grid values from the top down, in atomic units. The topmost value is
136    /// the largest multiple of `step` at or below `high`; iteration stops
137    /// strictly above `low`.
138    ///
139    /// Always terminates: values strictly decrease by `step >= 1`, stop at
140    /// `low`, and the total count is capped at [`MAX_GRID_LINES`].
141    fn values(&self) -> impl Iterator<Item = i64> + '_ {
142        std::iter::successors(Some(self.top()), move |&v| {
143            v.checked_sub(self.step).filter(|&n| n > self.low)
144        })
145        .take(MAX_GRID_LINES)
146    }
147
148    /// Normalized position of a grid value within `[low, high]`, where `0.0`
149    /// is the bottom of the range and `1.0` the top. Exact for every range
150    /// that fits in `i64`; the saturating fallback only kicks in past it.
151    fn ratio_of(&self, units: i64) -> f64 {
152        units.saturating_sub(self.low) as f64 / self.range as f64
153    }
154}
155
156/// A plain f64 grid over a range with no row alignment (indicator axes,
157/// non-price scales such as percent change).
158#[derive(Debug, Clone)]
159pub struct FloatGrid {
160    /// The selected grid step.
161    step: f64,
162    /// `(value, ratio)` pairs, ascending by value.
163    ticks: Vec<(f64, f64)>,
164}
165
166impl FloatGrid {
167    /// Compute a linear grid; a positive `min_step` aligns tick values to its multiples.
168    fn new(lowest: f64, highest: f64, labels_can_fit: i32, min_step: f64) -> Self {
169        let span = highest - lowest;
170        if !span.is_finite() || span <= 0.0 || !min_step.is_finite() || min_step < 0.0 {
171            return FloatGrid {
172                step: 0.0,
173                ticks: Vec::new(),
174            };
175        }
176
177        let budget = labels_can_fit.max(1) as f64;
178        let step = tick_span_min(0.0, span, span / budget, 0.0);
179        let step = if min_step > 0.0 {
180            (step / min_step).ceil().max(1.0) * min_step
181        } else {
182            step
183        };
184
185        if !step.is_finite() || step <= 0.0 || step > span {
186            return FloatGrid {
187                step,
188                ticks: Vec::new(),
189            };
190        }
191
192        let mut value = (highest / step).floor() * step;
193
194        let mut ticks = Vec::with_capacity((budget + 2.0) as usize);
195        let mut safety_counter = 0;
196        while value > lowest && safety_counter < MAX_GRID_LINES {
197            let ratio = (value - lowest) / span;
198            ticks.push((value, ratio));
199            value -= step;
200            safety_counter += 1;
201        }
202
203        // The loop walks top-down; store ascending to match the documented
204        // `ticks` order (bottom-first).
205        ticks.reverse();
206
207        FloatGrid { step, ticks }
208    }
209}
210
211/// A tick value in the scale's natural representation.
212#[derive(Debug, Clone, Copy, PartialEq)]
213pub enum TickValue {
214    /// Exact atomic price units (for [`YAxisScale::Price`]).
215    PriceUnits(i64),
216    /// Float value for linear, quantized, and percentage scales.
217    Float(f64),
218}
219
220/// A single Y-axis tick: value plus normalized position.
221#[derive(Debug, Clone, Copy)]
222pub struct YTick {
223    pub value: TickValue,
224    /// Normalized position within the visible range: `0.0` = bottom, `1.0` = top.
225    pub ratio: f64,
226}
227
228/// The result of [`YAxisScale::ticks`].
229#[derive(Debug, Clone, Default)]
230pub struct YTicks {
231    /// The chosen grid step for float scales (`Linear`/`Percent`); `None` for
232    /// [`YAxisScale::Price`].
233    pub step: Option<f64>,
234    /// Ticks ascending by value (bottom-first).
235    pub ticks: Vec<YTick>,
236}
237
238impl YTicks {
239    /// Convert any [`Grid`] into ascending ticks.
240    fn from_grid(grid: Grid) -> YTicks {
241        let ticks = grid
242            .ticks()
243            .into_iter()
244            .map(|(value, ratio)| YTick { value, ratio })
245            .collect();
246        let step = match grid {
247            Grid::Float(g) => Some(g.step),
248            Grid::Row(_) => None,
249        };
250        YTicks { step, ticks }
251    }
252}
253
254impl YAxisScale {
255    /// Generate the ticks for a visible range. Returns an empty list when the
256    /// range is empty or no grid fits (callers decide their fallback).
257    pub fn ticks(&self, lowest: f64, highest: f64, labels_can_fit: i32) -> YTicks {
258        match self {
259            YAxisScale::Price { row_step } => {
260                let row = RowGrid::new(lowest, highest, *row_step, labels_can_fit).map(Grid::Row);
261                row.map(YTicks::from_grid).unwrap_or_default()
262            }
263            YAxisScale::Linear | YAxisScale::Percent => {
264                let grid = Grid::Float(FloatGrid::new(lowest, highest, labels_can_fit, 0.0));
265                YTicks::from_grid(grid)
266            }
267            YAxisScale::Quantized { step } => {
268                let grid = Grid::Float(FloatGrid::new(lowest, highest, labels_can_fit, *step));
269                YTicks::from_grid(grid)
270            }
271        }
272    }
273
274    /// A `Price` grid at exactly the row step: a dense fallback used when no
275    /// coarser grid fits (e.g. a heatmap that always wants to show rows).
276    /// Returns an empty list for non-`Price` scales or an empty range.
277    fn ticks_from_row(&self, lowest: f64, highest: f64) -> YTicks {
278        let YAxisScale::Price { row_step } = self else {
279            return YTicks::default();
280        };
281
282        let row = RowGrid::from_row(lowest, highest, *row_step).map(Grid::Row);
283        row.map(YTicks::from_grid).unwrap_or_default()
284    }
285}
286
287#[derive(Debug, Clone, Copy)]
288pub struct PriceAxis {
289    /// Effective tick size the grid rows are aligned to.
290    pub row_step: PriceStep,
291    /// Precision used to format label values.
292    pub precision: MinTicksize,
293}
294
295impl PriceAxis {
296    /// Bundle a row step with a label precision. `precision` falls back to the
297    /// precision implied by `row_step` when not given.
298    pub fn new(row_step: PriceStep, precision: Option<MinTicksize>) -> Self {
299        Self {
300            row_step,
301            precision: precision
302                .unwrap_or_else(|| MinTicksize::new(-(row_step.decimal_places() as i8))),
303        }
304    }
305
306    /// Row-aligned ticks over a range; empty when no coarser "nice" grid fits.
307    pub fn ticks(&self, lowest: f64, highest: f64, labels_can_fit: i32) -> YTicks {
308        self.scale().ticks(lowest, highest, labels_can_fit)
309    }
310
311    /// A dense tick grid at exactly the row step (fallback for charts that
312    /// always want to show rows).
313    pub fn ticks_from_row(&self, lowest: f64, highest: f64) -> YTicks {
314        self.scale().ticks_from_row(lowest, highest)
315    }
316
317    /// Format a row value (atomic units) at this axis's precision.
318    pub fn format_units(&self, units: i64) -> String {
319        Price::from_units(units).to_string(self.precision)
320    }
321
322    /// Format a price at this axis's precision.
323    pub fn format(&self, price: Price) -> String {
324        price.to_string(self.precision)
325    }
326
327    /// The row-aligned price scale over this axis.
328    fn scale(&self) -> YAxisScale {
329        YAxisScale::Price {
330            row_step: self.row_step,
331        }
332    }
333
334    /// Convert a canvas y position (pixels) to a price, exact to within one
335    /// atomic unit (1e-11). Only the pixel geometry (`y`, `cell_height`) is
336    /// f32; the base price and row step are exact atomic-unit values, so the
337    /// offset arithmetic runs in f64 and is quantized once at the end.
338    pub fn y_to_price(&self, y: f32, cell_height: f32, min: Price) -> Price {
339        let ticks = f64::from(y) / f64::from(cell_height);
340        let price = min.to_f64() - ticks * self.row_step.to_f64_lossy();
341        Price::from_f64(price)
342    }
343
344    /// Align `value` down to the nearest row multiple, in atomic units.
345    fn floor_to_row(&self, value: f64) -> i64 {
346        let row = self.row_step.units.max(1);
347        let units = Price::from_f64(value).units;
348        units.div_euclid(row) * row
349    }
350}
351
352/// Computes a "nice" span for a range `[low, high]` (both in the same units):
353/// starting from the power of ten at/above the range, it repeatedly divides by
354/// the `dividers` pattern while the span still fits the pixel budget
355/// (`max_span` — the largest span that yields the target label count) and stays
356/// above `min_span`. The final span is always `>= min_span`.
357fn tick_span(low: f64, high: f64, max_span: f64, min_span: f64, dividers: [f64; 3]) -> f64 {
358    const EPS: f64 = 1e-14;
359
360    // The starting power of ten is clamped so extreme ranges can never turn
361    // the walk below into an infinite loop (`10^309` overflows to infinity).
362    let mut span = 10f64.powf((high - low).log10().ceil().clamp(0.0, 308.0));
363    let mut i = 0;
364    loop {
365        let c = dividers[i % dividers.len()];
366        let above_min = span >= min_span - EPS && span > min_span + EPS;
367        let above_budget = span >= max_span * c - EPS;
368        if !(above_min && above_budget) {
369            break;
370        }
371        span /= c;
372        i += 1;
373    }
374    span.max(min_span)
375}
376
377/// The minimum span over three divider patterns, which yields steps from
378/// the `{1, 2, 2.5, 4, 5} * 10^k` family.
379fn tick_span_min(low: f64, high: f64, max_span: f64, min_span: f64) -> f64 {
380    [
381        tick_span(low, high, max_span, min_span, [2.0, 2.5, 2.0]),
382        tick_span(low, high, max_span, min_span, [2.0, 2.0, 2.5]),
383        tick_span(low, high, max_span, min_span, [2.5, 2.0, 2.0]),
384    ]
385    .into_iter()
386    .fold(f64::INFINITY, f64::min)
387}
388
389/// A fully positioned, formatted Y-axis label, ready to be drawn.
390///
391/// UI-agnostic: carries the vertical position and label text, so callers can
392/// map it onto their own rendering primitives.
393#[derive(Debug, Clone, PartialEq)]
394pub struct YTickLabel {
395    /// Vertical position in pixels within the axis height (`0.0` = top).
396    pub y_pos: f32,
397    /// Rendered label text.
398    pub content: String,
399}
400
401impl YTickLabel {
402    /// Compute the Y-axis labels for a price range.
403    ///
404    /// `axis` bundles the chart's effective tick size with the label precision;
405    /// when set, labels are aligned to its rows and formatted at its precision.
406    /// When unset, a plain float grid is used and large numbers are abbreviated.
407    /// Positions are pixels from the top of an axis `height` pixels tall.
408    pub fn for_range(
409        lowest: f64,
410        highest: f64,
411        height: f32,
412        labels_can_fit: i32,
413        axis: Option<PriceAxis>,
414    ) -> Vec<Self> {
415        if !lowest.is_finite() || !highest.is_finite() || highest - lowest <= 0.0 {
416            return Vec::new();
417        }
418
419        if labels_can_fit <= 1 {
420            return match axis {
421                Some(axis) => Self::single_row(highest, axis),
422                None => Self::single_y(highest),
423            };
424        }
425
426        match axis {
427            Some(axis) => Self::row_grid(lowest, highest, height, labels_can_fit, axis),
428            None => Self::float_grid(lowest, highest, height, labels_can_fit),
429        }
430    }
431
432    /// A single label built from a ready-made content string, at the top of the
433    /// axis.
434    fn single(content: String) -> Vec<Self> {
435        vec![Self {
436            y_pos: 0.0,
437            content,
438        }]
439    }
440
441    /// A single fallback label for `value` with abbreviated large-number
442    /// formatting.
443    fn single_y(value: f64) -> Vec<Self> {
444        Self::single(abbr_large_numbers(value))
445    }
446
447    /// A single fallback label aligned down to the nearest price row, formatted
448    /// at the axis precision. Keeps the axis pointing at a real row even when
449    /// there is only room for one label.
450    fn single_row(value: f64, axis: PriceAxis) -> Vec<Self> {
451        let aligned = axis.floor_to_row(value);
452        Self::single(axis.format_units(aligned))
453    }
454
455    /// A grid of labels aligned exactly to the price row grid. Every label
456    /// lands on a real row; the grid math lives in [`PriceAxis::ticks`].
457    fn row_grid(
458        lowest: f64,
459        highest: f64,
460        height: f32,
461        labels_can_fit: i32,
462        axis: PriceAxis,
463    ) -> Vec<Self> {
464        let ticks = axis.ticks(lowest, highest, labels_can_fit);
465        if ticks.ticks.is_empty() {
466            return Self::single_row(highest, axis);
467        }
468
469        let mut labels = Vec::with_capacity((labels_can_fit + 2) as usize);
470        for tick in ticks.ticks {
471            let TickValue::PriceUnits(units) = tick.value else {
472                continue;
473            };
474            labels.push(Self {
475                y_pos: height - tick.ratio as f32 * height,
476                content: axis.format_units(units),
477            });
478        }
479
480        labels
481    }
482
483    /// A grid of labels from a plain f64 range (used for indicator axes that
484    /// have no row grid). Labels use abbreviated large-number formatting. The
485    /// grid values themselves are computed by [`YAxisScale::Linear`].
486    fn float_grid(lowest: f64, highest: f64, height: f32, labels_can_fit: i32) -> Vec<Self> {
487        let ticks = YAxisScale::Linear.ticks(lowest, highest, labels_can_fit);
488        if ticks.ticks.is_empty() {
489            return Self::single_y(highest);
490        }
491
492        let mut labels = Vec::with_capacity((labels_can_fit + 2) as usize);
493        for tick in ticks.ticks {
494            let TickValue::Float(value) = tick.value else {
495                continue;
496            };
497            labels.push(Self {
498                y_pos: height - tick.ratio as f32 * height,
499                content: abbr_large_numbers(value),
500            });
501        }
502
503        labels
504    }
505}