Skip to main content

gridline_engine/
plot.rs

1//! Plot spec encoding and data preparation for chart cells.
2//!
3//! This module provides:
4//! - [`PlotSpec`]: Specification for a plot (type, range, labels)
5//! - [`PlotData`]: Prepared data for rendering (frontend-agnostic)
6//! - Encoding/decoding of plot specs to/from cell display strings
7//!
8//! The engine returns a tagged string for plot formulas (e.g. `=BARCHART(A1:A10)`).
9//! The TUI detects and renders these in a modal.
10
11pub const PLOT_PREFIX: &str = "@PLOT:";
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum PlotKind {
15    Bar,
16    Line,
17    Scatter,
18}
19
20impl PlotKind {
21    pub fn as_tag(self) -> &'static str {
22        match self {
23            PlotKind::Bar => "BAR",
24            PlotKind::Line => "LINE",
25            PlotKind::Scatter => "SCATTER",
26        }
27    }
28
29    pub fn from_tag(tag: &str) -> Option<Self> {
30        match tag {
31            "BAR" => Some(PlotKind::Bar),
32            "LINE" => Some(PlotKind::Line),
33            "SCATTER" => Some(PlotKind::Scatter),
34            _ => None,
35        }
36    }
37}
38
39/// Specification for a plot (parsed from a plot cell).
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct PlotSpec {
42    pub kind: PlotKind,
43    pub r1: usize,
44    pub c1: usize,
45    pub r2: usize,
46    pub c2: usize,
47
48    pub title: Option<String>,
49    pub x_label: Option<String>,
50    pub y_label: Option<String>,
51}
52
53impl PlotSpec {
54    /// Validate that the plot spec is valid for its type.
55    ///
56    /// Returns `Ok(())` if valid, or an error message describing the problem.
57    pub fn validate(&self) -> Result<(), String> {
58        let cols = self.c2.abs_diff(self.c1) + 1;
59
60        match self.kind {
61            PlotKind::Scatter => {
62                if cols != 2 {
63                    return Err(format!(
64                        "SCATTER requires exactly 2 columns (X and Y), got {}",
65                        cols
66                    ));
67                }
68            }
69            PlotKind::Bar | PlotKind::Line => {
70                // Bar and Line can work with any range
71            }
72        }
73        Ok(())
74    }
75}
76
77/// Prepared data for rendering a plot (frontend-agnostic).
78///
79/// This intermediate representation separates data extraction from rendering,
80/// allowing different frontends (TUI, GUI) to use the same data preparation logic.
81#[derive(Clone, Debug)]
82pub struct PlotData {
83    /// The original plot specification.
84    pub spec: PlotSpec,
85    /// Data points as (x, y) pairs.
86    pub points: Vec<(f32, f32)>,
87    /// X-axis range (min, max).
88    pub x_range: (f32, f32),
89    /// Y-axis range (min, max).
90    pub y_range: (f32, f32),
91    /// Warnings about data quality (e.g., skipped non-numeric cells).
92    pub warnings: Vec<String>,
93}
94
95impl PlotData {
96    /// Extract plot data from a spec using a cell value accessor.
97    ///
98    /// The `cell_value` closure takes (row, col) and returns the numeric value
99    /// at that position, or `None` if the cell is empty or non-numeric.
100    pub fn from_spec<F>(spec: &PlotSpec, mut cell_value: F) -> Result<Self, String>
101    where
102        F: FnMut(usize, usize) -> Option<f64>,
103    {
104        // Validate first
105        spec.validate()?;
106
107        let r1 = spec.r1.min(spec.r2);
108        let r2 = spec.r1.max(spec.r2);
109        let c1 = spec.c1.min(spec.c2);
110        let c2 = spec.c1.max(spec.c2);
111
112        let mut points = Vec::new();
113        let mut warnings = Vec::new();
114        let mut skipped_count = 0;
115
116        match spec.kind {
117            PlotKind::Scatter => {
118                for r in r1..=r2 {
119                    let x = cell_value(r, c1);
120                    let y = cell_value(r, c2);
121                    match (x, y) {
122                        (Some(x), Some(y)) => points.push((x as f32, y as f32)),
123                        _ => skipped_count += 1,
124                    }
125                }
126            }
127            PlotKind::Bar | PlotKind::Line => {
128                let mut ys = Vec::new();
129                if r1 == r2 {
130                    // Single row: iterate columns
131                    for c in c1..=c2 {
132                        match cell_value(r1, c) {
133                            Some(v) => ys.push(v as f32),
134                            None => {
135                                ys.push(0.0);
136                                skipped_count += 1;
137                            }
138                        }
139                    }
140                } else if c1 == c2 {
141                    // Single column: iterate rows
142                    for r in r1..=r2 {
143                        match cell_value(r, c1) {
144                            Some(v) => ys.push(v as f32),
145                            None => {
146                                ys.push(0.0);
147                                skipped_count += 1;
148                            }
149                        }
150                    }
151                } else {
152                    // Multi-row, multi-column: iterate row-major
153                    for r in r1..=r2 {
154                        for c in c1..=c2 {
155                            match cell_value(r, c) {
156                                Some(v) => ys.push(v as f32),
157                                None => {
158                                    ys.push(0.0);
159                                    skipped_count += 1;
160                                }
161                            }
162                        }
163                    }
164                }
165                points = ys
166                    .into_iter()
167                    .enumerate()
168                    .map(|(i, y)| (i as f32, y))
169                    .collect();
170            }
171        }
172
173        if skipped_count > 0 {
174            warnings.push(format!("{} non-numeric cell(s) treated as 0", skipped_count));
175        }
176
177        if points.is_empty() {
178            return Err("No data points to plot".to_string());
179        }
180
181        // Calculate ranges
182        let (mut xmin, mut xmax) = (points[0].0, points[0].0);
183        let (mut ymin, mut ymax) = (points[0].1, points[0].1);
184        for (x, y) in &points {
185            xmin = xmin.min(*x);
186            xmax = xmax.max(*x);
187            ymin = ymin.min(*y);
188            ymax = ymax.max(*y);
189        }
190
191        // Ensure non-zero ranges
192        if xmax == xmin {
193            xmax = xmin + 1.0;
194        }
195        if ymax == ymin {
196            ymax = ymin + 1.0;
197        }
198
199        Ok(PlotData {
200            spec: spec.clone(),
201            points,
202            x_range: (xmin, xmax),
203            y_range: (ymin, ymax),
204            warnings,
205        })
206    }
207}
208
209fn percent_encode(s: &str) -> String {
210    let mut out = String::new();
211    for b in s.as_bytes() {
212        match *b {
213            b'%' | b'|' | b':' | b'\n' | b'\r' => {
214                out.push('%');
215                out.push_str(&format!("{:02X}", b));
216            }
217            _ => out.push(*b as char),
218        }
219    }
220    out
221}
222
223fn from_hex_digit(b: u8) -> Option<u8> {
224    match b {
225        b'0'..=b'9' => Some(b - b'0'),
226        b'a'..=b'f' => Some(b - b'a' + 10),
227        b'A'..=b'F' => Some(b - b'A' + 10),
228        _ => None,
229    }
230}
231
232fn percent_decode(s: &str) -> Option<String> {
233    let bytes = s.as_bytes();
234    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
235    let mut i = 0;
236    while i < bytes.len() {
237        if bytes[i] == b'%' {
238            if i + 2 >= bytes.len() {
239                return None;
240            }
241            let hi = from_hex_digit(bytes[i + 1])?;
242            let lo = from_hex_digit(bytes[i + 2])?;
243            out.push((hi << 4) | lo);
244            i += 3;
245        } else {
246            out.push(bytes[i]);
247            i += 1;
248        }
249    }
250    String::from_utf8(out).ok()
251}
252
253pub fn format_plot_spec(spec: &PlotSpec) -> String {
254    let base = format!(
255        "{}{}:{},{},{},{}",
256        PLOT_PREFIX,
257        spec.kind.as_tag(),
258        spec.r1,
259        spec.c1,
260        spec.r2,
261        spec.c2
262    );
263
264    let title = spec.title.as_deref().unwrap_or("");
265    let x = spec.x_label.as_deref().unwrap_or("");
266    let y = spec.y_label.as_deref().unwrap_or("");
267    if title.is_empty() && x.is_empty() && y.is_empty() {
268        return base;
269    }
270
271    format!(
272        "{}|{}|{}|{}",
273        base,
274        percent_encode(title),
275        percent_encode(x),
276        percent_encode(y)
277    )
278}
279
280pub fn parse_plot_spec(s: &str) -> Option<PlotSpec> {
281    let s = s.trim();
282    let rest = s.strip_prefix(PLOT_PREFIX)?;
283    let (kind_tag, rest) = rest.split_once(':')?;
284    let kind = PlotKind::from_tag(kind_tag)?;
285
286    let (coords, meta) = rest
287        .split_once('|')
288        .map_or((rest, None), |(a, b)| (a, Some(b)));
289
290    let mut it = coords.split(',');
291    let r1 = it.next()?.parse::<usize>().ok()?;
292    let c1 = it.next()?.parse::<usize>().ok()?;
293    let r2 = it.next()?.parse::<usize>().ok()?;
294    let c2 = it.next()?.parse::<usize>().ok()?;
295    if it.next().is_some() {
296        return None;
297    }
298
299    let mut title: Option<String> = None;
300    let mut x_label: Option<String> = None;
301    let mut y_label: Option<String> = None;
302    if let Some(meta) = meta {
303        let parts: Vec<&str> = meta.split('|').collect();
304        if let Some(p) = parts.first()
305            && !p.is_empty()
306        {
307            title = percent_decode(p);
308        }
309        if let Some(p) = parts.get(1)
310            && !p.is_empty()
311        {
312            x_label = percent_decode(p);
313        }
314        if let Some(p) = parts.get(2)
315            && !p.is_empty()
316        {
317            y_label = percent_decode(p);
318        }
319    }
320
321    Some(PlotSpec {
322        kind,
323        r1,
324        c1,
325        r2,
326        c2,
327        title,
328        x_label,
329        y_label,
330    })
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_plot_spec_round_trip() {
339        let spec = PlotSpec {
340            kind: PlotKind::Bar,
341            r1: 0,
342            c1: 1,
343            r2: 9,
344            c2: 1,
345            title: Some("My Plot".to_string()),
346            x_label: Some("X".to_string()),
347            y_label: Some("Y".to_string()),
348        };
349        let s = format_plot_spec(&spec);
350        assert_eq!(parse_plot_spec(&s), Some(spec));
351    }
352}