Skip to main content

plotting/
spec.rs

1//! Chart intermediate representation.
2//!
3//! This is the one contract that adapters, renderers (SVG, canvas,
4//! ratatui), and the JSON wire format all share. Serialises cleanly so
5//! the WASM canvas renderer can consume the same bytes the server
6//! produces.
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ChartKind {
13    Bar,
14    StackedBar,
15    Line,
16}
17
18/// One x/y datum. `y` is carried as a `num`/`denom` pair so the exact
19/// Rational64 crosses the wire without float drift; each renderer
20/// converts to `f64` for display.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct SeriesPoint {
23    pub x: String,
24    pub y_num: i64,
25    pub y_denom: i64,
26}
27
28impl SeriesPoint {
29    /// Best-effort conversion to an `f64` for the renderer. A zero
30    /// denominator falls back to 0.0 rather than panicking — an
31    /// invariant violation in the IR shouldn't take down the renderer.
32    #[must_use]
33    pub fn y_f64(&self) -> f64 {
34        if self.y_denom == 0 {
35            return 0.0;
36        }
37        self.y_num as f64 / self.y_denom as f64
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Series {
43    pub label: String,
44    /// Commodity symbol (e.g. "USD"). Empty when the series is unitless.
45    pub commodity_symbol: String,
46    pub points: Vec<SeriesPoint>,
47}
48
49/// A chart the server has already prepared. Renderers read this
50/// directly; none of them compute totals, pick top-N, or negate
51/// signs. All of that lives in the adapters.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ChartSpec {
54    pub title: String,
55    pub kind: ChartKind,
56    pub x_label: String,
57    pub y_label: String,
58    pub series: Vec<Series>,
59    /// Display-time notes (e.g. "Showing USD; 2 other commodities
60    /// hidden"). Rendered as an info line under the chart.
61    pub notes: Vec<String>,
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn sample() -> ChartSpec {
69        ChartSpec {
70            title: "Income vs Expense".to_string(),
71            kind: ChartKind::StackedBar,
72            x_label: "Month".to_string(),
73            y_label: "Amount".to_string(),
74            series: vec![
75                Series {
76                    label: "Income".to_string(),
77                    commodity_symbol: "USD".to_string(),
78                    points: vec![
79                        SeriesPoint {
80                            x: "2026-01".to_string(),
81                            y_num: 3200,
82                            y_denom: 1,
83                        },
84                        SeriesPoint {
85                            x: "2026-02".to_string(),
86                            y_num: 2800,
87                            y_denom: 1,
88                        },
89                    ],
90                },
91                Series {
92                    label: "Expense".to_string(),
93                    commodity_symbol: "USD".to_string(),
94                    points: vec![
95                        SeriesPoint {
96                            x: "2026-01".to_string(),
97                            y_num: 2100,
98                            y_denom: 1,
99                        },
100                        SeriesPoint {
101                            x: "2026-02".to_string(),
102                            y_num: 1900,
103                            y_denom: 1,
104                        },
105                    ],
106                },
107            ],
108            notes: vec!["Showing USD only; 1 other commodity hidden".to_string()],
109        }
110    }
111
112    #[test]
113    fn spec_round_trips_through_json() {
114        let original = sample();
115        let encoded = serde_json::to_string(&original).expect("serialise");
116        let decoded: ChartSpec = serde_json::from_str(&encoded).expect("deserialise");
117        assert_eq!(decoded, original, "wire format must be lossless");
118    }
119
120    #[test]
121    fn chart_kind_serialises_as_lowercase() {
122        let s = serde_json::to_string(&ChartKind::StackedBar).expect("serialise");
123        assert_eq!(s, "\"stackedbar\"");
124    }
125
126    #[test]
127    fn series_point_converts_to_f64() {
128        let p = SeriesPoint {
129            x: "x".to_string(),
130            y_num: 3,
131            y_denom: 2,
132        };
133        assert!((p.y_f64() - 1.5).abs() < f64::EPSILON);
134    }
135
136    #[test]
137    fn series_point_zero_denominator_is_safe() {
138        let p = SeriesPoint {
139            x: "x".to_string(),
140            y_num: 42,
141            y_denom: 0,
142        };
143        assert_eq!(p.y_f64(), 0.0);
144    }
145}