feat(v11-c): carte-o rendering refonte niveau/nature/statut + contextmenu positionne

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules Neny
2026-05-11 15:13:46 +02:00
parent 1e1c56db2f
commit d8d3af28a0
4 changed files with 192 additions and 244 deletions

View File

@@ -10,6 +10,11 @@ interface CarteNode {
id: string id: string
label: string label: string
family: string family: string
niveau?: number
nature?: 'essai' | 'projet'
statut?: 'gestation' | 'edite'
resume?: string | null
radius?: number
intention?: string intention?: string
slug?: string slug?: string
theme?: string theme?: string
@@ -36,7 +41,7 @@ const props = withDefaults(defineProps<{
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'node-click': [node: CarteNode] 'node-click': [payload: { node: CarteNode; x: number; y: number }]
}>() }>()
// Refs // Refs
@@ -59,8 +64,32 @@ let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null
const isMobile = computed(() => width.value < 600) const isMobile = computed(() => width.value < 600)
const nodeRadius = computed(() => isMobile.value ? 10 : 14) const nodeRadius = computed(() => isMobile.value ? 10 : 14)
function colorFor(family: string): string { function colorFor(d: SimNode): string {
return props.familyColors[family] || '#9ca3af' 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 { function truncate(str: string, max: number): string {
@@ -136,7 +165,6 @@ function render() {
const simNodes = buildSimNodes() const simNodes = buildSimNodes()
const simLinks = buildSimLinks(simNodes) const simLinks = buildSimLinks(simNodes)
const r = nodeRadius.value
const fontSize = isMobile.value ? 9 : 11 const fontSize = isMobile.value ? 9 : 11
// Liens // Liens
@@ -155,49 +183,55 @@ function render() {
.join('g') .join('g')
.attr('class', 'node') .attr('class', 'node')
.style('cursor', 'pointer') .style('cursor', 'pointer')
.on('click', (_event, d) => emit('node-click', d)) .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 () { .on('mouseover', function (_event, d) {
d3.select(this).select('circle') d3.select(this).select('circle')
.transition().duration(120) .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') d3.select(this).select('circle')
.transition().duration(120) .transition().duration(120)
.attr('stroke-width', 1.5) .attr('stroke-width', strokeWidthFor(d))
}) })
// Cercle (couleur famille) // Cercle
nodeGroups.append('circle') nodeGroups.append('circle')
.attr('r', r) .attr('r', d => getRadius(d))
.attr('fill', d => colorFor(d.family)) .attr('fill', d => colorFor(d))
.attr('stroke', '#ffffff') .attr('stroke', d => strokeFor(d))
.attr('stroke-width', 1.5) .attr('stroke-width', d => strokeWidthFor(d))
// Label // Label
nodeGroups.append('text') nodeGroups.append('text')
.attr('text-anchor', 'start') .attr('text-anchor', 'start')
.attr('dominant-baseline', 'central') .attr('dominant-baseline', 'central')
.attr('dx', r + 4) .attr('dx', d => getRadius(d) + 4)
.attr('font-size', fontSize) .attr('font-size', fontSize)
.attr('font-family', 'system-ui, sans-serif') .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') .attr('pointer-events', 'none')
.text(d => truncate(d.label, isMobile.value ? 18 : 30)) .text(d => truncate(d.label, isMobile.value ? 18 : 30))
// Tooltip <title> // Tooltip <title>
nodeGroups.append('title') 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) simulation = d3.forceSimulation<SimNode, SimLink>(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks) .force('link', d3.forceLink<SimNode, SimLink>(simLinks)
.id(d => d.id) .id(d => d.id)
.distance(80) .distance(80)
.strength(0.35)) .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('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) .alphaDecay(0.03)
.on('tick', tick) .on('tick', tick)

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <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 { ref, onMounted, computed } from 'vue'
import CarteO from './CarteO.vue' import CarteO from './CarteO.vue'
import CarteOModal from './CarteOModal.vue' import CarteOContextMenu from './CarteOContextMenu.vue'
interface CarteNode { interface CarteNode {
id: string id: string
label: string label: string
family: string family: string
niveau?: number
nature?: 'essai' | 'projet'
statut?: 'gestation' | 'edite'
resume?: string | null
intention?: string intention?: string
slug?: string slug?: string
theme?: string theme?: string
@@ -39,6 +41,8 @@ const props = withDefaults(defineProps<{
const data = ref<CarteData | null>(null) const data = ref<CarteData | null>(null)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const selectedNode = ref<CarteNode | null>(null) const selectedNode = ref<CarteNode | null>(null)
const contextX = ref(0)
const contextY = ref(0)
const isMobileScreen = ref(false) const isMobileScreen = ref(false)
const familyColors = computed(() => 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 () => { onMounted(async () => {
isMobileScreen.value = window.innerWidth < 768 isMobileScreen.value = window.innerWidth < 768
try { try {
@@ -62,7 +72,6 @@ onMounted(async () => {
error.value = e?.message || 'Erreur de chargement' error.value = e?.message || 'Erreur de chargement'
} }
// Update mobile flag on resize.
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
isMobileScreen.value = window.innerWidth < 768 isMobileScreen.value = window.innerWidth < 768
}) })
@@ -87,13 +96,13 @@ onMounted(async () => {
</svg> </svg>
</div> </div>
<p class="msg"> <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> </p>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-else-if="!data && !error" class="state"> <div v-else-if="!data && !error" class="state">
<span>Chargement de la Carte O</span> <span>Chargement de la Carte O...</span>
</div> </div>
<!-- Error state --> <!-- Error state -->
@@ -107,11 +116,12 @@ onMounted(async () => {
:nodes="data.nodes" :nodes="data.nodes"
:edges="data.edges" :edges="data.edges"
:family-colors="familyColors" :family-colors="familyColors"
@node-click="selectedNode = $event" @node-click="onNodeClick"
/> />
<CarteOModal <CarteOContextMenu
:node="selectedNode" :node="selectedNode"
:family-colors="familyColors" :x="contextX"
:y="contextY"
@close="selectedNode = null" @close="selectedNode = null"
/> />
</template> </template>