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