1use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use super::{
19 CssKind, CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts,
20 NumberSeriesStyleDefaultConfig,
21};
22
23#[derive(Clone, Debug, Default, Deserialize, Serialize)]
28pub struct ColumnConfigSchema {
29 pub fields: Vec<ControlSpec>,
30}
31
32impl ColumnConfigSchema {
33 pub fn active_keys(&self) -> HashSet<String> {
39 let mut out = HashSet::new();
40 for spec in &self.fields {
41 for k in spec.serialized_keys() {
42 out.insert(k.to_string());
43 }
44 }
45 out
46 }
47
48 pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
49 fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
50 for spec in fields {
51 match spec {
52 ControlSpec::Group { fields, .. } => collect(fields, out),
53 leaf => out.push(leaf),
54 }
55 }
56 }
57
58 let mut out = vec![];
59 collect(&self.fields, &mut out);
60 out
61 }
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize)]
70#[serde(tag = "kind")]
71pub enum ControlSpec {
72 Enum {
73 key: String,
74 variants: Vec<EnumVariant>,
75 default: String,
76 },
77 Bool {
78 key: String,
79 default: bool,
80 },
81 Number {
82 key: String,
83 default: f64,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 include: Option<bool>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 min: Option<f64>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 max: Option<f64>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 step: Option<f64>,
97 },
98 String {
99 key: String,
100 default: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 placeholder: Option<String>,
103 },
104 Color {
105 key: String,
106 default: String,
107 },
108 Palette {
109 key: String,
110 default: String,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 max: Option<usize>,
114 },
115 GradientStops {
116 key: String,
117 default: String,
118
119 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 discrete: bool,
121 },
122 DatetimeFormat {
123 #[serde(default, skip_serializing_if = "Option::is_none")]
126 default: Option<DatetimeFormatType>,
127 },
128 StringFormat,
129 NumberSeriesStyle {
130 default: NumberSeriesStyleDefaultConfig,
131 },
132 Symbols {
133 default: KeyValueOpts,
134 },
135 NumberFormat {
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 default: Option<CustomNumberFormatConfig>,
140 },
141 AggregateDepth,
142
143 Group {
144 key: String,
145 #[serde(default)]
146 fields: Vec<ControlSpec>,
147 },
148}
149
150#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
151pub struct EnumVariant {
152 pub value: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub label: Option<String>,
155}
156
157#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
160pub struct GradientStopSpec {
161 pub color: String,
162 pub offset: f64,
163}
164
165pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
169 for stop in &mut stops {
170 stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
171 }
172
173 stops.sort_by(|a, b| {
174 a.offset
175 .partial_cmp(&b.offset)
176 .unwrap_or(std::cmp::Ordering::Equal)
177 });
178
179 stops
180}
181
182pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
185 let stops = canonicalize_gradient_stops(stops);
186 match (stops.first(), stops.last()) {
187 (Some(first), Some(last)) if stops.len() > 2 => vec![
188 GradientStopSpec {
189 color: first.color.clone(),
190 offset: 0.0,
191 },
192 GradientStopSpec {
193 color: last.color.clone(),
194 offset: 1.0,
195 },
196 ],
197 _ => stops,
198 }
199}
200
201impl ColumnConfigSchema {
202 pub fn canonicalize(self) -> Self {
203 self.canonicalize_defaults().group_format_controls()
204 }
205
206 pub fn group_format_controls(mut self) -> Self {
207 fn is_format(spec: &ControlSpec) -> bool {
208 matches!(
209 spec,
210 ControlSpec::NumberFormat { .. }
211 | ControlSpec::DatetimeFormat { .. }
212 | ControlSpec::StringFormat
213 )
214 }
215
216 fn walk(fields: &mut Vec<ControlSpec>) {
217 for spec in fields.iter_mut() {
218 if let ControlSpec::Group { key, fields } = spec
219 && key != "format"
220 {
221 walk(fields);
222 }
223 }
224
225 let first = fields.iter().position(is_format);
226 if let Some(first) = first {
227 let mut formats = vec![];
228 let mut i = first;
229 while i < fields.len() {
230 if is_format(&fields[i]) {
231 formats.push(fields.remove(i));
232 } else {
233 i += 1;
234 }
235 }
236
237 fields.insert(first, ControlSpec::Group {
238 key: "format".to_owned(),
239 fields: formats,
240 });
241 }
242 }
243
244 walk(&mut self.fields);
245 self
246 }
247
248 pub fn canonicalize_defaults(mut self) -> Self {
251 fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
252 fields.retain_mut(|spec| {
253 let (kind, key, default) = match spec {
254 ControlSpec::Group { fields, .. } => {
255 canonicalize_specs(fields);
256 return !fields.is_empty();
257 },
258 ControlSpec::Color { key, default } => (CssKind::Color, key, default),
259 ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
260 ControlSpec::GradientStops { key, default, .. } => {
261 (CssKind::Gradient, key, default)
262 },
263 _ => return true,
264 };
265
266 match kind.canonicalize(default) {
267 Ok(canonical) => {
268 *default = canonical;
269 true
270 },
271 Err(error) => {
272 tracing::error!("Dropping `{key}` — invalid schema default: {error}");
273 false
274 },
275 }
276 });
277 }
278
279 canonicalize_specs(&mut self.fields);
280 self
281 }
282
283 pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
285 self.leaf_fields().into_iter().find_map(|spec| match spec {
286 ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
287 ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
288 ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
289 _ => None,
290 })
291 }
292}
293
294impl ControlSpec {
295 pub fn serialized_keys(&self) -> Vec<&str> {
301 match self {
302 ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
303 ControlSpec::StringFormat => vec!["format"],
304 ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
305 ControlSpec::Symbols { .. } => vec!["symbols"],
306 ControlSpec::NumberFormat { .. } => vec!["number_format"],
307 ControlSpec::AggregateDepth => vec!["aggregate_depth"],
308 ControlSpec::Enum { key, .. }
309 | ControlSpec::Bool { key, .. }
310 | ControlSpec::Number { key, .. }
311 | ControlSpec::String { key, .. }
312 | ControlSpec::Color { key, .. }
313 | ControlSpec::Palette { key, .. }
314 | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
315 ControlSpec::Group { fields, .. } => {
316 fields.iter().flat_map(|f| f.serialized_keys()).collect()
317 },
318 }
319 }
320}
321
322#[derive(Clone, Debug, Deserialize, Serialize)]
327pub struct ColumnConfigFieldUpdate {
328 pub keys: Vec<String>,
329 pub value: serde_json::Map<String, Value>,
330}
331
332pub fn filter_to_schema(
336 config: &serde_json::Map<String, Value>,
337 active_keys: &HashSet<String>,
338) -> serde_json::Map<String, Value> {
339 config
340 .iter()
341 .filter(|(k, _)| active_keys.contains(k.as_str()))
342 .map(|(k, v)| (k.clone(), v.clone()))
343 .collect()
344}
345
346#[cfg(test)]
347mod tests {
348 use serde_json::json;
349
350 use super::*;
351
352 fn color(key: &str, default: &str) -> ControlSpec {
353 ControlSpec::Color {
354 key: key.to_owned(),
355 default: default.to_owned(),
356 }
357 }
358
359 fn flag(key: &str) -> ControlSpec {
360 ControlSpec::Bool {
361 key: key.to_owned(),
362 default: false,
363 }
364 }
365
366 fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
367 ControlSpec::Group {
368 key: key.to_owned(),
369 fields,
370 }
371 }
372
373 #[test]
374 fn group_deserializes_recursively() {
375 let schema: ColumnConfigSchema = serde_json::from_value(json!({
376 "fields": [{
377 "kind": "Group",
378 "key": "legend",
379 "fields": [
380 { "kind": "Bool", "key": "legend_on", "default": false },
381 {
382 "kind": "Group",
383 "key": "inner",
384 "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
385 }
386 ]
387 }]
388 }))
389 .unwrap();
390
391 let keys = schema.active_keys();
392 assert_eq!(
393 keys,
394 HashSet::from(["legend_on".to_owned(), "color".to_owned()])
395 );
396
397 let leaves = schema.leaf_fields();
398 assert_eq!(leaves.len(), 2);
399 assert!(
400 leaves
401 .iter()
402 .all(|s| !matches!(s, ControlSpec::Group { .. }))
403 );
404 }
405
406 #[test]
407 fn grouped_schema_is_equivalent_to_flat() {
408 let flat = ColumnConfigSchema {
409 fields: vec![flag("stack"), color("color", "#0366d6")],
410 };
411
412 let grouped = ColumnConfigSchema {
413 fields: vec![group("series", vec![
414 flag("stack"),
415 color("color", "#0366d6"),
416 ])],
417 };
418
419 assert_eq!(flat.active_keys(), grouped.active_keys());
420 assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
421 assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
422 }
423
424 #[test]
425 fn format_controls_group_and_merge() {
426 let schema = ColumnConfigSchema {
427 fields: vec![
428 ControlSpec::NumberFormat { default: None },
429 flag("flag"),
430 ControlSpec::StringFormat,
431 ],
432 }
433 .group_format_controls();
434
435 assert_eq!(schema.fields.len(), 2);
436 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
437 panic!("expected format group first");
438 };
439
440 assert_eq!(key, "format");
441 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
442 assert!(matches!(fields[1], ControlSpec::StringFormat));
443 assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
444
445 assert_eq!(
446 schema.active_keys(),
447 HashSet::from([
448 "number_format".to_owned(),
449 "format".to_owned(),
450 "flag".to_owned()
451 ])
452 );
453 }
454
455 #[test]
456 fn format_grouping_recurses_but_never_double_wraps() {
457 let schema = ColumnConfigSchema {
458 fields: vec![
459 group("format", vec![ControlSpec::NumberFormat { default: None }]),
460 group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
461 default: None,
462 }]),
463 ],
464 }
465 .group_format_controls();
466
467 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
468 panic!("expected group");
469 };
470
471 assert_eq!(key, "format");
472 assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
473
474 let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
475 panic!("expected group");
476 };
477
478 assert!(matches!(
479 &fields[1],
480 ControlSpec::Group { key, fields }
481 if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
482 ));
483 }
484
485 #[test]
486 fn format_controls_deserialize_without_default_payload() {
487 let schema: ColumnConfigSchema = serde_json::from_value(json!({
488 "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
489 }))
490 .unwrap();
491
492 assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
493 default: None
494 }));
495 assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
496 default: None
497 }));
498 }
499
500 #[test]
501 fn number_format_default_payload_deserializes_flattened_families() {
502 let schema: ColumnConfigSchema = serde_json::from_value(json!({
503 "fields": [{
504 "kind": "NumberFormat",
505 "default": {
506 "notation": "compact",
507 "compactDisplay": "short",
508 "minimumFractionDigits": 0,
509 "maximumFractionDigits": 1
510 }
511 }]
512 }))
513 .unwrap();
514
515 let ControlSpec::NumberFormat {
516 default: Some(default),
517 } = &schema.fields[0]
518 else {
519 panic!("expected NumberFormat with default");
520 };
521
522 assert_eq!(
523 default._notation,
524 Some(crate::config::Notation::Compact(
525 crate::config::CompactDisplay::Short
526 ))
527 );
528 assert_eq!(default._style, None);
529 assert_eq!(default.minimum_fraction_digits, Some(0.));
530 assert_eq!(default.maximum_fraction_digits, Some(1.));
531 assert_eq!(
532 schema.active_keys(),
533 HashSet::from(["number_format".to_owned()])
534 );
535 }
536
537 #[test]
538 fn datetime_format_default_payload_deserializes_simple_arm() {
539 let schema: ColumnConfigSchema = serde_json::from_value(json!({
540 "fields": [{
541 "kind": "DatetimeFormat",
542 "default": { "dateStyle": "medium", "timeStyle": "disabled" }
543 }]
544 }))
545 .unwrap();
546
547 let ControlSpec::DatetimeFormat {
548 default: Some(DatetimeFormatType::Simple(simple)),
549 } = &schema.fields[0]
550 else {
551 panic!("expected DatetimeFormat with Simple default");
552 };
553
554 assert_eq!(
555 simple.date_style,
556 crate::config::SimpleDatetimeFormat::Medium
557 );
558 assert_eq!(
559 simple.time_style,
560 crate::config::SimpleDatetimeFormat::Disabled
561 );
562 }
563
564 #[test]
565 fn canonicalize_defaults_recurses_and_drops_empty_groups() {
566 let schema = ColumnConfigSchema {
567 fields: vec![
568 group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
569 group("doomed", vec![color("bad", "not-a-color")]),
570 ],
571 }
572 .canonicalize_defaults();
573
574 assert_eq!(schema.fields.len(), 1);
575 let ControlSpec::Group { key, fields } = &schema.fields[0] else {
576 panic!("expected group");
577 };
578
579 assert_eq!(key, "ok");
580 assert!(matches!(
581 &fields[0],
582 ControlSpec::Color { default, .. } if default == "#ff0000"
583 ));
584 }
585}