use js_sys::{Array, Function, Object, Promise, Reflect};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
pub const DEFAULT_WORKER_IMPORTS: &str = r#"
// Chart.js
await import("https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js");
// Luxon (ESM): import() returns the module namespace; bind it to self.luxon so
// the date adapter (which reads the global `luxon`) finds it.
self.luxon = await import("https://cdn.jsdelivr.net/npm/luxon@^2/+esm");
// Luxon date adapter for Chart.js time scales.
await import("https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@^1/dist/chartjs-adapter-luxon.umd.min.js");
"#;
struct CanvasEntry {
canvas: JsValue,
width: f64,
height: f64,
dpr: f64,
}
struct PendingBuild {
obj: JsValue,
plugins: String,
defaults: String,
}
thread_local! {
static CANVASES: RefCell<HashMap<String, CanvasEntry>> = RefCell::new(HashMap::new());
static CHARTS: RefCell<HashMap<String, JsValue>> = RefCell::new(HashMap::new());
static PENDING_BUILD: RefCell<HashMap<String, PendingBuild>> = RefCell::new(HashMap::new());
static MOUSE_FN: Function = Function::new_with_args(
"chart, eventType, x, y, computedStyles",
r#"
if (!chart) return;
if (computedStyles && !chart.canvas.ownerDocument) {
chart.canvas.ownerDocument = { defaultView: { getComputedStyle: function () {
computedStyles.getPropertyValue = function (prop) {
const camel = prop.replace(/-([a-z])/g, (m, l) => l.toUpperCase());
return computedStyles[camel] || computedStyles[prop];
};
return computedStyles;
} } };
}
// Map DOM event names to the names Chart.js handles internally.
const type = eventType === 'mouseleave' ? 'mouseout' : eventType;
// A native-event stand-in. Chart.js reads x/y off the normalized event
// (set below); native is only used for target / preventDefault.
const native = {
type, offsetX: x, offsetY: y, clientX: x, clientY: y,
target: chart.canvas, currentTarget: chart.canvas,
preventDefault() {}, stopPropagation() {},
};
// Normalized event shaped exactly like Chart.js's DOM platform builds.
const ev = { type, chart, native, x, y };
// Preferred path: route through Chart.js's real event handler — the same
// code main-thread charts use. Hit-testing and hover/tooltip state match
// the main thread, and legend-item clicks are handled natively by the
// legend plugin. Note: Chart.js disables animations on an OffscreenCanvas
// worker (issue #10305), so transitions are instant here regardless —
// the tooltip updates correctly, just without the main-thread glide.
if (typeof chart._eventHandler === 'function') {
chart._eventHandler(ev);
return;
}
// Fallback for a Chart.js build without _eventHandler: drive it manually.
const mode = chart.options.interaction?.mode || 'nearest';
const opts = chart.options.interaction || { intersect: false };
if (type === 'mousemove') {
chart.tooltip.setActiveElements(
chart.getElementsAtEventForMode(ev, mode, opts, false), ev);
chart.render();
} else if (type === 'mouseout') {
chart.tooltip.setActiveElements([], ev);
chart.render();
} else if (type === 'click') {
const legend = chart.legend;
if (legend && legend.legendHitBoxes) {
for (let i = 0; i < legend.legendHitBoxes.length; i++) {
const h = legend.legendHitBoxes[i];
if (x >= h.left && x <= h.left + h.width && y >= h.top && y <= h.top + h.height) {
const meta = chart.getDatasetMeta(h.datasetIndex !== undefined ? h.datasetIndex : i);
if (meta) { meta.hidden = !meta.hidden; chart.update(); return; }
}
}
}
const els = chart.getElementsAtEventForMode(ev, mode, opts, false);
if (chart.options.onClick) chart.options.onClick(ev, els, chart);
}
"#,
);
}
fn pin_integer_size(el: &web_sys::Element) -> (f64, f64) {
let rect = el.get_bounding_client_rect();
let w = rect.width().floor().max(1.0);
let h = rect.height().floor().max(1.0);
if let Some(he) = el.dyn_ref::<web_sys::HtmlElement>() {
let style = he.style();
style.set_property("width", &format!("{w}px")).unwrap();
style.set_property("height", &format!("{h}px")).unwrap();
}
(w, h)
}
fn debounce(timer: &Rc<Cell<i32>>, send_fn: &Function) {
if let Some(w) = web_sys::window() {
let t = timer.get();
if t != 0 {
w.clear_timeout_with_handle(t);
}
if let Ok(h) = w.set_timeout_with_callback_and_timeout_and_arguments_0(send_fn, 100) {
timer.set(h);
}
}
}
pub(crate) struct ResizeWatchers {
observer: Option<web_sys::ResizeObserver>,
win_cb: Closure<dyn FnMut()>,
_ro_cb: Closure<dyn FnMut()>,
_send_cb: Closure<dyn FnMut()>,
}
impl Drop for ResizeWatchers {
fn drop(&mut self) {
if let Some(o) = &self.observer {
o.disconnect();
}
if let Some(w) = web_sys::window() {
if let Err(e) = w
.remove_event_listener_with_callback("resize", self.win_cb.as_ref().unchecked_ref())
{
gloo_console::warn!(format!(
"chart-js-rs: failed to remove window resize listener: {e:?}"
));
}
}
}
}
pub(crate) struct MouseHandlers {
el: web_sys::Element,
#[allow(clippy::type_complexity)]
handlers: Vec<(&'static str, Closure<dyn FnMut(web_sys::MouseEvent)>)>,
}
impl Drop for MouseHandlers {
fn drop(&mut self) {
for (event_type, cb) in &self.handlers {
if let Err(e) = self
.el
.remove_event_listener_with_callback(event_type, cb.as_ref().unchecked_ref())
{
gloo_console::warn!(format!(
"chart-js-rs: failed to remove `{event_type}` listener: {e:?}"
));
}
}
}
}
fn worker_global() -> web_sys::DedicatedWorkerGlobalScope {
js_sys::global().unchecked_into()
}
fn chart_ctor() -> Result<Function, JsValue> {
Reflect::get(&js_sys::global(), &"Chart".into())?
.dyn_into::<Function>()
.map_err(|_| JsValue::from_str("global `Chart` is not a constructor — is Chart.js loaded?"))
}
fn call_method(obj: &JsValue, name: &str, args: &[JsValue]) -> Result<JsValue, JsValue> {
let f = Reflect::get(obj, &name.into())?.dyn_into::<Function>()?;
let arr = Array::new();
for a in args {
arr.push(a);
}
Reflect::apply(&f, obj, &arr)
}
fn build_chart(
entry: &CanvasEntry,
obj: JsValue,
plugins: &str,
defaults: &str,
) -> Result<JsValue, JsValue> {
if !defaults.is_empty() {
js_sys::eval(defaults)?;
}
if !plugins.is_empty() {
let plugins_val = js_sys::eval(plugins)?;
Reflect::set(&obj, &"plugins".into(), &plugins_val)?;
}
crate::utils::rationalise(&obj);
Reflect::set(
&entry.canvas,
&"width".into(),
&JsValue::from_f64(entry.width),
)?;
Reflect::set(
&entry.canvas,
&"height".into(),
&JsValue::from_f64(entry.height),
)?;
let options = {
let o = Reflect::get(&obj, &"options".into())?;
if o.is_object() {
o
} else {
let o: JsValue = Object::new().into();
Reflect::set(&obj, &"options".into(), &o)?;
o
}
};
Reflect::set(&options, &"responsive".into(), &JsValue::FALSE)?;
Reflect::set(&options, &"maintainAspectRatio".into(), &JsValue::FALSE)?;
Reflect::set(
&options,
&"devicePixelRatio".into(),
&JsValue::from_f64(entry.dpr),
)?;
let chart = Reflect::construct(&chart_ctor()?, &Array::of2(&entry.canvas, &obj))?;
call_method(
&chart,
"resize",
&[
JsValue::from_f64(entry.width),
JsValue::from_f64(entry.height),
],
)?;
let animate = Reflect::get(&obj, &"options".into())
.ok()
.and_then(|o| Reflect::get(&o, &"animation".into()).ok())
.map(|a| a != JsValue::FALSE)
.unwrap_or(true);
if animate {
call_method(&chart, "update", &[JsValue::from_str("active")])?;
}
Ok(chart)
}
fn update_chart(chart: &JsValue, updated: JsValue, animate: bool) -> bool {
let go = || -> Result<(), JsValue> {
crate::utils::rationalise(&updated);
let inner = Reflect::get(&Reflect::get(chart, &"config".into())?, &"_config".into())?;
Reflect::set(
&inner,
&"type".into(),
&Reflect::get(&updated, &"type".into())?,
)?;
Reflect::set(
&inner,
&"data".into(),
&Reflect::get(&updated, &"data".into())?,
)?;
Reflect::set(
&inner,
&"options".into(),
&Reflect::get(&updated, &"options".into())?,
)?;
if animate {
call_method(chart, "update", &[])?;
call_method(chart, "resize", &[])?;
} else {
call_method(chart, "update", &[JsValue::from_str("none")])?;
}
Ok(())
};
match go() {
Ok(()) => true,
Err(e) => {
gloo_console::error!(format!("chart-js-rs:worker update failed: {e:?}"));
false
}
}
}
fn store_canvas(id: String, canvas: JsValue, width: f64, height: f64, dpr: f64) {
CANVASES.with(|c| {
c.borrow_mut().insert(
id.clone(),
CanvasEntry {
canvas,
width,
height,
dpr,
},
);
});
if let Some(pb) = PENDING_BUILD.with(|p| p.borrow_mut().remove(&id)) {
if let Err(e) = build_now(&id, pb.obj, &pb.plugins, &pb.defaults) {
gloo_console::error!(format!("chart-js-rs:worker deferred build failed: {e}"));
}
}
}
fn resize_chart(id: &str, width: f64, height: f64, dpr: f64) {
CANVASES.with(|c| {
if let Some(e) = c.borrow_mut().get_mut(id) {
e.width = width;
e.height = height;
e.dpr = dpr;
}
});
CHARTS.with(|m| {
if let Some(chart) = m.borrow().get(id) {
let go = || -> Result<(), JsValue> {
let opts = Reflect::get(chart, &"options".into())?;
Reflect::set(&opts, &"devicePixelRatio".into(), &JsValue::from_f64(dpr))?;
call_method(
chart,
"resize",
&[JsValue::from_f64(width), JsValue::from_f64(height)],
)?;
Ok(())
};
if let Err(e) = go() {
gloo_console::error!(format!(
"chart-js-rs:worker resize failed for `{id}`: {e:?}"
));
}
}
});
}
fn handle_mouse(id: &str, event_type: &str, x: f64, y: f64, styles: JsValue) {
CHARTS.with(|m| {
if let Some(chart) = m.borrow().get(id) {
let r = MOUSE_FN.with(|f| {
Reflect::apply(
f,
&JsValue::NULL,
&Array::of5(
chart,
&JsValue::from_str(event_type),
&JsValue::from_f64(x),
&JsValue::from_f64(y),
&styles,
),
)
});
if let Err(e) = r {
gloo_console::error!(format!(
"chart-js-rs:worker mouse `{event_type}` failed for `{id}`: {e:?}"
));
}
}
});
}
fn build_now(id: &str, obj: JsValue, plugins: &str, defaults: &str) -> Result<(), String> {
let chart_js = CANVASES
.with(|c| {
c.borrow()
.get(id)
.map(|entry| build_chart(entry, obj, plugins, defaults))
})
.ok_or_else(|| format!("chart-js-rs: no OffscreenCanvas transferred for `{id}`"))?
.map_err(|e| format!("chart-js-rs: build failed for `{id}`: {e:?}"))?;
CHARTS.with(|m| m.borrow_mut().insert(id.to_string(), chart_js));
Ok(())
}
pub fn render(
chart: Box<dyn crate::WorkerChartExt>,
id: String,
plugins: String,
defaults: String,
) -> Result<(), String> {
let obj = chart.render_json(); let have_canvas = CANVASES.with(|c| c.borrow().contains_key(&id));
if have_canvas {
build_now(&id, obj, &plugins, &defaults)
} else {
PENDING_BUILD.with(|p| {
p.borrow_mut().insert(
id,
PendingBuild {
obj,
plugins,
defaults,
},
);
});
Ok(())
}
}
pub fn update(chart: Box<dyn crate::WorkerChartExt>, id: String, animate: bool) -> bool {
let updated = chart.render_json();
CHARTS.with(|m| match m.borrow().get(&id) {
Some(chart_js) => update_chart(chart_js, updated, animate),
None => false,
})
}
pub fn forget_chart(id: &str) {
CANVASES.with(|c| c.borrow_mut().remove(id));
PENDING_BUILD.with(|p| p.borrow_mut().remove(id));
if let Some(chart) = CHARTS.with(|m| m.borrow_mut().remove(id)) {
if let Err(e) = call_method(&chart, "destroy", &[]) {
gloo_console::error!(format!(
"chart-js-rs:worker destroy failed for `{id}`: {e:?}"
));
}
}
}
pub async fn bootstrap(imports: String) -> Result<(), String> {
let err = |c: &str, e: JsValue| format!("chart-js-rs bootstrap: {c}: {e:?}");
let run = Function::new_no_args(&format!("return (async () => {{\n{imports}\n}})();"));
let promise = run
.call0(&JsValue::NULL)
.map_err(|e| err("imports block", e))?;
JsFuture::from(Promise::from(promise))
.await
.map_err(|e| err("imports block", e))?;
crate::exports::register_chart_area_background();
{
let g = js_sys::global();
let glue = Reflect::get(&g, &"__worxide_glue".into())
.map_err(|e| err("read __worxide_glue", e))?;
if glue.is_undefined() || glue.is_null() {
return Err(
"chart-js-rs bootstrap: worxide glue not found on the worker \
(self.__worxide_glue is unset)"
.into(),
);
}
let window = Reflect::get(&g, &"window".into())
.ok()
.filter(|w| w.is_object())
.unwrap_or_else(|| Object::new().into());
Reflect::set(&window, &"callbacks".into(), &glue)
.map_err(|e| err("set window.callbacks", e))?;
Reflect::set(&g, &"window".into(), &window).map_err(|e| err("set self.window", e))?;
}
install_message_listener();
Ok(())
}
fn install_message_listener() {
let cb = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |ev: web_sys::MessageEvent| {
let data = ev.data();
let get = |k: &str| Reflect::get(&data, &k.into()).unwrap_or(JsValue::UNDEFINED);
match get("type").as_string().as_deref() {
Some("cjsrs-canvas") => {
store_canvas(
get("id").as_string().unwrap_or_default(),
get("canvas"),
get("width").as_f64().unwrap_or(0.0),
get("height").as_f64().unwrap_or(0.0),
{
let d = get("dpr").as_f64().unwrap_or(1.0);
if d > 0.0 {
d
} else {
1.0
}
},
);
}
Some("cjsrs-resize") => {
resize_chart(
&get("id").as_string().unwrap_or_default(),
get("width").as_f64().unwrap_or(0.0),
get("height").as_f64().unwrap_or(0.0),
{
let d = get("dpr").as_f64().unwrap_or(1.0);
if d > 0.0 {
d
} else {
1.0
}
},
);
}
Some("cjsrs-mouse") => {
handle_mouse(
&get("chartId").as_string().unwrap_or_default(),
&get("eventType").as_string().unwrap_or_default(),
get("x").as_f64().unwrap_or(0.0),
get("y").as_f64().unwrap_or(0.0),
get("computedStyles"),
);
}
_ => {} }
});
if let Err(e) =
worker_global().add_event_listener_with_callback("message", cb.as_ref().unchecked_ref())
{
gloo_console::warn!(format!(
"chart-js-rs:worker failed to install message listener (canvas/resize/mouse \
will not be received): {e:?}"
));
}
cb.forget(); }
#[derive(Clone)]
pub struct ChartWorker {
worker: Rc<worxide::Worker>,
}
impl ChartWorker {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
Self::with_imports(DEFAULT_WORKER_IMPORTS.to_string()).await
}
pub async fn with_imports(imports: String) -> Result<Self, Box<dyn std::error::Error>> {
let worker = worxide::Worker::new().await.map_err(|e| e.to_string())?;
worker
.run(move || async move { bootstrap(imports).await })
.await
.map_err(|e| e.to_string())?
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
Ok(Self {
worker: Rc::new(worker),
})
}
pub(crate) async fn run_setup(
&self,
f: Box<dyn FnOnce() + Send + 'static>,
) -> Result<(), Box<dyn std::error::Error>> {
self.worker
.run(move || async move {
f();
Ok::<(), String>(())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })
}
pub(crate) fn terminate(&self) {
self.worker.terminate();
}
pub(crate) async fn render(
&self,
chart: Box<dyn crate::WorkerChartExt>,
id: String,
plugins: String,
defaults: String,
) -> Result<MouseHandlers, Box<dyn std::error::Error>> {
let mouse = self.install_dom_mouse_handlers(&id)?;
self.transfer_canvas(&id)?;
self.worker
.run_blocking(move || render(chart, id, plugins, defaults))
.await
.map_err(|e| e.to_string())?
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
Ok(mouse)
}
pub(crate) async fn update(
&self,
chart: Box<dyn crate::WorkerChartExt>,
id: String,
animate: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(self
.worker
.run_blocking(move || update(chart, id, animate))
.await
.map_err(|e| e.to_string())?)
}
pub(crate) fn install_resize_watchers(
&self,
el: &web_sys::Element,
id: &str,
) -> ResizeWatchers {
let worker = self.worker.clone();
let id = id.to_string();
let el = el.clone();
let timer: Rc<Cell<i32>> = Rc::new(Cell::new(0));
let send_cb = Closure::<dyn FnMut()>::new({
let worker = worker.clone();
let id = id.clone();
let el = el.clone();
let timer = timer.clone();
move || {
timer.set(0);
let (w, h) = pin_integer_size(&el);
let dpr = web_sys::window()
.map(|x| x.device_pixel_ratio())
.filter(|d| *d > 0.0)
.unwrap_or(1.0);
let msg = Object::new();
Reflect::set(&msg, &"type".into(), &"cjsrs-resize".into()).unwrap();
Reflect::set(&msg, &"id".into(), &id.clone().into()).unwrap();
Reflect::set(&msg, &"width".into(), &JsValue::from_f64(w)).unwrap();
Reflect::set(&msg, &"height".into(), &JsValue::from_f64(h)).unwrap();
Reflect::set(&msg, &"dpr".into(), &JsValue::from_f64(dpr)).unwrap();
worker.worker_handle().post_message(&msg).unwrap();
}
});
let send_fn: Function = send_cb.as_ref().unchecked_ref::<Function>().clone();
let ro_cb = Closure::<dyn FnMut()>::new({
let timer = timer.clone();
let send_fn = send_fn.clone();
move || debounce(&timer, &send_fn)
});
let observer = web_sys::ResizeObserver::new(ro_cb.as_ref().unchecked_ref()).ok();
if let Some(o) = &observer {
o.observe(&el);
}
let win_cb = Closure::<dyn FnMut()>::new({
let timer = timer.clone();
let send_fn = send_fn.clone();
move || debounce(&timer, &send_fn)
});
if let Some(w) = web_sys::window() {
w.add_event_listener_with_callback("resize", win_cb.as_ref().unchecked_ref())
.unwrap();
}
ResizeWatchers {
observer,
win_cb,
_ro_cb: ro_cb,
_send_cb: send_cb,
}
}
fn transfer_canvas(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
let el = gloo_utils::document()
.get_element_by_id(id)
.ok_or_else(|| format!("no element with id `{id}`"))?;
let (width, height) = pin_integer_size(&el);
let dpr = web_sys::window()
.map(|w| w.device_pixel_ratio())
.filter(|d| *d > 0.0)
.unwrap_or(1.0);
let canvas = el
.dyn_into::<web_sys::HtmlCanvasElement>()
.map_err(|_| format!("element `{id}` is not a <canvas>"))?;
if canvas.has_attribute("data-cjsrs-transferred") {
return Err(format!(
"canvas `{id}` was already transferred to a worker; render once \
per element (reuse goes through update)"
)
.into());
}
let offscreen = canvas
.transfer_control_to_offscreen()
.map_err(|e| format!("{e:?}"))?;
canvas
.set_attribute("data-cjsrs-transferred", "1")
.map_err(|e| format!("{e:?}"))?;
let msg = Object::new();
let set = |k: &str, v: &JsValue| -> Result<(), Box<dyn std::error::Error>> {
Reflect::set(&msg, &k.into(), v).map_err(|e| format!("{e:?}"))?;
Ok(())
};
set("type", &"cjsrs-canvas".into())?;
set("id", &id.into())?;
set("canvas", &offscreen)?;
set("width", &JsValue::from_f64(width))?;
set("height", &JsValue::from_f64(height))?;
set("dpr", &JsValue::from_f64(dpr))?;
self.worker
.worker_handle()
.post_message_with_transfer(&msg, &Array::of1(&offscreen))
.map_err(|e| format!("{e:?}"))?;
Ok(())
}
fn install_dom_mouse_handlers(
&self,
id: &str,
) -> Result<MouseHandlers, Box<dyn std::error::Error>> {
let el = gloo_utils::document()
.get_element_by_id(id)
.ok_or_else(|| format!("no element with id `{id}`"))?;
let styles = web_sys::window()
.and_then(|w| w.get_computed_style(&el).ok().flatten())
.ok_or("could not read computed style")?;
let computed = || {
let o = Object::new();
for (k, css) in [
("fontFamily", "font-family"),
("fontSize", "font-size"),
("fontWeight", "font-weight"),
("fontStyle", "font-style"),
("lineHeight", "line-height"),
("color", "color"),
] {
Reflect::set(
&o,
&k.into(),
&styles.get_property_value(css).unwrap_or_default().into(),
)
.unwrap();
}
o
};
let mut handlers = Vec::new();
for (event_type, with_xy, with_styles) in [
("mousemove", true, true),
("mouseleave", false, false),
("click", true, true),
] {
let worker = self.worker.clone();
let chart_id = id.to_string();
let el_evt = el.clone();
let computed = computed();
let cb =
Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |e: web_sys::MouseEvent| {
let msg = Object::new();
Reflect::set(&msg, &"type".into(), &"cjsrs-mouse".into()).unwrap();
Reflect::set(&msg, &"eventType".into(), &event_type.into()).unwrap();
Reflect::set(&msg, &"chartId".into(), &chart_id.clone().into()).unwrap();
if with_xy {
let rect = el_evt.get_bounding_client_rect();
let cw = el_evt.client_width() as f64;
let ch = el_evt.client_height() as f64;
let x = (e.client_x() as f64 - rect.left()) * (cw / rect.width());
let y = (e.client_y() as f64 - rect.top()) * (ch / rect.height());
Reflect::set(&msg, &"x".into(), &JsValue::from_f64(x)).unwrap();
Reflect::set(&msg, &"y".into(), &JsValue::from_f64(y)).unwrap();
}
if with_styles {
Reflect::set(&msg, &"computedStyles".into(), &computed).unwrap();
}
worker.worker_handle().post_message(&msg).unwrap();
});
el.add_event_listener_with_callback(event_type, cb.as_ref().unchecked_ref())
.map_err(|e| format!("{e:?}"))?;
handlers.push((event_type, cb));
}
Ok(MouseHandlers { el, handlers })
}
}