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 pub out_margin: f32,
95
96 pub line_offset: f32,
102
103 pub line_visible: bool,
105 pub line_color: Color,
106 pub line_width: f32,
107 pub line_style: LineStylePreset,
108
109 pub major_tick_length: f32,
111 pub minor_tick_length: f32,
112}
113
114#[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 pub amplitude_px: f32,
123 pub wavelength_px: f32,
126 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#[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 pub star_density: f32,
152 pub ribbon_width_px: f32,
154 pub ribbon_intensity: f32,
157 pub star_scale: f32,
159 pub star_brightness: f32,
161 pub spread_px: f32,
164 pub structure_scale: f32,
171 pub faint_bias: f32,
175 pub glow: f32,
178 pub nebula: f32,
182 pub dust: f32,
185 pub planet_rim: f32,
188 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#[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 pub star_opacity: f32,
221 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#[derive(Clone, Copy, Debug, PartialEq)]
236#[cfg_attr(feature = "serde", derive(serde::Serialize))]
237pub struct StyleParamSpec {
238 pub key: &'static str,
240 pub min: f64,
241 pub max: f64,
242 pub default: f64,
243 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 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 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 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#[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#[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 x_index: usize,
393 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 level_index: usize,
407 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#[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 pub fn is_precise(&self) -> bool {
468 matches!(self, DrawStyle::Precise)
469 }
470 pub fn sketch(&self) -> Option<&SketchOptions> {
472 match self {
473 DrawStyle::Sketch(s) => Some(s),
474 _ => None,
475 }
476 }
477 pub fn milkyway(&self) -> Option<&MilkywayOptions> {
479 match self {
480 DrawStyle::Milkyway(c) => Some(c),
481 _ => None,
482 }
483 }
484 pub fn constellation(&self) -> Option<&ConstellationOptions> {
486 match self {
487 DrawStyle::Constellation(c) => Some(c),
488 _ => None,
489 }
490 }
491
492 pub fn mode_tags() -> &'static [&'static str] {
495 &["precise", "sketch", "milkyway", "constellation"]
496 }
497
498 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#[derive(Debug, Clone, PartialEq)]
516#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
517pub enum BarAlign {
518 Start,
520 Center,
521 End,
523}
524
525#[derive(Debug, Clone, PartialEq)]
549#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
550pub struct ColorBarOptions {
551 pub visible: bool,
553 pub side: Side,
560 pub thickness_px: f32,
563 pub gap_px: f32,
565 pub length_frac: f32,
567 pub align: BarAlign,
568 pub offset_x: f32,
577 pub offset_y: f32,
578 pub colormap: ColorMap,
579 pub nan_color: Color,
583 pub border_color: Color,
584 pub border_width: f32,
585 pub axis: AxisOptions,
587}
588
589impl ColorBarOptions {
590 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 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 #[cfg_attr(
664 feature = "serde",
665 serde(default, skip_serializing_if = "Option::is_none")
666 )]
667 pub picked_data: Option<DataSelectionsConfig>,
668 #[cfg_attr(
672 feature = "serde",
673 serde(default, skip_serializing_if = "Option::is_none")
674 )]
675 pub colorbar: Option<ColorBarOptions>,
676 #[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 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 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 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
778fn 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
794fn 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#[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 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 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 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 #[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#[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 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 #[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
945pub 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#[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 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 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 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 #[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 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}