Skip to main content

plotkit_core/
theme.rs

1//! Theme system controlling all visual defaults.
2//!
3//! A plot rendered with zero custom styling must look professional. The
4//! [`Theme::default()`] configuration follows the Visual Design Brief:
5//!
6//! - White background, despined axes (top + right hidden), light grid behind data.
7//! - Tableau-10 categorical palette, viridis continuous colormap.
8//! - Outward ticks, readable font sizes (title 14 pt bold, labels 11 pt, ticks 9 pt).
9//!
10//! Additional built-in themes are available via [`Theme::dark()`],
11//! [`Theme::seaborn()`], [`Theme::ggplot()`], and [`Theme::publication()`].
12
13use crate::primitives::{Color, FontWeight};
14
15// ---------------------------------------------------------------------------
16// LineStyle
17// ---------------------------------------------------------------------------
18
19/// Line drawing style.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum LineStyle {
23    /// A continuous solid line.
24    Solid,
25    /// A dashed line (e.g. `[6, 4]`).
26    Dashed,
27    /// A dotted line (e.g. `[2, 2]`).
28    Dotted,
29    /// Alternating long dash and dot (e.g. `[6, 3, 2, 3]`).
30    DashDot,
31}
32
33// ---------------------------------------------------------------------------
34// Marker
35// ---------------------------------------------------------------------------
36
37/// Scatter plot marker shape.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum Marker {
41    /// Filled circle.
42    Circle,
43    /// Filled square.
44    Square,
45    /// Filled upward-pointing triangle.
46    Triangle,
47    /// Filled diamond (rotated square).
48    Diamond,
49    /// Axis-aligned plus sign (stroked, not filled).
50    Plus,
51    /// Diagonal cross / X (stroked, not filled).
52    Cross,
53    /// Five-pointed star.
54    Star,
55    /// A single pixel-sized point (smallest possible marker).
56    Point,
57}
58
59// ---------------------------------------------------------------------------
60// Loc (legend location)
61// ---------------------------------------------------------------------------
62
63/// Legend location relative to the axes area.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum Loc {
67    /// Automatically choose the location that overlaps the fewest data points.
68    Best,
69    /// Upper-right corner.
70    UpperRight,
71    /// Upper-left corner.
72    UpperLeft,
73    /// Lower-left corner.
74    LowerLeft,
75    /// Lower-right corner.
76    LowerRight,
77    /// Centered on the right edge.
78    Right,
79    /// Centered on the left edge.
80    CenterLeft,
81    /// Centered on the right edge (alias kept for symmetry).
82    CenterRight,
83    /// Centered on the bottom edge.
84    LowerCenter,
85    /// Centered on the top edge.
86    UpperCenter,
87    /// Dead center of the axes area.
88    Center,
89}
90
91// ---------------------------------------------------------------------------
92// GridAxis
93// ---------------------------------------------------------------------------
94
95/// Which axes should display grid lines.
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
97pub enum GridAxis {
98    /// Grid lines for the x-axis only (vertical lines at each x-tick).
99    X,
100    /// Grid lines for the y-axis only (horizontal lines at each y-tick).
101    Y,
102    /// Grid lines for both axes (the default).
103    #[default]
104    Both,
105}
106
107// ---------------------------------------------------------------------------
108// TickDirection
109// ---------------------------------------------------------------------------
110
111/// Direction in which axis tick marks extend from the spine.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum TickDirection {
114    /// Ticks extend outward, away from the data area.
115    Outward,
116    /// Ticks extend inward, into the data area.
117    Inward,
118}
119
120// ---------------------------------------------------------------------------
121// Theme
122// ---------------------------------------------------------------------------
123
124/// Visual theme controlling all rendering defaults.
125///
126/// Every visual parameter that a renderer or layout engine might need lives
127/// here. Chart builders read from the active theme to fill in any value the
128/// user did not override explicitly.
129#[derive(Debug, Clone)]
130pub struct Theme {
131    /// Background color for the entire figure (outside the axes area).
132    pub figure_background: Color,
133    /// Background color for the axes face (the data-drawing region).
134    pub axes_background: Color,
135
136    // -- Grid ---------------------------------------------------------------
137    /// Color of major grid lines.
138    pub grid_color: Color,
139    /// Width (in px) of major grid lines.
140    pub grid_width: f64,
141    /// Whether the grid is shown by default (line/scatter: true; bar/hist: false).
142    pub show_grid: bool,
143
144    // -- Spines -------------------------------------------------------------
145    /// Color of visible axis spines.
146    pub spine_color: Color,
147    /// Width (in px) of axis spines.
148    pub spine_width: f64,
149    /// Whether the top spine is drawn.
150    pub show_top_spine: bool,
151    /// Whether the right spine is drawn.
152    pub show_right_spine: bool,
153    /// Whether the bottom spine is drawn.
154    pub show_bottom_spine: bool,
155    /// Whether the left spine is drawn.
156    pub show_left_spine: bool,
157
158    // -- Ticks --------------------------------------------------------------
159    /// Color of tick marks and tick labels.
160    pub tick_color: Color,
161    /// Length (in px) of major tick marks.
162    pub tick_length: f64,
163    /// Direction ticks extend from the spine.
164    pub tick_direction: TickDirection,
165    /// Font size (in pt) for tick labels.
166    pub tick_label_size: f64,
167
168    // -- Labels & Title -----------------------------------------------------
169    /// Font size (in pt) for axis labels.
170    pub axis_label_size: f64,
171    /// Font size (in pt) for the plot title.
172    pub title_size: f64,
173    /// Font weight for the plot title.
174    pub title_weight: FontWeight,
175    /// Color used for all text (titles, labels, tick labels).
176    pub text_color: Color,
177
178    // -- Data elements ------------------------------------------------------
179    /// Default line width (in px) for line plots.
180    pub line_width: f64,
181    /// Default marker diameter (in px) for scatter plots.
182    pub marker_size: f64,
183    /// Default marker opacity (0.0 = fully transparent, 1.0 = fully opaque).
184    pub marker_alpha: f64,
185
186    // -- Palette ------------------------------------------------------------
187    /// Categorical color cycle used when the user does not specify colors.
188    pub color_cycle: Vec<Color>,
189
190    // -- Font ---------------------------------------------------------------
191    /// Optional font family override. `None` means the renderer picks its
192    /// built-in default (typically a clean sans-serif such as Helvetica).
193    pub font_family: Option<String>,
194}
195
196// ---------------------------------------------------------------------------
197// Tableau-10 palette (convenience constant)
198// ---------------------------------------------------------------------------
199
200/// The Tableau-10 categorical palette as a fixed-size array.
201const TABLEAU_10: [Color; 10] = Color::TABLEAU_10;
202
203// ---------------------------------------------------------------------------
204// Default theme
205// ---------------------------------------------------------------------------
206
207impl Default for Theme {
208    /// Returns the canonical default theme matching the Visual Design Brief.
209    ///
210    /// - Background: `#FFFFFF`, axes face: `#FFFFFF`
211    /// - Grid: `#E6E6E6`, 1 px, shown by default
212    /// - Spines: `#333333`, 1 px, top + right hidden (despine look)
213    /// - Ticks: outward, 4 px, `#333333`
214    /// - Font sizes: title 14 pt bold, axis labels 11 pt, tick labels 9 pt
215    /// - Text color: `#333333`
216    /// - Line width 1.5 px, marker 6 px diameter, marker alpha 0.8
217    /// - Tableau-10 color cycle
218    fn default() -> Self {
219        let spine = Color::rgb(0x33, 0x33, 0x33);
220
221        Self {
222            figure_background: Color::WHITE,
223            axes_background: Color::WHITE,
224
225            grid_color: Color::rgb(0xE6, 0xE6, 0xE6),
226            grid_width: 1.0,
227            show_grid: true,
228
229            spine_color: spine,
230            spine_width: 1.0,
231            show_top_spine: false,
232            show_right_spine: false,
233            show_bottom_spine: true,
234            show_left_spine: true,
235
236            tick_color: spine,
237            tick_length: 4.0,
238            tick_direction: TickDirection::Outward,
239            tick_label_size: 9.0,
240
241            axis_label_size: 11.0,
242            title_size: 14.0,
243            title_weight: FontWeight::Bold,
244            text_color: spine,
245
246            line_width: 1.5,
247            marker_size: 6.0,
248            marker_alpha: 0.8,
249
250            color_cycle: TABLEAU_10.to_vec(),
251
252            font_family: None,
253        }
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Named themes
259// ---------------------------------------------------------------------------
260
261impl Theme {
262    /// Dark theme with a near-black background and bright, neon-ish data colors.
263    ///
264    /// Suited for dashboards and presentations on dark backgrounds.
265    pub fn dark() -> Self {
266        let bg = Color::rgb(0x1C, 0x1C, 0x1C);
267        let text = Color::rgb(0xE0, 0xE0, 0xE0);
268        let grid = Color::rgb(0x3A, 0x3A, 0x3A);
269        let spine = Color::rgb(0x55, 0x55, 0x55);
270
271        // Bright / neon-ish palette optimised for dark backgrounds.
272        let cycle = vec![
273            Color::rgb(0x00, 0xD4, 0xFF), // cyan
274            Color::rgb(0xFF, 0x6F, 0x61), // coral-red
275            Color::rgb(0x7B, 0xED, 0x72), // lime-green
276            Color::rgb(0xFF, 0xA6, 0x00), // amber
277            Color::rgb(0xD1, 0x7D, 0xFF), // violet
278            Color::rgb(0xFF, 0xE1, 0x00), // yellow
279            Color::rgb(0x00, 0xFF, 0xAB), // mint
280            Color::rgb(0xFF, 0x4D, 0xA6), // hot-pink
281            Color::rgb(0x48, 0xBF, 0xE3), // sky-blue
282            Color::rgb(0xE8, 0xE8, 0xE8), // light-grey
283        ];
284
285        Self {
286            figure_background: bg,
287            axes_background: bg,
288
289            grid_color: grid,
290            grid_width: 1.0,
291            show_grid: true,
292
293            spine_color: spine,
294            spine_width: 1.0,
295            show_top_spine: false,
296            show_right_spine: false,
297            show_bottom_spine: true,
298            show_left_spine: true,
299
300            tick_color: text,
301            tick_length: 4.0,
302            tick_direction: TickDirection::Outward,
303            tick_label_size: 9.0,
304
305            axis_label_size: 11.0,
306            title_size: 14.0,
307            title_weight: FontWeight::Bold,
308            text_color: text,
309
310            line_width: 1.5,
311            marker_size: 6.0,
312            marker_alpha: 0.9,
313
314            color_cycle: cycle,
315
316            font_family: None,
317        }
318    }
319
320    /// Seaborn-inspired theme with a tinted axes background and white grid.
321    ///
322    /// Mimics the popular seaborn `"whitegrid"` aesthetic: a pale blue-grey
323    /// axes face (`#EAEAF2`) with white grid lines over it.
324    pub fn seaborn() -> Self {
325        let text = Color::rgb(0x33, 0x33, 0x33);
326        let axes_bg = Color::rgb(0xEA, 0xEA, 0xF2);
327
328        Self {
329            figure_background: Color::WHITE,
330            axes_background: axes_bg,
331
332            grid_color: Color::WHITE,
333            grid_width: 1.0,
334            show_grid: true,
335
336            spine_color: Color::rgb(0xCC, 0xCC, 0xCC),
337            spine_width: 1.0,
338            show_top_spine: false,
339            show_right_spine: false,
340            show_bottom_spine: true,
341            show_left_spine: true,
342
343            tick_color: text,
344            tick_length: 0.0, // seaborn typically hides tick marks
345            tick_direction: TickDirection::Outward,
346            tick_label_size: 9.0,
347
348            axis_label_size: 11.0,
349            title_size: 14.0,
350            title_weight: FontWeight::Bold,
351            text_color: text,
352
353            line_width: 1.5,
354            marker_size: 6.0,
355            marker_alpha: 0.8,
356
357            color_cycle: TABLEAU_10.to_vec(),
358
359            font_family: None,
360        }
361    }
362
363    /// ggplot2-inspired theme with a grey panel and white grid.
364    ///
365    /// Reproduces the characteristic look of R's ggplot2: a medium-grey panel
366    /// (`#E5E5E5`), white major grid lines, all four spines hidden, and the
367    /// ggplot2 default qualitative palette.
368    pub fn ggplot() -> Self {
369        let panel = Color::rgb(0xE5, 0xE5, 0xE5);
370        let text = Color::rgb(0x30, 0x30, 0x30);
371
372        // Classic ggplot2 qualitative palette (first 8 hues at C=100, L=65).
373        let cycle = vec![
374            Color::rgb(0xF8, 0x76, 0x6D), // red
375            Color::rgb(0xA3, 0xA5, 0x00), // olive-yellow
376            Color::rgb(0x00, 0xBA, 0x38), // green
377            Color::rgb(0x00, 0xBF, 0xC4), // teal
378            Color::rgb(0x61, 0x9C, 0xFF), // blue
379            Color::rgb(0xF5, 0x64, 0xE3), // magenta
380            Color::rgb(0xFF, 0x64, 0xB0), // pink
381            Color::rgb(0xB7, 0x9F, 0x00), // gold
382        ];
383
384        Self {
385            figure_background: Color::WHITE,
386            axes_background: panel,
387
388            grid_color: Color::WHITE,
389            grid_width: 1.0,
390            show_grid: true,
391
392            // ggplot2 hides all spines by default.
393            spine_color: Color::WHITE,
394            spine_width: 0.0,
395            show_top_spine: false,
396            show_right_spine: false,
397            show_bottom_spine: false,
398            show_left_spine: false,
399
400            tick_color: text,
401            tick_length: 0.0, // no visible ticks in ggplot2 default
402            tick_direction: TickDirection::Outward,
403            tick_label_size: 9.0,
404
405            axis_label_size: 11.0,
406            title_size: 14.0,
407            title_weight: FontWeight::Bold,
408            text_color: text,
409
410            line_width: 1.0,
411            marker_size: 5.0,
412            marker_alpha: 1.0,
413
414            color_cycle: cycle,
415
416            font_family: None,
417        }
418    }
419
420    /// Publication-ready theme: crisp, minimal, and suitable for print.
421    ///
422    /// - Pure white background, no grid.
423    /// - All four spines visible but thin (0.5 px) in near-black.
424    /// - Inward ticks for a compact footprint.
425    /// - Serif font family for journal aesthetics.
426    pub fn publication() -> Self {
427        let ink = Color::rgb(0x1A, 0x1A, 0x1A);
428
429        Self {
430            figure_background: Color::WHITE,
431            axes_background: Color::WHITE,
432
433            grid_color: Color::rgb(0xD0, 0xD0, 0xD0),
434            grid_width: 0.5,
435            show_grid: false,
436
437            spine_color: ink,
438            spine_width: 0.5,
439            show_top_spine: true,
440            show_right_spine: true,
441            show_bottom_spine: true,
442            show_left_spine: true,
443
444            tick_color: ink,
445            tick_length: 3.0,
446            tick_direction: TickDirection::Inward,
447            tick_label_size: 8.0,
448
449            axis_label_size: 10.0,
450            title_size: 12.0,
451            title_weight: FontWeight::Bold,
452            text_color: ink,
453
454            line_width: 1.0,
455            marker_size: 4.0,
456            marker_alpha: 1.0,
457
458            color_cycle: TABLEAU_10.to_vec(),
459
460            font_family: Some("serif".to_string()),
461        }
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Tests
467// ---------------------------------------------------------------------------
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn default_theme_background_is_white() {
475        let t = Theme::default();
476        assert_eq!(t.figure_background, Color::WHITE);
477        assert_eq!(t.axes_background, Color::WHITE);
478    }
479
480    #[test]
481    fn default_theme_despine_look() {
482        let t = Theme::default();
483        assert!(!t.show_top_spine);
484        assert!(!t.show_right_spine);
485        assert!(t.show_bottom_spine);
486        assert!(t.show_left_spine);
487    }
488
489    #[test]
490    fn default_theme_grid() {
491        let t = Theme::default();
492        assert_eq!(t.grid_color, Color::rgb(0xE6, 0xE6, 0xE6));
493        assert!((t.grid_width - 1.0).abs() < f64::EPSILON);
494        assert!(t.show_grid);
495    }
496
497    #[test]
498    fn default_theme_spines() {
499        let t = Theme::default();
500        let expected = Color::rgb(0x33, 0x33, 0x33);
501        assert_eq!(t.spine_color, expected);
502        assert!((t.spine_width - 1.0).abs() < f64::EPSILON);
503    }
504
505    #[test]
506    fn default_theme_ticks() {
507        let t = Theme::default();
508        assert_eq!(t.tick_color, Color::rgb(0x33, 0x33, 0x33));
509        assert!((t.tick_length - 4.0).abs() < f64::EPSILON);
510        assert_eq!(t.tick_direction, TickDirection::Outward);
511    }
512
513    #[test]
514    fn default_theme_font_sizes() {
515        let t = Theme::default();
516        assert!((t.tick_label_size - 9.0).abs() < f64::EPSILON);
517        assert!((t.axis_label_size - 11.0).abs() < f64::EPSILON);
518        assert!((t.title_size - 14.0).abs() < f64::EPSILON);
519        assert_eq!(t.title_weight, FontWeight::Bold);
520    }
521
522    #[test]
523    fn default_theme_text_color() {
524        let t = Theme::default();
525        assert_eq!(t.text_color, Color::rgb(0x33, 0x33, 0x33));
526    }
527
528    #[test]
529    fn default_theme_data_defaults() {
530        let t = Theme::default();
531        assert!((t.line_width - 1.5).abs() < f64::EPSILON);
532        assert!((t.marker_size - 6.0).abs() < f64::EPSILON);
533        assert!((t.marker_alpha - 0.8).abs() < f64::EPSILON);
534    }
535
536    #[test]
537    fn default_theme_tableau_10_cycle() {
538        let t = Theme::default();
539        assert_eq!(t.color_cycle.len(), 10);
540        assert_eq!(t.color_cycle[0], Color::TAB_BLUE);
541        assert_eq!(t.color_cycle[9], Color::TAB_CYAN);
542    }
543
544    #[test]
545    fn dark_theme_has_dark_background() {
546        let t = Theme::dark();
547        assert_eq!(t.figure_background, Color::rgb(0x1C, 0x1C, 0x1C));
548        assert_eq!(t.axes_background, Color::rgb(0x1C, 0x1C, 0x1C));
549    }
550
551    #[test]
552    fn dark_theme_light_text() {
553        let t = Theme::dark();
554        assert_eq!(t.text_color, Color::rgb(0xE0, 0xE0, 0xE0));
555    }
556
557    #[test]
558    fn dark_theme_neon_cycle() {
559        let t = Theme::dark();
560        assert_eq!(t.color_cycle.len(), 10);
561        // First color is a bright cyan.
562        assert_eq!(t.color_cycle[0], Color::rgb(0x00, 0xD4, 0xFF));
563    }
564
565    #[test]
566    fn seaborn_theme_tinted_face() {
567        let t = Theme::seaborn();
568        assert_eq!(t.axes_background, Color::rgb(0xEA, 0xEA, 0xF2));
569    }
570
571    #[test]
572    fn seaborn_theme_white_grid() {
573        let t = Theme::seaborn();
574        assert_eq!(t.grid_color, Color::WHITE);
575        assert!(t.show_grid);
576    }
577
578    #[test]
579    fn ggplot_theme_grey_panel() {
580        let t = Theme::ggplot();
581        assert_eq!(t.axes_background, Color::rgb(0xE5, 0xE5, 0xE5));
582    }
583
584    #[test]
585    fn ggplot_theme_white_grid() {
586        let t = Theme::ggplot();
587        assert_eq!(t.grid_color, Color::WHITE);
588        assert!(t.show_grid);
589    }
590
591    #[test]
592    fn ggplot_theme_no_spines() {
593        let t = Theme::ggplot();
594        assert!(!t.show_top_spine);
595        assert!(!t.show_right_spine);
596        assert!(!t.show_bottom_spine);
597        assert!(!t.show_left_spine);
598    }
599
600    #[test]
601    fn ggplot_theme_palette() {
602        let t = Theme::ggplot();
603        assert_eq!(t.color_cycle.len(), 8);
604        assert_eq!(t.color_cycle[0], Color::rgb(0xF8, 0x76, 0x6D));
605    }
606
607    #[test]
608    fn publication_theme_all_spines_visible() {
609        let t = Theme::publication();
610        assert!(t.show_top_spine);
611        assert!(t.show_right_spine);
612        assert!(t.show_bottom_spine);
613        assert!(t.show_left_spine);
614    }
615
616    #[test]
617    fn publication_theme_no_grid() {
618        let t = Theme::publication();
619        assert!(!t.show_grid);
620    }
621
622    #[test]
623    fn publication_theme_thin_spines() {
624        let t = Theme::publication();
625        assert!((t.spine_width - 0.5).abs() < f64::EPSILON);
626    }
627
628    #[test]
629    fn publication_theme_inward_ticks() {
630        let t = Theme::publication();
631        assert_eq!(t.tick_direction, TickDirection::Inward);
632    }
633
634    #[test]
635    fn publication_theme_serif_font() {
636        let t = Theme::publication();
637        assert_eq!(t.font_family, Some("serif".to_string()));
638    }
639
640    #[test]
641    fn publication_theme_white_background() {
642        let t = Theme::publication();
643        assert_eq!(t.figure_background, Color::WHITE);
644        assert_eq!(t.axes_background, Color::WHITE);
645    }
646
647    #[test]
648    fn grid_axis_default_is_both() {
649        assert_eq!(GridAxis::default(), GridAxis::Both);
650    }
651}