Skip to main content

oxframe_components/
axes.rs

1//! Axes, number lines and plots.
2//!
3//! Data coordinates map into the axes' box. The ranges are signals: when one
4//! animates, the grid, the ticks, the labels, every plot and every placed
5//! node follow it on the same frame. Ticks sit at multiples of a round step
6//! (1, 2 or 5 times a power of ten), so labels slide with the data instead
7//! of jumping between values.
8
9use 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/// A data range, `min` to `max`. Animates with `.to(-10.0..10.0, 1.0)`.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Interval {
23    /// Left or bottom end.
24    pub min: f64,
25    /// Right or top end.
26    pub max: f64,
27}
28
29impl Interval {
30    /// `max - min`, never zero.
31    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    /// Never true: an interval always has a length.
37    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
66/// Labels kept per axis. Ticks beyond this many are drawn without a label.
67const LABELS: usize = 24;
68/// Most ticks an axis aims for.
69const TARGET_TICKS: f64 = 8.0;
70
71/// Round step for `span` that gives at most about [`TARGET_TICKS`] ticks.
72fn 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
83/// Tick indices `k` (value `k * step`) inside `range`.
84fn 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
91/// `value` with as many decimals as `step` needs, and no `-0`.
92fn 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    /// The y axis is drawn.
112    y_axis: bool,
113    /// Bars: x is categories, so the y axis sits at the left edge and the x
114    /// axis has no numeric ticks.
115    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/// A plotting area: a box with ranges, a grid, axes with arrows, ticks and
132/// labels. Derefs to [`Node`], so it moves and fades like any node.
133///
134/// ```ignore
135/// let axes = view.add(Axes::new().x_range(-5.0..5.0).y_range(-2.0..2.0).width(pct(80)));
136/// let sin = axes.plot(|x| x.sin());
137/// sin.draw_on().await;
138/// axes.x_range().to(-10.0..10.0, 1.0).await;
139/// ```
140#[derive(Clone)]
141pub struct Axes {
142    node: Node,
143    shared: Rc<Shared>,
144}
145
146impl Axes {
147    /// Axes over `-5..5` by `-5..5`, 80 % of the frame wide and 60 % high.
148    #[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    /// The horizontal data range. `.to(0.0..20.0, 1.0)` zooms the axes.
162    pub fn x_range(&self) -> Signal<Interval> {
163        self.shared.x.clone()
164    }
165
166    /// The vertical data range.
167    pub fn y_range(&self) -> Signal<Interval> {
168        self.shared.y.clone()
169    }
170
171    /// The scene point (relative to the axes) of data point `(x, y)`, for
172    /// the ranges right now.
173    pub fn c2p(&self, x: f64, y: f64) -> Vec2 {
174        self.shared.c2p(x, y)
175    }
176
177    /// Keeps `node` at data point `(x, y)` while the ranges change. The
178    /// node becomes a child of the axes.
179    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    /// A curve of `f` over the visible x range. Sampled again every frame,
189    /// finer where it bends, and broken where it jumps (no line across the
190    /// gap of `tan`). Accent, 4 px; `.stroke()` and `.line_width()` change it.
191    #[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    /// A dot that sits on `plot` at data x `x` and follows it.
220    #[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    /// A polyline through data points. Each point is a signal of the series.
243    #[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    /// A dot per data point. Returns the dots in order.
278    #[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    /// A bar per value at x = 0, 1, 2, …, growing up from y = 0, with the
297    /// value printed above it. Set the x range to `-0.5..n - 0.5`.
298    #[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    /// The untyped node handle.
345    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
357/// A plotted function, shared by the curve and the dots on it.
358type Func = Rc<dyn Fn(f64) -> f64>;
359
360struct Curve {
361    f: RefCell<Func>,
362    /// The function a `to_fn` tween comes from.
363    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/// A function plot. Derefs to its [`Path`], so `.draw_on()`, `.stroke()`
378/// and `.line_width()` work as on any path.
379#[derive(Clone)]
380pub struct Plot {
381    path: Path,
382    curve: Rc<Curve>,
383    shared: Rc<Shared>,
384}
385
386impl Plot {
387    /// Line color: a theme role such as `Accent`, or a color.
388    pub fn stroke(self, paint: impl IntoPaint) -> Self {
389        self.path.stroke().set(paint.into_paint());
390        self
391    }
392
393    /// Line width in pixels.
394    pub fn width(self, px: f64) -> Self {
395        self.path.line_width().set(px);
396        self
397    }
398
399    /// Morphs the curve into `g` over `seconds`: every x moves from the old
400    /// value to the new one.
401    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    /// The value the curve shows at `x` right now.
418    pub fn eval(&self, x: f64) -> f64 {
419        self.curve.eval(x)
420    }
421
422    /// The scene point of the curve at data `x`.
423    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
435/// A line through data points; see [`Axes::line_series`].
436pub struct Series {
437    path: Path,
438    points: Vec<Signal<Vec2>>,
439}
440
441impl Series {
442    /// Point `i` in data coordinates, as a signal.
443    ///
444    /// # Panics
445    ///
446    /// If the series has fewer than `i + 1` points.
447    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
459/// Bars from [`Axes::bars`].
460pub struct Bars {
461    bars: Vec<(Rect, Signal<f64>)>,
462    axes: Axes,
463}
464
465impl Bars {
466    /// Names under the bars, in order.
467    #[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    /// The value of bar `i`. `.to(8.0, 1.0)` grows it and its label.
490    ///
491    /// # Panics
492    ///
493    /// If there are fewer than `i + 1` bars.
494    pub fn value(&self, i: usize) -> Signal<f64> {
495        self.bars[i].1.clone()
496    }
497
498    /// The rectangle of bar `i`.
499    ///
500    /// # Panics
501    ///
502    /// If there are fewer than `i + 1` bars.
503    pub fn bar(&self, i: usize) -> Rect {
504        self.bars[i].0
505    }
506
507    /// How many bars.
508    pub fn len(&self) -> usize {
509        self.bars.len()
510    }
511
512    /// No values were given.
513    pub fn is_empty(&self) -> bool {
514        self.bars.is_empty()
515    }
516}
517
518/// The curve of `f` in scene coordinates for the current ranges, cut at
519/// the top and bottom of the box.
520fn 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    // Points in order; a non-finite y lifts the pen.
527    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
541/// A polyline through `pts`, kept between `-half` and `half` in y. A
542/// segment that crosses the edge ends exactly on it.
543fn clip(pts: &[Point], half: f64) -> BezPath {
544    let inside = |p: Point| p.y.is_finite() && p.y.abs() <= half + 1e-9;
545    // Where the segment from `inn` (inside) to `out` (outside) meets the edge.
546    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
577/// Emits the points after `a` up to and including `b`, splitting where the
578/// curve bends or jumps. A jump that does not shrink when split is a gap:
579/// `emit` gets a non-finite point there so the pen lifts.
580fn 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/// Builder for [`Axes`].
608#[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    /// Horizontal data range, e.g. `-5.0..5.0`.
621    pub fn x_range(mut self, r: impl Into<Interval>) -> Self {
622        self.x = r.into();
623        self
624    }
625
626    /// Vertical data range.
627    pub fn y_range(mut self, r: impl Into<Interval>) -> Self {
628        self.y = r.into();
629        self
630    }
631
632    /// Box width in pixels, or `pct()` of the frame.
633    pub fn width(mut self, v: impl Into<Span>) -> Self {
634        self.width = v.into();
635        self
636    }
637
638    /// Box height in pixels, or `pct()` of the frame.
639    pub fn height(mut self, v: impl Into<Span>) -> Self {
640        self.height = v.into();
641        self
642    }
643
644    /// Leaves out the grid lines.
645    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
703/// Grid, axis lines with arrows, and tick marks, from the current ranges.
704fn 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
751/// Tick labels for one axis. Label `j` shows the tick whose index is `j`
752/// modulo the pool, so a value keeps its node while the range slides.
753fn 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            // Right edge just left of the y axis, so "-0.5" never crosses it.
759            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        // The tick index this label shows now, if any.
767        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                // Where the other axis crosses, a label would sit on it.
777                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
809/// Where the axes cross, in scene coordinates.
810fn 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
816/// Where the axes cross, in data coordinates: 0 when in range.
817fn axis_cross_data(s: &Shared) -> (f64, f64) {
818    let (xr, yr) = (s.x.get(), s.y.get());
819    // With bars, x is categories and the y axis sits at the left edge.
820    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
828/// A single horizontal axis with ticks, labels and an arrow.
829pub struct NumberLine;
830
831impl NumberLine {
832    /// A number line over `range`, 80 % of the frame wide. It is an
833    /// [`Axes`] without a y axis, so `x_range`, `c2p` and `place` work.
834    #[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        // Poles at ±π/2 and ±3π/2. The outer pieces stay above or below the
903        // box (tan(±5) ≈ ±3.4), so three pieces are drawn.
904        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}