diff --git a/apps/web/site/app.js b/apps/web/site/app.js index a6d2c5c..b2d2d39 100644 --- a/apps/web/site/app.js +++ b/apps/web/site/app.js @@ -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({ diff --git a/apps/web/site/pure.js b/apps/web/site/pure.js index d334205..dc6fe10 100644 --- a/apps/web/site/pure.js +++ b/apps/web/site/pure.js @@ -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, }; diff --git a/apps/web/test/pure.property.test.js b/apps/web/test/pure.property.test.js index 4559deb..9c14741 100644 --- a/apps/web/test/pure.property.test.js +++ b/apps/web/test/pure.property.test.js @@ -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 } + ); + }); +}); diff --git a/apps/web/test/pure.test.js b/apps/web/test/pure.test.js index dc3fa4e..776382e 100644 --- a/apps/web/test/pure.test.js +++ b/apps/web/test/pure.test.js @@ -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); + } + }); +});