chart_js_rs/lib.rs
1#![allow(non_snake_case)]
2#![doc = include_str!("../README.md")]
3
4pub mod bar;
5pub mod coordinate;
6pub mod doughnut;
7pub mod exports;
8pub mod functions;
9pub mod objects;
10pub mod pie;
11pub mod scatter;
12pub mod traits;
13
14#[cfg(feature = "workers")]
15pub use worxide;
16#[cfg(feature = "workers")]
17pub mod worker;
18
19pub use objects::*;
20pub use traits::*;
21pub use utils::*;
22
23#[cfg(feature = "workers")]
24pub use worker::{ChartWorker, DEFAULT_WORKER_IMPORTS};
25#[cfg(feature = "workers")]
26pub use worker_chart::*;
27#[cfg(feature = "workers")]
28pub use worxide::is_worker;
29
30#[doc(hidden)]
31mod utils;
32
33use exports::get_chart;
34use serde::Deserialize;
35
36pub trait ChartExt: erased_serde::Serialize {
37 type DS;
38
39 fn new(id: impl AsRef<str>) -> Self
40 where
41 Self: Default,
42 {
43 Self::default().id(id.as_ref().into())
44 }
45
46 fn get_id(&self) -> &str;
47 fn id(self, id: String) -> Self
48 where
49 Self: Sized;
50
51 fn get_data(&mut self) -> &mut Self::DS;
52 fn data(mut self, data: impl Into<Self::DS>) -> Self
53 where
54 Self: Sized,
55 {
56 *self.get_data() = data.into();
57 self
58 }
59
60 fn get_options(&mut self) -> &mut ChartOptions;
61 fn options(mut self, options: impl Into<ChartOptions>) -> Self
62 where
63 Self: Sized,
64 {
65 *self.get_options() = options.into();
66 self
67 }
68
69 #[allow(clippy::wrong_self_convention)]
70 fn into_json(&self) -> wasm_bindgen::JsValue {
71 // Serialize via serde_json::Value then JSON-parse to a JsValue. The
72 // serde-wasm-bindgen shortcut (Rust -> JsValue directly) is faster but
73 // changes semantics Chart.js is sensitive to (`None` -> `undefined`
74 // instead of `null`, different map/number handling), which collapses
75 // some charts — so keep this round-trip.
76 let json_value = erased_serde::serialize(self, serde_json::value::Serializer)
77 .expect("Unable to serialize chart!");
78 <wasm_bindgen::JsValue as gloo_utils::format::JsValueSerdeExt>::from_serde(&json_value)
79 .expect("Unable to convert to JsValue!")
80 }
81
82 #[allow(clippy::wrong_self_convention)]
83 fn into_chart(&self) -> Chart {
84 Chart {
85 obj: self.into_json(),
86 id: self.get_id().into(),
87 mutate: false,
88 plugins: String::new(),
89 defaults: String::new(),
90 }
91 }
92
93 fn get_chart_from_id(id: &str) -> Option<Self>
94 where
95 for<'de> Self: Deserialize<'de>,
96 {
97 let chart = get_chart(id);
98
99 serde_wasm_bindgen::from_value(chart)
100 .inspect_err(|e| {
101 gloo_console::error!(e.to_string());
102 })
103 .ok()
104 }
105}
106
107#[cfg(feature = "workers")]
108mod worker_chart {
109 use std::{error::Error, future::Future, pin::Pin};
110
111 use crate::worker::ChartWorker;
112 use crate::*;
113 use std::cell::RefCell;
114 use std::collections::HashMap;
115 use wasm_bindgen::closure::Closure;
116 use wasm_bindgen::JsCast;
117
118 /// Object-safe interface for staging a chart on a worker.
119 ///
120 /// The two vtable methods are all the worker needs: serialize the chart (on
121 /// the worker) and read its canvas id. They take `&self` and return
122 /// non-`Self` types, so `dyn WorkerChartExt` is valid — you can hold a
123 /// `Box<dyn WorkerChartExt>` and render heterogeneous charts uniformly.
124 /// `into_worker_chart` is `where Self: Sized`, so it stays callable on
125 /// concrete charts without affecting object safety.
126 ///
127 /// Blanket-implemented for every [`ChartExt`] chart.
128 pub trait WorkerChartExt: Send + 'static {
129 /// Serialize the chart to a Chart.js config. Runs on the worker.
130 fn render_json(&self) -> wasm_bindgen::JsValue;
131
132 /// The chart's canvas element id.
133 fn chart_id(&self) -> String;
134
135 /// Boot a worker (importing `libs`) and stage this chart on it.
136 ///
137 /// The `self: Box<Self>` receiver is object-safe, so this is callable
138 /// through a `dyn WorkerChartExt` (e.g. on a `Box<dyn WorkerChartExt>`)
139 /// while still moving the chart to the worker by pointer. `imports` is the JS
140 /// boot block the worker runs (use [`DEFAULT_WORKER_IMPORTS`] for the
141 /// usual Chart.js + Luxon setup). For a worker shared across charts, construct a
142 /// [`ChartWorker`] yourself and use [`WorkerChart::on`].
143 #[allow(clippy::type_complexity)]
144 fn into_worker_chart(
145 self: Box<Self>,
146 imports: String,
147 ) -> Pin<Box<dyn Future<Output = Result<WorkerChart, Box<dyn Error>>>>>;
148 }
149
150 impl<T: ChartExt + Send + 'static> WorkerChartExt for T {
151 fn render_json(&self) -> wasm_bindgen::JsValue {
152 self.into_json()
153 }
154 fn chart_id(&self) -> String {
155 self.get_id().to_string()
156 }
157 // Body lives here (not as a trait default) because the
158 // `Box<Self> -> Box<dyn WorkerChartExt>` coercion needs `Self: Sized`,
159 // which holds for the concrete `T` but not in a `?Sized` default-method
160 // body. The trait declaration stays defaultless so the method remains in
161 // the vtable and is callable through `dyn WorkerChartExt`.
162 #[allow(clippy::type_complexity)]
163 fn into_worker_chart(
164 self: Box<Self>,
165 imports: String,
166 ) -> Pin<Box<dyn Future<Output = Result<WorkerChart, Box<dyn Error>>>>> {
167 Box::pin(async move {
168 let worker = ChartWorker::with_imports(imports).await?;
169 Ok(WorkerChart::on(worker, self))
170 })
171 }
172 }
173
174 /// Methods available directly on the trait object (`dyn WorkerChartExt` /
175 /// `Box<dyn WorkerChartExt>`), where the non-object-safe `ChartExt`
176 /// supertrait can't be required. Inherent methods, so they don't collide
177 /// with the equivalently-named `ChartExt` methods on concrete charts.
178 impl dyn WorkerChartExt {
179 /// Build a main-thread [`Chart`] from this worker chart. Mirrors
180 /// [`ChartExt::into_chart`].
181 #[allow(clippy::wrong_self_convention)]
182 pub fn into_chart(&self) -> Chart {
183 Chart {
184 obj: self.render_json(),
185 id: self.chart_id(),
186 mutate: false,
187 plugins: String::new(),
188 defaults: String::new(),
189 }
190 }
191 }
192
193 /// A chart staged on a worker, awaiting `render_async`. Holds the chart as a
194 /// trait object, so it is not generic over the chart type.
195 #[must_use = "\nAppend .render_async()\n"]
196 pub struct WorkerChart {
197 chart: Box<dyn WorkerChartExt>,
198 worker: ChartWorker,
199 plugins: String,
200 defaults: String,
201 /// Optional loading hook. Called once at render start with the canvas's
202 /// parent element; any DOM the callback appends to that parent is
203 /// removed automatically when the render finishes (success or error).
204 while_rendering: Option<Box<dyn FnOnce(web_sys::HtmlElement)>>,
205 /// Optional worker-side setup, run after the worker's libraries load and
206 /// before the chart is built. Use it to register custom Chart.js plugins
207 /// on the worker and to move owned Rust state across (captured in `f`).
208 worker_setup: Option<Box<dyn FnOnce() + Send + 'static>>,
209 }
210
211 thread_local! {
212 /// Live chart workers keyed by canvas id. A chart's worker is persistent
213 /// — it owns the OffscreenCanvas + Chart.js instance and serves tooltips
214 /// and updates — so it must outlive `render_async` (which otherwise drops
215 /// the only `Rc`, and worxide's `Worker` terminates on drop). We keep it
216 /// here and tear it down when the canvas element leaves the DOM (component
217 /// unmount, SPA navigation), so the consumer never has to manage it.
218 static LIVE: RefCell<HashMap<String, LiveChart>> = RefCell::new(HashMap::new());
219
220 /// Ids whose first render is in flight. `render_async` is async, so the
221 /// `LIVE` entry isn't recorded until after the render await — two calls
222 /// fired for one mount would both see `LIVE` empty and both transfer.
223 /// This set is claimed synchronously (before any await) so the second
224 /// call sees the claim and reuses instead of transferring again.
225 static PENDING: RefCell<std::collections::HashSet<String>> =
226 RefCell::new(std::collections::HashSet::new());
227 }
228
229 struct LiveChart {
230 worker: ChartWorker,
231 /// The canvas element this worker's OffscreenCanvas was transferred
232 /// from. Used to tell "rendered again on the same node" (→ update) from
233 /// "node recreated with the same id" (→ fresh transfer).
234 el: web_sys::Element,
235 observer: web_sys::MutationObserver,
236 _on_mutation: Closure<dyn FnMut()>,
237 /// Resize/zoom watchers; dropped (torn down) with this entry.
238 _resize: crate::worker::ResizeWatchers,
239 /// DOM mouse-forwarding listeners; dropped (removed + freed) with this
240 /// entry rather than leaked.
241 _mouse: crate::worker::MouseHandlers,
242 }
243
244 /// Terminate and forget the live worker for `id`, if any.
245 fn teardown(id: &str) {
246 PENDING.with(|p| p.borrow_mut().remove(id));
247 if let Some(live) = LIVE.with(|m| m.borrow_mut().remove(id)) {
248 live.observer.disconnect();
249 live.worker.terminate();
250 // observer + closure are dropped here (off any observer callback).
251 }
252 }
253
254 /// Keep `worker` alive until canvas `el` is removed from the DOM, then
255 /// terminate it. Watches the document subtree and tears down once the
256 /// element is no longer connected.
257 /// Resolve on the next animation frame. Used to defer removing the loading
258 /// hook's DOM until the just-rendered chart has composited to screen, so a
259 /// transparent chart never shows the spinner through it after render.
260 async fn next_animation_frame() {
261 let Some(win) = web_sys::window() else {
262 return;
263 };
264 let promise = js_sys::Promise::new(&mut |resolve, _reject| {
265 let cb = Closure::once_into_js(move |_t: wasm_bindgen::JsValue| {
266 resolve.call0(&wasm_bindgen::JsValue::NULL).ok();
267 });
268 win.request_animation_frame(cb.unchecked_ref()).ok();
269 });
270 wasm_bindgen_futures::JsFuture::from(promise).await.ok();
271 }
272
273 fn keep_until_removed(
274 id: String,
275 el: web_sys::Element,
276 worker: ChartWorker,
277 mouse: crate::worker::MouseHandlers,
278 ) {
279 let cb = {
280 let id = id.clone();
281 let el = el.clone();
282 Closure::<dyn FnMut()>::new(move || {
283 if !el.is_connected() {
284 // Defer the map mutation off the observer's own callback so
285 // the closure isn't dropped while it is executing.
286 let id = id.clone();
287 wasm_bindgen_futures::spawn_local(async move { teardown(&id) });
288 }
289 })
290 };
291 let observer = match web_sys::MutationObserver::new(cb.as_ref().unchecked_ref()) {
292 Ok(o) => o,
293 Err(_) => return, // no observer available; skip auto-teardown
294 };
295 let resize = worker.install_resize_watchers(&el, &id);
296 if let Some(body) = gloo_utils::document().body() {
297 let init = js_sys::Object::from_entries(&js_sys::Array::of2(
298 &js_sys::Array::of2(&"childList".into(), &wasm_bindgen::JsValue::TRUE),
299 &js_sys::Array::of2(&"subtree".into(), &wasm_bindgen::JsValue::TRUE),
300 ))
301 .unwrap_or_else(|_| js_sys::Object::new());
302 if let Err(e) = observer.observe_with_options(&body, init.unchecked_ref()) {
303 gloo_console::warn!(format!(
304 "chart-js-rs: MutationObserver.observe failed; auto-teardown disabled \
305 for this chart: {e:?}"
306 ));
307 }
308 }
309 LIVE.with(|m| {
310 m.borrow_mut().insert(
311 id,
312 LiveChart {
313 worker,
314 el,
315 observer,
316 _on_mutation: cb,
317 _resize: resize,
318 _mouse: mouse,
319 },
320 );
321 });
322 }
323
324 impl WorkerChart {
325 /// Stage a chart on an existing (possibly shared) worker. A concrete
326 /// chart is boxed at the call site: `WorkerChart::on(worker, Box::new(chart))`
327 /// — or just use [`WorkerChartExt::into_worker_chart`].
328 pub fn on(worker: ChartWorker, chart: Box<dyn WorkerChartExt>) -> Self {
329 Self {
330 chart,
331 worker,
332 plugins: String::new(),
333 defaults: String::new(),
334 while_rendering: None,
335 worker_setup: None,
336 }
337 }
338
339 /// Render the chart on the worker. Consumes the builder; the chart moves
340 /// to the worker by pointer and is serialized there.
341 pub async fn render_async(mut self) -> Result<(), Box<dyn Error>> {
342 let id = self.chart.chart_id();
343
344 // A canvas can be transferred to a worker exactly once. If this id is
345 // already live *on the same DOM node* (e.g. `render_async` ran twice
346 // for one mount), don't transfer again — reuse the existing worker
347 // and update it in place.
348 let target = gloo_utils::document().get_element_by_id(&id);
349 let reuse = LIVE.with(|m| {
350 let m = m.borrow();
351 let live = m.get(&id)?;
352 let target = target.as_ref()?;
353 (live.el.is_connected() && js_sys::Object::is(live.el.as_ref(), target.as_ref()))
354 .then(|| live.worker.clone())
355 });
356 if let Some(worker) = reuse {
357 return worker.update(self.chart, id, true).await.map(|_| ());
358 }
359
360 // Race guard: two render_async calls for the same id can both pass
361 // the reuse check above before either registers in LIVE. The first
362 // to reach `transfer_canvas` marks the element synchronously (before
363 // it yields), so if the attribute is already present, a concurrent
364 // call owns this chart — exit cleanly rather than transferring twice.
365 if target
366 .as_ref()
367 .map(|t| t.has_attribute("data-cjsrs-transferred"))
368 .unwrap_or(false)
369 {
370 return Ok(());
371 }
372
373 // Synchronously claim this id before any await. `render_async` is
374 // async and doesn't record `LIVE` until after the render completes,
375 // so without this two concurrent calls for one mount would both see
376 // `LIVE` empty and both transfer the canvas (the second throws). If
377 // the claim is already held, a render is in flight on this node —
378 // drop this duplicate call rather than transfer again.
379 let claimed = PENDING.with(|p| p.borrow_mut().insert(id.clone()));
380 if !claimed {
381 return Ok(());
382 }
383
384 // New, or a recreated node under a reused id: clear any stale live
385 // entry, then render (which transfers the fresh canvas exactly once).
386 // Note: `teardown` clears PENDING, so re-insert the claim after it.
387 if LIVE.with(|m| m.borrow().contains_key(&id)) {
388 teardown(&id);
389 PENDING.with(|p| p.borrow_mut().insert(id.clone()));
390 }
391
392 // Loading hook: hand the consumer the canvas's parent, snapshot the
393 // children that already exist, and let them append a spinner. After
394 // the render we remove only the nodes that appeared in between, so
395 // the canvas and any pre-existing overlays survive.
396 let loading: Option<(web_sys::Element, Vec<web_sys::Node>)> = {
397 let parent = gloo_utils::document()
398 .get_element_by_id(&id)
399 .and_then(|el| el.parent_element());
400 match (self.while_rendering.take(), parent) {
401 (Some(cb), Some(parent)) => {
402 let kids = parent.child_nodes();
403 let mut before = Vec::with_capacity(kids.length() as usize);
404 for i in 0..kids.length() {
405 if let Some(n) = kids.item(i) {
406 before.push(n);
407 }
408 }
409 if let Ok(he) = parent.clone().dyn_into::<web_sys::HtmlElement>() {
410 cb(he);
411 }
412 Some((parent, before))
413 }
414 _ => None,
415 }
416 };
417
418 // Worker-side setup (plugin registration etc.) runs after bootstrap
419 // and before the chart is built, so plugins are registered on the
420 // worker's `Chart` before construction (Chart.js applies plugins and
421 // runs their `beforeInit` at construct time).
422 if let Some(setup) = self.worker_setup.take() {
423 if let Err(e) = self.worker.run_setup(setup).await {
424 PENDING.with(|p| p.borrow_mut().remove(&id));
425 return Err(e);
426 }
427 }
428
429 let worker = self.worker.clone();
430 let result = self
431 .worker
432 .render(self.chart, id.clone(), self.plugins, self.defaults)
433 .await;
434
435 // Release the in-flight claim regardless of outcome.
436 PENDING.with(|p| p.borrow_mut().remove(&id));
437
438 // Remove whatever the loading hook appended (success or error).
439 // Wait one animation frame first so the just-rendered chart has
440 // composited to screen before the hook's DOM is removed — otherwise
441 // a transparent chart briefly shows the spinner through it.
442 if let Some((parent, before)) = loading {
443 next_animation_frame().await;
444 let kids = parent.child_nodes();
445 let mut added = Vec::new();
446 for i in 0..kids.length() {
447 if let Some(n) = kids.item(i) {
448 if !before
449 .iter()
450 .any(|b| js_sys::Object::is(b.as_ref(), n.as_ref()))
451 {
452 added.push(n);
453 }
454 }
455 }
456 for n in added {
457 parent.remove_child(&n).ok();
458 }
459 }
460
461 // Propagate a render error (the mouse handle was already dropped on
462 // the error path inside `ChartWorker::render`); otherwise take it.
463 let mouse = result?;
464
465 // Keep the worker alive (for tooltips / updates) until the canvas
466 // element leaves the DOM, then terminate it. The mouse handle rides
467 // along so its listeners are removed on teardown rather than leaked.
468 if let Some(el) = gloo_utils::document().get_element_by_id(&id) {
469 keep_until_removed(id, el, worker, mouse);
470 }
471 Ok(())
472 }
473
474 /// Update a chart previously rendered on this worker.
475 pub async fn update_async(self, animate: bool) -> Result<bool, Box<dyn Error>> {
476 let id = self.chart.chart_id();
477 self.worker.update(self.chart, id, animate).await
478 }
479
480 /// Run a closure ON THE WORKER after its libraries load and before the
481 /// chart is built. This is where you register custom Chart.js plugins on
482 /// the worker's `Chart` global. Owned Rust state captured by `f` (e.g.
483 /// an `Arc` of resolved data, or a synced `Mutable`) moves to the worker
484 /// with the closure. Runs once, on a fresh render (not on in-place
485 /// updates, where the worker already has its plugins).
486 #[must_use = "\nAppend .render_async()\n"]
487 pub fn on_worker_setup<F>(mut self, f: F) -> Self
488 where
489 F: FnOnce() + Send + 'static,
490 {
491 self.worker_setup = Some(Box::new(f));
492 self
493 }
494
495 /// Show a loading indicator while the worker renders.
496 ///
497 /// `f` is called once, at render start, with the canvas's parent
498 /// element; append your spinner (or anything) to it. Whatever you add
499 /// is removed automatically once the chart is built — including on
500 /// error, so it can't get stuck. Pre-existing children (the canvas, your
501 /// own overlays) are left untouched. Only fires on a fresh render, not
502 /// on in-place updates.
503 #[must_use = "\nAppend .render_async()\n"]
504 pub fn while_rendering(mut self, f: impl FnOnce(web_sys::HtmlElement) + 'static) -> Self {
505 self.while_rendering = Some(Box::new(f));
506 self
507 }
508
509 #[must_use = "\nAppend .render_async()\n"]
510 pub fn plugins(mut self, plugins: impl Into<String>) -> Self {
511 self.plugins = plugins.into();
512 self
513 }
514
515 #[must_use = "\nAppend .render_async()\n"]
516 pub fn defaults(mut self, defaults: impl Into<String>) -> Self {
517 self.defaults = format!("{}\n{}", self.defaults, defaults.into());
518 self
519 }
520 }
521}