facett-core 0.1.12

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Shared pan/zoom-in-time axis** (TIME-1) — the *one* time-window model behind
//! every time-laid-out facet: gantt bars, a CFD's day columns, a calendar's weeks,
//! a timeline's events. It is the temporal sibling of [`nav::Navigable`](crate::nav):
//! where `Navigable` pans/zooms 2-D *space*, [`TimeAxis`] pans/zooms a 1-D *time*
//! window — and several facets can **link** to one axis (a shared time-scrubber
//! scrolls them together) via [`sync_from`](TimeAxis::sync_from).
//!
//! Like the other primitives it is **engine-agnostic** (no egui, no rendering): a
//! serializable window with pure pan/zoom math, so the arithmetic is a *tested*
//! property (FC-7). Time is carried as an `f64` in whatever unit the host chooses
//! (epoch seconds, days, ms) — the axis only ever does affine arithmetic on it, so
//! it stays unit-agnostic. It follows the Elm idiom ([`crate::elm`]): one `Model`,
//! a [`TimeMsg`] input surface, mutation only via [`update`](TimeAxis::update),
//! and a window change surfaced **as data** ([`TimeEffect::WindowChanged`]) so a
//! host can propagate it to linked axes. It is a primitive *used by* facets, so —
//! like `Navigable` — it does not itself `impl Facet`.

use serde::{Deserialize, Serialize};

/// Side work as data (FC-8): the visible window moved. A host relays it to any
/// linked axes (the "one scrubber drives several facets" case).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeEffect {
    /// The window is now `[start, end]`.
    WindowChanged { start: f64, end: f64 },
}

/// Every input the axis accepts (FC-2) — the only legal way to mutate a
/// [`TimeAxis`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeMsg {
    /// Pan by a delta in axis units (positive = window slides later).
    Pan(f64),
    /// Zoom by `factor` (>1 zooms *in* / narrows the window) keeping the time at
    /// `pivot` (in axis units) fixed on screen.
    Zoom { factor: f64, pivot: f64 },
    /// Snap the window to exactly `[start, end]` (fit-to-range).
    Fit { start: f64, end: f64 },
    /// Widen the clamp bounds the window may pan/zoom within.
    SetBounds { min: f64, max: f64 },
}

/// The shared time window (TIME-1). `[start, end]` is what's visible; `[min, max]`
/// is the hard clamp (the data's full extent); `min_span` caps how far you can
/// zoom in.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TimeAxis {
    /// Left edge of the visible window (axis units).
    pub start: f64,
    /// Right edge of the visible window (axis units); always `> start`.
    pub end: f64,
    /// Hard clamp: the window never pans/zooms outside `[min, max]`.
    pub min: f64,
    pub max: f64,
    /// Smallest allowed visible span (max zoom-in).
    pub min_span: f64,
}

impl Default for TimeAxis {
    fn default() -> Self {
        Self { start: 0.0, end: 1.0, min: 0.0, max: 1.0, min_span: 1e-6 }
    }
}

impl TimeAxis {
    /// A window spanning `[min, max]` fully zoomed out.
    pub fn new(min: f64, max: f64) -> Self {
        let (min, max) = if max > min { (min, max) } else { (min, min + 1.0) };
        Self { start: min, end: max, min, max, min_span: (max - min) * 1e-6 }
    }

    /// The visible span (`end - start`).
    pub fn span(&self) -> f64 {
        self.end - self.start
    }

    /// The largest span the clamp allows (the full data extent).
    pub fn max_span(&self) -> f64 {
        self.max - self.min
    }

    /// Map an axis-unit time to a `0.0..=1.0` fraction of the visible window
    /// (`<0` / `>1` = off-screen left/right). The renderer multiplies by pixel
    /// width to place a bar/event.
    pub fn to_frac(&self, t: f64) -> f64 {
        let span = self.span();
        if span <= 0.0 { 0.0 } else { (t - self.start) / span }
    }

    /// Inverse of [`to_frac`](Self::to_frac): the time at window fraction `f`.
    pub fn from_frac(&self, f: f64) -> f64 {
        self.start + f * self.span()
    }

    /// Re-clamp the window so `[start, end]` lies inside `[min, max]` while
    /// preserving its span where possible.
    fn clamp_window(&mut self) {
        let extent = self.max_span();
        // Span can't exceed the extent, nor shrink below min_span.
        let mut span = self.span().clamp(self.min_span.min(extent).max(0.0), extent.max(self.min_span));
        if span <= 0.0 {
            span = extent.max(self.min_span);
        }
        // Slide the window back inside the bounds.
        let mut start = self.start;
        if start < self.min {
            start = self.min;
        }
        if start + span > self.max {
            start = self.max - span;
        }
        if start < self.min {
            start = self.min; // extent smaller than span (only when span==extent)
        }
        self.start = start;
        self.end = start + span;
    }

    /// **Pan** by a delta in axis units (positive slides later), clamped so the
    /// window stays inside `[min, max]`.
    pub fn pan(&mut self, delta: f64) {
        self.start += delta;
        self.end += delta;
        self.clamp_window();
    }

    /// **Zoom** toward a pivot time by `factor` (>1 narrows / zooms in), keeping
    /// the `pivot` time at the same on-screen fraction — the natural map/plot feel
    /// applied to time. Clamps the new span to `[min_span, max_span]`.
    pub fn zoom(&mut self, factor: f64, pivot: f64) {
        if factor <= 0.0 || !factor.is_finite() {
            return;
        }
        let span = self.span();
        if span <= 0.0 {
            return;
        }
        // Where the pivot sits in the current window (kept fixed).
        let frac = ((pivot - self.start) / span).clamp(0.0, 1.0);
        let new_span = (span / factor).clamp(self.min_span, self.max_span());
        self.start = pivot - frac * new_span;
        self.end = self.start + new_span;
        self.clamp_window();
    }

    /// Snap the window to `[start, end]` (fit-to-range), clamped to bounds.
    pub fn fit(&mut self, start: f64, end: f64) {
        let (a, b) = if end > start { (start, end) } else { (start, start + self.min_span) };
        self.start = a;
        self.end = b;
        self.clamp_window();
    }

    /// Widen (or reset) the clamp bounds, then re-clamp the window into them.
    pub fn set_bounds(&mut self, min: f64, max: f64) {
        if max > min {
            self.min = min;
            self.max = max;
            self.min_span = self.min_span.min(max - min);
            self.clamp_window();
        }
    }

    /// **Link** to another axis: copy its visible window (a shared scrubber scrolls
    /// several facets together, TIME-1). Bounds/min_span stay this axis's own.
    pub fn sync_from(&mut self, other: &TimeAxis) {
        self.start = other.start;
        self.end = other.end;
        self.clamp_window();
    }

    /// **The single mutation path** (FC-2 / FC-8). Apply one [`TimeMsg`]; return a
    /// [`TimeEffect::WindowChanged`] iff the visible window actually moved (empty
    /// otherwise), so linked axes only re-sync on a real change.
    pub fn update(&mut self, msg: TimeMsg) -> Vec<TimeEffect> {
        let before = (self.start, self.end);
        match msg {
            TimeMsg::Pan(d) => self.pan(d),
            TimeMsg::Zoom { factor, pivot } => self.zoom(factor, pivot),
            TimeMsg::Fit { start, end } => self.fit(start, end),
            TimeMsg::SetBounds { min, max } => self.set_bounds(min, max),
        }
        if (self.start, self.end) != before {
            vec![TimeEffect::WindowChanged { start: self.start, end: self.end }]
        } else {
            Vec::new()
        }
    }

    /// Observable state as JSON (the `state_json` discipline).
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "start": self.start,
            "end": self.end,
            "span": self.span(),
            "min": self.min,
            "max": self.max,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const EPS: f64 = 1e-9;

    fn approx(a: f64, b: f64) -> bool {
        (a - b).abs() < 1e-6
    }

    #[test]
    fn new_spans_the_full_extent() {
        let ax = TimeAxis::new(100.0, 200.0);
        assert_eq!(ax.span(), 100.0);
        assert_eq!(ax.to_frac(150.0), 0.5, "midpoint is halfway");
        assert!(approx(ax.from_frac(0.25), 125.0));
    }

    #[test]
    fn zoom_in_keeps_the_pivot_time_fixed() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        let pivot = 40.0;
        let frac_before = ax.to_frac(pivot);
        ax.zoom(2.0, pivot); // zoom in 2× about t=40
        assert!(approx(ax.span(), 50.0), "span halved, got {}", ax.span());
        assert!(approx(ax.to_frac(pivot), frac_before), "pivot stays at the same fraction");
    }

    #[test]
    fn zoom_out_clamps_to_the_full_extent() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        ax.zoom(4.0, 50.0); // zoom in to span 25
        assert!(approx(ax.span(), 25.0));
        for _ in 0..10 {
            ax.zoom(0.5, 50.0); // repeatedly zoom out
        }
        assert!(approx(ax.span(), 100.0), "cannot zoom past the full extent, got {}", ax.span());
        assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
    }

    #[test]
    fn zoom_in_clamps_to_min_span() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        ax.min_span = 5.0;
        for _ in 0..20 {
            ax.zoom(2.0, 30.0);
        }
        assert!(ax.span() >= 5.0 - EPS, "min_span floor honored, got {}", ax.span());
    }

    #[test]
    fn pan_clamps_within_bounds() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        ax.zoom(4.0, 50.0); // span 25, window ~[37.5, 62.5]
        ax.pan(1000.0); // shove far right
        assert!(approx(ax.end, 100.0), "clamped to max edge, got end={}", ax.end);
        assert!(approx(ax.span(), 25.0), "span preserved while panning");
        ax.pan(-1000.0); // shove far left
        assert!(approx(ax.start, 0.0), "clamped to min edge, got start={}", ax.start);
        assert!(approx(ax.span(), 25.0));
    }

    #[test]
    fn fit_snaps_to_a_range() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        ax.fit(20.0, 40.0);
        assert!(approx(ax.start, 20.0) && approx(ax.end, 40.0));
        // Out-of-bounds fit is clamped.
        ax.fit(-50.0, 500.0);
        assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
    }

    #[test]
    fn update_emits_window_changed_only_on_a_real_move() {
        let mut ax = TimeAxis::new(0.0, 100.0);
        // A no-op pan at the left edge (already clamped) emits nothing.
        let fx = ax.update(TimeMsg::Pan(-10.0));
        assert!(fx.is_empty(), "clamped-to-edge pan is a no-op");
        // A real zoom emits the new window.
        let fx = ax.update(TimeMsg::Zoom { factor: 2.0, pivot: 50.0 });
        assert_eq!(fx.len(), 1);
        match fx[0] {
            TimeEffect::WindowChanged { start, end } => {
                assert!(approx(start, ax.start) && approx(end, ax.end));
            }
        }
    }

    #[test]
    fn linked_axes_sync_windows() {
        let mut a = TimeAxis::new(0.0, 100.0);
        let mut b = TimeAxis::new(0.0, 100.0);
        a.zoom(4.0, 25.0);
        // Propagate a's window into b (the shared-scrubber case).
        for fx in a.update(TimeMsg::Pan(5.0)) {
            let TimeEffect::WindowChanged { .. } = fx;
        }
        b.sync_from(&a);
        assert!(approx(a.start, b.start) && approx(a.end, b.end), "b follows a's window");
    }

    #[test]
    fn serde_round_trip_is_identity() {
        let mut ax = TimeAxis::new(1000.0, 5000.0);
        ax.zoom(3.0, 2500.0);
        let json = serde_json::to_string(&ax).unwrap();
        let back: TimeAxis = serde_json::from_str(&json).unwrap();
        assert_eq!(ax, back);
    }
}