Skip to main content

model/
preset.rs

1//! Selectable style presets — axis frame styles and series color cycles.
2//!
3//! Both enums are **fieldless** on purpose: they cross the wasm boundary as
4//! plain integers (`wasm_bindgen` exposes C-like enums directly), and a host
5//! UI can list variants in a dropdown without knowing anything else about the
6//! model. Names describe the *style*, not the tool that popularized it.
7//!
8//! Applying a preset only mutates `Config` / `SeriesConfig` — the usual dirty
9//! rules apply on top: wrap axis-preset application in
10//! `Chart::with_decoration_change` (raster only), and rebuild each affected
11//! series' GPU style (`Renderer::create_style_for_series`) after recoloring.
12
13use crate::color::Color;
14use crate::config::{AxisOptions, Config, TickVisibility};
15use crate::data_config::{DataRenderType, SeriesConfig};
16
17const fn rgb8(r: u8, g: u8, b: u8) -> Color {
18    Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
19}
20
21// ============================================================================
22// Axis presets.
23// ============================================================================
24
25/// Whole-chart axis frame styles, applied to all four axes at once via
26/// [`Config::apply_axis_preset`].
27///
28/// Every preset keeps labels and titles on the bottom/left axes only and
29/// leaves data ranges, margins, tick lengths, colors, and title text
30/// untouched — it only switches which axis lines are drawn, the tick
31/// direction, and which sides show tick-value labels.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AxisPreset {
34    /// Closed box frame with ticks pointing **inward** on all four sides —
35    /// the classic lab-instrument / scientific-plotting default.
36    BoxedInward,
37    /// Closed box frame, ticks pointing **outward** on the labeled
38    /// bottom/left axes only — the common plotting-library default.
39    BoxedOutward,
40    /// Open L-frame: bottom/left axes only, ticks outward — the typical
41    /// publication / biostatistics style.
42    OpenOutward,
43    /// Open L-frame with inward ticks — journals that want an open frame
44    /// without ticks protruding into the margins.
45    OpenInward,
46    /// No axis lines and no ticks; tick-value labels only. Pair with a grid
47    /// for a minimal, web-dashboard look.
48    Minimal,
49}
50
51/// Per-side knobs a preset controls.
52#[derive(Clone)]
53struct SideStyle {
54    line: bool,
55    tick: TickVisibility,
56    labels: bool,
57}
58
59fn apply_side(axis: &mut AxisOptions, s: SideStyle) {
60    axis.line_visible = s.line;
61    axis.tick = s.tick;
62    axis.label_style.label_visible = s.labels;
63}
64
65impl Config {
66    /// Apply `preset` to all four axes at once. See [`AxisPreset`] for what
67    /// is (and is not) touched.
68    pub fn apply_axis_preset(&mut self, preset: AxisPreset) {
69        use TickVisibility::{Inside, None as NoTick, Outside};
70
71        // (labeled = bottom/left, unlabeled = top/right) side styles.
72        let (labeled, unlabeled) = match preset {
73            AxisPreset::BoxedInward => (
74                SideStyle {
75                    line: true,
76                    tick: Inside,
77                    labels: true,
78                },
79                SideStyle {
80                    line: true,
81                    tick: Inside,
82                    labels: false,
83                },
84            ),
85            AxisPreset::BoxedOutward => (
86                SideStyle {
87                    line: true,
88                    tick: Outside,
89                    labels: true,
90                },
91                SideStyle {
92                    line: true,
93                    tick: NoTick,
94                    labels: false,
95                },
96            ),
97            AxisPreset::OpenOutward => (
98                SideStyle {
99                    line: true,
100                    tick: Outside,
101                    labels: true,
102                },
103                SideStyle {
104                    line: false,
105                    tick: NoTick,
106                    labels: false,
107                },
108            ),
109            AxisPreset::OpenInward => (
110                SideStyle {
111                    line: true,
112                    tick: Inside,
113                    labels: true,
114                },
115                SideStyle {
116                    line: false,
117                    tick: NoTick,
118                    labels: false,
119                },
120            ),
121            AxisPreset::Minimal => (
122                SideStyle {
123                    line: false,
124                    tick: NoTick,
125                    labels: true,
126                },
127                SideStyle {
128                    line: false,
129                    tick: NoTick,
130                    labels: false,
131                },
132            ),
133        };
134
135        apply_side(&mut self.bottom_x, labeled.clone());
136        apply_side(&mut self.left_y, labeled);
137        apply_side(&mut self.top_x, unlabeled.clone());
138        apply_side(&mut self.right_y, unlabeled);
139    }
140}
141
142// ============================================================================
143// Series color cycles.
144// ============================================================================
145
146/// Color rotation rules for multi-series plots. `color(i)` wraps around, so
147/// any number of series can be colored consistently.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum ColorCycle {
150    /// Strong primary rotation starting at black — the classic
151    /// lab-instrument increment list.
152    Classic,
153    /// High-saturation, high-contrast rotation — the look of biostatistics
154    /// plotting tools, optimized for telling few series apart at a glance.
155    Vivid,
156    /// Ten balanced mid-saturation hues (the public "tab10" palette) — the
157    /// modern general-purpose default.
158    Balanced,
159    /// The Okabe–Ito eight-color palette, designed to stay distinguishable
160    /// under the common forms of color-vision deficiency.
161    ColorblindSafe,
162    /// Grayscale steps for print-safe single-ink figures.
163    Monochrome,
164}
165
166const CLASSIC: &[Color] = &[
167    rgb8(0, 0, 0),      // black
168    rgb8(255, 0, 0),    // red
169    rgb8(0, 128, 0),    // green
170    rgb8(0, 0, 255),    // blue
171    rgb8(255, 0, 255),  // magenta
172    rgb8(0, 139, 139),  // dark cyan
173    rgb8(184, 134, 11), // dark yellow
174    rgb8(0, 0, 128),    // navy
175    rgb8(128, 0, 128),  // purple
176    rgb8(128, 0, 0),    // wine
177    rgb8(128, 128, 0),  // olive
178    rgb8(255, 102, 0),  // orange
179];
180
181const VIVID: &[Color] = &[
182    rgb8(0, 32, 240),  // blue
183    rgb8(230, 16, 16), // red
184    rgb8(0, 153, 0),   // green
185    rgb8(128, 0, 192), // purple
186    rgb8(255, 128, 0), // orange
187    rgb8(0, 0, 0),     // black
188    rgb8(230, 0, 172), // pink
189    rgb8(0, 153, 153), // teal
190];
191
192const BALANCED: &[Color] = &[
193    rgb8(0x1f, 0x77, 0xb4),
194    rgb8(0xff, 0x7f, 0x0e),
195    rgb8(0x2c, 0xa0, 0x2c),
196    rgb8(0xd6, 0x27, 0x28),
197    rgb8(0x94, 0x67, 0xbd),
198    rgb8(0x8c, 0x56, 0x4b),
199    rgb8(0xe3, 0x77, 0xc2),
200    rgb8(0x7f, 0x7f, 0x7f),
201    rgb8(0xbc, 0xbd, 0x22),
202    rgb8(0x17, 0xbe, 0xcf),
203];
204
205const COLORBLIND_SAFE: &[Color] = &[
206    rgb8(0xe6, 0x9f, 0x00), // orange
207    rgb8(0x56, 0xb4, 0xe9), // sky blue
208    rgb8(0x00, 0x9e, 0x73), // bluish green
209    rgb8(0xf0, 0xe4, 0x42), // yellow
210    rgb8(0x00, 0x72, 0xb2), // blue
211    rgb8(0xd5, 0x5e, 0x00), // vermillion
212    rgb8(0xcc, 0x79, 0xa7), // reddish purple
213    rgb8(0x00, 0x00, 0x00), // black
214];
215
216const MONOCHROME: &[Color] = &[
217    rgb8(0, 0, 0),
218    rgb8(89, 89, 89),
219    rgb8(140, 140, 140),
220    rgb8(191, 191, 191),
221];
222
223impl ColorCycle {
224    /// The full palette in rotation order.
225    pub fn colors(self) -> &'static [Color] {
226        match self {
227            Self::Classic => CLASSIC,
228            Self::Vivid => VIVID,
229            Self::Balanced => BALANCED,
230            Self::ColorblindSafe => COLORBLIND_SAFE,
231            Self::Monochrome => MONOCHROME,
232        }
233    }
234
235    /// Color for the `index`-th series — wraps past the palette end, so it is
236    /// total over any series count.
237    pub fn color(self, index: usize) -> Color {
238        let palette = self.colors();
239        palette[index % palette.len()]
240    }
241
242    pub fn len(self) -> usize {
243        self.colors().len()
244    }
245
246    pub fn is_empty(self) -> bool {
247        false
248    }
249
250    /// Recolor one series as the `index`-th member of this cycle: line,
251    /// scatter, and errorbar sub-styles all take the same cycle color.
252    /// Rebuild the series' GPU style afterwards
253    /// (`Renderer::create_style_for_series`).
254    pub fn apply_to_series(self, series: &mut SeriesConfig, index: usize) {
255        let c = self.color(index);
256        match &mut series.render_type {
257            DataRenderType::Scatter { scatter } => {
258                scatter.point_color = c;
259            }
260            DataRenderType::Line { line } => {
261                line.line_color = c;
262            }
263            DataRenderType::ScatterLine { scatter, line } => {
264                scatter.point_color = c;
265                line.line_color = c;
266            }
267            DataRenderType::ScatterErrorbarX {
268                scatter, err_style, ..
269            }
270            | DataRenderType::ScatterErrorbarY {
271                scatter, err_style, ..
272            }
273            | DataRenderType::ScatterErrorbarXY {
274                scatter, err_style, ..
275            } => {
276                scatter.point_color = c;
277                err_style.error_bar_color = c;
278            }
279            DataRenderType::LineScatterErrorbarX {
280                scatter,
281                line,
282                err_style,
283                ..
284            }
285            | DataRenderType::LineScatterErrorbarY {
286                scatter,
287                line,
288                err_style,
289                ..
290            }
291            | DataRenderType::LineScatterErrorbarXY {
292                scatter,
293                line,
294                err_style,
295                ..
296            } => {
297                scatter.point_color = c;
298                line.line_color = c;
299                err_style.error_bar_color = c;
300            }
301            DataRenderType::Histogram { bar } => {
302                bar.fill_color = c;
303            }
304            // The contour line joins the cycle like any other line. The fill
305            // does not: its colours come from the colormap, and overwriting one
306            // of them with a cycle entry would make the colourbar lie.
307            DataRenderType::Contour { contour, .. }
308            | DataRenderType::HeatmapContour { contour, .. } => {
309                contour.line.line_color = c;
310            }
311            DataRenderType::Heatmap { .. } => {}
312        }
313    }
314
315    /// Recolor a whole series list in order: series `i` gets `color(i)`.
316    pub fn apply_to_all(self, series: &mut [SeriesConfig]) {
317        for (i, s) in series.iter_mut().enumerate() {
318            self.apply_to_series(s, i);
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::data_config::{
327        DataErrorBarStyleConfig, DataLineStyleConfig, DataScatterStyleConfig, ErrorRef,
328        ScatterShape,
329    };
330    use crate::default::default_config;
331    use crate::line::LineStylePreset;
332
333    #[test]
334    fn boxed_inward_shows_full_frame_with_inside_ticks() {
335        let mut cfg = default_config();
336        cfg.apply_axis_preset(AxisPreset::BoxedInward);
337        for axis in [&cfg.top_x, &cfg.bottom_x, &cfg.left_y, &cfg.right_y] {
338            assert!(axis.line_visible);
339            assert_eq!(axis.tick, TickVisibility::Inside);
340        }
341        assert!(cfg.bottom_x.label_style.label_visible);
342        assert!(cfg.left_y.label_style.label_visible);
343        assert!(!cfg.top_x.label_style.label_visible);
344        assert!(!cfg.right_y.label_style.label_visible);
345    }
346
347    #[test]
348    fn open_outward_hides_top_right_entirely() {
349        let mut cfg = default_config();
350        cfg.apply_axis_preset(AxisPreset::OpenOutward);
351        assert!(cfg.bottom_x.line_visible);
352        assert_eq!(cfg.bottom_x.tick, TickVisibility::Outside);
353        assert!(!cfg.top_x.line_visible);
354        assert_eq!(cfg.top_x.tick, TickVisibility::None);
355        assert!(!cfg.right_y.line_visible);
356    }
357
358    #[test]
359    fn minimal_drops_lines_and_ticks_but_keeps_labels() {
360        let mut cfg = default_config();
361        cfg.apply_axis_preset(AxisPreset::Minimal);
362        for axis in [&cfg.top_x, &cfg.bottom_x, &cfg.left_y, &cfg.right_y] {
363            assert!(!axis.line_visible);
364            assert_eq!(axis.tick, TickVisibility::None);
365        }
366        assert!(cfg.bottom_x.label_style.label_visible);
367    }
368
369    // A preset must not disturb anything outside frame/tick/label visibility.
370    #[test]
371    fn preset_leaves_ranges_margins_titles_untouched() {
372        let mut cfg = default_config();
373        let before = cfg.clone();
374        cfg.apply_axis_preset(AxisPreset::OpenOutward);
375
376        assert_eq!(cfg.bottom_x.min, before.bottom_x.min);
377        assert_eq!(cfg.bottom_x.max, before.bottom_x.max);
378        assert_eq!(cfg.bottom_x.out_margin, before.bottom_x.out_margin);
379        assert_eq!(
380            cfg.left_y.major_tick_length,
381            before.left_y.major_tick_length
382        );
383        assert_eq!(cfg.chart_title, before.chart_title);
384        assert_eq!(cfg.grid, before.grid);
385        assert_eq!(cfg.data_area().unwrap(), before.data_area().unwrap());
386    }
387
388    #[test]
389    fn cycle_color_wraps_around() {
390        for cycle in [
391            ColorCycle::Classic,
392            ColorCycle::Vivid,
393            ColorCycle::Balanced,
394            ColorCycle::ColorblindSafe,
395            ColorCycle::Monochrome,
396        ] {
397            let n = cycle.len();
398            assert!(n >= 4, "{cycle:?} palette too small");
399            assert_eq!(cycle.color(0), cycle.color(n));
400            assert_eq!(cycle.color(3), cycle.color(3 + n * 2));
401            // Adjacent rotation entries must differ.
402            assert_ne!(cycle.color(0), cycle.color(1));
403        }
404    }
405
406    fn full_series() -> SeriesConfig {
407        SeriesConfig {
408            series_id: "s".into(),
409            source_id: None,
410            label: None,
411            x_column: "x".into(),
412            y_column: "y".into(),
413            render_type: DataRenderType::LineScatterErrorbarXY {
414                scatter: DataScatterStyleConfig {
415                    point_color: Color::BLACK,
416                    point_shape: ScatterShape::Circle,
417                    point_size: 4.0,
418                    point_style_table: None,
419                    point_style_index_column: None,
420                    point_style_overrides: None,
421                },
422                line: DataLineStyleConfig {
423                    line_style: LineStylePreset::Solid,
424                    line_color: Color::BLACK,
425                    line_width: 1.0,
426                },
427                err_x: ErrorRef::Symmetric {
428                    column: "ex".into(),
429                },
430                err_y: ErrorRef::Symmetric {
431                    column: "ey".into(),
432                },
433                err_style: DataErrorBarStyleConfig {
434                    error_bar_color: Color::BLACK,
435                    error_bar_width: 1.0,
436                    error_bar_cap_size: 3.0,
437                    cap_width: 1.0,
438                    error_bar_style_table: None,
439                    error_bar_style_index_column: None,
440                    error_bar_style_overrides: None,
441                },
442            },
443        }
444    }
445
446    #[test]
447    fn apply_to_series_recolors_every_sub_style() {
448        let mut s = full_series();
449        ColorCycle::Balanced.apply_to_series(&mut s, 2);
450        let expected = ColorCycle::Balanced.color(2);
451        let DataRenderType::LineScatterErrorbarXY {
452            scatter,
453            line,
454            err_style,
455            ..
456        } = &s.render_type
457        else {
458            unreachable!()
459        };
460        assert_eq!(scatter.point_color, expected);
461        assert_eq!(line.line_color, expected);
462        assert_eq!(err_style.error_bar_color, expected);
463    }
464
465    #[test]
466    fn apply_to_all_assigns_sequential_rotation() {
467        let mut list = vec![full_series(), full_series(), full_series()];
468        ColorCycle::Classic.apply_to_all(&mut list);
469        for (i, s) in list.iter().enumerate() {
470            let DataRenderType::LineScatterErrorbarXY { line, .. } = &s.render_type else {
471                unreachable!()
472            };
473            assert_eq!(line.line_color, ColorCycle::Classic.color(i));
474        }
475    }
476
477    // The contour line joins the cycle like any other line; the field's colours
478    // come from the colormap and the cycle must not reach into them.
479    #[test]
480    fn the_cycle_recolors_bars_and_contour_lines_but_never_a_field() {
481        use crate::data_config::{
482            BarOrientation, ContourConfig, DataBarStyleConfig, FieldFillConfig, FillMode,
483            GridLayout, MatrixOrientation, MatrixRef, Shading,
484        };
485
486        let matrix = || MatrixRef {
487            columns: vec!["z0".into()],
488            orientation: MatrixOrientation::ColumnsAreX,
489            grid_layout: GridLayout::Centers,
490        };
491        let fill = || FieldFillConfig {
492            mode: FillMode::Continuous,
493            shading: Shading::Flat,
494            opacity: 1.0,
495        };
496        let contour = || ContourConfig {
497            levels: vec![1.0],
498            line: DataLineStyleConfig {
499                line_style: LineStylePreset::Solid,
500                line_color: Color::BLACK,
501                line_width: 1.0,
502            },
503            per_level_color: None,
504            labels: None,
505        };
506        let series = |render_type| SeriesConfig {
507            series_id: "s".into(),
508            source_id: None,
509            label: None,
510            x_column: "x".into(),
511            y_column: "y".into(),
512            render_type,
513        };
514        let expected = ColorCycle::Classic.color(2);
515
516        let mut histogram = series(DataRenderType::Histogram {
517            bar: DataBarStyleConfig {
518                fill_color: Color::BLACK,
519                border_color: Color::BLACK,
520                border_width: 1.0,
521                baseline: 0.0,
522                gap_px: 1.0,
523                width_ratio: 1.0,
524                orientation: BarOrientation::Vertical,
525                bar_style_overrides: None,
526            },
527        });
528        ColorCycle::Classic.apply_to_series(&mut histogram, 2);
529        let DataRenderType::Histogram { bar } = &histogram.render_type else {
530            unreachable!()
531        };
532        assert_eq!(bar.fill_color, expected);
533
534        for render_type in [
535            DataRenderType::Contour {
536                matrix: matrix(),
537                contour: contour(),
538            },
539            DataRenderType::HeatmapContour {
540                matrix: matrix(),
541                fill: fill(),
542                contour: contour(),
543            },
544        ] {
545            let mut item = series(render_type);
546            ColorCycle::Classic.apply_to_series(&mut item, 2);
547            let line_color = match &item.render_type {
548                DataRenderType::Contour { contour, .. }
549                | DataRenderType::HeatmapContour { contour, .. } => contour.line.line_color,
550                _ => unreachable!(),
551            };
552            assert_eq!(line_color, expected);
553        }
554
555        // A fill is left exactly as declared — including inside the combined
556        // variant, where only the line was recoloured above.
557        let mut heatmap = series(DataRenderType::Heatmap {
558            matrix: matrix(),
559            fill: fill(),
560        });
561        let before = heatmap.clone();
562        ColorCycle::Classic.apply_to_series(&mut heatmap, 2);
563        assert_eq!(heatmap, before);
564    }
565}