feat: PC3 mindmap Carte O (D3 force-directed) + scrape AEP/Articles + tabs centre HAUT

- scripts/build-carte-o.js : scan recursif AEP/Articles/, parse YAML + legacy header, extract wikilinks, infer 5 famille
- src/components/vue/CarteO.vue : D3 v7 force-directed avec drag, zoom + pan, click handler, tooltips, ResizeObserver
- src/components/vue/CarteOModal.vue : modal recap intention avec Teleport, Esc + backdrop close, transitions
- src/components/vue/CarteOWrapper.vue : fetch /data/carte-o.json, etat selectionne, fallback mobile (msg + miniature SVG)
- src/components/astro/ColCentre.astro : tabs Carte O / Chatbot, panneaux ARIA
- package.json : prebuild + predev hooks, build:carte-o script
- public/data/carte-o.json : 84 nodes / 94 edges sur 21 themes, distribution familles equilibree

Drill-down V1 = zoom + pan seul (V2 recursif backlog).
Pattern adapte de nav-carte/components/codev/CodevGraph.vue (sans coupling Nuxt).
Build Astro 6.3.1 OK, bundle CarteOWrapper 69KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-09 00:59:23 +02:00
parent aeaec6fc06
commit 32bdc9a2e5
8 changed files with 3423 additions and 7 deletions

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
// Modal récap intention pour Carte O.
// Click node -> emit('node-click', n) -> selectedNode.value = n -> ce modal s'affiche.
// Esc + click backdrop ferment.
import { onMounted, onUnmounted, watch } from 'vue'
interface CarteNode {
id: string
label: string
family: string
intention?: string
slug?: string
theme?: string
}
const props = defineProps<{
node: CarteNode | null
familyColors?: Record<string, string>
}>()
const emit = defineEmits<{
close: []
}>()
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.node) emit('close')
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
watch(() => props.node, (n) => {
if (n) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
function colorFor(family: string): string {
return props.familyColors?.[family] || '#9ca3af'
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="node"
class="modal-backdrop"
role="dialog"
aria-modal="true"
:aria-labelledby="`carte-o-modal-title`"
@click.self="emit('close')"
>
<div class="modal-card">
<button
type="button"
class="close-btn"
aria-label="Fermer"
@click="emit('close')"
>×</button>
<div class="family-badge" :style="{ backgroundColor: colorFor(node.family) }">
{{ node.family }}
</div>
<h2 id="carte-o-modal-title" class="title">
{{ node.label }}
</h2>
<p v-if="node.intention" class="intention">
{{ node.intention }}
</p>
<p v-else class="intention placeholder">
Pas d'intention extraite. Ouvrez la fiche pour le contenu complet.
</p>
<div v-if="node.theme" class="theme">
Thème : <strong>{{ node.theme }}</strong>
</div>
<a
v-if="node.slug"
:href="`/thematiques/${node.slug}`"
class="cta-link"
>
Lire la fiche
</a>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
backdrop-filter: blur(2px);
}
.modal-card {
position: relative;
background: #ffffff;
border-radius: 14px;
max-width: 32rem;
width: 100%;
padding: 1.75rem;
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.35);
}
.close-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 24px;
line-height: 1;
color: #9ca3af;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: #f3f4f6;
color: #374151;
}
.family-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #fff;
margin-bottom: 0.65rem;
}
.title {
font-size: 1.4rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.85rem 0;
line-height: 1.3;
}
.intention {
color: #4b5563;
line-height: 1.55;
margin: 0 0 1rem 0;
font-size: 0.95rem;
}
.intention.placeholder {
font-style: italic;
color: #9ca3af;
}
.theme {
font-size: 0.85rem;
color: #6b7280;
margin-bottom: 1.25rem;
}
.cta-link {
display: inline-block;
padding: 0.55rem 1.1rem;
background: #1f2937;
color: #fff;
border-radius: 8px;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: background 0.15s;
}
.cta-link:hover {
background: #111827;
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-card,
.modal-leave-active .modal-card {
transition: transform 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-card,
.modal-leave-to .modal-card {
transform: scale(0.96);
}
</style>