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