Skip to main content

perspective_viewer/config/
column_config_schema.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use super::{CssKind, KeyValueOpts, NumberSeriesStyleDefaultConfig};
19
20/// The full schema for one column at one point in time. Plugins may return
21/// different schemas for the same column based on the column's current
22/// stored value (e.g. to hide dependent fields), so this is re-queried on
23/// every field update.
24#[derive(Clone, Debug, Default, Deserialize, Serialize)]
25pub struct ColumnConfigSchema {
26    pub fields: Vec<ControlSpec>,
27}
28
29impl ColumnConfigSchema {
30    /// Union of every JSON key any control in this schema knows how to
31    /// read or write. Used to build the schema-filtered view of
32    /// `columns_config` passed to `plugin.restore()` — keys not in this
33    /// set are "ghost" state from a different plugin and stay invisible
34    /// to the active one.
35    pub fn active_keys(&self) -> HashSet<String> {
36        let mut out = HashSet::new();
37        for spec in &self.fields {
38            for k in spec.serialized_keys() {
39                out.insert(k.to_string());
40            }
41        }
42        out
43    }
44}
45
46/// Discriminated union of widget kinds the viewer can render. Composite
47/// variants wrap an existing rich Yew component and carry only the
48/// component's `*DefaultConfig`. Primitive variants render generic scalar
49/// widgets and carry their own `key` inline; the visible label is
50/// resolved at CSS time via `--psp-label--<key>--content`.
51#[derive(Clone, Debug, Deserialize, Serialize)]
52#[serde(tag = "kind")]
53pub enum ControlSpec {
54    Enum {
55        key: String,
56        variants: Vec<EnumVariant>,
57        default: String,
58    },
59    Bool {
60        key: String,
61        default: bool,
62    },
63    Number {
64        key: String,
65        default: f64,
66
67        /// If `true`, always serialize this values even if it is the default.
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        include: Option<bool>,
70
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        min: Option<f64>,
73
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        max: Option<f64>,
76
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        step: Option<f64>,
79    },
80    String {
81        key: String,
82        default: String,
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        placeholder: Option<String>,
85    },
86    Color {
87        key: String,
88        default: String,
89    },
90    Palette {
91        key: String,
92        default: String,
93
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        max: Option<usize>,
96    },
97    GradientStops {
98        key: String,
99        default: String,
100
101        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
102        discrete: bool,
103    },
104    DatetimeFormat,
105    StringFormat,
106    NumberSeriesStyle {
107        default: NumberSeriesStyleDefaultConfig,
108    },
109    Symbols {
110        default: KeyValueOpts,
111    },
112    NumberFormat,
113    AggregateDepth,
114}
115
116#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
117pub struct EnumVariant {
118    pub value: String,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub label: Option<String>,
121}
122
123/// One stop of a [`ControlSpec::GradientStops`] value in its in-memory
124/// form: a `#rrggbb` color at `offset` ∈ `[0, 1]`.
125#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
126pub struct GradientStopSpec {
127    pub color: String,
128    pub offset: f64,
129}
130
131/// The [`ControlSpec::GradientStops`] canonical stop order: offsets
132/// clamped to `[0, 1]` and rounded to 3 decimals, stops sorted stably
133/// by offset.
134pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
135    for stop in &mut stops {
136        stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
137    }
138
139    stops.sort_by(|a, b| {
140        a.offset
141            .partial_cmp(&b.offset)
142            .unwrap_or(std::cmp::Ordering::Equal)
143    });
144
145    stops
146}
147
148/// Fit `stops` to a `discrete` field's fixed pair: an over-length value
149/// keeps only its two end colors, pinned to `0`/`1`.
150pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
151    let stops = canonicalize_gradient_stops(stops);
152    match (stops.first(), stops.last()) {
153        (Some(first), Some(last)) if stops.len() > 2 => vec![
154            GradientStopSpec {
155                color: first.color.clone(),
156                offset: 0.0,
157            },
158            GradientStopSpec {
159                color: last.color.clone(),
160                offset: 1.0,
161            },
162        ],
163        _ => stops,
164    }
165}
166
167impl ColumnConfigSchema {
168    /// Canonicalize every CSS-valued default at schema ingest, dropping
169    /// (and logging) fields whose default fails its kind's reader.
170    pub fn canonicalize_defaults(mut self) -> Self {
171        self.fields.retain_mut(|spec| {
172            let (kind, key, default) = match spec {
173                ControlSpec::Color { key, default } => (CssKind::Color, key, default),
174                ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
175                ControlSpec::GradientStops { key, default, .. } => {
176                    (CssKind::Gradient, key, default)
177                },
178                _ => return true,
179            };
180
181            match kind.canonicalize(default) {
182                Ok(canonical) => {
183                    *default = canonical;
184                    true
185                },
186                Err(error) => {
187                    tracing::error!("Dropping `{key}` — invalid schema default: {error}");
188                    false
189                },
190            }
191        });
192
193        self
194    }
195
196    /// The CSS kind of the control owning `key`, if it is CSS-valued.
197    pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
198        self.fields.iter().find_map(|spec| match spec {
199            ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
200            ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
201            ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
202            _ => None,
203        })
204    }
205}
206
207impl ControlSpec {
208    /// Top-level JSON keys this control owns when its value is serialized
209    /// into a column's config map. For primitives this is just `[key]`;
210    /// for composites it's the set of fields the wrapped sub-struct
211    /// flattens. Used by [`ColumnConfigSchema::active_keys`] to filter the
212    /// `columns_config` blob passed to `plugin.restore()`.
213    pub fn serialized_keys(&self) -> Vec<&str> {
214        match self {
215            ControlSpec::DatetimeFormat => vec!["date_format"],
216            ControlSpec::StringFormat => vec!["format"],
217            ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
218            ControlSpec::Symbols { .. } => vec!["symbols"],
219            ControlSpec::NumberFormat => vec!["number_format"],
220            ControlSpec::AggregateDepth => vec!["aggregate_depth"],
221            ControlSpec::Enum { key, .. }
222            | ControlSpec::Bool { key, .. }
223            | ControlSpec::Number { key, .. }
224            | ControlSpec::String { key, .. }
225            | ControlSpec::Color { key, .. }
226            | ControlSpec::Palette { key, .. }
227            | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
228        }
229    }
230}
231
232/// One UI-emitted change to a single schema field. The emitting widget
233/// declares which top-level keys the update is allowed to write
234/// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
235/// and a partial new sub-state (`value`).
236#[derive(Clone, Debug, Deserialize, Serialize)]
237pub struct ColumnConfigFieldUpdate {
238    pub keys: Vec<String>,
239    pub value: serde_json::Map<String, Value>,
240}
241
242/// Filter a per-column config map to only the keys advertised by the
243/// active plugin's schema. Foreign keys (left over from a previous plugin)
244/// stay in the unfiltered presentation state but never reach `restore()`.
245pub fn filter_to_schema(
246    config: &serde_json::Map<String, Value>,
247    active_keys: &HashSet<String>,
248) -> serde_json::Map<String, Value> {
249    config
250        .iter()
251        .filter(|(k, _)| active_keys.contains(k.as_str()))
252        .map(|(k, v)| (k.clone(), v.clone()))
253        .collect()
254}