1use std::fmt;
13use std::str::FromStr;
14
15use super::{PriceMark, PriceMarkGenerator, PriceMarkGeneratorConfig};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
22pub enum PriceScaleMode {
23 #[default]
25 Normal,
26 Logarithmic,
28 Percentage,
30 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#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
65pub struct PriceRange {
66 pub min: f64,
68 pub max: f64,
70}
71
72impl PriceRange {
73 pub fn new(min: f64, max: f64) -> Self {
75 Self { min, max }
76 }
77
78 pub fn length(&self) -> f64 {
80 (self.max - self.min).max(1e-12)
81 }
82
83 pub fn contains(&self, price: f64) -> bool {
85 price >= self.min && price <= self.max
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
94pub struct PriceScaleMargins {
95 pub top: f64,
97 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
110pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
128pub enum PriceScaleId {
129 Left,
131 #[default]
133 Right,
134}
135
136impl PriceScaleId {
137 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#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
157pub struct PriceScaleOptions {
158 pub mode: PriceScaleMode,
160
161 pub invert_scale: bool,
163
164 pub scale_margins: PriceScaleMargins,
166
167 pub tick_marks: PriceMarkGeneratorConfig,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
176pub struct PriceTransform {
177 pub mode: PriceScaleMode,
179 pub base: f64,
181}
182
183impl PriceTransform {
184 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 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 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 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
259pub struct PriceScale {
260 options: PriceScaleOptions,
261 range: PriceRange,
263 transform: PriceTransform,
265 manual: bool,
267 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 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 pub fn set_price_precision(&mut self, decimals: Option<u8>) {
292 self.price_precision = decimals;
293 }
294
295 #[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 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 pub fn bar_ratio(&self) -> Option<f64> {
319 self.bar_ratio
320 }
321
322 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 pub fn bar_ratio_locked(&self) -> bool {
336 self.bar_ratio.is_some()
337 }
338
339 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 pub fn options(&self) -> &PriceScaleOptions {
357 &self.options
358 }
359
360 pub fn options_mut(&mut self) -> &mut PriceScaleOptions {
365 &mut self.options
366 }
367
368 pub fn range(&self) -> PriceRange {
370 self.range
371 }
372
373 pub(crate) fn transform(&self) -> PriceTransform {
375 self.transform
376 }
377
378 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 pub fn is_manual(&self) -> bool {
392 self.manual
393 }
394
395 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 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 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 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 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 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 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 pub fn reset(&mut self) {
493 self.manual = false;
494 self.bar_ratio = None;
495 }
496
497 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#[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 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 pub fn with_bounds(mut self, top: f32, bottom: f32) -> Self {
558 self.top = top;
559 self.bottom = bottom;
560 self
561 }
562
563 pub fn with_price_precision(mut self, decimals: Option<u8>) -> Self {
565 self.price_precision = decimals;
566 self
567 }
568
569 #[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 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 pub fn with_tick_marks(mut self, config: PriceMarkGeneratorConfig) -> Self {
609 self.tick_marks = config;
610 self
611 }
612
613 #[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 pub(crate) fn thousands_sep(&self) -> Option<char> {
625 self.thousands_sep
626 }
627
628 #[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 pub fn marks(&self) -> Vec<PriceMark> {
649 self.shared_marks().to_vec()
650 }
651
652 pub(crate) fn shared_marks(&self) -> std::rc::Rc<[PriceMark]> {
654 thread_local! {
657 static LAST: std::cell::RefCell<Option<(PriceMap, std::rc::Rc<[PriceMark]>)>> =
658 const { std::cell::RefCell::new(None) };
659 }
660 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 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 pub fn transform(&self) -> PriceTransform {
692 self.transform
693 }
694
695 pub fn range(&self) -> PriceRange {
697 self.range
698 }
699
700 pub(crate) fn extent(&self) -> (f32, f32) {
702 (self.top, self.bottom)
703 }
704
705 #[cfg(feature = "widget")]
707 pub(crate) fn is_inverted(&self) -> bool {
708 self.inverted
709 }
710
711 #[inline]
713 pub fn price_to_y(&self, price: f64) -> f32 {
714 self.scale_to_y(self.transform.to_scale(price))
715 }
716
717 #[inline]
719 pub fn y_to_price(&self, y: f32) -> f64 {
720 self.transform.to_price(self.y_to_scale(y))
721 }
722
723 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 #[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 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 #[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 #[test]
842 fn price_map_places_prices_by_mode() {
843 use PriceScaleMode::*;
844 let ln = f64::ln;
845 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 (Logarithmic, 0.0, (ln(10.0), ln(1000.0)), false, 100.0, 50.0),
852 (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 (IndexedTo100, 50.0, (90.0, 110.0), false, 45.0, 100.0),
857 (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 (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 #[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 #[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 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 use PriceScaleMode::*;
933 let ln = f64::ln;
934 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}