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}