Skip to main content

model/
data_config.rs

1//! Declarative series configuration types.
2//!
3//! Mapping layer between chart layout (`config::Config`) and raw data
4//! (`data::DataCell`). Each `SeriesConfig` says which columns are X/Y/error
5//! and which render type / style to use.
6
7use crate::color::Color;
8use crate::config::{AxisOptions, AxisScale};
9use crate::data::ColumnId;
10use crate::format::LabelFormat;
11use crate::line::LineStylePreset;
12use crate::text::RichText;
13use crate::tick::TickError;
14
15#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct DataConfig {
18    pub data_id: String,
19}
20
21/// One series — which columns to draw, with which render type and style.
22///
23/// Columns are referenced by the id used when registering them with the
24/// `ColumnPool`. `Renderer::prepare` resolves them to allocation-stamped GPU
25/// handles; the resulting `PreparedFrame` owns those handles for immutable
26/// paint recording.
27#[derive(Debug, Clone, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct SeriesConfig {
30    pub series_id: String,
31    #[cfg_attr(
32        feature = "serde",
33        serde(default, skip_serializing_if = "Option::is_none")
34    )]
35    pub source_id: Option<String>,
36    pub label: Option<RichText>,
37    /// X column id (must match the id passed to `add_column`).
38    pub x_column: ColumnId,
39    /// Y column id.
40    pub y_column: ColumnId,
41    pub render_type: DataRenderType,
42}
43
44/// Errorbar column reference: either a single column read as ±σ, or two
45/// separate columns for the lower / upper bound.
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum ErrorRef {
49    /// Column value is interpreted as ±σ (point ± column_value).
50    Symmetric { column: ColumnId },
51    /// Separate lower / upper columns (point − lower, point + upper).
52    Asymmetric { lower: ColumnId, upper: ColumnId },
53}
54
55/// Series render type. Each variant maps to one independent draw path.
56///
57/// Combinations are kept explicit (instead of optional fields on a single
58/// struct) so the renderer can pick its primitives with one `match`.
59#[derive(Debug, Clone, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum DataRenderType {
62    Scatter {
63        scatter: DataScatterStyleConfig,
64    },
65    Line {
66        line: DataLineStyleConfig,
67    },
68    ScatterLine {
69        scatter: DataScatterStyleConfig,
70        line: DataLineStyleConfig,
71    },
72    ScatterErrorbarX {
73        scatter: DataScatterStyleConfig,
74        err_x: ErrorRef,
75        err_style: DataErrorBarStyleConfig,
76    },
77    ScatterErrorbarY {
78        scatter: DataScatterStyleConfig,
79        err_y: ErrorRef,
80        err_style: DataErrorBarStyleConfig,
81    },
82    ScatterErrorbarXY {
83        scatter: DataScatterStyleConfig,
84        err_x: ErrorRef,
85        err_y: ErrorRef,
86        err_style: DataErrorBarStyleConfig,
87    },
88    LineScatterErrorbarX {
89        scatter: DataScatterStyleConfig,
90        line: DataLineStyleConfig,
91        err_x: ErrorRef,
92        err_style: DataErrorBarStyleConfig,
93    },
94    LineScatterErrorbarY {
95        scatter: DataScatterStyleConfig,
96        line: DataLineStyleConfig,
97        err_y: ErrorRef,
98        err_style: DataErrorBarStyleConfig,
99    },
100    LineScatterErrorbarXY {
101        scatter: DataScatterStyleConfig,
102        line: DataLineStyleConfig,
103        err_x: ErrorRef,
104        err_y: ErrorRef,
105        err_style: DataErrorBarStyleConfig,
106    },
107    /// Bars from pre-binned data. Binning is the host's job: the renderer is
108    /// handed `(edges, counts)` and draws them.
109    ///
110    /// `bar.orientation` alone decides which column is which — `Vertical` reads
111    /// `x_column` as edges and `y_column` as counts, `Horizontal` the reverse.
112    /// The lengths are not consulted to guess the roles.
113    Histogram {
114        bar: DataBarStyleConfig,
115    },
116    /// A filled field and nothing else. `x_column` / `y_column` are the grid
117    /// coordinates; `matrix` names the columns that make up z.
118    Heatmap {
119        matrix: MatrixRef,
120        fill: FieldFillConfig,
121    },
122    /// Contour lines only — the field is not filled.
123    Contour {
124        matrix: MatrixRef,
125        contour: ContourConfig,
126    },
127    /// Filled field with contour lines drawn over it.
128    HeatmapContour {
129        matrix: MatrixRef,
130        fill: FieldFillConfig,
131        contour: ContourConfig,
132    },
133}
134
135#[derive(Debug, Clone, PartialEq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct DataLineStyleConfig {
138    pub line_style: LineStylePreset,
139    pub line_color: Color,
140    pub line_width: f32,
141}
142
143#[derive(Debug, Clone, PartialEq)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct DataScatterStyleConfig {
146    pub point_color: Color,
147    pub point_shape: ScatterShape,
148    pub point_size: f32,
149    #[cfg_attr(
150        feature = "serde",
151        serde(default, skip_serializing_if = "Option::is_none")
152    )]
153    pub point_style_table: Option<Vec<DataScatterPointStyleConfig>>,
154    #[cfg_attr(
155        feature = "serde",
156        serde(default, skip_serializing_if = "Option::is_none")
157    )]
158    pub point_style_index_column: Option<ColumnId>,
159    #[cfg_attr(
160        feature = "serde",
161        serde(default, skip_serializing_if = "Option::is_none")
162    )]
163    pub point_style_overrides: Option<Vec<DataScatterPointStyleOverride>>,
164}
165
166#[derive(Debug, Clone, PartialEq, Default)]
167#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
168pub struct DataScatterPointStyleConfig {
169    #[cfg_attr(
170        feature = "serde",
171        serde(default, skip_serializing_if = "Option::is_none")
172    )]
173    pub point_color: Option<Color>,
174    #[cfg_attr(
175        feature = "serde",
176        serde(default, skip_serializing_if = "Option::is_none")
177    )]
178    pub point_shape: Option<ScatterShape>,
179    #[cfg_attr(
180        feature = "serde",
181        serde(default, skip_serializing_if = "Option::is_none")
182    )]
183    pub point_size: Option<f32>,
184}
185
186#[derive(Debug, Clone, PartialEq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct DataScatterPointStyleOverride {
189    pub index: usize,
190    #[cfg_attr(feature = "serde", serde(flatten))]
191    pub style: DataScatterPointStyleConfig,
192}
193
194#[derive(Debug, Clone, PartialEq, Default)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct DataErrorBarPointStyleConfig {
197    #[cfg_attr(
198        feature = "serde",
199        serde(default, skip_serializing_if = "Option::is_none")
200    )]
201    pub error_bar_color: Option<Color>,
202    #[cfg_attr(
203        feature = "serde",
204        serde(default, skip_serializing_if = "Option::is_none")
205    )]
206    pub error_bar_width: Option<f32>,
207    #[cfg_attr(
208        feature = "serde",
209        serde(default, skip_serializing_if = "Option::is_none")
210    )]
211    pub error_bar_cap_size: Option<f32>,
212    #[cfg_attr(
213        feature = "serde",
214        serde(default, skip_serializing_if = "Option::is_none")
215    )]
216    pub cap_width: Option<f32>,
217}
218
219#[derive(Debug, Clone, PartialEq)]
220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
221pub struct DataErrorBarPointStyleOverride {
222    pub index: usize,
223    #[cfg_attr(feature = "serde", serde(flatten))]
224    pub style: DataErrorBarPointStyleConfig,
225}
226
227#[derive(Debug, Clone, PartialEq)]
228#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
229pub struct DataErrorBarStyleConfig {
230    pub error_bar_color: Color,
231    pub error_bar_width: f32,
232    pub error_bar_cap_size: f32,
233    pub cap_width: f32,
234    #[cfg_attr(
235        feature = "serde",
236        serde(default, skip_serializing_if = "Option::is_none")
237    )]
238    pub error_bar_style_table: Option<Vec<DataErrorBarPointStyleConfig>>,
239    #[cfg_attr(
240        feature = "serde",
241        serde(default, skip_serializing_if = "Option::is_none")
242    )]
243    pub error_bar_style_index_column: Option<ColumnId>,
244    #[cfg_attr(
245        feature = "serde",
246        serde(default, skip_serializing_if = "Option::is_none")
247    )]
248    pub error_bar_style_overrides: Option<Vec<DataErrorBarPointStyleOverride>>,
249}
250
251/// A 2-D grid declared as a bundle of column ids — and nothing more.
252///
253/// There is no separate matrix container, no renderer-side registry, and no new
254/// data trait. The grid *is* those columns in the pool; this says which ones and
255/// how to read them. Keeping the declaration inline in the render type is what
256/// lets `Config` + `series` fully define the picture: an id pointing at
257/// something the renderer holds privately would not.
258///
259/// z statistics come from the constituent columns' own cached `min` / `max` /
260/// `min_positive`, combined. No GPU round trip, no second extent engine.
261#[derive(Debug, Clone, PartialEq)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263pub struct MatrixRef {
264    /// The grid's constituent columns, in grid order.
265    pub columns: Vec<ColumnId>,
266    /// Whether each constituent column is a slice along x or along y.
267    pub orientation: MatrixOrientation,
268    /// Whether the coordinate columns hold cell boundaries (n + 1 values) or
269    /// cell centres (n). Never inferred from the lengths.
270    pub grid_layout: GridLayout,
271}
272
273/// Which axis a constituent column runs along.
274#[derive(Debug, Clone, PartialEq)]
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276pub enum MatrixOrientation {
277    /// Each column is one x position, holding that position's y values.
278    ColumnsAreX,
279    /// Each column is one y position, holding that position's x values.
280    ColumnsAreY,
281}
282
283/// Whether coordinate columns give cell edges or cell centres.
284#[derive(Debug, Clone, PartialEq)]
285#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
286pub enum GridLayout {
287    /// `n + 1` coordinates bounding `n` cells.
288    Edges,
289    /// `n` coordinates at the middle of `n` cells.
290    Centers,
291}
292
293impl MatrixRef {
294    /// Cells along each axis, given the coordinate column lengths.
295    ///
296    /// Returns `(cols, rows)` in constituent-column order: `cols` is how many
297    /// constituent columns are usable, `rows` how deep into each of them to
298    /// read. A declaration that does not line up with the data is **not an
299    /// error** — the smallest common extent is drawn and the caller reports the
300    /// truncation. Refusing to draw would turn a data-shape surprise into a
301    /// blank chart.
302    pub fn effective_extent(
303        &self,
304        x_len: usize,
305        y_len: usize,
306        shortest_column_len: usize,
307    ) -> (usize, usize) {
308        let (along, across) = self.coordinate_cells(x_len, y_len);
309        (
310            self.columns.len().min(along),
311            shortest_column_len.min(across),
312        )
313    }
314
315    /// Cells the **coordinate columns alone** can bound, in constituent-column
316    /// order: `(along, across)`.
317    ///
318    /// The half of [`Self::effective_extent`] that does not depend on the data,
319    /// split out because a caller reporting truncation has to know what the
320    /// coordinates offered before the grid columns capped it.
321    pub fn coordinate_cells(&self, x_len: usize, y_len: usize) -> (usize, usize) {
322        let (along, across) = match self.orientation {
323            MatrixOrientation::ColumnsAreX => (x_len, y_len),
324            MatrixOrientation::ColumnsAreY => (y_len, x_len),
325        };
326        let cells = |coordinates: usize| match self.grid_layout {
327            GridLayout::Edges => coordinates.saturating_sub(1),
328            GridLayout::Centers => coordinates,
329        };
330        (cells(along), cells(across))
331    }
332}
333
334/// How a field's cells are painted.
335#[derive(Debug, Clone, PartialEq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337pub struct FieldFillConfig {
338    pub mode: FillMode,
339    pub shading: Shading,
340    /// Multiplied into the colormap's alpha — the way to put contour lines over
341    /// a muted field without editing the ramp.
342    pub opacity: f32,
343}
344
345/// Continuous ramp, or quantized into the contour levels' bands.
346#[derive(Debug, Clone, PartialEq)]
347#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
348pub enum FillMode {
349    Continuous,
350    /// One flat colour per interval between contour levels. With no levels this
351    /// is a single band, i.e. a flat field.
352    Bands,
353}
354
355/// Whether a cell is one colour or interpolated across its corners.
356#[derive(Debug, Clone, PartialEq)]
357#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
358pub enum Shading {
359    /// One colour per cell, from that cell's own z.
360    Flat,
361    /// Bilinear across the cell's corner values.
362    Interpolated,
363}
364
365/// Maximum number of contour levels accepted by one series.
366///
367/// Explicit level lists and levels produced by [`ContourConfig::nice_levels`]
368/// share this limit. Renderers reject longer explicit lists rather than
369/// truncating them.
370pub const MAX_CONTOUR_LEVELS: usize = 1024;
371
372fn push_contour_level(levels: &mut Vec<f64>, value: f64) -> Result<(), TickError> {
373    if levels.len() == MAX_CONTOUR_LEVELS {
374        return Err(TickError::InvalidRange);
375    }
376    levels.push(value);
377    Ok(())
378}
379
380/// Contour lines over a field.
381#[derive(Debug, Clone, PartialEq)]
382#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
383pub struct ContourConfig {
384    /// The levels to draw, in data units, with at most
385    /// [`MAX_CONTOUR_LEVELS`] entries. An explicit list only — there is no
386    /// "auto" variant, because a level set inferred at draw time is a value the
387    /// config does not contain. A helper computes levels and writes them here.
388    pub levels: Vec<f64>,
389    pub line: DataLineStyleConfig,
390    /// Per-level colours, indexed by level. `None` means **every level is
391    /// `line.line_color`** — colours are not derived from the colormap here. A
392    /// helper that wants colormap colours computes them and writes them in.
393    #[cfg_attr(
394        feature = "serde",
395        serde(default, skip_serializing_if = "Option::is_none")
396    )]
397    pub per_level_color: Option<Vec<Color>>,
398    #[cfg_attr(
399        feature = "serde",
400        serde(default, skip_serializing_if = "Option::is_none")
401    )]
402    pub labels: Option<ContourLabelConfig>,
403}
404
405impl ContourConfig {
406    /// About `target_count` round levels inside a colourbar's z range.
407    ///
408    /// There is no `Auto` level variant, and this is why there does not need to
409    /// be one: the levels a chart draws are always the ones in
410    /// [`Self::levels`], and a caller who wants them chosen for it calls this and
411    /// stores the answer. What is drawn is then still exactly what the config
412    /// says.
413    ///
414    /// Levels are **strictly inside** the range. One sitting exactly on
415    /// `axis.min` or `axis.max` coincides with the field's outer boundary or has
416    /// no visible coverage at
417    /// all, and it would also make the banded fill's `n + 1` bands come out
418    /// wrong by one.
419    ///
420    /// The spacing is the axis machinery's own: `compute_nice_ticks` picks it,
421    /// and the walk anchors to absolute multiples of it, so the levels are round
422    /// numbers even when the range ends are not. A logarithmic colourbar gets
423    /// decades from the same call, with no separate rule here.
424    pub fn nice_levels(axis: &AxisOptions, target_count: usize) -> Result<Vec<f64>, TickError> {
425        // `compute_nice_ticks` widens min/max to nice bounds; a level outside the
426        // colourbar's range has no colour, so only its spacing is used.
427        let plan = AxisOptions::compute_nice_ticks(
428            axis.scale.clone(),
429            axis.min,
430            axis.max,
431            target_count.max(1),
432        )?;
433        let mut out = Vec::new();
434        // Half a step of slack keeps a level that lands on the boundary through
435        // floating-point noise from being kept as an interior one.
436        let inside = |v: f64| v > axis.min && v < axis.max;
437        match axis.scale {
438            AxisScale::Linear => {
439                let step = plan.major_spacing;
440                if !(step.is_finite() && step > 0.0) {
441                    return Err(TickError::InvalidRange);
442                }
443                let first = ((axis.min / step) - 1e-9).ceil() as i64;
444                let last = ((axis.max / step) + 1e-9).floor() as i64;
445                for i in first..=last {
446                    let value = i as f64 * step;
447                    if inside(value) {
448                        push_contour_level(&mut out, value)?;
449                    }
450                }
451            }
452            AxisScale::Logarithmic => {
453                let step = plan.major_spacing.max(1.0);
454                let start = axis.min.log10().ceil();
455                let end = axis.max.log10().floor();
456                if !(step.is_finite()
457                    && step <= i32::MAX as f64
458                    && start.is_finite()
459                    && start >= i32::MIN as f64
460                    && start <= i32::MAX as f64
461                    && end.is_finite()
462                    && end >= i32::MIN as f64
463                    && end <= i32::MAX as f64)
464                {
465                    return Err(TickError::InvalidRange);
466                }
467                let step = step as i32;
468                let start = start as i32;
469                let end = end as i32;
470                if start > end {
471                    return Ok(out);
472                }
473                let exponent_span = end.checked_sub(start).ok_or(TickError::InvalidRange)?;
474                let iteration_count = exponent_span
475                    .checked_div(step)
476                    .and_then(|count| count.checked_add(1))
477                    .and_then(|count| usize::try_from(count).ok())
478                    .ok_or(TickError::InvalidRange)?;
479                let mut exponent = start;
480                for index in 0..iteration_count {
481                    // `powi` can overflow the reciprocal's intermediate power
482                    // for negative exponents, losing valid subnormal decades.
483                    let value = 10f64.powf(f64::from(exponent));
484                    if inside(value) {
485                        push_contour_level(&mut out, value)?;
486                    }
487                    if index + 1 < iteration_count {
488                        exponent = exponent.checked_add(step).ok_or(TickError::InvalidRange)?;
489                    }
490                }
491            }
492        }
493        Ok(out)
494    }
495
496    /// Replace [`Self::levels`] with [`Self::nice_levels`].
497    ///
498    /// [`Self::per_level_color`] is left alone: a colour list that no longer
499    /// lines up with the new levels falls back to [`Self::line`]'s colour per
500    /// level rather than being silently thrown away. Call
501    /// [`Self::set_colormap_colors`] after this to re-derive it.
502    pub fn set_nice_levels(
503        &mut self,
504        axis: &AxisOptions,
505        target_count: usize,
506    ) -> Result<(), TickError> {
507        self.levels = Self::nice_levels(axis, target_count)?;
508        Ok(())
509    }
510
511    /// Colour every level by where it sits on the colourbar's ramp.
512    ///
513    /// The helper the `per_level_color` doc means by "a helper that wants
514    /// colormap colours computes them and writes them in" — the field's fill and
515    /// the lines over it then agree about what a value looks like, because both
516    /// go through `ColorBarOptions::color_for_z`. A level the ramp cannot place
517    /// takes `nan_color`, the same as a cell with that value.
518    pub fn set_colormap_colors(&mut self, bar: &crate::config::ColorBarOptions) {
519        self.per_level_color = Some(
520            self.levels
521                .iter()
522                .map(|level| bar.color_for_z(*level))
523                .collect(),
524        );
525    }
526}
527
528/// Inline level labels on contour lines.
529#[derive(Debug, Clone, PartialEq)]
530#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
531pub struct ContourLabelConfig {
532    pub visible: bool,
533    pub font_size: f32,
534    /// Text colour, independent of the contour stroke and any per-level ramp
535    /// colours. Labels are annotations; changing a line palette must not make
536    /// their typography unreadable.
537    #[cfg_attr(feature = "serde", serde(default = "default_contour_label_color"))]
538    pub color: Color,
539    pub format: LabelFormat,
540    pub significant_digits: u8,
541    /// Target minimum screen distance between labels selected by the normal
542    /// automatic sweep. Must be finite and greater than zero even when labels
543    /// are hidden or explicit anchors override automatic placement.
544    ///
545    /// Read by the anchor pass, which seeds a lattice at this pitch over the data
546    /// area, projects each seed onto its level's isoline, and keeps the ones that
547    /// stay this far apart. A per-level fallback may keep a closer candidate
548    /// rather than omit that level. Labels are not arc-length-even: the seed
549    /// lattice is even, the curve is not. Ignored when the resolved override is
550    /// non-empty.
551    #[cfg_attr(
552        feature = "serde",
553        serde(deserialize_with = "deserialize_contour_label_spacing_px")
554    )]
555    pub spacing_px: f32,
556    /// Where the labels go — an **override**.
557    ///
558    /// Empty is the normal case: the GPU projects a seed lattice onto each
559    /// level's isoline and selects a separated subset. For an explicit list, the
560    /// renderer removes anchors whose `level_index` is outside `levels`, keeps at
561    /// most 1024 in input order, and uses that resolved list as the override. If
562    /// resolution leaves it empty, automatic placement remains active. Automatic
563    /// and explicit placement share the same 1024-label draw capacity. Either way
564    /// the same anchor record reaches the same draw.
565    pub anchors: Vec<ContourLabelAnchor>,
566    /// Fill painted behind the text, so a label over a line stays readable.
567    ///
568    /// `None` draws the text alone. This is a property of the *label*, not of the
569    /// chart — which is why it can live here at all: the renderer composites the
570    /// chrome over the data and does not know what colour is behind it, but the
571    /// label knows what it wants behind *itself*.
572    #[cfg_attr(
573        feature = "serde",
574        serde(default, skip_serializing_if = "Option::is_none")
575    )]
576    pub bg_color: Option<Color>,
577    /// Space around the text. This pads `bg_color` when one is present and is
578    /// also the contour-line gap around a transparent label.
579    #[cfg_attr(feature = "serde", serde(default))]
580    pub bg_padding_px: f32,
581}
582
583#[cfg(feature = "serde")]
584fn default_contour_label_color() -> Color {
585    Color::BLACK
586}
587
588impl ContourLabelConfig {
589    pub const INVALID_SPACING_REASON: &'static str =
590        "contour label spacing_px must be finite and greater than zero";
591
592    /// Validate invariants owned by the label configuration itself.
593    pub fn validate(&self) -> Result<(), &'static str> {
594        if !self.spacing_px.is_finite() || self.spacing_px <= 0.0 {
595            return Err(Self::INVALID_SPACING_REASON);
596        }
597        Ok(())
598    }
599}
600
601#[cfg(feature = "serde")]
602fn deserialize_contour_label_spacing_px<'de, D>(deserializer: D) -> Result<f32, D::Error>
603where
604    D: serde::Deserializer<'de>,
605{
606    let spacing_px = <f32 as serde::Deserialize>::deserialize(deserializer)?;
607    if !spacing_px.is_finite() || spacing_px <= 0.0 {
608        return Err(serde::de::Error::custom(
609            ContourLabelConfig::INVALID_SPACING_REASON,
610        ));
611    }
612    Ok(spacing_px)
613}
614
615/// One label placement, in data space.
616///
617/// Data coordinates and a data-space tangent, not screen values: the draw
618/// re-projects them, so the same record works whether the GPU projected it from
619/// the implicit field or a host wrote it by hand. The screen angle is this tangent
620/// projected through the current transform — exact on a linear axis, the local
621/// direction on a logarithmic one.
622#[derive(Debug, Clone, PartialEq)]
623#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
624pub struct ContourLabelAnchor {
625    /// Index into [`ContourConfig::levels`].
626    pub level_index: u32,
627    pub x: f64,
628    pub y: f64,
629    /// Tangent direction in data space; the label's screen angle is this
630    /// projected through the current transform.
631    pub tx: f64,
632    pub ty: f64,
633}
634
635/// Bar appearance for [`DataRenderType::Histogram`].
636#[derive(Debug, Clone, PartialEq)]
637#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
638pub struct DataBarStyleConfig {
639    pub fill_color: Color,
640    pub border_color: Color,
641    pub border_width: f32,
642    /// The value bars grow from — usually 0. On a logarithmic count axis 0 has
643    /// no position, and the base is clamped to the axis minimum instead.
644    pub baseline: f64,
645    /// Screen-space gap between neighbouring bars. The renderer reduces the
646    /// gap when necessary so a positive-width bin keeps a visible footprint;
647    /// Subpixel bins use a GPU pixel-column maximum envelope instead: gap
648    /// and positive width ratios do not cut holes in that envelope.
649    pub gap_px: f32,
650    /// Fraction of each bin occupied by its bar, centred on the bin. Values are
651    /// clamped to `0..=1` by the renderer. `1` uses the whole bin before
652    /// applying [`Self::gap_px`], while `0.8` leaves 10% empty on each side.
653    #[cfg_attr(feature = "serde", serde(default = "default_bar_width_ratio"))]
654    pub width_ratio: f32,
655    /// Which column is edges and which is counts. This alone decides; the
656    /// `edges = counts + 1` length relation is not used to guess.
657    pub orientation: BarOrientation,
658    /// Sparse, declaration-order overrides keyed by rendered bin index. A
659    /// later record for the same index layers over an earlier partial record.
660    #[cfg_attr(
661        feature = "serde",
662        serde(default, skip_serializing_if = "Option::is_none")
663    )]
664    pub bar_style_overrides: Option<Vec<DataBarStyleOverride>>,
665}
666
667#[cfg(feature = "serde")]
668fn default_bar_width_ratio() -> f32 {
669    1.0
670}
671
672/// Partial appearance override for one histogram bin.
673///
674/// `None` inherits the series-level value. A zero `border_width` removes the
675/// outline for just that bin.
676#[derive(Debug, Clone, PartialEq, Default)]
677#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
678pub struct DataBarBinStyleConfig {
679    #[cfg_attr(
680        feature = "serde",
681        serde(default, skip_serializing_if = "Option::is_none")
682    )]
683    pub fill_color: Option<Color>,
684    #[cfg_attr(
685        feature = "serde",
686        serde(default, skip_serializing_if = "Option::is_none")
687    )]
688    pub border_color: Option<Color>,
689    #[cfg_attr(
690        feature = "serde",
691        serde(default, skip_serializing_if = "Option::is_none")
692    )]
693    pub border_width: Option<f32>,
694    #[cfg_attr(
695        feature = "serde",
696        serde(default, skip_serializing_if = "Option::is_none")
697    )]
698    pub gap_px: Option<f32>,
699    #[cfg_attr(
700        feature = "serde",
701        serde(default, skip_serializing_if = "Option::is_none")
702    )]
703    pub width_ratio: Option<f32>,
704}
705
706/// One sparse histogram-bin style override.
707#[derive(Debug, Clone, PartialEq)]
708#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
709pub struct DataBarStyleOverride {
710    pub index: usize,
711    #[cfg_attr(feature = "serde", serde(flatten))]
712    pub style: DataBarBinStyleConfig,
713}
714
715/// Bar direction, and with it the roles of `x_column` / `y_column`.
716#[derive(Debug, Clone, PartialEq)]
717#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
718pub enum BarOrientation {
719    /// Bars rise along y: `x_column` is edges, `y_column` is counts.
720    Vertical,
721    /// Bars extend along x: `y_column` is edges, `x_column` is counts.
722    Horizontal,
723}
724
725#[derive(Debug, Clone, PartialEq)]
726#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
727pub enum ScatterShape {
728    Circle,
729    Square,
730    Triangle,
731    Diamond,
732    Cross,
733    CircleFilled,
734    SquareFilled,
735    TriangleFilled,
736    DiamondFilled,
737    TriangleDown,
738    TriangleLeft,
739    TriangleRight,
740    Plus,
741    Pentagon,
742    Hexagon,
743    Octagon,
744    Star,
745    TriangleDownFilled,
746    TriangleLeftFilled,
747    TriangleRightFilled,
748    PlusFilled,
749    CrossFilled,
750    PentagonFilled,
751    HexagonFilled,
752    OctagonFilled,
753    StarFilled,
754}
755
756#[cfg(test)]
757mod matrix_tests {
758    use super::*;
759
760    fn matrix(
761        orientation: MatrixOrientation,
762        grid_layout: GridLayout,
763        columns: usize,
764    ) -> MatrixRef {
765        MatrixRef {
766            columns: (0..columns).map(|i| format!("z{i}")).collect(),
767            orientation,
768            grid_layout,
769        }
770    }
771
772    // The declared shape, matching the data exactly: nothing is trimmed.
773    #[test]
774    fn a_well_formed_grid_uses_everything_it_declares() {
775        let centers = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
776        assert_eq!(centers.effective_extent(10, 5, 5), (10, 5));
777
778        // Edges bound cells, so the coordinate columns are one longer.
779        let edges = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
780        assert_eq!(edges.effective_extent(11, 6, 5), (10, 5));
781    }
782
783    // A mismatch is drawn to the smallest common extent and reported. It
784    // never errors, and it never draws past the data.
785    #[test]
786    fn a_mismatched_grid_shrinks_to_the_smallest_common_extent() {
787        let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
788        // Fewer x coordinates than constituent columns.
789        assert_eq!(m.effective_extent(4, 5, 5), (4, 5));
790        // A short constituent column limits the depth.
791        assert_eq!(m.effective_extent(10, 5, 2), (10, 2));
792        // Fewer y coordinates than the columns are deep.
793        assert_eq!(m.effective_extent(10, 3, 5), (10, 3));
794        // Nothing at all: zero cells, still not an error.
795        assert_eq!(m.effective_extent(0, 0, 0), (0, 0));
796    }
797
798    // `Edges` with a single coordinate bounds no cell. Saturating, not wrapping:
799    // `0 - 1` as a usize would ask for 18 quintillion cells.
800    #[test]
801    fn one_edge_bounds_no_cell() {
802        let m = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Edges, 10);
803        assert_eq!(m.effective_extent(1, 1, 5), (0, 0));
804        assert_eq!(m.effective_extent(0, 0, 5), (0, 0));
805    }
806
807    // Orientation swaps which coordinate column bounds which direction. It is
808    // the declaration that decides — never the lengths.
809    #[test]
810    fn orientation_decides_which_coordinate_bounds_the_columns() {
811        let columns_are_x = matrix(MatrixOrientation::ColumnsAreX, GridLayout::Centers, 10);
812        let columns_are_y = matrix(MatrixOrientation::ColumnsAreY, GridLayout::Centers, 10);
813        // x = 4, y = 20: one reads 4 usable columns, the other 10.
814        assert_eq!(columns_are_x.effective_extent(4, 20, 20), (4, 20));
815        assert_eq!(columns_are_y.effective_extent(4, 20, 20), (10, 4));
816    }
817}
818
819#[cfg(all(test, feature = "serde"))]
820mod serde_tests {
821    use super::{
822        DataBarStyleConfig, DataErrorBarPointStyleConfig, DataErrorBarPointStyleOverride,
823        DataErrorBarStyleConfig, DataRenderType, DataScatterPointStyleConfig,
824        DataScatterPointStyleOverride, DataScatterStyleConfig, ScatterShape, SeriesConfig,
825    };
826    use crate::color::Color;
827
828    fn base_series_json() -> serde_json::Value {
829        serde_json::json!({
830            "series_id": "s",
831            "label": null,
832            "x_column": "x",
833            "y_column": "y",
834            "render_type": {
835                "Scatter": {
836                    "scatter": {
837                        "point_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
838                        "point_shape": "Circle",
839                        "point_size": 3.0
840                    }
841                }
842            }
843        })
844    }
845
846    #[test]
847    fn old_series_json_without_additive_fields_still_parses() {
848        let cfg: SeriesConfig =
849            serde_json::from_value(base_series_json()).expect("old series shape parses");
850        assert_eq!(cfg.source_id, None);
851        let DataRenderType::Scatter { scatter } = &cfg.render_type else {
852            panic!("expected scatter");
853        };
854        assert_eq!(scatter.point_style_table, None);
855        assert_eq!(scatter.point_style_index_column, None);
856        assert_eq!(scatter.point_style_overrides, None);
857
858        let json = serde_json::to_value(cfg).expect("serialize");
859        assert!(json.get("source_id").is_none());
860        let scatter = &json["render_type"]["Scatter"]["scatter"];
861        assert!(scatter.get("point_style_table").is_none());
862        assert!(scatter.get("point_style_index_column").is_none());
863        assert!(scatter.get("point_style_overrides").is_none());
864    }
865
866    #[test]
867    fn point_style_mapping_round_trips_with_flattened_override() {
868        let scatter = DataScatterStyleConfig {
869            point_color: Color::BLACK,
870            point_shape: ScatterShape::Circle,
871            point_size: 3.0,
872            point_style_table: Some(vec![
873                DataScatterPointStyleConfig {
874                    point_color: Some(Color::from_rgb8(255, 0, 0)),
875                    point_shape: None,
876                    point_size: Some(5.0),
877                },
878                DataScatterPointStyleConfig {
879                    point_color: None,
880                    point_shape: Some(ScatterShape::DiamondFilled),
881                    point_size: None,
882                },
883            ]),
884            point_style_index_column: Some("style_idx".into()),
885            point_style_overrides: Some(vec![DataScatterPointStyleOverride {
886                index: 7,
887                style: DataScatterPointStyleConfig {
888                    point_color: None,
889                    point_shape: Some(ScatterShape::StarFilled),
890                    point_size: Some(9.0),
891                },
892            }]),
893        };
894
895        let json = serde_json::to_value(&scatter).expect("serialize scatter style");
896        assert_eq!(json["point_style_index_column"], "style_idx");
897        assert_eq!(json["point_style_overrides"][0]["index"], 7);
898        assert_eq!(
899            json["point_style_overrides"][0]["point_shape"],
900            "StarFilled"
901        );
902        assert_eq!(json["point_style_overrides"][0]["point_size"], 9.0);
903        assert!(json["point_style_overrides"][0].get("style").is_none());
904
905        let back: DataScatterStyleConfig =
906            serde_json::from_value(json).expect("parse scatter style");
907        assert_eq!(back, scatter);
908    }
909
910    #[test]
911    fn errorbar_style_mapping_round_trips_with_flattened_override() {
912        let err = DataErrorBarStyleConfig {
913            error_bar_color: Color::BLACK,
914            error_bar_width: 1.0,
915            error_bar_cap_size: 3.0,
916            cap_width: 1.0,
917            error_bar_style_table: Some(vec![
918                DataErrorBarPointStyleConfig {
919                    error_bar_color: Some(Color::from_rgb8(255, 0, 0)),
920                    error_bar_width: Some(2.0),
921                    error_bar_cap_size: None,
922                    cap_width: None,
923                },
924                DataErrorBarPointStyleConfig {
925                    error_bar_color: None,
926                    error_bar_width: None,
927                    error_bar_cap_size: Some(8.0),
928                    cap_width: Some(3.0),
929                },
930            ]),
931            error_bar_style_index_column: Some("err_style_idx".into()),
932            error_bar_style_overrides: Some(vec![DataErrorBarPointStyleOverride {
933                index: 4,
934                style: DataErrorBarPointStyleConfig {
935                    error_bar_color: None,
936                    error_bar_width: Some(4.0),
937                    error_bar_cap_size: None,
938                    cap_width: Some(2.0),
939                },
940            }]),
941        };
942
943        let json = serde_json::to_value(&err).expect("serialize errorbar style");
944        assert_eq!(json["error_bar_style_index_column"], "err_style_idx");
945        assert_eq!(json["error_bar_style_overrides"][0]["index"], 4);
946        assert_eq!(json["error_bar_style_overrides"][0]["error_bar_width"], 4.0);
947        assert_eq!(json["error_bar_style_overrides"][0]["cap_width"], 2.0);
948        assert!(json["error_bar_style_overrides"][0].get("style").is_none());
949
950        let back: DataErrorBarStyleConfig =
951            serde_json::from_value(json).expect("parse errorbar style");
952        assert_eq!(back, err);
953    }
954
955    #[test]
956    fn legacy_histogram_style_defaults_to_full_width_without_overrides() {
957        let legacy = serde_json::json!({
958            "fill_color": { "r": 0.2, "g": 0.3, "b": 0.4, "a": 1.0 },
959            "border_color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
960            "border_width": 1.0,
961            "baseline": 0.0,
962            "gap_px": 2.0,
963            "orientation": "Vertical"
964        });
965        let parsed: DataBarStyleConfig =
966            serde_json::from_value(legacy).expect("parse legacy histogram style");
967        assert_eq!(parsed.width_ratio, 1.0);
968        assert_eq!(parsed.bar_style_overrides, None);
969    }
970
971    #[test]
972    fn field_render_types_round_trip() {
973        use super::{
974            ContourConfig, ContourLabelAnchor, ContourLabelConfig, DataBarBinStyleConfig,
975            DataBarStyleConfig, DataBarStyleOverride, DataLineStyleConfig, FieldFillConfig,
976            FillMode, GridLayout, MatrixOrientation, MatrixRef, Shading,
977        };
978        use crate::format::LabelFormat;
979        use crate::line::LineStylePreset;
980
981        let matrix = MatrixRef {
982            columns: vec!["z0".into(), "z1".into()],
983            orientation: MatrixOrientation::ColumnsAreX,
984            grid_layout: GridLayout::Edges,
985        };
986        let fill = FieldFillConfig {
987            mode: FillMode::Bands,
988            shading: Shading::Interpolated,
989            opacity: 0.8,
990        };
991        let contour = ContourConfig {
992            levels: vec![1.0, 2.0, 4.0],
993            line: DataLineStyleConfig {
994                line_style: LineStylePreset::Solid,
995                line_color: Color::BLACK,
996                line_width: 1.5,
997            },
998            per_level_color: Some(vec![Color::from_rgb8(255, 0, 0)]),
999            labels: Some(ContourLabelConfig {
1000                visible: true,
1001                font_size: 11.0,
1002                color: Color::BLACK,
1003                format: LabelFormat::Decimal,
1004                significant_digits: 3,
1005                spacing_px: 120.0,
1006                anchors: vec![ContourLabelAnchor {
1007                    level_index: 1,
1008                    x: 0.25,
1009                    y: 0.5,
1010                    tx: 1.0,
1011                    ty: 0.0,
1012                }],
1013                bg_color: None,
1014                bg_padding_px: 0.0,
1015            }),
1016        };
1017        let cases = [
1018            DataRenderType::Histogram {
1019                bar: DataBarStyleConfig {
1020                    fill_color: Color::from_rgb8(70, 130, 180),
1021                    border_color: Color::BLACK,
1022                    border_width: 1.0,
1023                    baseline: 0.0,
1024                    gap_px: 1.0,
1025                    width_ratio: 0.8,
1026                    orientation: super::BarOrientation::Vertical,
1027                    bar_style_overrides: Some(vec![DataBarStyleOverride {
1028                        index: 2,
1029                        style: DataBarBinStyleConfig {
1030                            fill_color: Some(Color::from_rgb8(255, 0, 0)),
1031                            border_color: None,
1032                            border_width: Some(3.0),
1033                            gap_px: None,
1034                            width_ratio: Some(0.5),
1035                        },
1036                    }]),
1037                },
1038            },
1039            DataRenderType::Heatmap {
1040                matrix: matrix.clone(),
1041                fill: fill.clone(),
1042            },
1043            DataRenderType::Contour {
1044                matrix: matrix.clone(),
1045                contour: contour.clone(),
1046            },
1047            DataRenderType::HeatmapContour {
1048                matrix,
1049                fill,
1050                contour,
1051            },
1052        ];
1053        for case in cases {
1054            let json = serde_json::to_value(&case).expect("serialize render type");
1055            let back: DataRenderType = serde_json::from_value(json).expect("parse render type");
1056            assert_eq!(back, case);
1057        }
1058    }
1059
1060    // `per_level_color: None` means "every level uses `line.line_color`". It has
1061    // to survive as an absent key, not as an empty list — an empty list is a
1062    // table with no entries, which is a different statement.
1063    #[test]
1064    fn contour_optional_keys_are_omitted_when_absent() {
1065        use super::{ContourConfig, DataLineStyleConfig, FillMode};
1066        use crate::line::LineStylePreset;
1067
1068        let contour = ContourConfig {
1069            levels: vec![1.0],
1070            line: DataLineStyleConfig {
1071                line_style: LineStylePreset::Solid,
1072                line_color: Color::BLACK,
1073                line_width: 1.0,
1074            },
1075            per_level_color: None,
1076            labels: None,
1077        };
1078        let json = serde_json::to_value(&contour).expect("serialize contour");
1079        assert!(json.get("per_level_color").is_none());
1080        assert!(json.get("labels").is_none());
1081        let back: ContourConfig = serde_json::from_value(json).expect("parse contour");
1082        assert_eq!(back, contour);
1083        assert_eq!(back.per_level_color, None);
1084
1085        // And the discriminants stay spelled the way hosts write them.
1086        assert_eq!(
1087            serde_json::to_value(FillMode::Continuous).expect("serialize fill mode"),
1088            serde_json::json!("Continuous")
1089        );
1090    }
1091
1092    #[test]
1093    fn contour_label_spacing_serde_requires_a_finite_positive_value() {
1094        use super::ContourLabelConfig;
1095
1096        let json = |spacing_px: f32| {
1097            serde_json::json!({
1098                "visible": false,
1099                "font_size": 12.0,
1100                "format": "Decimal",
1101                "significant_digits": 3,
1102                "spacing_px": spacing_px,
1103                "anchors": []
1104            })
1105        };
1106        let parsed = serde_json::from_value::<ContourLabelConfig>(json(0.5)).unwrap();
1107        assert_eq!(
1108            parsed.color,
1109            Color::BLACK,
1110            "older JSON without the independent label colour defaults to black"
1111        );
1112        for spacing_px in [0.0, -1.0] {
1113            let error = serde_json::from_value::<ContourLabelConfig>(json(spacing_px))
1114                .expect_err("invalid spacing must fail during deserialization");
1115            assert!(error.to_string().contains("spacing_px must be finite"));
1116        }
1117    }
1118}
1119
1120#[cfg(test)]
1121mod contour_level_tests {
1122    use super::*;
1123    use crate::config::AxisScale;
1124    use crate::default::{default_axis_options_colorbar, default_colorbar_options};
1125
1126    fn axis(min: f64, max: f64, scale: AxisScale) -> AxisOptions {
1127        let mut axis = default_axis_options_colorbar();
1128        axis.min = min;
1129        axis.max = max;
1130        axis.scale = scale;
1131        axis
1132    }
1133
1134    fn contour() -> ContourConfig {
1135        ContourConfig {
1136            levels: Vec::new(),
1137            line: DataLineStyleConfig {
1138                line_style: LineStylePreset::Solid,
1139                line_color: Color::BLACK,
1140                line_width: 1.0,
1141            },
1142            per_level_color: None,
1143            labels: None,
1144        }
1145    }
1146
1147    #[test]
1148    fn contour_label_spacing_rust_validation_applies_to_all_placement_states() {
1149        for spacing_px in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1150            let labels = ContourLabelConfig {
1151                visible: false,
1152                font_size: 12.0,
1153                color: Color::BLACK,
1154                format: LabelFormat::Decimal,
1155                significant_digits: 3,
1156                spacing_px,
1157                anchors: vec![ContourLabelAnchor {
1158                    level_index: 0,
1159                    x: 0.0,
1160                    y: 0.0,
1161                    tx: 1.0,
1162                    ty: 0.0,
1163                }],
1164                bg_color: None,
1165                bg_padding_px: 0.0,
1166            };
1167            assert_eq!(
1168                labels.validate(),
1169                Err(ContourLabelConfig::INVALID_SPACING_REASON)
1170            );
1171        }
1172    }
1173
1174    /// Levels are round numbers on the spacing's own absolute grid, and they sit
1175    /// **inside** the range — a level on the boundary coincides with the field's edge and
1176    /// would leave the banded fill one band short.
1177    #[test]
1178    fn nice_levels_are_round_and_strictly_inside() {
1179        let levels = ContourConfig::nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5).unwrap();
1180        assert_eq!(levels, vec![20.0, 40.0, 60.0, 80.0]);
1181        for level in &levels {
1182            assert!(*level > 0.0 && *level < 100.0, "{level} is not inside");
1183        }
1184    }
1185
1186    /// Asking for more levels gets more, and they stay inside. That is the whole
1187    /// "how dense" control: the count in, the level list out.
1188    #[test]
1189    fn asking_for_more_levels_gets_more() {
1190        let sparse = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 3).unwrap();
1191        let dense = ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), 11).unwrap();
1192        assert!(
1193            dense.len() > sparse.len(),
1194            "sparse {sparse:?} vs dense {dense:?}"
1195        );
1196        for level in sparse.iter().chain(dense.iter()) {
1197            assert!(*level > 0.0 && *level < 1.0, "{level} is not inside");
1198        }
1199    }
1200
1201    #[test]
1202    fn nice_levels_accepts_exactly_the_linear_limit() {
1203        let levels =
1204            ContourConfig::nice_levels(&axis(0.0, 1025.0, AxisScale::Linear), 2001).unwrap();
1205
1206        assert_eq!(levels.len(), MAX_CONTOUR_LEVELS);
1207        assert_eq!(levels.first(), Some(&1.0));
1208        assert_eq!(levels.last(), Some(&1024.0));
1209        assert!(levels.windows(2).all(|pair| pair[1] - pair[0] == 1.0));
1210    }
1211
1212    #[test]
1213    fn nice_levels_rejects_the_1025th_linear_level() {
1214        assert_eq!(
1215            ContourConfig::nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
1216            Err(TickError::InvalidRange)
1217        );
1218    }
1219
1220    #[test]
1221    fn a_tiny_linear_spacing_is_bounded_by_the_level_limit() {
1222        assert_eq!(
1223            ContourConfig::nice_levels(&axis(0.0, 1.0, AxisScale::Linear), usize::MAX),
1224            Err(TickError::InvalidRange)
1225        );
1226    }
1227
1228    #[test]
1229    fn logarithmic_levels_are_not_silently_truncated() {
1230        let levels = ContourConfig::nice_levels(
1231            &axis(f64::from_bits(1), f64::MAX, AxisScale::Logarithmic),
1232            MAX_CONTOUR_LEVELS,
1233        )
1234        .unwrap();
1235
1236        assert_eq!(levels.len(), 632);
1237        assert_eq!(levels.first(), Some(&1e-323));
1238        assert_eq!(levels.last(), Some(&1e308));
1239        assert!(levels.windows(2).all(|pair| pair[0] < pair[1]));
1240    }
1241
1242    /// A range whose ends are not round still gets round levels: the walk anchors
1243    /// to absolute multiples of the spacing, not to `axis.min`.
1244    #[test]
1245    fn an_awkward_range_still_gets_round_levels() {
1246        let levels = ContourConfig::nice_levels(&axis(0.137, 9.42, AxisScale::Linear), 5).unwrap();
1247        assert!(!levels.is_empty());
1248        for level in &levels {
1249            assert!(
1250                (level / 2.0).fract().abs() < 1e-9,
1251                "{level} is not a multiple of the spacing"
1252            );
1253        }
1254    }
1255
1256    /// A logarithmic colourbar gets decades, from the same axis machinery — no
1257    /// second rule lives here.
1258    #[test]
1259    fn a_logarithmic_range_gets_decades() {
1260        let levels =
1261            ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 5).unwrap();
1262        assert_eq!(levels, vec![10.0, 100.0, 1000.0]);
1263    }
1264
1265    #[test]
1266    fn target_count_zero_keeps_its_existing_one_tick_correction() {
1267        let corrected =
1268            ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 0).unwrap();
1269        let explicit_one =
1270            ContourConfig::nice_levels(&axis(1.0, 10_000.0, AxisScale::Logarithmic), 1).unwrap();
1271        assert_eq!(corrected, explicit_one);
1272    }
1273
1274    #[test]
1275    fn a_valid_log_range_without_an_internal_decade_is_empty() {
1276        assert_eq!(
1277            ContourConfig::nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5),
1278            Ok(Vec::new())
1279        );
1280    }
1281
1282    /// A degenerate range has no levels to pick, and says so rather than
1283    /// inventing one.
1284    #[test]
1285    fn a_degenerate_range_is_refused() {
1286        assert!(ContourConfig::nice_levels(&axis(1.0, 1.0, AxisScale::Linear), 5).is_err());
1287        assert!(ContourConfig::nice_levels(&axis(-1.0, 10.0, AxisScale::Logarithmic), 5).is_err());
1288    }
1289
1290    /// `set_nice_levels` writes the answer into the config — what is drawn stays
1291    /// exactly what the config holds. Existing per-level colours are left for the
1292    /// draw's own fallback rather than discarded.
1293    #[test]
1294    fn set_nice_levels_writes_the_levels_and_keeps_the_colours() {
1295        let mut cfg = contour();
1296        cfg.per_level_color = Some(vec![Color::BLACK]);
1297        cfg.set_nice_levels(&axis(0.0, 100.0, AxisScale::Linear), 5)
1298            .unwrap();
1299        assert_eq!(cfg.levels, vec![20.0, 40.0, 60.0, 80.0]);
1300        assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1301    }
1302
1303    #[test]
1304    fn set_nice_levels_preserves_the_config_when_the_limit_is_exceeded() {
1305        let mut cfg = contour();
1306        cfg.levels = vec![42.0];
1307        cfg.per_level_color = Some(vec![Color::BLACK]);
1308
1309        assert_eq!(
1310            cfg.set_nice_levels(&axis(0.0, 1026.0, AxisScale::Linear), 2001),
1311            Err(TickError::InvalidRange)
1312        );
1313        assert_eq!(cfg.levels, vec![42.0]);
1314        assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1315    }
1316
1317    #[test]
1318    fn set_nice_levels_preserves_all_state_on_invalid_bounds() {
1319        let mut cfg = contour();
1320        cfg.levels = vec![42.0];
1321        cfg.per_level_color = Some(vec![Color::BLACK]);
1322        let before = cfg.clone();
1323
1324        assert_eq!(
1325            cfg.set_nice_levels(&axis(1.0, f64::INFINITY, AxisScale::Logarithmic), 5),
1326            Err(TickError::InvalidRange)
1327        );
1328        assert_eq!(cfg, before);
1329    }
1330
1331    #[test]
1332    fn set_nice_levels_replaces_levels_with_a_successful_empty_result() {
1333        let mut cfg = contour();
1334        cfg.levels = vec![42.0];
1335        cfg.per_level_color = Some(vec![Color::BLACK]);
1336
1337        cfg.set_nice_levels(&axis(2.0, 9.0, AxisScale::Logarithmic), 5)
1338            .unwrap();
1339        assert!(cfg.levels.is_empty());
1340        assert_eq!(cfg.per_level_color, Some(vec![Color::BLACK]));
1341    }
1342
1343    /// Colouring the levels from the ramp goes through the same `color_for_z` the
1344    /// field's cells do, so a line and the fill it bounds agree about the value.
1345    #[test]
1346    fn colormap_colours_come_from_color_for_z() {
1347        let mut bar = default_colorbar_options();
1348        bar.axis.min = 0.0;
1349        bar.axis.max = 100.0;
1350        let mut cfg = contour();
1351        cfg.set_nice_levels(&bar.axis, 5).unwrap();
1352        cfg.set_colormap_colors(&bar);
1353        let colors = cfg.per_level_color.as_ref().expect("colours written");
1354        assert_eq!(colors.len(), cfg.levels.len());
1355        for (level, color) in cfg.levels.iter().zip(colors) {
1356            assert_eq!(*color, bar.color_for_z(*level));
1357        }
1358    }
1359}