Extensions optionnelles du composant partagé, sans effet sur / et /agences : tri 'ordre' (ordre du tableau d'entrée) avec config.triDefaut et libellesTri, prop libellesVue (Graphe au lieu de Carte, Voir dans le graphe), adaptateur toMiniMot (kind 'mot'). 22 cas ajoutés à test-vue-fiches (80/80). Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
/**
|
|
* 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<string, string[]>
|
|
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 | null | undefined>): 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<string, string>()
|
|
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 }
|
|
}
|