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 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}