Lines
0 %
Functions
Branches
100 %
//! Wire the WASM canvas renderer into report pages.
//!
//! Each report fragment emits a `<figure class="report-chart">` with
//! `data-renderer`, `data-chart-src-svg`, and `data-chart-src-json`
//! attributes. When `data-renderer="canvas"`, this module fetches the
//! JSON spec, creates a `<canvas>` sized to the figure's CSS width
//! (scaled by `devicePixelRatio` for `HiDPI`), and draws via
//! `plotting::canvas::render_canvas`. When `data-renderer="svg"` (the
//! default), HTMX already handled the fetch via `hx-get` — nothing to
//! do here.
use js_sys::{JSON, Promise};
use plotting::{ChartSpec, canvas::render_canvas};
use wasm_bindgen::{JsCast, prelude::*};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{Element, HtmlCanvasElement, Request, RequestInit, Response, window};
const INITIALISED_ATTR: &str = "data-chart-initialised";
/// Initialise every unhandled `<figure class="report-chart">` inside
/// `container`. Called on page load and after `htmx:afterSwap`.
#[wasm_bindgen]
pub fn init_report_charts(container: &Element) {
let Ok(figures) = container.query_selector_all("figure.report-chart") else {
return;
};
for i in 0..figures.length() {
let Some(node) = figures.item(i) else {
continue;
let Ok(figure) = node.dyn_into::<Element>() else {
if figure.has_attribute(INITIALISED_ATTR) {
}
let renderer = figure
.get_attribute("data-renderer")
.unwrap_or_else(|| "svg".to_string());
if renderer != "canvas" {
// HTMX handles the SVG path via `hx-get` on the element;
// marking the figure prevents repeated work after swaps.
let _ = figure.set_attribute(INITIALISED_ATTR, "svg");
let Some(url) = figure.get_attribute("data-chart-src-json") else {
let _ = figure.set_attribute(INITIALISED_ATTR, "canvas");
let figure_clone = figure.clone();
spawn_local(async move {
if let Err(err) = fetch_and_draw(&figure_clone, &url).await {
figure_clone.set_inner_html(&format!(
"<div class=\"chart-error\">Chart failed to load: {}</div>",
js_error_message(&err),
));
});
fn js_error_message(value: &JsValue) -> String {
value
.as_string()
.unwrap_or_else(|| "unknown error".to_string())
async fn fetch_and_draw(figure: &Element, url: &str) -> Result<(), JsValue> {
let spec = fetch_chart_spec(url).await?;
let canvas = build_canvas(figure)?;
render_canvas(&spec, &canvas).map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
Ok(())
async fn fetch_chart_spec(url: &str) -> Result<ChartSpec, JsValue> {
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let request = Request::new_with_str_and_init(url, &opts)?;
request.headers().set("Accept", "application/json")?;
let win = window().ok_or_else(|| JsValue::from_str("no window"))?;
let resp_value: JsValue = JsFuture::from(win.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into()?;
if !resp.ok() {
return Err(JsValue::from_str(&format!(
"chart.json request returned {}",
resp.status()
)));
let text_promise: Promise = resp.text()?;
let text: JsValue = JsFuture::from(text_promise).await?;
let text = text
.ok_or_else(|| JsValue::from_str("response body was not text"))?;
// `serde_json` handles the deserialisation; go via `JSON.parse`
// first and `serde_wasm_bindgen` to avoid a second string copy.
let parsed = JSON::parse(&text)?;
serde_wasm_bindgen::from_value(parsed)
.map_err(|e| JsValue::from_str(&format!("invalid chart.json: {e}")))
/// Replace the placeholder inside `figure` with a fresh `<canvas>` sized
/// to the figure's content width. `HiDPI` is handled by
/// `plotting::canvas::render_canvas` (it scales the backing store by
/// `devicePixelRatio`); here we only set the CSS dimensions.
fn build_canvas(figure: &Element) -> Result<HtmlCanvasElement, JsValue> {
let document = window()
.and_then(|w| w.document())
.ok_or_else(|| JsValue::from_str("no document"))?;
let canvas: HtmlCanvasElement = document
.create_element("canvas")?
.dyn_into()
.map_err(|_| JsValue::from_str("failed to cast to HtmlCanvasElement"))?;
let logical_width = figure.client_width().max(300);
let logical_height = (logical_width / 2).max(200);
canvas.set_width(logical_width as u32);
canvas.set_height(logical_height as u32);
let style = canvas.style();
style.set_property("width", &format!("{logical_width}px"))?;
style.set_property("height", &format!("{logical_height}px"))?;
style.set_property("display", "block")?;
figure.set_inner_html("");
figure.append_child(&canvas)?;
Ok(canvas)