chartml-core 4.1.0

ChartML core library: YAML parser, plugin system, element tree, data model
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
//! Chart theme — colors, typography, and shape defaults for chart chrome.
//!
//! The `Theme` struct provides all chrome properties used by chart renderers:
//! colors (axes, grid, text, background), typography (fonts and sizes for
//! titles, labels, and numeric tick labels), and shape defaults (stroke
//! weights, dot radii, corner radii, grid styling).
//!
//! Plain values are written directly into SVG attributes, ensuring
//! compatibility with every SVG renderer (browsers, resvg, Inkscape, etc.).
//!
//! ## Browser theming
//!
//! When charts are rendered in a browser, a `<style>` block inside the SVG
//! maps element classes to CSS custom properties:
//!
//! ```css
//! .axis-line { stroke: var(--chartml-axis-line) }
//! .grid-line { stroke: var(--chartml-grid) }
//! ```
//!
//! CSS specificity means these override the inline attribute defaults, so
//! consuming apps can set `--chartml-axis-line: #9ca3af` on a parent element
//! and charts respond instantly — no re-render needed.
//!
//! ## Server-side rendering
//!
//! For server-side rendering (e.g. `render_to_png()`), pass a `Theme` that
//! matches your application's current appearance. The same `Theme` used
//! server-side should match the CSS custom properties set browser-side,
//! ensuring visual parity between both rendering paths.
//!
//! ## Example
//!
//! ```rust
//! use chartml_core::theme::Theme;
//!
//! // Light mode (default)
//! let light = Theme::default();
//!
//! // Dark mode
//! let dark = Theme::dark();
//!
//! // Custom theme — `Theme` is `#[non_exhaustive]`, so consumers must
//! // start from `Theme::default()` or `Theme::dark()` and mutate fields.
//! // This makes adding new theme fields non-breaking forever.
//! let mut custom = Theme::dark();
//! custom.axis_line = "#9ca3af".into();
//! custom.grid = "#374151".into();
//! ```

/// Grid line style — controls which gridlines are drawn.
#[derive(Debug, Clone, PartialEq)]
pub enum GridStyle {
    /// Draw both horizontal and vertical gridlines (current default behavior).
    Both,
    /// Draw only horizontal gridlines.
    HorizontalOnly,
    /// Draw only vertical gridlines.
    VerticalOnly,
    /// Do not draw gridlines.
    None,
}

/// Text transform applied to label text (tick labels, axis labels, legend).
#[derive(Debug, Clone, PartialEq)]
pub enum TextTransform {
    /// No transform — render text as-is.
    None,
    /// Transform to uppercase.
    Uppercase,
    /// Transform to lowercase.
    Lowercase,
}

/// Specification for the zero-line (baseline) overlay on value axes.
#[derive(Debug, Clone, PartialEq)]
pub struct ZeroLineSpec {
    /// Stroke color for the zero line.
    pub color: String,
    /// Stroke width in pixels.
    pub width: f32,
}

/// Which corners of a bar rect are rounded.
///
/// Default is `Uniform(0.0)` — no rounding — preserving the legacy square-bar
/// behavior. `Uniform(r)` rounds all four corners equally; `Top(r)` rounds
/// only the two corners at the maximum-value end of the bar (the top of a
/// vertical bar, the right end of a horizontal bar, and — for bars with
/// negative values — the end that points away from the zero baseline).
#[derive(Debug, Clone, PartialEq)]
pub enum BarCornerRadius {
    /// Round all four corners uniformly with the given radius.
    Uniform(f32),
    /// Round only the "top" corners — the two corners at the maximum-value
    /// end of the bar. For a vertical bar this is the top of the rect; for a
    /// horizontal bar this is the right side (the end pointing away from the
    /// category axis). For bars with negative values, the rounded corners
    /// flip to the opposite end (the side pointing away from zero).
    Top(f32),
}

impl Default for BarCornerRadius {
    fn default() -> Self {
        Self::Uniform(0.0)
    }
}

/// Chart theme — colors, typography, and shape defaults.
///
/// Color fields are CSS color strings (typically hex like `"#374151"`) that
/// are written directly into SVG `stroke` and `fill` attributes.
///
/// ## Line weight audit (Phase 2)
///
/// Defaults for the stroke-weight fields were chosen by auditing every
/// hardcoded `stroke_width: Some(X.0)` across the renderer crates and
/// categorizing each by role. The audit found:
///
/// - **axis_line_weight = 1.0** — universal across
///   `chartml-chart-cartesian/src/helpers.rs` (lines 419, 684, 756, 866, 957)
///   and `chartml-chart-scatter/src/lib.rs` (lines 237, 242). All axis lines
///   currently use 1.0. (Tick marks also use 1.0 today and reuse this field.)
/// - **grid_line_weight = 1.0** — universal across
///   `chartml-chart-cartesian/src/helpers.rs` (lines 434, 780, 982) and
///   `chartml-chart-scatter/src/lib.rs` (lines 168, 211).
/// - **series_line_weight = 2.0** — majority value in
///   `chartml-chart-cartesian/src/{line.rs:454, 563, 635}`, `area.rs:{212, 343, 481}`,
///   and `bar.rs:1258` (combo line).
///   Outlier: legend line symbols use 2.5 in `bar.rs:1352` and
///   `chartml-core/src/layout/legend.rs:220` — this is a legend-specific glyph
///   weight, not a series weight, and is intentionally not folded in.
///   NOT included: `chartml-chart-pie/src/lib.rs:75` (pie slice border). The
///   pie slice border uses `theme.bg` as its color — it is a background-colored
///   separator gap between slices, not a series mark, and must NOT be wired to
///   `series_line_weight` in later phases.
/// - **annotation_line_weight = 1.0** — annotations currently read their
///   stroke width from the spec (`ann.stroke_width` in `helpers.rs:1348, 1382`);
///   no hardcoded default exists. 1.0 is chosen as the natural fallback for
///   a future "annotation default" path (reference lines, brackets, etc.).
/// - **bar_corner_radius = BarCornerRadius::Uniform(0.0)** — default is no
///   rounding (byte-identical to pre-3.1 behavior). `Uniform(r)` rounds all
///   four corners; `Top(r)` rounds only the two corners at the max-value end
///   of the bar (see `BarCornerRadius` docs).
/// - **dot_radius = 5.0** — matches `chartml-chart-scatter/src/lib.rs:106`
///   (default when no size field) and line endpoint markers in
///   `chartml-chart-cartesian/src/line.rs:{466, 577, 649}` and
///   `bar.rs:1268` (combo line dots).
///
/// ## Construction contract
///
/// `Theme` is `#[non_exhaustive]`: external crates cannot construct it with
/// a struct literal *or* the functional-update spread syntax. They must start
/// from `Theme::default()` (or `Theme::dark()`) and mutate fields:
///
/// ```ignore
/// let mut theme = Theme::default();
/// theme.axis_line = "#9ca3af".into();
/// theme.grid = "#374151".into();
/// ```
///
/// This makes adding new fields genuinely non-breaking forever — every
/// downstream consumer that follows the mutate-after-default pattern keeps
/// compiling regardless of how many fields land in `Theme` later.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Theme {
    // ----- Chrome colors -----
    /// Primary text color (metric values, param controls).
    pub text: String,
    /// Secondary text color (tick labels, axis labels, legend labels).
    pub text_secondary: String,
    /// Strong/emphasized text color (chart titles).
    pub text_strong: String,
    /// Axis line strokes (the main horizontal/vertical axis lines).
    pub axis_line: String,
    /// Tick mark strokes (small marks at each tick position).
    pub tick: String,
    /// Grid line strokes.
    pub grid: String,
    /// Background-aware stroke for element separators
    /// (pie slice borders, dot outlines). Should match the chart background.
    pub bg: String,

    // ----- Typography: title -----
    /// Font family for chart titles.
    pub title_font_family: String,
    /// Font size (px) for chart titles.
    pub title_font_size: f32,
    /// Font weight for chart titles.
    pub title_font_weight: u16,
    /// Font style (`"normal"` / `"italic"`) for chart titles.
    pub title_font_style: String,

    // ----- Typography: labels (tick labels, axis labels, data labels) -----
    /// Font family for tick and axis labels.
    pub label_font_family: String,
    /// Font size (px) for tick and axis labels.
    pub label_font_size: f32,
    /// Font weight for tick and axis labels.
    pub label_font_weight: u16,
    /// Extra letter spacing (px) applied to labels.
    pub label_letter_spacing: f32,
    /// Text transform applied to labels.
    pub label_text_transform: TextTransform,

    // ----- Typography: numeric tick labels -----
    /// Font family for numeric tick labels (e.g. a tabular/monospaced face).
    pub numeric_font_family: String,
    /// Font size (px) for numeric tick labels.
    pub numeric_font_size: f32,

    // ----- Typography: legend -----
    /// Font family for legend labels.
    pub legend_font_family: String,
    /// Font size (px) for legend labels.
    pub legend_font_size: f32,
    /// Font weight for legend labels.
    pub legend_font_weight: u16,

    // ----- Shape / stroke -----
    /// Stroke width for axis lines. See audit in struct-level doc.
    pub axis_line_weight: f32,
    /// Stroke width for grid lines. See audit in struct-level doc.
    pub grid_line_weight: f32,
    /// Stroke width for series marks (line paths, area outlines, combo line).
    /// Pie slice borders are NOT series marks — they use `theme.bg` as a
    /// background-colored separator. See audit in struct-level doc.
    pub series_line_weight: f32,
    /// Stroke width for annotation lines (reference lines, brackets).
    /// See audit in struct-level doc.
    pub annotation_line_weight: f32,
    /// Corner radius for bar rects. Supports uniform rounding (all four
    /// corners) and top-only rounding (the two corners at the max-value end
    /// of the bar). When the enclosed radius is `0.0`, renderers MUST NOT
    /// emit any `rx`/`ry` attribute and MUST emit a plain `<rect>` (to
    /// preserve byte-identical output for un-themed charts).
    pub bar_corner_radius: BarCornerRadius,
    /// Default radius (px) for scatter points and line endpoint markers.
    pub dot_radius: f32,
    /// Optional halo/outline color for dots. When `None`, no halo is drawn.
    pub dot_halo_color: Option<String>,
    /// Halo stroke width (px). Only used when `dot_halo_color` is `Some`.
    pub dot_halo_width: f32,

    // ----- Grid + baseline -----
    /// Which gridlines to draw (both, horizontal-only, vertical-only, none).
    pub grid_style: GridStyle,
    /// Optional emphasized zero line on the value axis. `None` = no zero line.
    pub zero_line: Option<ZeroLineSpec>,

    // ----- Table chart tokens -----
    /// Background color for the table header row.
    pub table_header_bg: String,
    /// Text color for the table header row.
    pub table_header_text: String,
    /// Background color for a regular (non-striped) body row.
    pub table_row_bg: String,
    /// Background color for the alternating zebra-striped body row.
    pub table_row_bg_alt: String,
    /// Border color between cells and between header/body.
    pub table_border: String,
    /// Text color for body cells.
    pub table_text: String,
    /// CSS shorthand padding for body cells (e.g. `"8px 12px"`).
    pub table_cell_padding: String,
    /// CSS font size for table text (e.g. `"13px"`).
    pub table_font_size: String,
}

impl Default for Theme {
    /// Light mode theme — matches the `chartml.css` light-mode custom properties
    /// and all currently hardcoded renderer defaults.
    fn default() -> Self {
        Self {
            // Chrome colors
            text: "#374151".into(),
            text_secondary: "#6b7280".into(),
            text_strong: "#1f2937".into(),
            axis_line: "#374151".into(),
            tick: "#374151".into(),
            grid: "#e0e0e0".into(),
            bg: "#ffffff".into(),

            // Title typography
            title_font_family: "system-ui, sans-serif".into(),
            title_font_size: 14.0,
            title_font_weight: 700,
            title_font_style: "normal".into(),

            // Label typography
            label_font_family: "system-ui, sans-serif".into(),
            label_font_size: 12.0,
            label_font_weight: 400,
            label_letter_spacing: 0.0,
            label_text_transform: TextTransform::None,

            // Numeric typography
            numeric_font_family: "system-ui, sans-serif".into(),
            numeric_font_size: 12.0,

            // Legend typography
            legend_font_family: "system-ui, sans-serif".into(),
            legend_font_size: 12.0,
            legend_font_weight: 400,

            // Shape / stroke — see struct-level audit
            axis_line_weight: 1.0,
            grid_line_weight: 1.0,
            series_line_weight: 2.0,
            annotation_line_weight: 1.0,
            bar_corner_radius: BarCornerRadius::Uniform(0.0),
            dot_radius: 5.0,
            dot_halo_color: None,
            dot_halo_width: 0.0,

            // Grid + baseline
            grid_style: GridStyle::Both,
            zero_line: None,

            // Table tokens — light mode defaults
            table_header_bg: "#f9fafb".into(),
            table_header_text: "#1f2937".into(),
            table_row_bg: "#ffffff".into(),
            table_row_bg_alt: "#f9fafb".into(),
            table_border: "#e5e7eb".into(),
            table_text: "#374151".into(),
            table_cell_padding: "8px 12px".into(),
            table_font_size: "13px".into(),
        }
    }
}

impl Theme {
    /// Dark mode theme — matches the `chartml.css` dark-mode custom properties.
    ///
    /// Typography and shape defaults are identical to `Theme::default()`;
    /// chrome color fields and table tokens are overridden to dark values.
    pub fn dark() -> Self {
        Self {
            text: "#e5e7eb".into(),
            text_secondary: "#9ca3af".into(),
            text_strong: "#f3f4f6".into(),
            axis_line: "#9ca3af".into(),
            tick: "#9ca3af".into(),
            grid: "#374151".into(),
            bg: "#1f2937".into(),
            table_header_bg: "#111827".into(),
            table_header_text: "#f3f4f6".into(),
            table_row_bg: "#1f2937".into(),
            table_row_bg_alt: "#111827".into(),
            table_border: "#374151".into(),
            table_text: "#e5e7eb".into(),
            ..Theme::default()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_theme_has_no_css_var_values() {
        let theme = Theme::default();
        let all_values = [
            &theme.text, &theme.text_secondary, &theme.text_strong,
            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
        ];
        for value in all_values {
            assert!(
                !value.contains("var("),
                "Theme value should be plain hex, not CSS var(): {value}"
            );
        }
    }

    #[test]
    fn dark_theme_has_no_css_var_values() {
        let theme = Theme::dark();
        let all_values = [
            &theme.text, &theme.text_secondary, &theme.text_strong,
            &theme.axis_line, &theme.tick, &theme.grid, &theme.bg,
        ];
        for value in all_values {
            assert!(
                !value.contains("var("),
                "Dark theme value should be plain hex, not CSS var(): {value}"
            );
        }
    }

    #[test]
    fn theme_fields_are_customizable() {
        let custom = Theme {
            axis_line: "#ff0000".into(),
            ..Theme::dark()
        };
        assert_eq!(custom.axis_line, "#ff0000");
        assert_eq!(custom.grid, "#374151"); // rest from dark
    }

    // ---- Phase 2: new field default tests ----

    #[test]
    fn default_title_typography() {
        let t = Theme::default();
        assert_eq!(t.title_font_family, "system-ui, sans-serif");
        assert_eq!(t.title_font_size, 14.0);
        assert_eq!(t.title_font_weight, 700);
        assert_eq!(t.title_font_style, "normal");
    }

    #[test]
    fn default_label_typography() {
        let t = Theme::default();
        assert_eq!(t.label_font_family, "system-ui, sans-serif");
        assert_eq!(t.label_font_size, 12.0);
        assert_eq!(t.label_font_weight, 400);
        assert_eq!(t.label_letter_spacing, 0.0);
        assert_eq!(t.label_text_transform, TextTransform::None);
    }

    #[test]
    fn default_numeric_typography() {
        let t = Theme::default();
        assert_eq!(t.numeric_font_family, "system-ui, sans-serif");
        assert_eq!(t.numeric_font_size, 12.0);
    }

    #[test]
    fn default_legend_typography() {
        let t = Theme::default();
        assert_eq!(t.legend_font_family, "system-ui, sans-serif");
        assert_eq!(t.legend_font_size, 12.0);
        assert_eq!(t.legend_font_weight, 400);
    }

    #[test]
    fn default_stroke_weights_match_audit() {
        let t = Theme::default();
        assert_eq!(t.axis_line_weight, 1.0);
        assert_eq!(t.grid_line_weight, 1.0);
        assert_eq!(t.series_line_weight, 2.0);
        assert_eq!(t.annotation_line_weight, 1.0);
    }

    #[test]
    fn default_shape_fields() {
        let t = Theme::default();
        assert_eq!(t.bar_corner_radius, BarCornerRadius::Uniform(0.0));
        assert_eq!(t.dot_radius, 5.0);
        assert!(t.dot_halo_color.is_none());
        assert_eq!(t.dot_halo_width, 0.0);
    }

    #[test]
    fn default_grid_style_is_both() {
        assert_eq!(Theme::default().grid_style, GridStyle::Both);
    }

    #[test]
    fn default_zero_line_is_none() {
        assert!(Theme::default().zero_line.is_none());
    }

    #[test]
    fn dark_theme_inherits_typography_and_shape_from_default() {
        let d = Theme::default();
        let k = Theme::dark();
        // Typography
        assert_eq!(d.title_font_size, k.title_font_size);
        assert_eq!(d.label_font_weight, k.label_font_weight);
        assert_eq!(d.numeric_font_family, k.numeric_font_family);
        assert_eq!(d.legend_font_family, k.legend_font_family);
        // Shape
        assert_eq!(d.axis_line_weight, k.axis_line_weight);
        assert_eq!(d.grid_line_weight, k.grid_line_weight);
        assert_eq!(d.series_line_weight, k.series_line_weight);
        assert_eq!(d.dot_radius, k.dot_radius);
        assert_eq!(d.bar_corner_radius, k.bar_corner_radius);
        // Grid + baseline
        assert_eq!(d.grid_style, k.grid_style);
        assert_eq!(d.zero_line, k.zero_line);
    }

    #[test]
    fn custom_theme_can_override_new_fields_individually() {
        let custom = Theme {
            series_line_weight: 3.5,
            bar_corner_radius: BarCornerRadius::Uniform(4.0),
            dot_halo_color: Some("#ffffff".into()),
            dot_halo_width: 2.0,
            grid_style: GridStyle::HorizontalOnly,
            zero_line: Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 }),
            label_text_transform: TextTransform::Uppercase,
            ..Theme::default()
        };
        // Overridden
        assert_eq!(custom.series_line_weight, 3.5);
        assert_eq!(custom.bar_corner_radius, BarCornerRadius::Uniform(4.0));
        assert_eq!(custom.dot_halo_color.as_deref(), Some("#ffffff"));
        assert_eq!(custom.dot_halo_width, 2.0);
        assert_eq!(custom.grid_style, GridStyle::HorizontalOnly);
        assert_eq!(
            custom.zero_line,
            Some(ZeroLineSpec { color: "#000000".into(), width: 1.5 })
        );
        assert_eq!(custom.label_text_transform, TextTransform::Uppercase);
        // Not overridden — should match Default
        assert_eq!(custom.axis_line_weight, 1.0);
        assert_eq!(custom.dot_radius, 5.0);
        assert_eq!(custom.title_font_size, 14.0);
        assert_eq!(custom.axis_line, "#374151");
    }
}