#![allow(non_snake_case)]
use std::cell::RefCell;
use std::rc::Rc;
use futures::channel::oneshot::channel;
use js_sys::{Array, JsString};
use perspective_client::config::ViewConfigUpdate;
use perspective_client::utils::PerspectiveResultExt;
use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_derive::try_from_js_option;
use wasm_bindgen_futures::JsFuture;
use web_sys::HtmlElement;
use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
use crate::config::*;
use crate::custom_events::*;
use crate::dragdrop::*;
use crate::js::*;
use crate::presentation::*;
use crate::renderer::*;
use crate::root::Root;
use crate::session::{ResetOptions, Session, TableLoadState};
use crate::tasks::*;
use crate::utils::*;
use crate::*;
#[derive(serde::Deserialize, Default)]
struct ResizeOptions {
dimensions: Option<ResizeDimensions>,
}
#[derive(serde::Deserialize, Clone, Copy)]
struct ResizeDimensions {
width: f64,
height: f64,
}
#[derive(Clone)]
#[wasm_bindgen]
pub struct PerspectiveViewerElement {
elem: HtmlElement,
root: Root<components::viewer::PerspectiveViewer>,
resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
intersection_handle: Rc<RefCell<Option<IntersectionObserverHandle>>>,
session: Session,
renderer: Renderer,
presentation: Presentation,
custom_events: CustomEvents,
_subscriptions: Rc<[Subscription; 2]>,
}
impl HasCustomEvents for PerspectiveViewerElement {
fn custom_events(&self) -> &CustomEvents {
&self.custom_events
}
}
impl HasPresentation for PerspectiveViewerElement {
fn presentation(&self) -> &Presentation {
&self.presentation
}
}
impl HasRenderer for PerspectiveViewerElement {
fn renderer(&self) -> &Renderer {
&self.renderer
}
}
impl HasSession for PerspectiveViewerElement {
fn session(&self) -> &Session {
&self.session
}
}
impl StateProvider for PerspectiveViewerElement {
type State = PerspectiveViewerElement;
fn clone_state(&self) -> Self::State {
self.clone()
}
}
impl CustomElementMetadata for PerspectiveViewerElement {
const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer";
const STATICS: &'static [&'static str] = ["registerPlugin"].as_slice();
}
#[wasm_bindgen]
impl PerspectiveViewerElement {
#[doc(hidden)]
#[wasm_bindgen(constructor)]
pub fn new(elem: web_sys::HtmlElement) -> Self {
let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
let shadow_root = elem
.attach_shadow(&init)
.unwrap()
.unchecked_into::<web_sys::Element>();
Self::new_from_shadow(elem, shadow_root)
}
fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
let session = Session::new();
let renderer = Renderer::new(&elem);
let presentation = Presentation::new(&elem);
let custom_events = CustomEvents::new(&elem, &session, &renderer, &presentation);
let props = yew::props!(PerspectiveViewerProps {
elem: elem.clone(),
session: session.clone(),
renderer: renderer.clone(),
presentation: presentation.clone(),
dragdrop: DragDrop::new(&elem),
custom_events: custom_events.clone(),
});
let state = props.clone_state();
let root = Root::new(shadow_root, props);
let update_sub = session.table_updated.add_listener({
clone!(renderer, session);
move |_| {
clone!(renderer, session);
ApiFuture::spawn(async move {
renderer
.update(session.get_view())
.await
.ignore_view_delete()
.map(|_| ())
})
}
});
let eject_sub = presentation.on_eject.add_listener({
let root = root.clone();
move |_| ApiFuture::spawn(state.delete_all(&root))
});
let resize_handle =
ResizeObserverHandle::new(&elem, &renderer, &session, &presentation, &root);
let intersect_handle =
IntersectionObserverHandle::new(&elem, &presentation, &session, &renderer);
Self {
elem,
root,
session,
renderer,
presentation,
resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
custom_events,
_subscriptions: Rc::new([update_sub, eject_sub]),
}
}
#[doc(hidden)]
#[wasm_bindgen(js_name = "connectedCallback")]
pub fn connected_callback(&self) -> ApiResult<()> {
tracing::debug!("Connected <perspective-viewer>");
Ok(())
}
pub fn load(&self, table: JsValue) -> ApiResult<ApiFuture<()>> {
let promise = table
.clone()
.dyn_into::<js_sys::Promise>()
.unwrap_or_else(|_| js_sys::Promise::resolve(&table));
let _plugin = self.renderer.get_active_plugin()?;
let reset_task = self.session.reset(ResetOptions {
config: true,
expressions: true,
stats: true,
table: Some(session::TableIntermediateState::Reloaded),
});
clone!(self.renderer, self.session);
Ok(ApiFuture::new_throttled(async move {
let task = async {
let _ = reset_task.await;
let jstable = JsFuture::from(promise)
.await
.map_err(|x| apierror!(TableError(x)))?;
if let Ok(Some(table)) =
try_from_js_option::<perspective_js::Table>(jstable.clone())
{
let client = table.get_client().await;
session.set_client(client.get_client().clone());
let name = table.get_name().await;
tracing::debug!(
"Loading {:.0} rows from `Table` {}",
table.size().await?,
name
);
if session.set_table(name).await? {
session.validate().await?.create_view().await?;
}
Ok(session.get_view())
} else if let Ok(Some(client)) =
wasm_bindgen_derive::try_from_js_option::<perspective_js::Client>(jstable)
{
session.set_client(client.get_client().clone());
Ok(session.get_view())
} else {
Err(ApiError::new("Invalid argument"))
}
};
renderer.set_throttle(None);
let result = renderer.draw(task).await;
if let Err(e) = &result {
session.set_error(false, e.clone()).await?;
}
result
}))
}
pub fn delete(self) -> ApiFuture<()> {
self.delete_all(&self.root)
}
pub fn eject(&mut self) -> ApiFuture<()> {
if matches!(self.session.has_table(), Some(TableLoadState::Loaded)) {
let mut state = Self::new_from_shadow(
self.elem.clone(),
self.elem.shadow_root().unwrap().unchecked_into(),
);
std::mem::swap(self, &mut state);
ApiFuture::new_throttled(state.delete())
} else {
ApiFuture::new_throttled(async move { Ok(()) })
}
}
#[wasm_bindgen]
pub fn getView(&self) -> ApiFuture<View> {
let session = self.session.clone();
ApiFuture::new(async move { Ok(session.get_view().ok_or("No table set")?.into()) })
}
#[wasm_bindgen]
pub fn getViewConfig(&self) -> ApiFuture<JsViewConfig> {
let session = self.session.clone();
ApiFuture::new(async move {
let config = session.get_view_config();
Ok(JsValue::from_serde_ext(&*config)?.unchecked_into())
})
}
#[wasm_bindgen]
pub fn getTable(&self, wait_for_table: Option<bool>) -> ApiFuture<Table> {
let session = self.session.clone();
ApiFuture::new(async move {
match session.get_table() {
Some(table) => Ok(table.into()),
None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
None => {
session.table_loaded.read_next().await?;
Ok(session.get_table().ok_or("No `Table` set")?.into())
},
}
})
}
#[wasm_bindgen]
pub fn getClient(&self, wait_for_client: Option<bool>) -> ApiFuture<perspective_js::Client> {
let session = self.session.clone();
ApiFuture::new(async move {
match session.get_client() {
Some(client) => Ok(client.into()),
None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
None => {
session.table_loaded.read_next().await?;
Ok(session.get_client().ok_or("No `Client` set")?.into())
},
}
})
}
#[wasm_bindgen]
pub fn getRenderStats(&self) -> ApiResult<JsValue> {
Ok(JsValue::from_serde_ext(
&self.renderer.render_timer().get_stats(),
)?)
}
pub fn flush(&self) -> ApiFuture<()> {
clone!(self.renderer);
ApiFuture::new_throttled(async move {
request_animation_frame().await;
request_animation_frame().await;
renderer.clone().with_lock(async { Ok(()) }).await?;
renderer.with_lock(async { Ok(()) }).await
})
}
pub fn restore(&self, update: JsValue) -> ApiFuture<()> {
let this = self.clone();
ApiFuture::new_throttled(async move {
let decoded_update = ViewerConfigUpdate::decode(&update)?;
tracing::info!("Restoring {}", decoded_update);
let root = this.root.clone();
let settings = decoded_update.settings.clone();
let (sender, receiver) = channel::<()>();
root.borrow().as_ref().into_apierror()?.send_message(
PerspectiveViewerMsg::ToggleSettingsComplete(settings, sender),
);
let task = if let OptionalUpdate::Update(_) = &decoded_update.table {
Some(this.session.reset(ResetOptions {
config: true,
expressions: true,
stats: true,
..ResetOptions::default()
}))
} else {
None
};
let result = this
.restore_and_render(decoded_update.clone(), {
clone!(this, decoded_update.table);
async move {
if let OptionalUpdate::Update(name) = table {
if let Some(task) = task {
task.await?;
}
this.session.set_table(name).await?;
this.session
.update_column_defaults(&this.renderer.metadata());
};
receiver.await.unwrap_or_log();
Ok(())
}
})
.await;
if let Err(e) = &result {
this.session().set_error(false, e.clone()).await?;
}
result
})
}
pub fn resetError(&self) -> ApiFuture<()> {
ApiFuture::spawn(self.session.reset(ResetOptions::default()));
let this = self.clone();
ApiFuture::new_throttled(async move {
this.update_and_render(ViewConfigUpdate::default())?.await?;
Ok(())
})
}
pub fn save(&self) -> ApiFuture<JsValue> {
let this = self.clone();
ApiFuture::new(async move {
let viewer_config = this
.renderer
.clone()
.with_lock(async { this.get_viewer_config().await })
.await?;
viewer_config.encode()
})
}
pub fn download(&self, method: Option<JsString>) -> ApiFuture<()> {
let this = self.clone();
ApiFuture::new_throttled(async move {
let method = if let Some(method) = method
.map(|x| x.unchecked_into())
.map(serde_wasm_bindgen::from_value)
{
method?
} else {
ExportMethod::Csv
};
let blob = this.export_method_to_blob(method).await?;
let is_chart = this.renderer.is_chart();
download(
format!("untitled{}", method.as_filename(is_chart)).as_ref(),
&blob,
)
})
}
pub fn export(&self, method: Option<JsString>) -> ApiFuture<JsValue> {
let this = self.clone();
ApiFuture::new(async move {
let method = if let Some(method) = method
.map(|x| x.unchecked_into())
.map(serde_wasm_bindgen::from_value)
{
method?
} else {
ExportMethod::Csv
};
this.export_method_to_jsvalue(method).await
})
}
pub fn copy(&self, method: Option<JsString>) -> ApiFuture<()> {
let this = self.clone();
ApiFuture::new_throttled(async move {
let method = if let Some(method) = method
.map(|x| x.unchecked_into())
.map(serde_wasm_bindgen::from_value)
{
method?
} else {
ExportMethod::Csv
};
let js_task = this.export_method_to_blob(method);
copy_to_clipboard(js_task, MimeType::TextPlain).await
})
}
pub fn reset(&self, reset_all: Option<bool>) -> ApiFuture<()> {
tracing::debug!("Resetting config");
let root = self.root.clone();
let all = reset_all.unwrap_or_default();
ApiFuture::new_throttled(async move {
let (sender, receiver) = channel::<()>();
root.borrow()
.as_ref()
.ok_or("Already deleted")?
.send_message(PerspectiveViewerMsg::Reset(all, Some(sender)));
Ok(receiver.await?)
})
}
#[wasm_bindgen]
pub fn resize(&self, options: Option<JsValue>) -> ApiFuture<()> {
let opts: ResizeOptions = options
.map(|v| v.into_serde_ext())
.transpose()
.unwrap_or_default()
.unwrap_or_default();
let state = self.clone_state();
ApiFuture::new_throttled(async move {
if !state.renderer().is_plugin_activated()? {
state
.update_and_render(ViewConfigUpdate::default())?
.await?;
} else if let Some(dims) = opts.dimensions {
state
.renderer()
.resize_with_dimensions(dims.width, dims.height)
.await?;
} else {
state.renderer().resize().await?;
}
Ok(())
})
}
#[wasm_bindgen]
pub fn setAutoSize(&self, autosize: bool) {
if autosize {
let handle = Some(ResizeObserverHandle::new(
&self.elem,
&self.renderer,
&self.session,
&self.presentation,
&self.root,
));
*self.resize_handle.borrow_mut() = handle;
} else {
*self.resize_handle.borrow_mut() = None;
}
}
#[wasm_bindgen]
pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
if autopause {
let handle = Some(IntersectionObserverHandle::new(
&self.elem,
&self.presentation,
&self.session,
&self.renderer,
));
*self.intersection_handle.borrow_mut() = handle;
} else {
*self.intersection_handle.borrow_mut() = None;
if self.session.set_pause(false) {
return ApiFuture::new(
self.restore_and_render(ViewerConfigUpdate::default(), async move { Ok(()) }),
);
}
}
ApiFuture::new(async move { Ok(()) })
}
#[wasm_bindgen]
pub fn getSelection(&self) -> Option<JsViewWindow> {
self.renderer.get_selection().map(|x| x.into())
}
#[wasm_bindgen]
pub fn setSelection(&self, window: Option<JsViewWindow>) -> ApiResult<()> {
let window = window.map(|x| x.into_serde_ext()).transpose()?;
if self.renderer.get_selection() != window {
self.custom_events.dispatch_select(window.as_ref())?;
}
self.renderer.set_selection(window);
Ok(())
}
#[wasm_bindgen]
pub fn getEditPort(&self) -> Result<f64, JsValue> {
self.session
.metadata()
.get_edit_port()
.ok_or_else(|| "No `Table` loaded".into())
}
#[wasm_bindgen]
pub fn restyleElement(&self) -> ApiFuture<JsValue> {
clone!(self.renderer, self.session);
ApiFuture::new(async move {
let view = session.get_view().into_apierror()?;
renderer.restyle_all(&view).await
})
}
#[wasm_bindgen]
pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
clone!(self.renderer, self.session, self.presentation);
ApiFuture::new(async move {
let themes: Option<Vec<String>> = themes
.unwrap_or_default()
.iter()
.map(|x| x.as_string())
.collect();
let theme_name = presentation.get_selected_theme_name().await;
let mut changed = presentation.reset_available_themes(themes).await;
let reset_theme = presentation
.get_available_themes()
.await?
.iter()
.find(|y| theme_name.as_ref() == Some(y))
.cloned();
changed = presentation.set_theme_name(reset_theme.as_deref()).await? || changed;
if changed && let Some(view) = session.get_view() {
return renderer.restyle_all(&view).await;
}
Ok(JsValue::UNDEFINED)
})
}
#[wasm_bindgen]
pub fn setThrottle(&self, val: Option<f64>) {
self.renderer.set_throttle(val);
}
#[wasm_bindgen]
pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
let root = self.root.clone();
ApiFuture::new(async move {
let force = force.map(SettingsUpdate::Update);
let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
root.borrow().as_ref().into_apierror()?.send_message(
PerspectiveViewerMsg::ToggleSettingsInit(force, Some(sender)),
);
receiver.await.map_err(|_| JsValue::from("Cancelled"))?
})
}
#[wasm_bindgen]
pub fn getAllPlugins(&self) -> Array {
self.renderer.get_all_plugins().iter().collect::<Array>()
}
#[wasm_bindgen]
pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
match name {
None => self.renderer.get_active_plugin(),
Some(name) => self.renderer.get_plugin(&name),
}
}
#[doc(hidden)]
#[allow(clippy::use_self)]
#[wasm_bindgen]
pub fn __get_model(&self) -> PerspectiveViewerElement {
self.clone()
}
#[wasm_bindgen]
pub fn toggleColumnSettings(&self, column_name: String) -> ApiFuture<()> {
clone!(self.session, self.root);
ApiFuture::new_throttled(async move {
let locator = session.get_column_locator(Some(column_name));
let (sender, receiver) = channel::<()>();
root.borrow().as_ref().into_apierror()?.send_message(
PerspectiveViewerMsg::OpenColumnSettings {
locator,
sender: Some(sender),
toggle: true,
},
);
receiver.await.map_err(|_| ApiError::from("Cancelled"))
})
}
#[wasm_bindgen]
pub fn openColumnSettings(
&self,
column_name: Option<String>,
toggle: Option<bool>,
) -> ApiFuture<()> {
let locator = self.get_column_locator(column_name);
clone!(self.root);
ApiFuture::new_throttled(async move {
let (sender, receiver) = channel::<()>();
root.borrow().as_ref().into_apierror()?.send_message(
PerspectiveViewerMsg::OpenColumnSettings {
locator,
sender: Some(sender),
toggle: toggle.unwrap_or_default(),
},
);
receiver.await.map_err(|_| ApiError::from("Cancelled"))
})
}
}