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