From 582fdc5451fcb23966d91a0f6501368d5afec8b8 Mon Sep 17 00:00:00 2001 From: Nicolas FRYDER Date: Sat, 22 Aug 2026 13:55:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20la=20carte=20se=20vidait=20en=20sil?= =?UTF-8?q?ence=20d=C3=A8s=20qu'on=20d=C3=A9zoomait=20au=20niveau=20monde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/site/app.js | 18 ++++++------ apps/web/site/pure.js | 33 ++++++++++++++++++++++ apps/web/test/pure.property.test.js | 35 +++++++++++++++++++++++ apps/web/test/pure.test.js | 43 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 10 deletions(-) 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); + } + }); +});