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}