Skip to content

Added functionality for callbacks #287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
strategy:
fail-fast: false
matrix:
example: [wasm-yew-minimal]
example: [wasm-yew-minimal, wasm-yew-callback-minimal]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
### Changed
- [[#277](https://github.com/plotly/plotly.rs/pull/277)] Removed `wasm` feature flag and put evrything behind target specific dependencies. Added `.cargo/config.toml` for configuration flags needed by `getrandom` version 0.3 on `wasm` targets.
- [[#281]((https://github.com/plotly/plotly.rs/pull/xxx))] Update to askama 0.13.0
- [[#287]](https://github.com/plotly/plotly.rs/pull/287) Added functionality for callbacks (using wasm)
- [[#289]](https://github.com/plotly/plotly.rs/pull/289) Fixes Kaleido static export for MacOS targets by removing `--disable-gpu` flag for MacOS
- [[#290]](https://github.com/plotly/plotly.rs/pull/289) Remove `--disable-gpu` flag for Kaleido static-image generation for all targets.

Expand Down
12 changes: 12 additions & 0 deletions examples/wasm-yew-callback-minimal/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"}
9 changes: 9 additions & 0 deletions examples/wasm-yew-callback-minimal/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions examples/wasm-yew-callback-minimal/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">

<head>
<meta charset="utf-8" />
<title>Plotly Yew</title>
<script src="https://cdn.plot.ly/plotly-2.14.0.min.js"></script>
</head>

<body></body>

</html>
77 changes: 77 additions & 0 deletions examples/wasm-yew-callback-minimal/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use plotly::callbacks::ClickEvent;
use plotly::{Histogram, Plot, Scatter, common::Mode, histogram::Bins};
use web_sys::js_sys::Math;
use yew::prelude::*;

#[function_component(App)]
pub fn plot_component() -> Html {
let x = use_state(|| None::<f64>);
let y = use_state(|| None::<f64>);
let point_numbers = use_state(|| None::<Vec<usize>>);
let point_number = use_state(|| None::<usize>);
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<f64> = (0..50).map(|i| i as f64).collect();
let ys: Vec<f64> = xs.iter().map(|x| x.sin() * 5.0).collect();
fig.add_trace(
Scatter::new(xs.clone(), ys.clone())
.mode(Mode::Markers)
.name("Sine Wave Markers"),
);
let random_values: Vec<f64> = (0..500).map(|_| Math::random() * 100.0).collect();
fig.add_trace(
Histogram::new(random_values)
.name("Random Data Histogram")
.x_bins(Bins::new(-1.0, 30.0, 5.0)),
);
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! {
<>
<div id="plot-div"></div>
<div>
<p>{format!("x: {:?}",*x)}</p>
<p>{format!("y: {:?}",*y)}</p>
<p>{format!("curveNumber: {:?}",*curve_number)}</p>
<p>{format!("pointNumber: {:?}",*point_number)}</p>
<p>{format!("pointNumbers: {:?}",*point_numbers)}</p>
<p>{format!("ClickEvent: {:?}",*click_event)}</p>
</div>
</>
}
}

fn main() {
wasm_logger::init(wasm_logger::Config::default());
yew::Renderer::<App>::new().render();
}
2 changes: 2 additions & 0 deletions plotly/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
141 changes: 141 additions & 0 deletions plotly/src/callbacks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use web_sys::{js_sys::Function, 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 `<div>`
/// 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
/// `<div>`.
///
/// 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 `<div>`.
///
/// # 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 `<div>`.
///
/// # Type Parameters
/// - `F`: A `'static + FnMut(ClickEvent)` closure type to handle the click
/// data.
///
/// # Parameters
/// - `div_id`: The DOM `id` attribute of the Plotly `<div>`.
/// - `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<dyn FnMut(JsValue)>` 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<F>(div_id: &str, mut cb: F)
where
F: 'static + FnMut(ClickEvent),
{
let closure = Closure::wrap(Box::new(move |event: JsValue| {
let event: ClickEvent =
serde_wasm_bindgen::from_value(event).expect("Could not serialize the event");
cb(event);
}) as Box<dyn FnMut(JsValue)>);

let plot_div: PlotlyDiv = get_div(div_id).expect("Could not get Div element by Id");
plot_div.on("plotly_click", closure.as_ref().unchecked_ref());
closure.forget();
}

fn get_div(tag: &str) -> Option<PlotlyDiv> {
web_sys::window()?
.document()?
.get_element_by_id(tag)?
.dyn_into()
.ok()
}

/// 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<Vec<usize>>,
pub point_number: Option<usize>,
pub x: Option<f64>,
pub y: Option<f64>,
pub z: Option<f64>,
pub lat: Option<f64>,
pub lon: Option<f64>,
}

/// 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<ClickPoint> {
vec![ClickPoint::default()]
}

/// The top-level payload for a Plotly click event.
///
/// - `points`: A `Vec<ClickPoint>` 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<ClickPoint>,
}

/// 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![] }
}
}
3 changes: 3 additions & 0 deletions plotly/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading