diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml
index fac65a19..31185fe7 100644
--- a/.github/workflows/wasm.yml
+++ b/.github/workflows/wasm.yml
@@ -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'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73133a4d..85963b1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md
index bb1b47ec..7b98bb3a 100644
--- a/bindings/wasm/README.md
+++ b/bindings/wasm/README.md
@@ -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
```
@@ -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
diff --git a/bindings/wasm/index.html b/bindings/wasm/index.html
index f2e73690..dccfee19 100644
--- a/bindings/wasm/index.html
+++ b/bindings/wasm/index.html
@@ -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
-->
@@ -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; }
@@ -385,6 +386,12 @@
View
+
+
+
+
+
+
@@ -444,6 +451,7 @@
Run
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 };
@@ -674,9 +682,52 @@
Run
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`;
@@ -939,6 +990,14 @@
Run
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);
diff --git a/bindings/wasm/overlays.mjs b/bindings/wasm/overlays.mjs
new file mode 100644
index 00000000..464aac20
--- /dev/null
+++ b/bindings/wasm/overlays.mjs
@@ -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();
+}