- 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>
216 lines
4.2 KiB
Vue
216 lines
4.2 KiB
Vue
<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>
|