Skip to main content

okbot_core/
chart.rs

1//! Charts drawn as images (ADR 0016): the data the agent gives, and the port that draws it.
2
3use serde::{Deserialize, Serialize};
4
5use crate::prompt::Tone;
6
7/// Categories of a bar chart.
8pub const MAX_BARS: usize = 15;
9/// Categories of a column chart.
10pub const MAX_COLUMNS: usize = 12;
11/// Points of a line chart.
12pub const MAX_LINE_POINTS: usize = 60;
13/// Series of a column or line chart.
14pub const MAX_SERIES: usize = 3;
15/// Characters of a label or a series name.
16pub const MAX_CHART_LABEL: usize = 60;
17/// The largest value a chart takes, in size.
18pub const MAX_CHART_VALUE: f64 = 1e15;
19
20/// How a chart draws its data.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23#[non_exhaustive]
24pub enum ChartKind {
25    /// Horizontal bars: comparing and ranking, long labels. One series.
26    #[default]
27    Bar,
28    /// Vertical columns: short categories in order (months, days).
29    Column,
30    /// Lines: trends over time.
31    Line,
32}
33
34/// One series of values, one per label.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct Series {
37    /// Its name, shown in the legend when there are several.
38    #[serde(default)]
39    pub name: Option<String>,
40    /// The values; `None` is a gap.
41    pub values: Vec<Option<f64>>,
42}
43
44/// A chart to draw: labels along one axis and one to [`MAX_SERIES`] series of values.
45#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
46pub struct Chart {
47    /// How to draw it.
48    #[serde(default)]
49    pub kind: ChartKind,
50    /// A title drawn in the image.
51    #[serde(default)]
52    pub title: Option<String>,
53    /// The unit of the values (`kg`, `đồng`, `円`), written once under the title.
54    #[serde(default)]
55    pub unit: Option<String>,
56    /// The categories or points in order.
57    pub labels: Vec<String>,
58    /// The series.
59    pub series: Vec<Series>,
60    /// The label drawn in the accent colour while the others are muted.
61    #[serde(default)]
62    pub highlight: Option<usize>,
63    /// The colour of a single series.
64    #[serde(default)]
65    pub tone: Tone,
66    /// The language for numbers and the image's description (`vi`, `en`, `ja`); guessed from the
67    /// labels when unset.
68    #[serde(default)]
69    pub locale: Option<String>,
70}
71
72impl Chart {
73    /// Checks the chart against the limits of its kind; the error says what to change.
74    pub fn validate(&self) -> Result<(), String> {
75        let (max_labels, max_series) = match self.kind {
76            ChartKind::Bar => (MAX_BARS, 1),
77            ChartKind::Column => (MAX_COLUMNS, MAX_SERIES),
78            ChartKind::Line => (MAX_LINE_POINTS, MAX_SERIES),
79        };
80        let kind = match self.kind {
81            ChartKind::Bar => "bar",
82            ChartKind::Column => "column",
83            ChartKind::Line => "line",
84        };
85        let min_labels = if self.kind == ChartKind::Line { 2 } else { 1 };
86        if self.labels.len() < min_labels || self.labels.len() > max_labels {
87            return Err(format!(
88                "a {kind} chart has {min_labels} to {max_labels} labels"
89            ));
90        }
91        if self.series.is_empty() || self.series.len() > max_series {
92            return Err(format!("a {kind} chart has 1 to {max_series} series"));
93        }
94        for label in &self.labels {
95            label_ok("chart label", label)?;
96        }
97        if self.series.len() > 1 && self.series.iter().any(|s| s.name.is_none()) {
98            return Err("series need names when there are several".into());
99        }
100        for s in &self.series {
101            if let Some(name) = &s.name {
102                label_ok("series name", name)?;
103            }
104            if s.values.len() != self.labels.len() {
105                return Err("each series has one value per label".into());
106            }
107            if s.values
108                .iter()
109                .flatten()
110                .any(|v| !v.is_finite() || v.abs() > MAX_CHART_VALUE)
111            {
112                return Err(format!(
113                    "chart values are numbers no larger than {MAX_CHART_VALUE:e} in size"
114                ));
115            }
116        }
117        if self
118            .series
119            .iter()
120            .all(|s| s.values.iter().all(Option::is_none))
121        {
122            return Err("a chart needs at least one value".into());
123        }
124        if let Some(title) = &self.title {
125            label_ok("chart title", title)?;
126        }
127        if let Some(unit) = &self.unit
128            && unit.chars().count() > 30
129        {
130            return Err("chart unit: at most 30 characters".into());
131        }
132        if self.highlight.is_some_and(|i| i >= self.labels.len()) {
133            return Err("the highlight must be one of the labels".into());
134        }
135        Ok(())
136    }
137}
138
139fn label_ok(what: &str, text: &str) -> Result<(), String> {
140    if text.trim().is_empty() {
141        return Err(format!("{what}s must not be empty"));
142    }
143    if text.chars().count() > MAX_CHART_LABEL {
144        return Err(format!("{what}: at most {MAX_CHART_LABEL} characters"));
145    }
146    Ok(())
147}
148
149/// A picture to send, with what it shows in words.
150#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
151pub struct Image {
152    /// The file name (`chart.png`).
153    pub name: String,
154    /// What it shows, for people who cannot see it and for platforms' alt text.
155    pub alt: String,
156    /// Size in pixels.
157    pub width: u32,
158    /// Size in pixels.
159    pub height: u32,
160    /// The PNG data. Never stored: empty in a prompt read back from the store, where it means the
161    /// message keeps the picture it was sent with.
162    #[serde(skip)]
163    pub png: Vec<u8>,
164}
165
166/// Why a chart was not drawn. The runtime then shows it as text.
167#[derive(Debug, thiserror::Error)]
168#[non_exhaustive]
169pub enum ChartError {
170    /// The chart breaks a limit.
171    #[error("invalid chart: {0}")]
172    Invalid(String),
173    /// No font has these characters.
174    #[error("no font for {0:?}")]
175    Glyphs(String),
176    /// Drawing failed.
177    #[error("cannot draw the chart: {0}")]
178    Failed(String),
179}
180
181/// Draws charts as PNG images (ADR 0016). Synchronous and CPU-bound: the runtime calls it off the
182/// async threads.
183pub trait ChartRenderer: Send + Sync {
184    /// Draws `chart`.
185    fn render(&self, chart: &Chart) -> Result<Image, ChartError>;
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn chart(kind: ChartKind, labels: &[&str], series: Vec<Vec<Option<f64>>>) -> Chart {
193        Chart {
194            kind,
195            labels: labels.iter().map(|l| (*l).to_string()).collect(),
196            series: series
197                .into_iter()
198                .enumerate()
199                .map(|(i, values)| Series {
200                    name: Some(format!("s{i}")),
201                    values,
202                })
203                .collect(),
204            ..Chart::default()
205        }
206    }
207
208    #[test]
209    fn limits_by_kind() {
210        let one = chart(ChartKind::Bar, &["a", "b"], vec![vec![Some(1.0), None]]);
211        assert_eq!(one.validate(), Ok(()));
212        let two = chart(
213            ChartKind::Bar,
214            &["a"],
215            vec![vec![Some(1.0)], vec![Some(2.0)]],
216        );
217        assert!(two.validate().is_err());
218        let column = Chart {
219            kind: ChartKind::Column,
220            ..two
221        };
222        assert_eq!(column.validate(), Ok(()));
223        let line = chart(ChartKind::Line, &["a"], vec![vec![Some(1.0)]]);
224        assert!(line.validate().is_err(), "a line needs two points");
225        let bad = chart(ChartKind::Bar, &["a"], vec![vec![Some(f64::NAN)]]);
226        assert!(bad.validate().is_err());
227        let empty = chart(ChartKind::Bar, &["a"], vec![vec![None]]);
228        assert!(empty.validate().is_err());
229        let short = chart(ChartKind::Bar, &["a", "b"], vec![vec![Some(1.0)]]);
230        assert!(short.validate().is_err());
231    }
232}