/** * vueFiches — B11-M1. Logique pure de la vue fiches (SPEC-vue-fiches §2-§5) : * lecture et écriture de l'URL, filtrage, compteurs, tri, regroupement. * Aucune dépendance à Vue ni au routeur : `composables/useVueFiches.ts` * branche ces fonctions sur la route, `scripts/test-vue-fiches.mjs` les teste. * * Grammaire des filtres : OU dans une dimension, ET entre dimensions. * L'URL est la source de vérité ; un paramètre inconnu ou invalide est ignoré. */ import type { MiniFiche } from '../composables/useFicheAdapter' export type EtatVue = 'carte' | 'mixte' | 'fiches' export type TriFiches = 'pertinence' | 'nom' | 'recent' export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches'] export const LIBELLES_TRI: Record = { pertinence: 'Pertinence', nom: 'A → Z', recent: 'Récentes', } export interface ValeurFiltre { valeur: string libelle: string } export interface DimensionFiltre { /** Paramètre d'URL ET clé de `MiniFiche.etiquettes`. */ cle: string libelle: string valeurs: ValeurFiltre[] variante: 'fonction' | 'echelle' | 'posture' } export interface GroupeDef { /** Valeur de `groupe=` dans l'URL. */ cle: string libelle: string /** Clé de `MiniFiche.etiquettes` : la fiche va sous sa PREMIÈRE étiquette. */ dimension: string /** En-tête des fiches qui n'ont aucune étiquette dans cette dimension. */ sans: string /** Libellés et ordre des groupes, sinon ceux de la dimension de filtre, sinon A → Z. */ valeurs?: ValeurFiltre[] } export interface SousVuesCarte { /** Paramètre d'URL : `mode` sur `/` (existant), `carte` sur `/agences`. */ parametre: string valeurs: ValeurFiltre[] defaut: string } export interface ConfigVueFiches { dimensions: DimensionFiltre[] tris: TriFiches[] groupes: GroupeDef[] etats: EtatVue[] sousVues?: SousVuesCarte } export interface EtatFiches { /** null = pas de `vue=` dans l'URL : défaut par largeur (Mixte sur ordinateur, Fiches sur téléphone). */ vue: EtatVue | null q: string filtres: Record /** `adresse=sans` : seulement les fiches sans coordonnées (lien « N sans adresse »). */ sansAdresse: boolean /** null = défaut automatique (pertinence dès qu'un filtre est actif, sinon nom). */ tri: TriFiches | null /** null = aucun regroupement. */ groupe: string | null sousVue: string } export interface GroupeRendu { cle: string libelle: string fiches: MiniFiche[] } type Query = Record function premiere(v: unknown): string { if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '' return typeof v === 'string' ? v : '' } function liste(v: unknown): string[] { const brut = premiere(v) return [...new Set(brut.split(',').map((s) => s.trim()).filter(Boolean))] } /** Minuscules, sans accents : « Régional » et « regional » se valent. */ export function normaliser(s: string): string { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() } // ── URL ↔ état ───────────────────────────────────────────────────────────── export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches { const vueBrute = premiere(query.vue) as EtatVue const triBrut = premiere(query.tri) as TriFiches const groupeBrut = premiere(query.groupe) const sv = cfg.sousVues const svBrute = sv ? premiere(query[sv.parametre]) : '' const filtres: Record = {} for (const d of cfg.dimensions) filtres[d.cle] = liste(query[d.cle]) return { vue: cfg.etats.includes(vueBrute) ? vueBrute : null, q: premiere(query.q), filtres, sansAdresse: premiere(query.adresse) === 'sans', tri: cfg.tris.includes(triBrut) ? triBrut : null, groupe: cfg.groupes.some((g) => g.cle === groupeBrut) ? groupeBrut : null, sousVue: sv && sv.valeurs.some((v) => v.valeur === svBrute) ? svBrute : (sv?.defaut ?? ''), } } /** * Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas * (`territoire`, `random`…) sont recopiés depuis `base`, `fiche` aussi : un * filtre changé garde la fiche ouverte (elle peut passer « hors de vos filtres »). */ export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record { const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)]) if (cfg.sousVues) geres.add(cfg.sousVues.parametre) const out: Record = {} for (const [k, v] of Object.entries(base)) { if (!geres.has(k) && typeof premiere(v) === 'string' && premiere(v) !== '') out[k] = premiere(v) } if (etat.vue) out.vue = etat.vue if (etat.q.trim()) out.q = etat.q for (const d of cfg.dimensions) { const vals = etat.filtres[d.cle] ?? [] if (vals.length) out[d.cle] = vals.join(',') } if (etat.sansAdresse) out.adresse = 'sans' if (etat.tri) out.tri = etat.tri if (etat.groupe) out.groupe = etat.groupe if (cfg.sousVues && etat.sousVue && etat.sousVue !== cfg.sousVues.defaut) { out[cfg.sousVues.parametre] = etat.sousVue } return out } /** * Ne garde que les valeurs connues de chaque dimension. Une dimension dont la * liste de valeurs est encore vide (données pas chargées) garde tout : le * filtre s'appliquera dès l'arrivée des données, sans perdre le lien partagé. */ export function filtresValides(filtres: Record, cfg: ConfigVueFiches): Record { const out: Record = {} for (const d of cfg.dimensions) { const vals = filtres[d.cle] ?? [] out[d.cle] = d.valeurs.length ? vals.filter((v) => d.valeurs.some((x) => x.valeur === v)) : vals } return out } export function nbFiltresActifs(etat: EtatFiches): number { return Object.values(etat.filtres).reduce((n, v) => n + v.length, 0) + (etat.sansAdresse ? 1 : 0) } // ── Filtrage ─────────────────────────────────────────────────────────────── function porte(f: MiniFiche, dimension: string, valeur: string): boolean { return (f.etiquettes?.[dimension] ?? []).includes(valeur) } export function correspondRecherche(f: MiniFiche, q: string): boolean { const n = normaliser(q.trim()) if (!n) return true return [f.nom, f.ville, f.resume, f.texte].some((t) => !!t && normaliser(t).includes(n)) } /** * Fiches qui passent la recherche, `adresse=sans` et les filtres de toutes les * dimensions sauf `ignorer` (sert aux compteurs). OU dans une dimension, ET entre. */ export function filtrerFiches( fiches: MiniFiche[], etat: EtatFiches, cfg: ConfigVueFiches, ignorer?: string, ): MiniFiche[] { return fiches.filter((f) => { if (etat.sansAdresse && f.geoloc) return false if (!correspondRecherche(f, etat.q)) return false for (const d of cfg.dimensions) { if (d.cle === ignorer) continue const vals = etat.filtres[d.cle] ?? [] if (vals.length && !vals.some((v) => porte(f, d.cle, v))) return false } return true }) } /** * Compteur de chaque chip : fiches qui portent cette étiquette parmi celles que * laissent passer la recherche et les AUTRES dimensions. Une chip active affiche * donc combien de fiches elle apporte, une chip inactive combien on en aurait * en la cochant seule dans sa dimension ; 0 = chip grisée. */ export function compterEtiquettes( fiches: MiniFiche[], etat: EtatFiches, cfg: ConfigVueFiches, ): Record> { const out: Record> = {} for (const d of cfg.dimensions) { const base = filtrerFiches(fiches, etat, cfg, d.cle) const c: Record = {} for (const v of d.valeurs) c[v.valeur] = 0 for (const f of base) for (const v of f.etiquettes?.[d.cle] ?? []) if (v in c) c[v]++ out[d.cle] = c } return out } // ── Tri ──────────────────────────────────────────────────────────────────── export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches { if (etat.tri) return etat.tri const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0) if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence' return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom' } /** Nombre d'étiquettes cochées que porte la fiche. */ export function pertinence(f: MiniFiche, etat: EtatFiches): number { let s = 0 for (const [dim, vals] of Object.entries(etat.filtres)) for (const v of vals) if (porte(f, dim, v)) s++ return s } const collator = new Intl.Collator('fr', { sensitivity: 'base', numeric: true }) function parNom(a: MiniFiche, b: MiniFiche): number { return collator.compare(a.nom ?? '', b.nom ?? '') } function idNum(f: MiniFiche): number { const n = Number(f.id) return Number.isFinite(n) ? n : -Infinity } export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] { const copie = [...fiches] if (tri === 'nom') return copie.sort(parNom) if (tri === 'pertinence') { const score = new Map(copie.map((f) => [f, pertinence(f, etat)])) return copie.sort((a, b) => score.get(b)! - score.get(a)! || parNom(a, b)) } // récent : date décroissante, puis Id décroissant (Entraide : Id = ordre d'arrivée) return copie.sort((a, b) => { if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1 if (!!a.date !== !!b.date) return a.date ? -1 : 1 return idNum(b) - idNum(a) || parNom(a, b) }) } // ── Regroupement ─────────────────────────────────────────────────────────── /** * Une fiche va sous sa PREMIÈRE étiquette de la dimension (sinon le compteur * de groupe ment). Ordre des groupes : celui de `def.valeurs`, sinon de la * dimension de filtre, sinon A → Z ; « sans » en dernier. L'ordre des fiches * dans chaque groupe est celui reçu (déjà trié). */ export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: ConfigVueFiches): GroupeRendu[] { if (!def) return [{ cle: '', libelle: '', fiches }] const ref = def.valeurs ?? cfg.dimensions.find((d) => d.cle === def.dimension)?.valeurs ?? [] const libelle = (v: string) => ref.find((x) => x.valeur === v)?.libelle ?? v const parCle = new Map() for (const f of fiches) { const cle = f.etiquettes?.[def.dimension]?.[0] ?? '' if (!parCle.has(cle)) parCle.set(cle, []) parCle.get(cle)!.push(f) } const rang = (cle: string) => { if (cle === '') return Number.MAX_SAFE_INTEGER const i = ref.findIndex((x) => x.valeur === cle) return i === -1 ? ref.length : i } return [...parCle.keys()] .sort((a, b) => rang(a) - rang(b) || collator.compare(libelle(a), libelle(b))) .map((cle) => ({ cle, libelle: cle === '' ? def.sans : libelle(cle), fiches: parCle.get(cle)! })) } /** Ordre de lecture courant (filtres + tri + regroupement) : le fiche à fiche de B12 le suit. */ export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] { return groupes.flatMap((g) => g.fiches) } // ── Fiche ouverte (B12) ──────────────────────────────────────────────────── /** `fiche=` de l'URL ; null si absent ou vide. L'id reste une chaîne (Entraide : nombre, Réseaux : `f1-rotor`). */ export function lireFiche(query: Query): string | null { const v = premiere(query.fiche).trim() return v || null } /** Query avec `fiche=` posé (id) ou retiré (null), tout le reste recopié tel quel. */ export function queryAvecFiche(query: Query, id: string | number | null): Record { const out: Record = {} for (const [k, v] of Object.entries(query)) { if (k !== 'fiche' && premiere(v) !== '') out[k] = premiere(v) } if (id != null && String(id) !== '') out.fiche = String(id) return out } export interface PositionFiche { /** Rang dans l'ordre courant, de 1 à total. */ rang: number total: number precedente: MiniFiche | null suivante: MiniFiche | null } /** * Place de la fiche dans l'ordre courant (filtres + tri + regroupement). * null = fiche hors de l'ordre courant (« hors de vos filtres ») : pas de * précédente ni de suivante. Pas de bouclage aux extrémités. */ export function positionFiche(ordre: MiniFiche[], id: string | number | null): PositionFiche | null { if (id == null) return null const i = ordre.findIndex((f) => String(f.id) === String(id)) if (i === -1) return null return { rang: i + 1, total: ordre.length, precedente: i > 0 ? ordre[i - 1] : null, suivante: i < ordre.length - 1 ? ordre[i + 1] : null, } } /** * Historique (SPEC §5) : OUVRIR une fiche depuis la liste ajoute une entrée * (le retour du téléphone la referme) ; passer d'une fiche à l'autre la * remplace (le retour ne rejoue pas les vingt fiches parcourues). */ export function modeOuverture(ficheCourante: string | null): 'push' | 'replace' { return ficheCourante == null ? 'push' : 'replace' } /** * FERMER : si l'entrée a été ajoutée par cette page ET que la liste derrière * n'a pas changé depuis (mêmes filtres, tri, vue), on revient en arrière : * l'historique reste propre. Sinon (lien partagé, rechargement, filtre changé * fiche ouverte), on retire `fiche=` sur place : un retour arrière aurait * défait les filtres choisis entre-temps. */ export function modeFermeture(ouverteParLaPage: boolean, listeInchangee: boolean): 'back' | 'replace' { return ouverteParLaPage && listeInchangee ? 'back' : 'replace' } /** Empreinte de la liste derrière la fiche : la query sans `fiche=`, clés triées. */ export function empreinteListe(query: Query): string { const q = queryAvecFiche(query, null) return Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&') }