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//!
9//! [`Axes::apply_matrix`] maps the plane through a 2 × 2 matrix: plots,
10//! series, vectors and placed nodes move with it and a transformed grid is
11//! drawn over the original one, as in a linear-algebra explainer.
12
13use std::cell::{Cell, RefCell};
14use std::ops::{Deref, Range};
15use std::rc::Rc;
16
17use oxframe_2d::{
18    Accent, BaseProps, Circle, Custom, Group, IntoPaint, Line, Muted, Node, NodeBuilder, NodeId,
19    Path, Rect, Span, Text, Txt, pct,
20};
21use oxframe_core::kurbo::{BezPath, Point, Vec2};
22use oxframe_core::{Color, Lerp, Signal, Tween, all};
23
24/// A data range, `min` to `max`. Animates with `.to(-10.0..10.0, 1.0)`.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Interval {
27    /// Left or bottom end.
28    pub min: f64,
29    /// Right or top end.
30    pub max: f64,
31}
32
33impl Interval {
34    /// `max - min`, never zero.
35    pub fn len(&self) -> f64 {
36        let d = self.max - self.min;
37        if d.abs() < 1e-12 { 1e-12 } else { d }
38    }
39
40    /// Never true: an interval always has a length.
41    pub fn is_empty(&self) -> bool {
42        false
43    }
44}
45
46impl From<Range<f64>> for Interval {
47    fn from(r: Range<f64>) -> Self {
48        Self {
49            min: r.start,
50            max: r.end,
51        }
52    }
53}
54
55impl From<(f64, f64)> for Interval {
56    fn from((min, max): (f64, f64)) -> Self {
57        Self { min, max }
58    }
59}
60
61impl Lerp for Interval {
62    fn lerp(&self, to: &Self, t: f64) -> Self {
63        Self {
64            min: self.min.lerp(&to.min, t),
65            max: self.max.lerp(&to.max, t),
66        }
67    }
68}
69
70/// Labels kept per axis. Ticks beyond this many are drawn without a label.
71const LABELS: usize = 24;
72/// Most ticks an axis aims for.
73const TARGET_TICKS: f64 = 8.0;
74
75/// Round step for `span` that gives at most about [`TARGET_TICKS`] ticks.
76fn tick_step(span: f64) -> f64 {
77    let raw = span.abs().max(1e-12) / TARGET_TICKS;
78    let mag = 10f64.powf(raw.log10().floor());
79    for k in [1.0, 2.0, 5.0, 10.0] {
80        if k * mag >= raw {
81            return k * mag;
82        }
83    }
84    10.0 * mag
85}
86
87/// Tick indices `k` (value `k * step`) inside `range`.
88fn tick_indices(range: Interval, step: f64) -> Range<i64> {
89    let (lo, hi) = (range.min.min(range.max), range.min.max(range.max));
90    let first = (lo / step - 1e-9).ceil() as i64;
91    let last = (hi / step + 1e-9).floor() as i64;
92    first..last + 1
93}
94
95/// `value` with as many decimals as `step` needs, and no `-0`.
96fn label(value: f64, step: f64) -> String {
97    let decimals = if step >= 1.0 {
98        0
99    } else {
100        (-step.log10().floor()) as usize
101    };
102    let v = if value.abs() < step * 1e-6 {
103        0.0
104    } else {
105        value
106    };
107    format!("{v:.decimals$}")
108}
109
110struct Shared {
111    x: Signal<Interval>,
112    y: Signal<Interval>,
113    width: f64,
114    height: f64,
115    /// The y axis is drawn.
116    y_axis: bool,
117    /// Bars: x is categories, so the y axis sits at the left edge and the x
118    /// axis has no numeric ticks.
119    categories: Cell<bool>,
120    /// Applied to data points before they are mapped into the box.
121    matrix: Signal<Mat2>,
122}
123
124impl Shared {
125    /// Where a data point is drawn: through the matrix, then into the box.
126    fn c2p(&self, x: f64, y: f64) -> Vec2 {
127        let (x, y) = self.matrix.get().apply(x, y);
128        self.fixed(x, y)
129    }
130
131    /// Where a data point sits on the axes themselves (ticks, labels).
132    fn fixed(&self, x: f64, y: f64) -> Vec2 {
133        map(self.x.get(), self.y.get(), self.width, self.height, x, y)
134    }
135}
136
137/// A 2 × 2 matrix, row by row: `Mat2([[a, b], [c, d]])` maps `(x, y)` to
138/// `(a x + b y, c x + d y)`. Animates entry by entry, as Manim does.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Mat2(pub [[f64; 2]; 2]);
141
142impl Mat2 {
143    /// Leaves every point where it is.
144    pub const IDENTITY: Mat2 = Mat2([[1.0, 0.0], [0.0, 1.0]]);
145
146    /// The image of `(x, y)`.
147    pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
148        let [[a, b], [c, d]] = self.0;
149        (a * x + b * y, c * x + d * y)
150    }
151
152    /// `self` after `first`: the matrix product `self · first`.
153    pub fn after(&self, first: &Mat2) -> Mat2 {
154        let ([[a, b], [c, d]], [[e, f], [g, h]]) = (self.0, first.0);
155        Mat2([
156            [a * e + b * g, a * f + b * h],
157            [c * e + d * g, c * f + d * h],
158        ])
159    }
160
161    /// The determinant: how areas scale.
162    pub fn det(&self) -> f64 {
163        let [[a, b], [c, d]] = self.0;
164        a * d - b * c
165    }
166
167    fn inverse(&self) -> Option<Mat2> {
168        let det = self.det();
169        if det.abs() < 1e-9 {
170            return None;
171        }
172        let [[a, b], [c, d]] = self.0;
173        Some(Mat2([[d / det, -b / det], [-c / det, a / det]]))
174    }
175}
176
177impl Default for Mat2 {
178    fn default() -> Self {
179        Self::IDENTITY
180    }
181}
182
183impl From<[[f64; 2]; 2]> for Mat2 {
184    fn from(m: [[f64; 2]; 2]) -> Self {
185        Mat2(m)
186    }
187}
188
189impl Lerp for Mat2 {
190    fn lerp(&self, to: &Self, t: f64) -> Self {
191        let mut out = self.0;
192        for (r, row) in out.iter_mut().enumerate() {
193            for (c, v) in row.iter_mut().enumerate() {
194                *v = v.lerp(&to.0[r][c], t);
195            }
196        }
197        Mat2(out)
198    }
199}
200
201fn map(xr: Interval, yr: Interval, w: f64, h: f64, x: f64, y: f64) -> Vec2 {
202    Vec2::new(
203        ((x - xr.min) / xr.len() - 0.5) * w,
204        -((y - yr.min) / yr.len() - 0.5) * h,
205    )
206}
207
208/// A plotting area: a box with ranges, a grid, axes with arrows, ticks and
209/// labels. Derefs to [`Node`], so it moves and fades like any node.
210///
211/// ```ignore
212/// let axes = view.add(Axes::new().x_range(-5.0..5.0).y_range(-2.0..2.0).width(pct(80)));
213/// let sin = axes.plot(|x| x.sin());
214/// sin.draw_on().await;
215/// axes.x_range().to(-10.0..10.0, 1.0).await;
216/// ```
217#[derive(Clone)]
218pub struct Axes {
219    node: Node,
220    shared: Rc<Shared>,
221}
222
223impl Axes {
224    /// Axes over `-5..5` by `-5..5`, 80 % of the frame wide and 60 % high.
225    #[allow(clippy::new_ret_no_self)]
226    pub fn new() -> AxesBuilder {
227        AxesBuilder {
228            base: BaseProps::default(),
229            x: (-5.0..5.0).into(),
230            y: (-5.0..5.0).into(),
231            width: pct(80),
232            height: pct(60),
233            grid: true,
234            y_axis: true,
235        }
236    }
237
238    /// The horizontal data range. `.to(0.0..20.0, 1.0)` zooms the axes.
239    pub fn x_range(&self) -> Signal<Interval> {
240        self.shared.x.clone()
241    }
242
243    /// The vertical data range.
244    pub fn y_range(&self) -> Signal<Interval> {
245        self.shared.y.clone()
246    }
247
248    /// The scene point (relative to the axes) of data point `(x, y)`, for
249    /// the ranges right now.
250    pub fn c2p(&self, x: f64, y: f64) -> Vec2 {
251        self.shared.c2p(x, y)
252    }
253
254    /// Keeps `node` at data point `(x, y)` while the ranges change. The
255    /// node becomes a child of the axes.
256    pub fn place(&self, node: impl Into<Node>, x: f64, y: f64) {
257        let node = node.into();
258        node.reparent(self.node);
259        let s = self.shared.clone();
260        node.x().bind(move || s.c2p(x, y).x);
261        let s = self.shared.clone();
262        node.y().bind(move || s.c2p(x, y).y);
263    }
264
265    /// A curve of `f` over the visible x range. Sampled again every frame,
266    /// finer where it bends, and broken where it jumps (no line across the
267    /// gap of `tan`). Accent, 4 px; `.stroke()` and `.line_width()` change it.
268    #[track_caller]
269    pub fn plot(&self, f: impl Fn(f64) -> f64 + 'static) -> Plot {
270        oxframe_2d::add_site(|| self.plot_at(f))
271    }
272
273    fn plot_at(&self, f: impl Fn(f64) -> f64 + 'static) -> Plot {
274        let curve = Rc::new(Curve {
275            f: RefCell::new(Rc::new(f)),
276            from: RefCell::new(None),
277            mix: Signal::new(1.0),
278        });
279        let path = self.node.add(
280            Path::new()
281                .fill(Color::TRANSPARENT)
282                .stroke(Accent)
283                .line_width(4.0)
284                .name("plot"),
285        );
286        let s = self.shared.clone();
287        let c = curve.clone();
288        path.data().bind(move || sample(&s, &|x| c.eval(x)));
289        Plot {
290            path,
291            curve,
292            shared: self.shared.clone(),
293        }
294    }
295
296    /// A dot that sits on `plot` at data x `x` and follows it.
297    #[track_caller]
298    pub fn point_on(&self, plot: &Plot, x: Signal<f64>) -> Circle {
299        oxframe_2d::add_site(|| self.point_on_at(plot, x))
300    }
301
302    fn point_on_at(&self, plot: &Plot, x: Signal<f64>) -> Circle {
303        let dot = self
304            .node
305            .add(Circle::new().size(22.0).fill(Text).name("point"));
306        let (s, c, xs) = (self.shared.clone(), plot.curve.clone(), x.clone());
307        dot.x().bind(move || {
308            let x = xs.get();
309            s.c2p(x, c.eval(x)).x
310        });
311        let (s, c) = (self.shared.clone(), plot.curve.clone());
312        dot.y().bind(move || {
313            let x = x.get();
314            s.c2p(x, c.eval(x)).y
315        });
316        dot
317    }
318
319    /// A polyline through data points. Each point is a signal of the series.
320    #[track_caller]
321    pub fn line_series(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Series {
322        oxframe_2d::add_site(|| self.line_series_at(points))
323    }
324
325    fn line_series_at(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Series {
326        let points: Vec<Signal<Vec2>> = points
327            .into_iter()
328            .map(|(x, y)| Signal::new(Vec2::new(x, y)))
329            .collect();
330        let path = self.node.add(
331            Path::new()
332                .fill(Color::TRANSPARENT)
333                .stroke(Accent)
334                .line_width(4.0)
335                .name("series"),
336        );
337        let (s, pts) = (self.shared.clone(), points.clone());
338        path.data().bind(move || {
339            let mut out = BezPath::new();
340            for (i, p) in pts.iter().enumerate() {
341                let v = p.get();
342                let q = s.c2p(v.x, v.y).to_point();
343                if i == 0 {
344                    out.move_to(q);
345                } else {
346                    out.line_to(q);
347                }
348            }
349            out
350        });
351        Series { path, points }
352    }
353
354    /// A dot per data point. Returns the dots in order.
355    #[track_caller]
356    pub fn scatter(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Vec<Circle> {
357        oxframe_2d::add_site(|| self.scatter_at(points))
358    }
359
360    fn scatter_at(&self, points: impl IntoIterator<Item = (f64, f64)>) -> Vec<Circle> {
361        points
362            .into_iter()
363            .map(|(x, y)| {
364                let dot = self
365                    .node
366                    .add(Circle::new().size(16.0).fill(Accent).name("dot"));
367                self.place(dot, x, y);
368                dot
369            })
370            .collect()
371    }
372
373    /// A bar per value at x = 0, 1, 2, …, growing up from y = 0, with the
374    /// value printed above it. Set the x range to `-0.5..n - 0.5`.
375    #[track_caller]
376    pub fn bars(&self, values: impl IntoIterator<Item = f64>) -> Bars {
377        oxframe_2d::add_site(|| self.bars_at(values))
378    }
379
380    fn bars_at(&self, values: impl IntoIterator<Item = f64>) -> Bars {
381        self.shared.categories.set(true);
382        let mut bars = Vec::new();
383        for (i, v) in values.into_iter().enumerate() {
384            let value = Signal::new(v).with_name(format!("bar{}.value", i + 1));
385            let x = i as f64;
386            let rect = self
387                .node
388                .add(Rect::new().fill(Accent).name(format!("bar{}", i + 1)));
389            let s = self.shared.clone();
390            rect.width().bind(move || {
391                let a = s.c2p(x - 0.35, 0.0).x;
392                let b = s.c2p(x + 0.35, 0.0).x;
393                (b - a).abs()
394            });
395            let (s, val2) = (self.shared.clone(), value.clone());
396            rect.height()
397                .bind(move || (s.c2p(x, val2.get()).y - s.c2p(x, 0.0).y).abs());
398            let s2 = self.shared.clone();
399            rect.x().bind(move || s2.c2p(x, 0.0).x);
400            let (s3, val3) = (self.shared.clone(), value.clone());
401            rect.y()
402                .bind(move || (s3.c2p(x, val3.get()).y + s3.c2p(x, 0.0).y) / 2.0);
403            let text = self
404                .node
405                .add(Txt::new().caption().name(format!("bar{}_label", i + 1)));
406            let val4 = value.clone();
407            text.text().bind(move || label(val4.get(), 0.1));
408            let (s4, val5) = (self.shared.clone(), value.clone());
409            text.x().bind(move || s4.c2p(x, 0.0).x);
410            let s5 = self.shared.clone();
411            text.y()
412                .bind(move || s5.c2p(x, val5.get().max(0.0)).y - 28.0);
413            bars.push((rect, value));
414        }
415        Bars {
416            bars,
417            axes: self.clone(),
418        }
419    }
420
421    /// The matrix the plane is mapped through; the identity at first.
422    pub fn matrix(&self) -> Signal<Mat2> {
423        self.shared.matrix.clone()
424    }
425
426    /// Maps the plane through `m` (row by row, `[[a, b], [c, d]]`) over
427    /// `duration` seconds, after any matrix applied before. Plots, series,
428    /// vectors and placed nodes move with it, and the grid deforms. Placed
429    /// nodes keep their own shape; only their position follows.
430    pub fn apply_matrix(&self, m: impl Into<Mat2>, duration: f64) -> Tween<Mat2> {
431        let target = m.into().after(&self.shared.matrix.get());
432        self.shared.matrix.to(target, duration)
433    }
434
435    /// An arrow from the origin to data point `(x, y)`. The tip is two
436    /// signals, [`Vector::x`] and [`Vector::y`], and follows the ranges and
437    /// [`apply_matrix`](Self::apply_matrix). Accent, 6 px.
438    #[track_caller]
439    pub fn vector(&self, x: f64, y: f64) -> Vector {
440        oxframe_2d::add_site(|| self.vector_at(x, y))
441    }
442
443    fn vector_at(&self, x: f64, y: f64) -> Vector {
444        let (xs, ys) = (
445            Signal::new(x).with_name("Vector.x"),
446            Signal::new(y).with_name("Vector.y"),
447        );
448        let line = self.node.add(
449            Line::new()
450                .points([(0.0, 0.0), (0.0, 0.0)])
451                .end_arrow(true)
452                .stroke(Accent)
453                .line_width(6.0)
454                .name("vector"),
455        );
456        let (s, tx, ty) = (self.shared.clone(), xs.clone(), ys.clone());
457        line.points()
458            .bind(move || vec![s.c2p(0.0, 0.0), s.c2p(tx.get(), ty.get())]);
459        Vector {
460            line,
461            x: xs,
462            y: ys,
463            axes: self.clone(),
464        }
465    }
466
467    /// The untyped node handle.
468    pub fn node(&self) -> Node {
469        self.node
470    }
471}
472
473/// An arrow on [`Axes`] from the origin to a data point. Derefs to its
474/// [`Line`], so `.stroke()`, `.draw_on()` and `.fade_out()` work.
475#[derive(Clone)]
476pub struct Vector {
477    line: Line,
478    x: Signal<f64>,
479    y: Signal<f64>,
480    axes: Axes,
481}
482
483impl Vector {
484    /// Data x of the tip.
485    pub fn x(&self) -> Signal<f64> {
486        self.x.clone()
487    }
488
489    /// Data y of the tip.
490    pub fn y(&self) -> Signal<f64> {
491        self.y.clone()
492    }
493
494    /// Moves the tip to data point `(x, y)` over `duration` seconds.
495    pub fn to(
496        &self,
497        x: f64,
498        y: f64,
499        duration: f64,
500    ) -> impl std::future::Future<Output = ()> + 'static {
501        let tweens = [self.x.to(x, duration), self.y.to(y, duration)];
502        async move {
503            all(tweens).await;
504        }
505    }
506
507    /// The color of the arrow.
508    pub fn stroke(self, paint: impl IntoPaint) -> Self {
509        self.line.stroke().set(paint.into_paint());
510        self
511    }
512
513    /// A label just past the tip that follows it, such as `"v"`.
514    pub fn label(&self, text: impl Into<String>) -> Txt {
515        let txt = self
516            .axes
517            .node
518            .add(Txt::new().text(text).body().name("vector_label"));
519        let place = {
520            let (s, x, y) = (self.axes.shared.clone(), self.x.clone(), self.y.clone());
521            move || {
522                let (o, tip) = (s.c2p(0.0, 0.0), s.c2p(x.get(), y.get()));
523                let d = tip - o;
524                let len = d.hypot();
525                let dir = if len > 1e-6 {
526                    d / len
527                } else {
528                    Vec2::new(1.0, 0.0)
529                };
530                // Past the tip and off to the side, toward the top right,
531                // so a vector along an axis keeps its label off the axis
532                // line and away from the tick labels (below and left).
533                let side = Vec2::new(dir.y, -dir.x);
534                let side = if side.x - side.y >= 0.0 { side } else { -side };
535                tip + dir * 22.0 + side * 22.0
536            }
537        };
538        let p = place.clone();
539        txt.x().bind(move || p().x);
540        txt.y().bind(move || place().y);
541        txt
542    }
543
544    /// The arrow's line node.
545    pub fn line(&self) -> Line {
546        self.line
547    }
548}
549
550impl Deref for Vector {
551    type Target = Line;
552    fn deref(&self) -> &Line {
553        &self.line
554    }
555}
556
557impl Deref for Axes {
558    type Target = Node;
559    fn deref(&self) -> &Node {
560        &self.node
561    }
562}
563
564/// A plotted function, shared by the curve and the dots on it.
565type Func = Rc<dyn Fn(f64) -> f64>;
566
567struct Curve {
568    f: RefCell<Func>,
569    /// The function a `to_fn` tween comes from.
570    from: RefCell<Option<Func>>,
571    mix: Signal<f64>,
572}
573
574impl Curve {
575    fn eval(&self, x: f64) -> f64 {
576        let y = (self.f.borrow())(x);
577        match &*self.from.borrow() {
578            Some(g) => g(x).lerp(&y, self.mix.get()),
579            None => y,
580        }
581    }
582}
583
584/// A function plot. Derefs to its [`Path`], so `.draw_on()`, `.stroke()`
585/// and `.line_width()` work as on any path.
586#[derive(Clone)]
587pub struct Plot {
588    path: Path,
589    curve: Rc<Curve>,
590    shared: Rc<Shared>,
591}
592
593impl Plot {
594    /// Line color: a theme role such as `Accent`, or a color.
595    pub fn stroke(self, paint: impl IntoPaint) -> Self {
596        self.path.stroke().set(paint.into_paint());
597        self
598    }
599
600    /// Line width in pixels.
601    pub fn width(self, px: f64) -> Self {
602        self.path.line_width().set(px);
603        self
604    }
605
606    /// Morphs the curve into `g` over `seconds`: every x moves from the old
607    /// value to the new one.
608    pub fn to_fn(
609        &self,
610        g: impl Fn(f64) -> f64 + 'static,
611        seconds: f64,
612    ) -> impl std::future::Future<Output = ()> + 'static {
613        let curve = self.curve.clone();
614        let old = curve.f.replace(Rc::new(g));
615        *curve.from.borrow_mut() = Some(old);
616        curve.mix.set(0.0);
617        let tween = curve.mix.to(1.0, seconds);
618        async move {
619            tween.await;
620            *curve.from.borrow_mut() = None;
621        }
622    }
623
624    /// The value the curve shows at `x` right now.
625    pub fn eval(&self, x: f64) -> f64 {
626        self.curve.eval(x)
627    }
628
629    /// The scene point of the curve at data `x`.
630    pub fn point_at(&self, x: f64) -> Vec2 {
631        self.shared.c2p(x, self.curve.eval(x))
632    }
633}
634
635impl Deref for Plot {
636    type Target = Path;
637    fn deref(&self) -> &Path {
638        &self.path
639    }
640}
641
642/// A line through data points; see [`Axes::line_series`].
643pub struct Series {
644    path: Path,
645    points: Vec<Signal<Vec2>>,
646}
647
648impl Series {
649    /// Point `i` in data coordinates, as a signal.
650    ///
651    /// # Panics
652    ///
653    /// If the series has fewer than `i + 1` points.
654    pub fn point(&self, i: usize) -> Signal<Vec2> {
655        self.points[i].clone()
656    }
657}
658
659impl Deref for Series {
660    type Target = Path;
661    fn deref(&self) -> &Path {
662        &self.path
663    }
664}
665
666/// Bars from [`Axes::bars`].
667pub struct Bars {
668    bars: Vec<(Rect, Signal<f64>)>,
669    axes: Axes,
670}
671
672impl Bars {
673    /// Names under the bars, in order.
674    #[track_caller]
675    pub fn labels(self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
676        oxframe_2d::add_site(|| self.labels_at(names))
677    }
678
679    fn labels_at(self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
680        for (i, name) in names.into_iter().enumerate().take(self.bars.len()) {
681            let txt = self.axes.node.add(
682                Txt::new()
683                    .text(name)
684                    .caption()
685                    .name(format!("bar{}_name", i + 1)),
686            );
687            let s = self.axes.shared.clone();
688            let x = i as f64;
689            txt.x().bind(move || s.c2p(x, 0.0).x);
690            let s = self.axes.shared.clone();
691            txt.y().bind(move || axis_cross(&s).1 + 30.0);
692        }
693        self
694    }
695
696    /// The value of bar `i`. `.to(8.0, 1.0)` grows it and its label.
697    ///
698    /// # Panics
699    ///
700    /// If there are fewer than `i + 1` bars.
701    pub fn value(&self, i: usize) -> Signal<f64> {
702        self.bars[i].1.clone()
703    }
704
705    /// The rectangle of bar `i`.
706    ///
707    /// # Panics
708    ///
709    /// If there are fewer than `i + 1` bars.
710    pub fn bar(&self, i: usize) -> Rect {
711        self.bars[i].0
712    }
713
714    /// How many bars.
715    pub fn len(&self) -> usize {
716        self.bars.len()
717    }
718
719    /// No values were given.
720    pub fn is_empty(&self) -> bool {
721        self.bars.is_empty()
722    }
723}
724
725/// The curve of `f` in scene coordinates for the current ranges, cut at
726/// the top and bottom of the box.
727fn sample(s: &Shared, f: &dyn Fn(f64) -> f64) -> BezPath {
728    let (xr, yr) = (s.x.get(), s.y.get());
729    let (w, h) = (s.width, s.height);
730    let to_p = |x: f64, y: f64| map(xr, yr, w, h, x, y).to_point();
731    let n = 240usize;
732    let x_at = |i: usize| xr.min + (xr.max - xr.min) * i as f64 / n as f64;
733    // Points in order; a non-finite y lifts the pen.
734    let mut pts = Vec::with_capacity(n * 2);
735    let mut prev: Option<(f64, Point)> = None;
736    for i in 0..=n {
737        let x = x_at(i);
738        let p = to_p(x, f(x));
739        match prev {
740            Some(a) => refine(&to_p, f, a, (x, p), 0, h, &mut |q| pts.push(q)),
741            None => pts.push(p),
742        }
743        prev = Some((x, p));
744    }
745    clip(&pts, h / 2.0)
746}
747
748/// A polyline through `pts`, kept between `-half` and `half` in y. A
749/// segment that crosses the edge ends exactly on it.
750fn clip(pts: &[Point], half: f64) -> BezPath {
751    let inside = |p: Point| p.y.is_finite() && p.y.abs() <= half + 1e-9;
752    // Where the segment from `inn` (inside) to `out` (outside) meets the edge.
753    let edge = |inn: Point, out: Point| {
754        let bound = if out.y > 0.0 { half } else { -half };
755        let t = ((bound - inn.y) / (out.y - inn.y)).clamp(0.0, 1.0);
756        Point::new(inn.x + (out.x - inn.x) * t, bound)
757    };
758    let mut out = BezPath::new();
759    let mut pen = false;
760    let mut last: Option<Point> = None;
761    for &p in pts {
762        match (last, inside(p)) {
763            (Some(_), true) if pen => out.line_to(p),
764            (Some(a), true) if a.y.is_finite() && !inside(a) => {
765                out.move_to(edge(p, a));
766                out.line_to(p);
767                pen = true;
768            }
769            (_, true) => {
770                out.move_to(p);
771                pen = true;
772            }
773            (Some(a), false) if pen && p.y.is_finite() => {
774                out.line_to(edge(a, p));
775                pen = false;
776            }
777            _ => pen = false,
778        }
779        last = Some(p);
780    }
781    out
782}
783
784/// Emits the points after `a` up to and including `b`, splitting where the
785/// curve bends or jumps. A jump that does not shrink when split is a gap:
786/// `emit` gets a non-finite point there so the pen lifts.
787fn refine(
788    to_p: &dyn Fn(f64, f64) -> Point,
789    f: &dyn Fn(f64) -> f64,
790    a: (f64, Point),
791    b: (f64, Point),
792    depth: u32,
793    h: f64,
794    emit: &mut dyn FnMut(Point),
795) {
796    let jump = (b.1.y - a.1.y).abs();
797    if !(a.1.y.is_finite() && b.1.y.is_finite()) || jump > h * 0.05 {
798        if depth >= 6 {
799            if jump > h * 0.5 || !b.1.y.is_finite() {
800                emit(Point::new(b.1.x, f64::NAN));
801            }
802            emit(b.1);
803            return;
804        }
805        let mx = (a.0 + b.0) / 2.0;
806        let m = to_p(mx, f(mx));
807        refine(to_p, f, a, (mx, m), depth + 1, h, emit);
808        refine(to_p, f, (mx, m), b, depth + 1, h, emit);
809        return;
810    }
811    emit(b.1);
812}
813
814/// Builder for [`Axes`].
815#[must_use = "a builder does nothing until it is passed to `add`"]
816pub struct AxesBuilder {
817    base: BaseProps,
818    x: Interval,
819    y: Interval,
820    width: Span,
821    height: Span,
822    grid: bool,
823    y_axis: bool,
824}
825
826impl AxesBuilder {
827    /// Horizontal data range, e.g. `-5.0..5.0`.
828    pub fn x_range(mut self, r: impl Into<Interval>) -> Self {
829        self.x = r.into();
830        self
831    }
832
833    /// Vertical data range.
834    pub fn y_range(mut self, r: impl Into<Interval>) -> Self {
835        self.y = r.into();
836        self
837    }
838
839    /// Box width in pixels, or `pct()` of the frame.
840    pub fn width(mut self, v: impl Into<Span>) -> Self {
841        self.width = v.into();
842        self
843    }
844
845    /// Box height in pixels, or `pct()` of the frame.
846    pub fn height(mut self, v: impl Into<Span>) -> Self {
847        self.height = v.into();
848        self
849    }
850
851    /// Leaves out the grid lines.
852    pub fn no_grid(mut self) -> Self {
853        self.grid = false;
854        self
855    }
856}
857
858fn resolve(span: Span, frame: f64) -> f64 {
859    match span {
860        Span::Px(v) => v,
861        Span::Pct(p) => frame * p / 100.0,
862    }
863}
864
865impl NodeBuilder for AxesBuilder {
866    type Handle = Axes;
867    fn base(&mut self) -> &mut BaseProps {
868        &mut self.base
869    }
870    fn build(self, g: &oxframe_2d::SceneGraph, parent: NodeId) -> Axes {
871        let (fw, fh) = g.frame_size();
872        let width = resolve(self.width, fw).max(1.0);
873        let height = if self.y_axis {
874            resolve(self.height, fh).max(1.0)
875        } else {
876            0.0
877        };
878        let base = self.base;
879        let mut group = Group::new().x(base.x).y(base.y).opacity(base.opacity);
880        if let Some(name) = base.name {
881            group = group.name(name);
882        }
883        let root = Node::from_id(parent).add(group);
884        let shared = Rc::new(Shared {
885            x: Signal::new(self.x).with_name("Axes.x_range"),
886            y: Signal::new(self.y).with_name("Axes.y_range"),
887            width,
888            height,
889            y_axis: self.y_axis,
890            categories: Cell::new(false),
891            matrix: Signal::new(Mat2::IDENTITY).with_name("Axes.matrix"),
892        });
893        let (grid, y_axis) = (self.grid, self.y_axis);
894        let s = shared.clone();
895        root.add(
896            Custom::new(move |ctx, _| draw_frame(ctx, &s, grid, y_axis))
897                .size(width + 60.0, height.max(1.0) + 60.0)
898                .name("frame"),
899        );
900        labels(&root, &shared, true);
901        if y_axis {
902            labels(&root, &shared, false);
903        }
904        Axes {
905            node: *root,
906            shared,
907        }
908    }
909}
910
911/// Grid, axis lines with arrows, and tick marks, from the current ranges.
912fn draw_frame(ctx: &mut oxframe_2d::DrawCtx, s: &Shared, grid: bool, y_axis: bool) {
913    let (xr, yr) = (s.x.get(), s.y.get());
914    let (w, h) = (s.width, s.height);
915    let (hw, hh) = (w / 2.0, h / 2.0);
916    let p = |x: f64, y: f64| map(xr, yr, w, h, x, y);
917    let (ax_x, ax_y) = axis_cross(s);
918    let x_ticks = !s.categories.get();
919    let xs = tick_step(xr.max - xr.min);
920    let ys = tick_step(yr.max - yr.min);
921    if grid && y_axis {
922        for k in tick_indices(xr, xs).filter(|_| x_ticks) {
923            let x = p(k as f64 * xs, 0.0).x;
924            ctx.line((x, -hh), (x, hh)).stroke(Muted, 1.0);
925        }
926        for k in tick_indices(yr, ys) {
927            let y = p(0.0, k as f64 * ys).y;
928            ctx.line((-hw, y), (hw, y)).stroke(Muted, 1.0);
929        }
930        transformed_grid(ctx, s, xs, ys);
931    }
932    ctx.line((-hw - 12.0, ax_y), (hw + 18.0, ax_y))
933        .stroke(Text, 3.0);
934    arrow(ctx, Point::new(hw + 30.0, ax_y), Vec2::new(1.0, 0.0));
935    for k in tick_indices(xr, xs).filter(|_| x_ticks) {
936        let x = p(k as f64 * xs, 0.0).x;
937        ctx.line((x, ax_y - 8.0), (x, ax_y + 8.0)).stroke(Text, 2.0);
938    }
939    if y_axis {
940        ctx.line((ax_x, hh + 12.0), (ax_x, -hh - 18.0))
941            .stroke(Text, 3.0);
942        arrow(ctx, Point::new(ax_x, -hh - 30.0), Vec2::new(0.0, -1.0));
943        for k in tick_indices(yr, ys) {
944            let y = p(0.0, k as f64 * ys).y;
945            ctx.line((ax_x - 8.0, y), (ax_x + 8.0, y)).stroke(Text, 2.0);
946        }
947    }
948}
949
950/// The grid lines through the matrix, clipped to the box. Nothing while the
951/// matrix is the identity, so plain axes draw as before.
952fn transformed_grid(ctx: &mut oxframe_2d::DrawCtx, s: &Shared, xs: f64, ys: f64) {
953    let m = s.matrix.get();
954    if m == Mat2::IDENTITY {
955        return;
956    }
957    let (xr, yr) = (s.x.get(), s.y.get());
958    let (hw, hh) = (s.width / 2.0, s.height / 2.0);
959    // The data rectangle whose image covers the box: the box corners pulled
960    // back through the matrix. A singular matrix squashes the plane onto a
961    // line; the original range is drawn then.
962    let corners = [
963        (xr.min, yr.min),
964        (xr.min, yr.max),
965        (xr.max, yr.min),
966        (xr.max, yr.max),
967    ];
968    let (mut x0, mut x1, mut y0, mut y1) = (xr.min, xr.max, yr.min, yr.max);
969    if let Some(inv) = m.inverse() {
970        for (x, y) in corners {
971            let (u, v) = inv.apply(x, y);
972            x0 = x0.min(u);
973            x1 = x1.max(u);
974            y0 = y0.min(v);
975            y1 = y1.max(v);
976        }
977    }
978    // A sheared grid can need many lines; keep it bounded.
979    let (x0, x1) = (x0.max(xr.min - 40.0 * xs), x1.min(xr.max + 40.0 * xs));
980    let (y0, y1) = (y0.max(yr.min - 40.0 * ys), y1.min(yr.max + 40.0 * ys));
981    let paint = Accent.into_paint().with_alpha(0.55);
982    let mut segment = |a: (f64, f64), b: (f64, f64), width: f64| {
983        if let Some((p, q)) = clip_to_box(s.c2p(a.0, a.1), s.c2p(b.0, b.1), hw, hh) {
984            ctx.line(p, q).stroke(paint, width);
985        }
986    };
987    for k in (x0 / xs).ceil() as i64..=(x1 / xs).floor() as i64 {
988        let x = k as f64 * xs;
989        segment((x, y0), (x, y1), if k == 0 { 3.0 } else { 1.5 });
990    }
991    for k in (y0 / ys).ceil() as i64..=(y1 / ys).floor() as i64 {
992        let y = k as f64 * ys;
993        segment((x0, y), (x1, y), if k == 0 { 3.0 } else { 1.5 });
994    }
995}
996
997/// The part of segment `a`–`b` inside the box `±hw` × `±hh` (Liang–Barsky).
998fn clip_to_box(a: Vec2, b: Vec2, hw: f64, hh: f64) -> Option<((f64, f64), (f64, f64))> {
999    let d = b - a;
1000    let (mut t0, mut t1) = (0.0f64, 1.0f64);
1001    for (p, q) in [
1002        (-d.x, a.x + hw),
1003        (d.x, hw - a.x),
1004        (-d.y, a.y + hh),
1005        (d.y, hh - a.y),
1006    ] {
1007        if p.abs() < 1e-12 {
1008            if q < 0.0 {
1009                return None;
1010            }
1011            continue;
1012        }
1013        let t = q / p;
1014        if p < 0.0 {
1015            t0 = t0.max(t);
1016        } else {
1017            t1 = t1.min(t);
1018        }
1019        if t0 > t1 {
1020            return None;
1021        }
1022    }
1023    let p = a + d * t0;
1024    let q = a + d * t1;
1025    Some(((p.x, p.y), (q.x, q.y)))
1026}
1027
1028fn arrow(ctx: &mut oxframe_2d::DrawCtx, tip: Point, dir: Vec2) {
1029    let side = Vec2::new(-dir.y, dir.x);
1030    let mut path = BezPath::new();
1031    path.move_to(tip);
1032    path.line_to(tip - dir * 16.0 + side * 8.0);
1033    path.line_to(tip - dir * 16.0 - side * 8.0);
1034    path.close_path();
1035    ctx.path(path).fill(Text);
1036}
1037
1038/// Tick labels for one axis. Label `j` shows the tick whose index is `j`
1039/// modulo the pool, so a value keeps its node while the range slides.
1040fn labels(root: &Group, s: &Rc<Shared>, horizontal: bool) {
1041    for j in 0..LABELS {
1042        let txt = if horizontal {
1043            Txt::new().caption().fill(Muted).name(format!("x_label{j}"))
1044        } else {
1045            // Right edge just left of the y axis, so "-0.5" never crosses it.
1046            Txt::new()
1047                .caption()
1048                .fill(Muted)
1049                .anchor((1.0, 0.0))
1050                .name(format!("y_label{j}"))
1051        };
1052        let txt = root.add(txt);
1053        // The tick index this label shows now, if any.
1054        let pick = {
1055            let s = s.clone();
1056            move || -> Option<(f64, f64)> {
1057                if horizontal && s.categories.get() {
1058                    return None;
1059                }
1060                let r = if horizontal { s.x.get() } else { s.y.get() };
1061                let step = tick_step(r.max - r.min);
1062                let (cx, cy) = axis_cross_data(&s);
1063                // Where the other axis crosses, a label would sit on it.
1064                let skip = if horizontal {
1065                    s.y_axis.then_some(cx)
1066                } else {
1067                    Some(cy)
1068                };
1069                tick_indices(r, step)
1070                    .find(|k| k.rem_euclid(LABELS as i64) == j as i64)
1071                    .map(|k| (k as f64 * step, step))
1072                    .filter(|(v, step)| skip.is_none_or(|c| (v - c).abs() > step * 1e-6))
1073            }
1074        };
1075        let p = pick.clone();
1076        txt.text()
1077            .bind(move || p().map(|(v, st)| label(v, st)).unwrap_or_default());
1078        let p = pick.clone();
1079        txt.opacity()
1080            .bind(move || if p().is_some() { 1.0 } else { 0.0 });
1081        let (p, s2) = (pick.clone(), s.clone());
1082        txt.x().bind(move || match p() {
1083            Some((v, _)) if horizontal => s2.fixed(v, axis_cross(&s2).1).x,
1084            Some(_) => axis_cross(&s2).0 - 14.0,
1085            None => 0.0,
1086        });
1087        let (p, s2) = (pick, s.clone());
1088        txt.y().bind(move || match p() {
1089            Some(_) if horizontal => axis_cross(&s2).1 + 30.0,
1090            Some((v, _)) => s2.fixed(axis_cross_data(&s2).0, v).y,
1091            None => 0.0,
1092        });
1093    }
1094}
1095
1096/// Where the axes cross, in scene coordinates.
1097fn axis_cross(s: &Shared) -> (f64, f64) {
1098    let (x, y) = axis_cross_data(s);
1099    let p = s.fixed(x, y);
1100    (p.x, if s.height > 0.0 { p.y } else { 0.0 })
1101}
1102
1103/// Where the axes cross, in data coordinates: 0 when in range.
1104fn axis_cross_data(s: &Shared) -> (f64, f64) {
1105    let (xr, yr) = (s.x.get(), s.y.get());
1106    // With bars, x is categories and the y axis sits at the left edge.
1107    let x = if s.categories.get() {
1108        xr.min.min(xr.max)
1109    } else {
1110        0f64.clamp(xr.min.min(xr.max), xr.min.max(xr.max))
1111    };
1112    (x, 0f64.clamp(yr.min.min(yr.max), yr.min.max(yr.max)))
1113}
1114
1115/// A single horizontal axis with ticks, labels and an arrow.
1116pub struct NumberLine;
1117
1118impl NumberLine {
1119    /// A number line over `range`, 80 % of the frame wide. It is an
1120    /// [`Axes`] without a y axis, so `x_range`, `c2p` and `place` work.
1121    #[allow(clippy::new_ret_no_self)]
1122    pub fn new(range: impl Into<Interval>) -> AxesBuilder {
1123        AxesBuilder {
1124            base: BaseProps::default(),
1125            x: range.into(),
1126            y: (0.0..1.0).into(),
1127            width: pct(80),
1128            height: Span::Px(0.0),
1129            grid: false,
1130            y_axis: false,
1131        }
1132    }
1133}
1134
1135into_node! {
1136    Axes => |c| c.node;
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    #[test]
1144    fn steps_are_round_and_give_few_ticks() {
1145        assert_eq!(tick_step(10.0), 2.0);
1146        assert_eq!(tick_step(1.0), 0.2);
1147        assert_eq!(tick_step(100.0), 20.0);
1148        let r: Interval = (-5.0..5.0).into();
1149        assert_eq!(tick_indices(r, 2.0), -2..3);
1150    }
1151
1152    use oxframe_2d::{SceneGraph, View};
1153    use oxframe_core::kurbo::PathEl;
1154
1155    fn axes(graph: &std::rc::Rc<SceneGraph>) -> Axes {
1156        graph.enter(|| {
1157            View::current().add(
1158                Axes::new()
1159                    .x_range(-5.0..5.0)
1160                    .y_range(-2.0..2.0)
1161                    .width(800.0)
1162                    .height(400.0),
1163            )
1164        })
1165    }
1166
1167    #[test]
1168    fn a_matrix_moves_vectors_and_placed_nodes_but_not_the_ticks() {
1169        let near = |a: Vec2, b: Vec2| assert!((a - b).hypot() < 1e-9, "{a:?} != {b:?}");
1170        let graph = SceneGraph::new(1920, 1080);
1171        let axes = axes(&graph);
1172        graph.enter(|| {
1173            // 800 px over 10 units, 400 px over 4: 80 and 100 px per unit.
1174            let v = axes.vector(1.0, 1.0);
1175            let dot = View::current().add(Circle::new().size(10.0));
1176            axes.place(dot, 2.0, 0.0);
1177            near(v.points().get()[1], Vec2::new(80.0, -100.0));
1178            axes.matrix().set(Mat2([[1.0, 1.0], [0.0, 1.0]]));
1179            // Shear: (1, 1) → (2, 1), (2, 0) stays.
1180            near(v.points().get()[1], Vec2::new(160.0, -100.0));
1181            assert!((dot.x().get() - 160.0).abs() < 1e-9);
1182            near(axes.c2p(1.0, 1.0), Vec2::new(160.0, -100.0));
1183            near(axes.shared.fixed(1.0, 1.0), Vec2::new(80.0, -100.0));
1184            v.x().set(-1.0);
1185            near(v.points().get()[1], Vec2::new(0.0, -100.0));
1186        });
1187    }
1188
1189    #[test]
1190    fn matrices_compose_and_animate_entry_by_entry() {
1191        let shear = Mat2([[1.0, 1.0], [0.0, 1.0]]);
1192        let rot = Mat2([[0.0, -1.0], [1.0, 0.0]]);
1193        assert_eq!(rot.after(&shear), Mat2([[0.0, -1.0], [1.0, 1.0]]));
1194        assert_eq!(rot.det(), 1.0);
1195        assert_eq!(
1196            Mat2::IDENTITY.lerp(&shear, 0.5),
1197            Mat2([[1.0, 0.5], [0.0, 1.0]])
1198        );
1199        let inv = shear.inverse().unwrap();
1200        assert_eq!(inv.after(&shear), Mat2::IDENTITY);
1201        assert!(Mat2([[1.0, 2.0], [2.0, 4.0]]).inverse().is_none());
1202    }
1203
1204    #[test]
1205    fn grid_lines_are_clipped_to_the_box() {
1206        let inside = clip_to_box(Vec2::new(-10.0, 0.0), Vec2::new(10.0, 0.0), 50.0, 50.0);
1207        assert_eq!(inside, Some(((-10.0, 0.0), (10.0, 0.0))));
1208        let across = clip_to_box(Vec2::new(-100.0, 0.0), Vec2::new(100.0, 0.0), 50.0, 50.0);
1209        assert_eq!(across, Some(((-50.0, 0.0), (50.0, 0.0))));
1210        assert_eq!(
1211            clip_to_box(Vec2::new(-100.0, 80.0), Vec2::new(100.0, 80.0), 50.0, 50.0),
1212            None
1213        );
1214    }
1215
1216    #[test]
1217    fn tan_has_no_bridge_across_its_gaps_and_stays_in_the_box() {
1218        let graph = SceneGraph::new(1920, 1080);
1219        let a = axes(&graph);
1220        let tan = graph.enter(|| a.plot(f64::tan));
1221        let data = graph.enter(|| tan.data().get());
1222        let mut last: Option<Point> = None;
1223        let mut moves = 0;
1224        for el in data.elements() {
1225            match *el {
1226                PathEl::MoveTo(p) => {
1227                    moves += 1;
1228                    last = Some(p);
1229                }
1230                PathEl::LineTo(p) => {
1231                    let a = last.unwrap();
1232                    assert!(p.y.abs() <= 200.0 + 1e-6, "{p:?} leaves the box");
1233                    assert!(
1234                        (p.y - a.y).abs() < 200.0,
1235                        "a line jumps from {a:?} to {p:?}"
1236                    );
1237                    last = Some(p);
1238                }
1239                _ => {}
1240            }
1241        }
1242        // Poles at ±π/2 and ±3π/2. The outer pieces stay above or below the
1243        // box (tan(±5) ≈ ±3.4), so three pieces are drawn.
1244        assert_eq!(moves, 3);
1245    }
1246
1247    #[test]
1248    fn a_label_keeps_its_value_while_the_range_slides() {
1249        let graph = SceneGraph::new(1920, 1080);
1250        let a = axes(&graph);
1251        let read = |graph: &SceneGraph| -> Vec<(String, String)> {
1252            graph
1253                .inspect_nodes()
1254                .into_iter()
1255                .filter(|n| n.name.starts_with("x_label"))
1256                .map(|n| (n.name, n.text.unwrap_or_default()))
1257                .collect()
1258        };
1259        let before = read(&graph);
1260        graph.enter(|| a.x_range().set(-4.8..5.2));
1261        let after = read(&graph);
1262        for ((name, v), (_, w)) in before.iter().zip(&after) {
1263            if !v.is_empty() && !w.is_empty() {
1264                assert_eq!(v, w, "{name} changed its value while sliding");
1265            }
1266        }
1267    }
1268
1269    #[test]
1270    fn a_range_change_moves_the_curve() {
1271        let graph = SceneGraph::new(1920, 1080);
1272        let a = axes(&graph);
1273        let sin = graph.enter(|| a.plot(f64::sin));
1274        let first = graph.enter(|| sin.data().get());
1275        graph.enter(|| a.x_range().set(0.0..10.0));
1276        assert_ne!(first, graph.enter(|| sin.data().get()));
1277        let p = graph.enter(|| a.c2p(5.0, 0.0));
1278        assert!(p.x.abs() < 1e-9 && p.y.abs() < 1e-9, "{p:?}");
1279    }
1280
1281    #[test]
1282    fn plots_report_the_line_that_added_them() {
1283        let graph = SceneGraph::new(1920, 1080);
1284        let a = axes(&graph);
1285        graph.enter(|| {
1286            let _ = a.plot(f64::sin);
1287        });
1288        let line = line!() - 2;
1289        let plot = graph
1290            .inspect_nodes()
1291            .into_iter()
1292            .find(|n| n.kind == "Path")
1293            .unwrap();
1294        assert_eq!(
1295            plot.source.as_deref(),
1296            Some(format!("src/axes.rs:{line}").as_str())
1297        );
1298    }
1299
1300    #[test]
1301    fn labels_have_the_decimals_the_step_needs() {
1302        assert_eq!(label(0.4, 0.2), "0.4");
1303        assert_eq!(label(3.0, 1.0), "3");
1304        assert_eq!(label(-1e-12, 0.5), "0.0");
1305    }
1306}