From 6c5e75df2f519045dccf73d12d0bd82d6aa52fc6 Mon Sep 17 00:00:00 2001 From: h4ck4l1 Date: Fri, 25 Apr 2025 21:38:01 +0000 Subject: [PATCH] Added functionality for callbacks --- examples/wasm-yew-callback-minimal/Cargo.toml | 12 ++ examples/wasm-yew-callback-minimal/README.md | 9 ++ examples/wasm-yew-callback-minimal/index.html | 12 ++ .../wasm-yew-callback-minimal/src/main.rs | 80 ++++++++++ plotly/Cargo.toml | 2 + plotly/src/bindings.rs | 1 + plotly/src/callbacks.rs | 142 ++++++++++++++++++ plotly/src/lib.rs | 3 + 8 files changed, 261 insertions(+) create mode 100644 examples/wasm-yew-callback-minimal/Cargo.toml create mode 100644 examples/wasm-yew-callback-minimal/README.md create mode 100644 examples/wasm-yew-callback-minimal/index.html create mode 100644 examples/wasm-yew-callback-minimal/src/main.rs create mode 100644 plotly/src/callbacks.rs diff --git a/examples/wasm-yew-callback-minimal/Cargo.toml b/examples/wasm-yew-callback-minimal/Cargo.toml new file mode 100644 index 00000000..12f22f6d --- /dev/null +++ b/examples/wasm-yew-callback-minimal/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wasm-yew-callback-minimal" +version = "0.1.0" +edition = "2024" + +[dependencies] +plotly = { path = "../../plotly" } +yew = "0.21" +yew-hooks = "0.3" +log = "0.4" +wasm-logger = "0.2" +web-sys = { version = "0.3.77"} \ No newline at end of file diff --git a/examples/wasm-yew-callback-minimal/README.md b/examples/wasm-yew-callback-minimal/README.md new file mode 100644 index 00000000..a62a6681 --- /dev/null +++ b/examples/wasm-yew-callback-minimal/README.md @@ -0,0 +1,9 @@ +# Wasm Yew Minimal + +## Prerequisites + +1. Install [Trunk](https://trunkrs.dev/) using `cargo install --locked trunk`. + +## How to Run + +1. Run `trunk serve --open` in this directory to build and serve the application, opening the default web browser automatically. \ No newline at end of file diff --git a/examples/wasm-yew-callback-minimal/index.html b/examples/wasm-yew-callback-minimal/index.html new file mode 100644 index 00000000..88480a2e --- /dev/null +++ b/examples/wasm-yew-callback-minimal/index.html @@ -0,0 +1,12 @@ + + + + + + Plotly Yew + + + + + + \ No newline at end of file diff --git a/examples/wasm-yew-callback-minimal/src/main.rs b/examples/wasm-yew-callback-minimal/src/main.rs new file mode 100644 index 00000000..f1a82ab2 --- /dev/null +++ b/examples/wasm-yew-callback-minimal/src/main.rs @@ -0,0 +1,80 @@ +use plotly::{Plot,common::Mode, Scatter,Histogram}; +use plotly::callbacks::{ClickEvent}; +use web_sys::js_sys::Math; +use yew::prelude::*; + + +#[function_component(App)] +pub fn plot_component() -> Html { + + let x = use_state(|| None::); + let y = use_state(|| None::); + let point_numbers = use_state(|| None::>); + let point_number = use_state(|| None::); + let curve_number = use_state(|| 0usize); + let click_event = use_state(|| ClickEvent::default()); + + let x_clone = x.clone(); + let y_clone = y.clone(); + let curve_clone = curve_number.clone(); + let point_numbers_clone = point_numbers.clone(); + let point_number_clone = point_number.clone(); + let click_event_clone = click_event.clone(); + + let p = yew_hooks::use_async::<_, _, ()>({ + let id = "plot-div"; + let mut fig = Plot::new(); + let xs: Vec = (0..50).map(|i| i as f64).collect(); + let ys: Vec = xs.iter().map(|x| x.sin()).collect(); + fig.add_trace( + Scatter::new(xs.clone(), ys.clone()) + .mode(Mode::Markers) + .name("Sine markers") + ); + let random_values: Vec = (0..100) + .map(|_| Math::random()) + .collect(); + fig.add_trace( + Histogram::new(random_values) + .name("Random histogram") + ); + let layout = plotly::Layout::new().title("Click Event Callback Example in Yew"); + fig.set_layout(layout); + async move { + plotly::bindings::new_plot(id, &fig).await; + plotly::callbacks::bind_click(id, move |event| { + let pt = &event.points[0]; + x_clone.set(pt.x); + y_clone.set(pt.y); + curve_clone.set(pt.curve_number); + point_numbers_clone.set(pt.point_numbers.clone()); + point_number_clone.set(pt.point_number); + click_event_clone.set(event); + }); + Ok(()) + } + }); + // Only on first render + use_effect_with((), move |_| { + p.run(); + }); + + html! { + <> +
+
+

{format!("x: {:?}",*x)}

+

{format!("y: {:?}",*y)}

+

{format!("curveNumber: {:?}",*curve_number)}

+

{format!("pointNumber: {:?}",*point_number)}

+

{format!("pointNumbers: {:?}",*point_numbers)}

+

{format!("ClickEvent: {:?}",*click_event)}

+
+ + } +} + +fn main() { + wasm_logger::init(wasm_logger::Config::default()); + yew::Renderer::::new().render(); +} \ No newline at end of file diff --git a/plotly/Cargo.toml b/plotly/Cargo.toml index e7bbcee3..2340ef10 100644 --- a/plotly/Cargo.toml +++ b/plotly/Cargo.toml @@ -40,6 +40,8 @@ rand = "0.9" getrandom = { version = "0.3", features = ["wasm_js"] } wasm-bindgen-futures = { version = "0.4" } wasm-bindgen = { version = "0.2" } +serde-wasm-bindgen = {version = "0.6.3"} +web-sys = { version = "0.3.77", features = ["Document", "Window", "HtmlElement"]} [dev-dependencies] csv = "1.1" diff --git a/plotly/src/bindings.rs b/plotly/src/bindings.rs index 9b86c05d..40dcf94f 100644 --- a/plotly/src/bindings.rs +++ b/plotly/src/bindings.rs @@ -25,6 +25,7 @@ extern "C" { pub async fn new_plot(id: &str, plot: &Plot) { let plot_obj = &plot.to_js_object(); + // This will only fail if the Rust Plotly library has produced // plotly-incompatible JSON. An error here should have been handled by the // library, rather than down here. diff --git a/plotly/src/callbacks.rs b/plotly/src/callbacks.rs new file mode 100644 index 00000000..5e3b7fcf --- /dev/null +++ b/plotly/src/callbacks.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use web_sys::{js_sys::Function, window, HtmlElement}; + +/// Provides utilities for binding Plotly.js click events to Rust closures +/// via `wasm-bindgen`. +/// +/// This module defines a `PlotlyDiv` foreign type for the Plotly `
` element, +/// a high-level `bind_click` function to wire up Rust callbacks, and +/// the `ClickPoint`/`ClickEvent` data structures to deserialize event payloads. + +#[wasm_bindgen] +extern "C" { + + /// A wrapper around the JavaScript `HTMLElement` representing a Plotly `
`. + /// + /// This type extends `web_sys::HtmlElement` and exposes Plotly’s + /// `.on(eventName, callback)` method for attaching event listeners. + + #[wasm_bindgen(extends= HtmlElement, js_name=HTMLElement)] + type PlotlyDiv; + + /// Attach a JavaScript event listener to this Plotly `
`. + /// + /// # Parameters + /// - `event`: The Plotly event name (e.g., `"plotly_click"`). + /// - `cb`: A JS `Function` to invoke when the event fires. + /// + /// # Panics + /// This method assumes the underlying element is indeed a Plotly div + /// and that the Plotly.js library has been loaded on the page. + + #[wasm_bindgen(method,structural,js_name=on)] + fn on(this: &PlotlyDiv, event: &str, cb: &Function); +} + +/// Bind a Rust callback to the Plotly `plotly_click` event on a given `
`. +/// +/// # Type Parameters +/// - `F`: A `'static + FnMut(ClickEvent)` closure type to handle the click data. +/// +/// # Parameters +/// - `div_id`: The DOM `id` attribute of the Plotly `
`. +/// - `cb`: A mutable Rust closure that will be called with a `ClickEvent`. +/// +/// # Details +/// 1. Looks up the element by `div_id`, converts it to `PlotlyDiv`. +/// 2. Wraps a `Closure` that deserializes the JS event +/// into our `ClickEvent` type via `serde_wasm_bindgen`. +/// 3. Calls `plot_div.on("plotly_click", …)` to register the listener. +/// 4. Forgets the closure so it lives for the lifetime of the page. +/// +/// # Example +/// ```ignore +/// bind_click("my-plot", |evt| { +/// web_sys::console::log_1(&format!("{:?}", evt).into()); +/// }); +/// ``` + + +pub fn bind_click(div_id: &str, mut cb: F) +where + F: 'static + FnMut(ClickEvent) +{ + + let plot_div: PlotlyDiv = window().unwrap() + .document().unwrap() + .get_element_by_id(div_id).unwrap() + .unchecked_into(); + let closure = Closure::wrap(Box::new(move |event: JsValue| { + let event: ClickEvent = serde_wasm_bindgen::from_value(event) + .expect("\n Couldn't serialize the event \n"); + cb(event); + }) as Box); + plot_div.on("plotly_click", &closure.as_ref().unchecked_ref()); + closure.forget(); +} + + +/// Represents a single point from a Plotly click event. +/// +/// Fields mirror Plotly’s `event.points[i]` properties, all optional +/// where appropriate: +/// +/// - `curve_number`: The zero-based index of the trace that was clicked. +/// - `point_numbers`: An optional list of indices if multiple points were selected. +/// - `point_number`: The index of the specific point clicked (if singular). +/// - `x`, `y`, `z`: Optional numeric coordinates in data space. +/// - `lat`, `lon`: Optional geographic coordinates (for map plots). +/// +/// # Serialization +/// Uses `serde` with `camelCase` field names to match Plotly’s JS API. + + +#[derive(Debug,Deserialize,Serialize,Default)] +#[serde(rename_all = "camelCase")] +pub struct ClickPoint { + pub curve_number: usize, + pub point_numbers: Option>, + pub point_number: Option, + pub x: Option, + pub y: Option, + pub z: Option, + pub lat: Option, + pub lon: Option +} + + +/// Provide a default single-point vector for `ClickEvent::points`. +/// +/// Returns `vec![ClickPoint::default()]` so deserialization always yields +/// at least one element rather than an empty vector. + +fn default_click_event() -> Vec {vec![ClickPoint::default()]} + + +/// The top-level payload for a Plotly click event. +/// +/// - `points`: A `Vec` containing all clicked points. +/// Defaults to the result of `default_click_event` to ensure +/// `points` is non-empty even if Plotly sends no data. +/// +/// # Serialization +/// Uses `serde` with `camelCase` names and a custom default so you can +/// call `event.points` without worrying about missing values. + +#[derive(Debug,Deserialize,Serialize)] +#[serde(rename_all="camelCase",default)] +pub struct ClickEvent { + #[serde(default="default_click_event")] + pub points: Vec +} + +/// A `Default` implementation yielding an empty `points` vector. +/// +/// Useful when you need a zero-event placeholder (e.g., initial state). + +impl Default for ClickEvent { + fn default() -> Self { + ClickEvent { points: vec![] } + } +} \ No newline at end of file diff --git a/plotly/src/lib.rs b/plotly/src/lib.rs index ee082087..e22a1482 100644 --- a/plotly/src/lib.rs +++ b/plotly/src/lib.rs @@ -19,6 +19,9 @@ pub use crate::ndarray::ArrayTraces; #[cfg(target_family = "wasm")] pub mod bindings; +#[cfg(target_family = "wasm")] +pub mod callbacks; + pub mod common; pub mod configuration; pub mod layout;