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}