Skip to main content

chartml_core/spec/
chart.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use super::source::CacheConfig;
5use super::style::{FontsSpec, GridSpec, LegendSpec};
6use super::transform::TransformSpec;
7
8// --- Top-level ChartSpec ---
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct ChartSpec {
13    pub version: u32,
14    pub title: Option<String>,
15    pub data: DataRef,
16    pub transform: Option<TransformSpec>,
17    pub visualize: VisualizeSpec,
18    pub layout: Option<LayoutSpec>,
19    pub style: Option<StyleRefOrInline>,
20    pub params: Option<Vec<super::params::ParamDef>>,
21}
22
23// --- DataRef: string reference, inline/single data source, or a named-source map ---
24//
25// The JS chartml accepts three shapes for `data`:
26//   data: sales                     # registered-source reference (Named)
27//   data: { provider: ..., rows: ... }   # single inline/plugin source (Inline)
28//   data:                           # multi-source map, keyed by user-chosen names (NamedMap)
29//     visitors:  { datasource: ..., query: ... }
30//     revenue:   { datasource: ..., query: ... }
31//
32// The NamedMap variant is recognised because its values are themselves maps that
33// (unlike Inline's flat keys) don't contain the reserved data-source keywords
34// directly on `data`. Serde's untagged enum does structural matching, so order
35// here matters: Named first (plain string), Inline next (flat map with the
36// reserved keys), NamedMap last (map-of-maps).
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
39#[serde(untagged)]
40pub enum DataRef {
41    Named(String),
42    Inline(InlineData),
43    NamedMap(IndexMap<String, InlineData>),
44}
45
46/// Inline / single-source data descriptor. Every field is optional so the struct
47/// matches all of JS chartml's reserved-key shapes: `rows`-based inline data,
48/// `url`-based HTTP, `provider`-based plugin, and `datasource`+`query`-based
49/// slug references.
50///
51/// `deny_unknown_fields` is critical: the untagged `DataRef` enum tries
52/// `Inline` before `NamedMap`, and without the deny-unknown guard a
53/// named-source map like `{ visitors: {...}, revenue: {...} }` would silently
54/// deserialize into `InlineData` with every field set to `None`, masking the
55/// real shape.
56#[derive(Debug, Clone, Deserialize, Serialize)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct InlineData {
59    pub provider: Option<String>,
60    pub rows: Option<Vec<serde_json::Value>>,
61    pub url: Option<String>,
62    pub endpoint: Option<String>,
63    pub cache: Option<CacheConfig>,
64    /// Slug-based datasource reference (resolved at runtime by the host app).
65    pub datasource: Option<String>,
66    /// SQL query string for slug-based datasources.
67    pub query: Option<String>,
68}
69
70// --- StyleRef for chart-level: string or inline style ---
71
72#[derive(Debug, Clone, Deserialize, Serialize)]
73#[serde(untagged)]
74pub enum StyleRefOrInline {
75    Named(String),
76    Inline(Box<ChartStyleSpec>),
77}
78
79// --- VisualizeSpec ---
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct VisualizeSpec {
84    #[serde(rename = "type")]
85    pub chart_type: String,
86    pub mode: Option<ChartMode>,
87    pub orientation: Option<Orientation>,
88    pub columns: Option<FieldRef>,
89    pub rows: Option<FieldRef>,
90    pub marks: Option<MarksSpec>,
91    pub axes: Option<AxesSpec>,
92    pub annotations: Option<Vec<AnnotationSpec>>,
93    pub style: Option<ChartStyleSpec>,
94    // Metric-specific fields
95    pub value: Option<String>,
96    pub label: Option<String>,
97    pub format: Option<String>,
98    pub compare_with: Option<String>,
99    pub invert_trend: Option<bool>,
100    pub data_labels: Option<DataLabelsSpec>,
101}
102
103// --- ChartMode ---
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum ChartMode {
108    Stacked,
109    Grouped,
110    Normalized,
111}
112
113// --- Orientation ---
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
116#[serde(rename_all = "lowercase")]
117pub enum Orientation {
118    Vertical,
119    Horizontal,
120}
121
122// --- FieldRef ---
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125#[serde(untagged)]
126pub enum FieldRef {
127    Simple(String),
128    Detailed(Box<FieldSpec>),
129    Multiple(Vec<FieldRefItem>),
130}
131
132/// Items within a FieldRef::Multiple array — each can be a string or a detailed spec.
133#[derive(Debug, Clone, Deserialize, Serialize)]
134#[serde(untagged)]
135pub enum FieldRefItem {
136    Simple(String),
137    Detailed(Box<FieldSpec>),
138}
139
140/// A row/column field specification.
141///
142/// `field` is `Option<String>` rather than `String` because range marks —
143/// used for shading forecast confidence bands on line charts — have no single
144/// field name, only `upper`/`lower` bound field names. A range-mark item
145/// looks like `{ mark: range, upper: upper_bound, lower: lower_bound }`.
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct FieldSpec {
149    pub field: Option<String>,
150    pub mark: Option<String>,
151    pub axis: Option<String>,
152    pub label: Option<String>,
153    pub color: Option<String>,
154    pub format: Option<String>,
155    pub data_labels: Option<DataLabelsSpec>,
156    /// Line style: "solid" (default), "dashed", "dotted"
157    pub line_style: Option<String>,
158    /// For range marks: upper bound field name
159    pub upper: Option<String>,
160    /// For range marks: lower bound field name
161    pub lower: Option<String>,
162    /// Fill opacity for range marks (default 0.15)
163    pub opacity: Option<f64>,
164}
165
166
167// --- DataLabelsSpec ---
168
169#[derive(Debug, Clone, Deserialize, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct DataLabelsSpec {
172    pub show: Option<bool>,
173    pub position: Option<String>,
174    pub format: Option<String>,
175    pub color: Option<String>,
176    pub font_size: Option<f64>,
177}
178
179// --- MarksSpec ---
180
181#[derive(Debug, Clone, Deserialize, Serialize)]
182#[serde(rename_all = "camelCase")]
183pub struct MarksSpec {
184    pub color: Option<MarkEncoding>,
185    pub size: Option<MarkEncoding>,
186    pub shape: Option<MarkEncoding>,
187    pub text: Option<MarkEncoding>,
188}
189
190#[derive(Debug, Clone, Deserialize, Serialize)]
191#[serde(untagged)]
192pub enum MarkEncoding {
193    Simple(String),
194    Detailed(MarkEncodingSpec),
195}
196
197#[derive(Debug, Clone, Deserialize, Serialize)]
198#[serde(rename_all = "camelCase")]
199pub struct MarkEncodingSpec {
200    pub field: String,
201    pub label: Option<String>,
202    pub format: Option<String>,
203}
204
205// --- AxesSpec ---
206
207#[derive(Debug, Clone, Deserialize, Serialize)]
208#[serde(rename_all = "camelCase")]
209pub struct AxesSpec {
210    #[serde(alias = "columns", alias = "bottom")]
211    pub x: Option<AxisSpec>,
212    #[serde(alias = "rows")]
213    pub left: Option<AxisSpec>,
214    pub right: Option<AxisSpec>,
215}
216
217#[derive(Debug, Clone, Deserialize, Serialize)]
218#[serde(rename_all = "camelCase")]
219pub struct AxisSpec {
220    pub label: Option<String>,
221    pub format: Option<String>,
222    pub min: Option<f64>,
223    pub max: Option<f64>,
224    pub nice: Option<bool>,
225}
226
227// --- AnnotationSpec ---
228
229#[derive(Debug, Clone, Deserialize, Serialize)]
230#[serde(rename_all = "camelCase")]
231pub struct AnnotationSpec {
232    #[serde(rename = "type")]
233    pub annotation_type: String,
234    pub axis: Option<String>,
235    pub value: Option<serde_json::Value>,
236    pub from: Option<serde_json::Value>,
237    pub to: Option<serde_json::Value>,
238    pub orientation: Option<String>,
239    pub label: Option<String>,
240    pub label_position: Option<String>,
241    pub color: Option<String>,
242    pub stroke_width: Option<f64>,
243    pub dash_array: Option<String>,
244    pub opacity: Option<f64>,
245    pub stroke_color: Option<String>,
246    /// Line style shorthand: "solid" (default), "dashed", "dotted"
247    pub style: Option<String>,
248}
249
250// --- ChartStyleSpec ---
251
252#[derive(Debug, Clone, Deserialize, Serialize)]
253#[serde(rename_all = "camelCase")]
254pub struct ChartStyleSpec {
255    pub height: Option<f64>,
256    pub width: Option<f64>,
257    /// Color palette for chart series.
258    ///
259    /// Colors are assigned **per series**, not per category/bar:
260    /// - Single-series chart (no `marks.color`): ALL bars/points use `colors[0]`
261    /// - Multi-series chart (`marks.color` or multiple `rows`): each series gets
262    ///   `colors[series_index]`, cycling if more series than colors
263    ///
264    /// This matches the JS chartml behavior (d3ChartMapper.js lines 139-146).
265    pub colors: Option<Vec<String>>,
266    pub grid: Option<GridSpec>,
267    pub show_dots: Option<bool>,
268    pub stroke_width: Option<f64>,
269    pub curve_type: Option<String>,
270    pub fill_opacity: Option<f64>,
271    pub fonts: Option<FontsSpec>,
272    pub legend: Option<LegendSpec>,
273    /// Table-only: rows per page (default 50). Ignored by non-table chart types.
274    pub page_size: Option<usize>,
275}
276
277// --- LayoutSpec ---
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub struct LayoutSpec {
282    pub col_span: Option<u32>,
283}