1pub 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#[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 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 }
72 }
73 Ok(())
74 }
75}
76
77#[derive(Clone, Debug)]
82pub struct PlotData {
83 pub spec: PlotSpec,
85 pub points: Vec<(f32, f32)>,
87 pub x_range: (f32, f32),
89 pub y_range: (f32, f32),
91 pub warnings: Vec<String>,
93}
94
95impl PlotData {
96 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 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 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 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 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 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 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}