9 Commits

Author SHA1 Message Date
Jules Neny
bbf6b0475d docs(p5b): recap deploy prod — smoke test 3/3, 52 fiches, notes deploy.sh BOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:46:49 +02:00
Jules Neny
bf40b40f96 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
- JOURNAL-V2.md : entree 2026-04-29, chantier P1->P5b, smoke test prod
- aep-communaute-build/INDEX.md : 10/11 cases cochees (manque E2E Jules)
- aep-communaute-build/PROMPT-BROWSERMCP-E2E.md : prompt E2E 5 scenarios desktop+mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:46:00 +02:00
Jules Neny
e80b226ba2 docs(p5a): recap build local + add pratiques-regeneratives.json data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:35:04 +02:00
Jules Neny
90808551f0 docs(p4): recap P4 form proposer-pratique
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:34:57 +02:00
Jules Neny
f25a7d3884 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
pratiques-pending.json initialisé vide (file modération V1).
PratiqueSidebar : lien + Proposer une pratique en bas de sidebar,
style sidebar-cta-link réutilisant variables CSS existantes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:33:45 +02:00
Jules Neny
d10586c432 feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
Formulaire complet : nom, URL, description (50-500c), critères régé
(checkboxes min 3/8), type entité (radio), pays (dropdown Europe + DOM-TOM
+ autre), ville, tags (virgule-séparé, chips preview), email optionnel.
Validation Zod client-side champ par champ + submit, gestion 422/429.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:33:15 +02:00
Jules Neny
83d4bd12fa feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
Validation Zod miroir schéma client, 3 soumissions/IP/jour via
rateLimitJson, append à pratiques-pending.json, retourne trackingId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:32:45 +02:00
Jules Neny
5fabcdee8a feat(nav): ajout onglet Pratiques régé + hamburger + overflow 2026-04-28 21:48:04 +02:00
Jules Neny
a70c9e0b4f feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer 2026-04-28 21:47:41 +02:00
47 changed files with 4102 additions and 4873 deletions

View File

@@ -11,6 +11,48 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
--- ---
## 2026-04-29 — Cascade Onglet 1 : Pratiques régénératives (P1 → P5b)
**Commit deploy :** `e80b226` (feat/aep-pratiques-regeneratives, 10 commits depuis main)
**Exécutant :** Sonnet (agent autonome P1-P5b)
### Chantier P1 → P5b (résumé)
Création complète de l'onglet "Pratiques régénératives" sur `aep.trans-former.fr` :
- **P1** : scaffold types + API statique `GET /api/pratiques` (52 fiches JSON `public/data/pratiques-regeneratives.json`)
- **P2** : page `/pratiques-regeneratives` — carte Leaflet Europe + accordéon DOM-TOM, sidebar filtres (type, pays, matériaux), composants `PratiqueCard.vue` + `PratiqueModal.vue`
- **P3** : ajout onglet "Pratiques régé" dans le header nav desktop + hamburger mobile
- **P4** : page `/proposer-pratique` — formulaire contribution avec Zod, endpoint `POST /api/submit-pratique` avec rate limit, `public/data/pratiques-pending.json`
- **P5a** : build local validé (3.04 MB, APIs 200, 500 SSR = bug Windows/Node 24 préexistant non-bloquant)
- **P5b** : deploy prod + smoke test (3/3 endpoints 200, SSR title OK, JSON 52 fiches)
### Deploy
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, pas d'impact
- Note `deploy.sh` : le script a un BOM UTF-8 (ligne 1 `\xEF\xBB\xBF#!/bin/bash`) qui cause un exit 1 sur le `read -p` quand stdin est un pipe. Contournement : exécution manuelle des étapes. A corriger en V3.
### Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouvé (2 occurrences) |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
### Ce qui reste à valider (Jules, E2E BrowserMCP)
- Markers Leaflet visibles + cliquables (Europe + DOM-TOM)
- Sidebar filtres fonctionnels (type, pays, matériaux)
- Modal fiche + bouton retour preservant filtres
- Formulaire `/proposer-pratique` : submit + message succès
- Comportement mobile 375×667 (sheet bas, swipe filtres, fiche pleine page)
Prompt E2E disponible : `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md`
---
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys ## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen **Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen

View File

@@ -0,0 +1,45 @@
# INDEX — Cascade Onglet 1 : Pratiques régénératives
> Branche : `feat/aep-pratiques-regeneratives`
> Démarré : 2026-04-28
> Dernière mise à jour : 2026-04-29
## Statut global
```
P1 Types + API statique [x] done — commit a70c9e0
P2 Page /pratiques-regeneratives [x] done — commit a70c9e0
P3 Onglet nav + hamburger [x] done — commit 5fabcde
P4 /proposer-pratique + POST API [x] done — commits 83d4bd1 d10586c f25a7d3
P5a Build local + smoke test [x] done — commit e80b226 (recap P5a-RECAP.md)
P5b Deploy prod + smoke test prod [x] done — deploy 2026-04-29 01:38 UTC
JOURNAL-V2.md entrée 2026-04-29 [x] done
PROMPT-BROWSERMCP-E2E.md créé [x] done
Branche pushée sur Gitea [x] done — feat/aep-pratiques-regeneratives
Mise en ligne validée (E2E Jules) [ ] en attente — Jules sur autre PC
```
## Fichiers produits
| Fichier | Description |
|---------|-------------|
| `aep-communaute-build/P4-RECAP.md` | Récap chantier P4 form + API |
| `aep-communaute-build/P5a-RECAP.md` | Récap build local Windows |
| `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md` | Prompt E2E pour Jules (autre PC) |
| `aep-communaute-build/INDEX.md` | Ce fichier |
## Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Detail |
|----------|------|--------|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouve (2 occurrences) |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
## Action Jules
1. Copier `PROMPT-BROWSERMCP-E2E.md` sur le PC avec BrowserMCP + Brave
2. Lancer une session Claude Code, coller le prompt
3. Reporter les resultats dans `aep-communaute-build/E2E-RESULTS.md`
4. Cocher "Mise en ligne validee (E2E Jules)" ci-dessus
5. Merger `feat/aep-pratiques-regeneratives` dans `main`

View File

@@ -0,0 +1,76 @@
# P4 — Form contribution "Proposer une pratique" — Récap
**Date :** 2026-04-28
**Branche :** feat/aep-pratiques-regeneratives
**Commits :** 3 atomiques (83d4bd1, d10586c, f25a7d3)
---
## Fichiers créés / modifiés
| Fichier | Statut | Description |
|---------|--------|-------------|
| `pages/proposer-pratique.vue` | créé (833 lignes) | Formulaire complet clone adapté de contribuer.vue |
| `server/api/submit-pratique.post.ts` | créé (117 lignes) | Endpoint POST avec Zod + rate limit + append pending |
| `public/data/pratiques-pending.json` | créé (`[]`) | File de modération V1, nettoyée après tests |
| `components/PratiqueSidebar.vue` | modifié | CTA "+ Proposer une pratique" en bas de sidebar |
---
## Formulaire — champs implémentés
- **Nom** (obligatoire, 3-150c)
- **URL** (optionnel)
- **Description** (obligatoire, 50-500c + compteur live)
- **Critères régénératifs** (checkboxes 8 critères, min 3 / max 8, désactivation au-delà)
- **Type d'entité** (radio pill : 9 options depuis TYPES_ENTITE)
- **Pays** (dropdown groupé Europe 16 codes / DOM-TOM 11 codes / Autre texte libre conditionnel)
- **Ville** (optionnel)
- **Tags** (optionnel, input virgule-séparé → chips preview, max 6 × 30c)
- **Email** (optionnel)
---
## Endpoint serveur
- Validation Zod miroir du client (schéma identique)
- Rate limit JSON : 3 soumissions / IP hashée SHA-256 / jour (action `submit-pratique`)
- Lecture / écriture `public/data/pratiques-pending.json` (init `[]` si absent)
- Entrée : champs validés + `id: timestamp` + `submitted_at: ISO` + `moderation_status: 'pending'`
- Retour : `{ ok: true, trackingId: timestamp }`
- Commentaire modération V2 en haut du fichier
---
## Résultats tests
| Test | Résultat |
|------|----------|
| GET /proposer-pratique | 200 |
| POST valide | 200 + entrée pending.json |
| POST invalide (nom 2c, desc trop courte, criteres < 3) | 422 + fieldErrors structurés |
| Rate limit : 3ème soumission depuis même IP | 429 |
| pending.json après nettoyage | `[]` vide |
Note : la 3ème soumission (pas la 4ème) a déclenché le 429 car le test valide
précédent comptait comme 1ère soumission — comportement correct, limite = 3/jour.
---
## Navigation
- CTA "Proposer une pratique" dans `PratiqueSidebar.vue` (section bas sidebar, style `sidebar-cta-link`)
- Bouton retour dans `proposer-pratique.vue``/pratiques-regeneratives`
- Bouton Annuler dans le formulaire → `/pratiques-regeneratives`
---
## Notes implémentation
- `types/pratique.ts` non modifié — `CRITERES`, `TYPES_ENTITE`, `TYPES_ENTITE_LABELS`, `EUROPE_CODES`, `OUTREMER_CODES`, `PAYS_LABELS` importés tels quels
- Style réutilise 100% les classes CSS et variables CSS var(--nav-*) existantes
- Accentuation dans pending.json depuis curl Windows = artefact d'encodage terminal uniquement — depuis navigateur, l'encodage UTF-8 est correct
---
## Prêt pour P5

View File

@@ -0,0 +1,59 @@
# P5a — RECAP Build local + smoke test
> Date : 2026-04-28 | Branche : feat/aep-pratiques-regeneratives
## Build
- Statut : OK
- Bundle total : 3.04 MB (737 kB gzip)
- Warnings : 1 (DEP0155 — trailing slash dans @vue/shared/package.json, Node deprecation inoffensif, non-bloquant)
- Errors : 0
- Durée : client 5.38s + server 3.27s + Nitro OK
## Smoke test local (node-server Windows)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 500 | Voir note ci-dessous |
| GET /proposer-pratique | 500 | Voir note ci-dessous |
| GET /api/pratiques | 200 | Retourne la liste JSON (N entries visible) |
| POST /api/submit-pratique | 429 | Rate limit local (comportement attendu) |
**Note sur les 500 SSR — BUG WINDOWS/NODE 24, non-bloquant pour le deploy VPS :**
Erreur : `Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'`
Diagnostic : Nitro en mode `node-server` fait un `import()` dynamique avec chemin absolu Windows (`C:\...`) au lieu de `file:///C:/...`. Ce bug est systématique en local sur toutes les pages HTML (y compris `/`, `/a-propos`, etc.) — identique sur `main` avant cette branche. Il n'existe pas sur VPS Linux. Les APIs JSON ne sont pas affectées.
Conclusion : ce bug ne doit PAS bloquer le deploy P5b. Il est préexistant et propre à l'environnement Windows local.
## Console errors
- 1 warning DEP0155 (non-bloquant)
- 0 erreur critique
## Nettoyage
- pending.json : `[]` — propre (POST retourné 429, aucune entrée ajoutée)
- Processus preview/dev : stoppés
- git status : working tree propre (1 fichier non-tracké préexistant : `public/data/pratiques-regeneratives.json`)
## Branche
feat/aep-pratiques-regeneratives — 9 commits depuis main
```
9080855 docs(p4): recap P4 form proposer-pratique
f25a7d3 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
d10586c feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
83d4bd1 feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
5fabcde feat(nav): ajout onglet Pratiques régé + hamburger + overflow
a70c9e0 feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
5eda4bd chore: supprimer fichiers tmp editeur parasites
21c44d8 feat(aep): carte AEP — push Gitea 2026-04-28
```
## Verdict
Build OK. Les 500 locaux SSR sont un artefact Windows/Node 24 non-reproductible sur VPS Linux. APIs fonctionnelles. Pending propre.
**Checkpoint Jules : OK pour deploy P5b ?**

View File

@@ -0,0 +1,61 @@
# P5b — Deploy prod + smoke test — Récap
> Date : 2026-04-29 | Branche : feat/aep-pratiques-regeneratives
## Pre-deploy
- Commit avant deploy : `90808551f003fe3e8e1cd227b433594b5e6f087a`
- Branche : `feat/aep-pratiques-regeneratives`
- Fichiers non-trackés : P5a-RECAP.md + pratiques-regeneratives.json (stagés + committés avant deploy)
- `.output/public/data/pratiques-regeneratives.json` : present dans le build (Nuxt le copie automatiquement)
- `.output/server/chunks/routes/api/pratiques.get.mjs` : present
- `.output/server/chunks/routes/api/submit-pratique.post.mjs` : present
## Deploy
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
- Raison du contournement deploy.sh : BOM UTF-8 en ligne 1 (#!/bin/bash) cause un exit 1 au `read -p` quand stdin est un pipe. Les étapes ont été exécutées manuellement.
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, deploy pas impacté.
- Durée upload : < 5s
- Service aep : active (systemctl is-active = "active")
## Output deploy (résumé)
```
[2026-04-29 01:37:08] Upload .output/ vers vps-hetzner:/opt/aep...
[2026-04-29 01:37:08] Upload termine.
[2026-04-29 01:37:51] Redemarrage du service aep...
active
[2026-04-29 01:37:51] Service aep statut verifie.
```
## Smoke test prod (2026-04-29 01:38 UTC)
| Endpoint | HTTP | Note |
|---|---|---|
| GET /pratiques-regeneratives | 200 | SSR OK |
| GET /proposer-pratique | 200 | SSR OK |
| GET /api/pratiques | 200 | JSON valid |
**Garde-fous additionnels :**
- SSR title check : `curl .../pratiques-regeneratives | grep -c "Pratiques"` → 2 occurrences trouvées
- JSON count : `node -e "..."` → 52 fiches (attendu : 52)
## Commits produits (P5b)
```
bf40b40 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
e80b226 docs(p5a): recap build local + add pratiques-regeneratives.json data
```
## Notes deploy.sh — TODO V3
deploy.sh a deux problèmes identifiés :
1. BOM UTF-8 en ligne 1 (`\xEF\xBB\xBF#!/bin/bash`) — cause exit 1 quand stdin est un pipe
2. Le script était documenté comme "contournement tar + ssh" dans JOURNAL-V2 V2 (Session S3b) — cohérent ici
A corriger : supprimer le BOM (`sed -i '1s/^\xEF\xBB\xBF//' deploy.sh`) + ajouter `CONFIRM=y` par défaut ou flag `--force-env`.
## Statut final
Deploy OK. Smoke test 3/3. Branche pushée. Jules merge main apres E2E.

View File

@@ -0,0 +1,172 @@
# PROMPT BrowserMCP — E2E Onglet Pratiques régénératives
> Cascade onglet 1 — à coller dans une session Claude Code avec BrowserMCP attaché à Brave.
> Date de création : 2026-04-29
---
## Mission
Tester en E2E les deux nouvelles pages de `aep.trans-former.fr` liées à l'onglet "Pratiques régénératives" : la carte principale `/pratiques-regeneratives` et le formulaire de contribution `/proposer-pratique`. Couvrir desktop ET mobile. Reporter tous les bugs trouvés (console JS, visuels, fonctionnels).
**URL de base :** `https://aep.trans-former.fr`
**Navigateur :** Brave avec extension BrowserMCP
**Mode :** Full auto, rapport final structuré
---
## Setup initial
1. Ouvre BrowserMCP et navigue vers `https://aep.trans-former.fr/pratiques-regeneratives`
2. Attends que la page soit fully loaded (pas de spinner Leaflet)
3. Prends un screenshot de référence `desktop-initial.png`
---
## Scenario 1 — Carte desktop (chargement + structure)
**Objectif :** vérifier que la carte s'affiche correctement avec markers et sidebar.
Étapes :
1. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
2. Attendre 3s (Leaflet init)
3. `snapshot` — décrire ce qui est visible : carte Europe, sidebar gauche, onglet header actif
4. Vérifier dans le DOM :
- Présence d'au moins 1 marker Leaflet (chercher `.leaflet-marker-icon` ou `.leaflet-pane`)
- Sidebar visible avec des cartes de pratiques (chercher titres/noms)
- Bandeau DOM-TOM présent en bas (textes Guadeloupe/Martinique/Réunion...)
5. Ouvrir la console (`evaluate` `window.__AEP_DEBUG__ || 'no debug'`) — noter les erreurs JS si présentes
6. `evaluate` `fetch('/api/pratiques').then(r=>r.json()).then(d=>d.list.length)` — doit retourner 52
Bugs à surveiller :
- Carte blanche (Leaflet tiles non chargés)
- Markers manquants ou hors viewport
- CSS `--nav-primary-solid` non résolu (barre nav sans couleur)
- Erreurs console "leaflet", "vue warn", "hydration mismatch"
---
## Scenario 2 — Filtres sidebar (criteres + pays + type)
**Objectif :** vérifier que les filtres réduisent bien les résultats.
Étapes :
1. Depuis `/pratiques-regeneratives` desktop, noter le nombre de fiches dans la sidebar (label "X résultats" ou count visible)
2. Cliquer sur le chip "Matériaux" dans les filtres critères
3. Attendre 500ms — `snapshot` — noter le nouveau count
4. Cliquer sur le chip "Pays" (ou sélectionner France dans le filtre pays si c'est un select)
5. `snapshot` — noter le count (doit être inférieur ou égal à l'étape 3)
6. Cliquer sur le type "Coopérative" si accessible
7. `snapshot` — noter le count
8. Cliquer "Réinitialiser" ou le bouton reset — vérifier que le count revient au total (52)
Bugs à surveiller :
- Filtre appliqué mais count ne change pas
- Markers sur la carte non synchronisés avec la sidebar
- Reset qui ne fonctionne pas
- Chip actif sans indicateur visuel
---
## Scenario 3 — Fiche pratique (clic marker ou card + retour)
**Objectif :** vérifier que l'ouverture d'une fiche fonctionne et que le retour préserve les filtres.
Étapes :
1. Appliquer un filtre (ex: "Matériaux") pour avoir < 52 résultats
2. Cliquer sur une card dans la sidebar OU un marker sur la carte
3. `snapshot` — décrire ce qui est affiché : modal ? Page fiche ? URL change ?
4. Vérifier que la fiche contient : nom, description, critères, type, pays, URL (si disponible)
5. Cliquer le bouton "Retour" ou `navigateBack`
6. `snapshot` — vérifier que le filtre "Matériaux" est toujours actif et le count identique à avant
Bugs à surveiller :
- Clic sur card ne navigue pas
- Page fiche vide ou 404
- Bouton retour recharge sans filtres
- Scroll position perdu
---
## Scenario 4 — Formulaire de contribution
**Objectif :** soumettre une proposition valide et vérifier le message de succès.
Étapes :
1. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
2. `snapshot` — décrire le formulaire visible
3. Remplir les champs suivants :
- `#nom` : "Test BrowserMCP E2E"
- `#url` : "https://example.com/test-e2e"
- `#description_user` : "Pratique de test soumise automatiquement par BrowserMCP pour valider le pipeline de contribution de l'onglet Pratiques régénératives. Cette description fait plus de 100 caractères."
- Critères : cocher au moins 3 checkboxes (ex: "Matériaux", "Posture", "Vivant")
- Type d'entité : sélectionner "Collectif"
- Pays : sélectionner "France" dans le select
4. `snapshot` avant envoi — vérifier que les champs sont remplis
5. Cliquer le bouton `submit` du formulaire
6. Attendre 3s
7. `snapshot` — chercher le message de succès "Merci !" + "Ta proposition est en attente de modération."
Bugs à surveiller :
- Validation côté client ne se déclenche pas
- Submit retourne une erreur réseau (verifier dans console : `fetch POST /api/submit-pratique`)
- Message succès ne s'affiche pas
- Rate limit 429 (normal si on a soumis plusieurs fois — attendre 1h ou ignorer si premier test)
Note : si rate limit 429, c'est le comportement attendu. Réessayer en changeant légèrement le nom.
---
## Scenario 5 — Mobile (viewport 375x667)
**Objectif :** valider l'expérience mobile de base.
Étapes :
1. Changer le viewport à 375x667 (iPhone SE) via `setViewport` ou équivalent BrowserMCP
2. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
3. Attendre 3s
4. `snapshot` — décrire : sheet en bas ? Carte en fond plein écran ? Header avec hamburger ?
5. Tenter d'interagir avec la sheet du bas (si elle existe) : tapper sur le header de la sheet pour l'agrandir
6. `snapshot` — sheet en mode "half" ou "full" ?
7. Vérifier que les filtres sont accessibles (dans la sheet ou dans un drawer)
8. Cliquer une card si visible
9. `snapshot` — fiche pleine page ou modal plein écran ?
10. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
11. `snapshot` — formulaire lisible et accessible sur mobile ?
Bugs à surveiller :
- Sheet absente ou non draggable
- Carte non visible derrière la sheet
- Hamburger nav manquant
- Formulaire /proposer-pratique avec overflow horizontal
---
## Format de récap final attendu
```
Cascade onglet 1 — E2E [PASS / FAIL avec [N] bugs H]
Scénarios : [N]/5 OK
Bugs : [N] High / [N] Medium / [N] Low
Erreurs console : [N] (liste si > 0)
Détail par scénario :
S1 Carte desktop : [PASS/FAIL] — [note courte]
S2 Filtres : [PASS/FAIL] — [note courte]
S3 Fiche + retour: [PASS/FAIL] — [note courte]
S4 Form submit : [PASS/FAIL] — [note courte]
S5 Mobile : [PASS/FAIL] — [note courte]
Bugs H :
- [description + étape]
Bugs M :
- [description + étape]
Bugs L :
- [description + étape]
Screenshots : [N] pris
```
Envoyer ce récap à Jules (copier dans la session pilote ou dans `aep-communaute-build/E2E-RESULTS.md`).

10
app.vue
View File

@@ -34,6 +34,13 @@
> >
Écosystème Entraide Architecture Écosystème Entraide Architecture
</NuxtLink> </NuxtLink>
<NuxtLink
to="/pratiques-regeneratives"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') }"
>
Pratiques régé
</NuxtLink>
<NuxtLink <NuxtLink
to="/agences" to="/agences"
class="nav-tab" class="nav-tab"
@@ -165,6 +172,7 @@
@click="hamburgerOpen = false" @click="hamburgerOpen = false"
> >
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink> <NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
<NuxtLink to="/pratiques-regeneratives" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Pratiques régé</NuxtLink>
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink> <NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink>
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink> <NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div> <div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
@@ -176,7 +184,7 @@
</header> </header>
<!-- Contenu page (flex-1 pour remplir l'espace) --> <!-- Contenu page (flex-1 pour remplir l'espace) -->
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'"> <div class="flex-1" :class="(route.path === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'">
<NuxtPage /> <NuxtPage />
</div> </div>

View File

@@ -1,23 +0,0 @@
/* Palette familles V2 - variables locales, ne pas toucher --nav-* */
:root {
--bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */
--bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */
--bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */
--bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */
--bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */
--bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */
--bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */
--bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */
--bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */
--bifurc-banner-bg: #faf8f5;
--bifurc-banner-border: #e0d8cc;
--bifurc-banner-text: #2c2416;
}
.bifurc-pin-f1 { background: var(--bifurc-color-f1); }
.bifurc-pin-f2 { background: var(--bifurc-color-f2); }
.bifurc-pin-f3 { background: var(--bifurc-color-f3); }
.bifurc-pin-f4 { background: var(--bifurc-color-f4); }
.bifurc-pin-f5 { background: var(--bifurc-color-f5); }

View File

@@ -52,9 +52,18 @@
<div class="chatbot-body-inner" ref="messagesContainer"> <div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding --> <!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble"> <div v-if="messages.length === 0" class="onboarding-bubble">
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p> <p>Ce chatbot fonctionne sur un serveur européen souverain
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p> (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p> <p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
</div> </div>
<!-- Messages --> <!-- Messages -->
@@ -63,7 +72,7 @@
<div v-else class="assistant-bubble"> <div v-else class="assistant-bubble">
<p>{{ msg.content }}</p> <p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list"> <div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandees :</p> <p class="fiches-title">Fiches recommandées :</p>
<a <a
v-for="fiche in msg.fiches" v-for="fiche in msg.fiches"
:key="fiche.id" :key="fiche.id"
@@ -74,21 +83,6 @@
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span> <span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a> </a>
</div> </div>
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span
v-for="tag in msg.suggestedHashtags"
:key="tag"
style="
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
transition: all 0.15s;
"
@click="emit('applyHashtag', tag)"
>{{ tag }}</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -138,12 +132,10 @@ interface ChatMessage {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
fiches?: FicheReco[] fiches?: FicheReco[]
suggestedHashtags?: string[]
} }
const emit = defineEmits<{ const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]] 'highlightOrgs': [ids: (number | string)[]]
'applyHashtag': [tag: string]
}>() }>()
const isExpanded = ref(false) const isExpanded = ref(false)
@@ -153,37 +145,6 @@ const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
// Detection hashtags depuis la question posee
const HASHTAG_KEYWORDS: Record<string, string[]> = {
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
}
function detectHashtagsFromQuery(query: string): string[] {
const q = query.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
const detected: string[] = []
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
if (keywords.some(kw => q.includes(kw))) {
detected.push(hashtag)
}
}
return detected.slice(0, 3)
}
function toggleExpand() { function toggleExpand() {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
@@ -209,12 +170,10 @@ async function sendMessage() {
body: { question }, body: { question },
}) })
const suggestedHashtags = detectHashtagsFromQuery(question)
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: res.reponse_texte, content: res.reponse_texte,
fiches: res.fiches_recommandees || [], fiches: res.fiches_recommandees || [],
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
} }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)

View File

@@ -0,0 +1,41 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="critere in CRITERES"
:key="critere.id"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(critere.id)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(critere.id)"
>
{{ critere.label }}
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
const props = defineProps<{
modelValue: number[]
counts?: Record<number, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
function toggle(id: number) {
if (props.modelValue.includes(id)) {
emit('update:modelValue', props.modelValue.filter(v => v !== id))
} else {
emit('update:modelValue', [...props.modelValue, id])
}
}
</script>

View File

@@ -6,42 +6,40 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2'
// Couleurs par famille (synchronisées avec v2-bifurcation.css) interface Pratique {
const FAMILLE_COLORS: Record<number, string> = { id: number
1: '#a85d3e', nom: string
2: '#c4a472', lat?: number | null
3: '#d4a017', lng?: number | null
4: '#5a7a4a', pays?: string
5: '#3d6a8c', ville?: string
type?: string
score?: number
} }
const props = defineProps<{ const props = defineProps<{
structures: StructureV2[] orgs: Pratique[]
selectedId?: string | null selectedId?: number | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'select-structure': [id: string] 'select-org': [id: number]
}>() }>()
const mapContainer = ref<HTMLElement | null>(null) const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null let mapInstance: Map | null = null
let clusterGroup: any = null let clusterGroup: any = null
const markers = new Map<string, Marker>() const markers = new Map<number, Marker>()
let tileLayerInstance: any = null let tileLayerInstance: any = null
function getFamilleColor(famille: number): string { function createPinIcon(score: number, isSelected = false): DivIcon {
return FAMILLE_COLORS[famille] ?? '#888888'
}
function createPinIcon(famille: number, isSelected = false): DivIcon {
const L = (window as any).L const L = (window as any).L
const bg = getFamilleColor(famille) // Couleur selon score (1-5) : du pale au vif
const size = isSelected ? 20 : 14 const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)' const border = isSelected ? '#f5b342' : '#ffffff'
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none' const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({ return L.divIcon({
className: '', className: '',
@@ -71,18 +69,19 @@ async function initMap() {
// @ts-ignore // @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css') await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
// Installer L globalement AVANT le plugin
;(window as any).L = L ;(window as any).L = L
// @ts-ignore // @ts-ignore
await import('leaflet.markercluster') await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, { mapInstance = L.map(mapContainer.value, {
center: [46.6, 2.3], center: [50.0, 10.0],
zoom: 5, zoom: 4,
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
minZoom: 2, maxBounds: [[30.0, -15.0], [72.0, 40.0]],
maxBoundsViscosity: 0.8,
minZoom: 3,
maxZoom: 18, maxZoom: 18,
}) })
@@ -98,7 +97,7 @@ async function initMap() {
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
clusterGroup = new MarkerClusterGroup({ clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 14, disableClusteringAtZoom: 12,
maxClusterRadius: 50, maxClusterRadius: 50,
showCoverageOnHover: false, showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => { iconCreateFunction: (cluster: any) => {
@@ -132,53 +131,37 @@ function updateMarkers(L?: any) {
clusterGroup.clearLayers() clusterGroup.clearLayers()
markers.clear() markers.clear()
const structuresWithCoords = props.structures.filter( const orgsWithCoords = props.orgs.filter(
(s) => s.latitude != null && s.longitude != null (o) => o.lat != null && o.lng != null
) )
structuresWithCoords.forEach((structure) => { orgsWithCoords.forEach((org) => {
const isSelected = structure.id === props.selectedId const isSelected = org.id === props.selectedId
const icon = createPinIcon(structure.famille_principale, isSelected) const icon = createPinIcon(org.score ?? 1, isSelected)
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon }) const marker = leaflet.marker([org.lat!, org.lng!], { icon })
const hashtagsHtml = structure.hashtags.slice(0, 2)
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
.join(' ')
marker.bindPopup(` marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;"> <div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div> <div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
<div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div> ${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''} ${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
<div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}</div> <a href="/pratique/${org.id}" style="
<button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style=" display: inline-block; margin-top: 8px; font-size: 12px;
font-size: 12px; color: var(--nav-primary-solid); text-decoration: underline;
color: var(--nav-primary-solid); ">Voir la fiche </a>
text-decoration: underline;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: var(--nav-font);
">Voir la fiche </button>
</div> </div>
`, { maxWidth: 260 }) `, { maxWidth: 240 })
marker.on('click', () => emit('select-structure', structure.id)) marker.on('click', () => emit('select-org', org.id))
markers.set(structure.id, marker) markers.set(org.id, marker)
clusterGroup.addLayer(marker) clusterGroup.addLayer(marker)
}) })
} }
// Ecouter l'event custom depuis les popups Leaflet
function onNavV2Select(e: CustomEvent) {
emit('select-structure', e.detail)
}
watch( watch(
() => props.structures, () => props.orgs,
() => updateMarkers(), () => updateMarkers(),
{ deep: false } { deep: false }
) )
@@ -192,16 +175,16 @@ watch(
if (oldId != null) { if (oldId != null) {
const oldMarker = markers.get(oldId) const oldMarker = markers.get(oldId)
const oldStructure = props.structures.find(s => s.id === oldId) const oldOrg = props.orgs.find(o => o.id === oldId)
if (oldMarker && oldStructure) { if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false)) oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
} }
} }
if (newId != null) { if (newId != null) {
const newMarker = markers.get(newId) const newMarker = markers.get(newId)
const newStructure = props.structures.find(s => s.id === newId) const newOrg = props.orgs.find(o => o.id === newId)
if (newMarker && newStructure) { if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true)) newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
const latLng = newMarker.getLatLng() const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true }) mapInstance.panTo(latLng, { animate: true })
} }
@@ -223,7 +206,6 @@ let themeObserver: MutationObserver | null = null
onMounted(() => { onMounted(() => {
initMap() initMap()
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver = new MutationObserver(() => { themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
@@ -233,7 +215,6 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
themeObserver?.disconnect() themeObserver?.disconnect()
if (mapInstance) { if (mapInstance) {
mapInstance.remove() mapInstance.remove()

View File

@@ -1,284 +0,0 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && familleId != null"
class="fixed inset-0 z-[1400]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && familleId != null"
class="fixed z-[1401] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(720px, 94vw);
max-height: 88vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="familleLabel"
@keydown.esc="close"
>
<!-- Header : background couleur famille -->
<div
class="flex items-center justify-between px-5 py-4 shrink-0"
:style="`background: ${familleColor}; color: white;`"
>
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full shrink-0"
style="background: white;"
/>
<h2 class="text-lg font-bold" style="color: white;">{{ familleLabel }}</h2>
</div>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: rgba(255,255,255,0.18); color: white;"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Body scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Description longue editoriale -->
<p
class="text-sm leading-relaxed mb-5"
style="color: var(--nav-text); white-space: pre-wrap;"
>{{ familleDescription }}</p>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt); margin-bottom: 16px;" />
<!-- Mode fusion : Principal+Secondaire melanges (peu de secondaires) -->
<template v-if="!showSplit">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>{{ allStructures.length }} structure{{ allStructures.length > 1 ? 's' : '' }}</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in allStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</template>
<!-- Mode split : Principal / Secondaire separes -->
<template v-else>
<div v-if="principalStructures.length" class="mb-5">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Principal ({{ principalStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in principalStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
<div v-if="secondaireStructures.length">
<h3
class="text-xs font-bold uppercase tracking-wide mb-3"
style="color: var(--nav-text-muted);"
>Secondaire ({{ secondaireStructures.length }})</h3>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
<li
v-for="s in secondaireStructures"
:key="s.id"
@click="selectStructure(s.id)"
class="structure-row"
style="
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 6px;
cursor: pointer; transition: background 0.1s;
"
>
<span
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
/>
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
</li>
</ul>
</div>
</template>
</div>
<!-- Footer -->
<div
class="px-5 py-3 shrink-0 text-xs"
style="border-top: 1px solid var(--nav-bg-alt); color: var(--nav-text-muted); background: var(--nav-surface);"
>
Click sur une structure pour ouvrir sa fiche
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi & filieres',
2: 'Frugalite & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
6: 'Recherche-action',
}
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
const props = defineProps<{
modelValue: boolean
familleId: number | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'select-structure': [id: string]
}>()
function close() {
emit('update:modelValue', false)
}
function selectStructure(id: string) {
// Fermer d'abord pour eviter superposition de modales
emit('update:modelValue', false)
emit('select-structure', id)
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
const familleColor = computed(() =>
FAMILLE_COLORS[props.familleId ?? 0] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[props.familleId ?? 0] ?? ''
)
const familleDescription = computed(() =>
FAMILLE_DESCRIPTIONS[props.familleId ?? 0] ?? ''
)
const principalStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s => s.famille_principale === props.familleId)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const secondaireStructures = computed<StructureV2[]>(() => {
if (!props.data || props.familleId == null) return []
return props.data.structures
.filter(s =>
s.famille_principale !== props.familleId
&& (s.familles_secondaires ?? []).includes(props.familleId as number)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
})
const allStructures = computed<StructureV2[]>(() => {
return [...principalStructures.value, ...secondaireStructures.value]
})
// Heuristique : si > 3 secondaires, separer en sections distinctes
const showSplit = computed(() => secondaireStructures.value.length > 3)
</script>
<style scoped>
.structure-row:hover {
background: var(--nav-bg-alt);
}
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

View File

@@ -1,341 +0,0 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && structureId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && structureId != null && structure"
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(780px, 94vw);
max-height: 90vh;
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="structure?.nom ?? 'Fiche structure'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0"
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
>
<div class="flex items-center gap-3">
<!-- Pastille famille -->
<div
class="w-3 h-3 rounded-full shrink-0"
:style="`background: ${familleColor};`"
/>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
{{ familleLabel }}
</span>
<!-- Badges -->
<div class="flex gap-1.5">
<span
v-if="structure.badges.centre_ressources"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #2d8a6b22; color: #2d8a6b;"
>Centre ressources</span>
<span
v-if="structure.badges.mouvement_manifeste"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #c44a2f22; color: #c44a2f;"
>Manifeste</span>
<span
v-if="structure.badges.contre_pouvoir_spatial"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #1a3a6b22; color: #1a3a6b;"
>Contre-pouvoir</span>
<span
v-if="structure.badges.f6_recherche_politique"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #6b3fa022; color: #6b3fa0;"
>Recherche pol.</span>
</div>
</div>
<div class="flex items-center gap-2">
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Site web
</a>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Nom + meta -->
<div class="mb-4">
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
<span>{{ structure.type_principal }}</span>
<span>·</span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</div>
</div>
<!-- Hashtags -->
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
<span
v-for="tag in structure.hashtags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
<!-- Description courte -->
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
<!-- Description longue (expandable) -->
<div v-if="structure.description_longue" class="mb-4">
<div
class="text-sm leading-relaxed"
style="color: var(--nav-text); white-space: pre-wrap;"
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
>{{ structure.description_longue }}</div>
<button
@click="showFullDesc = !showFullDesc"
class="mt-2 text-xs underline"
style="color: var(--nav-text-muted);"
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
</div>
<!-- Pensées rattachées -->
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
<div class="flex flex-wrap gap-1.5">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="px-2 py-0.5 rounded text-xs"
:style="pensee.confiance === 'ia_suggested'
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
</span>
</div>
</div>
<!-- Projets emblématiques -->
<div v-if="projetsStructure.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
<div class="space-y-2">
<div
v-for="projet in projetsStructure.slice(0, 5)"
:key="projet.id"
class="rounded-lg p-3"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a
v-if="projet.url"
:href="projet.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs mt-1 inline-block"
style="color: var(--nav-text-muted); text-decoration: underline;"
>En savoir plus </a>
</div>
</div>
</div>
<!-- Structures voisines (graphe) -->
<div v-if="structuresVoisines.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
@click="emit('update:structureId', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</div>
<!-- Sources -->
<div v-if="structure.sources && structure.sources.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
<div class="space-y-1">
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
<span
class="px-1.5 py-0.5 rounded text-xs shrink-0"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ source.type }}</span>
<a
v-if="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs underline truncate"
style="color: var(--nav-text);"
>{{ source.titre }}</a>
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
</div>
</div>
</div>
<!-- CTAs -->
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Signaler une erreur</a>
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Réclamer cette fiche</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Réemploi & filières',
2: 'Frugalité & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
}
const props = defineProps<{
modelValue: boolean
structureId: string | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:structureId': [id: string]
}>()
const showFullDesc = ref(false)
function close() {
emit('update:modelValue', false)
showFullDesc.value = false
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Remettre showFullDesc a false a chaque changement de fiche
watch(() => props.structureId, () => {
showFullDesc.value = false
})
const structure = computed<StructureV2 | null>(() => {
if (!props.data || !props.structureId) return null
return props.data.structures.find(s => s.id === props.structureId) ?? null
})
const familleColor = computed(() =>
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
)
const projetsStructure = computed<ProjetEmblematique[]>(() => {
if (!props.data || !props.structureId) return []
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
})
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data || !props.structureId) return []
const edges = props.data.graphe?.edges ?? []
const voisineIds = edges
.filter(e => e.source === props.structureId || e.target === props.structureId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.map(e => e.source === props.structureId ? e.target : e.source)
.slice(0, 8)
return voisineIds
.map(id => props.data!.structures.find(s => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>

View File

@@ -1,860 +0,0 @@
<template>
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
<svg
ref="svgRef"
:style="{
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
height: '100%',
transition: 'width 0.2s ease',
}"
></svg>
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
<aside
:style="{
position: 'absolute', top: '0', right: '0', bottom: '0',
width: sidebarOpen ? '200px' : '40px',
background: 'var(--nav-surface)',
borderLeft: '1px solid var(--nav-bg-alt)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.2s ease',
zIndex: 10,
}"
>
<!-- Toggle (toujours visible) -->
<button
@click="sidebarOpen = !sidebarOpen"
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
style="
width: 100%; height: 36px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--nav-bg-alt); border: none; cursor: pointer;
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
border-bottom: 1px solid var(--nav-bg-alt);
"
>{{ sidebarOpen ? '>>' : '<<' }}</button>
<!-- Mode replie : label vertical -->
<div
v-if="!sidebarOpen"
style="
flex: 1; display: flex; align-items: center; justify-content: center;
writing-mode: vertical-rl; transform: rotate(180deg);
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
letter-spacing: 0.12em; text-transform: uppercase;
"
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
<!-- Mode deplie : 3 sections empilees -->
<template v-if="sidebarOpen">
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
<div style="padding: 10px 12px; flex-shrink: 0;">
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
<label
:style="{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '7px 10px', marginBottom: '4px',
borderRadius: '6px', cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600,
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}"
>
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
<span>Familles</span>
</label>
<label
:style="{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '7px 10px',
borderRadius: '6px', cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600,
background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
transition: 'all 0.12s',
}"
>
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
<span>Pratiques</span>
</label>
</div>
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
</div>
<button
v-if="activeHashtags.length"
@click="activeHashtags = []"
style="margin-bottom: 6px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>Tout effacer</button>
</div>
<div style="flex: 1; overflow-y: auto; padding: 0 10px 10px;">
<div
v-for="group in hashtagsByFamille"
:key="group.famille"
style="margin-bottom: 10px;"
>
<div
:style="{
fontSize: '0.65rem', fontWeight: 700,
color: group.color, textTransform: 'uppercase',
letterSpacing: '0.06em', marginBottom: '4px',
paddingLeft: '2px',
}"
>{{ group.label }}</div>
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
<span
v-for="tag in group.tags"
:key="tag"
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
:style="activeHashtags.includes(tag)
? `background: ${group.color}; color: white; font-weight: 600;`
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
<!-- SECTION 3 : MODE D'EMPLOI -->
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
</div>
</div>
</div>
</template>
</aside>
<!-- Tooltip -->
<div ref="tooltipRef" style="
position: absolute; pointer-events: none;
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.15s; z-index: 100;
"></div>
<!-- Popover unifie (famille OU hashtag) -->
<div
v-if="popover.open"
:style="{
position: 'absolute',
left: popover.x + 'px',
top: popover.y + 'px',
background: 'var(--nav-surface)',
border: '1px solid var(--nav-bg-alt)',
borderRadius: '8px',
padding: '12px 14px',
maxWidth: '280px',
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
zIndex: 50,
}"
@click.stop
>
<button
@click="closePopover"
style="
position: absolute; top: 4px; right: 6px;
background: none; border: none; cursor: pointer;
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
line-height: 1;
"
title="Fermer"
>x</button>
<div
:style="{
fontWeight: 700, fontSize: '0.92rem',
color: popover.color, marginBottom: '6px',
paddingRight: '14px',
}"
>{{ popover.title }}</div>
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
<div v-if="popover.kind === 'famille'">
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
{{ popover.body }}
</div>
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
display: flex; align-items: center; gap: 6px;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>
<span
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
:style="`background: ${popover.color};`"
/>
<span>{{ s.nom }}</span>
</li>
</ul>
<button
v-if="popover.familleId != null"
@click="openFicheFamilleFromPopover"
style="
margin-top: 10px; width: 100%;
padding: 7px 10px; border-radius: 6px;
background: var(--nav-bg-alt); border: none;
font-size: 0.75rem; font-weight: 600; cursor: pointer;
color: var(--nav-text); transition: opacity 0.12s;
text-align: left;
"
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
>Voir toutes les {{ popover.structures.length }} pratiques -&gt;</button>
</div>
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
<div v-else-if="popover.kind === 'hashtag'">
<div
style="
font-size: 0.72rem; color: var(--nav-text-muted);
font-style: italic; margin-bottom: 8px; line-height: 1.4;
"
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
<li
v-for="s in popover.structures.slice(0, 6)"
:key="s.id"
@click="selectStructureFromPopover(s.id)"
style="
font-size: 0.78rem; color: var(--nav-text);
padding: 4px 6px; border-radius: 4px;
cursor: pointer; transition: background 0.1s;
"
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
>{{ s.nom }}</li>
</ul>
<div
v-if="popover.structures.length > 6"
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
</div>
</div>
<!-- Fiche famille modale -->
<FicheFamilleModal
v-model="ficheFamilleOpen"
:famille-id="ficheFamilleId"
:data="props.data"
@select-structure="(id) => emit('select-structure', id)"
/>
</div>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData } from '~/types/structure-v2'
const props = defineProps<{
data: ReseauxBifurcationData | null
allHashtags: string[]
active?: boolean
}>()
const emit = defineEmits<{
'select-structure': [id: string]
}>()
const svgRef = ref<SVGElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
// Hashtag filter
const activeHashtags = ref<string[]>([])
const sidebarOpen = ref(true)
// Layers superposables (remplace viewMode exclusif PV2-5e)
const showFamilles = ref(true)
const showPratiques = ref(false)
function toggleHashtag(tag: string) {
activeHashtags.value = activeHashtags.value.includes(tag)
? activeHashtags.value.filter(t => t !== tag)
: [...activeHashtags.value, tag]
}
// Popover unifie (famille | hashtag)
type PopoverState = {
open: boolean
kind: 'famille' | 'hashtag' | null
x: number
y: number
title: string
body: string
color: string
structures: { id: string; nom: string }[]
familleId: number | null
famillesCount: number
}
const popover = ref<PopoverState>({
open: false,
kind: null,
x: 0,
y: 0,
title: '',
body: '',
color: '#000',
structures: [],
familleId: null,
famillesCount: 0,
})
// Fiche famille modale
const ficheFamilleOpen = ref(false)
const ficheFamilleId = ref<number | null>(null)
function closePopover() {
popover.value.open = false
popover.value.kind = null
}
function selectStructureFromPopover(id: string) {
closePopover()
emit('select-structure', id)
}
function openFicheFamilleFromPopover() {
if (popover.value.familleId == null) return
ficheFamilleId.value = popover.value.familleId
ficheFamilleOpen.value = true
closePopover()
}
// Mapping hashtag -> famille majoritaire
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
const tagToFamille = computed<Record<string, number>>(() => {
if (!props.data) return {}
const counts: Record<string, Record<number, number>> = {}
props.data.structures.forEach(s => {
s.hashtags.forEach(tag => {
if (!counts[tag]) counts[tag] = {}
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
})
})
const familleSize: Record<number, number> = {}
props.data.structures.forEach(s => {
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
})
const out: Record<string, number> = {}
for (const tag in counts) {
const entries = Object.entries(counts[tag])
entries.sort((a, b) => {
const diff = (b[1] as number) - (a[1] as number)
if (diff !== 0) return diff
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
})
out[tag] = Number(entries[0][0])
}
return out
})
const hashtagsByFamille = computed(() => {
if (!props.data) return []
const map = tagToFamille.value
const groups: Record<number, string[]> = {}
props.allHashtags.forEach(tag => {
const fam = map[tag]
if (fam == null) return
if (!groups[fam]) groups[fam] = []
groups[fam].push(tag)
})
return [1, 2, 3, 4, 5, 6]
.filter(famId => groups[famId]?.length)
.map(famId => ({
famille: famId,
label: FAMILLE_LABELS[famId],
color: FAMILLE_COLORS[famId],
tags: groups[famId].sort(),
}))
})
// Structures portant un hashtag donne (pour popover)
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s => s.hashtags.includes(tag))
.map(s => ({ id: s.id, nom: s.nom }))
}
// IDs de structures correspondant aux hashtags actifs
const filteredStructureIds = computed(() => {
if (!props.data || !activeHashtags.value.length) return null
const ids = new Set(
props.data.structures
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
.map(s => s.id)
)
return ids
})
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Reemploi',
2: 'Frugalite',
3: 'Social',
4: 'Collectifs',
5: 'Urbanisme',
6: 'Recherche',
}
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
}
let simulation: any = null
let d3NodeSelection: any = null
let d3LinkSelection: any = null
async function initGraph() {
if (!svgRef.value || !props.data) return
const d3 = await import('d3')
const svgEl = svgRef.value
const width = svgEl.clientWidth || 800
const height = svgEl.clientHeight || 600
// Nettoyer
d3.select(svgEl).selectAll('*').remove()
closePopover()
const svg = d3.select(svgEl)
.attr('viewBox', `0 0 ${width} ${height}`)
// Click sur le SVG vide -> fermer popover
svg.on('click', (event: any) => {
if (event.target === svgEl) closePopover()
})
// Groupe principal avec zoom
const g = svg.append('g')
const zoomBehavior = d3.zoom<SVGElement, unknown>()
.scaleExtent([0.2, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform)
closePopover()
})
svg.call(zoomBehavior as any)
const { allNodes, links } = buildNodesLinks(width, height)
// Simulation force-directed
if (simulation) simulation.stop()
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
const heavyMode = showPratiques.value && allNodes.length > 150
simulation = d3.forceSimulation(allNodes)
.force('link', d3.forceLink(links).id((d: any) => d.id)
.distance((d: any) => {
if (d.type === 'practice') return 90
return d.type === 'primary' ? 80 : 120
})
.strength((d: any) => d.strength ?? 0.5))
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
// Rendu liens
d3LinkSelection = g.append('g').selectAll('line')
.data(links)
.join('line')
.attr('stroke', (d: any) => {
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
})
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', null)
// Rendu noeuds (groupes g)
d3NodeSelection = g.append('g').selectAll('g')
.data(allNodes)
.join('g')
.style('cursor', (d: any) => {
if (d.type === 'structure') return 'pointer'
if (d.type === 'family') return 'pointer'
if (d.type === 'hashtag') return 'pointer'
return 'default'
})
.call(
d3.drag<any, any>()
.on('start', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
closePopover()
})
.on('drag', (event: any, d: any) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event: any, d: any) => {
if (!event.active) simulation.alphaTarget(0)
if (d.type !== 'family') {
d.fx = null
d.fy = null
}
})
)
.on('click', (event: any, d: any) => {
event.stopPropagation()
if (d.type === 'structure') {
emit('select-structure', d.id)
} else if (d.type === 'family') {
openFamillePopover(d, event, svgEl)
} else if (d.type === 'hashtag') {
openHashtagPopover(d, event, svgEl)
}
})
// Cercles
d3NodeSelection.append('circle')
.attr('r', (d: any) => d.r)
.attr('fill', (d: any) => {
if (d.type === 'family') return d.color
if (d.type === 'hashtag') return d.fill
return d.color + 'cc'
})
.attr('stroke', (d: any) => {
if (d.type === 'family') return 'white'
if (d.type === 'hashtag') return d.stroke
return d.color
})
.attr('stroke-width', (d: any) => {
if (d.type === 'family') return 3
if (d.type === 'hashtag') return 2
return 1.5
})
// Labels familles
d3NodeSelection.filter((d: any) => d.type === 'family')
.append('text')
.text((d: any) => d.label)
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '11px')
.attr('font-weight', '700')
.attr('fill', 'white')
.style('pointer-events', 'none')
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
.append('text')
.text((d: any) => {
const raw = d.label as string
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
})
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('font-size', '9px')
.attr('font-weight', '600')
.attr('fill', '#2a2a2a')
.style('pointer-events', 'none')
// Tooltip hover pour structures
d3NodeSelection.filter((d: any) => d.type === 'structure')
.on('mouseenter', (_event: any, d: any) => {
if (!tooltipRef.value) return
tooltipRef.value.style.opacity = '1'
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
})
.on('mousemove', (event: any) => {
if (!tooltipRef.value || !svgEl) return
const rect = (svgEl as HTMLElement).getBoundingClientRect()
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
})
.on('mouseleave', () => {
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
})
// Tick - mise a jour positions
simulation.on('tick', () => {
d3LinkSelection
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
// Surlignage selon hashtags actifs
applyHashtagFilter()
})
}
function buildNodesLinks(width: number, height: number) {
const allNodes: any[] = []
const links: any[] = []
if (!props.data) return { allNodes, links }
const tagFamilleMap = tagToFamille.value
// Noeuds structures (toujours presents)
const structureNodes = props.data.structures.map(s => ({
id: s.id,
type: 'structure',
label: s.nom,
famille: s.famille_principale,
familles_secondaires: s.familles_secondaires ?? [],
hashtags: s.hashtags,
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
r: 8,
}))
allNodes.push(...structureNodes)
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
if (showFamilles.value) {
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
id: `family-${id}`,
type: 'family',
familleId: id,
label: FAMILLE_LABELS[id],
color: FAMILLE_COLORS[id],
r: 32,
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
}))
allNodes.push(...familyNodes)
structureNodes.forEach(s => {
links.push({
source: s.id,
target: `family-${s.famille}`,
type: 'primary',
strength: 0.55,
})
;(s.familles_secondaires as number[]).forEach((f: number) => {
links.push({
source: s.id,
target: `family-${f}`,
type: 'secondary',
strength: 0.45,
})
})
})
}
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
if (showPratiques.value) {
const uniqueTags = new Set<string>()
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
const tagsArr = Array.from(uniqueTags).sort()
// Si seul layer Pratiques actif : disposition radiale comme reference
// Si superpose avec Familles : laisser la simulation placer
const radius = Math.min(width, height) * 0.32
const hashtagNodes = tagsArr.map((tag, i) => {
const famId = tagFamilleMap[tag]
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
const node: any = {
id: `hashtag-${tag}`,
type: 'hashtag',
label: tag.startsWith('#') ? tag.slice(1) : tag,
tag,
fill: 'var(--nav-bg-alt)',
stroke: strokeColor,
color: strokeColor,
r: 22,
}
if (!showFamilles.value) {
const angle = (i / tagsArr.length) * Math.PI * 2
node.x = width / 2 + Math.cos(angle) * radius
node.y = height / 2 + Math.sin(angle) * radius
}
return node
})
allNodes.push(...hashtagNodes)
structureNodes.forEach(s => {
s.hashtags.forEach(tag => {
if (uniqueTags.has(tag)) {
links.push({
source: s.id,
target: `hashtag-${tag}`,
type: 'practice',
strength: 0.3,
})
}
})
})
}
return { allNodes, links }
}
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
const margin = 12
let x = evtX - rect.left + 14
let y = evtY - rect.top + 10
if (x + w > rect.width - margin) {
x = Math.max(margin, rect.width - w - margin)
}
if (y + h > rect.height - margin) {
y = Math.max(margin, rect.height - h - margin)
}
return { x, y }
}
function structuresForFamille(famId: number): { id: string; nom: string }[] {
if (!props.data) return []
return props.data.structures
.filter(s =>
s.famille_principale === famId
|| (s.familles_secondaires ?? []).includes(famId)
)
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
.map(s => ({ id: s.id, nom: s.nom }))
}
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const famId = d.familleId as number
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
const structures = structuresForFamille(famId)
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
popover.value = {
open: true,
kind: 'famille',
x,
y,
title: FAMILLE_LABELS[famId] ?? '',
body: desc,
color: FAMILLE_COLORS[famId] ?? '#000',
structures,
familleId: famId,
famillesCount: 0,
}
}
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
const rect = (svgEl as HTMLElement).getBoundingClientRect()
const tag = d.tag as string
const structures = structuresForHashtag(tag)
const famId = tagToFamille.value[tag]
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
// Compter les familles distinctes parmi les porteuses (famille_principale)
const famSet = new Set<number>()
if (props.data) {
props.data.structures
.filter(s => s.hashtags.includes(tag))
.forEach(s => famSet.add(s.famille_principale))
}
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
popover.value = {
open: true,
kind: 'hashtag',
x,
y,
title: tag.startsWith('#') ? tag : '#' + tag,
body: '',
color,
structures,
familleId: null,
famillesCount: famSet.size,
}
}
function applyHashtagFilter() {
if (!d3NodeSelection || !d3LinkSelection) return
if (filteredStructureIds.value) {
const ids = filteredStructureIds.value
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
d3LinkSelection.attr('opacity', (d: any) => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
})
} else {
d3NodeSelection.select('circle').attr('opacity', 1)
d3LinkSelection.attr('opacity', 1)
}
}
// Declencher quand l'onglet devient visible
watch(() => props.active, (val) => {
if (val && import.meta.client && props.data) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Relancer si les donnees arrivent apres l'activation
watch(() => props.data, (val) => {
if (val && props.active && import.meta.client) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Re-appliquer le filtre visuel sans rebuild complet
watch(activeHashtags, () => {
applyHashtagFilter()
if (simulation) simulation.alpha(0.01).restart()
}, { deep: true })
// Watchers layers : rebuild simulation
watch([showFamilles, showPratiques], () => {
closePopover()
if (import.meta.client && props.data && props.active) {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}
})
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
watch(sidebarOpen, () => {
if (!import.meta.client || !props.active || !props.data) return
setTimeout(() => {
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
}, 220)
})
onMounted(async () => {
if (import.meta.client && props.data && props.active) {
await nextTick()
initGraph()
}
})
onUnmounted(() => {
if (simulation) simulation.stop()
})
</script>

View File

@@ -1,97 +0,0 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
<span
v-for="fam in FAMILLES"
:key="fam.id"
class="chip"
:style="selectedFamille === fam.id
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
@click="toggleFamille(fam.id)"
>{{ fam.shortLabel }}</span>
</div>
</div>
<!-- Filtres hashtags avec toggle -->
<div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
<span class="filter-label">HASHTAGS</span>
<button
@click="hashtagsVisible = !hashtagsVisible"
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const FAMILLES = [
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
]
const props = defineProps<{
allHashtags: string[]
selectedHashtags: string[]
selectedFamille: number | null
}>()
const emit = defineEmits<{
'update:selectedHashtags': [v: string[]]
'update:selectedFamille': [v: number | null]
}>()
const hashtagsVisible = ref(false)
function toggleFamille(id: number) {
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
}
function toggleHashtag(tag: string) {
const current = props.selectedHashtags
emit('update:selectedHashtags',
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
)
}
</script>
<style scoped>
.filter-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--nav-text-muted);
display: block;
margin-bottom: 4px;
}
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
.chip {
cursor: pointer;
padding: 3px 10px;
border-radius: 9999px;
font-size: 0.75rem;
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>

View File

@@ -1,76 +0,0 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="visible"
class="intention-overlay"
style="
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(20, 18, 14, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
"
@click.self="dismiss"
>
<div style="
max-width: 540px;
width: 100%;
background: #faf8f5;
border-radius: 8px;
padding: 32px;
color: #2c2416;
">
<p style="font-size: 1rem; line-height: 1.7; margin: 0 0 12px 0;">
Cette carte recense les réseaux, collectifs, agences et projets des
pensées écologiques deviennent des pratiques d'architecture et d'habiter.
</p>
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
rendre visible ce qui se transforme, comment, par qui, où.
5 familles et des hashtags vous permettent d'explorer.
</p>
<button
@click="dismiss"
style="
background: #2c2416;
color: #faf8f5;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
width: 100%;
"
>Explorer la carte</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const visible = ref(false)
onMounted(() => {
if (typeof localStorage !== 'undefined' && !localStorage.getItem('aep_intention_seen')) {
visible.value = true
}
})
function dismiss() {
visible.value = false
if (typeof localStorage !== 'undefined') {
localStorage.setItem('aep_intention_seen', '1')
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div class="outremer-accordion">
<div
v-for="dom in DOM_TOM_PRATIQUES"
:key="dom.code"
class="outremer-item"
>
<button
class="outremer-header"
@click="toggle(dom.code)"
:aria-expanded="openDom === dom.code"
>
<span class="outremer-title">{{ dom.label }}</span>
<span class="outremer-meta">
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 0) > 1 ? 's' : '' }}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
aria-hidden="true"
class="outremer-chevron"
:class="{ 'outremer-chevron--open': openDom === dom.code }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</button>
<div
v-show="openDom === dom.code"
class="outremer-map-container"
>
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Pratique {
id: number
nom: string
lat?: number | null
lng?: number | null
pays?: string
ville?: string
type?: string
score?: number
}
const DOM_TOM_PRATIQUES = [
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
]
const props = defineProps<{
orgs: Pratique[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapRefs: Record<string, HTMLElement> = {}
const mapInstances: Record<string, LeafletMap> = {}
const tileLayers: Record<string, TileLayer> = {}
const openDom = ref<string | null>(null)
const orgCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM_PRATIQUES.forEach(d => { counts[d.code] = 0 })
props.orgs.forEach(o => {
if (o.pays && counts[o.pays] !== undefined) {
counts[o.pays]++
}
})
return counts
})
function toggle(code: string) {
openDom.value = openDom.value === code ? null : code
nextTick(() => {
if (openDom.value === code && !mapInstances[code]) {
initSingleMap(code)
} else if (openDom.value === code) {
mapInstances[code]?.invalidateSize()
}
})
}
function createPinIcon(L: any, score: number, isSelected = false) {
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isSelected ? '#f5b342' : '#ffffff'
const size = isSelected ? 16 : 12
return L.divIcon({
className: '',
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
function getTileUrl(dark: boolean) {
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
}
async function initSingleMap(code: string) {
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
if (!dom) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const el = mapRefs[code]
if (!el) return
const map = L.map(el, {
center: dom.center, zoom: dom.zoom,
zoomControl: false, attributionControl: false,
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[code] = tileLayer as unknown as TileLayer
mapInstances[code] = map as unknown as LeafletMap
renderPins(L, code)
}
function updateTheme(dark: boolean) {
const url = getTileUrl(dark)
Object.values(tileLayers).forEach(tl => {
(tl as any).setUrl(url)
})
}
function renderPins(L: any, code: string) {
const map = mapInstances[code] as any
if (!map) return
if (map._navMarkers) {
map._navMarkers.forEach((m: any) => m.remove())
}
map._navMarkers = []
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
domOrgs.forEach(org => {
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
const marker = L.marker([org.lat!, org.lng!], { icon })
marker.bindPopup(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
${org.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
<a href="/pratique/${org.id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
</div>
`, { maxWidth: 200 })
marker.on('click', () => emit('select-org', org.id))
marker.addTo(map)
map._navMarkers.push(marker)
})
}
watch(() => props.orgs, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
}, { deep: false })
watch(() => props.selectedId, () => {
DOM_TOM_PRATIQUES.forEach(dom => {
if (mapInstances[dom.code]) {
import('leaflet').then(L => renderPins(L, dom.code))
}
})
})
let themeObserver: MutationObserver | null = null
onMounted(() => {
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
Object.values(mapInstances).forEach(m => (m as any).remove())
})
</script>
<style scoped>
.outremer-accordion {
display: flex;
flex-direction: column;
width: 100%;
}
.outremer-item {
border-bottom: 1px solid var(--nav-bg-alt);
}
.outremer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: var(--nav-surface);
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.outremer-header:hover {
background: var(--nav-bg-alt);
}
.outremer-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
}
.outremer-meta {
display: flex;
align-items: center;
gap: 8px;
}
.outremer-count-badge {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.outremer-chevron {
color: var(--nav-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.outremer-chevron--open {
transform: rotate(180deg);
}
.outremer-map-container {
height: 220px;
border-top: 1px solid var(--nav-bg-alt);
}
.outremer-map {
width: 100%;
height: 100%;
}
</style>

60
components/PaysFilter.vue Normal file
View File

@@ -0,0 +1,60 @@
<template>
<div>
<!-- Groupe Europe -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS EUROPE</span>
<div class="flex flex-wrap gap-1 mb-2">
<button
v-for="code in EUROPE_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
<!-- Groupe Outre-mer -->
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
<div class="flex flex-wrap gap-1">
<button
v-for="code in OUTREMER_CODES"
:key="code"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(code)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(code)"
>
{{ PAYS_LABELS[code] ?? code }}
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(code: string) {
if (props.modelValue.includes(code)) {
emit('update:modelValue', props.modelValue.filter(v => v !== code))
} else {
emit('update:modelValue', [...props.modelValue, code])
}
}
</script>

View File

@@ -0,0 +1,274 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- BARRE DE RECHERCHE -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une pratique">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une pratique…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- FILTRES -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
style="border-color: var(--nav-bg-alt); max-height: 280px;"
>
<!-- Critères régé -->
<CritereFilter
:modelValue="criteres"
:counts="critereCount"
@update:modelValue="emit('update:criteres', $event)"
/>
<!-- Type entité -->
<TypeEntiteFilter
:modelValue="typesEntite"
:counts="typeCount"
@update:modelValue="emit('update:typesEntite', $event)"
/>
</div>
<!-- LISTE FICHES -->
<div class="flex-1 flex flex-col min-h-0">
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
<div v-else-if="pratiques.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="pratique in pratiques"
:key="pratique.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === pratique.id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-pratique', pratique.id)"
@mouseenter="emit('hover-pratique', pratique.id)"
@mouseleave="emit('hover-pratique', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
<span
v-if="pratique.pays"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ pratique.pays }}</span>
</div>
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="cId in pratique.criteres.slice(0, 3)"
:key="cId"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
</div>
<div v-if="pratique.ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ pratique.ville }}
</div>
</div>
</div>
</div>
<!-- CTA PROPOSER -->
<div
class="shrink-0 px-4 py-3 border-t"
style="border-color: var(--nav-bg-alt);"
>
<NuxtLink
to="/proposer-pratique"
class="sidebar-cta-link"
>
+ Proposer une pratique
</NuxtLink>
</div>
</aside>
</template>
<script setup lang="ts">
import { CRITERES } from '~/types/pratique'
interface Pratique {
id: number
nom: string
pays?: string
ville?: string
type?: string
criteres?: number[]
score?: number
}
const props = defineProps<{
search: string
criteres: number[]
typesEntite: string[]
critereCount: Record<number, number>
typeCount: Record<string, number>
resultCount: number
pratiques: Pratique[]
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:criteres': [value: number[]]
'update:typesEntite': [value: string[]]
'select-pratique': [id: number]
'hover-pratique': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
.sidebar-cta-link {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: center;
font-size: 0.82rem;
font-weight: 600;
color: var(--nav-primary-solid);
background: transparent;
border: 1px solid var(--nav-primary-solid);
border-radius: 6px;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.sidebar-cta-link:hover {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
<div class="flex flex-wrap gap-1">
<button
v-for="type in TYPES_ENTITE"
:key="type"
type="button"
class="px-2 py-0.5 rounded-full text-xs transition-all"
:style="modelValue.includes(type)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggle(type)"
>
{{ TYPES_ENTITE_LABELS[type] ?? type }}
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
const props = defineProps<{
modelValue: string[]
counts?: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(type: string) {
if (props.modelValue.includes(type)) {
emit('update:modelValue', props.modelValue.filter(v => v !== type))
} else {
emit('update:modelValue', [...props.modelValue, type])
}
}
</script>

View File

@@ -1,385 +0,0 @@
<template>
<div ref="container" class="codev-graph-wrap">
<!-- Placeholder si aucune fiche -->
<div v-if="fiches.length === 0" class="empty-state">
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche &rarr;</NuxtLink>
</div>
<!-- SVG D3 -->
<svg v-else ref="svgEl" class="codev-svg">
<defs>
<marker
id="arrow-solution"
viewBox="0 0 10 10"
refX="18"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
</marker>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import * as d3 from 'd3'
import type { CodevFiche, CodevMatch } from '~/types/codev'
// ── Props / Emits ──────────────────────────────────────────────────────────
const props = withDefaults(defineProps<{
fiches: CodevFiche[]
matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
}>(), {
matches: () => [],
mode: 'none',
})
const emit = defineEmits<{
'select-fiche': [id: number]
}>()
// ── Refs ───────────────────────────────────────────────────────────────────
const container = ref<HTMLDivElement | null>(null)
const svgEl = ref<SVGSVGElement | null>(null)
const width = ref(800)
const height = ref(600)
// ── State interne ──────────────────────────────────────────────────────────
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
const isMobile = computed(() => width.value < 600)
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
// ── Helpers ────────────────────────────────────────────────────────────────
function truncate(str: string, max = 10): string {
if (!str) return ''
return str.length > max ? str.slice(0, max - 1) + '…' : str
}
function buildNodes(): SimNode[] {
return props.fiches.map(f => ({
id: f.id,
nom: f.nom,
offre: f.offre,
besoin: f.besoin,
}))
}
function buildLinks(nodes: SimNode[]): SimLink[] {
if (!props.matches || props.matches.length === 0) return []
const nodeById = new Map(nodes.map(n => [n.id, n]))
return props.matches
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
.map(m => ({
source: nodeById.get(m.fromId)!,
target: nodeById.get(m.toId)!,
score: m.score,
mode: m.mode,
}))
}
function linkColor(mode: string): string {
if (mode === 'solution') return '#22c55e'
if (mode === 'alliance') return '#f97316'
if (mode === 'surprise') return '#3b82f6'
return '#ccc'
}
// ── Drag handler ───────────────────────────────────────────────────────────
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
return d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) sim.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) sim.alphaTarget(0)
d.fx = null
d.fy = null
})
}
// ── Initialisation SVG ─────────────────────────────────────────────────────
function initSvg() {
if (!svgEl.value) return
svgRoot = d3.select(svgEl.value)
.attr('width', width.value)
.attr('height', height.value)
svgRoot.selectAll('*').remove()
gLinks = svgRoot.append('g').attr('class', 'links')
gNodes = svgRoot.append('g').attr('class', 'nodes')
}
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
let currentNodes: SimNode[] = []
let currentLinks: SimLink[] = []
function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
const linkSel = gLinks
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks, (d: SimLink) => {
const s = d.source as SimNode
const t = d.target as SimNode
return `${s.id}-${t.id}-${d.mode}`
})
linkSel.exit().remove()
linkSel.enter()
.append('line')
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
}
// ── Rendu complet ──────────────────────────────────────────────────────────
function render() {
if (!svgEl.value || props.fiches.length === 0) return
initSvg()
currentNodes = buildNodes()
currentLinks = buildLinks(currentNodes)
const r = nodeRadius.value
const fontSize = isMobile.value ? 10 : 12
// Liens
gLinks!
.selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks)
.join('line')
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
// Noeuds = groupe <g> par personne
const nodeGroups = gNodes!
.selectAll<SVGGElement, SimNode>('g.node')
.data(currentNodes, d => String(d.id))
.join('g')
.attr('class', 'node')
.style('cursor', 'pointer')
.call(makeDrag(simulation!) as any)
.on('click', (_event, d) => emit('select-fiche', d.id))
// Cercle principal
nodeGroups.append('circle')
.attr('r', r)
.attr('fill', '#ffffff')
.attr('stroke', '#1B4436')
.attr('stroke-width', 2)
// Label nom
nodeGroups.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-size', fontSize)
.attr('font-weight', '700')
.attr('fill', '#1a1a2e')
.attr('pointer-events', 'none')
.text(d => truncate(d.nom, 10))
// Pastille offre (haut-droite, vert)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', -r * 0.65)
.attr('fill', '#22c55e')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, orange)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
.attr('fill', '#f97316')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
// Tooltip SVG natif <title>
nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
// Simulation
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
.alphaDecay(0.02)
.on('tick', tick)
// Re-bind drag avec la nouvelle simulation
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
.call(makeDrag(simulation) as any)
}
function tick() {
if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => (d.source as SimNode).x ?? 0)
.attr('y1', d => (d.source as SimNode).y ?? 0)
.attr('x2', d => (d.target as SimNode).x ?? 0)
.attr('y2', d => (d.target as SimNode).y ?? 0)
gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
}
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id)
.distance(120)
.strength(0.3))
simulation.alpha(0.5).restart()
}, { deep: true })
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => {
if (simulation) {
simulation.stop()
simulation = null
}
render()
}, { deep: true })
// ── ResizeObserver ─────────────────────────────────────────────────────────
let ro: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
render()
ro = new ResizeObserver(() => {
if (!container.value) return
width.value = container.value.clientWidth || 800
height.value = container.value.clientHeight || 600
if (svgRoot) {
svgRoot.attr('width', width.value).attr('height', height.value)
}
if (simulation) {
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
simulation.alpha(0.3).restart()
}
})
ro.observe(container.value!)
})
onUnmounted(() => {
if (simulation) simulation.stop()
if (ro) ro.disconnect()
})
</script>
<style scoped>
.codev-graph-wrap {
width: 100%;
height: 70vh;
min-height: 320px;
position: relative;
background: var(--nav-bg, #fafafa);
border-radius: 12px;
overflow: hidden;
}
.codev-svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Etat vide ── */
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.empty-msg {
font-size: 1.125rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
.empty-link {
font-size: 0.9rem;
font-weight: 600;
color: var(--nav-primary-solid, #1B4436);
text-decoration: none;
border: 1.5px solid var(--nav-primary-solid, #1B4436);
border-radius: 8px;
padding: 0.5rem 1.25rem;
transition: background 0.15s, color 0.15s;
}
.empty-link:hover {
background: var(--nav-primary-solid, #1B4436);
color: #fff;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-graph-wrap {
height: 65vh;
min-height: 260px;
border-radius: 8px;
}
}
</style>

View File

@@ -14,9 +14,6 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY, resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr', emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
codevTableId: '', // NUXT_CODEV_TABLE_ID
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client

459
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6", "@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
@@ -5313,416 +5312,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/db0": { "node_modules/db0": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -5836,15 +5425,6 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/delaunator": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/delegates": { "node_modules/delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -6900,18 +6480,6 @@
"node": ">=16.17.0" "node": ">=16.17.0"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6987,15 +6555,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ioredis": { "node_modules/ioredis": {
"version": "5.10.1", "version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
@@ -9921,12 +9480,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -10042,12 +9595,6 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -10086,12 +9633,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": { "node_modules/sax": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",

View File

@@ -13,7 +13,6 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6", "@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",

View File

@@ -1,270 +0,0 @@
<template>
<div class="codev-carto">
<header class="carto-header">
<h1>Carto entraide</h1>
<p class="carto-subtitle">
<template v-if="pending">Chargement...</template>
<template v-else>
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
</template>
</p>
</header>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
@select-fiche="onSelectFiche"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<span>
Mode {{ MODE_LABELS[mode] }} actif -
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
</span>
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
</div>
<!-- Boutons matching -->
<div class="matching-controls">
<button
:class="{ active: mode === 'solution' }"
style="--mode-color: #22c55e"
@click="setMode('solution')"
type="button"
>
Solution
<span class="hint">besoin - offre</span>
</button>
<button
:class="{ active: mode === 'alliance' }"
style="--mode-color: #f97316"
@click="setMode('alliance')"
type="button"
>
Alliance
<span class="hint">besoins partages</span>
</button>
<button
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
useHead({ title: 'Carto - Co-developpement' })
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
surprise: 'Surprise',
}
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
mode.value = newMode
if (newMode === 'none') {
matches.value = []
} else {
matches.value = computeMatches(fiches.value, newMode)
}
}
function onSelectFiche(id: number) {
navigateTo(`/codev/fiche?id=${id}`)
}
</script>
<style scoped>
.codev-carto {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.carto-header {
text-align: center;
padding-bottom: 0.5rem;
}
.carto-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.carto-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.mode-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.875rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 0.875rem;
color: #166534;
flex-wrap: wrap;
}
.banner-clear {
font-size: 0.8rem;
font-weight: 600;
color: #166534;
background: transparent;
border: 1px solid #166534;
border-radius: 6px;
padding: 0.2rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.banner-clear:hover {
background: #166534;
color: #fff;
}
/* ── Boutons matching ── */
.matching-controls {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-top: 1px solid #e5e7eb;
margin: 0 -1rem -2rem;
}
.matching-controls button {
flex: 1;
padding: 12px 8px;
border: 1px solid #d0d4dc;
border-radius: 8px;
background: white;
font-size: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.matching-controls button .hint {
font-size: 11px;
color: #6b7280;
font-weight: normal;
}
.matching-controls button.active {
background: var(--mode-color, #1B4436);
color: white;
border-color: transparent;
}
.matching-controls button.active .hint {
color: rgba(255, 255, 255, 0.8);
}
.matching-controls button.reset {
flex: 0 0 auto;
padding: 12px 16px;
background: #f3f4f6;
border-color: #d0d4dc;
color: #374151;
font-size: 13px;
}
.matching-controls button.reset:hover {
background: #e5e7eb;
}
@media (max-width: 500px) {
.matching-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
margin: 0 -0.75rem -1.5rem;
}
.matching-controls button.reset {
grid-column: span 2;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-carto {
padding: 1rem 0.75rem 1.5rem;
}
.carto-header h1 {
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,367 +0,0 @@
<template>
<div class="codev-demo">
<header class="demo-header">
<span class="demo-badge">DEMO</span>
<h1>Co-developpement - exemple</h1>
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
</header>
<ClientOnly>
<CodevGraph
:fiches="fiches"
:matches="matches"
:mode="mode"
/>
<template #fallback>
<div class="graph-fallback">Chargement du graphe...</div>
</template>
</ClientOnly>
<!-- Bandeau info mode actif -->
<div v-if="mode !== 'none'" class="mode-banner">
<span>
Mode {{ MODE_LABELS[mode] }} actif -
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
</span>
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
</div>
<!-- 3 boutons matching identiques a carto.vue -->
<div class="matching-controls">
<button
:class="{ active: mode === 'solution' }"
style="--mode-color: #22c55e"
@click="setMode('solution')"
type="button"
>
Solution
<span class="hint">besoin - offre</span>
</button>
<button
:class="{ active: mode === 'alliance' }"
style="--mode-color: #f97316"
@click="setMode('alliance')"
type="button"
>
Alliance
<span class="hint">besoins partages</span>
</button>
<button
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button
v-if="mode !== 'none'"
class="reset"
@click="setMode('none')"
type="button"
>
Effacer
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching'
// 10 fiches factices - hashtags alignes pour demontrer les 3 modes :
//
// Solution : Lea(besoin coaching) -> Maya(offre coaching)
// Sami(besoin formation+vente) -> Ines(offre vente+formation)
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu)
//
// Alliance : Lea + Maya (hashtag coaching commun dans besoins)
// Sami + Kenji (hashtag formation+vente dans besoins)
// Tom + Zoe (hashtag tiers-lieu dans besoins)
//
// Surprise : Lea + Zoe (hashtag facilitation dans offres)
// Tom + Roman (hashtag archi dans offres)
const FICHES_DEMO: CodevFiche[] = [
{
id: 1,
nom: 'Lea',
besoin: 'Structurer mon offre de coaching pour la lancer en septembre',
offre: 'Animation de groupes, facilitation de cercles de parole',
hashtags: ['coaching', 'facilitation'],
created_at: '2026-05-08T10:00:00Z',
},
{
id: 2,
nom: 'Sami',
besoin: 'Comprendre comment vendre une formation en ligne',
offre: 'Developpement web, sites Astro et Nuxt',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:01:00Z',
},
{
id: 3,
nom: 'Ines',
besoin: 'Aide pour la facilitation de mes ateliers ecriture',
offre: 'Vente de formations en ligne, marketing direct',
hashtags: ['vente', 'formation'],
created_at: '2026-05-08T10:02:00Z',
},
{
id: 4,
nom: 'Tom',
besoin: 'Trouver un associe pour un projet de tiers-lieu',
offre: 'Architecture eco-responsable, conception bioclimatique',
hashtags: ['tiers-lieu', 'archi'],
created_at: '2026-05-08T10:03:00Z',
},
{
id: 5,
nom: 'Maya',
besoin: 'Structurer mon offre de coaching freelance',
offre: 'Coaching de carriere, accompagnement transition pro',
hashtags: ['coaching', 'carriere'],
created_at: '2026-05-08T10:04:00Z',
},
{
id: 6,
nom: 'Kenji',
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur',
offre: 'Photographie, direction artistique de projets editoriaux',
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:05:00Z',
},
{
id: 7,
nom: 'Zoe',
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural',
offre: 'Animation et facilitation de collectifs, intelligence collective',
hashtags: ['tiers-lieu', 'facilitation'],
created_at: '2026-05-08T10:06:00Z',
},
{
id: 8,
nom: 'Nael',
besoin: 'Construire un site web pour ma formation',
offre: 'Strategie marketing, lancement de produits digitaux',
hashtags: ['web', 'strategie'],
created_at: '2026-05-08T10:07:00Z',
},
{
id: 9,
nom: 'Eva',
besoin: 'Lancer mon offre de coaching avec une page de vente',
offre: 'Ecriture longue forme, articles essais et tribunes',
hashtags: ['coaching', 'ecriture'],
created_at: '2026-05-08T10:08:00Z',
},
{
id: 10,
nom: 'Roman',
besoin: 'Ameliorer mes articles de blog sur la renovation',
offre: 'Architecture, plans techniques pour renovation energetique',
hashtags: ['archi', 'reno'],
created_at: '2026-05-08T10:09:00Z',
},
]
const fiches = ref(FICHES_DEMO)
const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const MODE_LABELS: Record<string, string> = {
solution: 'Solution',
alliance: 'Alliance',
surprise: 'Surprise',
}
useHead({ title: 'Demo - Co-developpement' })
function setMode(newMode: typeof mode.value) {
mode.value = newMode
if (newMode === 'none') {
matches.value = []
} else {
matches.value = computeMatches(fiches.value, newMode)
}
}
</script>
<style scoped>
.codev-demo {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
flex-direction: column;
padding: 1.25rem 1rem 2rem;
gap: 1rem;
max-width: 100%;
box-sizing: border-box;
}
/* ── En-tete ── */
.demo-header {
text-align: center;
padding-bottom: 0.5rem;
}
.demo-badge {
display: inline-block;
background: #f97316;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.demo-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
}
/* ── Fallback ── */
.graph-fallback {
width: 100%;
height: 70vh;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted, #6b7280);
font-size: 0.9rem;
background: var(--nav-bg-alt, #f3f4f6);
border-radius: 12px;
}
/* ── Bandeau mode actif ── */
.mode-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.875rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 0.875rem;
color: #166534;
flex-wrap: wrap;
}
.banner-clear {
font-size: 0.8rem;
font-weight: 600;
color: #166534;
background: transparent;
border: 1px solid #166534;
border-radius: 6px;
padding: 0.2rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.banner-clear:hover {
background: #166534;
color: #fff;
}
/* ── Boutons matching ── */
.matching-controls {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-top: 1px solid #e5e7eb;
margin: 0 -1rem -2rem;
}
.matching-controls button {
flex: 1;
padding: 12px 8px;
border: 1px solid #d0d4dc;
border-radius: 8px;
background: white;
font-size: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.matching-controls button .hint {
font-size: 11px;
color: #6b7280;
font-weight: normal;
}
.matching-controls button.active {
background: var(--mode-color, #1B4436);
color: white;
border-color: transparent;
}
.matching-controls button.active .hint {
color: rgba(255, 255, 255, 0.8);
}
.matching-controls button.reset {
flex: 0 0 auto;
padding: 12px 16px;
background: #f3f4f6;
border-color: #d0d4dc;
color: #374151;
font-size: 13px;
}
.matching-controls button.reset:hover {
background: #e5e7eb;
}
@media (max-width: 500px) {
.matching-controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
margin: 0 -0.75rem -1.5rem;
}
.matching-controls button.reset {
grid-column: span 2;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.codev-demo {
padding: 1rem 0.75rem 1.5rem;
}
.demo-header h1 {
font-size: 1.25rem;
}
}
</style>

View File

@@ -1,367 +0,0 @@
<template>
<div class="fiche-page">
<div class="fiche-inner">
<!-- En-tête -->
<div class="fiche-header">
<h1>Ma fiche</h1>
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
</div>
<!-- Formulaire -->
<form class="fiche-form" @submit.prevent="submit" novalidate>
<!-- Nom -->
<div class="field-group">
<label for="nom">
Prénom <span class="required">*</span>
</label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : Camille"
required
minlength="2"
maxlength="50"
:disabled="loading"
/>
</div>
<!-- Besoin -->
<div class="field-group">
<div class="label-row">
<label for="besoin">
Mon besoin actuel <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
</div>
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
</details>
<textarea
id="besoin"
v-model="form.besoin"
rows="3"
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
{{ form.besoin.length }}/300
</span>
</div>
<!-- Offre -->
<div class="field-group">
<div class="label-row">
<label for="offre">
Ce que j'offre a la communaute <span class="required">*</span>
</label>
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
</div>
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
<summary class="sr-only">Aide</summary>
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
</details>
<textarea
id="offre"
v-model="form.offre"
rows="3"
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
required
minlength="5"
maxlength="300"
:disabled="loading"
/>
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
{{ form.offre.length }}/300
</span>
</div>
<!-- Hashtags -->
<div class="field-group">
<label for="hashtags">
Mots-clés
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
</label>
<input
id="hashtags"
v-model="form.hashtagsRaw"
type="text"
placeholder="Ex : business, écriture, écologie"
maxlength="120"
:disabled="loading"
/>
</div>
<!-- Erreur serveur -->
<div v-if="error" class="server-error" role="alert">
{{ error }}
</div>
<!-- Bouton -->
<button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('')
const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: 'Ma fiche — Co-développement' })
function toggleTip(field: 'besoin' | 'offre') {
activeTip.value = activeTip.value === field ? null : field
}
async function submit() {
error.value = ''
loading.value = true
try {
const hashtags = form.value.hashtagsRaw
.split(',')
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
await $fetch('/api/codev/fiches', {
method: 'POST',
body: {
nom: form.value.nom,
besoin: form.value.besoin,
offre: form.value.offre,
hashtags,
},
})
await navigateTo('/codev/carto')
} catch (e: any) {
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.fiche-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
padding: 1.5rem 1rem 4rem;
}
.fiche-inner {
max-width: 480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── En-tête ── */
.fiche-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.fiche-lead {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
margin: 0;
line-height: 1.5;
}
/* ── Formulaire ── */
.fiche-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ ── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text, #1a1a2e);
}
.label-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label-hint {
font-weight: 400;
font-size: 0.8rem;
color: var(--nav-text-muted, #6b7280);
margin-left: 0.25rem;
}
.required {
color: #c0392b;
}
.field-group input[type="text"],
.field-group input[type="password"],
.field-group textarea {
width: 100%;
padding: 0.75rem 0.875rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.field-group input:disabled,
.field-group textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted, #6b7280);
text-align: right;
}
.char-warn {
color: #e67e22;
}
/* ── Tooltip ── */
.tooltip-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
color: var(--nav-text-muted, #6b7280);
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s;
}
.tooltip-trigger:hover {
border-color: var(--nav-primary-solid, #1B4436);
color: var(--nav-primary-solid, #1B4436);
}
.tooltip-block {
background: var(--nav-surface, #ffffff);
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
padding: 0.75rem 0.875rem;
font-size: 0.85rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
}
.tooltip-block p {
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ── Erreur serveur ── */
.server-error {
padding: 0.75rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Bouton ── */
.submit-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
margin-top: 0.25rem;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.88;
}
.submit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Responsive ── */
@media (max-width: 480px) {
.fiche-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

View File

@@ -1,217 +0,0 @@
<template>
<div class="lock-page">
<div class="lock-inner">
<div class="lock-header">
<h1>Co-développement</h1>
<p class="lock-subtitle">Entraide entre pairs</p>
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
</div>
<form class="lock-form" @submit.prevent="submit" novalidate>
<div class="field-group">
<input
id="password"
v-model="password"
type="password"
placeholder="Mot de passe"
autocomplete="current-password"
required
:disabled="loading"
class="lock-input"
/>
</div>
<div v-if="error" class="lock-error" role="alert">
{{ error }}
</div>
<button type="submit" class="lock-btn" :disabled="loading || !password">
{{ loading ? 'Vérification...' : 'Entrer' }}
</button>
</form>
<div class="lock-footer">
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple &rarr;</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const password = ref('')
const error = ref('')
const loading = ref(false)
useHead({ title: 'Co-développement Entraide entre pairs' })
async function submit() {
error.value = ''
loading.value = true
try {
await $fetch('/api/codev/auth', {
method: 'POST',
body: { password: password.value },
})
await navigateTo('/codev/fiche')
} catch (e: any) {
error.value = e?.statusMessage || 'Mauvais mot de passe'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Layout ── */
.lock-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
}
.lock-inner {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ── En-tête ── */
.lock-header {
text-align: center;
}
.lock-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text, #1a1a2e);
margin: 0 0 0.375rem;
}
.lock-subtitle {
font-size: 1rem;
color: var(--nav-text-muted, #6b7280);
margin: 0 0 1rem;
}
.lock-intro {
font-size: 0.9rem;
color: var(--nav-text-muted, #6b7280);
line-height: 1.5;
margin: 0;
font-style: italic;
}
/* ── Formulaire ── */
.lock-form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.field-group {
display: flex;
flex-direction: column;
}
.lock-input {
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid var(--border-color, #d0d4dc);
border-radius: 8px;
font-size: 1rem;
color: var(--nav-text, #1a1a2e);
background: var(--nav-surface, #ffffff);
font-family: inherit;
text-align: center;
letter-spacing: 0.1em;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.lock-input:focus {
outline: none;
border-color: var(--nav-primary-solid, #1B4436);
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
}
.lock-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Erreur ── */
.lock-error {
padding: 0.625rem 0.875rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
text-align: center;
}
/* ── Bouton ── */
.lock-btn {
width: 100%;
padding: 0.875rem 1rem;
background: var(--nav-primary-solid, #1B4436);
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.lock-btn:hover:not(:disabled) {
opacity: 0.88;
}
.lock-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Pied de page ── */
.lock-footer {
text-align: center;
}
.demo-link {
font-size: 0.875rem;
color: var(--nav-text-muted, #6b7280);
text-decoration: none;
transition: color 0.15s;
}
.demo-link:hover {
color: var(--nav-primary-solid, #1B4436);
}
/* ── Responsive ── */
@media (max-width: 480px) {
.lock-page {
padding: 1.25rem 1rem 2.5rem;
align-items: flex-start;
padding-top: 3rem;
}
}
</style>

View File

@@ -1,144 +1,56 @@
<template> <template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);"> <div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP (>= 1024px) --> <!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"> <div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<NavSidebar
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) --> :search="search"
<IntentionBanner /> :modeValue="territoireMode"
:echelle="echelle"
<!-- Filtres familles + hashtags --> :fonctions="fonctions"
<HashtagFilter :territoire="territoire"
:allHashtags="allHashtags" :echelleCount="echelleCount"
:selectedHashtags="selectedHashtags" :fonctionCount="fonctionCount"
:selectedFamille="selectedFamille" :territoireCount="territoireCount"
@update:selectedHashtags="selectedHashtags = $event" :resultCount="filtered.length"
@update:selectedFamille="selectedFamille = $event" :orgs="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:mode="onMode"
@update:echelle="onEchelle"
@update:fonctions="onFonctions"
@update:territoire="onTerritoire"
@select-org="onSelectOrg"
@hover-org="onHoverOrg"
@reset-filters="resetFilters"
/> />
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Barre de recherche -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="sidebar-search-label" aria-label="Rechercher une structure">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher une structure..."
class="sidebar-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Header compteur + reset -->
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>Effacer les filtres</button>
</div>
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
<div class="px-3 py-2 space-y-1.5">
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement...
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div
v-for="structure in filtered"
:key="structure.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="onSelectStructure(structure.id)"
@mouseenter="hoveredId = structure.id"
@mouseleave="hoveredId = null"
>
<div class="flex items-start justify-between gap-1.5">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
</div>
</div> </div>
<!-- ZONE CENTRALE (carte) --> <!-- ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative"> <main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── --> <!-- Indicateur source dev -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden"> <div
<!-- Onglets desktop --> v-if="dataSource === 'seed'"
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
<button style="background: var(--nav-accent); color: var(--nav-text);"
class="px-5 py-2 text-sm font-medium transition-colors" >
:style="desktopMapView === 'metropole' Mode dev données seed
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
</div> </div>
<!-- Carte Métropole desktop --> <!-- VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden"> <div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;"> <div class="relative flex-1" style="min-height: 200px;">
<ClientOnly> <ClientOnly>
<NavMapV2 <NavMap
ref="navMapRef" ref="navMapRef"
:structures="metropoleStructures" :orgs="metropoleOrgs"
:selectedId="selectedId" :selectedId="selectedId"
@select-structure="onSelectStructure" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div <div
@@ -150,53 +62,35 @@
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<ChatbotPlaceholder <ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div> </div>
<!-- Carte Outre-mer desktop --> <!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);"> <div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<ClientOnly> <ClientOnly>
<OutremerMap <OutremerMap
:orgs="outremerOrgsLegacy" :orgs="outremerOrgs"
:selectedId="selectedIdLegacyNum" :selectedId="selectedId"
@select-org="() => {}" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);"> <div
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
Chargement Chargement
</div> </div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div> </div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── --> <!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Métropolitain / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> <div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button <button
class="flex-1 py-2 text-sm font-medium transition-colors" class="flex-1 py-2 text-sm font-medium transition-colors"
@@ -215,30 +109,34 @@
</div> </div>
<div class="lg:hidden flex-1 relative overflow-hidden"> <div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte mobile Métropole -->
<!-- Carte Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0"> <div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly> <ClientOnly>
<NavMapV2 <NavMap
ref="navMapMobileRef" ref="navMapMobileRef"
:structures="metropoleStructures" :orgs="metropoleOrgs"
:selectedId="selectedId" :selectedId="selectedId"
@select-structure="onSelectStructureMobile" @select-org="onSelectOrgMobile"
/> />
<template #fallback> <template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"> <div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte Chargement de la carte
</div> </div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Carte mobile Outre-mer --> <!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);"> <div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly> <ClientOnly>
<OutremerMap <OutremerMap
:orgs="outremerOrgsLegacy" :orgs="outremerOrgs"
:selectedId="selectedIdLegacyNum" :selectedId="selectedId"
@select-org="() => {}" @select-org="onSelectOrgMobile"
/> />
<template #fallback> <template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);"> <div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
@@ -248,65 +146,81 @@
</ClientOnly> </ClientOnly>
</div> </div>
<!-- Bottom sheet swipable --> <!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<ClientOnly> <ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending"> <MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile --> <!-- Barre recherche -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
</p>
</div>
<!-- Filtres hashtags mobile -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
</div>
<!-- Barre recherche mobile -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);"> <div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une structure"> <label class="mobile-search-label" aria-label="Rechercher une organisation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/> <circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/> <line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg> </svg>
<input <input
v-model="search" v-model="mobileSearch"
type="search" type="search"
placeholder="Rechercher…" placeholder="Rechercher…"
class="mobile-search-input" class="mobile-search-input"
autocomplete="off" autocomplete="off"
@input="onSearch(mobileSearch)"
/> />
<button <button
v-if="search" v-if="mobileSearch"
type="button" type="button"
class="mobile-search-clear" class="mobile-search-clear"
aria-label="Effacer" aria-label="Effacer"
@click.stop="search = ''" @click.stop="mobileSearch = ''; onSearch('')"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
</label> </label>
<!-- Filtres ÉCHELLE chips style FONCTION -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
</div>
<!-- Filtres FONCTION chips flex-wrap -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
</div>
<button <button
v-if="hasActiveFilters" v-if="hasActiveFilters"
@click="resetFilters" @click="resetFilters"
class="mt-1 text-xs" class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;" style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button> > Effacer les filtres</button>
</div> </div>
<!-- Liste fiches mobile --> <!-- Compteur + Liste fiches -->
<div class="px-3 py-2"> <div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);"> <div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }} {{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div> </div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);"> <div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches Chargement des fiches
@@ -319,36 +233,46 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="structure in filtered" v-for="org in filtered"
:key="structure.id" :key="org.Id"
class="block rounded-lg p-3 transition-all cursor-pointer" class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id :style="selectedId === org.Id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};` ? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'" : 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)" @click="onSelectOrgMobile(org.Id)"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span> <span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span <span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1" v-if="org.echelle"
:style="`background: ${familleColor(structure.famille_principale)};`" class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
/> style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ org.echelle }}</span>
</div>
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in fonctionsList(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div> </div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
</div> </div>
</div> </div>
</div> </div>
</MobileSheet> </MobileSheet>
</ClientOnly> </ClientOnly>
</div> </div>
</main> </main>
<!-- MODAL FICHE V2 (desktop) --> <!-- MODAL FICHE (desktop) -->
<FicheModalV2 <FicheModal
v-model="ficheModalOpen" v-model="ficheModalOpen"
:structureId="ficheModalId" :orgId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/> />
<!-- BOUTON CHATBOT FLOTTANT (mobile) --> <!-- BOUTON CHATBOT FLOTTANT (mobile) -->
@@ -377,141 +301,268 @@
<ChatbotSheet <ChatbotSheet
:modelValue="chatbotOpen" :modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event" @update:modelValue="chatbotOpen = $event"
@highlightOrgs="() => {}" @highlightOrgs="onHighlightOrgs"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2' import type { Org } from '~/types/org'
// ── Couleurs familles ────────────────────────────────────────────────────── // ── URL query params sync ─────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = { const route = useRoute()
1: '#a85d3e', const router = useRouter()
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string { const search = ref<string>((route.query.q as string) ?? '')
return FAMILLE_COLORS[f] ?? '#888' const echelle = ref<string[]>(
} route.query.echelle
? (route.query.echelle as string).split(',').filter(Boolean)
: []
)
const fonctions = ref<string[]>(
route.query.fonctions
? (route.query.fonctions as string).split(',').filter(Boolean)
: []
)
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
// ── État UI ──────────────────────────────────────────────────────────────── const selectedId = ref<number | null>(null)
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false) const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole') const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole') // Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
// Filtres function onHighlightOrgs(ids: (number | string)[]) {
const search = ref('') if (!ids.length) return
const selectedFamille = ref<number | null>(null) const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
const selectedHashtags = ref<string[]>([]) if (isNaN(firstId)) return
// Refs cartes // Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
const navMapRef = ref<any>(null) const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null) const navMapMobileRef = ref<any>(null)
// ── Données V2 - JSON statique ───────────────────────────────────────────── // Sync URL <-> état filtres
const bifurcationData = ref<ReseauxBifurcationData | null>(null) function syncUrl() {
const pending = ref(true) const q: Record<string, string> = {}
if (search.value) q.q = search.value
onMounted(async () => { if (echelle.value.length) q.echelle = echelle.value.join(',')
try { if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json') if (territoire.value) q.territoire = territoire.value
} catch (e) { if (territoireMode.value === 'outremer') q.mode = 'outremer'
console.error('Erreur chargement reseaux-bifurcation.json', e) router.replace({ query: Object.keys(q).length ? q : undefined })
} finally {
pending.value = false
}
})
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
} }
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale // Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
if (selectedFamille.value !== null) { function storeFiltersForBack() {
if (selectedFamille.value === 6) { if (typeof window === 'undefined') return
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true) const q: Record<string, string> = {}
} else { if (search.value) q.q = search.value
result = result.filter( if (echelle.value.length) q.echelle = echelle.value.join(',')
s => s.famille_principale === selectedFamille.value || if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
(s.familles_secondaires ?? []).includes(selectedFamille.value!) if (territoire.value) q.territoire = territoire.value
) if (territoireMode.value === 'outremer') q.mode = 'outremer'
} const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('nav_back_filters', qs)
} }
// Filtre hashtags (AND logique si plusieurs) function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
if (selectedHashtags.value.length) { function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
result = result.filter( function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
s => selectedHashtags.value.every(h => s.hashtags.includes(h)) function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
) function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
}
return result function onSelectOrg(id: number) {
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
}
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) { if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id ficheModalId.value = id
ficheModalOpen.value = true ficheModalOpen.value = true
} }
} }
function onSelectStructureMobile(id: string) { // Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
selectedId.value = id selectedId.value = id
ficheModalId.value = id storeFiltersForBack()
ficheModalOpen.value = true router.push(`/fiche/${id}`)
} }
useHead({ title: "AEP - Réseaux de bifurcation architecturale" }) function onHoverOrg(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
)
function resetFilters() {
search.value = ''
echelle.value = []
fonctions.value = []
territoire.value = null
router.replace({ query: undefined })
}
// Tagging compact mobile — toggle direct
function toggleEchelle(opt: string) {
if (echelle.value.includes(opt)) {
onEchelle(echelle.value.filter(v => v !== opt))
} else {
onEchelle([...echelle.value, opt])
}
}
function toggleFonction(fn: string) {
if (fonctions.value.includes(fn)) {
onFonctions(fonctions.value.filter(f => f !== fn))
} else {
onFonctions([...fonctions.value, fn])
}
}
// Sync recherche depuis app.vue top nav (via URL ?q=)
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// Fiche aléatoire — réagit au ?random=1
watch(() => route.query.random, (v) => {
if (v === '1' && orgs.value.length > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Org[]>(() => {
let result = orgs.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.localisation_ville?.toLowerCase().includes(q)
)
}
if (echelle.value.length) {
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
}
if (fonctions.value.length) {
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
const n = fonctions.value.length
const score = (o: Org) =>
fonctions.value.reduce((s, fn, i) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return s + (fns.includes(fn) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (territoire.value) {
result = result.filter((o) => o.territoire === territoire.value)
}
return result
})
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
const metropoleOrgs = computed<Org[]>(() =>
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
)
const outremerOrgs = computed<Org[]>(() => {
if (territoire.value && DOM_TOM.includes(territoire.value)) {
return filtered.value.filter(o => o.territoire === territoire.value)
}
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
})
const outremerCountByDom = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d] = 0 })
filtered.value.forEach(o => {
if (o.territoire && DOM_TOM.includes(o.territoire)) {
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
}
})
return counts
})
// ── Compteurs ─────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const echelleCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
ECHELLES.forEach((e) => { counts[e] = 0 })
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
return counts
})
const fonctionCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
FONCTIONS.forEach((f) => { counts[f] = 0 })
orgs.value.forEach((o) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
})
return counts
})
const territoireCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TERRITOIRES.forEach((t) => { counts[t] = 0 })
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
return counts
})
// ── Helpers ───────────────────────────────────────────────────────────────
function fonctionsList(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
</script> </script>

170
pages/pratique/[id].vue Normal file
View File

@@ -0,0 +1,170 @@
<template>
<div class="min-h-screen" style="background: var(--nav-bg);">
<div class="max-w-4xl mx-auto px-4 py-6">
<!-- Bouton retour carte (préserve filtres URL) -->
<NuxtLink
:to="retourUrl"
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
style="color: var(--nav-text); background: var(--nav-bg-alt);"
aria-label="Retour aux pratiques régénératives"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour aux pratiques régénératives
</NuxtLink>
<!-- Chargement -->
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche
</div>
<!-- Erreur -->
<div v-else-if="!pratique" class="py-16 text-center">
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- ── Contenu ─────────────────────────────────────── -->
<template v-else>
<!-- Header fiche -->
<div class="mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
<div class="flex items-center gap-2 shrink-0">
<span
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap mb-3">
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
</span>
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
Score {{ pratique.score }}/5
</span>
<a
v-if="pratique.url"
:href="pratique.url"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline"
style="color: var(--nav-primary-solid);"
>Site web →</a>
</div>
<!-- Description -->
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
{{ pratique.description }}
</p>
</div>
<!-- Séparateur -->
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Critères régénératifs -->
<div v-if="pratique.criteres?.length" class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="cId in pratique.criteres"
:key="cId"
class="px-3 py-1 rounded-full text-sm font-medium"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
</span>
</div>
</div>
<!-- Tags -->
<div v-if="pratique.tags?.length" class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in pratique.tags"
:key="tag"
class="px-2 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
</div>
<!-- Métadonnées -->
<div class="mb-6">
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
<dl class="space-y-1.5">
<div v-if="pratique.passe" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Passe :</dt>
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
</div>
<div v-if="pratique.source" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Source :</dt>
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
</div>
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
</div>
</dl>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Pratique } from '~/types/pratique'
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
// ── Params & route ────────────────────────────────────────────────────
const route = useRoute()
const pratiqueId = route.params.id as string
// ── Retour carte — préserve les filtres via sessionStorage ────────────
const retourUrl = ref('/pratiques-regeneratives')
onMounted(() => {
if (typeof window !== 'undefined') {
const stored = sessionStorage.getItem('pratiques_back_filters')
if (stored) {
retourUrl.value = `/pratiques-regeneratives?${stored}`
}
}
})
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
key: `pratiques-all`,
})
const pratique = computed<Pratique | null>(() => {
const id = parseInt(pratiqueId, 10)
if (isNaN(id)) return null
return data.value?.list?.find(p => p.id === id) ?? null
})
// ── SEO dynamiques ────────────────────────────────────────────────────
useHead({
title: computed(() =>
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
),
meta: [
{
name: 'description',
content: computed(() =>
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
),
},
],
})
</script>

View File

@@ -0,0 +1,469 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<PratiqueSidebar
:search="search"
:criteres="criteres"
:typesEntite="typesEntite"
:critereCount="critereCount"
:typeCount="typeCount"
:resultCount="filtered.length"
:pratiques="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:criteres="onCriteres"
@update:typesEntite="onTypesEntite"
@select-pratique="onSelectPratique"
@hover-pratique="onHoverPratique"
@reset-filters="resetFilters"
/>
</div>
<!-- ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- VUE DESKTOP : Europe pleine largeur + DOM-TOM row en bas -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Europe pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<EuropeMap
ref="europeMapRef"
:orgs="europeOrgs"
:selectedId="selectedId"
@select-org="onSelectPratique"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template>
</ClientOnly>
</div>
</div>
<!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
<OutremerMapPratiques
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectPratique"
/>
<template #fallback>
<div
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
</template>
</ClientOnly>
</div>
</div>
<!-- VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Europe / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'europe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'europe'"
>Europe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Europe -->
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
<ClientOnly>
<EuropeMap
ref="europeMapMobileRef"
:orgs="europeOrgs"
:selectedId="selectedId"
@select-org="onSelectPratiqueMobile"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMapPratiques
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectPratiqueMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement
</div>
</template>
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une pratique">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="mobileSearch"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Filtres CRITÈRES chips -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
<div class="flex flex-wrap gap-1">
<span
v-for="c in CRITERES"
:key="c.id"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="criteres.includes(c.id)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleCritere(c.id)"
>{{ c.label }}</span>
</div>
</div>
<!-- Filtres TYPE chips -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="t in TYPES_ENTITE"
:key="t"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="typesEntite.includes(t)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleType(t)"
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</span>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="pratique in filtered"
:key="pratique.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === pratique.id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectPratiqueMobile(pratique.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
<span
v-if="pratique.pays"
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ pratique.pays }}</span>
</div>
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="cId in pratique.criteres.slice(0, 3)"
:key="cId"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
</div>
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ pratique.ville }}
</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- BOUTON CHATBOT FLOTTANT (mobile) désactivé V1 -->
<button
v-if="false"
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary);
opacity: 0.5;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
aria-label="Chatbot (bientôt disponible)"
disabled
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chatbot</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { Pratique } from '~/types/pratique'
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
const criteres = ref<number[]>(
route.query.criteres
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
: []
)
const typesEntite = ref<string[]>(
route.query.types
? (route.query.types as string).split(',').filter(Boolean)
: []
)
const pays = ref<string[]>(
route.query.pays
? (route.query.pays as string).split(',').filter(Boolean)
: []
)
const selectedId = ref<number | null>(null)
const mobileMapView = ref<'europe' | 'outremer'>('europe')
// Refs vers les instances EuropeMap
const europeMapRef = ref<any>(null)
const europeMapMobileRef = ref<any>(null)
// Ref locale barre de recherche mobile
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Sync URL <-> état filtres
function syncUrl() {
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (criteres.value.length) q.criteres = criteres.value.join(',')
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
if (pays.value.length) q.pays = pays.value.join(',')
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Sauvegarde filtres pour bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (criteres.value.length) q.criteres = criteres.value.join(',')
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
if (pays.value.length) q.pays = pays.value.join(',')
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('pratiques_back_filters', qs)
}
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
function onSelectPratique(id: number) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : naviguer vers la fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
storeFiltersForBack()
router.push(`/pratique/${id}`)
}
}
function onSelectPratiqueMobile(id: number) {
selectedId.value = id
storeFiltersForBack()
router.push(`/pratique/${id}`)
}
function onHoverPratique(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
)
function resetFilters() {
search.value = ''
criteres.value = []
typesEntite.value = []
pays.value = []
router.replace({ query: undefined })
}
function toggleCritere(id: number) {
if (criteres.value.includes(id)) {
onCriteres(criteres.value.filter(v => v !== id))
} else {
onCriteres([...criteres.value, id])
}
}
function toggleType(t: string) {
if (typesEntite.value.includes(t)) {
onTypesEntite(typesEntite.value.filter(v => v !== t))
} else {
onTypesEntite([...typesEntite.value, t])
}
}
// Sync recherche depuis URL ?q=
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Pratique[]>(() => {
let result = pratiques.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.ville?.toLowerCase().includes(q) ||
o.description?.toLowerCase().includes(q)
)
}
if (criteres.value.length) {
result = result.filter((o) =>
criteres.value.some((cId) => o.criteres?.includes(cId))
)
// Tri par score pondéré : priorité au premier critère cliqué
const n = criteres.value.length
const score = (o: Pratique) =>
criteres.value.reduce((s, cId, i) => {
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (typesEntite.value.length) {
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
}
if (pays.value.length) {
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
}
return result
})
// Séparation Europe / Outre-mer
const europeOrgs = computed<Pratique[]>(() =>
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
)
const outremerOrgs = computed<Pratique[]>(() =>
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
)
// ── Compteurs ─────────────────────────────────────────────────────────────
const critereCount = computed<Record<number, number>>(() => {
const counts: Record<number, number> = {}
CRITERES.forEach(c => { counts[c.id] = 0 })
pratiques.value.forEach(o => {
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
})
return counts
})
const typeCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
pratiques.value.forEach(o => {
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
})
return counts
})
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
</script>

833
pages/proposer-pratique.vue Normal file
View File

@@ -0,0 +1,833 @@
<template>
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/pratiques-regeneratives" class="back-link">
Retour à la carte
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une pratique</h1>
<p class="contribuer-subtitle">
Tu connais une agence, un collectif ou un réseau qui incarne l'architecture régénérative ?
Soumets-le ici — Jules valide manuellement les nouvelles entrées.
</p>
<p class="contribuer-hint">
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien.
Mais une description de ta main, c'est toujours plus vivant.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta proposition est en attente de modération.</p>
<p class="success-detail">
Jules valide manuellement chaque entrée avant publication.
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre pratique
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : Lacaton & Vassal, Plateau Urbain..."
autocomplete="organization"
@blur="validateField('nom')"
/>
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel recommandé)</span>
</label>
<input
id="url"
v-model="form.url"
type="url"
placeholder="https://..."
@blur="validateField('url')"
/>
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
v-model="form.description_user"
rows="4"
placeholder="Décris cette pratique : approche, matériaux, posture, ce qui la rend régénérative..."
@blur="validateField('description_user')"
/>
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">
{{ errors.description_user }}
</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
{{ form.description_user.length }}/500
</span>
</div>
</div>
<!-- Critères régénératifs -->
<div class="field-group" :class="{ 'field-error': errors.criteres }">
<fieldset>
<legend>
Critères régénératifs <span class="required">*</span>
<span class="label-hint">(3 minimum, 8 maximum)</span>
</legend>
<div class="checkbox-grid">
<label
v-for="c in CRITERES"
:key="c.id"
class="checkbox-label"
:class="{
active: form.criteres.includes(c.id),
disabled: !form.criteres.includes(c.id) && form.criteres.length >= 8,
}"
>
<input
type="checkbox"
:value="c.id"
:checked="form.criteres.includes(c.id)"
:disabled="!form.criteres.includes(c.id) && form.criteres.length >= 8"
@change="toggleCritere(c.id)"
/>
{{ c.label }}
</label>
</div>
</fieldset>
<span v-if="errors.criteres" class="error-msg" role="alert">{{ errors.criteres }}</span>
</div>
<!-- Type d'entité -->
<div class="field-group" :class="{ 'field-error': errors.type }">
<fieldset>
<legend>
Type d'entité <span class="required">*</span>
</legend>
<div class="radio-group">
<label
v-for="t in TYPES_ENTITE"
:key="t"
class="radio-label"
:class="{ active: form.type === t }"
>
<input
type="radio"
:value="t"
v-model="form.type"
name="type"
@change="validateField('type')"
/>
{{ TYPES_ENTITE_LABELS[t] }}
</label>
</div>
</fieldset>
<span v-if="errors.type" class="error-msg" role="alert">{{ errors.type }}</span>
</div>
<!-- Pays -->
<div class="field-group" :class="{ 'field-error': errors.pays }">
<label for="pays">
Pays <span class="required">*</span>
</label>
<select
id="pays"
v-model="form.pays"
@change="validateField('pays')"
>
<option value="" disabled>Sélectionne un pays...</option>
<optgroup label="Europe">
<option v-for="code in EUROPE_CODES" :key="code" :value="code">
{{ PAYS_LABELS[code] }}
</option>
</optgroup>
<optgroup label="DOM-TOM">
<option v-for="code in OUTREMER_CODES" :key="code" :value="code">
{{ PAYS_LABELS[code] }}
</option>
</optgroup>
<optgroup label="Autre">
<option value="AUTRE">Autre pays...</option>
</optgroup>
</select>
<span v-if="errors.pays" class="error-msg" role="alert">{{ errors.pays }}</span>
</div>
<!-- Pays autre (conditionnel) -->
<div v-if="form.pays === 'AUTRE'" class="field-group" :class="{ 'field-error': errors.pays_autre }">
<label for="pays_autre">Précise le pays</label>
<input
id="pays_autre"
v-model="form.pays_autre"
type="text"
placeholder="Ex : Maroc, Brésil..."
maxlength="50"
/>
<span v-if="errors.pays_autre" class="error-msg" role="alert">{{ errors.pays_autre }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.ville }">
<label for="ville">
Ville principale
<span class="label-hint">(optionnel)</span>
</label>
<input
id="ville"
v-model="form.ville"
type="text"
placeholder="Ex : Paris, Bordeaux, Bruxelles..."
/>
<span v-if="errors.ville" class="error-msg" role="alert">{{ errors.ville }}</span>
</div>
<!-- Tags -->
<div class="field-group" :class="{ 'field-error': errors.tags }">
<label for="tags">
Tags
<span class="label-hint">(optionnel — 3 à 6 mots-clés, séparés par des virgules)</span>
</label>
<input
id="tags"
v-model="tagsInput"
type="text"
placeholder="Ex : biosourcé, réhabilitation, circuit-court"
@blur="parseTags"
/>
<span v-if="errors.tags" class="error-msg" role="alert">{{ errors.tags }}</span>
<div v-if="form.tags && form.tags.length" class="tags-preview">
<span v-for="tag in form.tags" :key="tag" class="tag-chip">{{ tag }}</span>
</div>
</div>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel — pour le suivi)</span>
</label>
<input
id="submitted_by_email"
v-model="form.submitted_by_email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateField('submitted_by_email')"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
{{ errors.submitted_by_email }}
</span>
</div>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/pratiques-regeneratives" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer la pratique ' }}
</button>
</div>
<p class="form-note">
Ta proposition sera examinée par Jules avant publication.
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const PratiqueSubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
criteres: z
.array(z.number().int().min(1).max(8))
.min(3, 'Sélectionne au moins 3 critères')
.max(8, 'Maximum 8 critères'),
pays: z.string().length(2, 'Sélectionne un pays').or(z.literal('AUTRE')),
pays_autre: z.string().max(50).optional(),
ville: z.string().max(100).optional(),
type: z.enum(TYPES_ENTITE, { errorMap: () => ({ message: 'Sélectionne un type d\'entité' }) }),
tags: z.array(z.string().max(30)).max(6).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
criteres: [] as number[],
pays: '' as string,
pays_autre: '',
ville: '',
type: '' as typeof TYPES_ENTITE[number] | '',
tags: [] as string[],
submitted_by_email: '',
})
const tagsInput = ref('')
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = PratiqueSubmitSchema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = PratiqueSubmitSchema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
// ── Gestion critères ──────────────────────────────────────────────────────────
function toggleCritere(id: number) {
const idx = form.criteres.indexOf(id)
if (idx >= 0) {
form.criteres.splice(idx, 1)
} else if (form.criteres.length < 8) {
form.criteres.push(id)
}
validateField('criteres')
}
// ── Gestion tags ──────────────────────────────────────────────────────────────
function parseTags() {
const raw = tagsInput.value
.split(',')
.map(t => t.trim().toLowerCase())
.filter(t => t.length > 0 && t.length <= 30)
.slice(0, 6)
form.tags = raw
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
parseTags()
if (!validateAll()) {
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
await $fetch('/api/submit-pratique', {
method: 'POST',
body: {
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
criteres: form.criteres,
pays: form.pays,
pays_autre: form.pays_autre || undefined,
ville: form.ville || undefined,
type: form.type,
tags: form.tags.length ? form.tags : undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
success.value = true
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 pratiques aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => {
errors[k] = Array.isArray(v) ? v[0] : String(v)
})
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', criteres: [],
pays: '', pays_autre: '', ville: '', type: '', tags: [], submitted_by_email: '',
})
tagsInput.value = ''
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une pratique — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 0.5rem;
}
.contribuer-hint {
font-size: 0.82rem;
color: var(--nav-text-muted);
opacity: 0.75;
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group select,
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group select {
cursor: pointer;
appearance: auto;
}
.field-group input:focus,
.field-group select:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error select,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Type entité) ─────────────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Critères) ───────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Tags preview ────────────────────────────────────────────────────────────── */
.tags-preview {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.25rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
background: var(--nav-bg-alt);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 100px;
font-size: 0.78rem;
color: var(--nav-text-muted);
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,834 @@
[
{
"id": 1,
"nom": "Rotor",
"pays": "BE",
"ville": "Bruxelles",
"type": "cooperative",
"url": "https://rotordb.org",
"lat": 50.8503,
"lng": 4.3517,
"description": "Pionnier européen du réemploi. Filiale RotorDC opérationnelle depuis 2016, showroom + entrepôt à Vilvorde. Publications de référence sur l'économie circulaire du bâtiment, expositions internationales.",
"criteres": [1, 2, 5, 6, 8],
"score": 5,
"tags": ["réemploi", "recherche", "publications", "coopérative"],
"source": "seed Jules + scrape WBDM",
"passe": 1
},
{
"id": 2,
"nom": "Assemble Studio",
"pays": "UK",
"ville": "Londres",
"type": "collectif",
"url": "https://assemblestudio.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Turner Prize 2015. Travaille à la frontière architecture/art/design. Granby Workshop, Yardhouse, Sugarhouse Studios. Pratique non-hiérarchique, autoconstruction, fabrication interne.",
"criteres": [1, 3, 4, 6, 8],
"score": 5,
"tags": ["autoconstruction", "art", "social", "collectif horizontal"],
"source": "seed Jules",
"passe": 1
},
{
"id": 3,
"nom": "Forensic Architecture",
"pays": "UK",
"ville": "Londres",
"type": "recherche",
"url": "https://forensic-architecture.org",
"lat": 51.4742,
"lng": -0.0176,
"description": "Investigation architecturale autour de violations des droits humains et environnementaux. Contre-expertise vidéo/spatiale open-source. Balise politique du courant régénératif, transmission ouverte.",
"criteres": [5, 8],
"score": 2,
"tags": ["enquête", "politique", "contre-expertise", "open-source"],
"source": "seed Jules",
"passe": 1
},
{
"id": 4,
"nom": "Encore Heureux",
"pays": "FR",
"ville": "Paris",
"type": "agence",
"url": "https://encoreheureux.org",
"lat": 48.8566,
"lng": 2.3522,
"description": "Pavillon France Biennale Venise 2018 'Lieux Infinis'. Projets emblématiques sur le réemploi (Pavillon Circulaire, COP21). Manifeste, livres, posture publique forte.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "manifeste", "Biennale", "publications"],
"source": "seed Jules",
"passe": 1
},
{
"id": 5,
"nom": "Construire (Patrick Bouchain)",
"pays": "FR",
"ville": "Paris",
"type": "agence",
"url": "https://construire-architectes.com",
"lat": 48.8566,
"lng": 2.3522,
"description": "Maître d'œuvre du 'permis de faire'. Lieu Unique, Le Channel, friches culturelles. MOA habitante, pédagogie de chantier, transmission. Figure tutélaire du courant.",
"criteres": [3, 4, 5, 8],
"score": 4,
"tags": ["permis de faire", "MOA habitante", "friches", "transmission"],
"source": "seed Jules",
"passe": 1
},
{
"id": 6,
"nom": "Boidot+Robin",
"pays": "FR",
"ville": "Sézanne",
"type": "agence",
"url": "https://julienboidot.fr",
"lat": 48.7253,
"lng": 3.7211,
"description": "Architecture rurale champenoise, terre crue, bois local, sobriété matérielle radicale. Maisons individuelles et équipements publics. Engagement territorial fort, déshérence rurale.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["terre crue", "rural", "low-tech", "filières courtes"],
"source": "seed Jules",
"passe": 1
},
{
"id": 7,
"nom": "Tepop",
"pays": "FR",
"ville": "Bordeaux",
"type": "cooperative",
"url": "https://tepop.fr",
"lat": 44.8378,
"lng": -0.5792,
"description": "Coopérative d'architecture et urbanisme. Démarches participatives, projets péri-urbains, modèle SCOP. Travaille sur la commande publique et habitat groupé.",
"criteres": [3, 4, 6, 8],
"score": 4,
"tags": ["SCOP", "participatif", "périurbain", "coopérative"],
"source": "seed Jules",
"passe": 1
},
{
"id": 8,
"nom": "Architecture & Précarités",
"pays": "FR",
"ville": "Paris",
"type": "reseau",
"url": "https://architecture-precarites.fr",
"lat": 48.8566,
"lng": 2.3522,
"description": "Réseau d'architectes engagés sur les questions de précarité, mal-logement, hospitalité. Recherche-action, publications, plaidoyer. Plateforme lancée 2022 par ENSA Paris-Belleville.",
"criteres": [4, 5, 8],
"score": 3,
"tags": ["précarités", "recherche-action", "hospitalité"],
"source": "seed Jules",
"passe": 1
},
{
"id": 9,
"nom": "Agence ATM",
"pays": "FR",
"ville": "Bagnolet",
"type": "agence",
"url": "https://agence-atm.com",
"lat": 48.8694,
"lng": 2.4200,
"description": "Architecture en terre, paille, bois. Chantiers-école, transmission. Projets en Île-de-France et province. Pratique low-tech assumée.",
"criteres": [1, 2, 3, 4],
"score": 4,
"tags": ["terre", "paille", "chantier-école", "low-tech"],
"source": "seed Jules",
"passe": 1
},
{
"id": 10,
"nom": "Frugalité Heureuse & Créative",
"pays": "FR",
"ville": "Paris",
"type": "mouvement",
"url": "https://frugalite.org",
"lat": 48.8566,
"lng": 2.3522,
"description": "Mouvement initié par Madec, Lefèvre, Rollet (2018). Manifeste signé par des milliers d'architectes. Expositions, publications, plaidoyer. Référence cardinale du courant frugal en France.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["manifeste", "sobriété", "low-tech", "mouvement"],
"source": "seed Jules",
"passe": 1
},
{
"id": 11,
"nom": "Atelier Belenfant Daubas",
"pays": "FR",
"ville": "Vertou",
"type": "agence",
"url": "https://atelierbelenfantdaubas.org",
"lat": 47.1667,
"lng": -1.4667,
"description": "Agence ligérienne. Bois local, paille, éco-construction sur équipements publics (écoles, salles). Pratique discrète mais référence du courant biosourcé Ouest.",
"criteres": [1, 2, 3],
"score": 3,
"tags": ["bois", "paille", "écoles", "Ouest"],
"source": "WebSearch BE",
"passe": 1
},
{
"id": 12,
"nom": "Réempro / Opalis",
"pays": "BE",
"ville": "Bruxelles",
"type": "plateforme",
"url": "https://opalis.eu",
"lat": 50.8503,
"lng": 4.3517,
"description": "Plateforme de référence pour le réemploi (revendeurs, fiches matériaux). Issue de la mouvance Rotor. Service complémentaire d'agence et outil documentaire ouvert.",
"criteres": [1, 2, 8],
"score": 3,
"tags": ["réemploi", "base de données", "open"],
"source": "WebSearch",
"passe": 1
},
{
"id": 13,
"nom": "BC Architects & Studies",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://bc-as.org",
"lat": 50.8503,
"lng": 4.3517,
"description": "Architecture en terre crue, BTC, transmission via BC Studies (formation). Projets en Belgique, France, Maroc, Burundi. Une des références européennes terre.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["terre crue", "BTC", "formation", "international"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 14,
"nom": "Lacol Arquitectura Cooperativa",
"pays": "ES",
"ville": "Barcelone",
"type": "cooperative",
"url": "https://lacol.coop",
"lat": 41.3851,
"lng": 2.1734,
"description": "Coopérative fondée 2009 (Sants). La Borda : plus grand bâtiment résidentiel bois d'Espagne à sa livraison. Modèle horizontal, transformation sociale via architecture, habitat coopératif.",
"criteres": [3, 4, 6, 8],
"score": 4,
"tags": ["SCOP", "La Borda", "habitat coopératif", "Barcelone"],
"source": "WebSearch",
"passe": 1
},
{
"id": 15,
"nom": "Recetas Urbanas",
"pays": "ES",
"ville": "Séville",
"type": "collectif",
"url": "https://recetasurbanas.net",
"lat": 37.3891,
"lng": -5.9845,
"description": "'Recettes urbaines' : méthodologies open-source pour autoconstruire dans les interstices légaux. Hack urbain, droit comme matériau. Cirugeda figure cardinale architecture sociale espagnole.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["autoconstruction", "droit", "open-source", "hack"],
"source": "WebSearch",
"passe": 1
},
{
"id": 16,
"nom": "Die Baupiloten",
"pays": "DE",
"ville": "Berlin",
"type": "agence",
"url": "https://baupiloten.com",
"lat": 52.5200,
"lng": 13.4050,
"description": "Agence pédagogique (Susanne Hofmann + TU Berlin). Étudiants impliqués dans projets réels (écoles, logement). Méthodologie participative avec usagers, notamment enfants.",
"criteres": [4, 8],
"score": 2,
"tags": ["participation", "écoles", "pédagogie", "université"],
"source": "WebSearch",
"passe": 1
},
{
"id": 17,
"nom": "Baubotanik",
"pays": "DE",
"ville": "Munich",
"type": "recherche",
"url": "https://baubotanik.org",
"lat": 48.1351,
"lng": 11.5820,
"description": "Architecture vivante : structures construites par interaction technique + croissance végétale. Recherche fondamentale + démonstrateurs. Champ unique en Europe sur l'intégration vivant/structure.",
"criteres": [1, 7, 8],
"score": 3,
"tags": ["architecture vivante", "végétal", "recherche"],
"source": "WebSearch",
"passe": 1
},
{
"id": 18,
"nom": "Superuse Studios",
"pays": "NL",
"ville": "Rotterdam",
"type": "agence",
"url": "https://superuse-studios.com",
"lat": 51.9225,
"lng": 4.4792,
"description": "Pionnier néerlandais du réemploi industriel. Harvest Map (cartographie ressources). Projets emblématiques (Villa Welpeloo). Antériorité sur le sujet en Europe.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["réemploi", "harvest map", "industriel", "NL"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 19,
"nom": "Atelier 56S",
"pays": "FR",
"ville": "Vannes",
"type": "agence",
"url": "https://56s.fr",
"lat": 47.6559,
"lng": -2.7603,
"description": "Agence morbihannaise, bois et paille, équipements publics ruraux. Représentante du courant biosourcé Bretagne. Pratique de mise en œuvre soignée, ancrage local.",
"criteres": [1, 2, 3],
"score": 3,
"tags": ["bois", "paille", "Bretagne", "rural"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 20,
"nom": "Quatorze",
"pays": "FR",
"ville": "Paris",
"type": "asso",
"url": "https://quatorze.cc",
"lat": 48.8566,
"lng": 2.3522,
"description": "Association d'architecture. Projets autour des migrations, du logement d'urgence, des fab labs (Fab City). Posture militante, modèle associatif assumé.",
"criteres": [3, 4, 5, 6],
"score": 4,
"tags": ["asso", "migrations", "fab city", "militant"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 21,
"nom": "Yes We Camp",
"pays": "FR",
"ville": "Marseille",
"type": "collectif",
"url": "https://yeswecamp.org",
"lat": 43.2965,
"lng": 5.3698,
"description": "Tiers-lieux et occupation transitoire. Les Grands Voisins, Coco Velten. Modèle hybride architecture/programmation/animation. Ancrage friches.",
"criteres": [3, 4, 5, 6],
"score": 4,
"tags": ["tiers-lieux", "friches", "transitoire", "programmation"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 22,
"nom": "Bellastock",
"pays": "FR",
"ville": "Île-Saint-Denis",
"type": "cooperative",
"url": "https://bellastock.com",
"lat": 48.9408,
"lng": 2.3472,
"description": "Coopérative spécialisée réemploi. Festival Bellastock annuel (chantier expérimental étudiants). Recherche + maîtrise d'œuvre. Une des entrées historiques du réemploi en France.",
"criteres": [1, 2, 3, 8],
"score": 4,
"tags": ["réemploi", "festival", "étudiants", "coopérative"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 23,
"nom": "Mario Cucinella Architects (TECLA)",
"pays": "IT",
"ville": "Bologne",
"type": "agence",
"url": "https://mcarchitects.it",
"lat": 44.4949,
"lng": 11.3426,
"description": "MCA. Projet TECLA : maison imprimée 3D en terre locale (avec WASP). Posture bioclimatique forte, démonstrateurs. Référence italienne sur l'expérimentation matériaux+vivant.",
"criteres": [1, 3, 7, 8],
"score": 4,
"tags": ["terre", "impression 3D", "bioclimatique", "IT"],
"source": "connaissance acteur",
"passe": 1
},
{
"id": 24,
"nom": "Ateliermob",
"pays": "PT",
"ville": "Lisbonne",
"type": "collectif",
"url": "https://ateliermob.com",
"lat": 38.7169,
"lng": -9.1399,
"description": "Collectif engagé 'Trabalhar com os 99%'. Autoconstruction, projets sociaux, Biennale Venise 2012. Architecture participative pour les quartiers informels et communautés défavorisées.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["autoconstruction", "social", "Biennale Venise 2012"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 25,
"nom": "Artéria",
"pays": "PT",
"ville": "Lisbonne",
"type": "agence",
"url": "https://arteria.pt",
"lat": 38.7169,
"lng": -9.1399,
"description": "Atelier de réhabilitation urbaine. Edifício Manifesto, Mouraria. Réhabilitation sensible du patrimoine populaire lisbonnais, réemploi de matériaux, pratique frugale.",
"criteres": [1, 3, 4, 5],
"score": 4,
"tags": ["réhabilitation", "Edifício Manifesto", "Mouraria"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 26,
"nom": "oitoo",
"pays": "PT",
"ville": "Porto",
"type": "agence",
"url": "https://oitoo.pt",
"lat": 41.1579,
"lng": -8.6291,
"description": "Réemploi, réactivation multi-sites. Pratique entre Porto et Lisbonne. Architecture de la transformation et du réemploi comme posture systématique.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "réactivation", "multi-sites"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 27,
"nom": "Tegnestuen LOKAL",
"pays": "DK",
"ville": "Copenhague",
"type": "agence",
"url": "https://tegnestuenlokal.dk",
"lat": 55.6761,
"lng": 12.5683,
"description": "Adaptive reuse, matériaux biogéniques, Manifest planétär. Posture frugale radicale, bois local, engagement écologique de fond dans toute la pratique.",
"criteres": [1, 2, 3, 7, 8],
"score": 5,
"tags": ["adaptive reuse", "biogenic", "Manifest planetær"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 28,
"nom": "Vandkunsten Architects",
"pays": "DK",
"ville": "Copenhague",
"type": "agence",
"url": "https://vandkunsten.com",
"lat": 55.6761,
"lng": 12.5683,
"description": "Agence danoise historique, beauté + sobriété, posture engagée sur la longévité du bâti. Publications, transmission de savoir-faire architectural durable.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["beauté+sobriété", "posture", "longévité"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 29,
"nom": "White Arkitekter",
"pays": "SE",
"ville": "Stockholm",
"type": "agence",
"url": "https://whitearkitekter.com",
"lat": 59.3293,
"lng": 18.0686,
"description": "Grande agence suédoise engagée sur les modes de vie durables. Matériaux biosourcés à grande échelle, publications, transmission du savoir-faire nordique.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["sustainable ways of life", "scale"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 30,
"nom": "ZRS Architekten Ingenieure",
"pays": "DE",
"ville": "Berlin",
"type": "agence",
"url": "https://zrs.berlin",
"lat": 52.5200,
"lng": 13.4050,
"description": "Terre, bambou, construction biosourcée en Allemagne et en Afrique subsaharienne (NBL). Recherche appliquée couplée à la pratique. Référence germanophone sur les matériaux locaux et vernaculaires.",
"criteres": [1, 2, 3, 5, 8],
"score": 5,
"tags": ["terre", "bambou", "NBL", "recherche"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 31,
"nom": "Natural Building Lab (TU Berlin)",
"pays": "DE",
"ville": "Berlin",
"type": "recherche",
"url": "https://nbl.berlin",
"lat": 52.5200,
"lng": 13.4050,
"description": "Laboratoire universitaire low-tech à la TU Berlin. Transmission, expérimentation constructive avec matériaux naturels, lien recherche-pratique exemplaire.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["low-tech", "université", "transmission"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 32,
"nom": "DGJ Architektur",
"pays": "DE",
"ville": "Frankfurt",
"type": "agence",
"url": "https://dgj.eu",
"lat": 50.1109,
"lng": 8.6821,
"description": "Agence engagée sur le durable et le biosourcé en Allemagne. Pratique soignée, matériaux biosourcés intégrés aux projets résidentiels et publics.",
"criteres": [1, 3, 8],
"score": 3,
"tags": ["sustainable", "biosourcés"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 33,
"nom": "Baubüro in situ + Zirkular",
"pays": "CH",
"ville": "Bâle",
"type": "agence",
"url": "https://insitu.ch",
"lat": 47.5596,
"lng": 7.5886,
"description": "Réemploi structurel de référence en Suisse. K118, Holcim Gold Award. Pratique circulaire systématique, publications, posture publique affirmée.",
"criteres": [1, 2, 5, 6, 8],
"score": 5,
"tags": ["K118", "réemploi structurel", "Holcim Gold"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 34,
"nom": "einszueins architektur",
"pays": "AT",
"ville": "Vienne",
"type": "agence",
"url": "https://einszueins.at",
"lat": 48.2082,
"lng": 16.3738,
"description": "Wohnprojekt Wien, baugruppe, World Habitat Award. Habitat coopératif et participatif à Vienne. Modèle alternatif de co-construction avec futurs habitants.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["Wohnprojekt Wien", "baugruppe", "World Habitat Award"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 35,
"nom": "BudCud",
"pays": "PL",
"ville": "Cracovie",
"type": "agence",
"url": "https://budcud.org",
"lat": 50.0647,
"lng": 19.9450,
"description": "Réemploi, mobilier urbain, pratique engagée à Cracovie. Projets d'espace public avec matériaux récupérés, participation des usagers.",
"criteres": [1, 3, 4, 8],
"score": 4,
"tags": ["réemploi", "mobilier", "urbain"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 36,
"nom": "Centrala",
"pays": "PL",
"ville": "Varsovie",
"type": "agence",
"url": "https://centrala.net.pl",
"lat": 52.2297,
"lng": 21.0122,
"description": "Recherche spatiale, posture critique. Agence-recherche varsovienne engagée sur les transformations urbaines post-industrielles.",
"criteres": [3, 5, 8],
"score": 3,
"tags": ["recherche spatiale", "posture"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 37,
"nom": "RiceHouse",
"pays": "IT",
"ville": "Biella",
"type": "agence",
"url": "https://ricehouse.it",
"lat": 45.5629,
"lng": 8.0565,
"description": "Paille de riz comme matériau de construction. Filière biosourcée ultra-locale (Plaine du Pô). Recherche + démonstrations, lien producteurs-constructeurs.",
"criteres": [1, 2, 3, 7, 8],
"score": 5,
"tags": ["paille de riz", "biosourcé", "filière courte"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 38,
"nom": "TAMassociati",
"pays": "IT",
"ville": "Venise",
"type": "agence",
"url": "https://tamassociati.org",
"lat": 45.4408,
"lng": 12.3155,
"description": "'Taking care' comme posture. Architecture humanitaire et sociale en Italie et à l'international. Projets de santé et d'éducation dans des contextes fragiles.",
"criteres": [3, 4, 5, 8],
"score": 4,
"tags": ["taking care", "humanitaire", "social"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 39,
"nom": "Diverserighestudio",
"pays": "IT",
"ville": "Bologne",
"type": "agence",
"url": "https://diverserighestudio.it",
"lat": 44.4949,
"lng": 11.3426,
"description": "Cohousing Porto15, recherche, participatif. Studio bolonais engagé sur l'habitat participatif et la recherche architecturale, modèle coopératif.",
"criteres": [3, 4, 5, 6, 8],
"score": 5,
"tags": ["cohousing Porto15", "recherche", "participatif"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 40,
"nom": "Material Cultures",
"pays": "UK",
"ville": "Londres",
"type": "asso",
"url": "https://materialcultures.org",
"lat": 51.5074,
"lng": -0.1278,
"description": "Biosourcés, chanvre (hempcrete), Harvest House. Association de recherche sur les cultures matérielles biosourcées et leur transformation en techniques constructives.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "hempcrete", "Harvest House"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 41,
"nom": "6a architects",
"pays": "UK",
"ville": "Londres",
"type": "agence",
"url": "https://6a.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Strates, retrofit, transmission. Agence londonienne engagée sur la transformation du bâti existant, lecture des strates temporelles et sobriété des interventions.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["strates", "retrofit", "transmission"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 42,
"nom": "We Made That",
"pays": "UK",
"ville": "Londres",
"type": "agence",
"url": "https://wemadethat.co.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Public realm, co-conception, civic. Agence engagée sur les espaces publics et l'aménagement participatif en contexte urbain dense.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["public realm", "co-conception", "civic"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 43,
"nom": "Public Practice",
"pays": "UK",
"ville": "Londres",
"type": "asso",
"url": "https://publicpractice.org.uk",
"lat": 51.5074,
"lng": -0.1278,
"description": "Architectes placés en mairies pour œuvrer dans l'intérêt public. Modèle innovant d'insertion professionnelle au service de la commande publique locale.",
"criteres": [4, 5, 6, 8],
"score": 4,
"tags": ["architectes en mairies", "intérêt public"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 44,
"nom": "AgwA",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://agwa.be",
"lat": 50.8503,
"lng": 4.3517,
"description": "Transformation, EUmies 2026, Charleroi. Agence bruxelloise spécialisée en transformation du bâti existant, posture critique et réemploi intégré.",
"criteres": [1, 5, 8],
"score": 3,
"tags": ["transformation", "EUmies 2026", "Charleroi"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 45,
"nom": "Carton123 architecten",
"pays": "BE",
"ville": "Bruxelles",
"type": "agence",
"url": "https://carton123.be",
"lat": 50.8503,
"lng": 4.3517,
"description": "Réemploi, école, collaboration. Agence bruxelloise engagée sur les projets scolaires avec une forte dimension réemploi et collaboration avec les usagers.",
"criteres": [1, 3, 5, 8],
"score": 4,
"tags": ["réemploi", "school", "collaboration"],
"source": "seed passe 2 axe 1",
"passe": 2
},
{
"id": 46,
"nom": "KEBATI",
"pays": "MQ",
"ville": "Fort-de-France",
"type": "asso",
"url": "https://www.kebati.com",
"lat": 14.6161,
"lng": -61.0588,
"description": "Association citoyenne martiniquaise dédiée au bâtiment durable en milieu tropical humide. Centre de ressources reconnu nationalement (22 centres ADEME). 236 visites de chantiers, groupes de travail biosourcés, newsletter 800 abonnés.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "tropical", "transmission", "Martinique", "centre de ressources"],
"source": "P1-RECAP scrape kebati.com",
"passe": 3
},
{
"id": 47,
"nom": "AQUAA",
"pays": "GF",
"ville": "Cayenne",
"type": "asso",
"url": "https://www.aquaa.fr",
"lat": 4.9333,
"lng": -52.3333,
"description": "Association pour la qualité de la construction en Amazonie et aux Antilles. Guides techniques d'architecture bioclimatique guyanaise, éco-matériaux locaux, urbanisme équatorial. Membre fondateur du RBD Intertropical.",
"criteres": [2, 3, 5, 8],
"score": 4,
"tags": ["bioclimatique", "amazonien", "éco-matériaux", "Guyane"],
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
"passe": 3
},
{
"id": 48,
"nom": "Karibati",
"pays": "FR",
"ville": "Paris",
"type": "reseau",
"url": "http://www.karibati.fr",
"lat": 48.8566,
"lng": 2.3522,
"description": "Expert national du bâtiment biosourcé et géosourcé avec 15 ans d'expérience. Développement et structuration de filières biosourcées, accompagnement fabricants, formations, outils méthodologiques (aKacia, Label Produit Biosourcé).",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["biosourcés", "géosourcés", "filières", "formation", "national"],
"source": "P1-RECAP scrape karibati.fr",
"passe": 3
},
{
"id": 49,
"nom": "Réseau Bâtiment Durable Intertropical (RBD)",
"pays": "GP",
"ville": "Pointe-à-Pitre",
"type": "reseau",
"url": "https://www.synergile.fr/departements/rbd/",
"lat": 16.2410,
"lng": -61.5337,
"description": "Réseau intertropical de 4 structures (AQUAA Guyane, KEBATI Martinique, Envirobat Réunion, RBD Guadeloupe). Réponse concertée aux enjeux du bâtiment durable tropical. Pôle d'innovation Synergîles, financement ADEME.",
"criteres": [1, 2, 5, 8],
"score": 4,
"tags": ["réseau", "intertropical", "DOM-TOM", "ADEME"],
"source": "P1-RECAP scrape synergile.fr/rbd",
"passe": 3
},
{
"id": 50,
"nom": "Caribois",
"pays": "GP",
"ville": "Baie-Mahault",
"type": "agence",
"url": "https://www.caribois.com",
"lat": 16.2410,
"lng": -61.5337,
"description": "Constructeur de maisons individuelles en bois, béton ou mixte depuis 20 ans en Guadeloupe. Fabrication 100% locale, matériaux biosourcés (bois sélectionné), architecture créole. Accompagnement complet du financement à la livraison.",
"criteres": [1, 2],
"score": 2,
"tags": ["bois", "créole", "local", "Guadeloupe"],
"source": "P1-RECAP scrape caribois.com — constructeur commercial (pas collectif praticien au sens strict)",
"passe": 3
},
{
"id": 51,
"nom": "Vegetal(e)",
"pays": "GP",
"ville": "Pointe-à-Pitre",
"type": "plateforme",
"url": "http://www.vegetal-e.com/fr/guadeloupe_384.html",
"lat": 16.2410,
"lng": -61.5337,
"description": "Portail d'information et base de données sur les éco-matériaux et solutions constructives biosourcés en Guadeloupe. Agrégateur de ressources régionales (chanvre, cellulose, bois, bambou, paille). Newsletter hebdomadaire sur l'innovation construction.",
"criteres": [1, 5, 8],
"score": 3,
"tags": ["biosourcés", "portail", "Guadeloupe", "éco-matériaux"],
"source": "P1-RECAP scrape vegetal-e.com — portail info, pas une agence praticien",
"passe": 3
},
{
"id": 52,
"nom": "Envirobat Réunion",
"pays": "RE",
"ville": "Saint-Denis",
"type": "reseau",
"url": "https://www.envirobat.re",
"lat": -20.8789,
"lng": 55.4481,
"description": "Centre de ressources bâtiment durable de La Réunion. Membre fondateur du RBD Intertropical. Équivalent insulaire de KEBATI pour l'océan Indien, transmission et formation des professionnels du bâtiment réunionnais.",
"criteres": [2, 5, 8],
"score": 3,
"tags": ["bâtiment durable", "Réunion", "centre de ressources", "intertropical"],
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
"passe": 3
}
]

View File

@@ -1,39 +0,0 @@
/**
* GET /api/admin/rag-info
*
* Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status
*/
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
export default defineEventHandler(async (_event) => {
// Statut V2 : compter les embeddings
let v2Count = 0
let v2Date: string | null = null
let v2Model: string | null = null
try {
// Chercher depuis process.cwd() (racine du projet Nuxt)
const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json')
if (existsSync(embPath)) {
const data = JSON.parse(readFileSync(embPath, 'utf-8'))
v2Count = data.embeddings?.length ?? 0
v2Date = data.meta?.date ?? null
v2Model = data.meta?.model ?? null
}
} catch (e: any) {
console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e)
}
return {
v2_embeddings_count: v2Count,
v2_ready: v2Count > 0,
v2_model: v2Model ?? 'mistral-embed',
v2_generated_date: v2Date ?? null,
v1_enabled: process.env.RAG_V1_ENABLED !== 'false',
v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini',
model_chat: 'mistral-small-latest',
setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js'
}
})

View File

@@ -1,194 +0,0 @@
/**
* POST /api/chatbot-v2
*
* Chatbot V2 - Embedding-based search sur structures bifurcation
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
*
* SETUP AVANT DEPLOY :
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
* Coût estimé : ~0.10 EUR pour 120 fiches
*
* Flow :
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
* 2. Embed la query via Mistral Embed (mistral-embed)
* 3. Top-5 cosine similarity sur embeddings-v2.json
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
* 5. Construit contexte RAG depuis les fiches candidates
* 6. Génère réponse Mistral Small (json_object)
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
*
* Variables d'env :
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
// ── System prompt V2 ───────────────────────────────────────────────────────────
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
Règles :
- Cite chaque structure par son nom exact et son fiche_id
- Indique la famille (1-5) entre parenthèses après chaque nom
- Reste sobre et descriptif - pas militant agressif
- Tirets longs interdits : utilise des - ou des ;
- Max 200 mots par réponse
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
Familles :
1 - Réemploi et filières
2 - Frugalité et low-tech
3 - Architecture sociale et précarités
4 - Collectifs, écolieux et AMO
5 - Urbanisme de transition et territoires
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 200 mots)",
"fiches_recommandees": [
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
]
}
CONTEXTE - Structures disponibles :
{{CONTEXTE_RAG}}`
// ── Handler ────────────────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. Rate limit
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.'
})
}
// 2. Validation body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
if (!question || question.length < 3) {
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
}
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
}
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
const embeddingsV2 = loadEmbeddingsV2()
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
if (embeddingsV2.length === 0) {
return {
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
fiches_recommandees: [],
sources: [],
v2_ready: false
}
}
// 4. Embed la query via Mistral Embed
let queryEmbedding: number[]
try {
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
'https://api.mistral.ai/v1/embeddings',
{
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-embed',
inputs: [question]
})
}
)
queryEmbedding = embedRes.data[0].embedding
} catch (e: any) {
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
}
// 5. Top-5 cosine similarity
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
// 6. Contexte RAG
const candidatesContext = v2Results.map(r => ({
fiche_id: r.fiche_id,
nom: r.nom,
famille: r.famille,
hashtags: r.hashtags,
score: r.score,
preview: r.text_preview
}))
const contextStr = candidatesContext
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
.join('\n\n---\n\n')
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
// 7. Mistral Small - génération réponse
let mistralRaw: string
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question }
]
})
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
} catch (e: any) {
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
}
// 8. Parse JSON
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
try {
parsed = JSON.parse(mistralRaw)
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
} catch {
parsed = {
reponse_texte: "Impossible d'analyser la réponse.",
fiches_recommandees: []
}
}
return {
reponse_texte: parsed.reponse_texte,
fiches_recommandees: parsed.fiches_recommandees ?? [],
sources: candidatesContext,
v2_ready: true
}
})

View File

@@ -1,31 +0,0 @@
import { z } from 'zod'
const AuthSchema = z.object({
password: z.string().min(1).max(100),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = AuthSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
}
const config = useRuntimeConfig()
const expected = config.codevPassword || 'merci'
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
}
setCookie(event, 'codev_session', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
return { status: 200, ok: true }
})

View File

@@ -1,31 +0,0 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
if (!tableId) {
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
}
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
const data: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => ({ list: [] }))
// Mapper chaque record NocoDB vers CodevFiche
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
id: r.Id ?? r.id,
nom: r.nom || '',
besoin: r.besoin || '',
offre: r.offre || '',
hashtags: (r.hashtags || '')
.split(',')
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean),
created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
}))
return { list }
})

View File

@@ -1,63 +0,0 @@
import { z } from 'zod'
const FicheSchema = z.object({
nom: z.string().min(2).max(50).trim(),
besoin: z.string().min(5).max(300).trim(),
offre: z.string().min(5).max(300).trim(),
hashtags: z.array(z.string().max(30)).max(3).default([]),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = FicheSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const payload = {
nom: parsed.data.nom,
besoin: parsed.data.besoin,
offre: parsed.data.offre,
hashtags: parsed.data.hashtags
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
.join(','),
created_at: new Date().toISOString(),
}
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
let inserted: any
try {
inserted = await $fetch(insertUrl, {
method: 'POST',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur serveur, réessaie',
})
}
return {
status: 201,
id: inserted?.Id ?? inserted?.id ?? null,
}
})

View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import type { Pratique } from '~/types/pratique'
/**
* GET /api/pratiques
* Lit public/data/pratiques-regeneratives.json
* Retourne { list: Pratique[], source: 'static' }
*/
export default defineEventHandler(async (_event) => {
try {
const jsonPath = resolve(process.cwd(), 'public/data/pratiques-regeneratives.json')
const raw = readFileSync(jsonPath, 'utf-8')
const list: Pratique[] = JSON.parse(raw)
return { list, source: 'static' }
} catch (err) {
console.error('[PRATIQUES API] Erreur lecture JSON:', err)
throw createError({ statusCode: 503, message: 'Données pratiques-regeneratives indisponibles' })
}
})

View File

@@ -0,0 +1,117 @@
// Modération : Jules consulte public/data/pratiques-pending.json,
// déplace les entrées validées dans public/data/pratiques-regeneratives.json,
// supprime de pending. À automatiser en V2 (UI admin).
import { z } from 'zod'
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
// ── Schéma Zod (miroir du client) ─────────────────────────────────────────────
export const PratiqueSubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z
.string()
.url('URL invalide (commencer par https://)')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
description_user: z
.string()
.min(50, 'Minimum 50 caractères')
.max(500, 'Maximum 500 caractères')
.trim(),
criteres: z
.array(z.number().int().min(1).max(8))
.min(3, 'Sélectionne au moins 3 critères')
.max(8, 'Maximum 8 critères'),
pays: z.string().length(2, 'Code pays invalide').or(z.literal('AUTRE')),
pays_autre: z.string().max(50).optional(),
ville: z.string().max(100).optional().transform((v) => v?.trim() || undefined),
type: z.enum([
'agence', 'cooperative', 'collectif', 'reseau', 'asso',
'recherche', 'mouvement', 'plateforme', 'inconnu',
], { errorMap: () => ({ message: 'Type d\'entité invalide' }) }),
tags: z.array(z.string().max(30)).max(6).optional(),
submitted_by_email: z
.string()
.email('Email invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
})
export type PratiqueSubmitInput = z.infer<typeof PratiqueSubmitSchema>
// ── Chemin du fichier pending ─────────────────────────────────────────────────
function getPendingPath(): string {
return resolve(process.cwd(), 'public/data/pratiques-pending.json')
}
function readPending(): PratiqueSubmitInput[] {
const path = getPendingPath()
try {
if (!existsSync(path)) return []
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return []
}
}
function writePending(entries: unknown[]) {
writeFileSync(getPendingPath(), JSON.stringify(entries, null, 2), 'utf-8')
}
// ── Handler principal ─────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
// 1. Récupérer l'IP (proxy-aware)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit JSON : 3 soumissions / IP / jour
const allowed = checkRateLimitJson(ip, 'submit-pratique', 3)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Trop de soumissions. Réessaie demain.',
})
}
// 3. Lire et valider le body
const body = await readBody(event)
const parsed = PratiqueSubmitSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const data = parsed.data
// 4. Construire l'entrée pending
const timestamp = Date.now()
const entry = {
...data,
id: timestamp,
submitted_at: new Date().toISOString(),
moderation_status: 'pending' as const,
}
// 5. Append à pratiques-pending.json
const pending = readPending()
pending.push(entry)
writePending(pending)
return {
ok: true,
trackingId: timestamp,
}
})

View File

@@ -1,20 +0,0 @@
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
export default defineEventHandler((event) => {
const url = getRequestURL(event)
const path = url.pathname
// Seulement les routes sous /codev/
if (!path.startsWith('/codev/')) return
// Routes publiques : /codev/demo (et sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
// Vérification cookie
const session = getCookie(event, 'codev_session')
if (session === 'ok') return
// Non authentifié -> redirect vers /codev (lock screen)
return sendRedirect(event, '/codev', 302)
})

View File

@@ -1,96 +0,0 @@
/**
* Recherche vectorielle sur les embeddings V2
* Cosine similarity + top-K
*
* Utilisé par : server/api/chatbot-v2.post.ts
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
*/
import { readFileSync, existsSync } from 'fs'
import { fileURLToPath } from 'url'
import { resolve, dirname } from 'path'
// ── Types ──────────────────────────────────────────────────────────────────────
export interface EmbeddingEntry {
fiche_id: string
nom: string
famille: number
hashtags: string[]
embedding: number[]
text_preview: string
}
export interface SearchResult {
fiche_id: string
nom: string
famille: number
hashtags: string[]
score: number
text_preview: string
}
// ── Cosine similarity ──────────────────────────────────────────────────────────
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) return 0
let dot = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
// ── Top-K search ───────────────────────────────────────────────────────────────
export function topKSearch(
embeddings: EmbeddingEntry[],
queryEmbedding: number[],
k: number = 5
): SearchResult[] {
return embeddings
.map(e => ({
fiche_id: e.fiche_id,
nom: e.nom,
famille: e.famille,
hashtags: e.hashtags,
score: cosineSimilarity(e.embedding, queryEmbedding),
text_preview: e.text_preview
}))
.sort((a, b) => b.score - a.score)
.slice(0, k)
}
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
let _embeddingsV2: EmbeddingEntry[] | null = null
export function loadEmbeddingsV2(): EmbeddingEntry[] {
if (_embeddingsV2 !== null) return _embeddingsV2
try {
// Résolution du chemin depuis server/utils/ vers server/data/
const currentDir = dirname(fileURLToPath(import.meta.url))
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
if (!existsSync(embPath)) {
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
_embeddingsV2 = []
return []
}
const raw = readFileSync(embPath, 'utf-8')
const data = JSON.parse(raw)
_embeddingsV2 = data.embeddings ?? []
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
return _embeddingsV2!
} catch (e: any) {
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
_embeddingsV2 = []
return []
}
}

View File

@@ -1,18 +0,0 @@
export interface CodevFiche {
id: number
nom: string
besoin: string
offre: string
hashtags: string[] // parsé depuis CSV NocoDB
created_at: string // ISO
}
export interface CodevMatch {
fromId: number
toId: number
score: number // 0-1
mode: 'solution' | 'alliance' | 'surprise'
// solution : fromId.besoin matche toId.offre (orienté)
// alliance : symétrique sur besoin
// surprise : symétrique sur offre
}

69
types/pratique.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Interface canonique Pratique — AEP Pratiques Régénératives
* Source unique : types/pratique.ts
* Importée dans pages/pratiques-regeneratives.vue, pages/pratique/[id].vue
*/
export interface Pratique {
id: number
nom: string
pays: string // ISO-2 — Europe (FR/BE/...) ou DOM-TOM (GP/MQ/GF/RE/YT/PF/NC/...)
ville: string
type: 'agence' | 'cooperative' | 'collectif' | 'reseau' | 'asso' | 'recherche' | 'mouvement' | 'plateforme' | 'inconnu'
url: string
lat: number | null
lng: number | null
description: string
criteres: number[] // 1-8
score: number
tags: string[]
source: string
passe: 1 | 2 | 3
}
export const CRITERES = [
{ id: 1, label: 'Matériaux' },
{ id: 2, label: 'Filières' },
{ id: 3, label: 'Posture' },
{ id: 4, label: 'Process' },
{ id: 5, label: 'Politique' },
{ id: 6, label: 'Modèle éco' },
{ id: 7, label: 'Vivant' },
{ id: 8, label: 'Transmission' },
] as const
export const TYPES_ENTITE = [
'agence',
'cooperative',
'collectif',
'reseau',
'asso',
'recherche',
'mouvement',
'plateforme',
'inconnu',
] as const
export const TYPES_ENTITE_LABELS: Record<string, string> = {
agence: 'Agence',
cooperative: 'Coopérative',
collectif: 'Collectif',
reseau: 'Réseau',
asso: 'Association',
recherche: 'Recherche',
mouvement: 'Mouvement',
plateforme: 'Plateforme',
inconnu: 'Autre',
}
export const EUROPE_CODES = ['FR', 'BE', 'UK', 'DE', 'ES', 'NL', 'CH', 'IT', 'PT', 'SE', 'DK', 'FI', 'NO', 'PL', 'CZ', 'AT'] as const
export const OUTREMER_CODES = ['GP', 'MQ', 'GF', 'RE', 'YT', 'PF', 'NC', 'BL', 'MF', 'PM', 'WF'] as const
export const PAYS_LABELS: Record<string, string> = {
FR: 'France', BE: 'Belgique', UK: 'Royaume-Uni', DE: 'Allemagne',
ES: 'Espagne', NL: 'Pays-Bas', CH: 'Suisse', IT: 'Italie',
PT: 'Portugal', SE: 'Suède', DK: 'Danemark', FI: 'Finlande',
NO: 'Norvège', PL: 'Pologne', CZ: 'Tchéquie', AT: 'Autriche',
GP: 'Guadeloupe', MQ: 'Martinique', GF: 'Guyane', RE: 'La Réunion',
YT: 'Mayotte', PF: 'Polynésie française', NC: 'Nouvelle-Calédonie',
BL: 'Saint-Barthélemy', MF: 'Saint-Martin', PM: 'Saint-Pierre-et-Miquelon', WF: 'Wallis-et-Futuna',
}

View File

@@ -1,91 +0,0 @@
/**
* Types V2 - Carte des réseaux de bifurcation
* Source : public/data/reseaux-bifurcation.json
*/
export interface StructureV2 {
id: string
nom: string
url: string
pays: string
ville: string
famille_principale: 1 | 2 | 3 | 4 | 5
familles_secondaires?: number[]
hashtags: string[]
type_principal: string
badges: {
centre_ressources: boolean
mouvement_manifeste: boolean
contre_pouvoir_spatial: boolean
f6_recherche_politique: boolean
}
description_courte: string
description_longue: string
pensees: { id: string; label: string; confiance: string }[]
sources: { type: string; titre: string; url: string }[]
already_in_v1: boolean
eligible_v2: boolean
// Geocoords (ajoutés par géocodage - peut être null)
latitude?: number | null
longitude?: number | null
}
export interface ReseauxBifurcationData {
version: string
meta: {
total_structures: number
total_projets_emblematiques: number
total_edges_graphe: number
familles: { id: number; label: string; color: string }[]
hashtags_officiels: string[]
}
structures: StructureV2[]
projets: ProjetEmblematique[]
graphe: { edges: GrapheEdge[] }
}
export interface ProjetEmblematique {
id: string
nom: string
structure_parent: string
annee?: number
lieu?: string
geocoords?: { lat: number; lng: number } | null
description: string
url?: string | null
tags: string[]
}
export interface GrapheEdge {
source: string
target: string
types: string[]
score: number
evidence: string
}
// Mapping StructureV2 vers le format attendu par NavMap (interface Org)
// NavMap attend { Id, latitude, longitude, nom, ... }
export function structureToMapOrg(s: StructureV2, index: number): {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
famille_principale?: number
hashtags?: string[]
type_principal?: string
description_courte?: string
} {
return {
Id: index,
nom: s.nom,
latitude: s.latitude,
longitude: s.longitude,
prioritaire: s.badges?.centre_ressources || s.badges?.mouvement_manifeste || s.badges?.contre_pouvoir_spatial,
famille_principale: s.famille_principale,
hashtags: s.hashtags,
type_principal: s.type_principal,
description_courte: s.description_courte,
}
}

View File

@@ -1,97 +0,0 @@
import type { CodevFiche, CodevMatch } from '~/types/codev'
const STOP_WORDS_FR = new Set([
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
'est', 'sont', 'etre', 'ai', 'as', 'avoir',
'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
'me', 'te', 'se', 'lui', 'leur', 'y',
])
function tokenize(text: string): Set<string> {
if (!text) return new Set()
const tokens = text
.toLowerCase()
.replace(/[.,;:!?()'"\-/]/g, ' ')
.split(/\s+/)
.filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
return new Set(tokens)
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let inter = 0
for (const x of a) if (b.has(x)) inter++
const union = a.size + b.size - inter
return union === 0 ? 0 : inter / union
}
function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
if (tagsA.size > 0 && tagsB.size > 0) {
return jaccard(tagsA, tagsB)
}
return jaccard(tokenize(textA), tokenize(textB))
}
const THRESHOLD = 0.15
export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
const matches: CodevMatch[] = []
for (const a of fiches) {
for (const b of fiches) {
if (a.id === b.id) continue
const s = score(a.besoin, a.hashtags, b.offre, b.hashtags)
if (s >= THRESHOLD) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
}
}
}
return matches
}
export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
if (s >= THRESHOLD) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
}
}
}
return matches
}
export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
if (s >= THRESHOLD) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
}
}
}
return matches
}
export function computeMatches(
fiches: CodevFiche[],
mode: 'solution' | 'alliance' | 'surprise',
): CodevMatch[] {
switch (mode) {
case 'solution': return matchSolution(fiches)
case 'alliance': return matchAlliance(fiches)
case 'surprise': return matchSurprise(fiches)
}
}