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>
182 lines
6.7 KiB
JavaScript
182 lines
6.7 KiB
JavaScript
/**
|
|
* Logique métier pure du site public : normalisation, filtrage, tri.
|
|
*
|
|
* Extraite d'app.js pour être testable. Toutes les fonctions ici sont sans
|
|
* effet de bord et ne lisent AUCUN état global : ce qu'elles filtrent leur est
|
|
* passé en argument. app.js conserve l'état (les Map de cases cochées) et
|
|
* délègue la décision à ce module.
|
|
*
|
|
* Chargé par <script> avant app.js (donc via la globale `BMPure`), et importé
|
|
* en CommonJS par les tests — d'où le pied de fichier. Pas d'étape de build.
|
|
*/
|
|
// `var` volontaire (et non const) : dans un script classique, seul `var` crée
|
|
// une propriété sur l'objet global. app.js s'en sortirait avec `const`, mais
|
|
// pas le harnais de test, qui évalue ce fichier dans un contexte `vm` et lit
|
|
// `sandbox.BMPure` — exactement comme le navigateur lit `window.BMPure`.
|
|
// eslint-disable-next-line no-var
|
|
var BMPure = (function () {
|
|
"use strict";
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
|
);
|
|
}
|
|
|
|
function norm(s) {
|
|
if (s == null || s === "") return "";
|
|
return String(s).trim();
|
|
}
|
|
|
|
/** Extrait une année d'un nombre ou d'un texte libre ("formé en 1991"). */
|
|
function parseYear(val) {
|
|
if (val == null) return null;
|
|
const n = Number(val);
|
|
if (Number.isFinite(n)) return n;
|
|
const m = String(val).match(/(19|20)\d{2}/);
|
|
return m ? Number(m[0]) : null;
|
|
}
|
|
|
|
function splitThemes(val) {
|
|
if (!val) return [];
|
|
if (Array.isArray(val)) return val.map(norm).filter(Boolean);
|
|
return String(val).split(/[,;/]/).map(norm).filter(Boolean);
|
|
}
|
|
|
|
function matchesQuery(b, q) {
|
|
if (!q) return true;
|
|
const themes = Array.isArray(b.themes) ? b.themes.join(" ") : "";
|
|
const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase();
|
|
return hay.includes(q);
|
|
}
|
|
|
|
/**
|
|
* Logique tri-état partagée par les filtres statut / pays / genre.
|
|
*
|
|
* Une valeur ABSENTE de la Map passe (la facette n'existe pas dans le jeu de
|
|
* données courant : on ne veut pas masquer des groupes à cause d'une facette
|
|
* inconnue). Une valeur présente ne passe que si elle est à true.
|
|
* Les valeurs vides retombent sur une clé de repli ("Unknown", "??").
|
|
*/
|
|
function matchesFacet(rawValue, enabledMap, fallbackKey) {
|
|
const v = norm(rawValue);
|
|
const key = v || fallbackKey;
|
|
if (!enabledMap.has(key)) return true;
|
|
return enabledMap.get(key) === true;
|
|
}
|
|
|
|
function matchesMacroGenre(b, macroGenres, macroGenreEnabled, macroActiveCount) {
|
|
if (macroActiveCount === 0) return true;
|
|
const g = norm(b.genre).toLowerCase();
|
|
if (!g) return false;
|
|
return macroGenres.some(
|
|
(m) => macroGenreEnabled.get(m.key) === true && m.terms.some((t) => g.includes(t))
|
|
);
|
|
}
|
|
|
|
function matchesTheme(b, themeEnabled) {
|
|
if (!themeEnabled.size) return true;
|
|
const allOn = Array.from(themeEnabled.values()).every((v) => v === true);
|
|
if (allOn) return true;
|
|
const tokens = Array.isArray(b.themes) ? b.themes : [];
|
|
if (!tokens.length) return false;
|
|
return tokens.some((t) => themeEnabled.get(t) === true);
|
|
}
|
|
|
|
/**
|
|
* Un groupe sans année reste visible tant que l'utilisateur n'a pas resserré
|
|
* la plage ; dès qu'il la resserre, l'absence d'année devient un motif
|
|
* d'exclusion (sinon la sélection « 1990-1995 » ramènerait des groupes sans
|
|
* date, ce qui est incompréhensible).
|
|
*/
|
|
function matchesYear(b, yearFilter, yearRange) {
|
|
if (yearFilter.min == null || yearFilter.max == null) return true;
|
|
if (yearRange.min == null || yearRange.max == null) return true;
|
|
|
|
const isFullRange = yearFilter.min === yearRange.min && yearFilter.max === yearRange.max;
|
|
if (isFullRange) return true;
|
|
|
|
const y = b.formed_year;
|
|
if (!Number.isFinite(y)) return false;
|
|
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)}`;
|
|
}
|
|
|
|
const byName = (x, y) =>
|
|
(x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" });
|
|
|
|
/** Tri stable, sans effet de bord : renvoie une nouvelle liste. */
|
|
function sortBands(arr, mode) {
|
|
const a = [...arr];
|
|
const byField = (f) => (x, y) =>
|
|
(x[f] || "").localeCompare(y[f] || "", "fr", { sensitivity: "base" }) || byName(x, y);
|
|
|
|
if (mode === "status") a.sort(byField("status"));
|
|
else if (mode === "genre") a.sort(byField("genre"));
|
|
else if (mode === "country") a.sort(byField("country"));
|
|
else if (mode === "year") {
|
|
// Les groupes sans année partent en fin de liste (-1).
|
|
a.sort(
|
|
(x, y) =>
|
|
(Number.isFinite(y.formed_year) ? y.formed_year : -1) -
|
|
(Number.isFinite(x.formed_year) ? x.formed_year : -1)
|
|
);
|
|
} else a.sort(byName);
|
|
|
|
return a;
|
|
}
|
|
|
|
return {
|
|
escapeHtml,
|
|
norm,
|
|
parseYear,
|
|
splitThemes,
|
|
matchesQuery,
|
|
matchesFacet,
|
|
matchesMacroGenre,
|
|
matchesTheme,
|
|
matchesYear,
|
|
viewportBbox,
|
|
keyFromLatLon,
|
|
sortBands,
|
|
};
|
|
})();
|
|
|
|
if (typeof module !== "undefined" && module.exports) module.exports = BMPure;
|