From d8d3af28a068d773ade5379396504cb236d24a00 Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Mon, 11 May 2026 15:13:46 +0200 Subject: [PATCH] feat(v11-c): carte-o rendering refonte niveau/nature/statut + contextmenu positionne Co-Authored-By: Claude Sonnet 4.6 --- src/components/vue/CarteO.vue | 74 +++++--- src/components/vue/CarteOContextMenu.vue | 119 +++++++++++++ src/components/vue/CarteOModal.vue | 215 ----------------------- src/components/vue/CarteOWrapper.vue | 28 ++- 4 files changed, 192 insertions(+), 244 deletions(-) create mode 100644 src/components/vue/CarteOContextMenu.vue delete mode 100644 src/components/vue/CarteOModal.vue diff --git a/src/components/vue/CarteO.vue b/src/components/vue/CarteO.vue index a663434..b055fc9 100644 --- a/src/components/vue/CarteO.vue +++ b/src/components/vue/CarteO.vue @@ -10,6 +10,11 @@ interface CarteNode { id: string label: string family: string + niveau?: number + nature?: 'essai' | 'projet' + statut?: 'gestation' | 'edite' + resume?: string | null + radius?: number intention?: string slug?: string theme?: string @@ -36,7 +41,7 @@ const props = withDefaults(defineProps<{ }) const emit = defineEmits<{ - 'node-click': [node: CarteNode] + 'node-click': [payload: { node: CarteNode; x: number; y: number }] }>() // Refs @@ -59,8 +64,32 @@ let zoomBehavior: d3.ZoomBehavior | null = null const isMobile = computed(() => width.value < 600) const nodeRadius = computed(() => isMobile.value ? 10 : 14) -function colorFor(family: string): string { - return props.familyColors[family] || '#9ca3af' +function colorFor(d: SimNode): string { + if (d.nature === 'projet') return '#b45309' + if (d.niveau === 0) return '#1d4ed8' + if (d.niveau === 1) return '#2563eb' + if (d.niveau === 2) return '#60a5fa' + return props.familyColors[d.family] || '#9ca3af' +} + +function getRadius(d: SimNode): number { + return d.radius ?? nodeRadius.value +} + +function strokeFor(d: SimNode): string { + return d.statut === 'edite' ? '#0f172a' : '#94a3b8' +} + +function strokeWidthFor(d: SimNode): number { + return d.statut === 'edite' ? 2.5 : 1 +} + +function labelWeightFor(d: SimNode): string { + return d.statut === 'edite' ? 'bold' : 'normal' +} + +function labelColorFor(d: SimNode): string { + return d.statut === 'edite' ? '#0f172a' : '#6b7280' } function truncate(str: string, max: number): string { @@ -136,7 +165,6 @@ function render() { const simNodes = buildSimNodes() const simLinks = buildSimLinks(simNodes) - const r = nodeRadius.value const fontSize = isMobile.value ? 9 : 11 // Liens @@ -155,49 +183,55 @@ function render() { .join('g') .attr('class', 'node') .style('cursor', 'pointer') - .on('click', (_event, d) => emit('node-click', d)) - .on('mouseover', function () { + .on('click', (_event, d) => emit('node-click', { node: d as CarteNode, x: (d as SimNode).x || 0, y: (d as SimNode).y || 0 })) + .on('mouseover', function (_event, d) { d3.select(this).select('circle') .transition().duration(120) - .attr('stroke-width', 2.5) + .attr('stroke-width', strokeWidthFor(d) + 1.5) }) - .on('mouseout', function () { + .on('mouseout', function (_event, d) { d3.select(this).select('circle') .transition().duration(120) - .attr('stroke-width', 1.5) + .attr('stroke-width', strokeWidthFor(d)) }) - // Cercle (couleur famille) + // Cercle nodeGroups.append('circle') - .attr('r', r) - .attr('fill', d => colorFor(d.family)) - .attr('stroke', '#ffffff') - .attr('stroke-width', 1.5) + .attr('r', d => getRadius(d)) + .attr('fill', d => colorFor(d)) + .attr('stroke', d => strokeFor(d)) + .attr('stroke-width', d => strokeWidthFor(d)) // Label nodeGroups.append('text') .attr('text-anchor', 'start') .attr('dominant-baseline', 'central') - .attr('dx', r + 4) + .attr('dx', d => getRadius(d) + 4) .attr('font-size', fontSize) .attr('font-family', 'system-ui, sans-serif') - .attr('fill', '#1f2937') + .attr('font-weight', d => labelWeightFor(d)) + .attr('fill', d => labelColorFor(d)) .attr('pointer-events', 'none') .text(d => truncate(d.label, isMobile.value ? 18 : 30)) // Tooltip nodeGroups.append('title') - .text(d => `${d.label}\n[${d.family}]\n${truncate(d.intention || '', 200)}`) + .text(d => `${d.label}\n[${d.family}]\n${truncate(d.resume || d.intention || '', 200)}`) - // Simulation force + // Simulation force avec charges differenciees par niveau simulation = d3.forceSimulation<SimNode, SimLink>(simNodes) .force('link', d3.forceLink<SimNode, SimLink>(simLinks) .id(d => d.id) .distance(80) .strength(0.35)) - .force('charge', d3.forceManyBody<SimNode>().strength(-220)) + .force('charge', d3.forceManyBody<SimNode>().strength(d => { + if (d.niveau === 0) return -800 + if (d.niveau === 1) return -400 + if (d.niveau === 2) return -150 + return -220 + })) .force('center', d3.forceCenter(width.value / 2, height.value / 2)) - .force('collide', d3.forceCollide<SimNode>().radius(r + 6)) + .force('collide', d3.forceCollide<SimNode>().radius(d => getRadius(d) + 6)) .alphaDecay(0.03) .on('tick', tick) diff --git a/src/components/vue/CarteOContextMenu.vue b/src/components/vue/CarteOContextMenu.vue new file mode 100644 index 0000000..458ce02 --- /dev/null +++ b/src/components/vue/CarteOContextMenu.vue @@ -0,0 +1,119 @@ +<script setup lang="ts"> +import { computed, onMounted, onUnmounted } from 'vue' + +interface CarteNode { + id: string + label: string + family?: string + nature?: 'essai' | 'projet' + statut?: 'gestation' | 'edite' + resume?: string | null + intention?: string +} + +const props = defineProps<{ + node: CarteNode | null + x: number + y: number +}>() + +const emit = defineEmits<{ close: [] }>() + +function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape' && props.node) emit('close') +} +function onClickOutside(e: MouseEvent) { + const el = document.getElementById('carte-o-context-menu') + if (el && !el.contains(e.target as Node)) emit('close') +} + +onMounted(() => { + window.addEventListener('keydown', onKeydown) + setTimeout(() => window.addEventListener('click', onClickOutside), 50) +}) +onUnmounted(() => { + window.removeEventListener('keydown', onKeydown) + window.removeEventListener('click', onClickOutside) +}) + +const naturLabel = computed(() => props.node?.nature === 'projet' ? 'Projet archi' : 'Essai politique') +const naturColor = computed(() => props.node?.nature === 'projet' ? '#b45309' : '#1d4ed8') +const texte = computed(() => props.node?.resume || props.node?.intention || 'En cours d\'ecriture.') +</script> + +<template> + <div + v-if="node" + id="carte-o-context-menu" + class="context-menu" + :style="{ left: x + 'px', top: y + 'px' }" + role="dialog" + :aria-label="node.label" + > + <button class="close-btn" type="button" @click="emit('close')" aria-label="Fermer">x</button> + <span class="nature-badge" :style="{ backgroundColor: naturColor }">{{ naturLabel }}</span> + <h3 class="title" :style="{ fontWeight: node.statut === 'edite' ? 'bold' : 'normal' }"> + {{ node.label }} + </h3> + <p class="resume">{{ texte }}</p> + <p v-if="node.statut === 'edite'" class="edite-badge">publie</p> + </div> +</template> + +<style scoped> +.context-menu { + position: absolute; + z-index: 1000; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px 14px; + width: 220px; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + font-size: 13px; +} +.close-btn { + position: absolute; + top: 6px; + right: 8px; + background: none; + border: none; + cursor: pointer; + color: #9ca3af; + font-size: 14px; + line-height: 1; + padding: 2px 4px; +} +.close-btn:hover { color: #374151; } +.nature-badge { + display: inline-block; + padding: 2px 7px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: #fff; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-bottom: 6px; + font-family: 'Courier New', Courier, monospace; +} +.title { + font-size: 14px; + color: #1f2937; + margin: 4px 0 6px 0; + line-height: 1.3; +} +.resume { + font-size: 12px; + color: #4b5563; + line-height: 1.5; + margin: 0; +} +.edite-badge { + margin-top: 8px; + font-size: 10px; + color: #059669; + font-family: 'Courier New', Courier, monospace; + font-weight: 600; +} +</style> diff --git a/src/components/vue/CarteOModal.vue b/src/components/vue/CarteOModal.vue deleted file mode 100644 index 9ad7410..0000000 --- a/src/components/vue/CarteOModal.vue +++ /dev/null @@ -1,215 +0,0 @@ -<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> diff --git a/src/components/vue/CarteOWrapper.vue b/src/components/vue/CarteOWrapper.vue index 2cfd08c..feecad9 100644 --- a/src/components/vue/CarteOWrapper.vue +++ b/src/components/vue/CarteOWrapper.vue @@ -1,14 +1,16 @@ <script setup lang="ts"> -// Wrapper Carte O : fetch /data/carte-o.json + state modal. -// Vue island Astro hydratée client:visible. import { ref, onMounted, computed } from 'vue' import CarteO from './CarteO.vue' -import CarteOModal from './CarteOModal.vue' +import CarteOContextMenu from './CarteOContextMenu.vue' interface CarteNode { id: string label: string family: string + niveau?: number + nature?: 'essai' | 'projet' + statut?: 'gestation' | 'edite' + resume?: string | null intention?: string slug?: string theme?: string @@ -39,6 +41,8 @@ const props = withDefaults(defineProps<{ const data = ref<CarteData | null>(null) const error = ref<string | null>(null) const selectedNode = ref<CarteNode | null>(null) +const contextX = ref(0) +const contextY = ref(0) const isMobileScreen = ref(false) const familyColors = computed(() => @@ -51,6 +55,12 @@ const familyColors = computed(() => } ) +function onNodeClick(payload: { node: CarteNode; x: number; y: number }) { + selectedNode.value = payload.node + contextX.value = payload.x + contextY.value = payload.y +} + onMounted(async () => { isMobileScreen.value = window.innerWidth < 768 try { @@ -62,7 +72,6 @@ onMounted(async () => { error.value = e?.message || 'Erreur de chargement' } - // Update mobile flag on resize. window.addEventListener('resize', () => { isMobileScreen.value = window.innerWidth < 768 }) @@ -87,13 +96,13 @@ onMounted(async () => { </svg> </div> <p class="msg"> - Carte O optimisée desktop. Retournez sur grand écran pour explorer la mindmap interactive. + Carte O optimisee desktop. Retournez sur grand ecran pour explorer la mindmap interactive. </p> </div> <!-- Loading state --> <div v-else-if="!data && !error" class="state"> - <span>Chargement de la Carte O…</span> + <span>Chargement de la Carte O...</span> </div> <!-- Error state --> @@ -107,11 +116,12 @@ onMounted(async () => { :nodes="data.nodes" :edges="data.edges" :family-colors="familyColors" - @node-click="selectedNode = $event" + @node-click="onNodeClick" /> - <CarteOModal + <CarteOContextMenu :node="selectedNode" - :family-colors="familyColors" + :x="contextX" + :y="contextY" @close="selectedNode = null" /> </template>