Skip to main content

autumn_plugin_d3/
data.rs

1//! Chart data and option values: [`Datum`], [`Series`], [`Point`],
2//! [`Color`], [`Format`], [`Curve`], [`Scale`].
3//!
4//! Data serializes to the JSON that `parse.js` reads. A non-finite number
5//! serializes as `null`. The runtime shows `null` as a gap.
6
7use serde::ser::{Serialize, SerializeSeq, SerializeStruct, Serializer};
8
9/// One labelled value. Bar and pie charts use it.
10///
11/// ```rust
12/// use autumn_plugin_d3::Datum;
13///
14/// let d = Datum::new("Apples", 3.0);
15/// assert_eq!(serde_json::to_string(&d).unwrap(), r#"{"label":"Apples","value":3.0}"#);
16/// ```
17#[derive(Debug, Clone, PartialEq)]
18pub struct Datum {
19    label: String,
20    value: f64,
21}
22
23impl Datum {
24    /// Makes a datum. A non-finite `value` is a missing value.
25    #[must_use]
26    pub fn new(label: impl Into<String>, value: f64) -> Self {
27        Self {
28            label: label.into(),
29            value,
30        }
31    }
32
33    /// The label.
34    #[must_use]
35    pub fn label(&self) -> &str {
36        &self.label
37    }
38
39    /// The value, or `None` when it is not finite.
40    #[must_use]
41    pub fn value(&self) -> Option<f64> {
42        finite(self.value)
43    }
44}
45
46impl<S: Into<String>> From<(S, f64)> for Datum {
47    fn from((label, value): (S, f64)) -> Self {
48        Self::new(label, value)
49    }
50}
51
52impl Serialize for Datum {
53    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
54        let mut s = serializer.serialize_struct("Datum", 2)?;
55        s.serialize_field("label", &self.label)?;
56        s.serialize_field("value", &self.value())?;
57        s.end()
58    }
59}
60
61/// One (x, y) point. Line, area, and scatter charts use it.
62///
63/// With [`Scale::Time`], `x` is Unix time in milliseconds.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Point {
66    /// The x value.
67    pub x: f64,
68    /// The y value.
69    pub y: f64,
70}
71
72impl Point {
73    /// Makes a point. A non-finite coordinate is a missing value.
74    #[must_use]
75    pub const fn new(x: f64, y: f64) -> Self {
76        Self { x, y }
77    }
78}
79
80impl From<(f64, f64)> for Point {
81    fn from((x, y): (f64, f64)) -> Self {
82        Self::new(x, y)
83    }
84}
85
86impl Serialize for Point {
87    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
88        let mut s = serializer.serialize_seq(Some(2))?;
89        s.serialize_element(&finite(self.x))?;
90        s.serialize_element(&finite(self.y))?;
91        s.end()
92    }
93}
94
95/// A named list of points.
96///
97/// ```rust
98/// use autumn_plugin_d3::Series;
99///
100/// let s = Series::new("Visits", [(1.0, 10.0), (2.0, f64::NAN)]);
101/// assert_eq!(
102///     serde_json::to_string(&s).unwrap(),
103///     r#"{"name":"Visits","points":[[1.0,10.0],[2.0,null]]}"#
104/// );
105/// ```
106#[derive(Debug, Clone, PartialEq)]
107pub struct Series {
108    name: String,
109    points: Vec<Point>,
110}
111
112impl Series {
113    /// Makes a series.
114    #[must_use]
115    pub fn new<P: Into<Point>>(
116        name: impl Into<String>,
117        points: impl IntoIterator<Item = P>,
118    ) -> Self {
119        Self {
120            name: name.into(),
121            points: points.into_iter().map(Into::into).collect(),
122        }
123    }
124
125    /// The series name.
126    #[must_use]
127    pub fn name(&self) -> &str {
128        &self.name
129    }
130
131    /// The points.
132    #[must_use]
133    pub fn points(&self) -> &[Point] {
134        &self.points
135    }
136}
137
138impl Serialize for Series {
139    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
140        let mut s = serializer.serialize_struct("Series", 2)?;
141        s.serialize_field("name", &self.name)?;
142        s.serialize_field("points", &self.points)?;
143        s.end()
144    }
145}
146
147/// An sRGB color.
148///
149/// ```rust
150/// use autumn_plugin_d3::Color;
151///
152/// assert_eq!(Color::hex(0x2a78d6).to_string(), "#2a78d6");
153/// assert_eq!(Color::rgb(255, 0, 16).to_string(), "#ff0010");
154/// ```
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
156pub struct Color(u32);
157
158impl Color {
159    /// Makes a color from `0xRRGGBB`. The function ignores higher bits.
160    #[must_use]
161    pub const fn hex(rgb: u32) -> Self {
162        Self(rgb & 0x00ff_ffff)
163    }
164
165    /// Makes a color from red, green, and blue channels.
166    #[must_use]
167    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
168        Self(((r as u32) << 16) | ((g as u32) << 8) | b as u32)
169    }
170}
171
172impl std::fmt::Display for Color {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(f, "#{:06x}", self.0)
175    }
176}
177
178/// A number format. It maps to a d3-format specifier.
179///
180/// ```rust
181/// use autumn_plugin_d3::Format;
182///
183/// assert_eq!(Format::percent(1).spec(), ".1%");
184/// assert_eq!(Format::currency(2).spec(), "$,.2f");
185/// ```
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Format(String);
188
189/// Most decimals a format keeps.
190const MAX_DECIMALS: u8 = 12;
191
192impl Format {
193    /// Integer with group separators: `1,234`.
194    #[must_use]
195    pub fn integer() -> Self {
196        Self(",.0f".into())
197    }
198
199    /// Fixed decimals with group separators: `1,234.50`.
200    #[must_use]
201    pub fn decimal(places: u8) -> Self {
202        Self(format!(",.{}f", places.min(MAX_DECIMALS)))
203    }
204
205    /// Percent. The value `0.25` shows as `25%`.
206    #[must_use]
207    pub fn percent(places: u8) -> Self {
208        Self(format!(".{}%", places.min(MAX_DECIMALS)))
209    }
210
211    /// SI prefix with significant digits: `1.2k`, `3.4M`.
212    #[must_use]
213    pub fn si(digits: u8) -> Self {
214        Self(format!(".{}~s", digits.clamp(1, 21)))
215    }
216
217    /// Dollar currency: `$1,234.50`.
218    #[must_use]
219    pub fn currency(places: u8) -> Self {
220        Self(format!("$,.{}f", places.min(MAX_DECIMALS)))
221    }
222
223    /// A raw d3-format specifier. The runtime ignores a bad specifier, or
224    /// one with a width above 64, and uses the default format.
225    #[must_use]
226    pub fn d3(spec: impl Into<String>) -> Self {
227        Self(spec.into())
228    }
229
230    /// The d3-format specifier.
231    #[must_use]
232    pub fn spec(&self) -> &str {
233        &self.0
234    }
235
236    /// The d3-format specifier, by value.
237    pub(crate) fn into_spec(self) -> String {
238        self.0
239    }
240}
241
242/// Line and area interpolation.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
244#[non_exhaustive]
245pub enum Curve {
246    /// Straight segments (default).
247    #[default]
248    Linear,
249    /// Smooth, and keeps monotonic data monotonic.
250    Monotone,
251    /// Steps at the midpoint between points.
252    Step,
253    /// Natural cubic spline.
254    Natural,
255}
256
257impl Curve {
258    /// The attribute value.
259    #[must_use]
260    pub const fn as_str(self) -> &'static str {
261        match self {
262            Self::Linear => "linear",
263            Self::Monotone => "monotone",
264            Self::Step => "step",
265            Self::Natural => "natural",
266        }
267    }
268}
269
270/// A continuous scale type.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
272#[non_exhaustive]
273pub enum Scale {
274    /// Linear (default).
275    #[default]
276    Linear,
277    /// UTC time. Values are Unix time in milliseconds.
278    Time,
279    /// Base-10 log. The runtime drops values that are not positive.
280    Log,
281}
282
283impl Scale {
284    /// The attribute value.
285    #[must_use]
286    pub const fn as_str(self) -> &'static str {
287        match self {
288            Self::Linear => "linear",
289            Self::Time => "time",
290            Self::Log => "log",
291        }
292    }
293}
294
295/// Returns `Some(v)` when `v` is finite.
296pub(crate) fn finite(v: f64) -> Option<f64> {
297    v.is_finite().then_some(v)
298}
299
300/// Formats a number for the fallback table, like JavaScript
301/// `String(number)`: exponent form outside `1e-6 ..= 1e21`, and `-0` is
302/// `0`. Missing values show as `—`.
303pub(crate) fn cell(v: f64) -> String {
304    let Some(v) = finite(v) else {
305        return "—".to_owned();
306    };
307    if v == 0.0 {
308        return "0".to_owned();
309    }
310    if (1e-6..1e21).contains(&v.abs()) {
311        return v.to_string();
312    }
313    let text = format!("{v:e}");
314    match text.split_once('e') {
315        Some((mantissa, exp)) if !exp.starts_with('-') => format!("{mantissa}e+{exp}"),
316        _ => text,
317    }
318}
319
320/// Formats Unix milliseconds as a UTC ISO 8601 string for the fallback
321/// table. A midnight time shows the date only. Missing values show as `—`.
322pub(crate) fn utc(ms: f64) -> String {
323    // The range of JavaScript dates: ±8.64e15 ms.
324    const MAX_MS: f64 = 8.64e15;
325    if !ms.is_finite() || ms.abs() > MAX_MS {
326        return cell(f64::NAN);
327    }
328    #[allow(clippy::cast_possible_truncation)] // In range: |ms| ≤ 8.64e15.
329    let ms = ms.floor() as i64;
330    let days = ms.div_euclid(86_400_000);
331    let rest = ms.rem_euclid(86_400_000);
332    let (year, month, day) = civil_from_days(days);
333    let date = format!("{year:04}-{month:02}-{day:02}");
334    if rest == 0 {
335        return date;
336    }
337    let (hour, minute, second, milli) = (
338        rest / 3_600_000,
339        rest / 60_000 % 60,
340        rest / 1000 % 60,
341        rest % 1000,
342    );
343    let time = format!("{date}T{hour:02}:{minute:02}:{second:02}");
344    if milli == 0 {
345        format!("{time}Z")
346    } else {
347        format!("{time}.{milli:03}Z")
348    }
349}
350
351/// Converts days since 1970-01-01 to a proleptic Gregorian (year, month,
352/// day). Algorithm: Howard Hinnant, `civil_from_days`.
353const fn civil_from_days(days: i64) -> (i64, i64, i64) {
354    let z = days + 719_468;
355    let era = z.div_euclid(146_097);
356    let doe = z.rem_euclid(146_097);
357    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
358    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
359    let mp = (5 * doy + 2) / 153;
360    let d = doy - (153 * mp + 2) / 5 + 1;
361    let m = if mp < 10 { mp + 3 } else { mp - 9 };
362    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
363    (y, m, d)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use proptest::prelude::*;
370
371    fn json<T: Serialize>(v: &T) -> String {
372        serde_json::to_string(v).expect("serializes")
373    }
374
375    #[test]
376    fn non_finite_values_serialize_as_null() {
377        assert_eq!(
378            json(&Datum::new("a", f64::INFINITY)),
379            r#"{"label":"a","value":null}"#
380        );
381        assert_eq!(json(&Point::new(f64::NAN, 1.0)), "[null,1.0]");
382        assert_eq!(Datum::new("a", f64::NAN).value(), None);
383        assert_eq!(Datum::new("a", 2.0).value(), Some(2.0));
384    }
385
386    #[test]
387    fn labels_escape_as_json_strings() {
388        assert_eq!(
389            json(&Datum::new("\"<b>\"\n", 1.0)),
390            r#"{"label":"\"<b>\"\n","value":1.0}"#
391        );
392    }
393
394    #[test]
395    fn tuples_convert() {
396        let d: Datum = ("x", 1.5).into();
397        assert_eq!(d.label(), "x");
398        let s = Series::new(String::from("s"), [Point::new(1.0, 2.0)]);
399        assert_eq!(s.name(), "s");
400        assert_eq!(s.points(), [Point::new(1.0, 2.0)]);
401    }
402
403    #[test]
404    fn colors_render_as_lowercase_hex() {
405        assert_eq!(Color::hex(0x00ab_cdef).to_string(), "#abcdef");
406        assert_eq!(Color::hex(0xff00_0000).to_string(), "#000000");
407        assert_eq!(Color::rgb(1, 2, 3).to_string(), "#010203");
408    }
409
410    #[test]
411    fn formats_map_to_d3_specifiers() {
412        assert_eq!(Format::integer().spec(), ",.0f");
413        assert_eq!(Format::decimal(2).spec(), ",.2f");
414        assert_eq!(Format::decimal(99).spec(), ",.12f");
415        assert_eq!(Format::percent(0).spec(), ".0%");
416        assert_eq!(Format::si(0).spec(), ".1~s");
417        assert_eq!(Format::si(3).spec(), ".3~s");
418        assert_eq!(Format::currency(0).spec(), "$,.0f");
419        assert_eq!(Format::d3("+.2e").spec(), "+.2e");
420    }
421
422    #[test]
423    fn enums_map_to_attribute_values() {
424        let curves = [Curve::Linear, Curve::Monotone, Curve::Step, Curve::Natural];
425        let names: Vec<_> = curves.iter().map(|c| c.as_str()).collect();
426        assert_eq!(names, ["linear", "monotone", "step", "natural"]);
427        let scales = [Scale::Linear, Scale::Time, Scale::Log];
428        let names: Vec<_> = scales.iter().map(|s| s.as_str()).collect();
429        assert_eq!(names, ["linear", "time", "log"]);
430        assert_eq!(Curve::default(), Curve::Linear);
431        assert_eq!(Scale::default(), Scale::Linear);
432    }
433
434    #[test]
435    fn utc_formats_dates_and_times() {
436        assert_eq!(utc(0.0), "1970-01-01");
437        assert_eq!(utc(951_782_400_000.0), "2000-02-29");
438        assert_eq!(utc(1_767_225_600_000.0), "2026-01-01");
439        assert_eq!(
440            utc(1_767_225_600_000.0 + 3_723_000.0),
441            "2026-01-01T01:02:03Z"
442        );
443        assert_eq!(
444            utc(1_767_225_600_000.0 + 3_723_004.0),
445            "2026-01-01T01:02:03.004Z"
446        );
447        assert_eq!(utc(-86_400_000.0), "1969-12-31");
448        assert_eq!(utc(-1.0), "1969-12-31T23:59:59.999Z");
449        assert_eq!(utc(f64::NAN), "—");
450        assert_eq!(utc(9e15), "—");
451    }
452
453    #[test]
454    fn cells_show_missing_values_as_a_dash() {
455        assert_eq!(cell(1.5), "1.5");
456        assert_eq!(cell(f64::NEG_INFINITY), "—");
457        // The same text as JavaScript String(number).
458        assert_eq!(cell(-0.0), "0");
459        assert_eq!(cell(1e21), "1e+21");
460        assert_eq!(cell(-2.5e30), "-2.5e+30");
461        assert_eq!(cell(1e-7), "1e-7");
462        assert_eq!(cell(0.000_001), "0.000001");
463        assert_eq!(cell(123_456_789.0), "123456789");
464    }
465
466    proptest! {
467        /// Serialized data is valid JSON. A value is `null` exactly when it
468        /// is not finite.
469        #[test]
470        fn json_is_always_valid(label in ".*", v in any::<f64>(), x in any::<f64>(), y in any::<f64>()) {
471            let text = json(&(Datum::new(label.clone(), v), Series::new("s", [(x, y)])));
472            let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
473            prop_assert_eq!(parsed[0]["label"].as_str(), Some(label.as_str()));
474            for (got, want) in [
475                (&parsed[0]["value"], v),
476                (&parsed[1]["points"][0][0], x),
477                (&parsed[1]["points"][0][1], y),
478            ] {
479                prop_assert_eq!(got.is_null(), !want.is_finite(), "{}", text);
480                prop_assert_eq!(got.is_f64(), want.is_finite(), "{}", text);
481            }
482        }
483
484        /// Day arithmetic round-trips: consecutive days give consecutive dates.
485        #[test]
486        fn civil_dates_are_consecutive(days in -3_000_000i64..3_000_000) {
487            let (y0, m0, d0) = civil_from_days(days);
488            let (y1, m1, d1) = civil_from_days(days + 1);
489            let next_day = y1 == y0 && m1 == m0 && d1 == d0 + 1;
490            let next_month = y1 == y0 && m1 == m0 + 1 && d1 == 1;
491            let next_year = y1 == y0 + 1 && m0 == 12 && d0 == 31 && m1 == 1 && d1 == 1;
492            prop_assert!(next_day || next_month || next_year, "{:?} {:?}", (y0, m0, d0), (y1, m1, d1));
493            prop_assert!((1..=12).contains(&m0) && (1..=31).contains(&d0));
494        }
495    }
496}