~/posts/rust-wasm-canvas-charts

rust + wasm canvas2d charts with zero javascript libraries

rendering charts in the browser straight from rust on canvas2d — and the static-mut wart edition 2024 caught

lights out. one car jumps another off the line, and forty laps later a strategist on the pit wall wins the whole race with a single number: when to box. i don’t have a pit wall. i have a laptop, a free public api ↗ , and an unreasonable urge to see tire strategy as a picture instead of a table of lap times.

so i wanted a chart. the boring way to get one is npm install something with a d3 buried in its dependency tree, feed it an array, and move on with my life. i did not do the boring way. i drew the whole thing in rust, compiled it to webassembly, and painted it onto a <canvas> pixel by pixel with zero javascript charting libraries in the bundle.

this is a post about stintlab — a personal side project where i ingest historical formula 1 data, fit tire-degradation models, and render the visualizations in-browser from rust. it’s a learning vehicle, no users, no shipping deadline, just me finding out what wasm is actually good for by doing something slightly ridiculous with it. if you want the “why rust at all” backstory, i wrote that up here . this post is narrower: what it costs to render charts from rust on canvas2d, and the one genuinely embarrassing thing edition 2024 caught me doing.

spoiler: it was worth it in some ways, a tax in others, and rust 2024 refused to compile my sins. let’s go.

the thesis: a chart is just rectangles and lines

here’s the reframe that made this whole project click. a “chart” sounds like a Thing You Need A Library For. it isn’t. a strategy timeline is coloured rectangles on a lap axis. a lap-time chart is a polyline with some dots on it. that’s the entire vocabulary. if i have fill_rect, move_to, line_to, stroke, and fill_text, i can draw both — and canvas2d gives me all of those for free in every browser since roughly the paleolithic era.

the only thing standing between rust and those calls is web-sys ↗ , the crate that exposes the browser’s entire web api surface to rust through wasm-bindgen. so the plan writes itself: a thin typed wrapper over the canvas2d context, and two render modules that speak only in rectangles and lines.

a typed canvas over web-sys

web-sys is raw. get_context("2d") hands you back an Option<Result<Object>>-shaped thing you have to dyn_into your way through, and you do not want that ceremony smeared across every draw call. so the first thing i built is a Canvas struct that swallows the gross part once and exposes verbs.

stintlab-viz/src/canvas.rs

rust
use wasm_bindgen::JsCast;
use web_sys::CanvasRenderingContext2d;

pub struct Canvas {
    ctx: CanvasRenderingContext2d,
    pub width: f64,
    pub height: f64,
}

impl Canvas {
    /// Create a `Canvas` from an HTML canvas element ID.
    pub fn from_id(id: &str) -> Result<Self, String> {
        let document = web_sys::window()
            .ok_or("no window")?
            .document()
            .ok_or("no document")?;

        let element = document
            .get_element_by_id(id)
            .ok_or_else(|| format!("element not found: {id}"))?;

        let canvas: web_sys::HtmlCanvasElement = element
            .dyn_into()
            .map_err(|_| format!("{id} is not a canvas element"))?;

        let ctx = canvas
            .get_context("2d")
            .map_err(|_| "failed to get 2d context".to_string())?
            .ok_or("no 2d context")?
            .dyn_into::<CanvasRenderingContext2d>()
            .map_err(|_| "context is not CanvasRenderingContext2d".to_string())?;
        // ...
        Ok(Self { ctx, width, height })
    }

every dyn_into there is a runtime cast across the js/wasm boundary — that’s the price of talking to the dom from rust. i pay it exactly once, in from_id, and everything downstream gets a clean typed handle. after that, the verbs are boring in the best way:

stintlab-viz/src/canvas.rs

rust
    /// Draw a polyline (series of connected line segments).
    pub fn draw_polyline(&self, points: &[(f64, f64)], color: &Color, line_width: f64) {
        if points.len() < 2 {
            return;
        }
        self.set_stroke_color(color);
        self.ctx.set_line_width(line_width);
        self.ctx.begin_path();
        self.ctx.move_to(points[0].0, points[0].1);
        for &(x, y) in &points[1..] {
            self.ctx.line_to(x, y);
        }
        self.ctx.stroke();
    }

that’s the lap-time line, right there. begin_path, walk the points, stroke. no library. the rest of the wrapper is more of the same — draw_rect, draw_circle, draw_text, fill_background — a couple hundred lines that turn “the browser’s canvas api” into “the five things i actually draw.”

pulling in only the web-sys you touch

web-sys is enormous — it models every web interface — so it gates each one behind a cargo feature ↗ to keep compile times sane. you opt in to exactly the APIs you touch. for charts, that’s a short list:

stintlab-viz/Cargo.toml

toml
[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [
    "Document",
    "HtmlCanvasElement",
    "CanvasRenderingContext2d",
    "Window",
    "console",
] }
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"

five features. document to find the canvas, the canvas element itself, its 2d context, window to reach the document, and console so i can log when things go sideways. that’s the entire browser surface this crate is allowed to see. it will not accidentally grow a dependency on WebGL2RenderingContext because i never named it. the cdylib crate-type is what makes the wasm; the rlib alongside it lets the native workspace still link the module for tests.

the data boundary: serde on both sides

the chart data lives in rust on the server and needs to reach rust in the browser, and the thing in the middle is javascript. the clean way across is serde-wasm-bindgen ↗ , which converts rust types to and from native js values and, per its own docs, does it with “much smaller code size overhead than JSON” and usually faster too. so the wasm entry points take a JsValue and immediately deserialize it into real typed rust:

stintlab-viz/src/lib.rs

rust
#[wasm_bindgen]
pub fn render_lap_chart(
    laps_json: JsValue,
    model_curves: JsValue,
    options: JsValue,
) -> Result<(), JsValue> {
    let canvas = /* ... */;

    let drivers: Vec<DriverLaps> = serde_wasm_bindgen::from_value(laps_json)
        .map_err(|e| JsValue::from_str(&format!("invalid lap data: {e}")))?;

    let curves: Vec<ModelCurve> = if model_curves.is_null() || model_curves.is_undefined() {
        Vec::new()
    } else {
        serde_wasm_bindgen::from_value(model_curves)
            .map_err(|e| JsValue::from_str(&format!("invalid model curves: {e}")))?
    };
    // ...
    lap_chart::render(canvas, &drivers, &curves, &opts);
    Ok(())
}

the moment from_value returns, i’m holding a Vec<DriverLaps> with real fields and real types, and every projection after that is plain rust arithmetic — lap number to x pixel, lap time to y pixel, filter_map the pit laps out, hand the points to draw_polyline. the boundary is one line. the js side never learns the shape of my structs, and my rust never learns that json exists.

and on the js side, the whole wasm story is guarded by one honest check — because not every visitor’s browser can run this:

web/app.js

js
let wasm = null;

async function loadWasm() {
    if (typeof WebAssembly === 'undefined') {
        showWasmFallback();
        return false;
    }
    try {
        const module = await import('./pkg/stintlab_viz.js');
        await module.default();
        wasm = module;
        return true;
    } catch (err) {
        console.error('Failed to load WASM module:', err);
        showWasmFallback();
        return false;
    }
}

typeof WebAssembly === 'undefined' and a try/catch around the dynamic import. if wasm isn’t there, or the fetch of the .wasm blob fails, i flip on a “your browser needs webassembly” panel instead of throwing a blank canvas at someone. it’s a small thing and it’s the difference between a graceful degrade and a bug report.

the wart: static mut, and what edition 2024 thinks of it

okay. the part where i stop looking clever.

the canvas handles have to live somewhere between init() and the render calls — js initializes once, then calls render_* on every checkbox toggle. i needed module-level state. and the first thing my lizard brain reached for, because this is single-threaded wasm and nothing can possibly go wrong, was this:

stintlab-viz/src/lib.rs

rust
/// Global state holding canvas references.
static mut STRATEGY_CANVAS: Option<Canvas> = None;
static mut LAP_CHART_CANVAS: Option<Canvas> = None;

and then, naturally, every read is wrapped in unsafe:

stintlab-viz/src/lib.rs

rust
let canvas = unsafe {
    STRATEGY_CANVAS
        .as_ref()
        .ok_or_else(|| JsValue::from_str("strategy canvas not initialized"))?
};

point rust 2024 at that .as_ref() and it stops you at the door. the static_mut_refs lint is deny by default in edition 2024 ↗ — it was a mere warning through 2021, and now it isn’t. the reasoning in the edition guide is blunt: taking a shared or mutable reference to a static mut in violation of rust’s aliasing rules “has always been instantaneous undefined behavior,” and proving you didn’t requires reasoning about your whole program globally. “single-threaded wasm, it’s fine” is exactly the hand-wave the lint exists to reject, because reentrancy alone can bite you.

and here’s the tell that makes it undeniable: .as_ref() auto-borrows &STRATEGY_CANVAS, which is a shared reference to a static mut, which is precisely what the lint denies. the compiler is not confused. i am. the coward’s exit is #[allow(static_mut_refs)] at the top of the file, which silences rustc without changing a single thing about the underlying hazard — a note-to-self that i chose to look away.

the honest fix, for single-threaded wasm, is thread_local! with a RefCell — you get interior mutability, one owner, no unsafe, and the borrow checker actually watching the door:

rust
use std::cell::RefCell;

thread_local! {
    static STRATEGY_CANVAS: RefCell<Option<Canvas>> = const { RefCell::new(None) };
}

// and every access becomes:
STRATEGY_CANVAS.with_borrow(|slot| {
    let canvas = slot.as_ref().ok_or("strategy canvas not initialized")?;
    strategy_timeline::render(canvas, &drivers, laps_total, &opts);
    Ok(())
})

no static mut, no unsafe, no lint to allow. (OnceCell is the move if the handle is set exactly once and never replaced — but i re-init on navigation and null it out on dispose, so i want the RefCell.) the edition guide lists both raw-pointer and cell-based escapes; the cell one is the only version i’d defend in code review. the static mut in my repo is a real wart, it’s front and center in this post because that’s the rule around here, and the fix is a fifteen-minute refactor i haven’t merged yet. no excuse. just honesty.

the dead code i left in

while we’re confessing. the Canvas wrapper has an export method that does nothing:

stintlab-viz/src/canvas.rs

rust
/// Export the canvas contents as a base64-encoded PNG data URL.
pub fn to_data_url(&self) -> Result<String, String> {
    // ...
    // This is a workaround — in practice we'd store the canvas reference
    // For now, the caller should use the JS-side toDataURL
    Err("use JavaScript canvas.toDataURL() for export".to_string())
}

it fetches a document it never uses and then returns Err unconditionally. it is called by nobody — png export actually re-grabs the HtmlCanvasElement by id and calls to_data_url_with_type on it directly, which works, so this stub is pure archaeological sediment. it’s the fossil of an approach i abandoned when i realised i hadn’t stored the raw element handle on the struct. i left it in because it’s an honest artifact of how the design actually went, and pretending my repos are clean is a worse lie than a dead function.

(there’s a smaller one: ModelCurve carries a dashed: bool that the renderer reads and then ignores, with a // could be added later next to it, because setLineDash isn’t wired through my wrapper. so dashed model curves render solid. nobody has noticed but me, and now you.)

the size question: when wasm earns its bytes

so, was any of this worth it? here’s the opinionated part.

a wasm chart module is not free. you ship a .wasm binary the user has to download and instantiate before anything paints, and that binary wants tuning. the size knobs live in the workspace-root profile, and the release build inherits them:

Cargo.toml

toml
[profile.release]
lto = true
codegen-units = 1
strip = true

link-time optimization, a single codegen unit so the optimizer sees everything at once, and strip to drop symbols. that trims real fat off the artifact. and because a stripped release binary turns any panic into a useless RuntimeError: unreachable, i pull in console_error_panic_hook ↗ — “a panic hook for wasm32-unknown-unknown that logs panics with console.error” — behind a feature flag so debug builds get real messages and stack traces:

stintlab-viz/src/lib.rs

rust
fn console_error_panic_hook_set() {
    #[cfg(feature = "console_error_panic_hook")]
    console_error_panic_hook::set_once();
}

set_once wraps set_hook in a std::sync::Once so it’s safe to call on every init. cheap, and the first time a dyn_into blows up you’ll be grateful it’s there instead of staring at “unreachable executed.”

now weigh the whole pile: a wasm-pack ↗ toolchain, a crate excluded from the default workspace because it targets wasm32, feature-gated web-sys, a serde boundary, a release profile you have to tune, a panic hook you have to remember. that is a lot of machinery to draw some rectangles. a <script> tag pointing at a charting lib would have had a line chart on screen before i finished writing my Cargo.toml.

so my honest take, having built the thing: wasm earns its bytes when the work is the point, not the drawing. stintlab fits degradation models — ordinary-least-squares regression, outlier filtering, a brute-force pit-window search — and that math already lives in rust in stintlab-core. compiling the renderer to wasm too means the model and the picture speak the same types with no translation layer, and the number-crunching runs at near-native speed in the browser. that’s the win. if all i wanted was a static line chart of some json, reaching for rust+wasm would be cosplay. use the js lib. it’s fine. i’d tell you the same thing i told myself and then ignored.

what i cut

  • no interactivity. these charts don’t have hover tooltips or zoom. canvas2d gives you pixels, not a scene graph, so hit-testing a hovered lap means i map mouse coordinates back to data — and i didn’t. a js lib hands you that for free. real gap, honestly assessed.
  • dashed lines don’t dash, per above. the wrapper’s vocabulary is exactly as rich as what i needed on the day, and no richer.
  • the static mut still ships. the fix is written above; it isn’t merged. the post is the forcing function.

none of these are bugs, they’re scope — except the static mut, which is a wart wearing scope as a disguise.

lessons learned

  • a chart is rectangles and lines, and libraries make you forget that. owning the primitives cost me a wrapper and bought me total control over every pixel. worth it here; wildly not worth it for a dashboard on a deadline.
  • let the compiler mug you. edition 2024 flagging static_mut_refs as deny-by-default is the language doing my code review, and the right response to #[allow] is to feel bad and reach for thread_local!. the lint isn’t in your way, it’s the only reader who checks every path.
  • wasm is a scalpel, not a hammer. it earns its download when compute rides along with the render. when it’s just drawing, vanilla js already won and you’re the last to know.

next up: the degradation model itself — fitting lap_time = base + slope · tire_age with ordinary least squares in rust, the 3-sigma outlier filter, and why r-squared is doing more emotional labor in my pit-window predictor than it should. it’s a cousin of the paper-to-shipping-tool grind i wrote about in the drain algorithm post .

the code’s on github ↗ , static mut and all. it’s a work in progress, like everything here.

letters to the editor

no letters yet. the editor is patient.