Skip to main content

ggplot_rs/data/
mod.rs

1mod dataframe;
2mod polars_macro;
3mod source;
4
5pub use dataframe::DataFrame;
6pub use source::GGData;
7
8/// Dynamic value type for DataFrame columns.
9#[derive(Clone, Debug)]
10pub enum Value {
11    Float(f64),
12    Integer(i64),
13    Str(String),
14    Bool(bool),
15    /// Seconds since Unix epoch (1970-01-01 00:00:00 UTC).
16    DateTime(i64),
17    Na,
18}
19
20impl Value {
21    /// Try to extract as f64, coercing integers and datetimes.
22    pub fn as_f64(&self) -> Option<f64> {
23        match self {
24            Value::Float(f) => Some(*f),
25            Value::Integer(i) => Some(*i as f64),
26            Value::DateTime(secs) => Some(*secs as f64),
27            _ => None,
28        }
29    }
30
31    /// Try to extract as string representation.
32    pub fn as_str(&self) -> Option<&str> {
33        match self {
34            Value::Str(s) => Some(s),
35            _ => None,
36        }
37    }
38
39    /// Check if this is NA/missing.
40    pub fn is_na(&self) -> bool {
41        matches!(self, Value::Na)
42    }
43
44    /// Check if this is a DateTime value.
45    pub fn is_datetime(&self) -> bool {
46        matches!(self, Value::DateTime(_))
47    }
48
49    /// Create a DateTime from seconds since Unix epoch.
50    pub fn from_timestamp(secs: i64) -> Self {
51        Value::DateTime(secs)
52    }
53
54    /// Convert to a string for display and discrete-scale level purposes.
55    ///
56    /// Note this is *not* injective: `Value::Na` and the string `"NA"` (and
57    /// `Float(1.0)`/`Integer(1)`/`Str("1")`) share a key. Use
58    /// [`group_key`](Self::group_key) to split rows into groups, and
59    /// [`key_str`](Self::key_str) for allocation-free lookups.
60    pub fn to_group_key(&self) -> String {
61        match self {
62            Value::Float(f) => format!("{f}"),
63            Value::Integer(i) => format!("{i}"),
64            Value::Str(s) => s.clone(),
65            Value::Bool(b) => format!("{b}"),
66            Value::DateTime(secs) => format_epoch_secs(*secs),
67            Value::Na => "NA".to_string(),
68        }
69    }
70}
71
72/// A grouping key for one value: [`Value::group_key`]. Unlike
73/// [`Value::to_group_key`] it keeps a missing value (`Na`) distinct from the
74/// literal string `"NA"`, and borrows string data instead of cloning it.
75#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
76pub enum GroupKey<'a> {
77    /// A missing value.
78    Na,
79    /// Any present value, keyed by its display string (so `Float(1.0)` and
80    /// `Integer(1)` still group together).
81    Key(std::borrow::Cow<'a, str>),
82}
83
84impl Value {
85    /// Same string as [`to_group_key`](Self::to_group_key), but borrowed for
86    /// `Str` values — use it for hot-path scale lookups.
87    pub fn key_str(&self) -> std::borrow::Cow<'_, str> {
88        match self {
89            Value::Str(s) => std::borrow::Cow::Borrowed(s.as_str()),
90            Value::Na => std::borrow::Cow::Borrowed("NA"),
91            other => std::borrow::Cow::Owned(other.to_group_key()),
92        }
93    }
94
95    /// Injective-on-missingness grouping key: `Na` never collides with the
96    /// string `"NA"`. Borrows `Str` data (no allocation).
97    pub fn group_key(&self) -> GroupKey<'_> {
98        match self {
99            Value::Na => GroupKey::Na,
100            other => GroupKey::Key(other.key_str()),
101        }
102    }
103}
104
105/// Format epoch seconds as a human-readable date/time string.
106pub fn format_epoch_secs(secs: i64) -> String {
107    // Simple UTC date/time formatting without external dependencies
108    const SECS_PER_DAY: i64 = 86400;
109    const SECS_PER_HOUR: i64 = 3600;
110    const SECS_PER_MINUTE: i64 = 60;
111
112    // Euclidean division: floor for negative timestamps, and total over the
113    // whole i64 range (no `secs - 86399` overflow at i64::MIN).
114    let mut days = secs.div_euclid(SECS_PER_DAY);
115    let rem = secs.rem_euclid(SECS_PER_DAY);
116
117    let hour = rem / SECS_PER_HOUR;
118    let minute = (rem % SECS_PER_HOUR) / SECS_PER_MINUTE;
119    let second = rem % SECS_PER_MINUTE;
120
121    // Days since 1970-01-01 to Y-M-D (civil calendar)
122    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
123    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
124    let doe = (days - era * 146_097) as u32;
125    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
126    let y = yoe as i64 + era * 400;
127    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
128    let mp = (5 * doy + 2) / 153;
129    let d = doy - (153 * mp + 2) / 5 + 1;
130    let m = if mp < 10 { mp + 3 } else { mp - 9 };
131    let y = if m <= 2 { y + 1 } else { y };
132
133    if hour == 0 && minute == 0 && second == 0 {
134        format!("{y:04}-{m:02}-{d:02}")
135    } else {
136        format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}:{second:02}")
137    }
138}
139
140impl PartialEq for Value {
141    fn eq(&self, other: &Self) -> bool {
142        match (self, other) {
143            (Value::Float(a), Value::Float(b)) => a.to_bits() == b.to_bits(),
144            (Value::Integer(a), Value::Integer(b)) => a == b,
145            (Value::Str(a), Value::Str(b)) => a == b,
146            (Value::Bool(a), Value::Bool(b)) => a == b,
147            (Value::DateTime(a), Value::DateTime(b)) => a == b,
148            (Value::Na, Value::Na) => true,
149            _ => false,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn na_and_literal_na_have_distinct_group_keys() {
160        let na = Value::Na;
161        let lit = Value::Str("NA".to_string());
162        assert_ne!(na.group_key(), lit.group_key());
163        // Display/level keys stay "NA" for both (what an axis or legend shows).
164        assert_eq!(na.to_group_key(), "NA");
165        assert_eq!(lit.to_group_key(), "NA");
166        // Numeric types still group by value.
167        assert_eq!(Value::Float(1.0).group_key(), Value::Integer(1).group_key());
168        assert!(matches!(
169            Value::Str("x".into()).key_str(),
170            std::borrow::Cow::Borrowed("x")
171        ));
172    }
173
174    #[test]
175    fn format_epoch_secs_is_total() {
176        assert_eq!(format_epoch_secs(0), "1970-01-01");
177        assert_eq!(format_epoch_secs(-1), "1969-12-31 23:59:59");
178        assert_eq!(format_epoch_secs(-86_400), "1969-12-31");
179        assert_eq!(format_epoch_secs(951_782_400), "2000-02-29");
180        // Extremes must not overflow (they used to panic in debug builds).
181        let lo = format_epoch_secs(i64::MIN);
182        let hi = format_epoch_secs(i64::MAX);
183        assert!(lo.starts_with('-') && lo.contains(':'), "{lo}");
184        assert!(hi.contains(':'), "{hi}");
185    }
186}