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,
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 pub out_margin: f32,
101
102 pub line_offset: f32,
108
109 pub line_visible: bool,
111 pub line_color: Color,
112 pub line_width: f32,
113 pub line_style: LineStylePreset,
114
115 pub major_tick_length: f32,
117 pub minor_tick_length: f32,
118}
119
120#[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 pub amplitude_px: f32,
129 pub wavelength_px: f32,
132 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#[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 pub star_density: f32,
158 pub ribbon_width_px: f32,
160 pub ribbon_intensity: f32,
163 pub star_scale: f32,
165 pub star_brightness: f32,
167 pub spread_px: f32,
170 pub structure_scale: f32,
177 pub faint_bias: f32,
181 pub glow: f32,
184 pub nebula: f32,
188 pub dust: f32,
191 pub planet_rim: f32,
194 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#[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 pub star_opacity: f32,
227 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#[derive(Clone, Copy, Debug, PartialEq)]
242#[cfg_attr(feature = "serde", derive(serde::Serialize))]
243pub struct StyleParamSpec {
244 pub key: &'static str,
246 pub min: f64,
247 pub max: f64,
248 pub default: f64,
249 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 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 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 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#[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#[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 x_index: usize,
399 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 level_index: usize,
413 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#[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 pub fn is_precise(&self) -> bool {
474 matches!(self, DrawStyle::Precise)
475 }
476 pub fn sketch(&self) -> Option<&SketchOptions> {
478 match self {
479 DrawStyle::Sketch(s) => Some(s),
480 _ => None,
481 }
482 }
483 pub fn milkyway(&self) -> Option<&MilkywayOptions> {
485 match self {
486 DrawStyle::Milkyway(c) => Some(c),
487 _ => None,
488 }
489 }
490 pub fn constellation(&self) -> Option<&ConstellationOptions> {
492 match self {
493 DrawStyle::Constellation(c) => Some(c),
494 _ => None,
495 }
496 }
497
498 pub fn mode_tags() -> &'static [&'static str] {
501 &["precise", "sketch", "milkyway", "constellation"]
502 }
503
504 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#[derive(Debug, Clone, PartialEq)]
522#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
523pub enum BarAlign {
524 Start,
526 Center,
527 End,
529}
530
531#[derive(Debug, Clone, PartialEq)]
555#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
556pub struct ColorBarOptions {
557 pub visible: bool,
559 pub side: Side,
566 pub thickness_px: f32,
569 pub gap_px: f32,
571 pub length_frac: f32,
573 pub align: BarAlign,
574 pub offset_x: f32,
583 pub offset_y: f32,
584 pub colormap: ColorMap,
585 pub nan_color: Color,
589 pub border_color: Color,
590 pub border_width: f32,
591 pub axis: AxisOptions,
593}
594
595impl ColorBarOptions {
596 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 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 #[cfg_attr(
670 feature = "serde",
671 serde(default, skip_serializing_if = "Option::is_none")
672 )]
673 pub picked_data: Option<DataSelectionsConfig>,
674 #[cfg_attr(
678 feature = "serde",
679 serde(default, skip_serializing_if = "Option::is_none")
680 )]
681 pub colorbar: Option<ColorBarOptions>,
682 #[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 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 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 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
784fn 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
800fn 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#[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 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 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 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 #[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#[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 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 #[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
951pub 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#[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 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 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 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 #[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 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}