1use std::cell::{Cell, RefCell};
10use std::ops::{Deref, Range};
11use std::rc::Rc;
12
13use oxframe_2d::{
14 Accent, BaseProps, Circle, Custom, Group, IntoPaint, Muted, Node, NodeBuilder, NodeId, Path,
15 Rect, Span, Text, Txt, pct,
16};
17use oxframe_core::kurbo::{BezPath, Point, Vec2};
18use oxframe_core::{Color, Lerp, Signal};
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Interval {
23 pub min: f64,
25 pub max: f64,
27}
28
29impl Interval {
30 pub fn len(&self) -> f64 {
32 let d = self.max - self.min;
33 if d.abs() < 1e-12 { 1e-12 } else { d }
34 }
35
36 pub fn is_empty(&self) -> bool {
38 false
39 }
40}
41
42impl From<Range<f64>> for Interval {
43 fn from(r: Range<f64>) -> Self {
44 Self {
45 min: r.start,
46 max: r.end,
47 }
48 }
49}
50
51impl From<(f64, f64)> for Interval {
52 fn from((min, max): (f64, f64)) -> Self {
53 Self { min, max }
54 }
55}
56
57impl Lerp for Interval {
58 fn lerp(&self, to: &Self, t: f64) -> Self {
59 Self {
60 min: self.min.lerp(&to.min, t),
61 max: self.max.lerp(&to.max, t),
62 }
63 }
64}
65
66const LABELS: usize = 24;
68const TARGET_TICKS: f64 = 8.0;
70
71fn tick_step(span: f64) -> f64 {
73 let raw = span.abs().max(1e-12) / TARGET_TICKS;
74 let mag = 10f64.powf(raw.log10().floor());
75 for k in [1.0, 2.0, 5.0, 10.0] {
76 if k * mag >= raw {
77 return k * mag;
78 }
79 }
80 10.0 * mag
81}
82
83fn tick_indices(range: Interval, step: f64) -> Range<i64> {
85 let (lo, hi) = (range.min.min(range.max), range.min.max(range.max));
86 let first = (lo / step - 1e-9).ceil() as i64;
87 let last = (hi / step + 1e-9).floor() as i64;
88 first..last + 1
89}
90
91fn label(value: f64, step: f64) -> String {
93 let decimals = if step >= 1.0 {
94 0
95 } else {
96 (-step.log10().floor()) as usize
97 };
98 let v = if value.abs() < step * 1e-6 {
99 0.0
100 } else {
101 value
102 };
103 format!("{v:.decimals$}")
104}
105
106struct Shared {
107 x: Signal<Interval>,
108 y: Signal<Interval>,
109 width: f64,
110 height: f64,
111 y_axis: bool,
113 categories: Cell<bool>,
116}
117
118impl Shared {
119 fn c2p(&self, x: f64, y: f64) -> Vec2 {
120 map(self.x.get(), self.y.get(), self.width, self.height, x, y)
121 }
122}
123
124fn map(xr: Interval, yr: Interval, w: f64, h: f64, x: f64, y: f64) -> Vec2 {
125 Vec2::new(
126 ((x - xr.min) / xr.len() - 0.5) * w,
127 -((y - yr.min) / yr.len() - 0.5) * h,
128 )
129}
130
131#[derive(Clone)]
141pub struct Axes {
142 node: Node,
143 shared: Rc<Shared>,
144}
145
146impl Axes {
147 #[allow(clippy::new_ret_no_self)]
149 pub fn new() -> AxesBuilder {
150 AxesBuilder {
151 base: BaseProps::default(),
152 x: (-5.0..5.0).into(),
153 y: (-5.0..5.0).into(),
154 width: pct(80),
155 height: pct(60),
156 grid: true,
157 y_axis: true,
158 }
159 }
160
161 pub fn x_range(&self) -> Signal<Interval> {
163 self.shared.x.clone()
164 }
165
166 pub fn y_range(&self) -> Signal<Interval> {
168 self.shared.y.clone()
169 }
170
171 pub fn c2p(&self, x: f64, y: f64) -> Vec2 {
174 self.shared.c2p(x, y)
175 }
176
177 pub fn place(&self, node: impl Into<Node>, x: f64, y: f64) {
180 let node = node.into();
181 node.reparent(self.node);
182 let s = self.shared.clone();
183 node.x().bind(move || s.c2p(x, y).x);
184 let s = self.shared.clone();
185 node.y().bind(move || s.c2p(x, y).y);
186 }
187
188 #[track_caller]
192 pub fn plot(&self, f: impl Fn(f64) -> f64 + 'static) -> Plot {
193 oxframe_2d::add_site(|| self.plot_at(f))
194 }
195
196 fn plot_at(&self, f: impl Fn(f64) -> f64 + 'static) -> Plot {
197 let curve = Rc::new(Curve {
198 f: RefCell::new(Rc::new(f)),
199 from: RefCell::new(None),
200 mix: Signal::new(1.0),
201 });
202 let path = self.node.add(
203 Path::new()
204 .fill(Color::TRANSPARENT)
205 .stroke(Accent)
206 .line_width(4.0)
207 .name("plot"),
208 );
209 let s = self.shared.clone();
210 let c = curve.clone();
211 path.data().bind(move || sample(&s, &|x| c.eval(x)));
212 Plot {
213 path,
214 curve,
215 shared: self.shared.clone(),
216 }
217 }
218
219 #[track_caller]
221 pub fn point_on(&self, plot: &Plot, x: Signal<f64>) -> Circle {
222 oxframe_2d::add_site(|| self.point_on_at(plot, x))
223 }
224
225 fn point_on_at(&self, plot: &Plot, x: Signal<f64>) -> Circle {
226 let dot = self
227 .node
228 .add(Circle::new().size(22.0).fill(Text).name("point"));
229 let (s, c, xs) = (self.shared.clone(), plot.curve.clone(), x.clone());
230 dot.x().bind(move || {
231 let x = xs.get();
232 s.c2p(x, c.eval(x)).x
233 });
234 let (s, c) = (self.shared.clone(), plot.curve.clone());
235 dot.y().bind(move || {
236 let x = x.get();
237 s.c2p(x, c.eval(x)).y
238 });
239 dot
240 }
241
242 #[track_caller]
244 pub fn line_series(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Series {
245 oxframe_2d::add_site(|| self.line_series_at(points))
246 }
247
248 fn line_series_at(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Series {
249 let points: Vec<Signal<Vec2>> = points
250 .into_iter()
251 .map(|(x, y)| Signal::new(Vec2::new(x, y)))
252 .collect();
253 let path = self.node.add(
254 Path::new()
255 .fill(Color::TRANSPARENT)
256 .stroke(Accent)
257 .line_width(4.0)
258 .name("series"),
259 );
260 let (s, pts) = (self.shared.clone(), points.clone());
261 path.data().bind(move || {
262 let mut out = BezPath::new();
263 for (i, p) in pts.iter().enumerate() {
264 let v = p.get();
265 let q = s.c2p(v.x, v.y).to_point();
266 if i == 0 {
267 out.move_to(q);
268 } else {
269 out.line_to(q);
270 }
271 }
272 out
273 });
274 Series { path, points }
275 }
276
277 #[track_caller]
279 pub fn scatter(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Vec<Circle> {
280 oxframe_2d::add_site(|| self.scatter_at(points))
281 }
282
283 fn scatter_at(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Vec<Circle> {
284 points
285 .into_iter()
286 .map(|(x, y)| {
287 let dot = self
288 .node
289 .add(Circle::new().size(16.0).fill(Accent).name("dot"));
290 self.place(dot, x, y);
291 dot
292 })
293 .collect()
294 }
295
296 #[track_caller]
299 pub fn bars(&self, values: impl IntoIterator<Item = f64>) -> Bars {
300 oxframe_2d::add_site(|| self.bars_at(values))
301 }
302
303 fn bars_at(&self, values: impl IntoIterator<Item = f64>) -> Bars {
304 self.shared.categories.set(true);
305 let mut bars = Vec::new();
306 for (i, v) in values.into_iter().enumerate() {
307 let value = Signal::new(v).with_name(format!("bar{}.value", i + 1));
308 let x = i as f64;
309 let rect = self
310 .node
311 .add(Rect::new().fill(Accent).name(format!("bar{}", i + 1)));
312 let s = self.shared.clone();
313 rect.width().bind(move || {
314 let a = s.c2p(x - 0.35, 0.0).x;
315 let b = s.c2p(x + 0.35, 0.0).x;
316 (b - a).abs()
317 });
318 let (s, val2) = (self.shared.clone(), value.clone());
319 rect.height()
320 .bind(move || (s.c2p(x, val2.get()).y - s.c2p(x, 0.0).y).abs());
321 let s2 = self.shared.clone();
322 rect.x().bind(move || s2.c2p(x, 0.0).x);
323 let (s3, val3) = (self.shared.clone(), value.clone());
324 rect.y()
325 .bind(move || (s3.c2p(x, val3.get()).y + s3.c2p(x, 0.0).y) / 2.0);
326 let text = self
327 .node
328 .add(Txt::new().caption().name(format!("bar{}_label", i + 1)));
329 let val4 = value.clone();
330 text.text().bind(move || label(val4.get(), 0.1));
331 let (s4, val5) = (self.shared.clone(), value.clone());
332 text.x().bind(move || s4.c2p(x, 0.0).x);
333 let s5 = self.shared.clone();
334 text.y()
335 .bind(move || s5.c2p(x, val5.get().max(0.0)).y - 28.0);
336 bars.push((rect, value));
337 }
338 Bars {
339 bars,
340 axes: self.clone(),
341 }
342 }
343
344 pub fn node(&self) -> Node {
346 self.node
347 }
348}
349
350impl Deref for Axes {
351 type Target = Node;
352 fn deref(&self) -> &Node {
353 &self.node
354 }
355}
356
357type Func = Rc<dyn Fn(f64) -> f64>;
359
360struct Curve {
361 f: RefCell<Func>,
362 from: RefCell<Option<Func>>,
364 mix: Signal<f64>,
365}
366
367impl Curve {
368 fn eval(&self, x: f64) -> f64 {
369 let y = (self.f.borrow())(x);
370 match &*self.from.borrow() {
371 Some(g) => g(x).lerp(&y, self.mix.get()),
372 None => y,
373 }
374 }
375}
376
377#[derive(Clone)]
380pub struct Plot {
381 path: Path,
382 curve: Rc<Curve>,
383 shared: Rc<Shared>,
384}
385
386impl Plot {
387 pub fn stroke(self, paint: impl IntoPaint) -> Self {
389 self.path.stroke().set(paint.into_paint());
390 self
391 }
392
393 pub fn width(self, px: f64) -> Self {
395 self.path.line_width().set(px);
396 self
397 }
398
399 pub fn to_fn(
402 &self,
403 g: impl Fn(f64) -> f64 + 'static,
404 seconds: f64,
405 ) -> impl std::future::Future<Output = ()> + 'static {
406 let curve = self.curve.clone();
407 let old = curve.f.replace(Rc::new(g));
408 *curve.from.borrow_mut() = Some(old);
409 curve.mix.set(0.0);
410 let tween = curve.mix.to(1.0, seconds);
411 async move {
412 tween.await;
413 *curve.from.borrow_mut() = None;
414 }
415 }
416
417 pub fn eval(&self, x: f64) -> f64 {
419 self.curve.eval(x)
420 }
421
422 pub fn point_at(&self, x: f64) -> Vec2 {
424 self.shared.c2p(x, self.curve.eval(x))
425 }
426}
427
428impl Deref for Plot {
429 type Target = Path;
430 fn deref(&self) -> &Path {
431 &self.path
432 }
433}
434
435pub struct Series {
437 path: Path,
438 points: Vec<Signal<Vec2>>,
439}
440
441impl Series {
442 pub fn point(&self, i: usize) -> Signal<Vec2> {
448 self.points[i].clone()
449 }
450}
451
452impl Deref for Series {
453 type Target = Path;
454 fn deref(&self) -> &Path {
455 &self.path
456 }
457}
458
459pub struct Bars {
461 bars: Vec<(Rect, Signal<f64>)>,
462 axes: Axes,
463}
464
465impl Bars {
466 #[track_caller]
468 pub fn labels(self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
469 oxframe_2d::add_site(|| self.labels_at(names))
470 }
471
472 fn labels_at(self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
473 for (i, name) in names.into_iter().enumerate().take(self.bars.len()) {
474 let txt = self.axes.node.add(
475 Txt::new()
476 .text(name)
477 .caption()
478 .name(format!("bar{}_name", i + 1)),
479 );
480 let s = self.axes.shared.clone();
481 let x = i as f64;
482 txt.x().bind(move || s.c2p(x, 0.0).x);
483 let s = self.axes.shared.clone();
484 txt.y().bind(move || axis_cross(&s).1 + 30.0);
485 }
486 self
487 }
488
489 pub fn value(&self, i: usize) -> Signal<f64> {
495 self.bars[i].1.clone()
496 }
497
498 pub fn bar(&self, i: usize) -> Rect {
504 self.bars[i].0
505 }
506
507 pub fn len(&self) -> usize {
509 self.bars.len()
510 }
511
512 pub fn is_empty(&self) -> bool {
514 self.bars.is_empty()
515 }
516}
517
518fn sample(s: &Shared, f: &dyn Fn(f64) -> f64) -> BezPath {
521 let (xr, yr) = (s.x.get(), s.y.get());
522 let (w, h) = (s.width, s.height);
523 let to_p = |x: f64, y: f64| map(xr, yr, w, h, x, y).to_point();
524 let n = 240usize;
525 let x_at = |i: usize| xr.min + (xr.max - xr.min) * i as f64 / n as f64;
526 let mut pts = Vec::with_capacity(n * 2);
528 let mut prev: Option<(f64, Point)> = None;
529 for i in 0..=n {
530 let x = x_at(i);
531 let p = to_p(x, f(x));
532 match prev {
533 Some(a) => refine(&to_p, f, a, (x, p), 0, h, &mut |q| pts.push(q)),
534 None => pts.push(p),
535 }
536 prev = Some((x, p));
537 }
538 clip(&pts, h / 2.0)
539}
540
541fn clip(pts: &[Point], half: f64) -> BezPath {
544 let inside = |p: Point| p.y.is_finite() && p.y.abs() <= half + 1e-9;
545 let edge = |inn: Point, out: Point| {
547 let bound = if out.y > 0.0 { half } else { -half };
548 let t = ((bound - inn.y) / (out.y - inn.y)).clamp(0.0, 1.0);
549 Point::new(inn.x + (out.x - inn.x) * t, bound)
550 };
551 let mut out = BezPath::new();
552 let mut pen = false;
553 let mut last: Option<Point> = None;
554 for &p in pts {
555 match (last, inside(p)) {
556 (Some(_), true) if pen => out.line_to(p),
557 (Some(a), true) if a.y.is_finite() && !inside(a) => {
558 out.move_to(edge(p, a));
559 out.line_to(p);
560 pen = true;
561 }
562 (_, true) => {
563 out.move_to(p);
564 pen = true;
565 }
566 (Some(a), false) if pen && p.y.is_finite() => {
567 out.line_to(edge(a, p));
568 pen = false;
569 }
570 _ => pen = false,
571 }
572 last = Some(p);
573 }
574 out
575}
576
577fn refine(
581 to_p: &dyn Fn(f64, f64) -> Point,
582 f: &dyn Fn(f64) -> f64,
583 a: (f64, Point),
584 b: (f64, Point),
585 depth: u32,
586 h: f64,
587 emit: &mut dyn FnMut(Point),
588) {
589 let jump = (b.1.y - a.1.y).abs();
590 if !(a.1.y.is_finite() && b.1.y.is_finite()) || jump > h * 0.05 {
591 if depth >= 6 {
592 if jump > h * 0.5 || !b.1.y.is_finite() {
593 emit(Point::new(b.1.x, f64::NAN));
594 }
595 emit(b.1);
596 return;
597 }
598 let mx = (a.0 + b.0) / 2.0;
599 let m = to_p(mx, f(mx));
600 refine(to_p, f, a, (mx, m), depth + 1, h, emit);
601 refine(to_p, f, (mx, m), b, depth + 1, h, emit);
602 return;
603 }
604 emit(b.1);
605}
606
607#[must_use = "a builder does nothing until it is passed to `add`"]
609pub struct AxesBuilder {
610 base: BaseProps,
611 x: Interval,
612 y: Interval,
613 width: Span,
614 height: Span,
615 grid: bool,
616 y_axis: bool,
617}
618
619impl AxesBuilder {
620 pub fn x_range(mut self, r: impl Into<Interval>) -> Self {
622 self.x = r.into();
623 self
624 }
625
626 pub fn y_range(mut self, r: impl Into<Interval>) -> Self {
628 self.y = r.into();
629 self
630 }
631
632 pub fn width(mut self, v: impl Into<Span>) -> Self {
634 self.width = v.into();
635 self
636 }
637
638 pub fn height(mut self, v: impl Into<Span>) -> Self {
640 self.height = v.into();
641 self
642 }
643
644 pub fn no_grid(mut self) -> Self {
646 self.grid = false;
647 self
648 }
649}
650
651fn resolve(span: Span, frame: f64) -> f64 {
652 match span {
653 Span::Px(v) => v,
654 Span::Pct(p) => frame * p / 100.0,
655 }
656}
657
658impl NodeBuilder for AxesBuilder {
659 type Handle = Axes;
660 fn base(&mut self) -> &mut BaseProps {
661 &mut self.base
662 }
663 fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> Axes {
664 let (fw, fh) = g.frame_size();
665 let width = resolve(self.width, fw).max(1.0);
666 let height = if self.y_axis {
667 resolve(self.height, fh).max(1.0)
668 } else {
669 0.0
670 };
671 let base = self.base;
672 let mut group = Group::new().x(base.x).y(base.y).opacity(base.opacity);
673 if let Some(name) = base.name {
674 group = group.name(name);
675 }
676 let root = Node::from_id(parent).add(group);
677 let shared = Rc::new(Shared {
678 x: Signal::new(self.x).with_name("Axes.x_range"),
679 y: Signal::new(self.y).with_name("Axes.y_range"),
680 width,
681 height,
682 y_axis: self.y_axis,
683 categories: Cell::new(false),
684 });
685 let (grid, y_axis) = (self.grid, self.y_axis);
686 let s = shared.clone();
687 root.add(
688 Custom::new(move |ctx, _| draw_frame(ctx, &s, grid, y_axis))
689 .size(width + 60.0, height.max(1.0) + 60.0)
690 .name("frame"),
691 );
692 labels(&root, &shared, true);
693 if y_axis {
694 labels(&root, &shared, false);
695 }
696 Axes {
697 node: *root,
698 shared,
699 }
700 }
701}
702
703fn draw_frame(ctx: &mut oxframe_2d::DrawCtx, s: &Shared, grid: bool, y_axis: bool) {
705 let (xr, yr) = (s.x.get(), s.y.get());
706 let (w, h) = (s.width, s.height);
707 let (hw, hh) = (w / 2.0, h / 2.0);
708 let p = |x: f64, y: f64| map(xr, yr, w, h, x, y);
709 let (ax_x, ax_y) = axis_cross(s);
710 let x_ticks = !s.categories.get();
711 let xs = tick_step(xr.max - xr.min);
712 let ys = tick_step(yr.max - yr.min);
713 if grid && y_axis {
714 for k in tick_indices(xr, xs).filter(|_| x_ticks) {
715 let x = p(k as f64 * xs, 0.0).x;
716 ctx.line((x, -hh), (x, hh)).stroke(Muted, 1.0);
717 }
718 for k in tick_indices(yr, ys) {
719 let y = p(0.0, k as f64 * ys).y;
720 ctx.line((-hw, y), (hw, y)).stroke(Muted, 1.0);
721 }
722 }
723 ctx.line((-hw - 12.0, ax_y), (hw + 18.0, ax_y))
724 .stroke(Text, 3.0);
725 arrow(ctx, Point::new(hw + 30.0, ax_y), Vec2::new(1.0, 0.0));
726 for k in tick_indices(xr, xs).filter(|_| x_ticks) {
727 let x = p(k as f64 * xs, 0.0).x;
728 ctx.line((x, ax_y - 8.0), (x, ax_y + 8.0)).stroke(Text, 2.0);
729 }
730 if y_axis {
731 ctx.line((ax_x, hh + 12.0), (ax_x, -hh - 18.0))
732 .stroke(Text, 3.0);
733 arrow(ctx, Point::new(ax_x, -hh - 30.0), Vec2::new(0.0, -1.0));
734 for k in tick_indices(yr, ys) {
735 let y = p(0.0, k as f64 * ys).y;
736 ctx.line((ax_x - 8.0, y), (ax_x + 8.0, y)).stroke(Text, 2.0);
737 }
738 }
739}
740
741fn arrow(ctx: &mut oxframe_2d::DrawCtx, tip: Point, dir: Vec2) {
742 let side = Vec2::new(-dir.y, dir.x);
743 let mut path = BezPath::new();
744 path.move_to(tip);
745 path.line_to(tip - dir * 16.0 + side * 8.0);
746 path.line_to(tip - dir * 16.0 - side * 8.0);
747 path.close_path();
748 ctx.path(path).fill(Text);
749}
750
751fn labels(root: &Group, s: &Rc<Shared>, horizontal: bool) {
754 for j in 0..LABELS {
755 let txt = if horizontal {
756 Txt::new().caption().fill(Muted).name(format!("x_label{j}"))
757 } else {
758 Txt::new()
760 .caption()
761 .fill(Muted)
762 .anchor((1.0, 0.0))
763 .name(format!("y_label{j}"))
764 };
765 let txt = root.add(txt);
766 let pick = {
768 let s = s.clone();
769 move || -> Option<(f64, f64)> {
770 if horizontal && s.categories.get() {
771 return None;
772 }
773 let r = if horizontal { s.x.get() } else { s.y.get() };
774 let step = tick_step(r.max - r.min);
775 let (cx, cy) = axis_cross_data(&s);
776 let skip = if horizontal {
778 s.y_axis.then_some(cx)
779 } else {
780 Some(cy)
781 };
782 tick_indices(r, step)
783 .find(|k| k.rem_euclid(LABELS as i64) == j as i64)
784 .map(|k| (k as f64 * step, step))
785 .filter(|(v, step)| skip.is_none_or(|c| (v - c).abs() > step * 1e-6))
786 }
787 };
788 let p = pick.clone();
789 txt.text()
790 .bind(move || p().map(|(v, st)| label(v, st)).unwrap_or_default());
791 let p = pick.clone();
792 txt.opacity()
793 .bind(move || if p().is_some() { 1.0 } else { 0.0 });
794 let (p, s2) = (pick.clone(), s.clone());
795 txt.x().bind(move || match p() {
796 Some((v, _)) if horizontal => s2.c2p(v, axis_cross(&s2).1).x,
797 Some(_) => axis_cross(&s2).0 - 14.0,
798 None => 0.0,
799 });
800 let (p, s2) = (pick, s.clone());
801 txt.y().bind(move || match p() {
802 Some(_) if horizontal => axis_cross(&s2).1 + 30.0,
803 Some((v, _)) => s2.c2p(axis_cross_data(&s2).0, v).y,
804 None => 0.0,
805 });
806 }
807}
808
809fn axis_cross(s: &Shared) -> (f64, f64) {
811 let (x, y) = axis_cross_data(s);
812 let p = s.c2p(x, y);
813 (p.x, if s.height > 0.0 { p.y } else { 0.0 })
814}
815
816fn axis_cross_data(s: &Shared) -> (f64, f64) {
818 let (xr, yr) = (s.x.get(), s.y.get());
819 let x = if s.categories.get() {
821 xr.min.min(xr.max)
822 } else {
823 0f64.clamp(xr.min.min(xr.max), xr.min.max(xr.max))
824 };
825 (x, 0f64.clamp(yr.min.min(yr.max), yr.min.max(yr.max)))
826}
827
828pub struct NumberLine;
830
831impl NumberLine {
832 #[allow(clippy::new_ret_no_self)]
835 pub fn new(range: impl Into<Interval>) -> AxesBuilder {
836 AxesBuilder {
837 base: BaseProps::default(),
838 x: range.into(),
839 y: (0.0..1.0).into(),
840 width: pct(80),
841 height: Span::Px(0.0),
842 grid: false,
843 y_axis: false,
844 }
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 #[test]
853 fn steps_are_round_and_give_few_ticks() {
854 assert_eq!(tick_step(10.0), 2.0);
855 assert_eq!(tick_step(1.0), 0.2);
856 assert_eq!(tick_step(100.0), 20.0);
857 let r: Interval = (-5.0..5.0).into();
858 assert_eq!(tick_indices(r, 2.0), -2..3);
859 }
860
861 use oxframe_2d::{SceneGraph, View};
862 use oxframe_core::kurbo::PathEl;
863
864 fn axes(graph: &std::rc::Rc<SceneGraph>) -> Axes {
865 graph.enter(|| {
866 View::current().add(
867 Axes::new()
868 .x_range(-5.0..5.0)
869 .y_range(-2.0..2.0)
870 .width(800.0)
871 .height(400.0),
872 )
873 })
874 }
875
876 #[test]
877 fn tan_has_no_bridge_across_its_gaps_and_stays_in_the_box() {
878 let graph = SceneGraph::new(1920, 1080);
879 let a = axes(&graph);
880 let tan = graph.enter(|| a.plot(f64::tan));
881 let data = graph.enter(|| tan.data().get());
882 let mut last: Option<Point> = None;
883 let mut moves = 0;
884 for el in data.elements() {
885 match *el {
886 PathEl::MoveTo(p) => {
887 moves += 1;
888 last = Some(p);
889 }
890 PathEl::LineTo(p) => {
891 let a = last.unwrap();
892 assert!(p.y.abs() <= 200.0 + 1e-6, "{p:?} leaves the box");
893 assert!(
894 (p.y - a.y).abs() < 200.0,
895 "a line jumps from {a:?} to {p:?}"
896 );
897 last = Some(p);
898 }
899 _ => {}
900 }
901 }
902 assert_eq!(moves, 3);
905 }
906
907 #[test]
908 fn a_label_keeps_its_value_while_the_range_slides() {
909 let graph = SceneGraph::new(1920, 1080);
910 let a = axes(&graph);
911 let read = |graph: &SceneGraph| -> Vec<(String, String)> {
912 graph
913 .inspect_nodes()
914 .into_iter()
915 .filter(|n| n.name.starts_with("x_label"))
916 .map(|n| (n.name, n.text.unwrap_or_default()))
917 .collect()
918 };
919 let before = read(&graph);
920 graph.enter(|| a.x_range().set((-4.8..5.2).into()));
921 let after = read(&graph);
922 for ((name, v), (_, w)) in before.iter().zip(&after) {
923 if !v.is_empty() && !w.is_empty() {
924 assert_eq!(v, w, "{name} changed its value while sliding");
925 }
926 }
927 }
928
929 #[test]
930 fn a_range_change_moves_the_curve() {
931 let graph = SceneGraph::new(1920, 1080);
932 let a = axes(&graph);
933 let sin = graph.enter(|| a.plot(f64::sin));
934 let first = graph.enter(|| sin.data().get());
935 graph.enter(|| a.x_range().set((0.0..10.0).into()));
936 assert_ne!(first, graph.enter(|| sin.data().get()));
937 let p = graph.enter(|| a.c2p(5.0, 0.0));
938 assert!(p.x.abs() < 1e-9 && p.y.abs() < 1e-9, "{p:?}");
939 }
940
941 #[test]
942 fn plots_report_the_line_that_added_them() {
943 let graph = SceneGraph::new(1920, 1080);
944 let a = axes(&graph);
945 graph.enter(|| {
946 let _ = a.plot(f64::sin);
947 });
948 let line = line!() - 2;
949 let plot = graph
950 .inspect_nodes()
951 .into_iter()
952 .find(|n| n.kind == "Path")
953 .unwrap();
954 assert_eq!(
955 plot.source.as_deref(),
956 Some(format!("src/axes.rs:{line}").as_str())
957 );
958 }
959
960 #[test]
961 fn labels_have_the_decimals_the_step_needs() {
962 assert_eq!(label(0.4, 0.2), "0.4");
963 assert_eq!(label(3.0, 1.0), "3");
964 assert_eq!(label(-1e-12, 0.5), "0.0");
965 }
966}