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, target.max(1))
136                .into_iter()
137                .map(|v| Tick {
138                    value: v,
139                    // Pass the tick's own magnitude as the precision context
140                    // so sub-1 decades label as "0.1"/"0.01", not "0".
141                    label: format_number(v, v.min(1.0)),
142                })
143                .collect(),
144            Scale::Time => time_ticks(lo, hi, target.max(1)),
145        }
146    }
147
148    /// Format a single data value the way this scale's ticks would, e.g. for
149    /// a crosshair readout. `context` is a neighbouring span (typically the
150    /// visible window width) used to choose precision; pass `0.0` to accept
151    /// the default.
152    pub fn format(&self, v: f64, context: f64) -> String {
153        match self {
154            Scale::Linear | Scale::Log { .. } => {
155                let step = if context > 0.0 { context / 100.0 } else { 0.0 };
156                format_number(v, step)
157            }
158            Scale::Time => format_time(v, context),
159        }
160    }
161}
162
163/// One axis tick: a position in data space and its formatted label.
164#[derive(Clone, Debug, PartialEq)]
165pub struct Tick {
166    /// The tick's data-space position.
167    pub value: f64,
168    /// The label to draw at the tick.
169    pub label: String,
170}
171
172// --- Linear "nice number" ticks -------------------------------------------
173
174/// Round `rough` up to a "nice" step: 1, 2, 5, or 10 times a power of ten.
175/// Classic "nice numbers" heuristic (Paul Heckbert, Graphics Gems, 1990).
176fn nice_step(rough: f64) -> f64 {
177    if !rough.is_finite() || rough <= 0.0 {
178        return 1.0;
179    }
180    let pow = 10f64.powf(rough.log10().floor());
181    let frac = rough / pow;
182    let nice = if frac < 1.5 {
183        1.0
184    } else if frac < 3.0 {
185        2.0
186    } else if frac < 7.0 {
187        5.0
188    } else {
189        10.0
190    };
191    nice * pow
192}
193
194/// The nice step a linear axis would use across `(lo, hi)` for `target`
195/// ticks.
196fn linear_tick_step(lo: f64, hi: f64, target: usize) -> f64 {
197    nice_step((hi - lo) / target as f64)
198}
199
200/// Tick *values* for a linear axis across `(lo, hi)`.
201fn linear_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
202    let step = linear_tick_step(lo, hi, target);
203    if step <= 0.0 {
204        return Vec::new();
205    }
206    let start = (lo / step).ceil() * step;
207    let mut out = Vec::new();
208    let mut t = start;
209    // Guard the loop against pathological windows.
210    let max_ticks = target.saturating_mul(4).max(8);
211    while t <= hi + step * 1e-9 && out.len() < max_ticks {
212        // Snap values that are a hair off a clean multiple (float drift).
213        let snapped = (t / step).round() * step;
214        out.push(snapped);
215        t += step;
216    }
217    out
218}
219
220/// Format a number for a tick label, choosing decimals from `step`'s
221/// magnitude (0 for an unknown step). Trims trailing zeros.
222fn format_number(v: f64, step: f64) -> String {
223    let v = if v == 0.0 { 0.0 } else { v }; // normalize -0.0
224    let decimals = if step > 0.0 && step < 1.0 {
225        (-step.log10().floor()).clamp(0.0, 12.0) as usize
226    } else {
227        0
228    };
229    let mut s = format!("{v:.decimals$}");
230    if s.contains('.') {
231        while s.ends_with('0') {
232            s.pop();
233        }
234        if s.ends_with('.') {
235            s.pop();
236        }
237    }
238    s
239}
240
241// --- Log ticks ------------------------------------------------------------
242
243/// Tick values at integer powers of `base` spanning `(lo, hi)`, thinned to
244/// roughly `target` of them by striding whole exponents when the window
245/// spans more decades than that (a 60-decade window ticks every 10th decade,
246/// not all 60).
247fn log_ticks(lo: f64, hi: f64, base: f64, target: usize) -> Vec<f64> {
248    let lo = lo.max(LOG_EPSILON);
249    if hi <= lo || base <= 1.0 {
250        return Vec::new();
251    }
252    // The in-range exponents: the smallest/largest `e` with `base^e` inside
253    // the window.
254    let lo_exp = lo.log(base).ceil() as i64;
255    let hi_exp = hi.log(base).floor() as i64;
256    if hi_exp < lo_exp {
257        // The window sits inside a single decade — no power falls in it.
258        return Vec::new();
259    }
260    let count = (hi_exp - lo_exp + 1) as usize;
261    let step = count.div_ceil(target.max(1)).max(1) as i64;
262    // Align to multiples of `step` so ticks hold still as the window pans.
263    let mut e = lo_exp.div_euclid(step) * step;
264    if e < lo_exp {
265        e += step;
266    }
267    let mut out = Vec::new();
268    while e <= hi_exp && out.len() < 64 {
269        let v = base.powi(e as i32);
270        // Belt-and-braces range check against float drift at the edges.
271        if v >= lo && v <= hi {
272            out.push(v);
273        }
274        e += step;
275    }
276    out
277}
278
279// --- Time ticks -----------------------------------------------------------
280
281/// Seconds per minute/hour/day, for tick interval selection.
282const MINUTE: f64 = 60.0;
283const HOUR: f64 = 3600.0;
284const DAY: f64 = 86_400.0;
285
286/// Candidate tick intervals, in seconds, from one second up to ~a year.
287/// Each divides the next cleanly enough that multiples land on natural
288/// clock/calendar boundaries (in UTC).
289const TIME_INTERVALS: &[f64] = &[
290    1.0,
291    2.0,
292    5.0,
293    10.0,
294    15.0,
295    30.0,
296    MINUTE,
297    2.0 * MINUTE,
298    5.0 * MINUTE,
299    10.0 * MINUTE,
300    15.0 * MINUTE,
301    30.0 * MINUTE,
302    HOUR,
303    2.0 * HOUR,
304    3.0 * HOUR,
305    6.0 * HOUR,
306    12.0 * HOUR,
307    DAY,
308    2.0 * DAY,
309    7.0 * DAY,
310    14.0 * DAY,
311    30.0 * DAY,
312    90.0 * DAY,
313    365.0 * DAY,
314];
315
316/// Ticks for a time axis over epoch seconds `(lo, hi)`.
317fn time_ticks(lo: f64, hi: f64, target: usize) -> Vec<Tick> {
318    let span = hi - lo;
319    let rough = span / target as f64;
320    let interval = TIME_INTERVALS
321        .iter()
322        .copied()
323        .find(|&i| i >= rough)
324        .unwrap_or(365.0 * DAY);
325    let start = (lo / interval).ceil() * interval;
326    let mut out = Vec::new();
327    let mut t = start;
328    let max_ticks = target.saturating_mul(4).max(8);
329    while t <= hi + interval * 1e-9 && out.len() < max_ticks {
330        out.push(Tick {
331            value: t,
332            label: format_time(t, interval),
333        });
334        t += interval;
335    }
336    out
337}
338
339/// Format an epoch-seconds value for a tick, with granularity chosen from
340/// the tick `interval` (clock time for sub-day intervals, a date otherwise).
341/// All formatting is UTC.
342fn format_time(epoch_secs: f64, interval: f64) -> String {
343    let secs = epoch_secs.floor() as i64;
344    let days = secs.div_euclid(DAY as i64);
345    let tod = secs.rem_euclid(DAY as i64); // seconds since UTC midnight
346    let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
347    let (y, mo, d) = civil_from_days(days);
348    if interval >= DAY {
349        format!("{y:04}-{mo:02}-{d:02}")
350    } else if interval >= MINUTE {
351        format!("{h:02}:{m:02}")
352    } else {
353        format!("{h:02}:{m:02}:{s:02}")
354    }
355}
356
357/// Convert a day count relative to the Unix epoch (1970-01-01) to a civil
358/// `(year, month, day)`, via Howard Hinnant's `civil_from_days` algorithm
359/// (valid across the proleptic Gregorian calendar).
360fn civil_from_days(z: i64) -> (i64, u32, u32) {
361    let z = z + 719_468;
362    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
363    let doe = z - era * 146_097; // [0, 146096]
364    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
365    let y = yoe + era * 400;
366    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
367    let mp = (5 * doy + 2) / 153; // [0, 11]
368    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
369    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
370    (if m <= 2 { y + 1 } else { y }, m, d)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn linear_warp_is_identity_and_invertible() {
379        let s = Scale::linear();
380        assert_eq!(s.forward(42.0), 42.0);
381        assert_eq!(s.inverse(42.0), 42.0);
382        // origin-relative map keeps precision near the origin
383        assert_eq!(s.map(105.0, 100.0), 5.0);
384        assert!((s.invert(5.0, 100.0) - 105.0).abs() < 1e-9);
385    }
386
387    #[test]
388    fn log_warp_roundtrips_and_pan_is_affine_in_scale_space() {
389        let s = Scale::log();
390        assert!((s.forward(1000.0) - 3.0).abs() < 1e-12);
391        assert!((s.inverse(3.0) - 1000.0).abs() < 1e-9);
392        // equal ratios are equal distances in scale space (the whole point)
393        let d1 = s.forward(100.0) - s.forward(10.0);
394        let d2 = s.forward(1000.0) - s.forward(100.0);
395        assert!((d1 - d2).abs() < 1e-12);
396    }
397
398    #[test]
399    fn log_warp_clamps_nonpositive() {
400        let s = Scale::log();
401        assert!(s.forward(0.0).is_finite());
402        assert!(s.forward(-5.0).is_finite());
403    }
404
405    #[test]
406    fn linear_ticks_are_nice_and_in_range() {
407        let s = Scale::linear();
408        let ticks = s.ticks((0.0, 100.0), 5);
409        assert!(!ticks.is_empty());
410        for t in &ticks {
411            assert!(t.value >= 0.0 && t.value <= 100.0);
412        }
413        // expect a step of 20 → 0,20,40,60,80,100
414        assert_eq!(ticks.first().unwrap().value, 0.0);
415        assert_eq!(ticks.last().unwrap().value, 100.0);
416        assert_eq!(ticks[1].value, 20.0);
417    }
418
419    #[test]
420    fn linear_ticks_fractional_labels_trim_zeros() {
421        let s = Scale::linear();
422        let ticks = s.ticks((0.0, 1.0), 5);
423        // step 0.2 → labels like "0.2", not "0.200000"
424        assert!(ticks.iter().any(|t| t.label == "0.2"));
425        assert!(ticks.iter().any(|t| t.label == "0"));
426    }
427
428    #[test]
429    fn nice_step_picks_1_2_5() {
430        assert_eq!(nice_step(1.0), 1.0);
431        assert_eq!(nice_step(2.3), 2.0);
432        assert_eq!(nice_step(3.5), 5.0);
433        assert_eq!(nice_step(0.04), 0.05);
434        assert_eq!(nice_step(8.0), 10.0);
435    }
436
437    #[test]
438    fn civil_from_days_known_dates() {
439        assert_eq!(civil_from_days(0), (1970, 1, 1));
440        assert_eq!(civil_from_days(-1), (1969, 12, 31));
441        // 2000-03-01 is 11017 days after the epoch
442        assert_eq!(civil_from_days(11017), (2000, 3, 1));
443        // 2026-06-24
444        let days = 20628; // verified below by round-trip
445        let (y, m, d) = civil_from_days(days);
446        assert_eq!((y, m, d), (2026, 6, 24));
447    }
448
449    #[test]
450    fn time_ticks_clock_format_for_minutes() {
451        let s = Scale::time();
452        // 2026-06-24 12:00:00 UTC .. +1h, expect HH:MM labels on clean
453        // boundaries
454        let base = 20628.0 * DAY + 12.0 * HOUR;
455        let ticks = s.ticks((base, base + HOUR), 4);
456        assert!(!ticks.is_empty());
457        assert!(
458            ticks
459                .iter()
460                .all(|t| t.label.len() == 5 && t.label.contains(':'))
461        );
462        // first tick at or after 12:00 on a clean 15-minute boundary
463        assert!(ticks.iter().any(|t| t.label == "12:15"));
464    }
465
466    #[test]
467    fn time_ticks_date_format_for_multiday() {
468        let s = Scale::time();
469        let base = 20628.0 * DAY;
470        let ticks = s.ticks((base, base + 10.0 * DAY), 5);
471        assert!(!ticks.is_empty());
472        assert!(ticks.iter().all(|t| t.label.starts_with("2026-")));
473    }
474
475    #[test]
476    fn log_ticks_are_decades_in_range() {
477        let s = Scale::log();
478        let ticks = s.ticks((0.574, 114_000.0), 6);
479        let values: Vec<f64> = ticks.iter().map(|t| t.value).collect();
480        assert_eq!(values, vec![1.0, 10.0, 100.0, 1000.0, 10_000.0, 100_000.0]);
481        assert_eq!(ticks[0].label, "1");
482        assert_eq!(ticks[5].label, "100000");
483    }
484
485    #[test]
486    fn log_ticks_label_sub_one_decades() {
487        let s = Scale::log();
488        let ticks = s.ticks((0.005, 20.0), 6);
489        let labels: Vec<&str> = ticks.iter().map(|t| t.label.as_str()).collect();
490        assert_eq!(labels, vec!["0.01", "0.1", "1", "10"]);
491    }
492
493    #[test]
494    fn log_ticks_thin_wide_windows_toward_target() {
495        let s = Scale::log();
496        let ticks = s.ticks((1e-30, 1e30), 6);
497        assert!(
498            (3..=8).contains(&ticks.len()),
499            "61 decades thinned to ~target: {}",
500            ticks.len()
501        );
502        // Strided decades stay powers of ten, ascending.
503        for w in ticks.windows(2) {
504            assert!(w[1].value > w[0].value);
505        }
506    }
507
508    #[test]
509    fn log_window_inside_one_decade_has_no_ticks() {
510        let s = Scale::log();
511        assert!(s.ticks((2.0, 8.0), 6).is_empty());
512    }
513
514    #[test]
515    fn empty_or_inverted_window_has_no_ticks() {
516        let s = Scale::linear();
517        assert!(s.ticks((5.0, 5.0), 5).is_empty());
518        assert!(s.ticks((10.0, 0.0), 5).is_empty());
519    }
520}