perspective-viewer 4.5.0

A data visualization and analytics component, especially well-suited for large and/or streaming datasets.
Documentation
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

use std::rc::Rc;

use perspective_client::ExprValidationError;
use perspective_js::utils::{ApiFuture, JsValueSerdeExt};
use wasm_bindgen::prelude::*;
use yew::prelude::*;

use crate::components::form::code_editor::CodeEditor;
use crate::components::style::LocalStyle;
use crate::css;
use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard};
use crate::presentation::*;
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;

#[derive(Clone, PartialEq, Properties)]
pub struct DebugPanelProps {
    pub presentation: Presentation,
    pub renderer: Renderer,
    pub session: Session,

    /// Trap-door width pinned by the parent `SettingsPanel` so switching
    /// tabs doesn't shrink the panel. Threaded into the hidden sizer
    /// `<div class="scroll-panel-auto-width">`.
    #[prop_or_default]
    pub initial_width: f64,

    /// Fires once on mount with this panel's measured natural width.
    /// Routed up to `SettingsPanel` which keeps the running max.
    #[prop_or_default]
    pub on_auto_width: Callback<f64>,
}

#[function_component(DebugPanel)]
pub fn debug_panel(props: &DebugPanelProps) -> Html {
    let expr = use_state_eq(|| Rc::new("".to_string()));
    let error = use_state_eq(|| Option::<ExprValidationError>::None);
    let select_all = use_memo((), |()| PubSub::default());
    let modified = use_state_eq(|| false);

    // Measure natural width on mount and route up to `SettingsPanel`.
    let sizer = use_node_ref();
    use_effect_with(expr.clone(), {
        let sizer = sizer.clone();
        let on_auto_width = props.on_auto_width.clone();
        move |_| {
            if let Some(elem) = sizer.cast::<web_sys::HtmlElement>() {
                on_auto_width.emit(elem.get_bounding_client_rect().width());
            }
        }
    });

    use_effect_with((expr.setter(), props.clone()), {
        clone!(error, modified);
        move |(text, state)| {
            state.set_text(text.clone());
            error.set(None);
            let sub1 = state
                .renderer
                .style_changed
                .add_listener(state.reset_callback(
                    text.clone(),
                    error.setter(),
                    modified.setter(),
                ));

            let sub2 = state
                .renderer
                .reset_changed
                .add_listener(state.reset_callback(
                    text.clone(),
                    error.setter(),
                    modified.setter(),
                ));

            let sub3 = state
                .session
                .view_config_changed
                .add_listener(state.reset_callback(
                    text.clone(),
                    error.setter(),
                    modified.setter(),
                ));

            || {
                drop(sub1);
                drop(sub2);
                drop(sub3);
            }
        }
    });

    let oninput = use_callback(expr.setter(), {
        clone!(modified);
        move |x, expr| {
            modified.set(true);
            expr.set(x)
        }
    });

    let onsave = use_callback((expr.clone(), error.clone(), props.clone()), {
        clone!(modified);
        move |_, (text, error, props)| props.on_save(text, error, &modified)
    });

    let oncopy = use_callback(
        (expr.clone(), select_all.callback()),
        move |_, (text, select_all)| {
            select_all.emit(());
            let options = web_sys::BlobPropertyBag::new();
            options.set_type("text/plain");
            let blob_txt = (JsValue::from((***text).clone())).clone();
            let blob_parts = js_sys::Array::from_iter([blob_txt].iter());
            let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &options);
            ApiFuture::spawn(copy_to_clipboard(
                async move { Ok(blob?) },
                MimeType::TextPlain,
            ));
        },
    );

    let onapply = use_callback((expr.clone(), error.clone(), props.clone()), {
        clone!(modified);
        move |_, (text, error, props)| props.on_save(text, error, &modified)
    });

    let onreset = use_callback((expr.setter(), error.clone(), props.clone()), {
        clone!(modified);
        move |_, (text, error, props)| {
            props.set_text(text.clone());
            error.set(None);
            modified.set(false);
        }
    });

    let onpaste = use_callback((expr.clone(), error.clone(), props.clone()), {
        clone!(modified);
        move |_, (text, error, props)| {
            clone!(text, error, props, modified);
            ApiFuture::spawn(async move {
                if let Some(x) = paste_from_clipboard().await {
                    let x = Rc::new(x);
                    modified.set(true);
                    error.set(None);
                    text.set(x.clone());
                    props.on_save(&x, &error, &modified);
                }

                Ok(())
            });
        }
    });

    html! {
        <>
            <LocalStyle href={css!("containers/tabs")} />
            <LocalStyle href={css!("form/debug")} />
            <div id="debug-panel-overflow">
                <div id="debug-panel" class="sidebar_column" ref={sizer}>
                    <div id="debug-panel-controls">
                        <button disabled={!*modified} onclick={onapply}>{ "Apply" }</button>
                        <button disabled={!*modified} onclick={onreset}>{ "Reset" }</button>
                        <button onclick={oncopy}>{ "Copy" }</button>
                        <button onclick={onpaste}>{ "Paste" }</button>
                    </div>
                    <div id="debug-panel-editor">
                        <CodeEditor
                            expr={&*expr}
                            disabled=false
                            {oninput}
                            {onsave}
                            select_all={select_all.subscriber()}
                            error={(*error).clone()}
                        />
                    </div>
                    <div
                        class="scroll-panel-auto-width"
                        style={format!("width:{}px", props.initial_width)}
                    />
                </div>
            </div>
        </>
    }
}

impl DebugPanelProps {
    fn set_text(&self, setter: UseStateSetter<Rc<String>>) {
        let props = self.clone();
        ApiFuture::spawn(async move {
            let config = crate::queries::get_viewer_config(
                &props.session,
                &props.renderer,
                &props.presentation,
            )
            .await?;
            let json = JsValue::from_serde_ext(&config)?;
            let js_string =
                js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?;

            setter.set(Rc::new(js_string.as_string().unwrap()));
            Ok(())
        });
    }

    fn reset_callback(
        &self,
        text: UseStateSetter<Rc<String>>,
        error: UseStateSetter<Option<ExprValidationError>>,
        modified: UseStateSetter<bool>,
    ) -> impl Fn(()) + use<> {
        let props = self.clone();
        move |_| {
            error.set(None);
            props.set_text(text.clone());
            modified.set(false);
        }
    }

    fn on_save(
        &self,
        text: &Rc<String>,
        error: &UseStateHandle<Option<ExprValidationError>>,
        modified: &UseStateHandle<bool>,
    ) {
        let props = self.clone();
        clone!(text, error, modified);
        ApiFuture::spawn(async move {
            match serde_json::from_str(&text) {
                Ok(config) => {
                    match crate::tasks::restore_and_render(
                        &props.session,
                        &props.renderer,
                        &props.presentation,
                        config,
                        async { Ok(()) },
                    )
                    .await
                    {
                        Ok(_) => {
                            modified.set(false);
                        },
                        Err(e) => {
                            modified.set(true);
                            error.set(Some(ExprValidationError {
                                error_message: JsValue::from(e).as_string().unwrap_or_else(|| {
                                    "Failed to validate viewer config".to_owned()
                                }),
                                line: 0_u32,
                                column: 0,
                            }));
                        },
                    }
                    Ok(())
                },
                Err(err) => {
                    modified.set(true);
                    error.set(Some(ExprValidationError {
                        error_message: err.to_string(),
                        line: err.line() as u32 - 1,
                        column: err.column() as u32 - 1,
                    }));

                    Ok(())
                },
            }
        });
    }
}