Skip to main content

perspective_viewer/config/
plugin_static_config.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 perspective_client::config::{GroupRollupMode, SplitRollupMode};
14use serde::Deserialize;
15use ts_rs::TS;
16
17#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, TS)]
18#[serde(rename_all = "camelCase")]
19pub enum ColumnSelectMode {
20    #[default]
21    Toggle,
22    Select,
23}
24
25impl ColumnSelectMode {
26    pub fn css(&self) -> yew::Classes {
27        match self {
28            Self::Toggle => yew::classes!("toggle-mode", "is_column_active"),
29            Self::Select => yew::classes!("select-mode", "is_column_active"),
30        }
31    }
32}
33
34/// Static, immutable configuration for a plugin.
35///
36/// Returned once per plugin from `get_static_config()` at registration
37/// time and cached in [`crate::renderer::PluginRecord`]. Consumers
38/// (renderer, session, queries, components) read these fields off the
39/// renderer's active-plugin metadata rather than calling back into JS.
40///
41/// `<perspective-viewer>` reads this exactly once per plugin (at
42/// `registerPlugin` time) and caches it for the lifetime of the
43/// application. The result must be stable; do not mutate any field
44/// after registration.
45#[derive(Clone, Debug, Default, Deserialize, PartialEq, TS)]
46pub struct PluginStaticConfig {
47    /// The unique key for this plugin. Used as the `plugin` field in a
48    /// `ViewerConfig` and as the display name key in the
49    /// `<perspective-viewer>` UI.
50    pub name: String,
51
52    /// Category in the plugin picker menu.
53    #[serde(default)]
54    #[ts(as = "Option<_>")]
55    #[ts(optional)]
56    pub category: Option<String>,
57
58    /// Soft limit on the number of columns the plugin will render.
59    /// Triggers the "Rendering N of M" warning when the view exceeds
60    /// this value (until dismissed).
61    #[serde(default)]
62    #[ts(as = "Option<_>")]
63    #[ts(optional)]
64    pub max_columns: Option<usize>,
65
66    /// Soft limit on the number of cells (rows × columns) the plugin
67    /// will render. Triggers the "Rendering N of M" warning when the view
68    /// exceeds this value (until dismissed).
69    #[serde(default)]
70    #[ts(as = "Option<_>")]
71    #[ts(optional)]
72    pub max_cells: Option<usize>,
73
74    /// Column add/remove behavior. `"select"` exclusively selects the
75    /// added column, removing other columns. `"toggle"` toggles the
76    /// column on or off based on its current state, leaving other
77    /// columns alone.
78    #[serde(default)]
79    #[ts(as = "Option<_>")]
80    #[ts(optional)]
81    pub select_mode: ColumnSelectMode,
82
83    /// Minimum number of columns the plugin requires to render. Mostly
84    /// affects drag/drop and column-remove button behavior. `undefined`
85    /// is treated identically to `1`.
86    #[serde(default)]
87    #[ts(as = "Option<_>")]
88    #[ts(optional)]
89    pub min_config_columns: Option<usize>,
90
91    /// Named column slots. Named columns have replace/swap behavior in
92    /// drag/drop rather than insert. The length must be at least
93    /// `min_config_columns`.
94    #[serde(default)]
95    #[ts(as = "Option<_>")]
96    #[ts(optional)]
97    pub config_column_names: Vec<String>,
98
99    /// Group-rollup modes the plugin accepts, in preference order.
100    /// The first entry that matches a feature flag becomes the default.
101    #[serde(default)]
102    #[ts(as = "Option<_>")]
103    #[ts(optional)]
104    pub group_rollup_modes: Option<Vec<GroupRollupMode>>,
105
106    /// Split-rollup modes the plugin accepts, in preference order.
107    /// The first entry that matches a feature flag becomes the default.
108    #[serde(default)]
109    #[ts(as = "Option<_>")]
110    #[ts(optional)]
111    pub split_rollup_modes: Option<Vec<SplitRollupMode>>,
112
113    /// Plugin load priority. Higher numbers win; ties resolve in
114    /// registration order. The highest-priority plugin is loaded by
115    /// default unless `restore({ plugin })` overrides it.
116    #[serde(default)]
117    #[ts(as = "Option<_>")]
118    #[ts(optional)]
119    pub priority: Option<i32>,
120
121    /// Whether this plugin opts into per-column style controls in the
122    /// settings sidebar. When `true`, the StyleTab is shown for active
123    /// columns and the plugin's `column_config_schema` is queried for
124    /// the per-column field set. When `false` or omitted, no StyleTab
125    /// is shown.
126    #[serde(default)]
127    #[ts(as = "Option<_>")]
128    #[ts(optional)]
129    pub can_render_column_styles: bool,
130
131    /// What `group_by` MEANS visually for this plugin, e.g. `"X Axis"`
132    /// for the Y-series charts or `"Hierarchy"` for treemap/sunburst.
133    /// `None` where the field has no visual role of its own and is a
134    /// plain aggregation key (the X/Y charts, whose axes both come from
135    /// `columns`).
136    ///
137    /// This is the `group_by` counterpart of [`Self::config_column_names`]:
138    /// the same declaration that names the positional `columns` slots
139    /// should say what the other view fields draw, so that consumers —
140    /// the settings UI's field labels, and the agent's `list_plugins`
141    /// contract — read one source instead of restating the mapping.
142    #[serde(default)]
143    #[ts(as = "Option<_>")]
144    #[ts(optional)]
145    pub group_by_role: Option<String>,
146
147    /// What `split_by` MEANS visually for this plugin, e.g. `"Series"`.
148    /// See [`Self::group_by_role`].
149    #[serde(default)]
150    #[ts(as = "Option<_>")]
151    #[ts(optional)]
152    pub split_by_role: Option<String>,
153
154    /// `true` when this plugin CONNECTS its points in row order, so the
155    /// `View`'s row order is visible in the drawing: an unsorted config
156    /// renders the table's natural order, which reads as a tangle unless
157    /// the rows already arrive ordered along the axis. Declared rather
158    /// than inferred, because "unsorted" is not itself a mistake — a
159    /// pre-ordered table needs no `sort`, and the point plugins
160    /// (scatter, density) do not care at all.
161    #[serde(default)]
162    #[ts(as = "Option<_>")]
163    #[ts(optional)]
164    pub connects_row_order: bool,
165}
166
167impl PluginStaticConfig {
168    /// The number of leading `columns` slots that are POSITIONAL: a drop
169    /// there swaps with the column already present, and the slot's
170    /// meaning is fixed by [`Self::config_column_names`]. Everything from
171    /// this index on is the insert TAIL, which repeats the last named
172    /// role — which is why a `Y Line` (named slots: `["Y Axis"]`) takes
173    /// any number of columns as additional Y series, while an
174    /// `X/Y Line` (`["X Axis", "Y Axis", "Tooltip"]`) pins its two axes
175    /// and treats the rest as further tooltips.
176    ///
177    /// The single definition of that rule: [`Self::is_swap`] and the
178    /// agent's `list_plugins` contract both derive from it, so the
179    /// convention cannot be read two different ways.
180    pub fn positional_columns(&self) -> usize {
181        self.config_column_names.len().saturating_sub(1)
182    }
183
184    /// The role that `columns` past the positional slots repeat, i.e.
185    /// the last named slot. `None` when the plugin names no slots.
186    pub fn tail_column_role(&self) -> Option<&str> {
187        self.config_column_names.last().map(|x| x.as_str())
188    }
189
190    /// `true` if dropping a column at `index` should swap with the
191    /// column already there rather than insert.
192    pub fn is_swap(&self, index: usize) -> bool {
193        !self.config_column_names.is_empty() && index < self.positional_columns()
194    }
195
196    pub fn get_group_rollups(&self, rollup_features: &[GroupRollupMode]) -> Vec<GroupRollupMode> {
197        self.group_rollup_modes
198            .clone()
199            .map(|x| {
200                x.into_iter()
201                    .filter(|y| rollup_features.is_empty() || rollup_features.contains(y))
202                    .collect()
203            })
204            .unwrap_or_default()
205    }
206
207    pub fn get_split_rollups(&self, rollup_features: &[SplitRollupMode]) -> Vec<SplitRollupMode> {
208        self.split_rollup_modes
209            .clone()
210            .map(|x| {
211                x.into_iter()
212                    .filter(|y| rollup_features.is_empty() || rollup_features.contains(y))
213                    .collect()
214            })
215            .unwrap_or_default()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn plugin(names: &[&str]) -> PluginStaticConfig {
224        PluginStaticConfig {
225            config_column_names: names.iter().map(|x| (*x).to_owned()).collect(),
226            ..Default::default()
227        }
228    }
229
230    /// The tail rule has ONE definition: `is_swap` and the role a
231    /// trailing column repeats must never disagree about where the
232    /// positional slots end.
233    #[test]
234    fn positional_slots_and_tail_agree() {
235        // `Y Line` — the only named slot IS the tail, so every column
236        // is another Y series and none of them swap.
237        let y_line = plugin(&["Y Axis"]);
238        assert_eq!(y_line.positional_columns(), 0);
239        assert_eq!(y_line.tail_column_role(), Some("Y Axis"));
240        assert!(!y_line.is_swap(0));
241
242        // `X/Y Line` — two pinned axes, then tooltips.
243        let xy_line = plugin(&["X Axis", "Y Axis", "Tooltip"]);
244        assert_eq!(xy_line.positional_columns(), 2);
245        assert_eq!(xy_line.tail_column_role(), Some("Tooltip"));
246        assert!(xy_line.is_swap(0));
247        assert!(xy_line.is_swap(1));
248        assert!(!xy_line.is_swap(2));
249
250        for index in 0..4 {
251            assert_eq!(xy_line.is_swap(index), index < xy_line.positional_columns());
252        }
253
254        let unnamed = plugin(&[]);
255        assert_eq!(unnamed.positional_columns(), 0);
256        assert_eq!(unnamed.tail_column_role(), None);
257        assert!(!unnamed.is_swap(0));
258    }
259}