Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ jobs:
mkdir -p _site
cp bin/forefire.mjs bin/forefire.wasm _site/
cp bindings/wasm/index.html bindings/wasm/worker.mjs \
bindings/wasm/landscape.mjs bindings/wasm/terrain3d.mjs _site/
bindings/wasm/landscape.mjs bindings/wasm/terrain3d.mjs bindings/wasm/overlays.mjs _site/

- name: Upload artifact
if: steps.pages.outputs.publish == 'true'
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ history, so they summarise each release rather than list every change. The

### Added

- The browser demo can draw the DFCI grid, water points and DFCI tracks from
the IGN Géoplateforme, in France. These layers are display only: they never
reach the simulation.
- `pyforefire.run_ensemble` runs one simulation per member in parallel
worker processes and returns their arrival-time maps;
`pyforefire.burn_probability` turns them into a burn-probability map. A
Expand Down
13 changes: 11 additions & 2 deletions bindings/wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ Headless, under Node:
node bindings/wasm/smoke.mjs bin/forefire.mjs
```

In a browser — copy the four page files next to the build outputs and serve
In a browser — copy the five page files next to the build outputs and serve
the directory, since a `file://` page can neither fetch the `.wasm` nor start a
module worker:

```sh
cp bindings/wasm/{index.html,worker.mjs,landscape.mjs,terrain3d.mjs} bin/
cp bindings/wasm/{index.html,worker.mjs,landscape.mjs,terrain3d.mjs,overlays.mjs} bin/
python3 -m http.server -d bin
```

Expand Down Expand Up @@ -161,6 +161,15 @@ perimeter survives but stops advancing, is caught by a burnt-area plateau over
a twelve-step window (`stalled`), and an ignition on bare ground is reported as
`never-caught` rather than as a fire that stopped.

### Reference layers

`overlays.mjs` draws three optional layers from the IGN Géoplateforme, in France
only: the DFCI grid with its square names, water points (cisterns as squares,
the rest as circles) and DFCI tracks. They are **for reading the map and nothing
else**: they never reach the worker, so a run is the same with them on or off.
Each is off by default and fetched only when switched on. BD TOPO shows what is
mapped, not whether a water point is full or a track is passable.

### 3D terrain

`terrain3d.mjs` draws the elevation field as a textured mesh in raw WebGL — no
Expand Down
63 changes: 61 additions & 2 deletions bindings/wasm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Copy the page files next to the build outputs and serve the directory over
http — a file:// page can neither fetch the .wasm nor start a module worker:

cp bindings/wasm/{index.html,worker.mjs,landscape.mjs,terrain3d.mjs} bin/
cp bindings/wasm/{index.html,worker.mjs,landscape.mjs,terrain3d.mjs,overlays.mjs} bin/
python3 -m http.server -d bin
-->
<html lang="en">
Expand Down Expand Up @@ -289,6 +289,7 @@
}
.linkish:hover { color: var(--ink); }
#credit { font-size: 11px; color: var(--dim); margin: 0; }
#referenceNote { font-size: 11px; color: var(--dim); margin: 0.5rem 0 0; }

@media (max-width: 52rem) {
body { grid-template-columns: minmax(0, 1fr); overflow: auto; }
Expand Down Expand Up @@ -385,6 +386,12 @@ <h2>View</h2>
<button class="chip on" id="showArrival" title="Colour the burnt ground by when it caught fire">Arrival</button>
<button class="chip on" id="showSpread" title="Colour the front by its rate of spread">Spread</button>
</div>
<div class="chips" style="margin-top: 0.5rem">
<button class="chip" id="showGrid" title="DFCI grid squares and their names">DFCI grid</button>
<button class="chip" id="showWater" title="Cisterns (squares) and other water points (circles)">Water</button>
<button class="chip" id="showTracks" title="DFCI tracks">DFCI tracks</button>
</div>
<p id="referenceNote" hidden></p>
<div class="field" id="exagRow" hidden>
<label>Relief ×<output id="exagOut">2.5</output></label>
<input id="exag" type="range" min="1" max="6" step="0.5" value="2.5" aria-label="Vertical exaggeration" />
Expand Down Expand Up @@ -444,6 +451,7 @@ <h2>Run</h2>
CLASS_DEFAULTS,
} from "./landscape.mjs";
import { createTerrainView } from "./terrain3d.mjs";
import { REFERENCE, drawReference, inFrance } from "./overlays.mjs";

// startFire[lonlat=(8.70, 41.952, 0)] in tests/runff/run.ff.
const HOME = { lon: 8.7, lat: 41.952, side: 4000 };
Expand Down Expand Up @@ -674,9 +682,52 @@ <h2>Run</h2>
clearTimeout(reloadTimer);
// Wait for the gesture to settle rather than hammer the tile hosts;
// the canvas previews the move with the tiles it already has.
reloadTimer = setTimeout(loadLandscape, 350);
reloadTimer = setTimeout(() => {
loadLandscape();
loadReference();
}, 350);
}

// ---- reference layers --------------------------------------------------

// For reading the map only: none of this reaches the worker.
let reference = {};
let referenceToken = 0;

async function loadReference() {
const token = ++referenceToken;
const wanted = Object.keys(REFERENCE).filter(isOn);
const note = $("referenceNote");
note.hidden = wanted.length === 0;
reference = Object.fromEntries(Object.entries(reference).filter(([id]) => wanted.includes(id)));
dirty = true;
if (!wanted.length) return;

const north = (site.side / 2) / M_PER_DEG_LAT;
const east = (site.side / 2) / mPerDegLon(site.lat);
const bounds = { west: site.lon - east, south: site.lat - north, east: site.lon + east, north: site.lat + north };
if (!inFrance(bounds)) {
reference = {};
note.textContent = "These layers cover France only.";
return;
}
note.textContent = "Display only: the simulation does not use these layers. Indicative data, not for operational use. IGN BD TOPO, Licence Ouverte.";
const got = await Promise.allSettled(wanted.map((id) => REFERENCE[id](bounds, site.side)));
if (token !== referenceToken) return; // a newer pan overtook this one
got.forEach((result, i) => {
if (result.status === "fulfilled") reference[wanted[i]] = result.value;
else console.warn("reference layer unavailable:", wanted[i], result.reason);
});
if (got.some((r) => r.status === "rejected")) note.textContent = "Some reference layers are unavailable.";
dirty = true;
}

for (const id of Object.keys(REFERENCE))
$(id).addEventListener("click", () => {
$(id).classList.toggle("on");
loadReference();
});

function updateSiteReadout() {
$("dCentre").textContent = `${site.lat.toFixed(4)}, ${site.lon.toFixed(4)}`;
$("dSide").textContent = site.side >= 1000 ? `${(site.side / 1000).toFixed(1)} km` : `${site.side.toFixed(0)} m`;
Expand Down Expand Up @@ -939,6 +990,14 @@ <h2>Run</h2>
ctx.restore();
}

drawReference(ctx, reference, (lon, lat) => {
const s = size / site.side;
return [
size / 2 + (lon - site.lon) * mPerDegLon(site.lat) * s,
size / 2 - (lat - site.lat) * M_PER_DEG_LAT * s,
];
});

if (!timeline) {
drawIgnition(size);
setWindBadge(wind.angleDeg);
Expand Down
135 changes: 135 additions & 0 deletions bindings/wasm/overlays.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Reference layers for reading the map: the DFCI grid, water points and DFCI
* tracks, from the IGN Géoplateforme (France only, Licence Ouverte 2.0).
*
* They are drawn and nothing else. The simulation never sees them: no fuel, no
* barrier, nothing goes to the worker. Their job is to let someone name a place
* on the map the way a firefighter would.
*
* Nothing is fetched until a layer is switched on, so a visitor who never asks
* for one makes no request to IGN.
*/

const WFS = "https://data.geopf.fr/wfs/ows";
// WFS CQL takes south,west,north,east in this CRS, and answers lon,lat.
const CRS = "urn:ogc:def:crs:EPSG::4326";
const FRANCE = { west: -5.5, south: 41, east: 10, north: 51.5 };
// The 1 km grid is only readable on a small domain.
const FINE_GRID_MAX_SIDE = 8000;

// The server caps a response at 5000 features and says nothing about the rest, so
// a dense area has to be read page by page or it comes back cut off.
const PAGE = 5000;

async function features(layer, cql, properties) {
const all = [];
for (let start = 0; ; start += PAGE) {
const query = new URLSearchParams({
SERVICE: "WFS",
VERSION: "2.0.0",
REQUEST: "GetFeature",
OUTPUTFORMAT: "application/json",
SRSNAME: "EPSG:4326",
TYPENAMES: layer,
COUNT: PAGE,
STARTINDEX: start,
cql_filter: cql,
});
if (properties) query.set("PROPERTYNAME", properties);
const response = await fetch(`${WFS}?${query}`);
if (!response.ok) throw new Error(`${layer}: HTTP ${response.status}`);
const page = (await response.json()).features;
all.push(...page);
if (page.length < PAGE) return all; // a short page is the last one
}
}

const box = (field, b) => `BBOX(${field},${b.south},${b.west},${b.north},${b.east},'${CRS}')`;

/*! Each layer: how to fetch it for a box, and what to keep. The key is the id of
* the page's chip. */
export const REFERENCE = {
showGrid: async (b, side) => {
const size = side <= FINE_GRID_MAX_SIDE ? "1x1" : "2x2";
const found = await features(`GEOGRAPHICALGRIDSYSTEM.DFCI:carro_dfci_${size}_l93`, box("the_geom", b));
return found.map((f) => ({ name: f.properties.nom, rings: f.geometry.coordinates.flat() }));
},
showWater: async (b) => {
const cql = `nature IN ('Citerne','Point d''eau') AND ${box("geometrie", b)}`;
const found = await features("BDTOPO_V3:detail_hydrographique", cql, "nature,geometrie");
return found.map((f) => ({ cistern: f.properties.nature === "Citerne", at: f.geometry.coordinates }));
},
showTracks: async (b) => {
const cql = `piste_dfci=true AND ${box("geometrie", b)}`;
const found = await features("BDTOPO_V3:troncon_de_route", cql, "cpx_numero,geometrie");
return found.map((f) => ({ number: f.properties.cpx_numero, line: f.geometry.coordinates }));
},
};

export function inFrance(b) {
return b.east > FRANCE.west && b.west < FRANCE.east && b.north > FRANCE.south && b.south < FRANCE.north;
}

function path(ctx, points, project) {
ctx.beginPath();
points.forEach(([lon, lat], i) => {
const [x, y] = project(lon, lat);
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
}

function halo(ctx, text, x, y) {
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
}

/*! `project` takes lon, lat to canvas pixels. */
export function drawReference(ctx, layers, project) {
ctx.save();
ctx.font = "600 11px ui-monospace, Menlo, monospace";
ctx.textAlign = "center";
ctx.lineWidth = 3;
ctx.lineJoin = "round";

for (const track of layers.showTracks ?? []) {
path(ctx, track.line, project);
ctx.strokeStyle = "#ffd166";
ctx.lineWidth = 1.5;
ctx.stroke();
}

for (const cell of layers.showGrid ?? []) {
let first = null;
ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.lineWidth = 1;
for (const ring of cell.rings) {
path(ctx, ring, project);
ctx.stroke();
first ??= ring;
}
const corners = first.map(([lon, lat]) => project(lon, lat));
const xs = corners.map((c) => c[0]);
const ys = corners.map((c) => c[1]);
const wide = Math.max(...xs) - Math.min(...xs);
if (wide < 58) continue; // a label wider than its square is noise
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(0,0,0,0.75)";
ctx.fillStyle = "#fff";
halo(ctx, cell.name, (Math.max(...xs) + Math.min(...xs)) / 2, (Math.max(...ys) + Math.min(...ys)) / 2);
}

for (const point of layers.showWater ?? []) {
const [x, y] = project(...point.at);
ctx.fillStyle = point.cistern ? "#29b6f6" : "#b3e5fc";
ctx.strokeStyle = "#04293a";
ctx.lineWidth = 1.5;
ctx.beginPath();
// A cistern is a square, any other water point a circle, so the two read
// apart without a legend's colours having to carry it.
if (point.cistern) ctx.rect(x - 4, y - 4, 8, 8);
else ctx.arc(x, y, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
Loading