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