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