1#![warn(missing_docs)]
31
32#[derive(Clone, Copy, Debug, PartialEq)]
41pub enum Scale {
42 Linear,
45 Time,
51 Log {
55 base: f64,
58 },
59}
60
61const LOG_EPSILON: f64 = 1e-300;
64
65impl Scale {
66 pub fn linear() -> Self {
68 Scale::Linear
69 }
70
71 pub fn time() -> Self {
73 Scale::Time
74 }
75
76 pub fn log() -> Self {
79 Scale::Log { base: 10.0 }
80 }
81
82 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 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 pub fn map(&self, v: f64, origin: f64) -> f32 {
108 (self.forward(v) - self.forward(origin)) as f32
109 }
110
111 pub fn invert(&self, s: f32, origin: f64) -> f64 {
115 self.inverse(self.forward(origin) + s as f64)
116 }
117
118 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 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 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#[derive(Clone, Debug, PartialEq)]
165pub struct Tick {
166 pub value: f64,
168 pub label: String,
170}
171
172fn 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
194fn linear_tick_step(lo: f64, hi: f64, target: usize) -> f64 {
197 nice_step((hi - lo) / target as f64)
198}
199
200fn 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 let max_ticks = target.saturating_mul(4).max(8);
211 while t <= hi + step * 1e-9 && out.len() < max_ticks {
212 let snapped = (t / step).round() * step;
214 out.push(snapped);
215 t += step;
216 }
217 out
218}
219
220fn format_number(v: f64, step: f64) -> String {
223 let v = if v == 0.0 { 0.0 } else { v }; 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
241fn 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 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 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 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 if v >= lo && v <= hi {
272 out.push(v);
273 }
274 e += step;
275 }
276 out
277}
278
279const MINUTE: f64 = 60.0;
283const HOUR: f64 = 3600.0;
284const DAY: f64 = 86_400.0;
285
286const 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
316fn 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
339fn 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); 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
357fn 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
366 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (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 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 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 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 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 assert_eq!(civil_from_days(11017), (2000, 3, 1));
443 let days = 20628; 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 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 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 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}