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:
parent
cded10cb3b
commit
582fdc5451
4 changed files with 119 additions and 10 deletions
|
|
@ -343,17 +343,15 @@ async function loadViewportBands() {
|
||||||
const bounds = map.getBounds();
|
const bounds = map.getBounds();
|
||||||
const zoom = map.getZoom();
|
const zoom = map.getZoom();
|
||||||
|
|
||||||
// Expand bounds slightly to preload edges
|
// Élargie de 20 % pour précharger les bords, puis bornée : voir
|
||||||
const expandFactor = 0.2;
|
// BMPure.viewportBbox pour le détail (une bbox hors bornes fait répondre
|
||||||
const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
|
// 400 à l'API, et la carte reste vide sans message).
|
||||||
const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
|
const { minLon, minLat, maxLon, maxLat } = BMPure.viewportBbox({
|
||||||
|
south: bounds.getSouth(), west: bounds.getWest(),
|
||||||
|
north: bounds.getNorth(), east: bounds.getEast(),
|
||||||
|
}, 0.2);
|
||||||
|
|
||||||
const bbox = [
|
const bbox = [minLon, minLat, maxLon, maxLat].join(",");
|
||||||
bounds.getWest() - lonDiff,
|
|
||||||
bounds.getSouth() - latDiff,
|
|
||||||
bounds.getEast() + lonDiff,
|
|
||||||
bounds.getNorth() + latDiff
|
|
||||||
].join(",");
|
|
||||||
|
|
||||||
// Build query params for filters
|
// Build query params for filters
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,38 @@ var BMPure = (function () {
|
||||||
return y >= yearFilter.min && y <= yearFilter.max;
|
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. */
|
/** Clé d'agrégation des groupes partageant exactement les mêmes coordonnées. */
|
||||||
function keyFromLatLon(lat, lon) {
|
function keyFromLatLon(lat, lon) {
|
||||||
return `${Number(lat).toFixed(6)},${Number(lon).toFixed(6)}`;
|
return `${Number(lat).toFixed(6)},${Number(lon).toFixed(6)}`;
|
||||||
|
|
@ -141,6 +173,7 @@ var BMPure = (function () {
|
||||||
matchesMacroGenre,
|
matchesMacroGenre,
|
||||||
matchesTheme,
|
matchesTheme,
|
||||||
matchesYear,
|
matchesYear,
|
||||||
|
viewportBbox,
|
||||||
keyFromLatLon,
|
keyFromLatLon,
|
||||||
sortBands,
|
sortBands,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -193,3 +193,38 @@ describe("clé de coordonnées", () => {
|
||||||
), { numRuns: 400 });
|
), { 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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -337,3 +337,46 @@ describe("sortBands", () => {
|
||||||
expect(P.sortBands([], "az")).toEqual([]);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue