Skip to main content

model/
config.rs

1use crate::color::Color;
2use crate::colormap::ColorMap;
3use crate::format::LabelFormat;
4use crate::layout::{ChartArea, Side};
5use crate::line::LineStylePreset;
6use crate::text::RichText;
7
8#[derive(Debug, Clone, PartialEq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub struct ChartTitleOptions {
11    pub text: RichText,
12    pub visible: bool,
13    pub offset_x: f32,
14    pub offset_y: f32,
15    /// Height of the title band at the top of the chart area; the title is
16    /// centred in it. The title helpers grow it through
17    /// [`fit_chart_title_margin`] when the title is taller than the band; a
18    /// value injected directly is kept as given.
19    ///
20    /// [`fit_chart_title_margin`]: crate::layout::fit_chart_title_margin
21    pub top_margin: f32,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct GridOptions {
27    pub show_major_x: bool,
28    pub major_x_color: Color,
29    pub major_x_width: f32,
30    pub major_x_style: LineStylePreset,
31
32    pub show_major_y: bool,
33    pub major_y_color: Color,
34    pub major_y_width: f32,
35    pub major_y_style: LineStylePreset,
36
37    pub show_minor_x: bool,
38    pub minor_x_color: Color,
39    pub minor_x_width: f32,
40    pub minor_x_style: LineStylePreset,
41
42    pub show_minor_y: bool,
43    pub minor_y_color: Color,
44    pub minor_y_width: f32,
45    pub minor_y_style: LineStylePreset,
46}
47
48#[derive(Debug, Clone, PartialEq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub enum AxisScale {
51    Linear,
52    Logarithmic,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub enum TickVisibility {
58    None,
59    Outside,
60    Inside,
61    Both,
62}
63
64#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct AxisTitleOptions {
67    pub text: RichText,
68    pub visible: bool,
69    pub offset_x: f32,
70    pub offset_y: f32,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct LabelStyle {
76    pub visible: bool,
77    pub color: Color,
78    pub font_size: f32,
79    pub label_visible: bool,
80    pub label_font: String,
81    pub label_offset_x: f32,
82    pub label_offset_y: f32,
83    pub format: LabelFormat,
84    pub significant_digits: u8,
85}
86
87#[derive(Debug, Clone, PartialEq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct AxisOptions {
90    pub scale: AxisScale,
91    pub min: f64,
92    pub max: f64,
93    pub major_spacing: f64,
94    pub minor_count: usize,
95    pub inverted: bool,
96    pub label_style: LabelStyle,
97    pub tick: TickVisibility,
98    pub title_option: AxisTitleOptions,
99    /// Outer margin past the axis title band. Always counted, regardless of title visibility.
100    pub out_margin: f32,
101
102    /// Detached-axis offset: shifts the axis line + ticks + tick labels
103    /// perpendicular to the axis (Δx for y-axes, Δy for x-axes) away from the
104    /// data-area edge they normally sit on. Margin-noncontributing visual
105    /// offset — the data area, grid, and data transform are unaffected, so
106    /// tick positions along the axis stay aligned with the data.
107    pub line_offset: f32,
108
109    // Axis line appearance. Tick marks reuse these (color / width / style).
110    pub line_visible: bool,
111    pub line_color: Color,
112    pub line_width: f32,
113    pub line_style: LineStylePreset,
114
115    // Tick mark lengths. `margins()` uses `major_tick_length`.
116    pub major_tick_length: f32,
117    pub minor_tick_length: f32,
118}
119
120/// Hand-drawn ("sketch") render-mode parameters. Every field has a default —
121/// JSON `{"mode":"sketch"}` alone enables the mode with stock values
122/// (`serde(default)`).
123#[derive(Clone, Copy, Debug, PartialEq)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125#[cfg_attr(feature = "serde", serde(default))]
126pub struct SketchOptions {
127    /// Perpendicular path-displacement amplitude, in px. Default 1.5.
128    pub amplitude_px: f32,
129    /// Displacement wavelength, in px — one wobble per this arc length along
130    /// the path. Default 60.0.
131    pub wavelength_px: f32,
132    /// Global seed. Same (config, data) → byte-identical output. Default 0.
133    pub seed: u32,
134}
135
136impl Default for SketchOptions {
137    fn default() -> Self {
138        Self {
139            amplitude_px: 1.5,
140            wavelength_px: 60.0,
141            seed: 0,
142        }
143    }
144}
145
146/// Milkyway draw-style parameters. All fields have defaults — JSON
147/// `{"mode":"milkyway"}` alone works. Lines render as star chains over
148/// a soft nebula ribbon tinted with the series `line_color` (that ribbon is
149/// what keeps multiple series distinguishable); star counts stay uniform per
150/// arc length, while star colors stay physical (blackbody locus, population
151/// mix follows the clump field).
152#[derive(Clone, Copy, Debug, PartialEq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154#[cfg_attr(feature = "serde", serde(default))]
155pub struct MilkywayOptions {
156    /// Stars per 100 px of arc length. Default 14.0.
157    pub star_density: f32,
158    /// Nebula ribbon full width, in px ("두께"). Default 14.0.
159    pub ribbon_width_px: f32,
160    /// Ribbon brightness 0..1 — how strongly the series-colored haze shows
161    /// behind the stars. Default 0.30 (thin).
162    pub ribbon_intensity: f32,
163    /// Global star size multiplier. Default 1.0.
164    pub star_scale: f32,
165    /// Global star brightness multiplier. Default 1.0.
166    pub star_brightness: f32,
167    /// Perpendicular star scatter σ from the path, in px. Larger reads more
168    /// like a loose cluster, smaller tracks the data tighter. Default 2.5.
169    pub spread_px: f32,
170    /// Multiplier on the style's px-denominated STRUCTURE constants — the
171    /// clump/population noise wavelength (90 px base) and binary-companion
172    /// separation. This is what keeps the star texture resolution-invariant:
173    /// export scaling multiplies it (and divides `star_density`)
174    /// automatically; hosts that render a larger physical canvas (dpr > 1)
175    /// should do the same. Default 1.0.
176    pub structure_scale: f32,
177    /// Luminosity-function slope: the exponent of the brightness power law.
178    /// Higher → a larger fraction of faint small stars per bright anchor
179    /// (real fields sit faint-heavy). Sensible range ~1.5..6. Default 3.0.
180    pub faint_bias: f32,
181    /// Decoration glow (line-light bloom) gain on axes/ticks/labels/titles.
182    /// 0 disables the bloom. Default 0.55.
183    pub glow: f32,
184    /// Nebula cloud intensity multiplier for the space backdrop. 1.0 is the
185    /// legibility-calibrated default (peak ≤ ~12/255 per channel); the
186    /// painter keeps the panel center cleanest at any setting. Default 1.0.
187    pub nebula: f32,
188    /// Background dust-star density multiplier (1 px, far dimmer than data
189    /// stars). 0 removes the dust. Default 1.0.
190    pub dust: f32,
191    /// Planet atmospheric rim-glow strength (the series color's only
192    /// appearance on scatter planets). Default 0.34.
193    pub planet_rim: f32,
194    /// Global seed. Same (config, data) → identical output. Default 0.
195    pub seed: u32,
196}
197
198impl Default for MilkywayOptions {
199    fn default() -> Self {
200        Self {
201            star_density: 14.0,
202            ribbon_width_px: 14.0,
203            ribbon_intensity: 0.30,
204            star_scale: 1.0,
205            star_brightness: 1.0,
206            spread_px: 2.5,
207            structure_scale: 1.0,
208            faint_bias: 3.0,
209            glow: 0.55,
210            nebula: 1.0,
211            dust: 1.0,
212            planet_rim: 0.34,
213            seed: 0,
214        }
215    }
216}
217
218/// Constellation draw-style parameters. All fields have defaults. Only
219/// `ScatterLine` series are drawn: PSF-rendered stars sit at the scatter data
220/// points, connected by a translucent line.
221#[derive(Clone, Copy, Debug, PartialEq)]
222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
223#[cfg_attr(feature = "serde", serde(default))]
224pub struct ConstellationOptions {
225    /// Opacity applied to the point stars. Default 1.0.
226    pub star_opacity: f32,
227    /// Opacity applied to the connecting line. Default 0.45.
228    pub line_opacity: f32,
229}
230
231impl Default for ConstellationOptions {
232    fn default() -> Self {
233        Self {
234            star_opacity: 1.0,
235            line_opacity: 0.45,
236        }
237    }
238}
239
240/// UI-facing metadata for one stylized-mode parameter.
241#[derive(Clone, Copy, Debug, PartialEq)]
242#[cfg_attr(feature = "serde", derive(serde::Serialize))]
243pub struct StyleParamSpec {
244    /// JSON field name inside the `draw_style` object.
245    pub key: &'static str,
246    pub min: f64,
247    pub max: f64,
248    pub default: f64,
249    /// Integer-valued (e.g. `seed`) — hosts render a stepper, not a slider.
250    pub integer: bool,
251}
252
253const fn spec(key: &'static str, min: f64, max: f64, default: f64) -> StyleParamSpec {
254    StyleParamSpec {
255        key,
256        min,
257        max,
258        default,
259        integer: false,
260    }
261}
262
263const fn spec_int(key: &'static str, min: f64, max: f64, default: f64) -> StyleParamSpec {
264    StyleParamSpec {
265        key,
266        min,
267        max,
268        default,
269        integer: true,
270    }
271}
272
273impl SketchOptions {
274    /// Parameter metadata for hosts — see [`StyleParamSpec`]. A model test
275    /// pins each `default` to [`Default`], so the two cannot drift.
276    pub const PARAM_SPECS: &'static [StyleParamSpec] = &[
277        spec("amplitude_px", 0.0, 8.0, 1.5),
278        spec("wavelength_px", 10.0, 200.0, 60.0),
279        spec_int("seed", 0.0, 9999.0, 0.0),
280    ];
281}
282
283impl MilkywayOptions {
284    /// Parameter metadata for hosts — see [`StyleParamSpec`]. A model test
285    /// pins each `default` to [`Default`], so the two cannot drift.
286    pub const PARAM_SPECS: &'static [StyleParamSpec] = &[
287        spec("star_density", 0.0, 120.0, 14.0),
288        spec("ribbon_width_px", 2.0, 40.0, 14.0),
289        spec("ribbon_intensity", 0.0, 1.0, 0.30),
290        spec("star_scale", 0.3, 3.0, 1.0),
291        spec("star_brightness", 0.0, 4.0, 1.0),
292        spec("spread_px", 0.0, 10.0, 2.5),
293        spec("structure_scale", 0.25, 4.0, 1.0),
294        spec("faint_bias", 0.5, 24.0, 3.0),
295        spec("glow", 0.0, 1.5, 0.55),
296        spec("nebula", 0.0, 1.5, 1.0),
297        spec("dust", 0.0, 3.0, 1.0),
298        spec("planet_rim", 0.0, 1.0, 0.34),
299        spec_int("seed", 0.0, 9999.0, 0.0),
300    ];
301}
302
303impl ConstellationOptions {
304    /// Parameter metadata for hosts -- see [`StyleParamSpec`]. A model test
305    /// pins each `default` to [`Default`], so the two cannot drift.
306    pub const PARAM_SPECS: &'static [StyleParamSpec] = &[
307        spec("star_opacity", 0.0, 1.0, 1.0),
308        spec("line_opacity", 0.0, 1.0, 0.45),
309    ];
310}
311
312/// Chart-global render style. `Precise` is the default scientific path;
313/// every other variant is an opt-in stylized mode with its own GPU
314/// pipeline variants and decoration stroker.
315#[derive(Clone, Copy, Debug, PartialEq, Default)]
316#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
317#[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))]
318pub enum DrawStyle {
319    #[default]
320    Precise,
321    Sketch(SketchOptions),
322    Milkyway(MilkywayOptions),
323    Constellation(ConstellationOptions),
324}
325
326#[derive(Debug, Clone, PartialEq)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
328pub struct PickedPointRef {
329    #[cfg_attr(
330        feature = "serde",
331        serde(default, skip_serializing_if = "Option::is_none")
332    )]
333    pub source_id: Option<String>,
334    pub series_id: String,
335    pub point_index: usize,
336}
337
338#[derive(Debug, Clone, PartialEq)]
339#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
340#[cfg_attr(feature = "serde", serde(default))]
341pub struct PickedPointsConfig {
342    pub visible: bool,
343    pub refs: Vec<PickedPointRef>,
344    pub ring_color: Color,
345    pub ring_width_px: f32,
346    pub radius_extra_px: f32,
347}
348
349impl Default for PickedPointsConfig {
350    fn default() -> Self {
351        Self {
352            visible: true,
353            refs: Vec::new(),
354            ring_color: Color::from_rgb8(255, 215, 0),
355            ring_width_px: 2.0,
356            radius_extra_px: 3.0,
357        }
358    }
359}
360
361/// A renderer-independent reference to one selected data primitive.
362///
363/// These records deliberately carry provenance only. Coordinates, bin edges,
364/// matrix values, and contour values remain in the caller's registered source
365/// columns / series configuration; the renderer never creates a CPU shadow of
366/// GPU-owned data just to remember a selection.
367#[derive(Debug, Clone, PartialEq)]
368#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
369#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
370pub enum PickedDataRef {
371    Point {
372        #[cfg_attr(
373            feature = "serde",
374            serde(default, skip_serializing_if = "Option::is_none")
375        )]
376        source_id: Option<String>,
377        series_id: String,
378        point_index: usize,
379    },
380    HistogramBin {
381        #[cfg_attr(
382            feature = "serde",
383            serde(default, skip_serializing_if = "Option::is_none")
384        )]
385        source_id: Option<String>,
386        series_id: String,
387        bin_index: usize,
388    },
389    MatrixCell {
390        #[cfg_attr(
391            feature = "serde",
392            serde(default, skip_serializing_if = "Option::is_none")
393        )]
394        source_id: Option<String>,
395        series_id: String,
396        /// Canonical x-axis cell/quad index, independent of matrix storage
397        /// orientation.
398        x_index: usize,
399        /// Canonical y-axis cell/quad index, independent of matrix storage
400        /// orientation.
401        y_index: usize,
402    },
403    ContourLevel {
404        #[cfg_attr(
405            feature = "serde",
406            serde(default, skip_serializing_if = "Option::is_none")
407        )]
408        source_id: Option<String>,
409        series_id: String,
410        /// Index into `ContourConfig.levels`; the level value itself stays in
411        /// the chart configuration SSoT.
412        level_index: usize,
413        /// Canonical x/y sample-cell indices containing the hit. They identify
414        /// the local piece without copying a reconstructed coordinate.
415        x_index: usize,
416        y_index: usize,
417    },
418}
419
420impl PickedDataRef {
421    pub fn source_id(&self) -> Option<&str> {
422        match self {
423            Self::Point { source_id, .. }
424            | Self::HistogramBin { source_id, .. }
425            | Self::MatrixCell { source_id, .. }
426            | Self::ContourLevel { source_id, .. } => source_id.as_deref(),
427        }
428    }
429
430    pub fn series_id(&self) -> &str {
431        match self {
432            Self::Point { series_id, .. }
433            | Self::HistogramBin { series_id, .. }
434            | Self::MatrixCell { series_id, .. }
435            | Self::ContourLevel { series_id, .. } => series_id,
436        }
437    }
438}
439
440/// Visual policy for typed data selections.
441///
442/// The selected records are small stable identities. Every overlay resolves
443/// its geometry from the same current `Config` and `ColumnPool` used by the
444/// normal draw, so an axis/range update cannot leave a stored CPU rectangle or
445/// contour segment behind.
446#[derive(Debug, Clone, PartialEq)]
447#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
448#[cfg_attr(feature = "serde", serde(default))]
449pub struct DataSelectionsConfig {
450    pub visible: bool,
451    pub refs: Vec<PickedDataRef>,
452    pub highlight_color: Color,
453    pub outline_width_px: f32,
454    pub point_radius_extra_px: f32,
455    pub contour_width_extra_px: f32,
456}
457
458impl Default for DataSelectionsConfig {
459    fn default() -> Self {
460        Self {
461            visible: true,
462            refs: Vec::new(),
463            highlight_color: Color::from_rgb8(255, 215, 0),
464            outline_width_px: 2.0,
465            point_radius_extra_px: 3.0,
466            contour_width_extra_px: 2.0,
467        }
468    }
469}
470
471impl DrawStyle {
472    /// True for the default scientific path (used by serde skip).
473    pub fn is_precise(&self) -> bool {
474        matches!(self, DrawStyle::Precise)
475    }
476    /// Sketch parameters when the sketch style is active.
477    pub fn sketch(&self) -> Option<&SketchOptions> {
478        match self {
479            DrawStyle::Sketch(s) => Some(s),
480            _ => None,
481        }
482    }
483    /// Milkyway parameters when the milkyway style is active.
484    pub fn milkyway(&self) -> Option<&MilkywayOptions> {
485        match self {
486            DrawStyle::Milkyway(c) => Some(c),
487            _ => None,
488        }
489    }
490    /// Constellation parameters when the constellation style is active.
491    pub fn constellation(&self) -> Option<&ConstellationOptions> {
492        match self {
493            DrawStyle::Constellation(c) => Some(c),
494            _ => None,
495        }
496    }
497
498    /// Every mode tag, in declaration order — the values valid as the JSON
499    /// `"mode"` of `draw_style`.
500    pub fn mode_tags() -> &'static [&'static str] {
501        &["precise", "sketch", "milkyway", "constellation"]
502    }
503
504    /// Parameter metadata for one mode tag. `precise` has no parameters
505    /// (empty slice); unknown tags yield `None`. Hosts generate their
506    /// parameter UI (sliders/steppers) from this instead of hardcoding
507    /// ranges.
508    pub fn param_specs_for_mode(mode: &str) -> Option<&'static [StyleParamSpec]> {
509        match mode {
510            "precise" => Some(&[]),
511            "sketch" => Some(SketchOptions::PARAM_SPECS),
512            "milkyway" => Some(MilkywayOptions::PARAM_SPECS),
513            "constellation" => Some(ConstellationOptions::PARAM_SPECS),
514            _ => None,
515        }
516    }
517}
518
519/// Where the colourbar sits along its side when it is shorter than the data
520/// area (`length_frac < 1`).
521#[derive(Debug, Clone, PartialEq)]
522#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
523pub enum BarAlign {
524    /// Top for a vertical bar, left for a horizontal one.
525    Start,
526    Center,
527    /// Bottom for a vertical bar, right for a horizontal one.
528    End,
529}
530
531/// The colourbar — and, because it owns the z axis, the chart's only z scale.
532///
533/// `axis` is the whole point. It is the same [`AxisOptions`] the four chart
534/// axes use, so `scale` (including `Logarithmic`), `min` / `max`,
535/// `major_spacing`, `minor_count`, `label_style` (including
536/// [`LabelFormat::Power`]), `tick`, and `title_option` all mean here exactly
537/// what they mean there — and tick generation, label formatting, and log
538/// handling are the same code, not a parallel implementation that drifts.
539///
540/// `axis.min` / `axis.max` are the authority on the z range. A helper may fill
541/// them in from matrix statistics, the way [`AxisOptions::auto_ticks`] fills in
542/// spacing, but nothing else stores a z range.
543///
544/// Consequences worth stating, because they are decisions and not accidents:
545///
546/// - A `Heatmap` / `Contour` / `HeatmapContour` series requires this to be
547///   `Some`. Without it there is no z range and no colormap anywhere, so there
548///   is no value to draw — the renderer rejects the series rather than
549///   inventing one.
550/// - To hide the bar, set `visible: false`. The options stay, the field still
551///   renders, and the band is given back to the data area.
552/// - There is therefore **one z scale per chart**. Several heatmaps in one
553///   chart share it; two colourbars with different scales is out of scope.
554#[derive(Debug, Clone, PartialEq)]
555#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
556pub struct ColorBarOptions {
557    /// False draws nothing and reserves no band. Field series still render.
558    pub visible: bool,
559    /// Which margin the bar occupies. `Left` / `Right` give a vertical bar,
560    /// `Top` / `Bottom` a horizontal one, and this alone decides which.
561    ///
562    /// The discrete half of the bar's placement, the way
563    /// [`Legend::corner`](crate::legend::Legend::corner) is for the legend box;
564    /// `offset_x` / `offset_y` are the continuous half.
565    pub side: Side,
566    /// The strip's short dimension: width for a vertical bar, height for a
567    /// horizontal one.
568    pub thickness_px: f32,
569    /// Space between the data area and the strip.
570    pub gap_px: f32,
571    /// Strip length as a fraction of the data area's length along its side.
572    pub length_frac: f32,
573    pub align: BarAlign,
574    /// Free offset from the `side` + `align` anchor, in screen pixels — the
575    /// same contract as `Legend::offset_{x,y}` and the title / label offsets:
576    /// **margin-noncontributing**, so nudging the bar moves it without
577    /// reflowing the data area under the pointer.
578    ///
579    /// This is where a drag accumulates. Without it the bar had no continuous
580    /// position at all, which is what kept it out of the selection / drag /
581    /// resize machinery every other piece of chrome already uses.
582    pub offset_x: f32,
583    pub offset_y: f32,
584    pub colormap: ColorMap,
585    /// Colour for z values the ramp cannot place: NaN, and non-positive z on a
586    /// logarithmic colourbar. Fully transparent by default — "missing" reads as
587    /// absent rather than as some particular value.
588    pub nan_color: Color,
589    pub border_color: Color,
590    pub border_width: f32,
591    /// The z axis. See the type docs — this is the SSoT for the z range.
592    pub axis: AxisOptions,
593}
594
595impl ColorBarOptions {
596    /// Where `z` falls on the ramp, in `[0, 1]`, or `None` when the ramp cannot
597    /// place it — draw those with [`Self::nan_color`].
598    ///
599    /// This is **the** z→colour normalization. The CPU colourbar strip and the
600    /// GPU field sampler both go through this formula, so a cell and the tick
601    /// beside it cannot disagree about what a value looks like:
602    ///
603    /// ```text
604    /// linear: t = (z - min) / (max - min)
605    /// log:    t = (log10 z - log10 min) / (log10 max - log10 min)
606    /// ```
607    ///
608    /// clamped to `[0, 1]`. `None` for NaN, for a non-positive `z` on a
609    /// logarithmic bar (no logarithm to take), and for a degenerate range.
610    ///
611    /// Two things it deliberately does **not** do:
612    ///
613    /// - It does not clamp an unplaceable value to an endpoint. "Missing" and
614    ///   "smallest" are different facts, and `nan_color` is how the second one
615    ///   stays distinguishable.
616    /// - It does not apply `axis.inverted`. Inverting an axis moves where a
617    ///   value is *drawn*; it does not change which colour that value has. The
618    ///   strip's screen ends swap, its ramp does not reverse.
619    pub fn normalized_z(&self, z: f64) -> Option<f32> {
620        let axis = &self.axis;
621        if !z.is_finite() || !axis.min.is_finite() || !axis.max.is_finite() {
622            return None;
623        }
624        let (value, low, high) = match axis.scale {
625            AxisScale::Linear => (z, axis.min, axis.max),
626            AxisScale::Logarithmic => {
627                if z <= 0.0 || axis.min <= 0.0 || axis.max <= 0.0 {
628                    return None;
629                }
630                (z.log10(), axis.min.log10(), axis.max.log10())
631            }
632        };
633        let span = high - low;
634        if span <= 0.0 {
635            return None;
636        }
637        Some((((value - low) / span) as f32).clamp(0.0, 1.0))
638    }
639
640    /// The colour for `z`: the ramp where it can be placed, `nan_color` where it
641    /// cannot.
642    pub fn color_for_z(&self, z: f64) -> Color {
643        match self.normalized_z(z) {
644            Some(t) => self.colormap.sample(t),
645            None => self.nan_color,
646        }
647    }
648}
649
650#[derive(Debug, Clone, PartialEq)]
651#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
652pub struct Config {
653    pub chart_area: ChartArea,
654    pub top_x: AxisOptions,
655    pub bottom_x: AxisOptions,
656    pub left_y: AxisOptions,
657    pub right_y: AxisOptions,
658    pub chart_title: ChartTitleOptions,
659    pub grid: GridOptions,
660    pub legend: Legend,
661    #[cfg_attr(
662        feature = "serde",
663        serde(default, skip_serializing_if = "Option::is_none")
664    )]
665    pub picked_points: Option<PickedPointsConfig>,
666    /// Typed point/bin/cell/contour selections. Additive to the legacy
667    /// `picked_points` field so existing documents and hosts keep their exact
668    /// point-only contract.
669    #[cfg_attr(
670        feature = "serde",
671        serde(default, skip_serializing_if = "Option::is_none")
672    )]
673    pub picked_data: Option<DataSelectionsConfig>,
674    /// The colourbar and, with it, the chart's z scale. `None` (key absent in
675    /// JSON) is a chart with no z dimension — the shape of every document
676    /// written before field series existed.
677    #[cfg_attr(
678        feature = "serde",
679        serde(default, skip_serializing_if = "Option::is_none")
680    )]
681    pub colorbar: Option<ColorBarOptions>,
682    /// Chart-global render style. `Precise` (default, key absent in JSON) is
683    /// identical to current rendering; every other variant is an opt-in
684    /// stylized mode. No per-series mixing.
685    #[cfg_attr(
686        feature = "serde",
687        serde(default, skip_serializing_if = "DrawStyle::is_precise")
688    )]
689    pub draw_style: DrawStyle,
690}
691
692impl Config {
693    /// Multiply every **pixel-based visual dim** by `scale`. Data ranges, colors,
694    /// and scale enums are untouched. For resolution-invariant high-DPI export.
695    pub fn scaled(&self, scale: f32) -> Self {
696        let mut c = self.clone();
697        c.scale_in_place(scale);
698        c
699    }
700
701    pub fn scale_in_place(&mut self, s: f32) {
702        let scale_u32 = |v: u32| ((v as f32) * s).round() as u32;
703        self.chart_area.0.x = scale_u32(self.chart_area.0.x);
704        self.chart_area.0.y = scale_u32(self.chart_area.0.y);
705        self.chart_area.0.width = scale_u32(self.chart_area.0.width);
706        self.chart_area.0.height = scale_u32(self.chart_area.0.height);
707
708        scale_rich_text(&mut self.chart_title.text, s);
709        self.chart_title.top_margin *= s;
710        self.chart_title.offset_x *= s;
711        self.chart_title.offset_y *= s;
712
713        for axis in [
714            &mut self.top_x,
715            &mut self.bottom_x,
716            &mut self.left_y,
717            &mut self.right_y,
718        ] {
719            scale_axis_in_place(axis, s);
720        }
721
722        self.grid.major_x_width *= s;
723        self.grid.major_y_width *= s;
724        self.grid.minor_x_width *= s;
725        self.grid.minor_y_width *= s;
726
727        self.legend.offset_x *= s;
728        self.legend.offset_y *= s;
729        self.legend.padding *= s;
730        scale_rich_text(&mut self.legend.content, s);
731
732        if let Some(picked_points) = self.picked_points.as_mut() {
733            picked_points.ring_width_px *= s;
734            picked_points.radius_extra_px *= s;
735        }
736        if let Some(picked_data) = self.picked_data.as_mut() {
737            picked_data.outline_width_px *= s;
738            picked_data.point_radius_extra_px *= s;
739            picked_data.contour_width_extra_px *= s;
740        }
741
742        // The colourbar's axis goes through the same helper as the four chart
743        // axes — a new pixel field on `AxisOptions` must not be scalable on the
744        // chart axes and silently fixed on the colourbar's. `length_frac` is a
745        // fraction of the data area and stays put.
746        if let Some(bar) = self.colorbar.as_mut() {
747            bar.thickness_px *= s;
748            bar.gap_px *= s;
749            bar.border_width *= s;
750            bar.offset_x *= s;
751            bar.offset_y *= s;
752            scale_axis_in_place(&mut bar.axis, s);
753        }
754
755        // Sketch wobble dims are pixel-based visual dims too. Scaled only in
756        // Stylized modes — `Precise` carries no dims, so the precise path
757        // sees no change. Milkyway must come out RESOLUTION-INVARIANT:
758        // px-sized dims (ribbon width, spread, star size, structure
759        // constants via structure_scale) multiply, and `star_density`
760        // (stars per arc-PX) divides — the arc itself gains px with the
761        // scale, so dividing keeps the star COUNT per data span identical.
762        // Without the division a 2× export doubles the stars and the chains
763        // bloat into overexposed blobs (field report).
764        match &mut self.draw_style {
765            DrawStyle::Sketch(sketch) => {
766                sketch.amplitude_px *= s;
767                sketch.wavelength_px *= s;
768            }
769            DrawStyle::Milkyway(c) => {
770                c.ribbon_width_px *= s;
771                c.spread_px *= s;
772                c.star_scale *= s;
773                c.structure_scale *= s;
774                if s > 0.0 {
775                    c.star_density /= s;
776                }
777            }
778            DrawStyle::Constellation(_) => {}
779            DrawStyle::Precise => {}
780        }
781    }
782}
783
784/// Scale one axis' pixel-based dims. Shared by the four chart axes and the
785/// colourbar's z axis so the set of "which fields are pixels" is written once.
786fn scale_axis_in_place(axis: &mut AxisOptions, s: f32) {
787    axis.label_style.font_size *= s;
788    axis.label_style.label_offset_x *= s;
789    axis.label_style.label_offset_y *= s;
790    scale_rich_text(&mut axis.title_option.text, s);
791    axis.title_option.offset_x *= s;
792    axis.title_option.offset_y *= s;
793    axis.out_margin *= s;
794    axis.line_offset *= s;
795    axis.line_width *= s;
796    axis.major_tick_length *= s;
797    axis.minor_tick_length *= s;
798}
799
800/// Scale a `RichText`'s pixel-based dims: the document-level `font_size` and
801/// every per-segment `font_size` override.
802fn scale_rich_text(rt: &mut RichText, s: f32) {
803    rt.font_size *= s;
804    for seg in &mut rt.segments {
805        if let Some(size) = seg.font_size.as_mut() {
806            *size *= s;
807        }
808    }
809}
810
811// z -> colour normalization. This is the formula the CPU strip and the GPU field
812// sampler share, so its edges are contracts, not incidental behaviour.
813#[cfg(test)]
814mod colorbar_normalization_tests {
815    use crate::color::Color;
816    use crate::colormap::ColorMap;
817    use crate::config::AxisScale;
818    use crate::default::default_colorbar_options;
819
820    #[test]
821    fn a_linear_bar_normalizes_and_clamps() {
822        let mut bar = default_colorbar_options();
823        bar.axis.min = -50.0;
824        bar.axis.max = 150.0;
825
826        assert_eq!(bar.normalized_z(-50.0), Some(0.0));
827        assert_eq!(bar.normalized_z(150.0), Some(1.0));
828        assert_eq!(bar.normalized_z(50.0), Some(0.5));
829        // Outside the range is the endpoint colour, not an out-of-range index.
830        assert_eq!(bar.normalized_z(-1000.0), Some(0.0));
831        assert_eq!(bar.normalized_z(1000.0), Some(1.0));
832    }
833
834    #[test]
835    fn a_logarithmic_bar_normalizes_in_decades_and_rejects_non_positive() {
836        let mut bar = default_colorbar_options();
837        bar.axis.scale = AxisScale::Logarithmic;
838        bar.axis.min = 1.0e-2;
839        bar.axis.max = 1.0e2;
840
841        assert_eq!(bar.normalized_z(1.0e-2), Some(0.0));
842        assert_eq!(bar.normalized_z(1.0e2), Some(1.0));
843        assert_eq!(bar.normalized_z(1.0), Some(0.5));
844
845        // No logarithm to take. These are `nan_color`'s job — clamping them to
846        // the low end would draw them as "smallest", which they are not.
847        assert_eq!(bar.normalized_z(0.0), None);
848        assert_eq!(bar.normalized_z(-1.0), None);
849    }
850
851    #[test]
852    fn unplaceable_values_and_ranges_yield_none() {
853        let mut bar = default_colorbar_options();
854        bar.axis.min = 0.0;
855        bar.axis.max = 1.0;
856        assert_eq!(bar.normalized_z(f64::NAN), None);
857        assert_eq!(bar.normalized_z(f64::INFINITY), None);
858
859        // A degenerate range has no ramp position for anything.
860        bar.axis.max = bar.axis.min;
861        assert_eq!(bar.normalized_z(0.0), None);
862
863        bar.axis.max = f64::NAN;
864        assert_eq!(bar.normalized_z(0.0), None);
865    }
866
867    #[test]
868    fn color_for_z_falls_back_to_nan_color() {
869        let mut bar = default_colorbar_options();
870        bar.axis.scale = AxisScale::Logarithmic;
871        bar.axis.min = 1.0;
872        bar.axis.max = 100.0;
873        bar.colormap = ColorMap::GrayScale;
874        bar.nan_color = Color::from_rgb8(255, 0, 255);
875
876        assert_eq!(bar.color_for_z(1.0), ColorMap::GrayScale.sample(0.0));
877        assert_eq!(bar.color_for_z(100.0), ColorMap::GrayScale.sample(1.0));
878        assert_eq!(bar.color_for_z(0.0), bar.nan_color);
879        assert_eq!(bar.color_for_z(f64::NAN), bar.nan_color);
880    }
881
882    // `inverted` is screen layout, not colour assignment.
883    #[test]
884    fn inverting_the_axis_does_not_reverse_the_ramp() {
885        let mut bar = default_colorbar_options();
886        bar.axis.min = 0.0;
887        bar.axis.max = 10.0;
888        let upright = bar.normalized_z(2.5);
889        bar.axis.inverted = true;
890        assert_eq!(bar.normalized_z(2.5), upright);
891    }
892}
893
894// High-DPI export invariance for the colourbar. `scaled(s)` must multiply every
895// pixel dim it owns — including the ones inside its axis, which is why the axis
896// scaling is a shared helper and not a loop over the four chart axes.
897#[cfg(test)]
898mod colorbar_scale_tests {
899    use crate::default::{default_colorbar_options, default_config};
900
901    #[test]
902    fn scaling_a_config_scales_the_colorbar_pixels_but_not_its_fractions() {
903        let mut cfg = default_config();
904        cfg.colorbar = Some(default_colorbar_options());
905        let before = cfg.colorbar.clone().expect("colourbar");
906
907        let scaled = cfg.scaled(2.0);
908        let after = scaled.colorbar.as_ref().expect("colourbar");
909
910        assert_eq!(after.thickness_px, before.thickness_px * 2.0);
911        assert_eq!(after.gap_px, before.gap_px * 2.0);
912        assert_eq!(after.border_width, before.border_width * 2.0);
913        assert_eq!(after.axis.out_margin, before.axis.out_margin * 2.0);
914        assert_eq!(
915            after.axis.major_tick_length,
916            before.axis.major_tick_length * 2.0
917        );
918        assert_eq!(
919            after.axis.minor_tick_length,
920            before.axis.minor_tick_length * 2.0
921        );
922        assert_eq!(after.axis.line_width, before.axis.line_width * 2.0);
923        assert_eq!(
924            after.axis.label_style.font_size,
925            before.axis.label_style.font_size * 2.0
926        );
927
928        // Fractions, ranges, and colours are not pixels.
929        assert_eq!(after.length_frac, before.length_frac);
930        assert_eq!(after.axis.min, before.axis.min);
931        assert_eq!(after.axis.max, before.axis.max);
932        assert_eq!(after.colormap, before.colormap);
933        assert_eq!(after.nan_color, before.nan_color);
934    }
935
936    // The colourbar's axis must scale by exactly the same rule as a chart axis:
937    // if the two ever diverge, a high-DPI export puts the bar's labels at a
938    // different size than the axis labels beside them.
939    #[test]
940    fn the_colorbar_axis_scales_like_a_chart_axis() {
941        let mut cfg = default_config();
942        let mut bar = default_colorbar_options();
943        bar.axis = cfg.left_y.clone();
944        cfg.colorbar = Some(bar);
945
946        let scaled = cfg.scaled(1.75);
947        assert_eq!(scaled.colorbar.expect("colourbar").axis, scaled.left_y);
948    }
949}
950
951// Legend types live in `crate::legend`; re-exported here so existing
952// `config::Legend…` paths keep working.
953pub use crate::legend::{
954    Legend, LegendCorner, LegendEntryKind, append_legend_entry, append_legend_entry_rich,
955    legend_entry_count, remove_legend_entry, scatter_shape_char, series_symbol_segments,
956    set_legend_entry_label, symbol_segments, update_legend_symbols_preserving_text,
957};
958
959// `draw_style` is additive schema: configs serialized before the field
960// existed must still parse (→ `Precise` mode), and `Precise` must not
961// serialize — the same discipline as `RichSegment`'s per-segment overrides
962// (text.rs). The enum is internally tagged (`"mode"`), so a style's
963// parameters sit INLINE next to the tag.
964#[cfg(all(test, feature = "serde"))]
965mod draw_style_serde_tests {
966    use super::{
967        Config, DataSelectionsConfig, DrawStyle, PickedDataRef, PickedPointRef, PickedPointsConfig,
968        SketchOptions,
969    };
970    use crate::default::default_config;
971
972    /// Serialized default config — `draw_style` is `Precise`, so the JSON has
973    /// no `"draw_style"` key: exactly the shape of pre-sketch documents.
974    fn default_config_json() -> serde_json::Value {
975        serde_json::to_value(default_config()).expect("serialize Config")
976    }
977
978    #[test]
979    fn config_without_draw_style_key_deserializes_to_precise() {
980        let cfg: Config =
981            serde_json::from_value(default_config_json()).expect("pre-sketch document parses");
982        assert_eq!(cfg.draw_style, DrawStyle::Precise);
983    }
984
985    #[test]
986    fn sketch_tag_alone_yields_all_defaults() {
987        let mut json = default_config_json();
988        json.as_object_mut()
989            .unwrap()
990            .insert("draw_style".into(), serde_json::json!({ "mode": "sketch" }));
991        let cfg: Config = serde_json::from_value(json).expect("tag-only sketch object parses");
992        let s = *cfg.draw_style.sketch().expect("sketch enabled");
993        assert_eq!(s, SketchOptions::default());
994        // Pin the stock values themselves, not just Default == Default.
995        assert_eq!(s.amplitude_px, 1.5);
996        assert_eq!(s.wavelength_px, 60.0);
997        assert_eq!(s.seed, 0);
998    }
999
1000    #[test]
1001    fn partial_sketch_fields_fill_remaining_defaults() {
1002        let mut json = default_config_json();
1003        json.as_object_mut().unwrap().insert(
1004            "draw_style".into(),
1005            serde_json::json!({ "mode": "sketch", "seed": 7 }),
1006        );
1007        let cfg: Config = serde_json::from_value(json).expect("partial sketch object parses");
1008        assert_eq!(
1009            cfg.draw_style,
1010            DrawStyle::Sketch(SketchOptions {
1011                seed: 7,
1012                ..SketchOptions::default()
1013            })
1014        );
1015    }
1016
1017    #[test]
1018    fn sketch_round_trips_with_inline_fields() {
1019        let mut cfg = default_config();
1020        cfg.draw_style = DrawStyle::Sketch(SketchOptions {
1021            amplitude_px: 2.5,
1022            wavelength_px: 42.0,
1023            seed: 9,
1024        });
1025        let json = serde_json::to_value(&cfg).expect("serialize Config");
1026        // Internally tagged: the SketchOptions fields are inline siblings of
1027        // `"mode"`, not a nested object.
1028        let ds = json.get("draw_style").expect("draw_style key present");
1029        assert_eq!(ds.get("mode"), Some(&serde_json::json!("sketch")));
1030        assert_eq!(ds.get("amplitude_px"), Some(&serde_json::json!(2.5)));
1031        assert_eq!(ds.get("wavelength_px"), Some(&serde_json::json!(42.0)));
1032        assert_eq!(ds.get("seed"), Some(&serde_json::json!(9)));
1033        let back: Config = serde_json::from_value(json).expect("round-trip parses");
1034        assert_eq!(back.draw_style, cfg.draw_style);
1035    }
1036
1037    #[test]
1038    fn explicit_precise_mode_deserializes_to_precise() {
1039        let mut json = default_config_json();
1040        json.as_object_mut().unwrap().insert(
1041            "draw_style".into(),
1042            serde_json::json!({ "mode": "precise" }),
1043        );
1044        let cfg: Config = serde_json::from_value(json).expect("explicit precise parses");
1045        assert_eq!(cfg.draw_style, DrawStyle::Precise);
1046    }
1047
1048    #[test]
1049    fn precise_serializes_without_key() {
1050        let json = default_config_json();
1051        assert!(
1052            json.get("draw_style").is_none(),
1053            "Precise draw_style must be skipped in serialization: {json}"
1054        );
1055    }
1056
1057    #[test]
1058    fn config_without_picked_points_key_deserializes_to_none() {
1059        let cfg: Config =
1060            serde_json::from_value(default_config_json()).expect("pre-picker document parses");
1061        assert_eq!(cfg.picked_points, None);
1062    }
1063
1064    #[test]
1065    fn picked_points_empty_object_yields_defaults() {
1066        let mut json = default_config_json();
1067        json.as_object_mut()
1068            .unwrap()
1069            .insert("picked_points".into(), serde_json::json!({}));
1070        let cfg: Config = serde_json::from_value(json).expect("tag-only picked_points parses");
1071        assert_eq!(cfg.picked_points, Some(PickedPointsConfig::default()));
1072    }
1073
1074    #[test]
1075    fn picked_point_refs_skip_absent_source_id() {
1076        let mut cfg = default_config();
1077        cfg.picked_points = Some(PickedPointsConfig {
1078            refs: vec![
1079                PickedPointRef {
1080                    source_id: None,
1081                    series_id: "series-a".into(),
1082                    point_index: 2,
1083                },
1084                PickedPointRef {
1085                    source_id: Some("source-a".into()),
1086                    series_id: "series-b".into(),
1087                    point_index: 4,
1088                },
1089            ],
1090            ..PickedPointsConfig::default()
1091        });
1092
1093        let json = serde_json::to_value(&cfg).expect("serialize Config");
1094        assert!(json["picked_points"]["refs"][0].get("source_id").is_none());
1095        assert_eq!(json["picked_points"]["refs"][1]["source_id"], "source-a");
1096        let back: Config = serde_json::from_value(json).expect("parse back");
1097        assert_eq!(back.picked_points, cfg.picked_points);
1098    }
1099
1100    #[test]
1101    fn scale_scales_picked_point_pixel_dimensions() {
1102        let mut cfg = default_config();
1103        cfg.picked_points = Some(PickedPointsConfig::default());
1104        cfg.scale_in_place(2.0);
1105        let picked = cfg.picked_points.expect("picked points");
1106        assert_eq!(picked.ring_width_px, 4.0);
1107        assert_eq!(picked.radius_extra_px, 6.0);
1108    }
1109
1110    #[test]
1111    fn config_without_picked_data_key_deserializes_to_none() {
1112        let cfg: Config =
1113            serde_json::from_value(default_config_json()).expect("pre-data-pick document parses");
1114        assert_eq!(cfg.picked_data, None);
1115    }
1116
1117    #[test]
1118    fn picked_data_empty_object_yields_defaults() {
1119        let mut json = default_config_json();
1120        json.as_object_mut()
1121            .unwrap()
1122            .insert("picked_data".into(), serde_json::json!({}));
1123        let cfg: Config = serde_json::from_value(json).expect("empty picked_data parses");
1124        assert_eq!(cfg.picked_data, Some(DataSelectionsConfig::default()));
1125    }
1126
1127    #[test]
1128    fn typed_picked_data_round_trips_with_minimal_provenance() {
1129        let mut cfg = default_config();
1130        cfg.picked_data = Some(DataSelectionsConfig {
1131            refs: vec![
1132                PickedDataRef::Point {
1133                    source_id: None,
1134                    series_id: "points".into(),
1135                    point_index: 3,
1136                },
1137                PickedDataRef::HistogramBin {
1138                    source_id: Some("source-a".into()),
1139                    series_id: "hist".into(),
1140                    bin_index: 4,
1141                },
1142                PickedDataRef::MatrixCell {
1143                    source_id: None,
1144                    series_id: "heat".into(),
1145                    x_index: 5,
1146                    y_index: 6,
1147                },
1148                PickedDataRef::ContourLevel {
1149                    source_id: None,
1150                    series_id: "contour".into(),
1151                    level_index: 2,
1152                    x_index: 7,
1153                    y_index: 8,
1154                },
1155            ],
1156            ..DataSelectionsConfig::default()
1157        });
1158
1159        let json = serde_json::to_value(&cfg).expect("serialize typed selections");
1160        assert_eq!(json["picked_data"]["refs"][0]["kind"], "point");
1161        assert!(json["picked_data"]["refs"][0].get("source_id").is_none());
1162        assert_eq!(json["picked_data"]["refs"][1]["kind"], "histogram_bin");
1163        assert_eq!(json["picked_data"]["refs"][2]["kind"], "matrix_cell");
1164        assert_eq!(json["picked_data"]["refs"][3]["kind"], "contour_level");
1165        let back: Config = serde_json::from_value(json).expect("parse typed selections");
1166        assert_eq!(back.picked_data, cfg.picked_data);
1167    }
1168
1169    #[test]
1170    fn scale_scales_typed_selection_pixel_dimensions() {
1171        let mut cfg = default_config();
1172        cfg.picked_data = Some(DataSelectionsConfig::default());
1173        cfg.scale_in_place(2.0);
1174        let picked = cfg.picked_data.expect("picked data");
1175        assert_eq!(picked.outline_width_px, 4.0);
1176        assert_eq!(picked.point_radius_extra_px, 6.0);
1177        assert_eq!(picked.contour_width_extra_px, 4.0);
1178    }
1179
1180    #[test]
1181    fn constellation_tag_alone_yields_all_defaults() {
1182        let mut json = default_config_json();
1183        json["draw_style"] = serde_json::json!({ "mode": "constellation" });
1184        let cfg: Config = serde_json::from_value(json).expect("tag-only constellation parses");
1185        assert_eq!(
1186            cfg.draw_style,
1187            super::DrawStyle::Constellation(super::ConstellationOptions::default()),
1188        );
1189    }
1190
1191    #[test]
1192    fn milkyway_tag_alone_yields_all_defaults() {
1193        let mut json = default_config_json();
1194        json["draw_style"] = serde_json::json!({ "mode": "milkyway" });
1195        let cfg: Config = serde_json::from_value(json).expect("tag-only milkyway parses");
1196        assert_eq!(
1197            cfg.draw_style,
1198            super::DrawStyle::Milkyway(super::MilkywayOptions::default()),
1199        );
1200    }
1201
1202    /// PARAM_SPECS defaults are literals (const context) — pin them to the
1203    /// `Default` impls so the two sources cannot drift, and pin every spec
1204    /// key to a real serde field by writing it through `draw_style` JSON.
1205    #[test]
1206    fn param_specs_match_defaults_and_serde_fields() {
1207        use super::{ConstellationOptions, MilkywayOptions, SketchOptions};
1208
1209        let check = |mode: &str, specs: &[super::StyleParamSpec], defaults: serde_json::Value| {
1210            for s in specs {
1211                let d = defaults
1212                    .get(s.key)
1213                    .unwrap_or_else(|| panic!("{mode}: spec key {} is not a serde field", s.key))
1214                    .as_f64()
1215                    .expect("numeric field");
1216                assert!(
1217                    (d - s.default).abs() < 1e-6,
1218                    "{mode}.{}: spec default {} != Default impl {}",
1219                    s.key,
1220                    s.default,
1221                    d
1222                );
1223                assert!(
1224                    s.min <= s.default && s.default <= s.max,
1225                    "{mode}.{}: default outside range",
1226                    s.key
1227                );
1228
1229                // Round-trip the key through the tagged enum to prove it is
1230                // accepted (a typo'd key would be silently ignored). Integer
1231                // specs must be written as JSON integers — u32 fields reject
1232                // a float literal.
1233                let mut style = serde_json::json!({ "mode": mode });
1234                style[s.key] = if s.integer {
1235                    serde_json::json!(s.max as i64)
1236                } else {
1237                    serde_json::json!(s.max)
1238                };
1239                let parsed: super::DrawStyle =
1240                    serde_json::from_value(style).expect("spec key parses in draw_style");
1241                let back = serde_json::to_value(parsed).expect("serialize");
1242                let v = back
1243                    .get(s.key)
1244                    .expect("key survives round trip")
1245                    .as_f64()
1246                    .unwrap();
1247                assert!(
1248                    (v - s.max).abs() < 1e-4,
1249                    "{mode}.{}: value did not stick",
1250                    s.key
1251                );
1252            }
1253        };
1254
1255        check(
1256            "sketch",
1257            SketchOptions::PARAM_SPECS,
1258            serde_json::to_value(SketchOptions::default()).unwrap(),
1259        );
1260        check(
1261            "milkyway",
1262            MilkywayOptions::PARAM_SPECS,
1263            serde_json::to_value(MilkywayOptions::default()).unwrap(),
1264        );
1265        check(
1266            "constellation",
1267            ConstellationOptions::PARAM_SPECS,
1268            serde_json::to_value(ConstellationOptions::default()).unwrap(),
1269        );
1270        assert_eq!(super::DrawStyle::param_specs_for_mode("nope"), None);
1271        assert_eq!(
1272            super::DrawStyle::param_specs_for_mode("precise"),
1273            Some(&[][..])
1274        );
1275    }
1276
1277    #[test]
1278    fn constellation_round_trips_with_inline_fields() {
1279        let mut cfg = default_config();
1280        cfg.draw_style = super::DrawStyle::Constellation(super::ConstellationOptions {
1281            star_opacity: 0.75,
1282            line_opacity: 0.25,
1283        });
1284        let json = serde_json::to_value(&cfg).expect("serialize");
1285        assert_eq!(json["draw_style"]["mode"], "constellation");
1286        assert_eq!(json["draw_style"]["star_opacity"], 0.75);
1287        assert_eq!(json["draw_style"]["line_opacity"], 0.25);
1288        let back: Config = serde_json::from_value(json).expect("parse back");
1289        assert_eq!(back.draw_style, cfg.draw_style);
1290    }
1291
1292    #[test]
1293    fn milkyway_round_trips_with_inline_fields() {
1294        let mut cfg = default_config();
1295        cfg.draw_style = super::DrawStyle::Milkyway(super::MilkywayOptions {
1296            star_density: 22.0,
1297            ribbon_width_px: 20.0,
1298            ribbon_intensity: 0.4,
1299            star_scale: 1.3,
1300            star_brightness: 1.8,
1301            spread_px: 3.5,
1302            structure_scale: 1.25,
1303            faint_bias: 4.5,
1304            glow: 0.8,
1305            nebula: 0.7,
1306            dust: 1.5,
1307            planet_rim: 0.5,
1308            seed: 9,
1309        });
1310        let json = serde_json::to_value(&cfg).expect("serialize");
1311        assert_eq!(json["draw_style"]["mode"], "milkyway");
1312        assert_eq!(json["draw_style"]["star_density"], 22.0);
1313        let back: Config = serde_json::from_value(json).expect("parse back");
1314        assert_eq!(back.draw_style, cfg.draw_style);
1315    }
1316}