@@ -361,7 +382,8 @@ View
Fuel
Relief
- Trail
+ Arrival
+ Spread
Relief ×2.5
@@ -735,6 +757,7 @@
Run
function clearRun() {
timeline = null;
+ document.body.classList.remove("ran");
$("scrub").disabled = true;
$("playback").disabled = true;
$("outcome").className = "";
@@ -742,8 +765,65 @@ Run
play(false);
}
+ // Arrival runs from char (early) to ember (late), so the newest ground is
+ // the brightest; spread runs cool to white-hot, and stays legible against
+ // the ember it borders.
+ const ARRIVAL = [[42, 15, 46], [138, 28, 60], [226, 71, 12], [255, 166, 43]];
+ const SPREAD = [[46, 196, 241], [255, 225, 77], [255, 255, 255]];
+
+ function rgb(stops, t) {
+ const x = Math.max(0, Math.min(1, t || 0)) * (stops.length - 1);
+ const i = Math.min(stops.length - 2, Math.floor(x));
+ const f = x - i;
+ const c = stops[i].map((v, k) => Math.round(v + (stops[i + 1][k] - v) * f));
+ return `rgb(${c})`;
+ }
+ const gradient = (stops) => `linear-gradient(90deg, ${stops.map((c) => `rgb(${c})`).join(", ")})`;
+
+ /*! What the drawing needs once per run rather than once per frame: each
+ * frame's fronts as one path in domain metres, the isochrones, and the
+ * two scales' ranges. */
+ function prepare(data) {
+ data.paths = data.frames.map((f) => {
+ const p = new Path2D();
+ for (const c of f.fronts) {
+ p.moveTo(c[0], c[1]);
+ for (let i = 2; i < c.length; i += 2) p.lineTo(c[i], c[i + 1]);
+ p.closePath();
+ }
+ return p;
+ });
+ data.tEnd = Math.max(1, data.frames.at(-1).t);
+
+ // A round interval giving at most eight lines over the run.
+ const minutes = [5, 10, 15, 30, 60, 120, 240];
+ const every = 60 * (minutes.find((m) => data.tEnd / (60 * m) <= 8) ?? 480);
+ data.isochrones = [];
+ for (let t = every; t < data.tEnd; t += every) {
+ const frame = data.frames.findIndex((f) => f.t >= t);
+ if (frame < 0 || !data.frames[frame].fronts.length) continue;
+ // Labelled at the northernmost node, which is rarely under another line.
+ let label = [0, -Infinity];
+ for (const c of data.frames[frame].fronts)
+ for (let i = 0; i < c.length; i += 2) if (c[i + 1] > label[1]) label = [c[i], c[i + 1]];
+ data.isochrones.push({ frame, label, text: clock(t) });
+ }
+
+ // The 95th percentile rather than the maximum: a handful of nodes on a
+ // tight curve run far faster than the head and would wash out the scale.
+ const all = data.frames.flatMap((f) => f.speeds.flatMap((v) => Array.from(v))).sort((a, b) => a - b);
+ data.vMax = Math.max(1e-3, all[Math.floor(0.95 * (all.length - 1))] ?? 0);
+
+ $("arrivalBar").style.background = gradient(ARRIVAL);
+ $("arrivalEnd").textContent = clock(data.tEnd);
+ $("spreadBar").style.background = gradient(SPREAD);
+ $("spreadEnd").textContent = `${(data.vMax * 60).toFixed(1)} m/min`;
+ return data;
+ }
+
function showTimeline(data) {
- timeline = data;
+ timeline = prepare(data);
+ document.body.classList.add("ran");
cursor = 0;
$("scrub").max = String(data.frames.length - 1);
$("scrub").value = "0";
@@ -866,43 +946,87 @@ Run
}
// From the run's domain metres to this view's pixels. The run's square
// sits wherever it sits on the ground, so panning and zooming move the
- // fire with the map instead of clearing it.
+ // fire with the map instead of clearing it. Canvas y grows downward,
+ // the domain's grows upward.
const s = size / site.side;
const { where } = timeline;
const x0 = size / 2 + ((where.lon - site.lon) * mPerDegLon(site.lat) - where.side / 2) * s;
const y0 = size / 2 - ((where.lat - site.lat) * M_PER_DEG_LAT - where.side / 2) * s;
- // Canvas y grows downward, the domain's grows upward.
- const path = (coords) => {
- const p = new Path2D();
- for (let i = 0; i < coords.length; i += 2) {
- const x = x0 + coords[i] * s;
- const y = y0 - coords[i + 1] * s;
- i === 0 ? p.moveTo(x, y) : p.lineTo(x, y);
+ const toCanvas = (x, y) => [x0 + x * s, y0 - y * s];
+ const inMetres = () => ctx.setTransform(s, 0, 0, -s, x0, y0);
+ const frame = timeline.frames[cursor];
+
+ ctx.save();
+ inMetres();
+ if (isOn("showArrival")) {
+ // Arrival time: paint the burnt area latest first and earlier frames
+ // over it, so each point keeps the colour of the first frame that
+ // contained it. Strided past ~150 frames to keep playback smooth.
+ const stride = Math.max(1, Math.ceil(cursor / 150));
+ ctx.globalAlpha = 0.6;
+ for (let i = cursor; i >= 0; i -= stride) {
+ ctx.fillStyle = rgb(ARRIVAL, timeline.frames[i].t / timeline.tEnd);
+ ctx.fill(timeline.paths[i], "evenodd");
+ if (i > 0 && i - stride < 0) i = stride; // always end on frame 0
}
- p.closePath();
- return p;
- };
+ ctx.globalAlpha = 1;
+
+ ctx.lineWidth = 1 / s;
+ ctx.strokeStyle = "rgba(255,255,255,0.55)";
+ for (const iso of timeline.isochrones)
+ if (iso.frame <= cursor) ctx.stroke(timeline.paths[iso.frame]);
+ } else {
+ // One shape, filled even-odd: a front nested inside another is the
+ // edge of ground the fire went round, not more fire.
+ ctx.fillStyle = "rgba(255,70,0,0.35)";
+ ctx.fill(timeline.paths[cursor], "evenodd");
+ }
- if (isOn("showTrail")) {
- ctx.lineWidth = 1;
- const first = Math.max(0, cursor - 60);
- for (let i = first; i < cursor; i += 2) {
- const age = (i - first) / Math.max(1, cursor - first);
- ctx.strokeStyle = `rgba(255,106,19,${0.05 + age * 0.3})`;
- for (const c of timeline.frames[i].fronts) ctx.stroke(path(c));
+ ctx.lineWidth = 3 / s;
+ ctx.lineCap = "round";
+ if (isOn("showSpread")) {
+ // Each segment takes the mean speed of its two nodes.
+ for (let f = 0; f < frame.fronts.length; f++) {
+ const c = frame.fronts[f];
+ const v = frame.speeds[f];
+ const n = c.length / 2;
+ for (let k = 0; k < n; k++) {
+ const j = (k + 1) % n;
+ ctx.strokeStyle = rgb(SPREAD, (v[k] + v[j]) / 2 / timeline.vMax);
+ ctx.beginPath();
+ ctx.moveTo(c[2 * k], c[2 * k + 1]);
+ ctx.lineTo(c[2 * j], c[2 * j + 1]);
+ ctx.stroke();
+ }
}
+ } else {
+ ctx.strokeStyle = "#ffb347";
+ ctx.stroke(timeline.paths[cursor]);
+ }
+ ctx.restore();
+
+ if (isOn("showArrival")) {
+ ctx.save();
+ ctx.font = "600 22px system-ui, sans-serif";
+ ctx.textAlign = "center";
+ ctx.lineWidth = 4;
+ ctx.strokeStyle = "rgba(13,11,10,0.8)";
+ ctx.fillStyle = "#fff";
+ // Later lines first, and none on top of another: once the fire has
+ // reached the domain edge, successive lines share their northernmost
+ // point.
+ const placed = [];
+ for (const iso of [...timeline.isochrones].reverse()) {
+ if (iso.frame > cursor) continue;
+ const [x, y] = toCanvas(...iso.label);
+ if (placed.some(([px, py]) => Math.abs(px - x) < 70 && Math.abs(py - y) < 28)) continue;
+ placed.push([x, y]);
+ ctx.strokeText(iso.text, x, y - 8);
+ ctx.fillText(iso.text, x, y - 8);
+ }
+ ctx.restore();
}
- const frame = timeline.frames[cursor];
- ctx.lineWidth = 2.5;
- ctx.strokeStyle = "#ffb347";
- ctx.fillStyle = "rgba(255,70,0,0.35)";
- // One shape, filled even-odd: a front nested inside another is the edge
- // of ground the fire went round, not more fire, and stays unfilled.
- const burnt = new Path2D();
- for (const c of frame.fronts) burnt.addPath(path(c));
- ctx.fill(burnt, "evenodd");
- ctx.stroke(burnt);
drawIgnition(size);
setWindBadge(frame.windDeg);
}
@@ -944,10 +1068,10 @@ Run
$("dFronts").textContent = f.fronts.length;
}
- for (const id of ["showFuel", "showRelief", "showTrail"])
+ for (const id of ["showFuel", "showRelief", "showArrival", "showSpread"])
$(id).addEventListener("click", () => {
$(id).classList.toggle("on");
- if (id === "showFuel") document.body.classList.toggle("fuel", isOn(id));
+ document.body.classList.toggle(id.slice(4).toLowerCase(), isOn(id));
dirty = true;
});
diff --git a/bindings/wasm/worker.mjs b/bindings/wasm/worker.mjs
index 6a59891..cf4cb4c 100644
--- a/bindings/wasm/worker.mjs
+++ b/bindings/wasm/worker.mjs
@@ -71,26 +71,36 @@ function build(land, model, solver) {
return ff;
}
-/*! Split print[] into one coordinate array per front.
+/*! Split print[] into one coordinate array per front, with each node's
+ * rate of spread alongside.
*
* Fronts nest — an inner front is printed indented inside its parent — so
* every FireFront line starts a fresh polygon. Running them together is what
- * draws spurious chords straight across the burn.
+ * draws spurious chords straight across the burn. The speed is the length of
+ * the node's `vel`, in m/s.
*/
function parseFronts(text) {
const fronts = [];
let current = null;
for (const line of text.split("\n")) {
if (line.includes("FireFront[")) {
- current = [];
+ current = { coords: [], speeds: [] };
fronts.push(current);
} else if (current && line.includes("FireNode[")) {
- const m = /loc=\(([-\d.eE+]+),([-\d.eE+]+),/.exec(line);
- if (m) current.push(parseFloat(m[1]), parseFloat(m[2]));
+ const loc = /loc=\(([-\d.eE+]+),([-\d.eE+]+),/.exec(line);
+ const vel = /vel=\(([-\d.eE+]+),([-\d.eE+]+),/.exec(line);
+ if (loc) {
+ current.coords.push(parseFloat(loc[1]), parseFloat(loc[2]));
+ current.speeds.push(vel ? Math.hypot(parseFloat(vel[1]), parseFloat(vel[2])) : 0);
+ }
}
}
// A front of one or two nodes is a degenerate sliver, not a perimeter.
- return fronts.filter((f) => f.length >= 6).map((f) => Float32Array.from(f));
+ const kept = fronts.filter((f) => f.coords.length >= 6);
+ return {
+ fronts: kept.map((f) => Float32Array.from(f.coords)),
+ speeds: kept.map((f) => Float32Array.from(f.speeds)),
+ };
}
/*! Shoelace area, summed over fronts. Inner fronts wind the other way, so
@@ -148,12 +158,13 @@ function simulate({ land, model, params, steps, dt, wind, ignition }) {
const WINDOW = 12;
const push = (step, ms) => {
- const fronts = parseFronts(ff.execute("print[]"));
+ const { fronts, speeds } = parseFronts(ff.execute("print[]"));
frames.push({
step,
t: ff.getTime(),
ms,
fronts,
+ speeds,
nodes: fronts.reduce((n, f) => n + f.length / 2, 0),
area: burntArea(fronts),
windDeg: windAt(step, wind).deg,
@@ -226,7 +237,7 @@ self.onmessage = ({ data: msg }) => {
const result = simulate(msg.run);
self.postMessage(
{ type: "timeline", ...result },
- result.frames.flatMap((f) => f.fronts.map((c) => c.buffer))
+ result.frames.flatMap((f) => [...f.fronts, ...f.speeds].map((c) => c.buffer))
);
} catch (err) {
self.postMessage({ type: "failed", message: String(err && err.message) });