perspective-viewer 5.0.0

A data visualization and analytics component, especially well-suited for large and/or streaming datasets.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

use std::cell::RefCell;
use std::collections::HashMap;

use perspective_js::JsViewWindow;
use perspective_js::utils::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;

use crate::config::PluginStaticConfig;
use crate::renderer::ColumnConfigMap;

/// Perspective FFI
#[wasm_bindgen]
#[rustfmt::skip]
extern "C" {

    #[derive(Clone)]
    pub type JsPerspectiveViewer;

    /// A `<perspective-viewer>` plugin custom element.
    ///
    /// # Capability tiers
    ///
    /// Only `get_static_config`, `draw`, `save`/`restore` and `delete` are
    /// REQUIRED. Every other method is optional — detected once per element
    /// tag ([`PluginCapabilities`], methods live on the class prototype) —
    /// with a defined host fallback, so a minimal plugin renders correctly
    /// and each added method buys performance or polish, never correctness:
    ///
    /// | method     | host fallback when absent                              |
    /// |------------|--------------------------------------------------------|
    /// | `update`   | `draw` — every repaint is a full render (such plugins  |
    /// |            | must NOT treat `draw` as a state-reset license)        |
    /// | `resize`   | no-op — CSS reflows the box; next dispatch repaints    |
    /// | `restyle`  | no-op — the plugin re-reads CSS at its next render     |
    /// | `clear`    | no-op                                                  |
    /// | `deselect` | no-op                                                  |
    /// | `presize`  | held style-override presize (`Renderer` resize paths)  |
    /// | `render`   | image export reports unsupported                       |
    ///
    /// # Call discipline
    ///
    /// Plugin implementations assume the host NEVER overlaps calls on the
    /// same element: each of the rendering methods (`draw`, `update`,
    /// `render`, `clear`, `resize`) and `delete` must run to completion
    /// before the next begins. Every call site must therefore hold that
    /// plugin's per-`Renderer` draw lock, witnessed by
    /// [`crate::utils::RenderGuard`] — the guard-taking dispatch helpers
    /// (`Renderer::draw_fresh`/`update_bound`,
    /// `renderer::activate::activate_plugin`) and the already-locked
    /// `render_task`/`update_lazy`/`resize`/`restyle_all`/`dispose`
    /// entrypoints do not compile from an unlocked context. Synchronous
    /// calls (`restore`, `restyle`, the schema queries) are exempt, though
    /// `restyle` should ride inside a locked restyle-then-update sequence
    /// (see `Renderer::restyle_all`).
    ///
    /// # Dispatch semantics (`PLUGIN_DRAW_INVARIANT_PLAN.md`, amended
    /// 2026-07-16)
    ///
    /// The host invokes `draw` **iff there is a NEW `View` for this
    /// plugin** — `bind_view` REBUILT the engine `View`, or a
    /// freshly-selected plugin element owes its first paint of the bound
    /// one — so implementations may treat `draw` as "new data shape"
    /// (reset zoom/scroll/domain state). The witness enforcing this is
    /// [`crate::session::FreshView`], which only the rebuild and
    /// first-paint paths mint.
    ///
    /// `update` is a repaint of the SAME `View`, invoked iff a
    /// plugin-visible SOURCE changed — one of exactly six: (1) data
    /// (`View::on_update`), (2) an adopted placeholder-config delta,
    /// (3) a genuinely CHANGED `plugin_config`/`columns_config` the host
    /// just delivered via `restore` (so `update`-after-`restore` is the
    /// sanctioned delivery pairing), (4) genuinely changed CSS vars just
    /// applied via `restyle` (`Renderer::restyle_all`, or the fused
    /// stale-capture restyle immediately preceding a dispatch inside
    /// `draw_view` — gated by `Renderer::needs_restyle`), (5) a
    /// render-limits change (warning dismissed), (6) an explicit PUBLIC
    /// element-API call that reconciled as a no-op
    /// ([`crate::tasks::RunOrigin::Public`] — the documented
    /// `viewer.restore({})` refresh affordance). An INTERNAL run that
    /// changed none of these dispatches nothing.
    ///
    /// `resize` is geometry/chrome only, from retained state (and is also
    /// the activation-chrome nudge — implementations should no-op it
    /// while hidden).
    ///
    /// `restore`/`save` are state transfer, NOT rendering: plugins must
    /// not render internally from them, and must not call host APIs from
    /// inside `restore` (a `restore`-triggered `restorePanel` echo is how
    /// the initial-load double render happened — user gestures may echo,
    /// host-delivered restores must not). (The built-in `Debug` plugin's
    /// own `resize()` delegating to its `draw()` is plugin-INTERNAL and
    /// exempt.)
    ///
    /// # Render-callable contract (invariant I5,
    /// `SESSION_CONFIG_COHERENCE_PLAN.md`)
    ///
    /// From inside `draw`/`update`/`render`/`resize`, a plugin may call
    /// back into its host `<perspective-viewer>` ONLY the per-panel
    /// snapshot getters — `getViewConfigPanel`, `getTablePanel`,
    /// `getClientPanel`, `getEditPortPanel` — which answer from the run's
    /// pinned `RenderContext`, so plugin-visible state always equals the
    /// snapshot being drawn, never a fresher in-flight commit.
    /// Lock-acquiring host methods (`savePanel`, `restorePanel`, `flush`,
    /// `download`/`export`/`copy`, …) DEADLOCK on the non-reentrant draw
    /// lock if called from a render and are forbidden.
    #[derive(Clone)]
    pub type JsPerspectiveViewerPlugin;

    #[derive(Clone)]
    pub type JsPluginStaticConfig;

    /// The static configuration of the plugin which defines the basic
    /// integration with `perspective-viewer`. Called once per plugin at
    /// registration time and cached — the result must be stable for
    /// the lifetime of the application.
    #[wasm_bindgen(method)]
    pub fn get_static_config(this: &JsPerspectiveViewerPlugin) -> JsPluginStaticConfig;

    /// Returns the per-column schema describing which controls to render
    /// in the sidebar Style tab and the keys each control owns in the
    /// column's persisted config map. `column_stats` carries cached
    /// per-column numeric stats (currently `{ abs_max?: number }`);
    /// fields are populated lazily and may be missing on the first
    /// call — the view re-renders and re-queries the schema once the
    /// async fetch resolves.
    #[wasm_bindgen(method, catch, js_name = column_config_schema)]
    pub fn _column_config_schema(this: &JsPerspectiveViewerPlugin, view_type: &str, group: Option<&str>, column_name: &str, current_value: &JsValue, view_config: &JsValue, column_stats: &JsValue) -> ApiResult<JsValue>;

    #[wasm_bindgen(method, catch, js_name = plugin_config_schema)]
    pub fn _plugin_config_schema(this: &JsPerspectiveViewerPlugin, view_config: &JsValue) -> ApiResult<JsValue>;

    /// STATE TRANSFER, not rendering (dispatch semantics, rule for
    /// `restore`/`save`): deliver a `plugin_config` + `columns_config`
    /// snapshot into the plugin. Sync and lock-exempt. The host pairs a
    /// restore that genuinely CHANGED plugin state with exactly one
    /// `update` in the same locked run (update source 3) — plugins must
    /// not render from `restore()` themselves, and must not call host
    /// APIs from inside it (the echo rule: a plugin-issued `restorePanel`
    /// re-enters through the PUBLIC surface, indistinguishable from a
    /// user call — the initial-load double-render bug). Prefer the typed
    /// [`JsPerspectiveViewerPlugin::restore`] wrapper.
    #[wasm_bindgen(method, js_name=restore, catch)]
    pub fn _restore(this: &JsPerspectiveViewerPlugin, token: &JsValue, columns_config: &JsValue) -> ApiResult<()>;

    /// Free the plugin's resources. Serialized like the rendering methods —
    /// callers route through the draw lock (`Renderer::dispose`/`delete`
    /// defer teardown through it; a sync `delete` mid-`draw` violates the
    /// call discipline).
    #[wasm_bindgen(method)]
    pub fn delete(this: &JsPerspectiveViewerPlugin);

    #[wasm_bindgen(method, js_name = restyle)]
    fn _restyle(
        this: &JsPerspectiveViewerPlugin,
    );

    #[wasm_bindgen(method, catch, js_name = render)]
    async fn _render(
        this: &JsPerspectiveViewerPlugin,
        view: perspective_js::View,
        viewport: Option<JsViewWindow>,
    ) -> ApiResult<web_sys::Blob>;

    /// Full render of a `View` that is NEW to this plugin — dispatched iff
    /// `bind_view` REBUILT the engine `View`. The only REQUIRED rendering
    /// method.
    #[wasm_bindgen(method, catch)]
    pub async fn draw(
        this: &JsPerspectiveViewerPlugin,
        view: perspective_js::View,
        column_limit: Option<usize>,
        row_limit: Option<usize>,
        force: bool
    ) -> ApiResult<()>;

    #[wasm_bindgen(method, catch, js_name = update)]
    async fn _update(
        this: &JsPerspectiveViewerPlugin,
        view: perspective_js::View,
        column_limit: Option<usize>,
        row_limit: Option<usize>,
        force: bool
    ) -> ApiResult<()>;

    #[wasm_bindgen(method, catch, js_name = clear)]
    async fn _clear(this: &JsPerspectiveViewerPlugin) -> ApiResult<JsValue>;

    #[wasm_bindgen(method, catch, js_name = resize)]
    async fn _resize(this: &JsPerspectiveViewerPlugin) -> ApiResult<JsValue>;

    #[wasm_bindgen(method, catch, js_name = deselect)]
    async fn _deselect(this: &JsPerspectiveViewerPlugin) -> ApiResult<()>;

    #[wasm_bindgen(method, catch, js_name = presize)]
    async fn _presize(this: &JsPerspectiveViewerPlugin, width: f64, height: f64) -> ApiResult<JsValue>;
}

impl From<JsPluginStaticConfig> for PluginStaticConfig {
    fn from(value: JsPluginStaticConfig) -> Self {
        value.into_serde_ext().expect("Invalid plugin config")
    }
}

/// Which OPTIONAL plugin methods a plugin implements (see "Capability
/// tiers" on [`JsPerspectiveViewerPlugin`]). Detected once per
/// custom-element tag — methods live on the class prototype, so every
/// instance of a tag answers identically — and memoized; call sites read
/// cached flags instead of running per-call `Reflect` probes.
#[derive(Clone, Copy, Debug, Default)]
pub struct PluginCapabilities {
    pub draw: bool,
    pub update: bool,
    pub resize: bool,
    pub restyle: bool,
    pub clear: bool,
    pub deselect: bool,
    pub presize: bool,
    pub render: bool,
}

thread_local! {
    static PLUGIN_CAPABILITIES: RefCell<HashMap<String, PluginCapabilities>> =
        RefCell::new(HashMap::new());
}

impl PluginCapabilities {
    fn detect(plugin: &JsPerspectiveViewerPlugin) -> Self {
        let is_fn = |name: &str| {
            js_sys::Reflect::get(plugin, &JsValue::from_str(name))
                .map(|x| x.is_function())
                .unwrap_or_default()
        };

        Self {
            draw: is_fn("draw"),
            update: is_fn("update"),
            resize: is_fn("resize"),
            restyle: is_fn("restyle"),
            clear: is_fn("clear"),
            deselect: is_fn("deselect"),
            presize: is_fn("presize"),
            render: is_fn("render"),
        }
    }
}

impl JsPerspectiveViewerPlugin {
    /// Read and deserialize the plugin's static config. Should only
    /// be called once per plugin (at registration time); cache the
    /// result and read fields off the cached value rather than
    /// reaching back through the FFI.
    pub fn read_static_config(&self) -> PluginStaticConfig {
        self.get_static_config().into()
    }

    pub fn restore(
        &self,
        token: &JsValue,
        columns_config: Option<&ColumnConfigMap>,
    ) -> ApiResult<()> {
        let columns_config = JsValue::from_serde_ext(&columns_config).unwrap();
        self._restore(token, &columns_config)
    }

    /// This plugin's memoized [`PluginCapabilities`] — the first call for a
    /// given element tag runs the `Reflect` probes; all later calls (any
    /// instance of the tag) read the cache.
    pub fn capabilities(&self) -> PluginCapabilities {
        let tag = self.unchecked_ref::<web_sys::Element>().tag_name();
        PLUGIN_CAPABILITIES.with(|cache| {
            *cache
                .borrow_mut()
                .entry(tag)
                .or_insert_with(|| PluginCapabilities::detect(self))
        })
    }

    /// Repaint of the SAME `View` — dispatched iff one of the six
    /// plugin-visible sources changed (see "Dispatch semantics" on
    /// [`JsPerspectiveViewerPlugin`]); never defensively. Reaches the
    /// plugin via [`crate::renderer::Renderer::update_bound`] (pipeline
    /// runs — `Adopted` deltas, changed-config delivery, public no-op
    /// refresh) or [`crate::renderer::Renderer::update_lazy`]
    /// (`table_updated` data refreshes, the config-apply tasks, warning
    /// dismiss), both locked. Tier fallback: a plugin without `update`
    /// receives `draw` — it declared no incremental path, so every repaint
    /// is a full render.
    pub async fn update(
        &self,
        view: perspective_js::View,
        column_limit: Option<usize>,
        row_limit: Option<usize>,
        force: bool,
    ) -> ApiResult<()> {
        if self.capabilities().update {
            self._update(view, column_limit, row_limit, force).await
        } else {
            self.draw(view, column_limit, row_limit, force).await
        }
    }

    /// Repaint from retained state — dispatched iff geometry or visibility
    /// changed: box resizes (resize observers, presize sweeps, settings
    /// toggles) and the panel-ACTIVATION chrome nudge
    /// ([`crate::renderer::Renderer::activation_repaint`], stamped inside
    /// one locked dispatch). Same `View`, same data, no CSS re-read —
    /// implementations should no-op while hidden (`offsetParent == null`).
    /// Tier fallback: no-op — CSS reflows the box and the next dispatch
    /// repaints the content.
    pub async fn resize(&self) -> ApiResult<JsValue> {
        if self.capabilities().resize {
            self._resize().await
        } else {
            Ok(JsValue::UNDEFINED)
        }
    }

    /// Re-read the `--psp-*` CSS custom properties (sync). Dispatched ONLY
    /// when the effective theme genuinely changed, state-keyed by
    /// [`crate::renderer::Renderer::needs_restyle`] (the effective theme
    /// vs. the one recorded at this plugin's last capture — first paint or
    /// last restyle), from two sites: fused immediately BEFORE a
    /// `draw`/`update` inside the same locked dispatch
    /// (`Renderer::draw_view`, "restyle then draw" — one render pass in
    /// the new theme), or as `Renderer::restyle_all`'s locked
    /// restyle-then-`update` pair when no other dispatch is coming
    /// (theme picker, `resetThemes`, default-theme discovery, restore
    /// tails). Exception: the public `restyleElement()` API restyles
    /// unconditionally — it is the "my external CSS changed" affordance,
    /// outside what captured-theme state can know. (Plus one
    /// `mount_plugin` restyle on first light-DOM mount.) Tier fallback:
    /// no-op — the plugin re-reads CSS at its next render.
    pub fn restyle(&self) {
        if self.capabilities().restyle {
            self._restyle()
        }
    }

    /// Blank the plugin (view deleted, table removed). Tier fallback:
    /// no-op.
    pub async fn clear(&self) -> ApiResult<JsValue> {
        if self.capabilities().clear {
            self._clear().await
        } else {
            Ok(JsValue::UNDEFINED)
        }
    }

    /// Offscreen export render (`copy`/`export`) — returns an image
    /// `Blob`; not a dispatch verb (does not touch the plugin's mounted
    /// DOM state), but still serialized under the draw lock. Tier
    /// fallback: errors — image export is unsupported without it.
    pub async fn render(
        &self,
        view: perspective_js::View,
        viewport: Option<JsViewWindow>,
    ) -> ApiResult<web_sys::Blob> {
        if self.capabilities().render {
            self._render(view, viewport).await
        } else {
            Err(ApiError::from("Plugin does not support image export"))
        }
    }

    /// Clear any visible selection state (highlighted rows, pinned
    /// tooltips) WITHOUT emitting selection events. Invoked by the host
    /// when an element-level global filter contributed by this panel's
    /// selection is removed (`GlobalFilterBar` chip × / "Clear"), so the
    /// selection visual can't outlive the filter it produced.
    /// Implementations may redraw, so callers must hold the plugin's
    /// per-`Renderer` draw lock (see "Call discipline"). Tier fallback:
    /// no-op.
    pub async fn deselect(&self) -> ApiResult<()> {
        if self.capabilities().deselect {
            self._deselect().await
        } else {
            Ok(())
        }
    }

    /// Staged presize: render at the TARGET element box `(width, height)`
    /// offscreen — nothing on screen changes — resolving, once the frame
    /// is staged, to a present closure that reveals it synchronously.
    /// Callers run the closure in the same task as the layout commit the
    /// presize anticipated (see `Renderer::presize_with_dimensions`), so
    /// geometry and pixels land in one paint; the closure is lock-free (it
    /// blits retained pixels, no plugin render entry). Only call when
    /// [`Self::capabilities`] reports `presize` — plugins without it take
    /// the held style-override path. Resolves `None` when the plugin
    /// skipped (hidden), presenting nothing.
    pub async fn presize(&self, width: f64, height: f64) -> ApiResult<Option<js_sys::Function>> {
        let present = self._presize(width, height).await?;
        Ok(present.dyn_into::<js_sys::Function>().ok())
    }
}