perspective_viewer/custom_elements/viewer.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
13#![allow(non_snake_case)]
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use futures::channel::oneshot::channel;
19use futures::future::join_all;
20use js_sys::{Array, JsString};
21use perspective_client::config::ViewConfigUpdate;
22use perspective_client::utils::PerspectiveResultExt;
23use perspective_js::utils::global;
24use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
25use wasm_bindgen::JsCast;
26use wasm_bindgen::prelude::*;
27use wasm_bindgen_derive::try_from_js_option;
28use wasm_bindgen_futures::JsFuture;
29use web_sys::HtmlElement;
30use yew::Callback;
31
32#[cfg(feature = "llm-agent")]
33use crate::agent::AgentRuntime;
34use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
35use crate::config::*;
36use crate::custom_events::*;
37use crate::js::*;
38use crate::presentation::*;
39use crate::queries::*;
40use crate::root::Root;
41use crate::session::{MissingTable, ResetOptions, TableLoadState};
42use crate::tasks::*;
43use crate::utils::*;
44use crate::workspace::{Panel, PanelId, Workspace};
45use crate::*;
46
47#[wasm_bindgen]
48extern "C" {
49 /// `load()` argument: a [`Client`], a (deprecated) [`Table`], or a
50 /// `Promise` resolving to either. Typed rather than `any` so callers get
51 /// completion; the `Table` forms remain runtime-deprecated.
52 #[wasm_bindgen(typescript_type = "Client | Table | Promise<Client | Table>")]
53 pub type JsClientLoad;
54
55 /// `eject()` argument dict (`{ client?: string }`).
56 #[wasm_bindgen(typescript_type = "ClientOptions")]
57 pub type JsClientOptions;
58
59 /// Panel-selector dict (`{ panel?: string }`) for the active/base
60 /// accessor methods.
61 #[wasm_bindgen(typescript_type = "PanelOptions")]
62 pub type JsPanelOptions;
63
64 /// The `restore()` options dict.
65 #[wasm_bindgen(typescript_type = "RestoreOptions")]
66 pub type JsRestoreOptions;
67
68 /// The `restoreWorkspace()` options dict.
69 #[wasm_bindgen(typescript_type = "RestoreWorkspaceOptions")]
70 pub type JsRestoreWorkspaceOptions;
71
72 /// The `addPanel()` options dict.
73 #[wasm_bindgen(typescript_type = "AddPanelOptions")]
74 pub type JsAddPanelOptions;
75
76 /// `addPanel()` argument: a new panel's initial config — `table`
77 /// REQUIRED.
78 #[wasm_bindgen(typescript_type = "ViewerConfigInitial")]
79 pub type JsViewerConfigInitial;
80
81 /// `download`/`export`/`copy` options dict
82 /// (`{ method?: ExportMethod, panel?: string }`).
83 #[wasm_bindgen(typescript_type = "ExportOptions")]
84 pub type JsExportOptions;
85
86 /// `getTable` options dict (`{ wait?: boolean, panel?: string }`).
87 #[wasm_bindgen(typescript_type = "GetTableOptions")]
88 pub type JsGetTableOptions;
89
90 /// `getClient` options dict (`{ wait?: boolean, panel?: string }`).
91 #[wasm_bindgen(typescript_type = "GetClientOptions")]
92 pub type JsGetClientOptions;
93
94 /// `getView` options dict (`{ mode?: GetViewMode, panel?: string }`).
95 #[wasm_bindgen(typescript_type = "GetViewOptions")]
96 pub type JsGetViewOptions;
97
98 /// `saveWorkspace()` options dict (`{ full_palette?: boolean }`).
99 #[wasm_bindgen(typescript_type = "SaveWorkspaceOptions")]
100 pub type JsSaveWorkspaceOptions;
101
102 /// `saveWorkspace()` return: a workspace config.
103 #[wasm_bindgen(typescript_type = "Promise<WorkspaceConfig>")]
104 pub type JsWorkspaceConfigPromise;
105
106 /// A `Promise<void>` return, used by the `restore` family (whose
107 /// `ApiFuture<()>` would otherwise erase to `Promise<any>`).
108 #[wasm_bindgen(typescript_type = "Promise<void>")]
109 pub type JsVoidPromise;
110
111 /// `save()` return: a single-panel config.
112 #[wasm_bindgen(typescript_type = "Promise<ViewerConfig>")]
113 pub type JsViewerConfigPromise;
114}
115
116#[derive(serde::Deserialize, Default)]
117struct ResizeOptions {
118 dimensions: Option<ResizeDimensions>,
119}
120
121#[derive(serde::Deserialize, Clone, Copy)]
122struct ResizeDimensions {
123 width: f64,
124 height: f64,
125}
126
127/// Leniently deserialize an optional JS options dict into a serde struct,
128/// falling back to `Default` on absence or a malformed argument (matching the
129/// `ResizeOptions` precedent — an options bag is a best-effort convenience,
130/// not a hard-validated payload).
131fn parse_options<T, U>(options: Option<T>) -> ApiResult<U>
132where
133 T: Into<JsValue>,
134 U: Default + for<'a> serde::Deserialize<'a>,
135{
136 match options {
137 None => Ok(U::default()),
138 Some(x) => Ok(x.into_serde_ext()?),
139 }
140}
141
142/// The `<perspective-viewer>` custom element.
143///
144/// # JavaScript Examples
145///
146/// Create a new `<perspective-viewer>`:
147///
148/// ```javascript
149/// const viewer = document.createElement("perspective-viewer");
150/// window.body.appendChild(viewer);
151/// ```
152///
153/// Complete example including loading and restoring the [`Table`]:
154///
155/// ```javascript
156/// import perspective from "@perspective-dev/viewer";
157/// import perspective from "@perspective-dev/client";
158///
159/// const viewer = document.createElement("perspective-viewer");
160/// const worker = await perspective.worker();
161///
162/// await worker.table("x\n1", {name: "table_one"});
163/// await viewer.load(worker);
164/// await viewer.restore({table: "table_one"});
165/// ```
166#[derive(Clone)]
167#[wasm_bindgen]
168pub struct PerspectiveViewerElement {
169 pub(crate) presentation: Presentation,
170 pub(crate) workspace: Workspace,
171 pub(crate) elem: HtmlElement,
172 pub(crate) root: Root<components::viewer::PerspectiveViewer>,
173 resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
174 intersection_handle: Rc<RefCell<Option<AutoPauseHandle>>>,
175 hosted_table_subs: HostedTableSubs,
176 _subscriptions: Rc<[Subscription; 2]>,
177 _custom_event_subs: Rc<Vec<Subscription>>,
178}
179
180impl CustomElementMetadata for PerspectiveViewerElement {
181 const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer";
182 const STATICS: &'static [&'static str] =
183 ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice();
184}
185
186impl PerspectiveViewerElement {
187 fn layout_changed_notify(&self) -> Callback<()> {
188 let root = self.root.clone();
189 Callback::from(move |_: ()| {
190 if let Some(app) = root.borrow().as_ref() {
191 app.send_message(PerspectiveViewerMsg::LayoutChanged);
192 }
193 })
194 }
195
196 fn resolve_panel(&self, name: Option<String>) -> ApiResult<Panel> {
197 let id = name.map(PanelId::from);
198 self.workspace.panel_or_active(id.as_ref()).ok_or_else(|| {
199 format!(
200 "No panel named \"{}\"",
201 id.as_ref().map(PanelId::as_str).unwrap_or_default()
202 )
203 .into()
204 })
205 }
206
207 /// Re-stamp the global-filter overlay on every panel not in `fresh` and
208 /// re-render those whose overlay changed.
209 async fn rerender_retained_overlays(&self, fresh: &[PanelId]) -> ApiResult<()> {
210 let retained = self
211 .workspace
212 .panels()
213 .into_iter()
214 .filter(|panel| !fresh.contains(&panel.id))
215 .filter(|panel| stamp_global_overlay(&self.workspace, &panel.id, &panel.session))
216 .collect::<Vec<_>>();
217
218 join_all(retained.iter().map(|panel| async move {
219 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?;
220 Ok(())
221 }))
222 .await
223 .into_iter()
224 .collect::<ApiResult<Vec<_>>>()?;
225
226 Ok(())
227 }
228
229 fn layout_element(&self) -> Option<RegularLayout> {
230 self.elem
231 .shadow_root()?
232 .query_selector(RegularLayout::TAG_NAME)
233 .ok()
234 .flatten()
235 .map(|el| el.unchecked_into())
236 }
237
238 async fn workspace_config(this: Self, full_palette: bool) -> ApiResult<JsValue> {
239 this.workspace.effects().settle().await;
240 let mut panels: std::collections::BTreeMap<String, PanelViewerConfig> = Default::default();
241 for id in &this.workspace.panel_ids() {
242 let panel = this.workspace.panel(id).into_apierror()?;
243 let config = panel
244 .renderer
245 .clone()
246 .with_lock(async {
247 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
248 })
249 .await?;
250
251 panels.insert(id.as_str().to_owned(), config.panel);
252 }
253
254 let mut palette = palette_set(&this.workspace, &this.presentation);
255 let mut referenced = std::collections::BTreeSet::new();
256 for (id, item) in styles_in_use(&this.workspace) {
257 let Some(name) = palette_name_for(&palette, item.kind, &item.literal) else {
258 continue;
259 };
260
261 if let Some(entry) = panels
262 .get_mut(id.as_str())
263 .and_then(|panel| panel.columns_config.get_mut(&item.column))
264 {
265 entry.insert(item.key, serde_json::Value::String(format_var_ref(&name)));
266 referenced.insert(name);
267 }
268 }
269
270 if !full_palette {
271 palette.retain(|name, _| referenced.contains(name));
272 }
273
274 let active = this
275 .presentation
276 .is_settings_open()
277 .then(|| this.workspace.active_id())
278 .flatten()
279 .map(|id| id.as_str().to_owned());
280
281 let layout = this
282 .layout_element()
283 .map(|l| l.save().into_serde_ext::<crate::js::Layout>())
284 .transpose()?;
285
286 Ok(JsValue::from_serde_ext(&WorkspaceConfig {
287 version: API_VERSION.to_string(),
288 active,
289 layout,
290 panels,
291 global_filters: this.workspace.global_filters(),
292 masters: this
293 .workspace
294 .masters()
295 .iter()
296 .map(|id| id.as_str().to_owned())
297 .collect(),
298 palette,
299 })?)
300 }
301}
302
303fn eject_client_panels(
304 workspace: &Workspace,
305 root: &Root<crate::components::viewer::PerspectiveViewer>,
306 target: String,
307 ids: Vec<PanelId>,
308) -> ApiFuture<()> {
309 clone!(workspace, root);
310 let effect = workspace.effects().guard();
311 ApiFuture::new_throttled(async move {
312 let _effect = effect;
313 for id in ids {
314 let (completion, receiver) = Completion::new();
315 root.borrow()
316 .as_ref()
317 .into_apierror()?
318 .send_message(PerspectiveViewerMsg::ClosePanel(
319 id.to_string(),
320 Some(completion),
321 ));
322
323 receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
324 }
325
326 workspace.remove_client(&target);
327 Ok(())
328 })
329}
330
331#[rustfmt::skip]
332const DEPRECATED_TABLE_MESSAGE: &str =
333 "`load(table)` is deprecated - use `load(client)` followed by `restore({table: \"name\"})` instead";
334
335#[wasm_bindgen]
336impl PerspectiveViewerElement {
337 #[doc(hidden)]
338 #[wasm_bindgen(constructor)]
339 pub fn new(elem: web_sys::HtmlElement) -> Self {
340 let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
341 let shadow_root = elem
342 .attach_shadow(&init)
343 .unwrap()
344 .unchecked_into::<web_sys::Element>();
345
346 Self::new_from_shadow(elem, shadow_root)
347 }
348
349 fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
350 // Application State.
351 let presentation = Presentation::new(&elem);
352
353 // Boot with ZERO panels — an unconfigured element is a blank stage. The
354 // first `load`/`restore`/`addPanel` creates the first panel, which
355 // adopts the element's `theme` attribute when set (see
356 // `create_panel_model`'s authored-theme boot).
357 let workspace = Workspace::new();
358 let custom_event_subs = wire_element_events(&elem, &presentation, &workspace);
359
360 // Create Yew App
361 let props = yew::props!(PerspectiveViewerProps {
362 elem: elem.clone(),
363 presentation: presentation.clone(),
364 workspace: workspace.clone(),
365 });
366
367 let state = props.clone();
368 let root = Root::new(shadow_root, props);
369
370 // Create callbacks
371 let eject_sub = presentation.on_eject.add_listener({
372 let root = root.clone();
373 move |_| {
374 clone!(state.workspace, root);
375 ApiFuture::spawn(async move {
376 if let Some(target) = workspace.active_client().map(|c| c.get_name().to_owned())
377 {
378 let ids = workspace.panels_for_client(&target);
379 if ids.len() < workspace.panel_ids().len() {
380 return eject_client_panels(&workspace, &root, target, ids).await;
381 }
382 }
383
384 delete_all(&workspace, &root).await
385 })
386 }
387 });
388
389 let resize_handle = ResizeObserverHandle::new(&elem, &workspace, &presentation, &root);
390 let intersect_handle = AutoPauseHandle::new(&elem, &presentation, &workspace);
391 let (lifecycle_sub, hosted_table_subs) = wire_table_lifecycle(&workspace, &presentation);
392
393 Self {
394 elem,
395 root,
396 presentation,
397 workspace,
398 resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
399 intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
400 hosted_table_subs,
401 _subscriptions: Rc::new([eject_sub, lifecycle_sub]),
402 _custom_event_subs: Rc::new(custom_event_subs),
403 }
404 }
405
406 #[doc(hidden)]
407 #[wasm_bindgen(js_name = "connectedCallback")]
408 pub fn connected_callback(&self) -> ApiResult<()> {
409 tracing::debug!("Connected <perspective-viewer>");
410 Ok(())
411 }
412
413 /// Loads a [`Client`], or optionally [`Table`], or optionally a Javascript
414 /// `Promise` which returns a [`Client`] or [`Table`], in this viewer.
415 ///
416 /// Loading a [`Client`] does not render, but subsequent calls to
417 /// [`PerspectiveViewerElement::restore`] will use this [`Client`] to look
418 /// up the proviced `table` name field for the provided
419 /// [`ViewerConfigUpdate`].
420 ///
421 /// Loading a [`Table`] is equivalent to subsequently calling
422 /// [`Self::restore`] with the `table` field set to [`Table::get_name`], and
423 /// will render the UI in its default state when [`Self::load`] resolves.
424 /// If you plan to call [`Self::restore`] anyway, prefer passing a
425 /// [`Client`] argument to [`Self::load`] as it will conserve one render.
426 ///
427 /// When [`PerspectiveViewerElement::load`] resolves, the first frame of the
428 /// UI + visualization is guaranteed to have been drawn. Awaiting the result
429 /// of this method in a `try`/`catch` block will capture any errors
430 /// thrown during the loading process, or from the [`Client`] `Promise`
431 /// itself.
432 ///
433 /// [`PerspectiveViewerElement::load`] may also be called with a [`Table`],
434 /// which is equivalent to:
435 ///
436 /// ```javascript
437 /// await viewer.load(await table.get_client());
438 /// await viewer.restore({name: await table.get_name()})
439 /// ```
440 ///
441 /// If you plan to call [`PerspectiveViewerElement::restore`] immediately
442 /// after [`PerspectiveViewerElement::load`] yourself, as is commonly
443 /// done when loading and configuring a new `<perspective-viewer>`, you
444 /// should use a [`Client`] as an argument and set the `table` field in the
445 /// restore call as
446 ///
447 /// A [`Table`] can be created using the
448 /// [`@perspective-dev/client`](https://www.npmjs.com/package/@perspective-dev/client)
449 /// library from NPM (see [`perspective_js`] documentation for details).
450 ///
451 /// # JavaScript Examples
452 ///
453 /// ```javascript
454 /// import perspective from "@perspective-dev/client";
455 ///
456 /// const worker = await perspective.worker();
457 /// viewer.load(worker);
458 /// ```
459 ///
460 /// ... or
461 ///
462 /// ```javascript
463 /// const table = await worker.table(data, {name: "superstore"});
464 /// viewer.load(table);
465 /// ```
466 ///
467 /// Complete example:
468 ///
469 /// ```javascript
470 /// const viewer = document.createElement("perspective-viewer");
471 /// const worker = await perspective.worker();
472 ///
473 /// await worker.table("x\n1", {name: "table_one"});
474 /// await viewer.load(worker);
475 /// await viewer.restore({table: "table_one", columns: ["x"]});
476 /// ```
477 ///
478 /// ... or, if you don't want to pass your own arguments to `restore`:
479 ///
480 /// ```javascript
481 /// const viewer = document.createElement("perspective-viewer");
482 /// const worker = await perspective.worker();
483 ///
484 /// const table = await worker.table("x\n1", {name: "table_one"});
485 /// await viewer.load(table);
486 /// ```
487 pub fn load(&self, client: JsClientLoad) -> ApiResult<ApiFuture<()>> {
488 let effect = self.workspace.effects().guard();
489 let table: JsValue = client.into();
490 let promise = table
491 .clone()
492 .dyn_into::<js_sys::Promise>()
493 .unwrap_or_else(|_| js_sys::Promise::resolve(&table));
494
495 // Resolve the target panel. On an EMPTY element (zero panels):
496 // - a synchronously-detectable `Client` registers inertly with NO panel (the
497 // common `load(client)` — no phantom panel is left behind);
498 // - otherwise (a resolved `Table`, or a `Promise` whose type isn't yet known)
499 // the first panel is RESERVED synchronously here — a full panel model held
500 // in the workspace's reservation slot, NOT placed — so its ordering position
501 // is fixed at the call site: a `restore()` fired right after an unawaited
502 // `load()` CLAIMS the reservation (placing it) and targets THIS panel, not a
503 // second one. The reservation is likewise placed when the payload proves to
504 // be a `Table` (or the load fails, surfacing its error), and discarded —
505 // only while still unclaimed — for an inert `Client` payload. Placement and
506 // discard are both atomic slot transfers (`Workspace::claim_reserved` /
507 // `Workspace::take_reserved`), so an inert payload disposing a panel a
508 // racing `restore` claimed is unrepresentable.
509 // A pre-existing active panel is used as-is (a `Client` registers
510 // inertly against it, never clearing its table).
511 let (panel, notify) = match self.workspace.active_panel() {
512 Some(panel) => (panel, None),
513 None => {
514 // Empty element — classify the payload synchronously where possible.
515 if let Ok(Some(client)) =
516 try_from_js_option::<perspective_js::Client>(table.clone())
517 {
518 // A resolved `Client` registers SYNCHRONOUSLY (so an unawaited
519 // `load(client)` is visible to a `restore()` fired right after,
520 // which creates the first panel and federates against loaded
521 // clients) and creates NO panel — inert.
522 self.workspace
523 .set_default_client(client.get_client().clone());
524 return Ok(ApiFuture::new(async { Ok(()) }));
525 }
526
527 // A resolved `Table` (or a `Promise` whose type isn't yet known)
528 // adopts the pending reservation (a second `load()` on a
529 // still-empty element), else reserves a fresh panel model.
530 let panel = self.workspace.reserved_panel().unwrap_or_else(|| {
531 create_panel_model(
532 &self.elem,
533 &self.presentation,
534 &self.workspace,
535 None,
536 ViewerConfigUpdate::default(),
537 None,
538 Placement::Reserved,
539 );
540 self.workspace
541 .reserved_panel()
542 .expect("just-reserved panel is present")
543 });
544
545 (panel, Some(self.layout_changed_notify()))
546 },
547 };
548
549 let session = panel.session;
550 let renderer = panel.renderer;
551 let generation = session.begin_pending_load();
552 clone!(self.workspace, self.presentation);
553 Ok(ApiFuture::new_throttled(async move {
554 let _effect = effect;
555 renderer.set_throttle(None);
556 let _run_token = session.begin_config_run();
557 let result = {
558 clone!(session, renderer, workspace, notify);
559 renderer
560 .clone()
561 .render_task(|guard| async move {
562 seed_panel_theme(&presentation, &renderer).await;
563 renderer.stamp_theme(None);
564 let jstable = JsFuture::from(promise)
565 .await
566 .map_err(|x| apierror!(TableError(x)))?;
567
568 if let Ok(Some(table)) =
569 try_from_js_option::<perspective_js::Table>(jstable.clone())
570 {
571 tracing::warn!("{}", DEPRECATED_TABLE_MESSAGE);
572 let Some(journal) = session.take_pending_load(generation) else {
573 return Ok(None);
574 };
575
576 if let Some(notify) = ¬ify {
577 place_reserved(&workspace, notify, true);
578 }
579
580 let _plugin = renderer.ensure_plugin_selected()?;
581 let _ = renderer.mount_active_plugin();
582 session
583 .reset(ResetOptions {
584 config: true,
585 expressions: true,
586 stats: true,
587 table: Some(session::TableIntermediateState::Reloaded),
588 })
589 .await
590 .unwrap_or_log();
591
592 let client = table.get_client().await;
593 let inner_client = client.get_client().clone();
594 session.set_client(inner_client.clone());
595 workspace.set_default_client(inner_client);
596 let name = table.get_name().await;
597 tracing::debug!(
598 "Loading {:.0} rows from `Table` {}",
599 table.size().await?,
600 name
601 );
602
603 session.set_table(name).await?;
604 for delta in journal {
605 session.commit_view_config(delta)?;
606 }
607
608 session.commit_table_defaults();
609 let (disposition, _pin) =
610 crate::tasks::bind_snapshot(&guard, &session, &renderer).await?;
611
612 crate::tasks::dispatch_bound(
613 &guard,
614 &renderer,
615 disposition,
616 false,
617 crate::tasks::RunOrigin::Public,
618 )
619 .await?;
620
621 Ok(None)
622 } else if let Ok(Some(client)) = wasm_bindgen_derive::try_from_js_option::<
623 perspective_js::Client,
624 >(jstable)
625 {
626 // INERT: register the client only — never rebind or
627 // reset the active panel (its table is preserved).
628 // Panels bind their client lazily at table-resolution
629 // time (`Workspace::resolve_client_for_table`). The
630 // window is discarded (not replayed): a `Client`
631 // performs no reset, and any racing `restore`'s
632 // commits already applied live (`commit_view_config`).
633 let owned_window = session.take_pending_load(generation).is_some();
634 let discard = if owned_window && notify.is_some() {
635 match workspace.take_reserved() {
636 Some(panel) => Some((panel, None)),
637 // The one-shot claim read: a table-less
638 // `restore` claimed the reservation, and no
639 // table has bound nor is pending — evict the
640 // panel `CREATE_REQUIRES_TABLE` forbids.
641 None if workspace
642 .resolve_claim()
643 .is_some_and(|has_table| !has_table)
644 && session.get_table().is_none()
645 && session.pending_table().is_none() =>
646 {
647 let evicted = renderer
648 .slot_name()
649 .map(PanelId::from)
650 .and_then(|id| workspace.remove_panel(&id));
651
652 if evicted.is_some()
653 && let Some(notify) = ¬ify
654 {
655 notify.emit(());
656 }
657
658 evicted.map(|panel| {
659 (panel, Some(ApiError::new(CREATE_REQUIRES_TABLE)))
660 })
661 },
662 None => None,
663 }
664 } else {
665 None
666 };
667
668 workspace.set_default_client(client.get_client().clone());
669 Ok(discard)
670 } else {
671 session.take_pending_load(generation);
672 Err(ApiError::new("Invalid argument"))
673 }
674 })
675 .await
676 };
677
678 match result {
679 Err(e) => {
680 session.take_pending_load(generation);
681 if let Some(notify) = ¬ify {
682 place_reserved(&workspace, notify, true);
683 }
684
685 session.set_error(false, e.clone()).await?;
686 Err(e)
687 },
688 Ok(Some((panel, error))) => {
689 eject_panel(panel).await?;
690 match error {
691 Some(e) => Err(e),
692 None => Ok(()),
693 }
694 },
695 Ok(None) => Ok(()),
696 }
697 }))
698 }
699
700 /// Delete all internal [`View`]s and all associated state, rendering this
701 /// `<perspective-viewer>` unusable and freeing all associated resources.
702 /// Does not delete any supplied [`Table`] (as this is constructed by the
703 /// callee).
704 ///
705 /// Calling _any_ method on a `<perspective-viewer>` after [`Self::delete`]
706 /// will throw.
707 ///
708 /// <div class="warning">
709 ///
710 /// Allowing a `<perspective-viewer>` to be garbage-collected
711 /// without calling [`PerspectiveViewerElement::delete`] will leak WASM
712 /// memory!
713 ///
714 /// </div>
715 ///
716 /// # JavaScript Examples
717 ///
718 /// ```javascript
719 /// await viewer.delete();
720 /// ```
721 pub fn delete(self) -> ApiFuture<()> {
722 let subs = std::mem::take(&mut *self.hosted_table_subs.borrow_mut());
723 let teardown = delete_all(&self.workspace, &self.root);
724 ApiFuture::new(async move {
725 for (client, id) in subs {
726 let _ = client.remove_hosted_tables_update(id).await;
727 }
728
729 teardown.await
730 })
731 }
732
733 /// Remove a [`Client`] from this `<perspective-viewer>` and dispose every
734 /// panel bound to it (each panel's `View` is deleted and its `Table`
735 /// reference released).
736 ///
737 /// # Arguments
738 ///
739 /// - `options` - An optional `{client?: string}` dict naming the client to
740 /// eject; the active panel's client when omitted.
741 ///
742 /// # JavaScript Examples
743 ///
744 /// ```javascript
745 /// await viewer.eject();
746 /// await viewer.eject({client: "remote"});
747 /// ```
748 pub fn eject(&mut self, options: Option<JsClientOptions>) -> ApiFuture<()> {
749 let ClientOptions { client } = match parse_options(options) {
750 Ok(x) => x,
751 Err(x) => return ApiFuture::new_err(x),
752 };
753
754 // Default target: the active panel's client, or — when the active panel
755 // is unbound (`load(Client)` is now inert) — the default client.
756 let Some(target) = client
757 .or_else(|| {
758 self.workspace
759 .active_client()
760 .map(|c| c.get_name().to_owned())
761 })
762 .or_else(|| {
763 self.workspace
764 .default_client()
765 .map(|c| c.get_name().to_owned())
766 })
767 else {
768 return ApiFuture::new_throttled(async move { Ok(()) });
769 };
770
771 let ids = self.workspace.panels_for_client(&target);
772
773 // The target client backs EVERY panel — reset the element to its
774 // pre-`load` state (dropping the client with it), as a `Workspace`
775 // must always keep at least one panel.
776 if !ids.is_empty() && ids.len() == self.workspace.panel_ids().len() {
777 let mut state = Self::new_from_shadow(
778 self.elem.clone(),
779 self.elem.shadow_root().unwrap().unchecked_into(),
780 );
781
782 std::mem::swap(self, &mut state);
783 return ApiFuture::new_throttled(state.delete());
784 }
785
786 eject_client_panels(&self.workspace, &self.root, target, ids)
787 }
788
789 /// Get a [`View`] for a panel of this viewer, as currently configured by
790 /// the user.
791 ///
792 /// # Arguments
793 ///
794 /// - `mode` - which [`View`] to return:
795 /// - `"live"` (default) - the panel's own [`View`], which the viewer
796 /// replaces on every config change and deletes when auto-paused or
797 /// [`PerspectiveViewerElement::delete`]d, so calls on it may fail with
798 /// "View already deleted", it must never be deleted by the caller, and
799 /// it rejects with `No View for panel "<name>"` while none is bound
800 /// (auto-paused or not yet rendered).
801 /// - `"clone"` - a new [`View`] built from the panel's effective config
802 /// (global filter included), independent of the render lifecycle, which
803 /// the caller owns and must [`View::delete`].
804 /// - `"auto"` - `"live"` when the panel has a bound [`View`], else
805 /// `"clone"`.
806 /// - `panel` - the target panel, or the active panel when omitted.
807 ///
808 /// Plugins which rely on the live [`View`] for their
809 /// [`HTMLPerspectiveViewerPluginElement::draw`] implementations should
810 /// treat "View already deleted" as a _cancellation_ by silently aborting.
811 ///
812 /// # JavaScript Examples
813 ///
814 /// ```javascript
815 /// const view = await viewer.getView();
816 /// ```
817 ///
818 /// Export every panel without borrowing the viewer's own [`View`]:
819 ///
820 /// ```javascript
821 /// for (const panel of viewer.getPanelNames()) {
822 /// const view = await viewer.getView({ panel, mode: "clone" });
823 /// const arrow = await view.to_arrow();
824 /// await view.delete();
825 /// }
826 /// ```
827 #[wasm_bindgen]
828 pub fn getView(&self, options: Option<JsGetViewOptions>) -> ApiFuture<View> {
829 let this = self.clone();
830 ApiFuture::new(async move {
831 let GetViewOptions { mode, panel: name } = parse_options(options)?;
832 let panel = this.resolve_panel(name)?;
833 match (mode.unwrap_or_default(), panel.session.get_view()) {
834 (GetViewMode::Live | GetViewMode::Auto, Some(view)) => Ok(view.into()),
835 (GetViewMode::Live, None) => {
836 Err(format!("No View for panel \"{}\"", panel.id.as_str()).into())
837 },
838 (GetViewMode::Clone, _) | (GetViewMode::Auto, None) => {
839 Ok(panel.session.create_detached_view().await?.into())
840 },
841 }
842 })
843 }
844
845 /// Get a copy of the [`ViewConfig`] for the current [`View`]. This is
846 /// non-blocking as it does not need to access the plugin (unlike
847 /// [`PerspectiveViewerElement::save`]), and also makes no API calls to the
848 /// server (unlike [`PerspectiveViewerElement::getView`] followed by
849 /// [`View::get_config`])
850 #[wasm_bindgen]
851 pub fn getViewConfig(&self, options: Option<JsPanelOptions>) -> ApiFuture<JsViewConfig> {
852 let this = self.clone();
853 ApiFuture::new(async move {
854 let PanelOptions { panel: name } = parse_options(options)?;
855 let panel = this.resolve_panel(name)?;
856 let config = if let Some(ctx) = panel.renderer.render_context() {
857 (*ctx.view_config).clone()
858 } else if let Some(rendered) = panel.session.get_rendered_view_config() {
859 (*rendered).clone()
860 } else {
861 panel.session.get_view_config().clone()
862 };
863
864 Ok(JsValue::from_serde_ext(&config)?.unchecked_into())
865 })
866 }
867
868 /// Get the underlying [`Table`] for this viewer (as passed to
869 /// [`PerspectiveViewerElement::load`] or as the `table` field to
870 /// [`PerspectiveViewerElement::restore`]).
871 ///
872 /// # Arguments
873 ///
874 /// - `wait_for_table` - whether to wait for
875 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
876 /// if [`PerspectiveViewerElement::load`] has not yet been called.
877 ///
878 /// # JavaScript Examples
879 ///
880 /// ```javascript
881 /// const table = await viewer.getTable();
882 /// ```
883 #[wasm_bindgen]
884 pub fn getTable(&self, options: Option<JsGetTableOptions>) -> ApiFuture<Table> {
885 let this = self.clone();
886 ApiFuture::new(async move {
887 let GetTableOptions {
888 wait: wait_for_table,
889 panel: name,
890 } = parse_options(options)?;
891 let panel = this.resolve_panel(name)?;
892 if !wait_for_table.unwrap_or_default()
893 && let Some(ctx) = panel.renderer.render_context()
894 {
895 return Ok(ctx.table.clone().into());
896 }
897
898 let session = panel.session;
899 match session.get_table() {
900 Some(table) => Ok(table.into()),
901 None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
902 None => {
903 session.table_loaded.read_next().await?;
904 Ok(session.get_table().ok_or("No `Table` set")?.into())
905 },
906 }
907 })
908 }
909
910 /// Get the underlying [`Client`] for this viewer (as passed to, or
911 /// associated with the [`Table`] passed to,
912 /// [`PerspectiveViewerElement::load`]).
913 ///
914 /// # Arguments
915 ///
916 /// - `wait_for_client` - whether to wait for
917 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
918 /// if [`PerspectiveViewerElement::load`] has not yet been called.
919 ///
920 /// # JavaScript Examples
921 ///
922 /// ```javascript
923 /// const client = await viewer.getClient();
924 /// ```
925 #[wasm_bindgen]
926 pub fn getClient(
927 &self,
928 options: Option<JsGetClientOptions>,
929 ) -> ApiFuture<perspective_js::Client> {
930 let this = self.clone();
931 ApiFuture::new(async move {
932 let GetClientOptions {
933 wait: wait_for_client,
934 panel: name,
935 } = parse_options(options)?;
936 let panel = this.resolve_panel(name)?;
937 if !wait_for_client.unwrap_or_default()
938 && let Some(ctx) = panel.renderer.render_context()
939 {
940 return Ok(ctx.client.clone().into());
941 }
942
943 let session = panel.session;
944 match session.get_client() {
945 Some(client) => Ok(client.into()),
946 None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
947 None => {
948 session.table_loaded.read_next().await?;
949 Ok(session.get_client().ok_or("No `Client` set")?.into())
950 },
951 }
952 })
953 }
954
955 /// Get render statistics. Some fields of the returned stats object are
956 /// relative to the last time [`PerspectiveViewerElement::getRenderStats`]
957 /// was called, ergo calling this method resets these fields.
958 ///
959 /// # JavaScript Examples
960 ///
961 /// ```javascript
962 /// const {virtual_fps, actual_fps} = await viewer.getRenderStats();
963 /// ```
964 #[wasm_bindgen]
965 pub fn getRenderStats(&self, options: Option<JsPanelOptions>) -> ApiResult<JsValue> {
966 let PanelOptions { panel: name } = parse_options(options)?;
967 let panel = self.resolve_panel(name)?;
968 Ok(JsValue::from_serde_ext(
969 &panel.renderer.render_timer().get_stats(),
970 )?)
971 }
972
973 /// Flush any pending modifications to this `<perspective-viewer>`. Since
974 /// `<perspective-viewer>`'s API is almost entirely `async`, it may take
975 /// some milliseconds before any user-initiated changes to the [`View`]
976 /// affects the rendered element. If you want to make sure all pending
977 /// actions have been rendered, call and await [`Self::flush`].
978 ///
979 /// [`Self::flush`] will resolve immediately if there is no [`Table`] set.
980 ///
981 /// # JavaScript Examples
982 ///
983 /// In this example, [`Self::restore`] is called without `await`, but the
984 /// eventual render which results from this call can still be awaited by
985 /// immediately awaiting [`Self::flush`] instead.
986 ///
987 /// ```javascript
988 /// viewer.restore(config);
989 /// await viewer.flush();
990 /// ```
991 pub fn flush(&self) -> ApiFuture<()> {
992 let workspace = self.workspace.clone();
993 let presentation = self.presentation.clone();
994 ApiFuture::new_throttled(async move {
995 loop {
996 workspace.effects().settle().await;
997 let panels = workspace
998 .reserved_panel()
999 .into_iter()
1000 .chain(workspace.panels())
1001 .collect::<Vec<_>>();
1002
1003 let mut fulfilled = false;
1004 for panel in &panels {
1005 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
1006 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
1007 panel.session.settle_dispatches().await?;
1008 if !global::document().hidden()
1009 && presentation.is_visible()
1010 && !panel.renderer.is_plugin_activated()?
1011 && panel.session.get_error().is_none()
1012 && matches!(panel.session.has_table(), Some(TableLoadState::Loaded))
1013 {
1014 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1015 .await?;
1016 if !panel.renderer.is_plugin_activated()? {
1017 just_render(&panel.session, &panel.renderer)?.await?;
1018 }
1019
1020 fulfilled = true;
1021 }
1022 }
1023
1024 if !fulfilled && workspace.effects().is_empty() {
1025 return Ok(());
1026 }
1027 }
1028 })
1029 }
1030
1031 /// Restore a single panel from a full/partial
1032 /// [`perspective_js::JsViewConfig`] (its user-configurable state, including
1033 /// the `Table` name) — the active panel, or a specific panel via the
1034 /// optional `{panel}` selector.
1035 ///
1036 /// If `panel` names no existing panel, a NEW panel is created with that id
1037 /// and the config restored into it (an upsert). Creation REQUIRES a
1038 /// `table` — the same rule [`Self::addPanel`] enforces in its argument
1039 /// type — and a would-create call without one REJECTS before any state
1040 /// (including `settings`) is applied: with no panel to target and no
1041 /// `table`, the patch has no data arrival path. In particular, on an
1042 /// element with zero panels every `restore` must carry a `table`.
1043 ///
1044 /// On an empty element with a pending [`Self::load`] whose payload is not
1045 /// yet classified, the active-target form (no `panel`) instead claims and
1046 /// restores into that load's reserved first panel — see [`Self::load`].
1047 ///
1048 /// This restores a SINGLE panel; a workspace config (with a `panels`
1049 /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` /
1050 /// `layout` keys are ignored here.
1051 ///
1052 /// One of the best ways to use [`Self::restore`] is by first configuring
1053 /// a `<perspective-viewer>` as you wish, then using either the `Debug`
1054 /// panel or "Copy" -> "config.json" from the toolbar menu to snapshot
1055 /// the [`Self::restore`] argument as JSON.
1056 ///
1057 /// # Arguments
1058 ///
1059 /// - `update` - The config to restore to, as returned by [`Self::save`] in
1060 /// either "json", "string" or "arraybuffer" format.
1061 /// - `options.panel` - The panel to target, or the active panel when
1062 /// omitted.
1063 /// - `options.suppress_errors` - when `true`, a failed restore only rejects
1064 /// the returned `Promise` and rolls the view config back, leaving the
1065 /// viewer's visible error state and any element-level state the call
1066 /// already applied (theme, title, plugin, a successful `table` change)
1067 /// untouched.
1068 /// - `options.wait_for_table` - when `true`, a `table` no loaded client
1069 /// hosts yet leaves the panel empty and pending until the table is
1070 /// created, instead of the default error.
1071 ///
1072 /// # JavaScript Examples
1073 ///
1074 /// Loads a default plugin for the table named `"superstore"`:
1075 ///
1076 /// ```javascript
1077 /// await viewer.restore({table: "superstore"});
1078 /// ```
1079 ///
1080 /// Apply a `group_by` to the same `viewer` element, without
1081 /// modifying/resetting other fields - you can omit the `table` field,
1082 /// this has already been set once and is not modified:
1083 ///
1084 /// ```javascript
1085 /// await viewer.restore({group_by: ["State"]});
1086 /// ```
1087 pub fn restore(
1088 &self,
1089 update: JsViewerConfigUpdate,
1090 options: Option<JsRestoreOptions>,
1091 ) -> JsVoidPromise {
1092 let effect = self.workspace.effects().guard();
1093 let this = self.clone();
1094 let fut = ApiFuture::new_throttled(async move {
1095 let RestoreOptions {
1096 panel: name,
1097 suppress_errors,
1098 wait_for_table,
1099 } = parse_options(options)?;
1100
1101 let errors = if suppress_errors.unwrap_or_default() {
1102 RestoreErrors::Suppress
1103 } else {
1104 RestoreErrors::Publish
1105 };
1106
1107 let missing = MissingTable::from_wait(wait_for_table);
1108
1109 let _effect = effect;
1110 let id = name.map(PanelId::from);
1111 let mut update = ViewerConfigUpdate::decode(&update)?;
1112 let settings = std::mem::replace(&mut update.settings, OptionalUpdate::Missing);
1113 enum Target {
1114 Existing { panel: Panel, active: bool },
1115 Claimed(Panel),
1116 Create(Box<ViewerConfigInitial>),
1117 }
1118
1119 let notify = this.layout_changed_notify();
1120 let target = match this.workspace.panel_or_active(id.as_ref()) {
1121 // An existing (or the active) panel — update it in place.
1122 Some(panel) => {
1123 let active = this.workspace.active_id().as_ref() == Some(&panel.id);
1124 Target::Existing { panel, active }
1125 },
1126 None => {
1127 let has_table = matches!(&update.table, OptionalUpdate::Update(_));
1128 match id
1129 .is_none()
1130 .then(|| place_reserved(&this.workspace, ¬ify, has_table))
1131 .flatten()
1132 {
1133 Some(panel) => Target::Claimed(panel),
1134 None => Target::Create(Box::new(ViewerConfigInitial::try_from(
1135 std::mem::take(&mut update),
1136 )?)),
1137 }
1138 },
1139 };
1140
1141 if !matches!(settings, OptionalUpdate::Missing) {
1142 let (sender, receiver) = channel::<ApiResult<JsValue>>();
1143 this.root.borrow().as_ref().into_apierror()?.send_message(
1144 PerspectiveViewerMsg::ToggleSettingsInit(Some(settings), false, Some(sender)),
1145 );
1146
1147 receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
1148 }
1149
1150 match target {
1151 Target::Existing { panel, active } => {
1152 restore_panel(
1153 &panel.session,
1154 &panel.renderer,
1155 &this.presentation,
1156 &this.workspace,
1157 RestoreMode::Existing { active },
1158 update,
1159 errors,
1160 missing,
1161 )
1162 .await
1163 },
1164 Target::Claimed(panel) => {
1165 restore_panel(
1166 &panel.session,
1167 &panel.renderer,
1168 &this.presentation,
1169 &this.workspace,
1170 RestoreMode::Existing { active: true },
1171 update,
1172 errors,
1173 missing,
1174 )
1175 .await
1176 },
1177 Target::Create(config) => {
1178 create_panel(
1179 &this.elem,
1180 &this.presentation,
1181 &this.workspace,
1182 ¬ify,
1183 id,
1184 *config,
1185 None,
1186 missing,
1187 )
1188 .await?;
1189 Ok(())
1190 },
1191 }
1192 });
1193
1194 js_sys::Promise::from(fut).unchecked_into()
1195 }
1196
1197 /// Apply a [`WorkspaceConfigUpdate`] to the element — the multi-panel
1198 /// counterpart of [`Self::restore`] and like it a field-wise update, where
1199 /// an absent field is unchanged, `null` resets and a value replaces.
1200 ///
1201 /// # Arguments
1202 ///
1203 /// - `update` - The workspace config update: a [`Self::saveWorkspace`]
1204 /// token replaces every panel (`panels` is the whole set), while a token
1205 /// without `panels` keeps them and `layout`, `active` and `masters` name
1206 /// them by their existing ids.
1207 /// - `options.wait_for_table` - when `true`, a panel whose `table` no
1208 /// loaded client hosts yet is created empty and pending until the table
1209 /// is created, instead of the default error.
1210 ///
1211 /// # JavaScript Examples
1212 ///
1213 /// ```javascript
1214 /// await viewer.restoreWorkspace(await otherViewer.saveWorkspace());
1215 /// ```
1216 ///
1217 /// Cross-filter the current panels without re-creating them:
1218 ///
1219 /// ```javascript
1220 /// await viewer.restoreWorkspace({ global_filters: [["Region", "==", "West"]] });
1221 /// ```
1222 pub fn restoreWorkspace(
1223 &self,
1224 update: JsWorkspaceConfigUpdate,
1225 options: Option<JsRestoreWorkspaceOptions>,
1226 ) -> JsVoidPromise {
1227 let effect = self.workspace.effects().guard();
1228 let this = self.clone();
1229 let fut = ApiFuture::new(async move {
1230 let _effect = effect;
1231 let RestoreWorkspaceOptions { wait_for_table } = parse_options(options)?;
1232 let missing = MissingTable::from_wait(wait_for_table);
1233 let (contents, eject_tasks) = sync_update_panels(&this, update)?;
1234 let fresh = contents
1235 .iter()
1236 .map(|(id, ..)| id.clone())
1237 .collect::<Vec<_>>();
1238 let results = join_all(contents.into_iter().map(|(id, session, renderer, config)| {
1239 let presentation = this.presentation.clone();
1240 let workspace = this.workspace.clone();
1241 async move {
1242 stamp_global_overlay(&workspace, &id, &session);
1243 restore_panel(
1244 &session,
1245 &renderer,
1246 &presentation,
1247 &workspace,
1248 RestoreMode::Fresh,
1249 config,
1250 crate::tasks::RestoreErrors::Publish,
1251 missing,
1252 )
1253 .await?;
1254 if workspace.is_master(&id) {
1255 set_edit_mode(&session, &renderer, "SELECT_ROW_TREE");
1256 }
1257
1258 Ok(())
1259 }
1260 }))
1261 .await;
1262
1263 results.into_iter().collect::<ApiResult<Vec<_>>>()?;
1264 this.rerender_retained_overlays(&fresh).await?;
1265 join_all(eject_tasks)
1266 .await
1267 .into_iter()
1268 .collect::<ApiResult<Vec<_>>>()?;
1269
1270 Ok(())
1271 });
1272
1273 js_sys::Promise::from(fut).unchecked_into()
1274 }
1275
1276 /// If this element is in an _errored_ state, this method will clear it and
1277 /// re-render. Calling this method is equivalent to clicking the error reset
1278 /// button in the UI.
1279 pub fn resetError(&self) -> ApiFuture<()> {
1280 let Some(panel) = self.workspace.active_panel() else {
1281 return ApiFuture::new_throttled(async move { Ok(()) });
1282 };
1283
1284 let reset_effect = self.workspace.effects().guard();
1285 let reset_task = panel.session.reset(ResetOptions::default());
1286 ApiFuture::spawn(async move {
1287 let _effect = reset_effect;
1288 reset_task.await
1289 });
1290
1291 let effect = self.workspace.effects().guard();
1292 ApiFuture::new_throttled(async move {
1293 let _effect = effect;
1294 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?;
1295 Ok(())
1296 })
1297 }
1298
1299 /// Save a single panel's user-configurable state as a [`ViewerConfig`], one
1300 /// which can be restored via [`Self::restore`] — the active panel, or a
1301 /// specific panel via the optional `{panel}` selector.
1302 ///
1303 /// This saves a SINGLE panel; to snapshot the ENTIRE element (every panel +
1304 /// layout + cross-filters) use [`Self::saveWorkspace`].
1305 ///
1306 /// # Arguments
1307 ///
1308 /// - `options` - An optional `{panel?: string}`; the panel to save, or the
1309 /// active panel when omitted.
1310 ///
1311 /// # JavaScript Examples
1312 ///
1313 /// Get the current `group_by` setting:
1314 ///
1315 /// ```javascript
1316 /// const {group_by} = await viewer.save();
1317 /// ```
1318 ///
1319 /// Reset workflow attached to an external button `myResetButton`:
1320 ///
1321 /// ```javascript
1322 /// const token = await viewer.save();
1323 /// myResetButton.addEventListener("click", async () => {
1324 /// await viewer.restore(token);
1325 /// });
1326 /// ```
1327 pub fn save(&self, options: Option<JsPanelOptions>) -> JsViewerConfigPromise {
1328 let this = self.clone();
1329 let fut = ApiFuture::new(async move {
1330 let PanelOptions { panel: name } = parse_options(options)?;
1331 this.workspace.effects().settle().await;
1332 let panel = this.resolve_panel(name)?;
1333 let viewer_config = panel
1334 .renderer
1335 .clone()
1336 .with_lock(async {
1337 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
1338 })
1339 .await?;
1340
1341 viewer_config.encode()
1342 });
1343
1344 js_sys::Promise::from(fut).unchecked_into()
1345 }
1346
1347 /// Save the ENTIRE element to a [`WorkspaceConfig`]
1348 /// (`{version, active?, layout, panels, palette?, ...}`) — the
1349 /// multi-panel counterpart of [`Self::save`]. Unlike [`Self::save`]
1350 /// (which emits a single `ViewerConfig` for one panel), this ALWAYS
1351 /// emits the workspace format, restorable via
1352 /// [`Self::restoreWorkspace`].
1353 ///
1354 /// # JavaScript Examples
1355 ///
1356 /// ```javascript
1357 /// const token = await viewer.saveWorkspace();
1358 /// await viewer.restoreWorkspace(token);
1359 /// ```
1360 pub fn saveWorkspace(
1361 &self,
1362 options: Option<JsSaveWorkspaceOptions>,
1363 ) -> JsWorkspaceConfigPromise {
1364 let this = self.clone();
1365 let fut = ApiFuture::new(async move {
1366 let SaveWorkspaceOptions { full_palette } = parse_options(options)?;
1367 Self::workspace_config(this, full_palette.unwrap_or(false)).await
1368 });
1369
1370 js_sys::Promise::from(fut).unchecked_into()
1371 }
1372
1373 /// Download this viewer's internal [`View`] data via a browser download
1374 /// event.
1375 ///
1376 /// # Arguments
1377 ///
1378 /// - `method` - The `ExportMethod` to use to render the data to download.
1379 ///
1380 /// # JavaScript Examples
1381 ///
1382 /// ```javascript
1383 /// myDownloadButton.addEventListener("click", async () => {
1384 /// await viewer.download();
1385 /// })
1386 /// ```
1387 pub fn download(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1388 let this = self.clone();
1389 ApiFuture::new_throttled(async move {
1390 let ExportOptions {
1391 method,
1392 panel: name,
1393 } = parse_options(options)?;
1394 let method = method.map(|m| JsString::from(m.as_str()));
1395 let method = if let Some(method) = method
1396 .map(|x| x.unchecked_into())
1397 .map(serde_wasm_bindgen::from_value)
1398 {
1399 method?
1400 } else {
1401 ExportMethod::Csv
1402 };
1403
1404 let panel = this.resolve_panel(name)?;
1405 let blob =
1406 export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method)
1407 .await?;
1408 let is_chart = panel.renderer.is_chart();
1409 download(
1410 format!("untitled{}", method.as_filename(is_chart)).as_ref(),
1411 &blob,
1412 )
1413 })
1414 }
1415
1416 /// Exports this viewer's internal [`View`] as a JavaSript data, the
1417 /// exact type of which depends on the `method` but defaults to `String`
1418 /// in CSV format.
1419 ///
1420 /// This method is only really useful for the `"plugin"` method, which
1421 /// will use the configured plugin's export (e.g. PNG for
1422 /// `@perspective-dev/viewer-charts`). Otherwise, prefer to call the
1423 /// equivalent method on the underlying [`View`] directly.
1424 ///
1425 /// # Arguments
1426 ///
1427 /// - `method` - The `ExportMethod` to use to render the data to download.
1428 ///
1429 /// # JavaScript Examples
1430 ///
1431 /// ```javascript
1432 /// const data = await viewer.export("plugin");
1433 /// ```
1434 pub fn export(&self, options: Option<JsExportOptions>) -> ApiFuture<JsValue> {
1435 let this = self.clone();
1436 ApiFuture::new(async move {
1437 let ExportOptions {
1438 method,
1439 panel: name,
1440 } = parse_options(options)?;
1441
1442 let method = method.map(|m| JsString::from(m.as_str()));
1443 let method = if let Some(method) = method
1444 .map(|x| x.unchecked_into())
1445 .map(serde_wasm_bindgen::from_value)
1446 {
1447 method?
1448 } else {
1449 ExportMethod::Csv
1450 };
1451
1452 let panel = this.resolve_panel(name)?;
1453 export_method_to_jsvalue(&panel.session, &panel.renderer, &this.presentation, method)
1454 .await
1455 })
1456 }
1457
1458 /// Copy this viewer's `View` or `Table` data as CSV to the system
1459 /// clipboard.
1460 ///
1461 /// # Arguments
1462 ///
1463 /// - `method` - The `ExportMethod` (serialized as a `String`) to use to
1464 /// render the data to the Clipboard.
1465 ///
1466 /// # JavaScript Examples
1467 ///
1468 /// ```javascript
1469 /// myDownloadButton.addEventListener("click", async () => {
1470 /// await viewer.copy();
1471 /// })
1472 /// ```
1473 pub fn copy(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1474 let this = self.clone();
1475 ApiFuture::new_throttled(async move {
1476 let ExportOptions {
1477 method,
1478 panel: name,
1479 } = parse_options(options)?;
1480
1481 let method = method.map(|m| JsString::from(m.as_str()));
1482 let method = if let Some(method) = method
1483 .map(|x| x.unchecked_into())
1484 .map(serde_wasm_bindgen::from_value)
1485 {
1486 method?
1487 } else {
1488 ExportMethod::Csv
1489 };
1490
1491 let panel = this.resolve_panel(name)?;
1492 let js_task =
1493 export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method);
1494 copy_to_clipboard(js_task, MimeType::TextPlain).await
1495 })
1496 }
1497
1498 /// Reset a panel's `ViewerConfig` to its data-relative default.
1499 ///
1500 /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the
1501 /// cross-filter overlay cleared (symmetric with
1502 /// [`Self::saveWorkspace`] / [`Self::restoreWorkspace`]). With `{panel}`,
1503 /// only that panel is reset — the other panels and the overlay are left
1504 /// untouched.
1505 ///
1506 /// # Arguments
1507 ///
1508 /// - `reset_all` - If set, will clear expressions and column settings as
1509 /// well.
1510 /// - `options` - An optional `{panel?: string}`; the panel to reset, or
1511 /// every panel when omitted.
1512 ///
1513 /// # JavaScript Examples
1514 ///
1515 /// ```javascript
1516 /// await viewer.reset(); // every panel
1517 /// await viewer.reset(true, {panel: "p1"}); // just "p1", + expressions
1518 /// ```
1519 pub fn reset(&self, reset_all: Option<bool>, options: Option<JsPanelOptions>) -> ApiFuture<()> {
1520 let effect = self.workspace.effects().guard();
1521 let this = self.clone();
1522 let all = reset_all.unwrap_or_default();
1523 ApiFuture::new_throttled(async move {
1524 let PanelOptions { panel: name } = parse_options(options)?;
1525 let _effect = effect;
1526 let (completion, receiver) = Completion::new();
1527 {
1528 let root = this.root.borrow();
1529 let app = root.as_ref().ok_or("Already deleted")?;
1530 match name {
1531 // Element-level: reset every panel + the cross-filter overlay.
1532 None => {
1533 tracing::debug!("Resetting config");
1534 app.send_message(PerspectiveViewerMsg::Reset(all, Some(completion)));
1535 },
1536 // A single named panel; errors if the panel doesn't exist.
1537 Some(name) => {
1538 let panel = this.resolve_panel(Some(name))?;
1539 tracing::debug!("Resetting config ({})", panel.id);
1540 app.send_message(PerspectiveViewerMsg::ResetPanel(
1541 Some(panel.id.to_string()),
1542 all,
1543 Some(completion),
1544 ));
1545 },
1546 }
1547 }
1548
1549 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1550 })
1551 }
1552
1553 /// Recalculate the viewer's dimensions and redraw.
1554 ///
1555 /// Use this method to tell `<perspective-viewer>` its dimensions have
1556 /// changed when auto-size mode has been disabled via [`Self::setAutoSize`].
1557 /// [`Self::resize`] resolves when the resize-initiated redraw of this
1558 /// element has completed.
1559 ///
1560 /// # Arguments
1561 ///
1562 /// - `options` - An optional object with the following fields:
1563 /// - `dimensions` - An optional object `{width, height}` providing
1564 /// explicit size hints (in pixels) for the plugin container. When
1565 /// provided, the plugin element will be temporarily sized to these
1566 /// dimensions during resize, then reset.
1567 ///
1568 /// # JavaScript Examples
1569 ///
1570 /// ```javascript
1571 /// await viewer.resize()
1572 /// await viewer.resize({dimensions: {width: 800, height: 600}})
1573 /// ```
1574 #[wasm_bindgen]
1575 pub fn resize(&self, options: Option<JsValue>) -> ApiFuture<()> {
1576 let opts: ResizeOptions = options
1577 .map(|v| v.into_serde_ext())
1578 .transpose()
1579 .unwrap_or_default()
1580 .unwrap_or_default();
1581
1582 let effect = self.workspace.effects().guard();
1583 let workspace = self.workspace.clone();
1584 ApiFuture::new_throttled(async move {
1585 let _effect = effect;
1586 // With zero panels there is nothing to resize; fan out to whatever
1587 // panels exist otherwise.
1588 let Some(panel) = workspace.active_panel() else {
1589 resize_visible_panels(&workspace).await;
1590 return Ok(());
1591 };
1592
1593 if !panel.renderer.is_plugin_activated()? {
1594 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?
1595 .await?;
1596 } else if let Some(dims) = opts.dimensions {
1597 panel
1598 .renderer
1599 .resize_with_dimensions(dims.width, dims.height)
1600 .await?;
1601 } else {
1602 resize_visible_panels(&workspace).await;
1603 }
1604
1605 Ok(())
1606 })
1607 }
1608
1609 /// Sets the auto-size behavior of this component.
1610 ///
1611 /// When `true`, this `<perspective-viewer>` will register a
1612 /// `ResizeObserver` on itself and call [`Self::resize`] whenever its own
1613 /// dimensions change. However, when embedded in a larger application
1614 /// context, you may want to call [`Self::resize`] manually to avoid
1615 /// over-rendering; in this case auto-sizing can be disabled via this
1616 /// method. Auto-size behavior is enabled by default.
1617 ///
1618 /// # Arguments
1619 ///
1620 /// - `autosize` - Whether to enable `auto-size` behavior or not.
1621 ///
1622 /// # JavaScript Examples
1623 ///
1624 /// Disable auto-size behavior:
1625 ///
1626 /// ```javascript
1627 /// viewer.setAutoSize(false);
1628 /// ```
1629 #[wasm_bindgen]
1630 pub fn setAutoSize(&self, autosize: bool) {
1631 if autosize {
1632 let handle = Some(ResizeObserverHandle::new(
1633 &self.elem,
1634 &self.workspace,
1635 &self.presentation,
1636 &self.root,
1637 ));
1638 *self.resize_handle.borrow_mut() = handle;
1639 } else {
1640 *self.resize_handle.borrow_mut() = None;
1641 }
1642 }
1643
1644 /// Sets the auto-pause behavior of this component.
1645 ///
1646 /// When `true`, this `<perspective-viewer>` will skip rendering
1647 /// whenever it cannot be seen — tracked via an `IntersectionObserver`
1648 /// on itself (scrolled out of the viewport, `display: none`) combined
1649 /// with the document's page visibility (backgrounded browser tab,
1650 /// minimized window). Auto-pause is enabled by default.
1651 ///
1652 /// # Arguments
1653 ///
1654 /// - `autopause` Whether to enable `auto-pause` behavior or not.
1655 ///
1656 /// # JavaScript Examples
1657 ///
1658 /// Disable auto-size behavior:
1659 ///
1660 /// ```javascript
1661 /// viewer.setAutoPause(false);
1662 /// ```
1663 #[wasm_bindgen]
1664 pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
1665 if autopause {
1666 let handle = Some(AutoPauseHandle::new(
1667 &self.elem,
1668 &self.presentation,
1669 &self.workspace,
1670 ));
1671
1672 *self.intersection_handle.borrow_mut() = handle;
1673 } else {
1674 *self.intersection_handle.borrow_mut() = None;
1675 let effect = self.workspace.effects().guard();
1676 let workspace = self.workspace.clone();
1677 let presentation = self.presentation.clone();
1678 return ApiFuture::new(async move {
1679 let _effect = effect;
1680 for id in workspace.panel_ids() {
1681 if let Some(panel) = workspace.panel(&id) {
1682 // A failed resume is already surfaced as that
1683 // panel's error state — don't let it abort the
1684 // remaining panels' resumes.
1685 let _ =
1686 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1687 .await;
1688 }
1689 }
1690
1691 Ok(())
1692 });
1693 }
1694
1695 ApiFuture::new(async move { Ok(()) })
1696 }
1697
1698 /// Return a [`perspective_js::JsViewWindow`] for the currently selected
1699 /// region of the named panel, or the active panel when `panel` is omitted.
1700 #[wasm_bindgen]
1701 pub fn getSelection(&self, options: Option<JsPanelOptions>) -> ApiResult<Option<JsViewWindow>> {
1702 let PanelOptions { panel: name } = parse_options(options)?;
1703 let panel = self.resolve_panel(name)?;
1704 Ok(panel.renderer.get_selection().map(|x| x.into()))
1705 }
1706
1707 /// Set the selection [`perspective_js::JsViewWindow`] for the named panel,
1708 /// or the active panel when `panel` is omitted.
1709 #[wasm_bindgen]
1710 pub fn setSelection(
1711 &self,
1712 window: Option<JsViewWindow>,
1713 options: Option<JsPanelOptions>,
1714 ) -> ApiResult<()> {
1715 let PanelOptions { panel: name } = parse_options(options)?;
1716 let window = window.map(|x| x.into_serde_ext()).transpose()?;
1717 self.resolve_panel(name)?.renderer.set_selection(window);
1718 Ok(())
1719 }
1720
1721 /// Get this viewer's edit port for the named panel's [`Table`] (see
1722 /// [`Table::update`] for details on ports), or the active panel when
1723 /// `panel` is omitted.
1724 #[wasm_bindgen]
1725 pub fn getEditPort(&self, options: Option<JsPanelOptions>) -> ApiResult<f64> {
1726 let PanelOptions { panel: name } = parse_options(options)?;
1727 let panel = self.resolve_panel(name)?;
1728 let edit_port = if let Some(ctx) = panel.renderer.render_context() {
1729 ctx.edit_port
1730 } else {
1731 panel.session.metadata().get_edit_port()
1732 };
1733
1734 Ok(edit_port.ok_or("No `Table` loaded")?)
1735 }
1736
1737 /// Restyle all plugins from current document.
1738 ///
1739 /// <div class="warning">
1740 ///
1741 /// [`Self::restyleElement`] _must_ be called for many runtime changes to
1742 /// CSS properties to be reflected in an already-rendered
1743 /// `<perspective-viewer>`.
1744 ///
1745 /// </div>
1746 ///
1747 /// # JavaScript Examples
1748 ///
1749 /// ```javascript
1750 /// viewer.style = "--psp--color: red";
1751 /// await viewer.restyleElement();
1752 /// ```
1753 #[wasm_bindgen]
1754 pub fn restyleElement(&self) -> ApiFuture<JsValue> {
1755 clone!(self.workspace);
1756 let effect = workspace.effects().guard();
1757 ApiFuture::new(async move {
1758 let _effect = effect;
1759 for panel in workspace
1760 .panel_ids()
1761 .into_iter()
1762 .filter_map(|id| workspace.panel(&id))
1763 {
1764 panel.renderer.restyle_all().await?;
1765 }
1766
1767 Ok(JsValue::UNDEFINED)
1768 })
1769 }
1770
1771 #[wasm_bindgen]
1772 pub fn getThemes(&self) -> ApiFuture<JsValue> {
1773 clone!(self.presentation);
1774 ApiFuture::new(async move {
1775 let x = presentation
1776 .get_available_themes()
1777 .await?
1778 .iter()
1779 .cloned()
1780 .collect::<Vec<_>>();
1781
1782 Ok(JsValue::from(x))
1783 })
1784 }
1785
1786 /// Set the available theme names available in the status bar UI.
1787 ///
1788 /// Calling [`Self::resetThemes`] may cause the current theme to switch,
1789 /// if e.g. the new theme set does not contain the current theme.
1790 ///
1791 /// # JavaScript Examples
1792 ///
1793 /// Restrict `<perspective-viewer>` theme options to _only_ default light
1794 /// and dark themes, regardless of what is auto-detected from the page's
1795 /// CSS:
1796 ///
1797 /// ```javascript
1798 /// viewer.resetThemes(["Pro Light", "Pro Dark"])
1799 /// ```
1800 #[wasm_bindgen]
1801 pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
1802 clone!(self.workspace, self.presentation);
1803 let effect = workspace.effects().guard();
1804 ApiFuture::new(async move {
1805 let _effect = effect;
1806 // `None` (re-parse the document) must survive the conversion —
1807 // mapping BEFORE defaulting is what keeps that branch reachable
1808 // from JavaScript at all.
1809 let themes: Option<Vec<String>> = match themes {
1810 None => None,
1811 Some(themes) => themes.iter().map(|x| x.as_string()).collect(),
1812 };
1813
1814 let previous = presentation.active_theme_name_sync();
1815 let active = presentation.reset_themes(themes).await?;
1816 let available = presentation.get_available_themes().await?;
1817 for panel in workspace
1818 .panel_ids()
1819 .into_iter()
1820 .filter_map(|id| workspace.panel(&id))
1821 {
1822 let theme = panel.renderer.theme();
1823
1824 // A panel follows the host only when it was TRACKING it (it
1825 // holds the host's previous theme, which is how every panel
1826 // born without an explicit one starts — and what keeps the
1827 // active panel and the host in agreement), or when its own
1828 // theme has left the registry. A panel explicitly set to a
1829 // different, still-available theme is untouched: re-ordering
1830 // alone must repaint nothing.
1831 let stale = theme.as_ref().is_none_or(|x| !available.contains(x));
1832 if (stale || theme == previous) && theme != active {
1833 panel.renderer.set_theme(active.clone());
1834 if panel.renderer.needs_restyle() {
1835 panel.renderer.restyle_all().await?;
1836 }
1837 }
1838 }
1839
1840 presentation.publish_theme_config().await?;
1841 Ok(JsValue::UNDEFINED)
1842 })
1843 }
1844
1845 /// Determines the render throttling behavior. Can be an integer, for
1846 /// millisecond window to throttle render event; or, if `None`, adaptive
1847 /// throttling will be calculated from the measured render time of the
1848 /// last 5 frames.
1849 ///
1850 /// # Arguments
1851 ///
1852 /// - `throttle` - The throttle rate in milliseconds (f64), or `None` for
1853 /// adaptive throttling.
1854 ///
1855 /// # JavaScript Examples
1856 ///
1857 /// Only draws at most 1 frame/sec:
1858 ///
1859 /// ```rust
1860 /// viewer.setThrottle(1000);
1861 /// ```
1862 #[wasm_bindgen]
1863 pub fn setThrottle(&self, val: Option<f64>) {
1864 for panel in self
1865 .workspace
1866 .panel_ids()
1867 .into_iter()
1868 .filter_map(|id| self.workspace.panel(&id))
1869 {
1870 panel.renderer.set_throttle(val);
1871 }
1872 }
1873
1874 /// Toggle (or force) the config panel open/closed.
1875 ///
1876 /// # Arguments
1877 ///
1878 /// - `force` - Force the state of the panel open or closed, or `None` to
1879 /// toggle.
1880 ///
1881 /// # JavaScript Examples
1882 ///
1883 /// ```javascript
1884 /// await viewer.toggleConfig();
1885 /// ```
1886 #[wasm_bindgen]
1887 pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
1888 let effect = self.workspace.effects().guard();
1889 let root = self.root.clone();
1890 ApiFuture::new(async move {
1891 let _effect = effect;
1892 let force = force.map(SettingsUpdate::Update);
1893 let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1894 root.borrow().as_ref().into_apierror()?.send_message(
1895 PerspectiveViewerMsg::ToggleSettingsInit(force, true, Some(sender)),
1896 );
1897
1898 receiver.await.map_err(|_| JsValue::from("Cancelled"))?
1899 })
1900 }
1901
1902 /// Get an `Array` of all of the plugin custom elements registered for this
1903 /// element. This may not include plugins which called
1904 /// [`registerPlugin`] after the host has rendered for the first time.
1905 #[wasm_bindgen]
1906 pub fn getAllPlugins(&self) -> Array {
1907 self.workspace
1908 .active_renderer()
1909 .map(|r| r.get_all_plugins().iter().collect::<Array>())
1910 .unwrap_or_default()
1911 }
1912
1913 /// Gets a plugin Custom Element with the `name` field, or get the active
1914 /// plugin if no `name` is provided.
1915 ///
1916 /// # Arguments
1917 ///
1918 /// - `name` - The `name` property of a perspective plugin Custom Element,
1919 /// or `None` for the active plugin's Custom Element.
1920 #[wasm_bindgen]
1921 pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
1922 let renderer = self
1923 .workspace
1924 .active_renderer()
1925 .ok_or_else(|| ApiError::new("No active panel"))?;
1926 match name {
1927 None => renderer.ensure_plugin_selected(),
1928 Some(name) => renderer.get_plugin(&name),
1929 }
1930 }
1931
1932 /// Add a new, independent panel to this viewer's layout, rendering the
1933 /// supplied [`ViewerConfigInitial`] into it. Unlike [`Self::restore`]'s
1934 /// update-shaped argument, a new panel has no prior state, so `table`
1935 /// is REQUIRED — a table-less call rejects before the layout is
1936 /// touched. The panel uses the default [`perspective_client::Client`]
1937 /// (the first passed to [`Self::load`]) to resolve its `table`. Returns
1938 /// the generated panel id.
1939 ///
1940 /// The element-level `settings` field does not exist on the argument
1941 /// type (it is shared across the element, not per-panel).
1942 ///
1943 /// # Arguments
1944 ///
1945 /// - `config` - The new panel's initial config, whose `table` is required.
1946 /// - `options.wait_for_table` - when `true`, a `table` no loaded client
1947 /// hosts yet creates the panel empty and pending until the table is
1948 /// created, instead of the default rejection.
1949 #[wasm_bindgen]
1950 pub fn addPanel(
1951 &self,
1952 config: JsViewerConfigInitial,
1953 options: Option<JsAddPanelOptions>,
1954 ) -> ApiFuture<JsValue> {
1955 clone!(self.elem, self.presentation, self.workspace);
1956 let effect = workspace.effects().guard();
1957 let notify = self.layout_changed_notify();
1958 ApiFuture::new(async move {
1959 let _effect = effect;
1960 let AddPanelOptions { wait_for_table } = parse_options(options)?;
1961 let config = ViewerConfigInitial::decode(&config)?;
1962 let id = create_panel(
1963 &elem,
1964 &presentation,
1965 &workspace,
1966 ¬ify,
1967 None,
1968 config,
1969 None,
1970 MissingTable::from_wait(wait_for_table),
1971 )
1972 .await?;
1973 Ok(JsValue::from_str(id.as_str()))
1974 })
1975 }
1976
1977 /// Get the ids of all panels in this viewer's layout, in insertion order.
1978 #[wasm_bindgen]
1979 pub fn getPanelNames(&self) -> Array {
1980 self.workspace
1981 .panel_ids()
1982 .iter()
1983 .map(|id| JsValue::from_str(id.as_str()))
1984 .collect()
1985 }
1986
1987 /// The id of the active panel — the one the settings panel and status-bar
1988 /// toolbar target — or `null` when the element has zero panels.
1989 #[wasm_bindgen]
1990 pub fn getActivePanel(&self) -> JsValue {
1991 self.workspace
1992 .active_id()
1993 .map(|id| JsValue::from_str(id.as_str()))
1994 .unwrap_or(JsValue::NULL)
1995 }
1996
1997 /// Make the panel with id `name` the active panel, re-targeting the
1998 /// settings panel and status-bar toolbar (and the root's
1999 /// session/renderer subscriptions) to its engines. Resolves after the
2000 /// activation-chrome redraws on both sides of the switch have completed
2001 /// (invariant I6).
2002 #[wasm_bindgen]
2003 pub fn setActivePanel(&self, name: String) -> ApiFuture<()> {
2004 let effect = self.workspace.effects().guard();
2005 let root = self.root.clone();
2006 ApiFuture::new(async move {
2007 let _effect = effect;
2008 let (completion, receiver) = Completion::new();
2009 root.borrow()
2010 .as_ref()
2011 .into_apierror()?
2012 .send_message(PerspectiveViewerMsg::SetActivePanel(name, Some(completion)));
2013
2014 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
2015 })
2016 }
2017
2018 /// Remove the panel with id `name` from the layout, disposing its engines
2019 /// (its `View` is deleted and its `Table` reference released). The last
2020 /// remaining panel cannot be removed (resolves as a no-op). Resolves
2021 /// after the panel's teardown run completes, carrying any teardown
2022 /// error — previously fire-and-forget and silently dropped (invariant
2023 /// I6). See also [`Self::addPanel`].
2024 #[wasm_bindgen]
2025 pub fn removePanel(&self, name: String) -> ApiFuture<()> {
2026 let effect = self.workspace.effects().guard();
2027 let root = self.root.clone();
2028 ApiFuture::new(async move {
2029 let _effect = effect;
2030 let (completion, receiver) = Completion::new();
2031 root.borrow()
2032 .as_ref()
2033 .into_apierror()?
2034 .send_message(PerspectiveViewerMsg::ClosePanel(name, Some(completion)));
2035
2036 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
2037 })
2038 }
2039
2040 /// Create a new JavaScript Heap reference for this model instance.
2041 #[doc(hidden)]
2042 #[allow(clippy::use_self)]
2043 #[wasm_bindgen]
2044 pub fn __get_model(&self) -> PerspectiveViewerElement {
2045 self.clone()
2046 }
2047
2048 /// Asynchronously opens the column settings for a specific column.
2049 /// When finished, the `<perspective-viewer>` element will emit a
2050 /// "perspective-toggle-column-settings" CustomEvent.
2051 /// The event's details property has two fields: `{open: bool, column_name?:
2052 /// string}`. The CustomEvent is also fired whenever the user toggles the
2053 /// sidebar manually.
2054 #[wasm_bindgen]
2055 pub fn toggleColumnSettings(
2056 &self,
2057 column_name: String,
2058 options: Option<JsPanelOptions>,
2059 ) -> ApiFuture<()> {
2060 let effect = self.workspace.effects().guard();
2061 let this = self.clone();
2062 ApiFuture::new_throttled(async move {
2063 let PanelOptions { panel: name } = parse_options(options)?;
2064 let _effect = effect;
2065 let panel = this.resolve_panel(name)?;
2066 let was_active = this.workspace.active_id().as_ref() == Some(&panel.id);
2067 let target = {
2068 let config = panel.session.get_view_config();
2069 let metadata = panel.session.metadata();
2070 classify_column(&column_name, &config, &metadata)
2071 .map(|_| crate::presentation::ColumnSettingsTarget::Column(column_name))
2072 };
2073 if !was_active {
2074 this.root.borrow().as_ref().into_apierror()?.send_message(
2075 PerspectiveViewerMsg::SetActivePanel(panel.id.as_str().to_owned(), None),
2076 );
2077 }
2078
2079 let (sender, receiver) = channel::<()>();
2080 this.root.borrow().as_ref().into_apierror()?.send_message(
2081 PerspectiveViewerMsg::OpenColumnSettings {
2082 target,
2083 sender: Some(sender),
2084 toggle: was_active,
2085 },
2086 );
2087
2088 receiver.await.map_err(|_| ApiError::from("Cancelled"))
2089 })
2090 }
2091}
2092
2093#[cfg(feature = "llm-agent")]
2094#[wasm_bindgen]
2095impl PerspectiveViewerElement {
2096 /// Configure the embedded LLM agent (see `prompt()`), replacing any prior
2097 /// configuration and conversation.
2098 ///
2099 /// The agent core is provider-agnostic: one OpenAI-chat-completions
2100 /// protocol over primitive connection fields. Exactly one of
2101 /// `config.url` or `config.engine` is required; the `providers` presets
2102 /// exported by this package are plain spreadable collections of these
2103 /// fields (`{...providers.anthropic, apiKey}`).
2104 ///
2105 /// - `config.url` - a full chat-completions endpoint URL (any
2106 /// OpenAI-compatible service: Anthropic/Gemini compatibility endpoints,
2107 /// LM Studio, Ollama, OpenRouter, a proxy...).
2108 /// - `config.engine` - an in-page engine object with an OpenAI-compatible
2109 /// `chat.completions.create(request)` method (e.g. WebLLM's `MLCEngine`);
2110 /// mutually exclusive with `url`.
2111 /// - `config.headers` - extra request headers, sent verbatim.
2112 /// - `config.apiKey` - sugar for the `Authorization: Bearer` header.
2113 /// - `config.model` - model id sent with each request; local servers and
2114 /// engines generally answer with whatever model is loaded.
2115 /// - `config.name` - cosmetic label for the chat badge (presets set this).
2116 /// - `config.systemPrompt` - extra system-prompt context appended to the
2117 /// agent's built-in instructions.
2118 /// - `config.maxTurns` - max model turns (tool-call rounds + the final
2119 /// answer) per `prompt()` call. Defaults to 16.
2120 /// - `config.docs` - the agent metadata bundle, which supplies the
2121 /// `search_docs` corpus and the rich tool parameter schemas: the packaged
2122 /// `dist/docs/perspective-docs.json` asset as a parsed object (`import
2123 /// docs from "…json" with { type: "json" }`), a `fetch()` `Response`, an
2124 /// `ArrayBuffer`, a JSON string, or a `Promise` of any of those — and/or
2125 /// an inline array of `{title?, text}` entries for host data definitions.
2126 /// Omitted, `search_docs` searches an empty corpus and the parameter
2127 /// schemas degrade to permissive objects.
2128 /// - `config.systemRole` - where the preamble (plus `systemPrompt`) is
2129 /// placed: `"system"` (default) or `"user"`. Some engines refuse a system
2130 /// message alongside `tools` because they substitute their own — WebLLM's
2131 /// Hermes function calling throws `CustomSystemPromptError` on ANY system
2132 /// message — and those need `"user"`, which folds the same text into the
2133 /// opening user turn.
2134 /// - `config.entitlements` - access grants limiting which tools the agent
2135 /// is offered (and may call): any of `"read_view"`, `"configure_view"`,
2136 /// `"manage_layout"`, `"read_docs"`, `"read_data"`. Omitted, all but
2137 /// `"read_data"` are granted; `["read_view", "read_docs"]` yields a
2138 /// read-only agent.
2139 ///
2140 /// # JavaScript Examples
2141 ///
2142 /// ```javascript
2143 /// import { providers } from "@perspective-dev/viewer";
2144 ///
2145 /// viewer.agentConfig({
2146 /// ...providers.anthropic,
2147 /// apiKey: "sk-ant-...",
2148 /// docs: fetch(
2149 /// "node_modules/@perspective-dev/viewer/dist/docs/perspective-docs.json",
2150 /// ),
2151 /// });
2152 /// ```
2153 #[wasm_bindgen(js_name = "agentConfig")]
2154 pub fn agent_config(&self, config: JsValue) -> ApiResult<()> {
2155 let runtime = AgentRuntime::new(&config, self.clone())?;
2156 self.presentation.agent.configure(runtime);
2157 Ok(())
2158 }
2159
2160 /// Run one conversational turn of the embedded LLM agent (configured via
2161 /// `agentConfig()`), resolving with the agent's final text response after
2162 /// any tool calls have been applied to this element. Turns share a
2163 /// conversation history (and the chat sidebar's transcript) until
2164 /// `agentReset()`; a call made while a turn is already running rejects.
2165 /// Tool activity is emitted as `perspective-agent-tool` CustomEvents on
2166 /// this element.
2167 ///
2168 /// # JavaScript Examples
2169 ///
2170 /// ```javascript
2171 /// await viewer.agentPrompt("Show me sales by region as a bar chart");
2172 /// ```
2173 #[wasm_bindgen(js_name = "agentPrompt")]
2174 pub fn agent_prompt(&self, prompt: String) -> js_sys::Promise {
2175 let presentation = self.presentation.clone();
2176 let fut = ApiFuture::new(async move {
2177 Ok(JsValue::from(presentation.agent.run_prompt(prompt).await?))
2178 });
2179
2180 js_sys::Promise::from(fut)
2181 }
2182
2183 /// Clear the agent's conversation (history and chat transcript), keeping
2184 /// its configuration. Cancels any in-flight turn.
2185 #[wasm_bindgen(js_name = "agentReset")]
2186 pub fn agent_reset(&self) -> js_sys::Promise {
2187 let presentation = self.presentation.clone();
2188 let fut = ApiFuture::new(async move {
2189 presentation.agent.reset().await;
2190 Ok(JsValue::UNDEFINED)
2191 });
2192
2193 js_sys::Promise::from(fut)
2194 }
2195}