use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{
CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts, NumberSeriesStyleDefaultConfig,
};
use crate::utils::{CssKind, GradientStopSpec, canonicalize_gradient_stops};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ColumnConfigSchema {
pub fields: Vec<ControlSpec>,
}
impl ColumnConfigSchema {
pub fn active_keys(&self) -> HashSet<String> {
let mut out = HashSet::new();
for spec in &self.fields {
for k in spec.serialized_keys() {
out.insert(k.to_string());
}
}
out
}
pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
for spec in fields {
match spec {
ControlSpec::Group { fields, .. } => collect(fields, out),
leaf => out.push(leaf),
}
}
}
let mut out = vec![];
collect(&self.fields, &mut out);
out
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum NumberStat {
AbsMax,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "kind")]
pub enum ControlSpec {
Enum {
key: String,
variants: Vec<EnumVariant>,
default: String,
},
Font {
key: String,
default: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
size: Option<FontSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bold: Option<FontToggle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
italic: Option<FontToggle>,
},
Alignment {
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<Alignment>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
corners: bool,
},
Bool {
key: String,
default: bool,
},
Number {
key: String,
default: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
include: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
step: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_stat: Option<NumberStat>,
},
String {
key: String,
default: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
placeholder: Option<String>,
},
Color {
key: String,
default: String,
},
Palette {
key: String,
default: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<usize>,
},
GradientStops {
key: String,
default: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
discrete: bool,
},
DatetimeFormat {
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<DatetimeFormatType>,
},
NumberSeriesStyle {
default: NumberSeriesStyleDefaultConfig,
},
Symbols {
default: KeyValueOpts,
},
NumberFormat {
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<CustomNumberFormatConfig>,
},
AggregateDepth,
Group {
key: String,
#[serde(default)]
fields: Vec<ControlSpec>,
},
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Alignment {
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Alignment {
pub const ALL: [Alignment; 9] = [
Alignment::TopLeft,
Alignment::Top,
Alignment::TopRight,
Alignment::Left,
Alignment::Center,
Alignment::Right,
Alignment::BottomLeft,
Alignment::Bottom,
Alignment::BottomRight,
];
pub const CORNERS: [Alignment; 4] = [
Alignment::TopLeft,
Alignment::TopRight,
Alignment::BottomLeft,
Alignment::BottomRight,
];
pub fn is_corner(self) -> bool {
Self::CORNERS.contains(&self)
}
pub fn as_str(self) -> &'static str {
match self {
Alignment::TopLeft => "top-left",
Alignment::Top => "top",
Alignment::TopRight => "top-right",
Alignment::Left => "left",
Alignment::Center => "center",
Alignment::Right => "right",
Alignment::BottomLeft => "bottom-left",
Alignment::Bottom => "bottom",
Alignment::BottomRight => "bottom-right",
}
}
pub fn parse(src: &str) -> Option<Self> {
Self::ALL.into_iter().find(|x| x.as_str() == src)
}
pub fn humanized(self) -> String {
self.as_str()
.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct FontToggle {
pub key: String,
#[serde(default)]
pub default: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct FontSize {
pub key: String,
pub default: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct EnumVariant {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
let stops = canonicalize_gradient_stops(stops);
match (stops.first(), stops.last()) {
(Some(first), Some(last)) if stops.len() > 2 => vec![
GradientStopSpec {
color: first.color.clone(),
offset: 0.0,
},
GradientStopSpec {
color: last.color.clone(),
offset: 1.0,
},
],
_ => stops,
}
}
impl ColumnConfigSchema {
pub fn canonicalize(self) -> Self {
self.canonicalize_defaults().group_format_controls()
}
pub fn resolve_stat_defaults(mut self, abs_max: Option<f64>) -> Self {
fn walk(fields: &mut [ControlSpec], abs_max: Option<f64>) {
for spec in fields {
match spec {
ControlSpec::Group { fields, .. } => walk(fields, abs_max),
ControlSpec::Number {
default,
default_stat: Some(NumberStat::AbsMax),
..
} => {
if let Some(abs_max) = abs_max {
*default = abs_max;
}
},
_ => {},
}
}
}
walk(&mut self.fields, abs_max);
self
}
pub fn group_format_controls(mut self) -> Self {
fn is_format(spec: &ControlSpec) -> bool {
matches!(
spec,
ControlSpec::NumberFormat { .. } | ControlSpec::DatetimeFormat { .. }
)
}
fn walk(fields: &mut Vec<ControlSpec>) {
for spec in fields.iter_mut() {
if let ControlSpec::Group { key, fields } = spec
&& key != "format"
{
walk(fields);
}
}
let first = fields.iter().position(is_format);
if let Some(first) = first {
let mut formats = vec![];
let mut i = first;
while i < fields.len() {
if is_format(&fields[i]) {
formats.push(fields.remove(i));
} else {
i += 1;
}
}
fields.insert(first, ControlSpec::Group {
key: "format".to_owned(),
fields: formats,
});
}
}
walk(&mut self.fields);
self
}
pub fn canonicalize_defaults(mut self) -> Self {
fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
fields.retain_mut(|spec| {
let (kind, key, default) = match spec {
ControlSpec::Group { fields, .. } => {
canonicalize_specs(fields);
return !fields.is_empty();
},
ControlSpec::Alignment {
key,
default: Some(default),
corners: true,
} if !default.is_corner() => {
tracing::error!(
"Dropping `{key}` — default `{}` is not a corner",
default.as_str()
);
return false;
},
ControlSpec::Color { key, default } => (CssKind::Color, key, default),
ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
ControlSpec::GradientStops { key, default, .. } => {
(CssKind::Gradient, key, default)
},
_ => return true,
};
match kind.canonicalize(default) {
Ok(canonical) => {
*default = canonical;
true
},
Err(error) => {
tracing::error!("Dropping `{key}` — invalid schema default: {error}");
false
},
}
});
}
canonicalize_specs(&mut self.fields);
self
}
pub fn enum_variants_of(&self, key: &str) -> Option<&[EnumVariant]> {
self.leaf_fields().into_iter().find_map(|spec| match spec {
ControlSpec::Enum {
key: k, variants, ..
} if k == key => Some(variants.as_slice()),
_ => None,
})
}
pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
self.leaf_fields().into_iter().find_map(|spec| match spec {
ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
_ => None,
})
}
}
impl ControlSpec {
pub fn serialized_keys(&self) -> Vec<&str> {
match self {
ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
ControlSpec::Font {
key,
size,
bold,
italic,
..
} => [
Some(key.as_str()),
size.as_ref().map(|s| s.key.as_str()),
bold.as_ref().map(|t| t.key.as_str()),
italic.as_ref().map(|t| t.key.as_str()),
]
.into_iter()
.flatten()
.collect(),
ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
ControlSpec::Symbols { .. } => vec!["symbols"],
ControlSpec::NumberFormat { .. } => vec!["number_format"],
ControlSpec::AggregateDepth => vec!["aggregate_depth"],
ControlSpec::Enum { key, .. }
| ControlSpec::Alignment { key, .. }
| ControlSpec::Bool { key, .. }
| ControlSpec::Number { key, .. }
| ControlSpec::String { key, .. }
| ControlSpec::Color { key, .. }
| ControlSpec::Palette { key, .. }
| ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
ControlSpec::Group { fields, .. } => {
fields.iter().flat_map(|f| f.serialized_keys()).collect()
},
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ColumnConfigFieldUpdate {
pub keys: Vec<String>,
pub value: serde_json::Map<String, Value>,
}
pub fn filter_to_schema(
config: &serde_json::Map<String, Value>,
active_keys: &HashSet<String>,
) -> serde_json::Map<String, Value> {
config
.iter()
.filter(|(k, _)| active_keys.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}