Skip to main content

damascene_core/plot/
scale.rs

1//! Axis scales: the continuous, invertible warp from data space to scale
2//! space, plus tick generation and value formatting.
3//!
4//! A [`Scale`] is the 2D-plot analogue of a [D3 scale]: it maps a value on
5//! one axis from **data space** (what the app measures — a temperature, a
6//! byte count, an epoch timestamp) to **scale space** (the linearized
7//! coordinate the plot lays out in) and back. It is a *power-user
8//! primitive* — pure, standalone, and usable without ever building an
9//! [`El`](crate::tree::El): an app composing a custom chart from the public
10//! pieces (see `docs/PLOT2D_PLAN.md`, Layer 3) reaches for `Scale` directly.
11//!
12//! ## Why "scale space"
13//!
14//! A plot pans and zooms by changing only a transform *uniform*, never
15//! re-uploading the data (see the plan's decision 2). That works because a
16//! pan/zoom of the visible window is **affine in scale space** — including
17//! for a logarithmic axis, where panning is affine in `log(value)` even
18//! though it is not in `value`. So every `Scale` exposes a
19//! [`forward`](Scale::forward)/[`inverse`](Scale::inverse) warp; the plot
20//! lays out, pans, and zooms in the forward (scale-space) coordinate and
21//! only converts back to data space to label ticks and read out the
22//! crosshair.
23//!
24//! [`Scale::Linear`] and [`Scale::Time`] share the identity warp — time is
25//! linear in epoch seconds — and differ only in how ticks are chosen and
26//! formatted. [`Scale::Log`] warps through `log`.
27//!
28//! [D3 scale]: https://d3js.org/d3-scale
29
30#![warn(missing_docs)]
31
32/// A continuous, invertible map from an axis's data space to scale space,
33/// with tick generation and default value formatting.
34///
35/// Construct with [`Scale::linear`], [`Scale::time`], or [`Scale::log`] and
36/// hand it to a plot axis (`plot(...).x(Scale::time())`). The visible domain
37/// is *not* carried here — it lives in the plot's [`PlotView`](crate::plot::PlotView)
38/// state, the same way a CSS `transform` is separate from the element it
39/// applies to. A `Scale` is just the warp + tick policy.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum Scale {
42    /// A linear (identity-warp) numeric axis. Ticks are "nice" numbers
43    /// (multiples of 1/2/5 × 10ⁿ).
44    Linear,
45    /// A time axis. Data is **epoch seconds** (`f64`, so millisecond
46    /// precision survives even for present-day timestamps — see the plan's
47    /// decision 7). The warp is the identity (time is linear in seconds);
48    /// ticks land on natural calendar/clock boundaries and format as clock
49    /// time or dates.
50    Time,
51    /// A logarithmic axis. The warp is `log(value)`, so panning and zooming
52    /// stay affine in scale space. The domain must stay strictly positive;
53    /// non-positive values are clamped to a tiny epsilon by the warp.
54    Log {
55        /// The logarithm base (e.g. `10.0`). Tick placement uses powers of
56        /// this base.
57        base: f64,
58    },
59}
60
61/// The smallest positive value [`Scale::Log`] will take a logarithm of, so
62/// the warp never returns `-inf` for a clamped zero/negative input.
63const LOG_EPSILON: f64 = 1e-300;
64
65impl Scale {
66    /// A linear numeric axis.
67    pub fn linear() -> Self {
68        Scale::Linear
69    }
70
71    /// A time axis over **epoch seconds**.
72    pub fn time() -> Self {
73        Scale::Time
74    }
75
76    /// A base-10 logarithmic axis. Use [`Scale::Log`] directly for another
77    /// base.
78    pub fn log() -> Self {
79        Scale::Log { base: 10.0 }
80    }
81
82    /// Warp a data-space value into scale space. Identity for
83    /// [`Linear`](Scale::Linear)/[`Time`](Scale::Time); `log_base` for
84    /// [`Log`](Scale::Log).
85    pub fn forward(&self, v: f64) -> f64 {
86        match self {
87            Scale::Linear | Scale::Time => v,
88            Scale::Log { base } => v.max(LOG_EPSILON).log(*base),
89        }
90    }
91
92    /// Inverse of [`forward`](Scale::forward): map a scale-space coordinate
93    /// back to data space.
94    pub fn inverse(&self, u: f64) -> f64 {
95        match self {
96            Scale::Linear | Scale::Time => u,
97            Scale::Log { base } => base.powf(u),
98        }
99    }
100
101    /// Map a data value to a GPU-ready scale-space coordinate measured
102    /// **relative to `origin`** (a data-space reference subtracted before
103    /// the cast to `f32`). Subtracting a per-axis origin is what keeps large
104    /// absolute coordinates — epoch timestamps especially — inside `f32`'s
105    /// ~7 significant digits on the GPU (the plan's decision 7). Pass the
106    /// same `origin` to [`invert`](Scale::invert).
107    pub fn map(&self, v: f64, origin: f64) -> f32 {
108        (self.forward(v) - self.forward(origin)) as f32
109    }
110
111    /// Inverse of [`map`](Scale::map): recover the data value from an
112    /// origin-relative scale-space coordinate (e.g. for a crosshair
113    /// readout).
114    pub fn invert(&self, s: f32, origin: f64) -> f64 {
115        self.inverse(self.forward(origin) + s as f64)
116    }
117
118    /// Generate axis ticks across the visible data window `(lo, hi)`,
119    /// aiming for about `target` of them. Returns ticks in ascending data
120    /// order, each with its data value and a default label. `lo`/`hi` are in
121    /// data space; an empty or inverted window yields no ticks.
122    pub fn ticks(&self, window: (f64, f64), target: usize) -> Vec<Tick> {
123        let (lo, hi) = window;
124        if !lo.is_finite() || !hi.is_finite() || hi <= lo {
125            return Vec::new();
126        }
127        match self {
128            Scale::Linear => linear_ticks(lo, hi, target.max(1))
129                .into_iter()
130                .map(|v| Tick {
131                    value: v,
132                    label: format_number(v, linear_tick_step(lo, hi, target.max(1))),
133                })
134                .collect(),
135            Scale::Log { base } => log_ticks(lo, hi, *base)
136                .into_iter()
137                .map(|v| Tick {
138                    value: v,
139                    label: format_number(v, 0.0),
140                })
141                .collect(),
142            Scale::Time => time_ticks(lo, hi, target.max(1)),
143        }
144    }
145
146    /// Format a single data value the way this scale's ticks would, e.g. for
147    /// a crosshair readout. `context` is a neighbouring span (typically the
148    /// visible window width) used to choose precision; pass `0.0` to accept
149    /// the default.
150    pub fn format(&self, v: f64, context: f64) -> String {
151        match self {
152            Scale::Linear | Scale::Log { .. } => {
153                let step = if context > 0.0 { context / 100.0 } else { 0.0 };
154                format_number(v, step)
155            }
156            Scale::Time => format_time(v, context),
157        }
158    }
159}
160
161/// One axis tick: a position in data space and its formatted label.
162#[derive(Clone, Debug, PartialEq)]
163pub struct Tick {
164    /// The tick's data-space position.
165    pub value: f64,
166    /// The label to draw at the tick.
167    pub label: String,
168}
169
170// --- Linear "nice number" ticks -------------------------------------------
171
172/// Round `rough` up to a "nice" step: 1, 2, 5, or 10 times a power of ten.
173fn nice_step(rough: f64) -> f64 {
174    if !rough.is_finite() || rough <= 0.0 {
175        return 1.0;
176    }
177    let pow = 10f64.powf(rough.log10().floor());
178    let frac = rough / pow;
179    let nice = if frac < 1.5 {
180        1.0
181    } else if frac < 3.0 {
182        2.0
183    } else if frac < 7.0 {
184        5.0
185    } else {
186        10.0
187    };
188    nice * pow
189}
190
191/// The nice step a linear axis would use across `(lo, hi)` for `target`
192/// ticks.
193fn linear_tick_step(lo: f64, hi: f64, target: usize) -> f64 {
194    nice_step((hi - lo) / target as f64)
195}
196
197/// Tick *values* for a linear axis across `(lo, hi)`.
198fn linear_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
199    let step = linear_tick_step(lo, hi, target);
200    if step <= 0.0 {
201        return Vec::new();
202    }
203    let start = (lo / step).ceil() * step;
204    let mut out = Vec::new();
205    let mut t = start;
206    // Guard the loop against pathological windows.
207    let max_ticks = target.saturating_mul(4).max(8);
208    while t <= hi + step * 1e-9 && out.len() < max_ticks {
209        // Snap values that are a hair off a clean multiple (float drift).
210        let snapped = (t / step).round() * step;
211        out.push(snapped);
212        t += step;
213    }
214    out
215}
216
217/// Format a number for a tick label, choosing decimals from `step`'s
218/// magnitude (0 for an unknown step). Trims trailing zeros.
219fn format_number(v: f64, step: f64) -> String {
220    let v = if v == 0.0 { 0.0 } else { v }; // normalize -0.0
221    let decimals = if step > 0.0 && step < 1.0 {
222        (-step.log10().floor()).clamp(0.0, 12.0) as usize
223    } else {
224        0
225    };
226    let mut s = format!("{v:.decimals$}");
227    if s.contains('.') {
228        while s.ends_with('0') {
229            s.pop();
230        }
231        if s.ends_with('.') {
232            s.pop();
233        }
234    }
235    s
236}
237
238// --- Log ticks ------------------------------------------------------------
239
240/// Tick values at integer powers of `base` spanning `(lo, hi)`.
241fn log_ticks(lo: f64, hi: f64, base: f64) -> Vec<f64> {
242    let lo = lo.max(LOG_EPSILON);
243    if hi <= lo || base <= 1.0 {
244        return Vec::new();
245    }
246    let lo_exp = lo.log(base).floor() as i32;
247    let hi_exp = hi.log(base).ceil() as i32;
248    let mut out = Vec::new();
249    for e in lo_exp..=hi_exp {
250        let v = base.powi(e);
251        if v >= lo && v <= hi && out.len() < 64 {
252            out.push(v);
253        }
254    }
255    out
256}
257
258// --- Time ticks -----------------------------------------------------------
259
260/// Seconds per minute/hour/day, for tick interval selection.
261const MINUTE: f64 = 60.0;
262const HOUR: f64 = 3600.0;
263const DAY: f64 = 86_400.0;
264
265/// Candidate tick intervals, in seconds, from one second up to ~a year.
266/// Each divides the next cleanly enough that multiples land on natural
267/// clock/calendar boundaries (in UTC).
268const TIME_INTERVALS: &[f64] = &[
269    1.0,
270    2.0,
271    5.0,
272    10.0,
273    15.0,
274    30.0,
275    MINUTE,
276    2.0 * MINUTE,
277    5.0 * MINUTE,
278    10.0 * MINUTE,
279    15.0 * MINUTE,
280    30.0 * MINUTE,
281    HOUR,
282    2.0 * HOUR,
283    3.0 * HOUR,
284    6.0 * HOUR,
285    12.0 * HOUR,
286    DAY,
287    2.0 * DAY,
288    7.0 * DAY,
289    14.0 * DAY,
290    30.0 * DAY,
291    90.0 * DAY,
292    365.0 * DAY,
293];
294
295/// Ticks for a time axis over epoch seconds `(lo, hi)`.
296fn time_ticks(lo: f64, hi: f64, target: usize) -> Vec<Tick> {
297    let span = hi - lo;
298    let rough = span / target as f64;
299    let interval = TIME_INTERVALS
300        .iter()
301        .copied()
302        .find(|&i| i >= rough)
303        .unwrap_or(365.0 * DAY);
304    let start = (lo / interval).ceil() * interval;
305    let mut out = Vec::new();
306    let mut t = start;
307    let max_ticks = target.saturating_mul(4).max(8);
308    while t <= hi + interval * 1e-9 && out.len() < max_ticks {
309        out.push(Tick {
310            value: t,
311            label: format_time(t, interval),
312        });
313        t += interval;
314    }
315    out
316}
317
318/// Format an epoch-seconds value for a tick, with granularity chosen from
319/// the tick `interval` (clock time for sub-day intervals, a date otherwise).
320/// All formatting is UTC.
321fn format_time(epoch_secs: f64, interval: f64) -> String {
322    let secs = epoch_secs.floor() as i64;
323    let days = secs.div_euclid(DAY as i64);
324    let tod = secs.rem_euclid(DAY as i64); // seconds since UTC midnight
325    let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
326    let (y, mo, d) = civil_from_days(days);
327    if interval >= DAY {
328        format!("{y:04}-{mo:02}-{d:02}")
329    } else if interval >= MINUTE {
330        format!("{h:02}:{m:02}")
331    } else {
332        format!("{h:02}:{m:02}:{s:02}")
333    }
334}
335
336/// Convert a day count relative to the Unix epoch (1970-01-01) to a civil
337/// `(year, month, day)`, via Howard Hinnant's `civil_from_days` algorithm
338/// (valid across the proleptic Gregorian calendar).
339fn civil_from_days(z: i64) -> (i64, u32, u32) {
340    let z = z + 719_468;
341    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
342    let doe = z - era * 146_097; // [0, 146096]
343    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
344    let y = yoe + era * 400;
345    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
346    let mp = (5 * doy + 2) / 153; // [0, 11]
347    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
348    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
349    (if m <= 2 { y + 1 } else { y }, m, d)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn linear_warp_is_identity_and_invertible() {
358        let s = Scale::linear();
359        assert_eq!(s.forward(42.0), 42.0);
360        assert_eq!(s.inverse(42.0), 42.0);
361        // origin-relative map keeps precision near the origin
362        assert_eq!(s.map(105.0, 100.0), 5.0);
363        assert!((s.invert(5.0, 100.0) - 105.0).abs() < 1e-9);
364    }
365
366    #[test]
367    fn log_warp_roundtrips_and_pan_is_affine_in_scale_space() {
368        let s = Scale::log();
369        assert!((s.forward(1000.0) - 3.0).abs() < 1e-12);
370        assert!((s.inverse(3.0) - 1000.0).abs() < 1e-9);
371        // equal ratios are equal distances in scale space (the whole point)
372        let d1 = s.forward(100.0) - s.forward(10.0);
373        let d2 = s.forward(1000.0) - s.forward(100.0);
374        assert!((d1 - d2).abs() < 1e-12);
375    }
376
377    #[test]
378    fn log_warp_clamps_nonpositive() {
379        let s = Scale::log();
380        assert!(s.forward(0.0).is_finite());
381        assert!(s.forward(-5.0).is_finite());
382    }
383
384    #[test]
385    fn linear_ticks_are_nice_and_in_range() {
386        let s = Scale::linear();
387        let ticks = s.ticks((0.0, 100.0), 5);
388        assert!(!ticks.is_empty());
389        for t in &ticks {
390            assert!(t.value >= 0.0 && t.value <= 100.0);
391        }
392        // expect a step of 20 → 0,20,40,60,80,100
393        assert_eq!(ticks.first().unwrap().value, 0.0);
394        assert_eq!(ticks.last().unwrap().value, 100.0);
395        assert_eq!(ticks[1].value, 20.0);
396    }
397
398    #[test]
399    fn linear_ticks_fractional_labels_trim_zeros() {
400        let s = Scale::linear();
401        let ticks = s.ticks((0.0, 1.0), 5);
402        // step 0.2 → labels like "0.2", not "0.200000"
403        assert!(ticks.iter().any(|t| t.label == "0.2"));
404        assert!(ticks.iter().any(|t| t.label == "0"));
405    }
406
407    #[test]
408    fn nice_step_picks_1_2_5() {
409        assert_eq!(nice_step(1.0), 1.0);
410        assert_eq!(nice_step(2.3), 2.0);
411        assert_eq!(nice_step(3.5), 5.0);
412        assert_eq!(nice_step(0.04), 0.05);
413        assert_eq!(nice_step(8.0), 10.0);
414    }
415
416    #[test]
417    fn civil_from_days_known_dates() {
418        assert_eq!(civil_from_days(0), (1970, 1, 1));
419        assert_eq!(civil_from_days(-1), (1969, 12, 31));
420        // 2000-03-01 is 11017 days after the epoch
421        assert_eq!(civil_from_days(11017), (2000, 3, 1));
422        // 2026-06-24
423        let days = 20628; // verified below by round-trip
424        let (y, m, d) = civil_from_days(days);
425        assert_eq!((y, m, d), (2026, 6, 24));
426    }
427
428    #[test]
429    fn time_ticks_clock_format_for_minutes() {
430        let s = Scale::time();
431        // 2026-06-24 12:00:00 UTC .. +1h, expect HH:MM labels on clean
432        // boundaries
433        let base = 20628.0 * DAY + 12.0 * HOUR;
434        let ticks = s.ticks((base, base + HOUR), 4);
435        assert!(!ticks.is_empty());
436        assert!(
437            ticks
438                .iter()
439                .all(|t| t.label.len() == 5 && t.label.contains(':'))
440        );
441        // first tick at or after 12:00 on a clean 15-minute boundary
442        assert!(ticks.iter().any(|t| t.label == "12:15"));
443    }
444
445    #[test]
446    fn time_ticks_date_format_for_multiday() {
447        let s = Scale::time();
448        let base = 20628.0 * DAY;
449        let ticks = s.ticks((base, base + 10.0 * DAY), 5);
450        assert!(!ticks.is_empty());
451        assert!(ticks.iter().all(|t| t.label.starts_with("2026-")));
452    }
453
454    #[test]
455    fn empty_or_inverted_window_has_no_ticks() {
456        let s = Scale::linear();
457        assert!(s.ticks((5.0, 5.0), 5).is_empty());
458        assert!(s.ticks((10.0, 0.0), 5).is_empty());
459    }
460}