@perspective-dev/viewer 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/cdn/perspective-viewer.js +1 -2
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -0
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/bootstrap.d.ts +2 -1
  24. package/dist/esm/column-format.d.ts +51 -0
  25. package/dist/esm/extensions.d.ts +6 -0
  26. package/dist/esm/perspective-viewer.d.ts +4 -1
  27. package/dist/esm/perspective-viewer.inline.js +1 -2
  28. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  29. package/dist/esm/perspective-viewer.js +1 -2
  30. package/dist/esm/perspective-viewer.js.map +4 -4
  31. package/dist/esm/perspective-viewer.worker.d.ts +2 -0
  32. package/dist/esm/plugin.d.ts +21 -77
  33. package/dist/esm/ts-rs/ColumnSelectMode.d.ts +1 -0
  34. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +77 -0
  35. package/dist/esm/ts-rs/ViewerConfig.d.ts +39 -0
  36. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -4
  37. package/dist/wasm/perspective-viewer.d.ts +88 -24
  38. package/dist/wasm/perspective-viewer.js +320 -151
  39. package/dist/wasm/perspective-viewer.wasm +0 -0
  40. package/dist/wasm/perspective-viewer.wasm.d.ts +22 -17
  41. package/package.json +24 -2
  42. package/src/css/column-selector.css +3 -2
  43. package/src/css/column-settings-panel.css +36 -6
  44. package/src/css/column-style.css +27 -2
  45. package/src/css/containers/scroll-panel.css +2 -1
  46. package/src/css/containers/tabs.css +8 -52
  47. package/src/css/dom/checkbox.css +0 -4
  48. package/src/css/form/code-editor.css +1 -0
  49. package/src/css/form/debug.css +3 -10
  50. package/src/css/plugin-selector.css +33 -0
  51. package/src/css/plugin-settings-panel.css +99 -0
  52. package/src/css/status-bar.css +1 -1
  53. package/src/css/viewer.css +65 -3
  54. package/src/rust/components/column_dropdown.rs +3 -1
  55. package/src/rust/components/column_selector/active_column.rs +13 -19
  56. package/src/rust/components/column_selector/config_selector.rs +20 -20
  57. package/src/rust/components/column_selector/filter_column.rs +14 -14
  58. package/src/rust/components/column_selector/inactive_column.rs +9 -15
  59. package/src/rust/components/column_selector/pivot_column.rs +7 -7
  60. package/src/rust/components/column_selector/sort_column.rs +7 -7
  61. package/src/rust/components/column_selector.rs +55 -37
  62. package/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs +15 -7
  63. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +394 -0
  64. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +15 -6
  65. package/src/rust/components/column_settings_sidebar/style_tab.rs +267 -136
  66. package/src/rust/components/column_settings_sidebar.rs +43 -49
  67. package/src/rust/components/containers/dragdrop_list.rs +5 -5
  68. package/src/rust/components/containers/mod.rs +0 -1
  69. package/src/rust/components/containers/scroll_panel.rs +21 -7
  70. package/src/rust/components/containers/sidebar.rs +8 -6
  71. package/src/rust/components/containers/split_panel.rs +3 -3
  72. package/src/rust/components/containers/tab_list.rs +3 -9
  73. package/src/rust/components/copy_dropdown.rs +2 -3
  74. package/src/rust/components/datetime_column_style.rs +19 -81
  75. package/src/rust/components/editable_header.rs +2 -3
  76. package/src/rust/components/export_dropdown.rs +2 -3
  77. package/src/rust/components/expression_editor.rs +29 -17
  78. package/src/rust/components/filter_dropdown.rs +2 -1
  79. package/src/rust/components/form/color_range_selector.rs +14 -7
  80. package/src/rust/components/form/debug.rs +47 -37
  81. package/src/rust/components/main_panel.rs +24 -65
  82. package/src/rust/components/mod.rs +2 -1
  83. package/src/rust/components/number_series_style.rs +161 -0
  84. package/src/rust/components/plugin_tab.rs +221 -0
  85. package/src/rust/components/settings_panel.rs +181 -59
  86. package/src/rust/components/status_bar.rs +140 -173
  87. package/src/rust/components/status_indicator.rs +15 -22
  88. package/src/rust/components/string_column_style.rs +20 -82
  89. package/src/rust/components/style_controls/number_string_format.rs +14 -30
  90. package/src/rust/components/viewer.rs +92 -132
  91. package/src/rust/config/column_config_schema.rs +195 -0
  92. package/src/rust/config/columns_config.rs +4 -97
  93. package/src/rust/config/datetime_column_style.rs +0 -5
  94. package/src/rust/config/mod.rs +8 -2
  95. package/src/rust/config/number_series_style.rs +79 -0
  96. package/src/rust/config/plugin_static_config.rs +144 -0
  97. package/src/rust/config/string_column_style.rs +0 -5
  98. package/src/rust/config/viewer_config.rs +7 -8
  99. package/src/rust/custom_elements/copy_dropdown.rs +30 -18
  100. package/src/rust/custom_elements/debug_plugin.rs +5 -7
  101. package/src/rust/custom_elements/export_dropdown.rs +26 -18
  102. package/src/rust/custom_elements/viewer.rs +77 -77
  103. package/src/rust/custom_events.rs +181 -224
  104. package/src/rust/js/plugin.rs +45 -117
  105. package/src/rust/lib.rs +39 -5
  106. package/src/rust/presentation/drag_helpers.rs +206 -0
  107. package/src/rust/presentation/props.rs +8 -0
  108. package/src/rust/presentation.rs +256 -41
  109. package/src/rust/{tasks → queries}/column_locator.rs +17 -73
  110. package/src/rust/queries/column_values.rs +59 -0
  111. package/src/rust/{tasks → queries}/columns_iter_set.rs +11 -18
  112. package/src/rust/queries/exports.rs +96 -0
  113. package/src/rust/queries/fetch_column_stats.rs +94 -0
  114. package/src/rust/queries/get_viewer_config.rs +54 -0
  115. package/src/rust/queries/mod.rs +44 -0
  116. package/src/rust/queries/plugin_column_styles.rs +101 -0
  117. package/src/rust/{engines.rs → queries/validate_expression.rs} +26 -15
  118. package/src/rust/renderer/activate.rs +1 -0
  119. package/src/rust/renderer/limits.rs +9 -4
  120. package/src/rust/renderer/plugin_store.rs +12 -0
  121. package/src/rust/renderer/props.rs +28 -3
  122. package/src/rust/renderer/registry.rs +40 -15
  123. package/src/rust/renderer.rs +649 -55
  124. package/src/rust/session/column_defaults_update.rs +20 -28
  125. package/src/rust/session/drag_drop_update.rs +10 -10
  126. package/src/rust/session/metadata.rs +31 -16
  127. package/src/rust/session/props.rs +15 -6
  128. package/src/rust/session/view_subscription.rs +10 -0
  129. package/src/rust/session.rs +109 -147
  130. package/src/rust/tasks/copy_export.rs +178 -158
  131. package/src/rust/tasks/{structural.rs → dismiss_render_warning.rs} +20 -40
  132. package/src/rust/tasks/edit_expression.rs +68 -88
  133. package/src/rust/tasks/eject.rs +25 -22
  134. package/src/rust/tasks/intersection_observer.rs +8 -21
  135. package/src/rust/tasks/mod.rs +19 -21
  136. package/src/rust/tasks/reset_all.rs +78 -0
  137. package/src/rust/tasks/resize_observer.rs +11 -33
  138. package/src/rust/tasks/restore_and_render.rs +117 -89
  139. package/src/rust/tasks/{get_viewer_config.rs → send_column_config.rs} +38 -35
  140. package/src/rust/tasks/send_plugin_config.rs +32 -33
  141. package/src/rust/tasks/update_and_render.rs +66 -47
  142. package/src/rust/{components/containers/trap_door_panel.rs → tasks/update_theme.rs} +34 -33
  143. package/src/rust/tasks/validate_expression.rs +61 -0
  144. package/src/rust/utils/browser/selection.rs +4 -4
  145. package/src/rust/utils/mod.rs +0 -63
  146. package/src/svg/datagrid-select-row-tree.svg +13 -0
  147. package/src/svg/mega-menu-icons-density.svg +23 -0
  148. package/src/svg/mega-menu-icons-map-density.svg +24 -0
  149. package/src/svg/mega-menu-icons-map-line.svg +19 -0
  150. package/src/themes/botanical.css +27 -53
  151. package/src/themes/defaults.css +42 -36
  152. package/src/themes/dracula.css +36 -54
  153. package/src/themes/gruvbox-dark.css +39 -59
  154. package/src/themes/gruvbox.css +16 -28
  155. package/src/themes/icons.css +4 -18
  156. package/src/themes/intl/de.css +42 -6
  157. package/src/themes/intl/es.css +42 -6
  158. package/src/themes/intl/fr.css +42 -6
  159. package/src/themes/intl/ja.css +42 -6
  160. package/src/themes/intl/pt.css +42 -6
  161. package/src/themes/intl/zh.css +42 -6
  162. package/src/themes/intl.css +37 -4
  163. package/src/themes/monokai.css +45 -61
  164. package/src/themes/phosphor.css +175 -0
  165. package/src/themes/pro-dark.css +25 -34
  166. package/src/themes/solarized-dark.css +21 -36
  167. package/src/themes/solarized.css +13 -23
  168. package/src/themes/themes.css +1 -0
  169. package/src/themes/vaporwave.css +40 -74
  170. package/src/ts/bootstrap.ts +14 -3
  171. package/src/ts/column-format.ts +162 -0
  172. package/src/ts/extensions.ts +12 -1
  173. package/src/ts/perspective-viewer.ts +10 -1
  174. package/src/{rust/components/column_settings_sidebar/style_tab/stub.rs → ts/perspective-viewer.worker.ts} +2 -22
  175. package/src/ts/plugin.ts +29 -105
  176. package/src/ts/ts-rs/{FormatUnit.ts → ColumnSelectMode.ts} +1 -1
  177. package/src/ts/ts-rs/PluginStaticConfig.ts +78 -0
  178. package/src/ts/ts-rs/ViewerConfig.ts +14 -0
  179. package/src/ts/ts-rs/ViewerConfigUpdate.ts +2 -3
  180. package/dist/esm/ts-rs/ColumnConfigValues.d.ts +0 -31
  181. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +0 -1
  182. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +0 -15
  183. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +0 -18
  184. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  185. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +0 -6
  186. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  187. package/dist/esm/ts-rs/FormatUnit.d.ts +0 -1
  188. package/dist/esm/ts-rs/NumberBackgroundMode.d.ts +0 -1
  189. package/dist/esm/ts-rs/NumberForegroundMode.d.ts +0 -1
  190. package/dist/esm/ts-rs/PluginConfig.d.ts +0 -2
  191. package/dist/esm/ts-rs/RoundingMode.d.ts +0 -1
  192. package/dist/esm/ts-rs/RoundingPriority.d.ts +0 -1
  193. package/dist/esm/ts-rs/SignDisplay.d.ts +0 -1
  194. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +0 -1
  195. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +0 -6
  196. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  197. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +0 -1
  198. package/dist/esm/ts-rs/UseGrouping.d.ts +0 -1
  199. package/src/rust/components/number_column_style.rs +0 -483
  200. package/src/rust/config/number_column_style.rs +0 -132
  201. package/src/rust/dragdrop.rs +0 -481
  202. package/src/rust/tasks/plugin_column_styles.rs +0 -98
  203. package/src/ts/ts-rs/ColumnConfigValues.ts +0 -14
  204. package/src/ts/ts-rs/CustomDatetimeFormat.ts +0 -3
  205. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +0 -5
  206. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +0 -8
  207. package/src/ts/ts-rs/DatetimeColorMode.ts +0 -3
  208. package/src/ts/ts-rs/DatetimeFormatType.ts +0 -8
  209. package/src/ts/ts-rs/FormatMode.ts +0 -3
  210. package/src/ts/ts-rs/NumberBackgroundMode.ts +0 -3
  211. package/src/ts/ts-rs/NumberForegroundMode.ts +0 -3
  212. package/src/ts/ts-rs/PluginConfig.ts +0 -4
  213. package/src/ts/ts-rs/RoundingMode.ts +0 -3
  214. package/src/ts/ts-rs/RoundingPriority.ts +0 -3
  215. package/src/ts/ts-rs/SignDisplay.ts +0 -3
  216. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +0 -3
  217. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +0 -4
  218. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  219. package/src/ts/ts-rs/TrailingZeroDisplay.ts +0 -3
  220. package/src/ts/ts-rs/UseGrouping.ts +0 -3
  221. /package/dist/wasm/snippets/{perspective-viewer-68fef752754ffbc6 → perspective-viewer-39ab7da3ca157861}/inline0.js +0 -0
  222. /package/dist/wasm/snippets/{perspective-viewer-68fef752754ffbc6 → perspective-viewer-39ab7da3ca157861}/inline1.js +0 -0
  223. /package/dist/wasm/snippets/{perspective-viewer-68fef752754ffbc6 → perspective-viewer-39ab7da3ca157861}/inline2.js +0 -0
  224. /package/dist/wasm/snippets/{perspective-viewer-68fef752754ffbc6 → perspective-viewer-39ab7da3ca157861}/inline3.js +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-68fef752754ffbc6 → perspective-viewer-39ab7da3ca157861}/inline4.js +0 -0
  226. /package/src/rust/{tasks → config}/export_method.rs +0 -0
  227. /package/src/rust/{tasks → queries}/export_app.rs +0 -0
  228. /package/src/rust/{tasks → queries}/is_invalid_drop.rs +0 -0
@@ -24,18 +24,20 @@ mod props;
24
24
  mod registry;
25
25
  mod render_timer;
26
26
 
27
- use std::cell::{Ref, RefCell};
27
+ use std::cell::{Cell, RefCell};
28
28
  use std::collections::HashMap;
29
29
  use std::future::Future;
30
30
  use std::ops::Deref;
31
31
  use std::pin::Pin;
32
32
  use std::rc::Rc;
33
33
 
34
- use futures::future::{join_all, select_all};
34
+ use futures::future::select_all;
35
+ use perspective_client::config::ViewConfig;
35
36
  use perspective_client::utils::*;
36
37
  use perspective_client::{View, ViewWindow};
37
38
  use perspective_js::json;
38
- use perspective_js::utils::ApiResult;
39
+ use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
40
+ use serde_json::Value;
39
41
  use wasm_bindgen::prelude::*;
40
42
  use web_sys::*;
41
43
  use yew::html::ImplicitClone;
@@ -50,9 +52,25 @@ pub use self::registry::*;
50
52
  use self::render_timer::*;
51
53
  use crate::config::*;
52
54
  use crate::js::plugin::*;
53
- use crate::presentation::ColumnConfigMap;
55
+ use crate::session::Session;
54
56
  use crate::utils::*;
55
57
 
58
+ /// A per-column config map. Each inner [`serde_json::Map`] is a flat collection
59
+ /// of plugin-defined JSON keys whose shape is dictated by the active plugin's
60
+ /// [`crate::config::ColumnConfigSchema`].
61
+ pub type ColumnConfigMap = HashMap<String, serde_json::Map<String, serde_json::Value>>;
62
+
63
+ /// Per-plugin config bucket. Holds the per-column style map and the
64
+ /// plugin-level config map for one plugin. Buckets are keyed by plugin
65
+ /// name in [`RendererMutData::plugin_states`], so foreign keys from a
66
+ /// different plugin physically cannot appear in the active plugin's
67
+ /// bucket.
68
+ #[derive(Clone, Debug, Default)]
69
+ pub struct PluginScopedConfig {
70
+ pub columns: ColumnConfigMap,
71
+ pub plugin: serde_json::Map<String, serde_json::Value>,
72
+ }
73
+
56
74
  /// Immutable state
57
75
  pub struct RendererData {
58
76
  plugin_data: RefCell<RendererMutData>,
@@ -60,22 +78,36 @@ pub struct RendererData {
60
78
  pub plugin_changed: PubSub<JsPerspectiveViewerPlugin>,
61
79
  pub style_changed: PubSub<()>,
62
80
  pub reset_changed: PubSub<()>,
81
+ pub selection_changed: PubSub<Option<ViewWindow>>,
82
+
83
+ /// Fires after a column-style edit lands in the active plugin's
84
+ /// bucket.
85
+ pub column_style_changed: PubSub<ColumnConfigMap>,
86
+
87
+ /// Fires after a plugin-level config edit lands in the active
88
+ /// plugin's bucket.
89
+ pub plugin_config_changed: PubSub<serde_json::Map<String, serde_json::Value>>,
63
90
 
64
- /// Injected callback from the root component, replacing the former
65
- /// `render_limits_changed: PubSub` field. Fires after every draw/update
66
- /// with the computed render limits.
91
+ /// `true` while the active plugin's "rendering N of M" warning is
92
+ /// dismissable.
93
+ pub render_warning: Cell<bool>,
94
+
95
+ /// Fires after every draw/update with the computed render limits.
67
96
  pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
68
97
  }
69
98
 
70
99
  /// Mutable state
71
100
  pub struct RendererMutData {
72
101
  viewer_elem: HtmlElement,
73
- metadata: ViewConfigRequirements,
102
+ metadata: Rc<PluginStaticConfig>,
74
103
  plugin_store: PluginStore,
75
104
  plugins_idx: Option<usize>,
76
105
  timer: MovingWindowRenderTimer,
77
106
  selection: Option<ViewWindow>,
78
107
  pending_plugin: Option<usize>,
108
+
109
+ /// Per-plugin config buckets, keyed by plugin name.
110
+ plugin_states: HashMap<String, PluginScopedConfig>,
79
111
  }
80
112
 
81
113
  /// The state object responsible for the active [`JsPerspectiveViewerPlugin`].
@@ -117,17 +149,22 @@ impl Renderer {
117
149
  Self(Rc::new(RendererData {
118
150
  plugin_data: RefCell::new(RendererMutData {
119
151
  viewer_elem: viewer_elem.clone(),
120
- metadata: ViewConfigRequirements::default(),
152
+ metadata: Rc::new(PluginStaticConfig::default()),
121
153
  plugin_store: PluginStore::default(),
122
154
  plugins_idx: None,
123
155
  selection: None,
124
156
  timer: MovingWindowRenderTimer::default(),
125
157
  pending_plugin: None,
158
+ plugin_states: HashMap::default(),
126
159
  }),
127
160
  draw_lock: Default::default(),
128
161
  plugin_changed: Default::default(),
129
162
  style_changed: Default::default(),
130
163
  reset_changed: Default::default(),
164
+ selection_changed: Default::default(),
165
+ column_style_changed: Default::default(),
166
+ plugin_config_changed: Default::default(),
167
+ render_warning: Cell::new(true),
131
168
  on_render_limits_changed: Default::default(),
132
169
  }))
133
170
  }
@@ -153,13 +190,442 @@ impl Renderer {
153
190
  Ok(())
154
191
  }
155
192
 
156
- pub fn metadata(&self) -> Ref<'_, ViewConfigRequirements> {
157
- Ref::map(self.borrow(), |x| &x.metadata)
193
+ pub fn metadata(&self) -> Rc<PluginStaticConfig> {
194
+ self.borrow().metadata.clone()
158
195
  }
159
196
 
160
197
  pub fn is_chart(&self) -> bool {
161
- let plugin = self.get_active_plugin().unwrap();
162
- plugin.name().as_str() != "Datagrid"
198
+ self.metadata().name.as_str() != "Datagrid"
199
+ }
200
+
201
+ /// Whether the active plugin opts into per-column style controls.
202
+ pub fn can_render_column_styles(&self) -> bool {
203
+ self.metadata().can_render_column_styles
204
+ }
205
+
206
+ /// Name of the currently-active plugin (used as the key into
207
+ /// `plugin_states`). Returns `None` when no plugin has been
208
+ /// activated yet.
209
+ fn active_plugin_name(&self) -> Option<String> {
210
+ Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty())
211
+ }
212
+
213
+ // ─── Per-column config (active plugin's bucket) ───────────────────
214
+
215
+ /// Snapshot of the active plugin's per-column config map.
216
+ pub fn all_columns_configs(&self) -> ColumnConfigMap {
217
+ self.active_plugin_name()
218
+ .and_then(|n| {
219
+ self.borrow()
220
+ .plugin_states
221
+ .get(&n)
222
+ .map(|b| b.columns.clone())
223
+ })
224
+ .unwrap_or_default()
225
+ }
226
+
227
+ /// Restore-prep snapshot: like [`Self::all_columns_configs`], but
228
+ /// for each column also materializes any `ControlSpec::Number`
229
+ /// fields the schema declares with `include: true` that aren't
230
+ /// already in the bucket entry. The materialized value is the
231
+ /// schema's `default`, which the schema computes from cached
232
+ /// column stats (via [`Self::query_column_config_schema`]).
233
+ ///
234
+ /// The bucket itself stays minimal (user edits + `include: true`
235
+ /// values the user *explicitly set*); this helper produces the
236
+ /// fully-realized payload the plugin's `restore` is expected to
237
+ /// receive. Every restore-prep site should call this rather than
238
+ /// `all_columns_configs` directly — otherwise widgets that gate
239
+ /// `include` fields off other fields (e.g. Datagrid's
240
+ /// `fg_gradient` revealed when `number_fg_mode = "bar"`) will
241
+ /// reach the plugin without their default value populated.
242
+ pub fn all_columns_configs_materialized(
243
+ &self,
244
+ view_config: &ViewConfig,
245
+ session: &Session,
246
+ ) -> ColumnConfigMap {
247
+ let mut configs = self.all_columns_configs();
248
+ for (col, entry) in &mut configs {
249
+ let Ok(schema) =
250
+ self.query_column_config_schema(view_config, session, col, Some(entry))
251
+ else {
252
+ continue;
253
+ };
254
+
255
+ for field in &schema.fields {
256
+ let ControlSpec::Number {
257
+ key,
258
+ default,
259
+ include: Some(true),
260
+ ..
261
+ } = field
262
+ else {
263
+ continue;
264
+ };
265
+
266
+ if entry.contains_key(key) {
267
+ continue;
268
+ }
269
+
270
+ let Some(num) = serde_json::Number::from_f64(*default) else {
271
+ continue;
272
+ };
273
+
274
+ entry.insert(key.clone(), serde_json::Value::Number(num));
275
+ }
276
+ }
277
+
278
+ configs
279
+ }
280
+
281
+ /// Clear the active plugin's per-column config map.
282
+ pub fn reset_columns_configs(&self) {
283
+ if let Some(n) = self.active_plugin_name() {
284
+ self.borrow_mut()
285
+ .plugin_states
286
+ .entry(n)
287
+ .or_default()
288
+ .columns
289
+ .clear();
290
+ }
291
+ }
292
+
293
+ /// Clone of the active plugin's per-column entry for `column_name`,
294
+ /// or `None` if no value is stored.
295
+ pub fn get_columns_config(
296
+ &self,
297
+ column_name: &str,
298
+ ) -> Option<serde_json::Map<String, serde_json::Value>> {
299
+ let n = self.active_plugin_name()?;
300
+ self.borrow()
301
+ .plugin_states
302
+ .get(&n)?
303
+ .columns
304
+ .get(column_name)
305
+ .cloned()
306
+ }
307
+
308
+ /// Wholesale update the active plugin's per-column config map
309
+ /// (e.g. from a `restore()` call). Each incoming column entry is
310
+ /// schema-stripped before insertion — values matching the
311
+ /// schema-declared default are dropped so the bucket converges to
312
+ /// the "empty ⇒ reads-default" invariant, mirroring
313
+ /// [`Self::update_plugin_config`]. `ControlSpec::Number` fields
314
+ /// declared with `include: true` survive the strip (their default
315
+ /// is data-dependent, so a literal default value is preserved as
316
+ /// the user's explicit choice). Column entries that become empty
317
+ /// after stripping are removed from the bucket entirely.
318
+ pub fn update_columns_configs(
319
+ &self,
320
+ view_config: &ViewConfig,
321
+ session: &Session,
322
+ update: ColumnConfigUpdate,
323
+ ) -> bool {
324
+ let Some(n) = self.active_plugin_name() else {
325
+ return false;
326
+ };
327
+
328
+ match update {
329
+ OptionalUpdate::SetDefault => {
330
+ let mut st = self.borrow_mut();
331
+ let bucket = st.plugin_states.entry(n).or_default();
332
+ let was_nonempty = !bucket.columns.is_empty();
333
+ bucket.columns.clear();
334
+ was_nonempty
335
+ },
336
+ OptionalUpdate::Missing => false,
337
+ OptionalUpdate::Update(map) => {
338
+ // Strip per-column before borrowing the bucket mutably:
339
+ // the schema query takes an immutable borrow via
340
+ // `self.metadata()`, which would alias-conflict with
341
+ // `borrow_mut` below.
342
+ let stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> = map
343
+ .into_iter()
344
+ .map(|(col, mut cfg)| {
345
+ if let Ok(schema) =
346
+ self.query_column_config_schema(view_config, session, &col, Some(&cfg))
347
+ {
348
+ strip_default_values(&schema, &mut cfg);
349
+ }
350
+
351
+ (col, cfg)
352
+ })
353
+ .collect();
354
+
355
+ let mut st = self.borrow_mut();
356
+ let bucket = st.plugin_states.entry(n).or_default();
357
+ let mut changed = false;
358
+ for (col, cfg) in stripped {
359
+ if cfg.is_empty() {
360
+ if bucket.columns.remove(&col).is_some() {
361
+ changed = true;
362
+ }
363
+ } else {
364
+ match bucket.columns.insert(col, cfg.clone()) {
365
+ None => changed = true,
366
+ Some(old) if old != cfg => changed = true,
367
+ _ => {},
368
+ }
369
+ }
370
+ }
371
+
372
+ changed
373
+ },
374
+ }
375
+ }
376
+
377
+ /// Apply a single schema-field update from the column-style UI to
378
+ /// the active plugin's bucket. Clears the keys the field owns,
379
+ /// then splices in the partial new sub-state. Drops empty
380
+ /// entries.
381
+ ///
382
+ /// The schema-strip is defense-in-depth: widget callbacks (e.g.
383
+ /// `NumberFieldPrimitive`) already pre-strip default values, so
384
+ /// for live edits this strip pass is a no-op. It closes the hole
385
+ /// for programmatic callers that construct a
386
+ /// `ColumnConfigFieldUpdate` directly without going through the
387
+ /// widget (where `include: true` would otherwise be ignored).
388
+ pub fn update_columns_config_field(
389
+ &self,
390
+ view_config: &ViewConfig,
391
+ session: &Session,
392
+ column_name: String,
393
+ mut update: ColumnConfigFieldUpdate,
394
+ ) {
395
+ let Some(n) = self.active_plugin_name() else {
396
+ return;
397
+ };
398
+
399
+ // Take the schema query before the mutable borrow — same
400
+ // RefCell aliasing reason as in `update_columns_configs`.
401
+ let current_value = self.get_columns_config(&column_name);
402
+ if let Ok(schema) = self.query_column_config_schema(
403
+ view_config,
404
+ session,
405
+ &column_name,
406
+ current_value.as_ref(),
407
+ ) {
408
+ strip_default_values(&schema, &mut update.value);
409
+ }
410
+
411
+ let mut st = self.borrow_mut();
412
+ let bucket = st.plugin_states.entry(n).or_default();
413
+ let entry = bucket.columns.entry(column_name.clone()).or_default();
414
+ for k in &update.keys {
415
+ entry.remove(k);
416
+ }
417
+ for (k, v) in update.value {
418
+ if update.keys.contains(&k) {
419
+ entry.insert(k, v);
420
+ }
421
+ }
422
+ if entry.is_empty() {
423
+ bucket.columns.remove(&column_name);
424
+ }
425
+ }
426
+
427
+ // ─── Plugin-level config (active plugin's bucket) ─────────────────
428
+
429
+ /// Snapshot of the active plugin's plugin-level config map.
430
+ pub fn get_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
431
+ self.active_plugin_name()
432
+ .and_then(|n| {
433
+ self.borrow()
434
+ .plugin_states
435
+ .get(&n)
436
+ .map(|b| b.plugin.clone())
437
+ })
438
+ .unwrap_or_default()
439
+ }
440
+
441
+ /// Clear the active plugin's plugin-level config map.
442
+ pub fn reset_plugin_config(&self) {
443
+ if let Some(n) = self.active_plugin_name() {
444
+ self.borrow_mut()
445
+ .plugin_states
446
+ .entry(n)
447
+ .or_default()
448
+ .plugin
449
+ .clear();
450
+ }
451
+ }
452
+
453
+ /// Synchronously query the active plugin's
454
+ /// [`ColumnConfigSchema`] used to gate plugin-config strip logic.
455
+ /// Inlined here (rather than calling `queries::get_plugin_config_schema`)
456
+ /// to keep `renderer` from back-importing the `queries` module.
457
+ fn query_plugin_config_schema(
458
+ &self,
459
+ view_config: &ViewConfig,
460
+ ) -> ApiResult<ColumnConfigSchema> {
461
+ let plugin = self.get_active_plugin()?;
462
+ let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
463
+ let raw = plugin._plugin_config_schema(&view_config_js)?;
464
+ serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
465
+ }
466
+
467
+ /// Per-column counterpart of [`query_plugin_config_schema`]. Used by
468
+ /// the columns-config write paths (strip-on-write) and the
469
+ /// restore-prep snapshot (materialize-on-read).
470
+ ///
471
+ /// Reads the cached `ColumnStats` (populated by the StyleTab's
472
+ /// `fetch_column_abs_max` task and cleared on `view_config_changed`)
473
+ /// so plugins emit gradient defaults against the column's current
474
+ /// `abs_max` instead of falling back to `0`. Cache cold ⇒ stats
475
+ /// pass through as missing and the plugin uses its `?? 0` fallback;
476
+ /// `include: true` fields are still preserved via
477
+ /// [`matches_declared_default`]'s short-circuit.
478
+ fn query_column_config_schema(
479
+ &self,
480
+ view_config: &ViewConfig,
481
+ session: &Session,
482
+ column_name: &str,
483
+ current_value: Option<&serde_json::Map<String, serde_json::Value>>,
484
+ ) -> ApiResult<ColumnConfigSchema> {
485
+ let plugin = self.get_active_plugin()?;
486
+ let plugin_config = self.metadata();
487
+ let names = &plugin_config.config_column_names;
488
+ let group = view_config
489
+ .columns
490
+ .iter()
491
+ .position(|maybe_s| maybe_s.as_deref() == Some(column_name))
492
+ .and_then(|idx| names.get(idx))
493
+ .map(|s| s.as_str());
494
+
495
+ let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
496
+ return Ok(ColumnConfigSchema { fields: vec![] });
497
+ };
498
+
499
+ let current_js = JsValue::from_serde_ext(&current_value).unwrap_or(JsValue::NULL);
500
+ let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
501
+
502
+ // Pull the column's cached stats from the session. The StyleTab
503
+ // pre-warms this via `fetch_column_abs_max` whenever the user
504
+ // opens column settings; the cache is invalidated on every
505
+ // `view_config_changed`, so freshness is bounded.
506
+ let stats = session.get_column_stats(column_name).unwrap_or_default();
507
+ let stats_json = serde_json::json!({
508
+ "abs_max": stats.abs_max,
509
+ });
510
+ let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL);
511
+
512
+ let raw = plugin._column_config_schema(
513
+ &view_type.to_string(),
514
+ group,
515
+ column_name,
516
+ &current_js,
517
+ &view_config_js,
518
+ &stats_js,
519
+ )?;
520
+
521
+ serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
522
+ }
523
+
524
+ /// Wholesale update the active plugin's plugin-level config map.
525
+ /// Entries whose value equals the schema-declared default are
526
+ /// treated as "reset this key" — the corresponding bucket entry
527
+ /// is cleared rather than the default being stored literally.
528
+ /// Keys absent from the incoming map are left alone (merge
529
+ /// semantics for the non-default subset).
530
+ pub fn update_plugin_config(
531
+ &self,
532
+ view_config: &ViewConfig,
533
+ update: PluginConfigUpdate,
534
+ ) -> bool {
535
+ let Some(n) = self.active_plugin_name() else {
536
+ return false;
537
+ };
538
+
539
+ let schema = self.query_plugin_config_schema(view_config).ok();
540
+ let mut st = self.borrow_mut();
541
+ let bucket = st.plugin_states.entry(n).or_default();
542
+ match update {
543
+ OptionalUpdate::SetDefault => {
544
+ let changed = !bucket.plugin.is_empty();
545
+ bucket.plugin.clear();
546
+ changed
547
+ },
548
+ OptionalUpdate::Missing => false,
549
+ OptionalUpdate::Update(mut map) => {
550
+ let mut changed = false;
551
+ if let Some(s) = &schema {
552
+ // Default-valued entries in a restore payload
553
+ // semantically reset the key — strip from the
554
+ // map AND clear any existing override in the
555
+ // bucket so the wholesale-restore path matches
556
+ // the live-edit path (where the widget emits an
557
+ // empty value to clear).
558
+ map.retain(|key, value| {
559
+ let is_default = s
560
+ .fields
561
+ .iter()
562
+ .any(|spec| matches_declared_default(spec, key, value));
563
+ if is_default {
564
+ if bucket.plugin.remove(key).is_some() {
565
+ changed = true;
566
+ }
567
+ false
568
+ } else {
569
+ true
570
+ }
571
+ });
572
+ }
573
+
574
+ for (k, v) in map {
575
+ let prev = bucket.plugin.insert(k, v.clone());
576
+ if prev.as_ref() != Some(&v) {
577
+ changed = true;
578
+ }
579
+ }
580
+
581
+ changed
582
+ },
583
+ }
584
+ }
585
+
586
+ /// Apply a single schema-field update from the plugin-settings UI
587
+ /// to the active plugin's bucket. Clear-then-insert semantics
588
+ /// mirror [`Self::update_columns_config_field`]. Entries in
589
+ /// `update.value` whose value equals the schema default are
590
+ /// stripped before applying so default picks reset the key
591
+ /// rather than store the default literally.
592
+ pub fn update_plugin_config_field(
593
+ &self,
594
+ view_config: &ViewConfig,
595
+ mut update: ColumnConfigFieldUpdate,
596
+ ) -> bool {
597
+ let Some(n) = self.active_plugin_name() else {
598
+ return false;
599
+ };
600
+
601
+ if let Ok(schema) = self.query_plugin_config_schema(view_config) {
602
+ strip_default_values(&schema, &mut update.value);
603
+ }
604
+
605
+ let mut st = self.borrow_mut();
606
+ let bucket = st.plugin_states.entry(n).or_default();
607
+ let mut changed = false;
608
+
609
+ for k in &update.keys {
610
+ if let Some(v) = update.value.get(k) {
611
+ let prev = bucket.plugin.insert(k.to_string(), v.clone());
612
+ if prev.as_ref() != Some(v) {
613
+ changed = true;
614
+ }
615
+ } else if bucket.plugin.remove(k).is_some() {
616
+ changed = true;
617
+ }
618
+ }
619
+
620
+ changed
621
+ }
622
+
623
+ /// Whether the active plugin's render warning is currently armed
624
+ /// (i.e. an oversized view will be capped). Becomes `false` once
625
+ /// the user clicks "Render all points"; resets to `true` on the
626
+ /// next plugin change.
627
+ pub fn is_render_warning_enabled(&self) -> bool {
628
+ self.0.render_warning.get()
163
629
  }
164
630
 
165
631
  /// Return all plugin instances, whether they are active or not. Useful
@@ -173,6 +639,13 @@ impl Renderer {
173
639
  self.0.borrow_mut().plugin_store.plugin_records().clone()
174
640
  }
175
641
 
642
+ /// Cached `PluginStaticConfig`s for every registered plugin, in
643
+ /// registration (priority) order. Mirrors `get_all_plugins()`
644
+ /// element-for-element.
645
+ pub fn get_all_plugin_configs(&self) -> Vec<Rc<PluginStaticConfig>> {
646
+ self.0.borrow_mut().plugin_store.plugin_configs().clone()
647
+ }
648
+
176
649
  /// Gets the currently active plugin. Calling this method before a plugin
177
650
  /// has been selected will cause the default (first) plugin to be
178
651
  /// selected, and doing so when no plugins have been registered is an
@@ -206,16 +679,17 @@ impl Renderer {
206
679
  }
207
680
 
208
681
  pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
209
- let plugins = self.get_all_plugins();
210
- let tasks = plugins
211
- .iter()
212
- .map(|plugin| plugin.restyle(view.clone().into()));
213
-
214
- join_all(tasks)
215
- .await
216
- .into_iter()
217
- .collect::<Result<Vec<_>, _>>()
218
- .map(|_| JsValue::UNDEFINED)
682
+ let plugin = self.get_active_plugin()?;
683
+ let meta = self.metadata();
684
+ plugin.restyle();
685
+ let mut limits =
686
+ get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
687
+ limits.is_update = false;
688
+ plugin
689
+ .draw(view.clone().into(), limits.max_cols, limits.max_rows, false)
690
+ .await?;
691
+
692
+ Ok(JsValue::UNDEFINED)
219
693
  }
220
694
 
221
695
  pub fn set_throttle(&self, val: Option<f64>) {
@@ -223,7 +697,12 @@ impl Renderer {
223
697
  }
224
698
 
225
699
  pub fn set_selection(&self, window: Option<ViewWindow>) {
226
- self.borrow_mut().selection = window
700
+ if self.borrow().selection == window {
701
+ return;
702
+ }
703
+
704
+ self.borrow_mut().selection = window.clone();
705
+ self.selection_changed.emit(window);
227
706
  }
228
707
 
229
708
  pub fn get_selection(&self) -> Option<ViewWindow> {
@@ -231,14 +710,13 @@ impl Renderer {
231
710
  }
232
711
 
233
712
  pub fn disable_active_plugin_render_warning(&self) {
234
- self.borrow_mut().metadata.render_warning = false;
235
- self.get_active_plugin().unwrap().set_render_warning(false);
713
+ self.0.render_warning.set(false);
236
714
  }
237
715
 
238
716
  pub fn get_next_plugin_metadata(
239
717
  &self,
240
718
  update: &PluginUpdate,
241
- ) -> Option<ViewConfigRequirements> {
719
+ ) -> Option<Rc<PluginStaticConfig>> {
242
720
  let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
243
721
  let name = match update {
244
722
  PluginUpdate::Missing => return None,
@@ -246,7 +724,7 @@ impl Renderer {
246
724
  PluginUpdate::Update(plugin) => plugin,
247
725
  };
248
726
 
249
- let idx = self.find_plugin_idx(name).expect("f");
727
+ let idx = self.find_plugin_idx(name)?;
250
728
  let changed = !matches!(
251
729
  self.0.borrow().plugins_idx,
252
730
  Some(selected_idx) if selected_idx == idx
@@ -254,9 +732,12 @@ impl Renderer {
254
732
 
255
733
  if changed {
256
734
  self.borrow_mut().pending_plugin = Some(idx);
257
- self.get_plugin(name)
258
- .and_then(|x| x.get_requirements())
259
- .ok()
735
+ self.0
736
+ .borrow_mut()
737
+ .plugin_store
738
+ .plugin_configs()
739
+ .get(idx)
740
+ .cloned()
260
741
  } else {
261
742
  None
262
743
  }
@@ -271,10 +752,7 @@ impl Renderer {
271
752
  );
272
753
 
273
754
  if changed {
274
- self.borrow_mut().plugins_idx = Some(idx);
275
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
276
- self.borrow_mut().metadata = plugin.get_requirements()?;
277
- self.plugin_changed.emit(plugin);
755
+ self.commit_plugin_idx(idx)?;
278
756
  }
279
757
 
280
758
  Ok(changed)
@@ -301,15 +779,49 @@ impl Renderer {
301
779
  );
302
780
 
303
781
  if changed {
304
- self.borrow_mut().plugins_idx = Some(idx);
305
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
306
- self.borrow_mut().metadata = plugin.get_requirements()?;
307
- self.plugin_changed.emit(plugin);
782
+ self.commit_plugin_idx(idx)?;
308
783
  }
309
784
 
310
785
  Ok(changed)
311
786
  }
312
787
 
788
+ /// Shared tail of `apply_pending_plugin` / `set_plugin`: switch the
789
+ /// active plugin to `idx`, swap in its cached `PluginStaticConfig`,
790
+ /// reset the per-plugin render-warning flag, and fire
791
+ /// `plugin_changed`.
792
+ fn commit_plugin_idx(&self, idx: usize) -> ApiResult<()> {
793
+ self.borrow_mut().plugins_idx = Some(idx);
794
+ let config = self
795
+ .0
796
+ .borrow_mut()
797
+ .plugin_store
798
+ .plugin_configs()
799
+ .get(idx)
800
+ .cloned()
801
+ .ok_or("No Plugin")?;
802
+
803
+ self.borrow_mut().metadata = config.clone();
804
+ self.0.render_warning.set(true);
805
+ let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
806
+
807
+ // Push the newly-activated plugin's stored bucket through
808
+ // `plugin.restore` so the swap immediately reflects any
809
+ // viewer-owned per-column and plugin-level config.
810
+ let bucket = self
811
+ .borrow()
812
+ .plugin_states
813
+ .get(&config.name)
814
+ .cloned()
815
+ .unwrap_or_default();
816
+ let token = JsValue::from_serde_ext(&bucket.plugin).unwrap_or(JsValue::NULL);
817
+ if let Err(e) = plugin.restore(&token, Some(&bucket.columns)) {
818
+ tracing::warn!("plugin.restore on swap failed: {:?}", e);
819
+ }
820
+
821
+ self.plugin_changed.emit(plugin);
822
+ Ok(())
823
+ }
824
+
313
825
  pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
314
826
  let draw_mutex = self.draw_lock();
315
827
  draw_mutex.lock(task).await
@@ -397,19 +909,25 @@ impl Renderer {
397
909
 
398
910
  async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
399
911
  let plugin = self.get_active_plugin()?;
400
- let meta = self.metadata().clone();
401
- let mut limits = get_row_and_col_limits(view, &meta).await?;
912
+ let meta = self.metadata();
913
+ let mut limits =
914
+ get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
402
915
  limits.is_update = is_update;
403
916
  if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
404
917
  cb.emit(limits);
405
918
  }
919
+
406
920
  let viewer_elem = &self.0.borrow().viewer_elem.clone();
407
- if is_update {
921
+ let result = if is_update {
408
922
  let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
409
- activate_plugin(viewer_elem, &plugin, task).await?;
923
+ activate_plugin(viewer_elem, &plugin, task).await
410
924
  } else {
411
925
  let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
412
- activate_plugin(viewer_elem, &plugin, task).await?;
926
+ activate_plugin(viewer_elem, &plugin, task).await
927
+ };
928
+
929
+ if let Err(error) = result.ignore_view_delete() {
930
+ tracing::warn!("{}", error);
413
931
  }
414
932
 
415
933
  remove_inactive_plugin(
@@ -496,12 +1014,20 @@ impl Renderer {
496
1014
 
497
1015
  fn find_plugin_idx(&self, name: &str) -> Option<usize> {
498
1016
  let short_name = make_short_name(name);
499
- self.0
500
- .borrow_mut()
501
- .plugin_store
502
- .plugins()
1017
+ let mut borrowed = self.0.borrow_mut();
1018
+ let configs = borrowed.plugin_store.plugin_configs();
1019
+ // Prefer an exact (normalised) match so e.g. `"Y Line"` doesn't
1020
+ // substring-resolve to `"X/Y Line"` just because it was registered
1021
+ // first. Falls back to `contains` so short/abbreviated names
1022
+ // (`restore({ plugin: "scat" })` → `"scatter"`) still work.
1023
+ let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
1024
+ if let Some(i) = short_names.iter().position(|n| n == &short_name) {
1025
+ return Some(i);
1026
+ }
1027
+
1028
+ short_names
503
1029
  .iter()
504
- .position(|elem| make_short_name(&elem.name()).contains(&short_name))
1030
+ .position(|n: &String| n.contains(&short_name))
505
1031
  }
506
1032
  }
507
1033
 
@@ -526,28 +1052,96 @@ impl Renderer {
526
1052
  // later will never be found.
527
1053
  let has_plugin = self.0.borrow().plugins_idx.is_some();
528
1054
  if has_plugin {
529
- let plugin_name = self.get_active_plugin().ok().map(|p| p.name());
530
- let requirements = self.metadata().clone();
1055
+ let config = self.metadata();
1056
+ let plugin_name = Some(config.name.clone());
1057
+ let is_chart = config.name.as_str() != "Datagrid";
531
1058
  let available_plugins = self
532
- .get_all_plugins()
1059
+ .get_all_plugin_configs()
533
1060
  .into_iter()
534
- .map(|p| p.name())
1061
+ .map(|c| c.name.clone())
535
1062
  .collect::<Vec<_>>()
536
1063
  .into();
1064
+ let plugin_config = PtrEqRc::new(self.get_plugin_config());
537
1065
 
538
1066
  RendererProps {
539
1067
  plugin_name,
540
- requirements,
1068
+ config,
541
1069
  render_limits,
542
1070
  available_plugins,
1071
+ is_chart,
1072
+ plugin_config,
543
1073
  }
544
1074
  } else {
545
1075
  RendererProps {
546
1076
  plugin_name: None,
547
- requirements: ViewConfigRequirements::default(),
1077
+ config: Rc::new(PluginStaticConfig::default()),
548
1078
  render_limits,
549
1079
  available_plugins: PtrEqRc::new(vec![]),
1080
+ is_chart: false,
1081
+ plugin_config: PtrEqRc::default(),
550
1082
  }
551
1083
  }
552
1084
  }
553
1085
  }
1086
+
1087
+ /// Drop entries from `map` whose value matches the schema-declared
1088
+ /// default for that key. Used by both the plugin-config and
1089
+ /// columns-config write paths to converge buckets to the
1090
+ /// "empty ⇒ reads-default" invariant. For `ControlSpec::Number`
1091
+ /// entries marked `include: Some(true)`,
1092
+ /// [`matches_declared_default`] short-circuits so the value survives
1093
+ /// (used when the declared default is data-dependent and unreliable —
1094
+ /// e.g. Datagrid's `fg_gradient`, whose default is the column's
1095
+ /// `abs_max`).
1096
+ fn strip_default_values(
1097
+ schema: &ColumnConfigSchema,
1098
+ map: &mut serde_json::Map<String, serde_json::Value>,
1099
+ ) {
1100
+ map.retain(|key, value| {
1101
+ !schema
1102
+ .fields
1103
+ .iter()
1104
+ .any(|spec| matches_declared_default(spec, key, value))
1105
+ });
1106
+ }
1107
+
1108
+ /// Does `value` for `key` match the `default` declared by `spec`?
1109
+ /// Composite variants (`NumberSeriesStyle`, `DatetimeFormat`, etc.) own
1110
+ /// nested defaults that don't have a single comparable scalar — the
1111
+ /// widget is responsible for emitting empty values when the user
1112
+ /// resets composite controls, so this helper returns `false` for them.
1113
+ fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool {
1114
+ match spec {
1115
+ ControlSpec::Enum {
1116
+ key: k, default, ..
1117
+ } if k == key => value.as_str() == Some(default.as_str()),
1118
+ ControlSpec::Bool {
1119
+ key: k, default, ..
1120
+ } if k == key => value.as_bool() == Some(*default),
1121
+ ControlSpec::Number {
1122
+ key: k,
1123
+ include: Some(true),
1124
+ ..
1125
+ } if k == key => false,
1126
+ ControlSpec::Number {
1127
+ key: k, default, ..
1128
+ } if k == key => value.as_f64() == Some(*default),
1129
+ ControlSpec::String {
1130
+ key: k, default, ..
1131
+ } if k == key => value.as_str() == Some(default.as_str()),
1132
+ ControlSpec::Color {
1133
+ key: k, default, ..
1134
+ } if k == key => value.as_str() == Some(default.as_str()),
1135
+ ControlSpec::ColorRange {
1136
+ key_pos,
1137
+ default_pos,
1138
+ ..
1139
+ } if key_pos == key => value.as_str() == Some(default_pos.as_str()),
1140
+ ControlSpec::ColorRange {
1141
+ key_neg,
1142
+ default_neg,
1143
+ ..
1144
+ } if key_neg == key => value.as_str() == Some(default_neg.as_str()),
1145
+ _ => false,
1146
+ }
1147
+ }