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