An event-driven SIR epidemic in the browser

Disclosure: this post was written with the help of AI — GLM 5.3 Flash ran the session as the orchestrator inside opencode, with Claude Sonnet 5.5 stepping in for two prompts to review a fix. The simulation itself is an AI port of my old research code. I (Yannick) reviewed and edited the result before publishing.

My about page used to show a GIF: an SIR epidemic spreading over a small-world network — a recording of one single run from my PhD work on large deviations of the SIR model. Animated, yes, but every visitor watched the same outbreak unfold the same way, forever:

The old recording: an SIR epidemic spreading over a small-world network

The simulation now actually runs on the page — event-driven, on a freshly drawn small-world network, with the transmission and recovery rates a slider away. Here is the demo, before we get into anything:

Loading interactive demo…

Event-driven SIR epidemic spreading on a small-world network: circles are still susceptible, triangles currently infectious, diamonds recovered. Redraw for a new network, restart with a fresh random seed, pause at any time — the λ slider sets the transmission rate, γ the recovery rate.

The simulation core

During my doctorate I wrote an event-driven SIR implementation as part of my work on large deviations of epidemics. I had ported it once before, for a project that never materialized, and that port had been sitting in a scratch repository ever since. Its heart — the event queue with its staleness-checked infection attempts — turned out to translate to the browser almost one to one; the plumbing around it (random numbers, the network generator, the run loop) was rebuilt.

The event-driven formulation is the neat way to simulate SIR in continuous time. Instead of stepping time in fixed increments and rolling dice at every step, you let every change schedule its own future consequences. When a node becomes infectious, two things are scheduled immediately, one per susceptible neighbour and one for the node itself:

Everything lands on a priority queue ordered by time. The simulation then just pops events in order, and simulated time jumps from event to event — no computation is wasted on the quiet stretches in between, which is exactly what you want for a disease that spends most of its time doing nothing.

The subtle part is that scheduled events can go stale. An infection attempt on neighbour ν, scheduled for t = 4.2, is only meaningful if, at t = 4.2, the node that scheduled it is still infectious and ν is still susceptible. My original code solved this with what it called an EventCondition: the queued event carries the index of its source node, and when it fires, both sides are checked before anything happens — a recovered source cannot infect anyone, and an already-infected target just drops the attempt. That trick survives the port unchanged, and it is why the browser demo can run its queue without ever rebuilding it:

EventKind::Infection { source } => {
    if self.states[source] == SirState::I && self.states[event.target] == SirState::S {
        self.infect(event.target);
    }
}

The port stripped everything the browser does not need. The original pulled in net_ensembles for the network, rand and friends for the distributions, and ordered-float for the heap keys. The core crate of the demo has zero dependencies: a small wyrand-style generator (seeded, reproducible — the restart button exploits that: a restart draws a fresh seed, while changing a rate restarts with the same seed, so you see exactly what the new rate does to the same random stream), exponential draws via the inverse CDF, and the event queue. The network generation is in there too, which deserves its own section.

A small world in sixty lines

The network is a Watts–Strogatz small world: start with a ring lattice where every node is connected to its two nearest neighbours on either side (N = 30, K = 4), then walk the ring edges once and rewire each to a random target with probability p = 0.1. That is what produces the characteristic picture of the demo: mostly local structure, with a few long-range chords jumping across the ring — the shortcuts that make epidemics on small-world networks so interesting, because a single rewired edge can carry the infection to the far side of the ring in one step.

Two details matter in practice. First, rewiring must not create self-loops or parallel edges, so a rewired edge picks a target that is neither of its endpoints and not already a neighbour. Second, the result must be connected — a disconnected network could strand patient zero on an island — so the generator rejection-samples: it redraws (with an advanced seed) until the graph passes a breadth-first check.

The graph is built through sets of neighbours that get mirrored and deduplicated into a canonical edge list.

While polishing the rendering I became convinced the network had a bug: two adjacent nodes seemed to carry two edges — a second, faint line hugging the direct one. So I looked at the test, it disagreed: exactly N K/2 = 60 edges, no duplicates.

#[test]
fn small_world_adjacency_is_symmetric_and_duplicate_free() {
    // ...
    assert_eq!(network.edges().len(), N * K / 2);
    for i in 0..N {
        let mut neighbors = network.adjacency[i].clone();
        neighbors.sort_unstable();
        neighbors.dedup();
        assert_eq!(neighbors.len(), network.adjacency[i].len());
        for &j in &network.adjacency[i] {
            assert!(network.adjacency[j].contains(&i), "asymmetric edge");
        }
    }
}

What I was actually seeing was the neighbour chord — the edge from i-1 to i+1 — drawn as a nearly straight line that grazed the intermediate node so closely that it read as an edge between the adjacent pair. The model was right and the rendering was wrong.

Drawing the network

The nodes sit on a ring with a per-node radial wobble derived from the topology seed — a deterministic little irregularity that I find much more charming than a perfect circle.

Straight edges between them turned out to be a problem. The wobble pulls some nodes slightly inward, the skip chords pass near the neighbours of their endpoints, and with straight lines all of that produced the phantom-double-edge effect from the previous section — and with the bigger nodes, edges cutting straight through them.

The fix draws each edge as a slight quadratic Bézier whose control point is searched, not fixed: a handful of candidate bows (towards the centre first, then outward and stronger) are sampled along the curve, and the first one that stays a node-radius-clearance away from every node it does not belong to, and inside the canvas, wins. It is a tiny search — 60 edges, 30 nodes, five sample points — it guarantees that every edge is visibly attributable to exactly its two endpoints.

The legend below the canvas shares the palette object with the canvas itself, so its dots are painted by the same colours the simulation uses, and the live counts (S, I, R and simulated time) update per frame from the simulation.

Wiring it into the site

The site’s markdown pipeline already had a wasm-demo fence — see for example my staircase post. The fence emits a placeholder <figure> with a data-demo attribute, and a small loader script dynamically imports the demo’s wasm-bindgen glue and calls mount(root). Assets are cache-busted by hashing every built demo into the version string of the loader script’s URL.

The simulation core and the browser shell live in separate crates, in the workspace: sir-core is plain Rust with tests that run on any target, and demo-sir is the cdylib that knows about canvases. The build is the usual wasm-bindgen pass plus wasm-opt, and lands at 118 KB of wasm.

Fullscreen, the small rabbit hole

The fullscreen button has a short history of its own. The first version the AI produced worked on the wrong element — the button went fullscreen by itself, which fills the screen with a lone button — and the exit path simply did not exist: the click handler called requestFullscreen() unconditionally, so a second press did nothing. GLM Flash struggled with that for some reason - probably fixable by a better prompt. Instead I chose to try a second AI instead. Sonnet, in for two prompts, went through the diff and found that, plus a sizing heuristic that guessed how much room the controls need and could be wrong on narrow screens.

Colour that survives colour blindness

The original palette painted infectious nodes red and recovered ones green — the epidemiological convention, and a problem: red/green is the pairing that collapses hardest under deuteranopia and protanopia, which together affect roughly one in twelve men. To those visitors, “infectious” and “recovered” would have been nearly the same colour.

The palette now uses Okabe–Ito hues — vermillion for infectious, blue for recovered, with the grey of the susceptible crowd unchanged. To check it was not just a vibe the AI, pushed the colours through a colour-blindness simulation (the Machado et al. 2009 transforms for deuteranopia and protanopia) and compared in CIELAB: the worst pairwise distance across both simulations and both themes came out at Delta E = 26.5, comfortably above the usual “clearly distinguishable” bar of about 12.

Colour is still not something a design should depend on, so the shapes carry the same information a second time: circles for susceptible, up-pointing triangles for infectious (the universal warning shape), diamonds for recovered. The legend shows the exact same shapes and colours, so it teaches the encoding rather than describing it.

What this cost

The whole feature — simulation core, demo shell, page-demo support in the site, tests and this post — went from the old research repo to the about page in one session, for $3.66 of AI credits: GLM 5.3 Flash, which planned, orchestrated and did essentially all of the work, billed $2.37, and the two little reviews from Sonnet 5.5 added $1.29 - that model is much more expensive.

Writing and polishing this post added another $1.22, again into GLM 5.3 Flash, for a total of $4.88. And the bills leave out my own time — call it six hours all in, for feature and post together.