/** * useFicheAdapter — B3-M2, étendu en B11-M2 * Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par * FicheMiniCard / VueFiches. Aucune mutation des types sources. * * Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas * encore) : première phrase du premier champ non vide parmi une liste de * candidats ordonnée par type, tronquée proprement à ~110 caractères sur un * mot (jamais au milieu). * * B11 : chaque fiche porte ses `etiquettes` par dimension (clé = paramètre * d'URL de la dimension : `fonctions`, `echelle`, `territoire` pour l'Entraide ; * `tags`, `famille`, `pays` pour Réseaux AEP). C'est ce que filtre, trie et * regroupe `utils/vueFiches.ts` ; `chips` et `echelle` restent l'affichage * de la carte-fiche, inchangé. */ import type { Org } from '~/types/org' import type { StructureV2 } from '~/types/structure-v2' export interface MiniFiche { id: string | number nom: string resume: string /** Étiquettes affichées sur la carte-fiche (3 au plus). */ chips: string[] /** Dimension d'où viennent `chips` : les rend cliquables dans la vue fiches. */ chipsDimension?: string echelle?: string geoloc: boolean kind: 'org' | 'structure' | 'mot' /** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */ etiquettes?: Record coords?: [number, number] /** Date ISO si la source en a une (tri « récent »). */ date?: string ville?: string /** Texte cherchable en plus du nom, de la ville et du résumé. */ texte?: string } export const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] const RESUME_MAX = 110 /** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */ function firstSentence(text: string): string { const match = text.match(/^.*?[.!?](?=\s|$)/) return (match ? match[0] : text).trim() } /** Tronque proprement sur un mot, jamais au milieu. */ function truncateOnWord(text: string, max = RESUME_MAX): string { if (text.length <= max) return text const slice = text.slice(0, max) const lastSpace = slice.lastIndexOf(' ') const cut = lastSpace > max * 0.4 ? slice.slice(0, lastSpace) : slice return `${cut.trimEnd()}…` } function buildResume(candidates: Array): string { const source = candidates.find((c) => !!c && c.trim().length > 0) if (!source) return '' return truncateOnWord(firstSentence(source.trim())) } function isStructure(source: Org | StructureV2): source is StructureV2 { return 'famille_principale' in source } function orgFonctions(org: Org): string[] { return (org.tags_fonction ?? '') .split(',') .map((f) => f.trim()) .filter(Boolean) } function coordsDe(lat?: number | null, lon?: number | null): [number, number] | undefined { return lat != null && lon != null ? [lat, lon] : undefined } /** Hashtag sans son « # » : c'est la forme de l'URL (`tags=reemploi-structurel`). */ export function sansDiese(tag: string): string { return tag.replace(/^#/, '') } /** Familles d'une structure : principale, secondaires, et 6 pour le badge recherche politique. */ function famillesDe(s: StructureV2): string[] { const ids = [s.famille_principale, ...(s.familles_secondaires ?? [])] if (s.badges?.f6_recherche_politique) ids.push(6 as any) return [...new Set(ids.map(String))] } export function toMini(source: Org | StructureV2): MiniFiche { if (isStructure(source)) { const hashtags = source.hashtags ?? [] return { id: source.id, nom: source.nom, resume: buildResume([source.description_courte, source.description_longue]), chips: hashtags.slice(0, 3), chipsDimension: 'tags', geoloc: source.latitude != null && source.longitude != null, kind: 'structure', etiquettes: { tags: hashtags.map(sansDiese), famille: famillesDe(source), pays: source.pays ? [source.pays] : [], }, coords: coordsDe(source.latitude, source.longitude), ville: source.ville, // Portée de la recherche d'avant B11 sur /agences : description courte et hashtags. texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '), } } const fonctions = orgFonctions(source) return { id: source.Id, nom: source.nom, resume: buildResume([source.description_enrichie, source.description]), chips: fonctions.slice(0, 3), chipsDimension: 'fonctions', echelle: source.echelle, geoloc: source.latitude != null && source.longitude != null, kind: 'org', etiquettes: { fonctions, echelle: source.echelle ? [source.echelle] : [], territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'], }, coords: coordsDe(source.latitude, source.longitude), date: source.submitted_at, ville: source.localisation_ville, } } // ── Partir de soi (B6-M2) : un mot de la slide 17 = une fiche ───────────── /** Une fiche de `public/data/outils.json` → `partir_de_soi.fiches[]`. */ export interface FicheMot { id: string mot: string resume: string vide?: string | null } /** Une ressource de `partir_de_soi.ressources[]`, reliée à ses mots par `fiches`. */ export interface RessourceMot { id: string titre: string format: string acces: string pourquoi?: string fiches: string[] } /** « don libre » → `don-libre` : la forme d'une valeur de filtre dans l'URL (`acces=don-libre`). */ export function slugEtiquette(s: string): string { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') } /** Valeurs distinctes d'un champ des ressources, dans l'ordre de première apparition (ordre stable des chips). */ export function valeursRessources(ressources: RessourceMot[], champ: 'format' | 'acces'): { valeur: string; libelle: string }[] { const vus = new Map() for (const r of ressources) { const l = r[champ] if (l && !vus.has(slugEtiquette(l))) vus.set(slugEtiquette(l), l) } return [...vus].map(([valeur, libelle]) => ({ valeur, libelle })) } /** * Un mot de la conférence → `MiniFiche`. `ressources` = celles qui portent ce mot. * Pas de carte géographique : `geoloc: true` (aucune fiche « sans adresse »). * La recherche couvre aussi les titres et les « pourquoi » des ressources. */ export function toMiniMot(fiche: FicheMot, ressources: RessourceMot[]): MiniFiche { const formats = [...new Set(ressources.map((r) => r.format).filter(Boolean))] const acces = [...new Set(ressources.map((r) => r.acces).filter(Boolean))] return { id: fiche.id, nom: fiche.mot, resume: buildResume([fiche.resume, fiche.vide]), chips: formats.slice(0, 3), chipsDimension: 'format', geoloc: true, kind: 'mot', etiquettes: { format: formats.map(slugEtiquette), acces: acces.map(slugEtiquette), }, texte: [fiche.resume, ...ressources.flatMap((r) => [r.titre, r.pourquoi])].filter(Boolean).join(' '), } } export function useFicheAdapter() { return { toMini, toMiniMot } }