Skip to main content

egui_charts/scales/
pricescale.rs

1//! Price scale engine -- maps price values to Y-axis pixel coordinates.
2//!
3//! Supports four scaling modes:
4//!
5//! | Mode            | Axis labels             | Use case                         |
6//! |-----------------|-------------------------|----------------------------------|
7//! | Normal          | Absolute prices         | Default for most instruments     |
8//! | Logarithmic     | Log-spaced prices       | Long-term trends, large ranges   |
9//! | Percentage      | `+5.2%`, `-3.1%`        | Comparing relative performance   |
10//! | IndexedTo100    | Rebased to 100          | Portfolio benchmarking           |
11
12use std::fmt;
13use std::str::FromStr;
14
15use super::{PriceMark, PriceMarkGenerator, PriceMarkGeneratorConfig};
16
17/// Scaling mode for the price (Y) axis.
18///
19/// Determines how raw price values are transformed before being mapped to
20/// pixel coordinates.  The default is [`Normal`](PriceScaleMode::Normal).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
22pub enum PriceScaleMode {
23    /// Prices on a linear axis: equal price differences take equal heights.
24    #[default]
25    Normal,
26    /// Prices on a logarithmic axis: equal price ratios take equal heights.
27    Logarithmic,
28    /// Change from the first visible value, in percent; that value reads 0%.
29    Percentage,
30    /// Like [`Percentage`](PriceScaleMode::Percentage), shifted so the first visible value reads 100.
31    IndexedTo100,
32}
33
34impl fmt::Display for PriceScaleMode {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            PriceScaleMode::Normal => write!(f, "Normal"),
38            PriceScaleMode::Logarithmic => write!(f, "Logarithmic"),
39            PriceScaleMode::Percentage => write!(f, "Percentage"),
40            PriceScaleMode::IndexedTo100 => write!(f, "Indexed to 100"),
41        }
42    }
43}
44
45impl FromStr for PriceScaleMode {
46    type Err = crate::model::ParseError;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        match s.to_lowercase().as_str() {
50            "normal" => Ok(PriceScaleMode::Normal),
51            "logarithmic" | "log" => Ok(PriceScaleMode::Logarithmic),
52            "percentage" | "percent" | "%" => Ok(PriceScaleMode::Percentage),
53            "indexedto100" | "indexed to 100" | "indexed" => Ok(PriceScaleMode::IndexedTo100),
54            _ => Err(crate::model::ParseError::new(
55                "price scale mode",
56                s,
57                "expected normal, logarithmic, percentage or indexed to 100",
58            )),
59        }
60    }
61}
62
63/// A min/max price range, used throughout the scale engine.
64#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
65pub struct PriceRange {
66    /// Minimum price in the range.
67    pub min: f64,
68    /// Maximum price in the range.
69    pub max: f64,
70}
71
72impl PriceRange {
73    /// Creates a new price range.
74    pub fn new(min: f64, max: f64) -> Self {
75        Self { min, max }
76    }
77
78    /// Returns the size of the range (`max - min`), clamped to a minimum of `1e-12`.
79    pub fn length(&self) -> f64 {
80        (self.max - self.min).max(1e-12)
81    }
82
83    /// Returns `true` if `price` is within `[min, max]` inclusive.
84    pub fn contains(&self, price: f64) -> bool {
85        price >= self.min && price <= self.max
86    }
87}
88
89/// Space auto-scaling leaves above and below the data, as fractions of the pane height.
90///
91/// The defaults keep the data in the middle 80% of the pane. Margins that together
92/// leave no room for the data are ignored.
93#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
94pub struct PriceScaleMargins {
95    /// Space above the highest value, as a fraction of the pane height (0.0 to 1.0)
96    pub top: f64,
97    /// Space below the lowest value, as a fraction of the pane height (0.0 to 1.0)
98    pub bottom: f64,
99}
100
101impl Default for PriceScaleMargins {
102    fn default() -> Self {
103        Self {
104            top: 0.1,
105            bottom: 0.1,
106        }
107    }
108}
109
110/// Base span that auto-scale margins are computed from, for data covering `[min, max]`.
111///
112/// Flat data (no movement beyond float noise) gets 1% of the price level, or 1.0 at
113/// zero, so other prices such as drawing anchors stay within a sane pixel distance.
114pub(crate) fn auto_scale_span(min: f64, max: f64) -> f64 {
115    let range = max - min;
116    let level = min.abs().max(max.abs());
117    if range > level * 1e-9 {
118        range
119    } else if level > 0.0 {
120        level * 0.01
121    } else {
122        1.0
123    }
124}
125
126/// Identifies one of a pane's price axes.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
128pub enum PriceScaleId {
129    /// Left price scale
130    Left,
131    /// Right price scale (holds the main series)
132    #[default]
133    Right,
134}
135
136impl PriceScaleId {
137    /// Returns the opposite scale
138    pub fn opposite(&self) -> Self {
139        match self {
140            Self::Left => Self::Right,
141            Self::Right => Self::Left,
142        }
143    }
144}
145
146impl fmt::Display for PriceScaleId {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Self::Left => write!(f, "left"),
150            Self::Right => write!(f, "right"),
151        }
152    }
153}
154
155/// Configuration options for a [`PriceScale`].
156#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
157pub struct PriceScaleOptions {
158    /// Price scale mode
159    pub mode: PriceScaleMode,
160
161    /// Invert the price scale (uptrends become downtrends)
162    pub invert_scale: bool,
163
164    /// Margins (top and bottom)
165    pub scale_margins: PriceScaleMargins,
166
167    /// Spacing and number of the tick marks, which place the axis labels and grid lines
168    pub tick_marks: PriceMarkGeneratorConfig,
169}
170
171/// How one series' prices become scale units.
172///
173/// Percentage and indexed modes are relative to `base`, the series' first
174/// visible close; normal and logarithmic modes ignore it.
175#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
176pub struct PriceTransform {
177    /// Scale mode
178    pub mode: PriceScaleMode,
179    /// Reference price for the percentage and indexed modes
180    pub base: f64,
181}
182
183impl PriceTransform {
184    /// Relative modes without a finite, nonzero reference show ordinary prices instead.
185    pub(crate) fn new(mode: PriceScaleMode, base: f64) -> Self {
186        match mode {
187            PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100 => {
188                if base.is_finite() && base != 0.0 {
189                    Self { mode, base }
190                } else {
191                    Self::default()
192                }
193            }
194            _ => Self { mode, base: 0.0 },
195        }
196    }
197
198    /// Converts a price to scale units.
199    ///
200    /// Logarithmic maps non-positive prices to negative infinity, which
201    /// [`PriceMap`] places far below the pane.
202    pub fn to_scale(&self, price: f64) -> f64 {
203        match self.mode {
204            PriceScaleMode::Normal => price,
205            PriceScaleMode::Logarithmic if price > 0.0 => price.ln(),
206            PriceScaleMode::Logarithmic => f64::NEG_INFINITY,
207            PriceScaleMode::Percentage => (price - self.base) / self.base.abs() * 100.0,
208            PriceScaleMode::IndexedTo100 => 100.0 + (price - self.base) / self.base.abs() * 100.0,
209        }
210    }
211
212    /// Converts scale units back to a price.
213    pub fn to_price(&self, value: f64) -> f64 {
214        match self.mode {
215            PriceScaleMode::Normal => value,
216            PriceScaleMode::Logarithmic => value.exp(),
217            PriceScaleMode::Percentage => self.base + value / 100.0 * self.base.abs(),
218            PriceScaleMode::IndexedTo100 => self.base + (value - 100.0) / 100.0 * self.base.abs(),
219        }
220    }
221
222    /// The value an axis label shows for a scale value: the price in the normal and logarithmic
223    /// modes, the percentage or index value otherwise.
224    pub(crate) fn axis_value(&self, value: f64) -> f64 {
225        if self.mode == PriceScaleMode::Logarithmic {
226            self.to_price(value)
227        } else {
228            value
229        }
230    }
231
232    /// Formats a scale value for the axis: prices, signed percentages or index values.
233    ///
234    /// A host `formatter` receives the price in the normal and logarithmic modes and
235    /// the percentage or index value otherwise, and replaces `decimals`.
236    pub(crate) fn label(
237        &self,
238        value: f64,
239        decimals: usize,
240        formatter: Option<&dyn super::PriceFormatter>,
241    ) -> String {
242        use super::{DefaultPriceFormatter, PriceFormatter};
243        let value = self.axis_value(value);
244        match (formatter, self.mode) {
245            (Some(formatter), _) => formatter.format(value),
246            (None, PriceScaleMode::Percentage) => {
247                let formatted = super::price_formatter::format_decimal(value, decimals);
248                let sign = if formatted.starts_with('-') { "" } else { "+" };
249                format!("{sign}{formatted}%")
250            }
251            (None, _) => DefaultPriceFormatter::with_precision(decimals).format(value),
252        }
253    }
254}
255
256/// One price axis: options and the visible range in scale units, fitted to the
257/// data until the user scales it by hand.
258#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
259pub struct PriceScale {
260    options: PriceScaleOptions,
261    /// Visible range in scale units
262    range: PriceRange,
263    /// The transform `range` is in, from the last `rebase`
264    transform: PriceTransform,
265    /// The user zoomed or set the range, so autoscale leaves it alone until reset.
266    manual: bool,
267    /// Larger magnitude of the data at the last fit, in scale units
268    level: f64,
269    price_precision: Option<u8>,
270    #[serde(default)]
271    tick_size: Option<f64>,
272    bar_ratio: Option<f64>,
273}
274
275impl PriceScale {
276    /// Creates an auto-scaled price scale.
277    pub fn new(options: PriceScaleOptions) -> Self {
278        Self {
279            options,
280            range: PriceRange::new(0.0, 100.0),
281            transform: PriceTransform::default(),
282            manual: false,
283            level: 0.0,
284            price_precision: None,
285            tick_size: None,
286            bar_ratio: None,
287        }
288    }
289
290    /// Sets the decimals used by price ticks and tags. `None` derives them from the tick step.
291    pub fn set_price_precision(&mut self, decimals: Option<u8>) {
292        self.price_precision = decimals;
293    }
294
295    /// The instrument's default price grid, separate from the host's tick-mark override.
296    #[cfg(feature = "widget")]
297    pub(crate) fn set_tick_size(&mut self, tick_size: Option<f64>) {
298        self.tick_size = tick_size.filter(|size| size.is_finite() && *size > 0.0);
299    }
300
301    /// Locks scale units per bar at the current bar spacing and pane height.
302    /// Invalid dimensions leave the scale unlocked. Unlocking keeps the current manual range.
303    pub fn set_bar_ratio_locked(&mut self, locked: bool, bar_spacing: f32, height: f32) {
304        self.bar_ratio = (locked
305            && self.range.max > self.range.min
306            && (self.range.max - self.range.min).is_finite()
307            && bar_spacing.is_finite()
308            && bar_spacing > 0.0
309            && height.is_finite()
310            && height > 0.0)
311            .then(|| (self.range.max - self.range.min) * f64::from(bar_spacing / height));
312        if self.bar_ratio.is_some() {
313            self.manual = true;
314        }
315    }
316
317    /// Locked scale units per bar, or `None` when unlocked.
318    pub fn bar_ratio(&self) -> Option<f64> {
319        self.bar_ratio
320    }
321
322    /// Set scale units per bar; `None` unlocks. Invalid ratios leave the scale unchanged.
323    /// The next `update_bar_ratio` applies it about the range centre.
324    pub fn set_bar_ratio(&mut self, ratio: Option<f64>) {
325        if ratio.is_some_and(|r| !r.is_finite() || r <= 0.0) {
326            return;
327        }
328        self.bar_ratio = ratio;
329        if ratio.is_some() {
330            self.manual = true;
331        }
332    }
333
334    /// Whether horizontal zoom and pane resizing preserve scale units per bar.
335    pub fn bar_ratio_locked(&self) -> bool {
336        self.bar_ratio.is_some()
337    }
338
339    /// Preserves the locked ratio about the range centre after horizontal zoom or pane resize.
340    pub fn update_bar_ratio(&mut self, bar_spacing: f32, height: f32) {
341        if let Some(ratio) = self.bar_ratio
342            && bar_spacing.is_finite()
343            && bar_spacing > 0.0
344            && height.is_finite()
345            && height > 0.0
346        {
347            let span = ratio * f64::from(height / bar_spacing);
348            let centre = self.range.min / 2.0 + self.range.max / 2.0;
349            if span.is_finite() && span > 0.0 {
350                self.range = PriceRange::new(centre - span / 2.0, centre + span / 2.0);
351            }
352        }
353    }
354
355    /// Returns the options.
356    pub fn options(&self) -> &PriceScaleOptions {
357        &self.options
358    }
359
360    /// Returns the options for changing the mode, inversion or margins.
361    ///
362    /// The range is in scale units, so after changing the mode call
363    /// [`reset`](Self::reset) to fit the data in the new units.
364    pub fn options_mut(&mut self) -> &mut PriceScaleOptions {
365        &mut self.options
366    }
367
368    /// Returns the visible range in scale units.
369    pub fn range(&self) -> PriceRange {
370        self.range
371    }
372
373    /// Returns the transform the range is in.
374    pub(crate) fn transform(&self) -> PriceTransform {
375        self.transform
376    }
377
378    /// Puts the range in the units of `transform`, this frame's transform.
379    ///
380    /// A range scaled by hand keeps showing the same prices: the relative modes
381    /// measure from a base that moves as the chart scrolls.
382    pub(crate) fn rebase(&mut self, transform: PriceTransform) {
383        let old = std::mem::replace(&mut self.transform, transform);
384        if self.manual && old != transform {
385            let rebased = |value| transform.to_scale(old.to_price(value));
386            self.range = PriceRange::new(rebased(self.range.min), rebased(self.range.max));
387        }
388    }
389
390    /// Returns `true` when the user has scaled by hand.
391    pub fn is_manual(&self) -> bool {
392        self.manual
393    }
394
395    /// Fits `min..max` (scale units) plus margins unless the user scaled by hand.
396    ///
397    /// Non-finite bounds (no drawable data) and a fit that overflows keep the current range.
398    pub fn autoscale(&mut self, min: f64, max: f64) {
399        if !min.is_finite() || !max.is_finite() {
400            return;
401        }
402        self.level = min.abs().max(max.abs());
403        if self.manual {
404            return;
405        }
406        let span = match self.transform.mode {
407            // Log units measure ratios: flat data gets 0.01, which is 1% of the price.
408            PriceScaleMode::Logarithmic if max - min <= 1e-9 => 0.01,
409            _ => auto_scale_span(min, max),
410        };
411        let PriceScaleMargins { top, bottom } = self.options.scale_margins;
412        let (top, bottom) = (top.clamp(0.0, 1.0), bottom.clamp(0.0, 1.0));
413        let (top, bottom) = if top + bottom < 1.0 {
414            (top, bottom)
415        } else {
416            (0.0, 0.0)
417        };
418        // The data fills the rest of the pane, so the whole range is that share's inverse.
419        let total = span / (1.0 - top - bottom);
420        let range = PriceRange::new(min - total * bottom, max + total * top);
421        if range.min.is_finite() && range.max.is_finite() {
422            self.range = range;
423        }
424    }
425
426    /// Shows `min..max` and switches to manual.
427    ///
428    /// The bounds are in the units of the transform the last fit returned
429    /// ([`ChartState::fit_price_scale`](crate::studies::ChartState::fit_price_scale)).
430    /// Non-finite, equal or reversed bounds, or an overflowing span, leave the scale unchanged.
431    pub fn set_range(&mut self, min: f64, max: f64) {
432        if !min.is_finite() || !max.is_finite() || min >= max || !(max - min).is_finite() {
433            return;
434        }
435        if let Some(ratio) = &mut self.bar_ratio {
436            let span = self.range.max - self.range.min;
437            if span > 0.0 && span.is_finite() {
438                *ratio *= (max - min) / span;
439            } else {
440                self.bar_ratio = None;
441            }
442        }
443        self.range = PriceRange::new(min, max);
444        self.manual = true;
445    }
446
447    /// Scales the range by `factor` (above 1 zooms out) around the scale value
448    /// `anchor`, which keeps its position on screen, and switches to manual.
449    /// `anchor` is in the units of the transform the last fit returned.
450    ///
451    /// One call changes the span by at most 20 times either way. Zooming in stops at a
452    /// millionth of the price level: 1e-6 log units on a logarithmic scale, 1e-4 percentage
453    /// or index points in the relative modes, and for prices 1e-6 of the larger magnitude of
454    /// the data at the last fit or of the range. A result that is not finite leaves the range
455    /// unchanged.
456    pub fn zoom(&mut self, factor: f64, anchor: f64) {
457        let PriceRange { min, max } = self.range;
458        let span = auto_scale_span(min, max);
459        let floor = self.min_span();
460        // Never widen a range that is already narrower than the floor.
461        let new_span = (span * factor)
462            .clamp(span * 0.05, span * 20.0)
463            .max(floor.min(span));
464        let t = ((anchor - min) / span).clamp(0.0, 1.0);
465        let new_min = anchor - t * new_span;
466        let new_max = new_min + new_span;
467        if (new_max - new_min).is_finite() {
468            self.set_range(new_min, new_max);
469        }
470    }
471
472    /// The narrowest span zooming reaches, in scale units: a millionth of the price level,
473    /// far above f64 rounding (~1e-16) and at or below common tick sizes. The level is the
474    /// price on a logarithmic scale and the base in the relative modes, which do not shrink
475    /// with a view that crosses 0; for prices it is the larger magnitude of the data at the last
476    /// fit and of the range (1e-6 when both are 0).
477    pub(crate) fn min_span(&self) -> f64 {
478        match self.transform.mode {
479            PriceScaleMode::Logarithmic => 1e-6,
480            PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100 => 1e-4,
481            PriceScaleMode::Normal => {
482                let level = self
483                    .level
484                    .max(self.range.min.abs())
485                    .max(self.range.max.abs());
486                if level > 0.0 { level * 1e-6 } else { 1e-6 }
487            }
488        }
489    }
490
491    /// Returns to auto-scaling (double click on the axis).
492    pub fn reset(&mut self) {
493        self.manual = false;
494        self.bar_ratio = None;
495    }
496
497    /// Mapping for one series for one frame, given the pane's vertical extent in points.
498    pub fn map(&self, transform: PriceTransform, top: f32, bottom: f32) -> PriceMap {
499        let mut tick_marks = self.options.tick_marks;
500        if matches!(
501            transform.mode,
502            PriceScaleMode::Normal | PriceScaleMode::Logarithmic
503        ) {
504            tick_marks.min_price_step = tick_marks.min_price_step.or(self.tick_size);
505        }
506        PriceMap::new(
507            transform,
508            self.range,
509            top,
510            bottom,
511            self.options.invert_scale,
512        )
513        .with_price_precision(self.price_precision)
514        .with_tick_marks(tick_marks)
515    }
516}
517
518/// Per-frame price-to-pixel mapping.
519///
520/// Unclamped: a price outside the visible range maps outside the pane, and the
521/// painter's clip rect clips it.
522#[derive(Debug, Clone, Copy, PartialEq)]
523pub struct PriceMap {
524    transform: PriceTransform,
525    range: PriceRange,
526    top: f32,
527    bottom: f32,
528    inverted: bool,
529    price_precision: Option<u8>,
530    tick_marks: PriceMarkGeneratorConfig,
531    thousands_sep: Option<char>,
532}
533
534impl PriceMap {
535    /// Maps `range` (scale units) onto `top..bottom` (points), the low end at the
536    /// bottom unless `inverted`.
537    pub fn new(
538        transform: PriceTransform,
539        range: PriceRange,
540        top: f32,
541        bottom: f32,
542        inverted: bool,
543    ) -> Self {
544        Self {
545            transform,
546            range,
547            top,
548            bottom,
549            inverted,
550            price_precision: None,
551            tick_marks: PriceMarkGeneratorConfig::default(),
552            thousands_sep: None,
553        }
554    }
555
556    /// Uses the same scale, orientation and formatting in a different vertical plot area.
557    pub fn with_bounds(mut self, top: f32, bottom: f32) -> Self {
558        self.top = top;
559        self.bottom = bottom;
560        self
561    }
562
563    /// Sets the price precision for this frame.
564    pub fn with_price_precision(mut self, decimals: Option<u8>) -> Self {
565        self.price_precision = decimals;
566        self
567    }
568
569    /// Applies a series' raw display units without changing the range or transform.
570    #[cfg(feature = "widget")]
571    pub(crate) fn with_display_format(mut self, format: crate::model::SeriesDisplayFormat) -> Self {
572        if matches!(
573            self.transform.mode,
574            PriceScaleMode::Normal | PriceScaleMode::Logarithmic
575        ) {
576            self.price_precision = Some(format.precision);
577            let mut unit = 10f64.powi(-i32::from(format.precision));
578            if format.kind == crate::model::SeriesFormat::Volume {
579                let magnitude = self
580                    .transform
581                    .to_price(self.range.min)
582                    .abs()
583                    .max(self.transform.to_price(self.range.max).abs());
584                // Volume decimals belong to the abbreviated unit, so adjacent ticks must
585                // still have distinguishable labels after applying its suffix.
586                unit *= if magnitude >= 1e9 {
587                    1e9
588                } else if magnitude >= 1e6 {
589                    1e6
590                } else if magnitude >= 1e3 {
591                    1e3
592                } else {
593                    1.0
594                };
595            }
596            self.tick_marks.min_price_step =
597                Some(if format.min_move.is_finite() && format.min_move > 0.0 {
598                    let step = (unit / format.min_move).ceil().max(1.0) * format.min_move;
599                    if step.is_finite() { step } else { unit }
600                } else {
601                    unit
602                });
603        }
604        self
605    }
606
607    /// Sets how [`marks`](Self::marks) spaces the tick marks.
608    pub fn with_tick_marks(mut self, config: PriceMarkGeneratorConfig) -> Self {
609        self.tick_marks = config;
610        self
611    }
612
613    /// Groups the thousands of the built-in labels with `separator`, which charts take from
614    /// `ChartConfig::thousands_sep`. `'.'` and digits group nothing: built-in labels write
615    /// `'.'` as the decimal point.
616    #[cfg(feature = "widget")]
617    pub(crate) fn with_thousands_sep(mut self, separator: Option<char>) -> Self {
618        self.thousands_sep =
619            separator.filter(|separator| *separator != '.' && !separator.is_ascii_digit());
620        self
621    }
622
623    /// The separator that groups the thousands of the built-in labels, if any.
624    pub(crate) fn thousands_sep(&self) -> Option<char> {
625        self.thousands_sep
626    }
627
628    /// Formats a scale value as [`PriceTransform::label`] does and groups the thousands of a
629    /// built-in label; a host `formatter`'s label is returned as written.
630    #[cfg(feature = "widget")]
631    pub(crate) fn label(
632        &self,
633        value: f64,
634        decimals: usize,
635        formatter: Option<&dyn super::PriceFormatter>,
636    ) -> String {
637        let label = self.transform.label(value, decimals, formatter);
638        if formatter.is_some() {
639            label
640        } else {
641            super::price_formatter::group_thousands(label, self.thousands_sep)
642        }
643    }
644
645    /// Tick marks for the visible range, spaced by the scale's
646    /// [`tick_marks`](PriceScaleOptions::tick_marks) settings. The labels of a chart's maps write
647    /// prices of four or more integer digits with its `ChartConfig::thousands_sep`.
648    pub fn marks(&self) -> Vec<PriceMark> {
649        self.shared_marks().to_vec()
650    }
651
652    /// The [`marks`](Self::marks), shared rather than copied.
653    pub(crate) fn shared_marks(&self) -> std::rc::Rc<[PriceMark]> {
654        // A frame asks for the same marks for its grid, labels, axis width and readouts, and
655        // formatting their labels is costly, so the marks of the last map are kept.
656        thread_local! {
657            static LAST: std::cell::RefCell<Option<(PriceMap, std::rc::Rc<[PriceMark]>)>> =
658                const { std::cell::RefCell::new(None) };
659        }
660        // A signed zero labels differently ("-0.00"), so the range also compares by bits.
661        let same = |map: &PriceMap| {
662            map == self
663                && map.range.min.to_bits() == self.range.min.to_bits()
664                && map.range.max.to_bits() == self.range.max.to_bits()
665        };
666        LAST.with_borrow_mut(|last| match last {
667            Some((map, marks)) if same(map) => marks.clone(),
668            _ => {
669                let marks: std::rc::Rc<[PriceMark]> =
670                    PriceMarkGenerator::with_config(self.tick_marks)
671                        .generate_marks(self)
672                        .into();
673                *last = Some((*self, marks.clone()));
674                marks
675            }
676        })
677    }
678
679    /// Supplied price decimals, or `None` to derive them from the tick step.
680    /// Percentage and indexed axes keep their own precision.
681    pub fn price_precision(&self) -> Option<usize> {
682        match self.transform.mode {
683            PriceScaleMode::Normal | PriceScaleMode::Logarithmic => {
684                self.price_precision.map(usize::from)
685            }
686            _ => None,
687        }
688    }
689
690    /// Returns the price transform.
691    pub fn transform(&self) -> PriceTransform {
692        self.transform
693    }
694
695    /// Returns the visible range in scale units.
696    pub fn range(&self) -> PriceRange {
697        self.range
698    }
699
700    /// Returns the pane's vertical extent `(top, bottom)` in points.
701    pub(crate) fn extent(&self) -> (f32, f32) {
702        (self.top, self.bottom)
703    }
704
705    /// Whether scale values increase down the pane.
706    #[cfg(feature = "widget")]
707    pub(crate) fn is_inverted(&self) -> bool {
708        self.inverted
709    }
710
711    /// Converts a price to a y coordinate.
712    #[inline]
713    pub fn price_to_y(&self, price: f64) -> f32 {
714        self.scale_to_y(self.transform.to_scale(price))
715    }
716
717    /// Converts a y coordinate to a price.
718    #[inline]
719    pub fn y_to_price(&self, y: f32) -> f64 {
720        self.transform.to_price(self.y_to_scale(y))
721    }
722
723    /// Translates a price by the scale-unit offset between two y coordinates.
724    ///
725    /// Equal y coordinates keep valid prices exactly unchanged. Non-positive logarithmic
726    /// prices start from their bounded off-pane coordinate.
727    pub fn translate_price(&self, price: f64, from_y: f32, to_y: f32) -> f64 {
728        if self.transform.mode == PriceScaleMode::Logarithmic && price <= 0.0 {
729            return self.y_to_price(self.price_to_y(price) + (to_y - from_y));
730        }
731        if from_y == to_y {
732            return price;
733        }
734        let shift = self.y_to_scale(to_y) - self.y_to_scale(from_y);
735        self.transform
736            .to_price(self.transform.to_scale(price) + shift)
737    }
738
739    /// Converts scale units to a y coordinate.
740    ///
741    /// A flat range has no vertical extent to map onto, so every value lands on
742    /// the middle of the pane.
743    #[inline]
744    pub(crate) fn scale_to_y(&self, value: f64) -> f32 {
745        let span = self.range.max - self.range.min;
746        if span == 0.0 {
747            return (self.top + self.bottom) / 2.0;
748        }
749        // Bounded so that a non-positive price on a log scale stays a finite coordinate off the pane.
750        let ratio = ((value - self.range.min) / span).clamp(-1e4, 1e4) as f32;
751        let height = self.bottom - self.top;
752        if self.inverted {
753            self.top + ratio * height
754        } else {
755            self.bottom - ratio * height
756        }
757    }
758
759    /// Converts a y coordinate to scale units.
760    ///
761    /// A zero-height pane has no inverse, so the low end of the range is returned.
762    #[inline]
763    pub(crate) fn y_to_scale(&self, y: f32) -> f64 {
764        let height = self.bottom - self.top;
765        if height.abs() < f32::EPSILON {
766            return self.range.min;
767        }
768        let ratio = if self.inverted {
769            (y - self.top) / height
770        } else {
771            (self.bottom - y) / height
772        };
773        self.range.min + ratio as f64 * (self.range.max - self.range.min)
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn percentage_labels_round_zero_without_a_minus() {
783        let transform = PriceTransform::new(PriceScaleMode::Percentage, 100.0);
784        for (price, expected) in [
785            (99.996, "+0.00%"),
786            (100.0, "+0.00%"),
787            (100.004, "+0.00%"),
788            (99.994, "-0.01%"),
789            (100.006, "+0.01%"),
790        ] {
791            assert_eq!(
792                transform.label(transform.to_scale(price), 2, None),
793                expected
794            );
795        }
796        for (min, max, expected) in [(-0.35, 0.45, "+0.000%"), (-0.0, 1e-10, "+0e0%")] {
797            let map = PriceMap::new(transform, PriceRange::new(min, max), 0.0, 500.0, false);
798            let zero = map
799                .marks()
800                .into_iter()
801                .find(|m| m.price.abs() < (max - min) * 1e-9)
802                .unwrap();
803            assert_eq!(zero.label, expected);
804        }
805    }
806
807    #[test]
808    fn invalid_manual_ranges_leave_the_scale_unchanged() {
809        for (min, max) in [
810            (f64::NAN, f64::NAN),
811            (f64::NAN, 100.0),
812            (0.0, f64::NAN),
813            (f64::NEG_INFINITY, 100.0),
814            (0.0, f64::INFINITY),
815            (200.0, 100.0),
816            (100.0, 100.0),
817            (-f64::MAX, f64::MAX),
818        ] {
819            for locked in [false, true] {
820                let mut scale = PriceScale::new(PriceScaleOptions::default());
821                scale.autoscale(100.0, 170.0);
822                if locked {
823                    scale.set_bar_ratio_locked(true, 10.0, 500.0);
824                }
825                let before = serde_json::to_value(&scale).unwrap();
826                scale.set_range(min, max);
827                assert_eq!(
828                    serde_json::to_value(&scale).unwrap(),
829                    before,
830                    "{min}..{max}"
831                );
832                if !locked {
833                    scale.autoscale(200.0, 270.0);
834                    assert!(scale.range().min > 170.0);
835                }
836            }
837        }
838    }
839
840    /// Hand-computed positions on a pane from y = 0 (top) to y = 100 (bottom).
841    #[test]
842    fn price_map_places_prices_by_mode() {
843        use PriceScaleMode::*;
844        let ln = f64::ln;
845        // (mode, base, scale range, inverted, price, expected y)
846        let cases = [
847            (Normal, 0.0, (100.0, 200.0), false, 150.0, 50.0),
848            (Normal, 0.0, (100.0, 200.0), false, 120.0, 80.0),
849            (Normal, 0.0, (1e-18, 6e-18), false, 2e-18, 80.0),
850            // ln 100 is the mean of ln 10 and ln 1,000.
851            (Logarithmic, 0.0, (ln(10.0), ln(1000.0)), false, 100.0, 50.0),
852            // 55 is +10% of 50, the top of -10%..+10%.
853            (Percentage, 50.0, (-10.0, 10.0), false, 55.0, 0.0),
854            (Percentage, 50.0, (-10.0, 10.0), false, 50.0, 50.0),
855            // 45 is 90 when 50 is 100.
856            (IndexedTo100, 50.0, (90.0, 110.0), false, 45.0, 100.0),
857            // A negative base keeps the price order: -40 is 20% above -50 and reads 120.
858            (IndexedTo100, -50.0, (80.0, 120.0), false, -40.0, 0.0),
859            (IndexedTo100, -50.0, (80.0, 120.0), false, -60.0, 100.0),
860            (Normal, 0.0, (100.0, 200.0), true, 120.0, 20.0),
861            // Outside the range: above the pane, not on its edge.
862            (Normal, 0.0, (100.0, 200.0), false, 250.0, -50.0),
863            (Normal, 0.0, (100.0, 200.0), true, 50.0, -50.0),
864        ];
865        for (mode, base, (min, max), inverted, price, y) in cases {
866            let transform = PriceTransform { mode, base };
867            let map = PriceMap::new(transform, PriceRange::new(min, max), 0.0, 100.0, inverted);
868            let got = map.price_to_y(price);
869            assert!(
870                (got - y).abs() < 1e-3,
871                "{mode:?} {price}: y {got}, expected {y}"
872            );
873            let back = map.y_to_price(got);
874            assert!(
875                (back - price).abs() < 1e-3 * price.abs(),
876                "{mode:?} {price}: back {back}"
877            );
878        }
879    }
880
881    /// Margins are shares of the pane height: data from 100 to 170 with 20% above and 10% below
882    /// fills 70% of the pane, so the range is 100 wide and runs from 90 to 190. Margins that
883    /// leave no room for the data are ignored.
884    #[test]
885    fn autoscale_margins_are_shares_of_the_pane() {
886        for (top, bottom, expected) in [
887            (0.2, 0.1, (90.0, 190.0)),
888            (0.0, 0.0, (100.0, 170.0)),
889            (0.6, 0.4, (100.0, 170.0)),
890            (f64::NAN, 0.1, (100.0, 170.0)),
891        ] {
892            let mut scale = PriceScale::new(PriceScaleOptions {
893                scale_margins: PriceScaleMargins { top, bottom },
894                ..Default::default()
895            });
896            scale.autoscale(100.0, 170.0);
897            let PriceRange { min, max } = scale.range();
898            assert!(
899                (min - expected.0).abs() < 1e-9 && (max - expected.1).abs() < 1e-9,
900                "{top} {bottom}: {min}..{max}"
901            );
902        }
903    }
904
905    /// Manual zoom must narrow ordinary intraday ranges, stop at a span tied to the
906    /// price level, and never overflow to infinite or NaN bounds when zooming out.
907    #[test]
908    fn zoom_stays_within_sane_span() {
909        let scale_with = |lo, hi| {
910            let mut s = PriceScale::new(PriceScaleOptions::default());
911            s.set_range(lo, hi);
912            s
913        };
914        // EURUSD, BTC and a series crossing zero, each zoomed in at its anchor.
915        for (lo, hi, anchor) in [
916            (1.0845, 1.0855, 1.085),
917            (99_500.0, 100_000.0, 99_750.0),
918            (-0.5, 0.5, 0.0),
919        ] {
920            let mut s = scale_with(lo, hi);
921            s.zoom((-0.1f64).exp(), anchor);
922            assert!(
923                s.range().max - s.range().min < hi - lo,
924                "({lo}, {hi}) -> {:?}",
925                s.range()
926            );
927        }
928
929        // Zoomed all the way in, the span is a millionth of the price level: of the visible data
930        // (at most 110, or 0.5 across zero), of the base (100) in percentage and index points,
931        // and of the price (1e-6 log units) on a logarithmic scale, also around ln 1 = 0.
932        use PriceScaleMode::*;
933        let ln = f64::ln;
934        // (mode, data in scale units, anchor, expected span)
935        for (mode, (lo, hi), anchor, floor) in [
936            (Normal, (90.0, 110.0), 100.0, 1.1e-4),
937            (Normal, (-0.5, 0.5), 0.0, 5e-7),
938            (Percentage, (-5.0, 5.0), 0.0, 1e-4),
939            (IndexedTo100, (95.0, 105.0), 100.0, 1e-4),
940            (Logarithmic, (ln(0.95), ln(1.05)), 0.0, 1e-6),
941        ] {
942            let mut s = PriceScale::new(PriceScaleOptions::default());
943            s.rebase(PriceTransform { mode, base: 50.0 });
944            s.autoscale(lo, hi);
945            for _ in 0..200 {
946                s.zoom((-1.0f64).exp(), anchor);
947            }
948            let span = s.range().max - s.range().min;
949            assert!(
950                (span - floor).abs() < floor * 1e-6,
951                "{mode:?} ({lo}, {hi}): {:?}",
952                s.range()
953            );
954
955            for _ in 0..1000 {
956                s.zoom(1.0f64.exp(), anchor);
957            }
958            let r = s.range();
959            assert!(
960                r.min.is_finite() && r.max.is_finite() && r.min < r.max,
961                "{mode:?}: {r:?}"
962            );
963        }
964    }
965}