Skip to main content

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}