1
//! Wire the WASM canvas renderer into report pages.
2
//!
3
//! Each report fragment emits a `<figure class="report-chart">` with
4
//! `data-renderer`, `data-chart-src-svg`, and `data-chart-src-json`
5
//! attributes. When `data-renderer="canvas"`, this module fetches the
6
//! JSON spec, creates a `<canvas>` sized to the figure's CSS width
7
//! (scaled by `devicePixelRatio` for `HiDPI`), and draws via
8
//! `plotting::canvas::render_canvas`. When `data-renderer="svg"` (the
9
//! default), HTMX already handled the fetch via `hx-get` — nothing to
10
//! do here.
11

            
12
use js_sys::{JSON, Promise};
13
use plotting::{ChartSpec, canvas::render_canvas};
14
use wasm_bindgen::{JsCast, prelude::*};
15
use wasm_bindgen_futures::{JsFuture, spawn_local};
16
use web_sys::{Element, HtmlCanvasElement, Request, RequestInit, Response, window};
17

            
18
const INITIALISED_ATTR: &str = "data-chart-initialised";
19

            
20
/// Initialise every unhandled `<figure class="report-chart">` inside
21
/// `container`. Called on page load and after `htmx:afterSwap`.
22
#[wasm_bindgen]
23
pub fn init_report_charts(container: &Element) {
24
    let Ok(figures) = container.query_selector_all("figure.report-chart") else {
25
        return;
26
    };
27
    for i in 0..figures.length() {
28
        let Some(node) = figures.item(i) else {
29
            continue;
30
        };
31
        let Ok(figure) = node.dyn_into::<Element>() else {
32
            continue;
33
        };
34
        if figure.has_attribute(INITIALISED_ATTR) {
35
            continue;
36
        }
37
        let renderer = figure
38
            .get_attribute("data-renderer")
39
            .unwrap_or_else(|| "svg".to_string());
40
        if renderer != "canvas" {
41
            // HTMX handles the SVG path via `hx-get` on the element;
42
            // marking the figure prevents repeated work after swaps.
43
            let _ = figure.set_attribute(INITIALISED_ATTR, "svg");
44
            continue;
45
        }
46
        let Some(url) = figure.get_attribute("data-chart-src-json") else {
47
            continue;
48
        };
49
        let _ = figure.set_attribute(INITIALISED_ATTR, "canvas");
50
        let figure_clone = figure.clone();
51
        spawn_local(async move {
52
            if let Err(err) = fetch_and_draw(&figure_clone, &url).await {
53
                figure_clone.set_inner_html(&format!(
54
                    "<div class=\"chart-error\">Chart failed to load: {}</div>",
55
                    js_error_message(&err),
56
                ));
57
            }
58
        });
59
    }
60
}
61

            
62
fn js_error_message(value: &JsValue) -> String {
63
    value
64
        .as_string()
65
        .unwrap_or_else(|| "unknown error".to_string())
66
}
67

            
68
async fn fetch_and_draw(figure: &Element, url: &str) -> Result<(), JsValue> {
69
    let spec = fetch_chart_spec(url).await?;
70
    let canvas = build_canvas(figure)?;
71
    render_canvas(&spec, &canvas).map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
72
    Ok(())
73
}
74

            
75
async fn fetch_chart_spec(url: &str) -> Result<ChartSpec, JsValue> {
76
    let opts = RequestInit::new();
77
    opts.set_method("GET");
78
    opts.set_mode(web_sys::RequestMode::SameOrigin);
79
    let request = Request::new_with_str_and_init(url, &opts)?;
80
    request.headers().set("Accept", "application/json")?;
81

            
82
    let win = window().ok_or_else(|| JsValue::from_str("no window"))?;
83
    let resp_value: JsValue = JsFuture::from(win.fetch_with_request(&request)).await?;
84
    let resp: Response = resp_value.dyn_into()?;
85
    if !resp.ok() {
86
        return Err(JsValue::from_str(&format!(
87
            "chart.json request returned {}",
88
            resp.status()
89
        )));
90
    }
91
    let text_promise: Promise = resp.text()?;
92
    let text: JsValue = JsFuture::from(text_promise).await?;
93
    let text = text
94
        .as_string()
95
        .ok_or_else(|| JsValue::from_str("response body was not text"))?;
96

            
97
    // `serde_json` handles the deserialisation; go via `JSON.parse`
98
    // first and `serde_wasm_bindgen` to avoid a second string copy.
99
    let parsed = JSON::parse(&text)?;
100
    serde_wasm_bindgen::from_value(parsed)
101
        .map_err(|e| JsValue::from_str(&format!("invalid chart.json: {e}")))
102
}
103

            
104
/// Replace the placeholder inside `figure` with a fresh `<canvas>` sized
105
/// to the figure's content width. `HiDPI` is handled by
106
/// `plotting::canvas::render_canvas` (it scales the backing store by
107
/// `devicePixelRatio`); here we only set the CSS dimensions.
108
fn build_canvas(figure: &Element) -> Result<HtmlCanvasElement, JsValue> {
109
    let document = window()
110
        .and_then(|w| w.document())
111
        .ok_or_else(|| JsValue::from_str("no document"))?;
112
    let canvas: HtmlCanvasElement = document
113
        .create_element("canvas")?
114
        .dyn_into()
115
        .map_err(|_| JsValue::from_str("failed to cast to HtmlCanvasElement"))?;
116

            
117
    let logical_width = figure.client_width().max(300);
118
    let logical_height = (logical_width / 2).max(200);
119
    canvas.set_width(logical_width as u32);
120
    canvas.set_height(logical_height as u32);
121
    let style = canvas.style();
122
    style.set_property("width", &format!("{logical_width}px"))?;
123
    style.set_property("height", &format!("{logical_height}px"))?;
124
    style.set_property("display", "block")?;
125

            
126
    figure.set_inner_html("");
127
    figure.append_child(&canvas)?;
128
    Ok(canvas)
129
}