fix(web): la carte se vidait en silence dès qu'on dézoomait au niveau monde

Le viewport est élargi de 20 % pour précharger les bords, sans aucun bornage.
Au-delà d'un certain dézoom, la bbox sortait des limites géographiques :

  zoom 2 (monde) -> -164,-88.4,144,110.4

/api/clusters rejette en 400 toute coordonnée hors [-90,90] / [-180,180].
Vérifié contre la vraie validation : les zooms 3 et 4 passent, les zooms 1 et 2
renvoient « Coordinates out of range ». L'utilisateur qui dézoomait ne voyait
donc plus aucun point, sans le moindre message.

Leaflet renvoie par ailleurs des longitudes au-delà de ±180 quand la vue
chevauche plusieurs copies du monde ; le bornage seul produirait alors un
intervalle vide, que l'API rejette aussi. On retombe dans ce cas sur la bande
complète, que la vue couvre de toute façon.

Le calcul part dans pure.js, conformément à la convention du fichier, avec des
cas limites et une propriété fast-check : aucune vue Leaflet imaginable ne doit
produire une bbox que l'API refuse. Vérifié que cette propriété échoue bien sur
l'ancienne implémentation (contre-exemple : east = 180.00000000000003).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-22 13:55:54 +02:00
parent cded10cb3b
commit 582fdc5451
4 changed files with 119 additions and 10 deletions

View file

@ -343,17 +343,15 @@ async function loadViewportBands() {
const bounds = map.getBounds();
const zoom = map.getZoom();
// Expand bounds slightly to preload edges
const expandFactor = 0.2;
const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
// Élargie de 20 % pour précharger les bords, puis bornée : voir
// BMPure.viewportBbox pour le détail (une bbox hors bornes fait répondre
// 400 à l'API, et la carte reste vide sans message).
const { minLon, minLat, maxLon, maxLat } = BMPure.viewportBbox({
south: bounds.getSouth(), west: bounds.getWest(),
north: bounds.getNorth(), east: bounds.getEast(),
}, 0.2);
const bbox = [
bounds.getWest() - lonDiff,
bounds.getSouth() - latDiff,
bounds.getEast() + lonDiff,
bounds.getNorth() + latDiff
].join(",");
const bbox = [minLon, minLat, maxLon, maxLat].join(",");
// Build query params for filters
const params = new URLSearchParams({

View file

@ -102,6 +102,38 @@ var BMPure = (function () {
return y >= yearFilter.min && y <= yearFilter.max;
}
/**
* Bbox du viewport, élargie puis BORNÉE aux limites géographiques.
*
* /api/clusters rejette en 400 toute coordonnée hors [-90,90] / [-180,180],
* ainsi que tout intervalle dégénéré. Sans bornage, l'élargissement de 20 %
* destiné à précharger les bords produisait au niveau monde une bbox du type
* -164,-88.4,144,110.4 : refusée, donc plus aucun point sur la carte et aucun
* message. Leaflet peut de surcroît renvoyer des longitudes au-delà de ±180
* quand la vue chevauche plusieurs copies du monde.
*
* @param {{south:number,west:number,north:number,east:number}} b
* @param {number} expandFactor
* @returns {{minLon:number,minLat:number,maxLon:number,maxLat:number}}
*/
function viewportBbox(b, expandFactor = 0.2) {
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const latDiff = (b.north - b.south) * expandFactor;
const lonDiff = (b.east - b.west) * expandFactor;
const minLat = clamp(b.south - latDiff, -90, 90);
const maxLat = clamp(b.north + latDiff, -90, 90);
let minLon = clamp(b.west - lonDiff, -180, 180);
let maxLon = clamp(b.east + lonDiff, -180, 180);
// Vue à cheval sur plusieurs copies du monde : après bornage l'intervalle
// peut être vide. On demande alors la bande complète, que la vue couvre
// de toute façon.
if (minLon >= maxLon) { minLon = -180; maxLon = 180; }
return { minLon, minLat, maxLon, maxLat };
}
/** Clé d'agrégation des groupes partageant exactement les mêmes coordonnées. */
function keyFromLatLon(lat, lon) {
return `${Number(lat).toFixed(6)},${Number(lon).toFixed(6)}`;
@ -141,6 +173,7 @@ var BMPure = (function () {
matchesMacroGenre,
matchesTheme,
matchesYear,
viewportBbox,
keyFromLatLon,
sortBands,
};

View file

@ -193,3 +193,38 @@ describe("clé de coordonnées", () => {
), { numRuns: 400 });
});
});
describe("viewportBbox — propriétés", () => {
// Les contraintes exactes de /api/clusters. Toute vue Leaflet imaginable doit
// produire une bbox que l'API accepte : sinon la carte se vide sans message.
const acceptable = ({ minLon, minLat, maxLon, maxLat }) =>
minLat >= -90 && maxLat <= 90 &&
minLon >= -180 && maxLon <= 180 &&
minLon < maxLon && minLat < maxLat;
it("aucune vue ne produit une bbox rejetée par l'API", () => {
fc.assert(
fc.property(
// Latitudes dans la plage Web Mercator, longitudes volontairement
// au-delà de ±180 : Leaflet les renvoie ainsi quand la vue chevauche
// plusieurs copies du monde.
fc.double({ min: -85, max: 85, noNaN: true }),
fc.double({ min: -85, max: 85, noNaN: true }),
fc.double({ min: -540, max: 540, noNaN: true }),
fc.double({ min: -540, max: 540, noNaN: true }),
fc.double({ min: 0, max: 1, noNaN: true }),
(a, b, c, d, f) => {
const south = Math.min(a, b);
const north = Math.max(a, b);
const west = Math.min(c, d);
const east = Math.max(c, d);
// Une vue dégénérée (hauteur ou largeur nulle) n'existe pas sur une
// carte réellement affichée.
fc.pre(north > south && east > west);
return acceptable(P.viewportBbox({ south, west, north, east }, f));
}
),
{ numRuns: 500 }
);
});
});

View file

@ -337,3 +337,46 @@ describe("sortBands", () => {
expect(P.sortBands([], "az")).toEqual([]);
});
});
describe("viewportBbox", () => {
const vue = (south, west, north, east) => ({ south, west, north, east });
// Contraintes reprises telles quelles de /api/clusters (app.js) : toute
// violation y renvoie un 400, et la carte reste vide sans message.
const estAcceptableParLAPI = ({ minLon, minLat, maxLon, maxLat }) =>
minLat >= -90 && maxLat <= 90 && minLon >= -180 && maxLon <= 180 &&
minLon < maxLon && minLat < maxLat;
it("élargit la vue pour précharger les bords", () => {
const b = P.viewportBbox(vue(40, 0, 50, 10), 0.2);
expect(b).toEqual({ minLon: -2, minLat: 38, maxLon: 12, maxLat: 52 });
});
it("borne la latitude au niveau monde", () => {
// Sans bornage : maxLat = 110.4 -> 400 « Coordinates out of range ».
const b = P.viewportBbox(vue(-60, -120, 82, 100), 0.2);
expect(b.maxLat).toBe(90);
expect(b.minLat).toBeGreaterThanOrEqual(-90);
expect(estAcceptableParLAPI(b)).toBe(true);
});
it("retombe sur la bande complète quand la vue chevauche plusieurs mondes", () => {
const b = P.viewportBbox(vue(40, 340, 60, 400), 0.2);
expect(b.minLon).toBe(-180);
expect(b.maxLon).toBe(180);
expect(estAcceptableParLAPI(b)).toBe(true);
});
it("produit toujours une bbox que l'API accepte", () => {
const vues = [
vue(38, -10, 60.5, 30), // Europe, vue initiale
vue(25, -25, 70, 45), // dézoom intermédiaire
vue(-60, -120, 82, 100), // monde
vue(-85, -180, 85, 180), // dézoom maximal
vue(-85, -400, 85, 400), // plusieurs copies du monde
vue(0, 0, 0.001, 0.001), // zoom extrême
];
for (const v of vues) {
expect(estAcceptableParLAPI(P.viewportBbox(v, 0.2))).toBe(true);
}
});
});