utils/vueFiches.ts porte la logique pure, composables/useVueFiches.ts la branche sur la route ; MiniFiche gagne etiquettes, coords, date, ville, texte. Test : node scripts/test-vue-fiches.mjs (42/42). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCt6e8wWiYqRXLJAuKqP5i
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/**
|
|
* 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<TriFiches, string> = {
|
|
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<string, string[]>
|
|
/** `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<string, unknown>
|
|
|
|
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<string, string[]> = {}
|
|
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`, `fiche` de B12…) sont recopiés depuis `base`.
|
|
*/
|
|
export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
|
|
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<string, string> = {}
|
|
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<string, string[]>, cfg: ConfigVueFiches): Record<string, string[]> {
|
|
const out: Record<string, string[]> = {}
|
|
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<string, Record<string, number>> {
|
|
const out: Record<string, Record<string, number>> = {}
|
|
for (const d of cfg.dimensions) {
|
|
const base = filtrerFiches(fiches, etat, cfg, d.cle)
|
|
const c: Record<string, number> = {}
|
|
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<string, MiniFiche[]>()
|
|
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)
|
|
}
|