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:
@@ -1,9 +1,341 @@
|
||||
<script setup lang="ts">
|
||||
// Placeholder Carte O — PC3 implémente avec D3 + GraphView porté de nav-carte
|
||||
// Carte O - mindmap force-directed D3 (V1 zoom + pan + drag + click).
|
||||
// Pattern adapté de nav-carte/components/codev/CodevGraph.vue (sans coupling Nuxt).
|
||||
// Drill-down V1 = zoom + pan seul. V2 récursif backlog (cf MP-PAGE-CERVEAU.md).
|
||||
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface CarteNode {
|
||||
id: string
|
||||
label: string
|
||||
family: string
|
||||
intention?: string
|
||||
slug?: string
|
||||
theme?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface CarteEdge {
|
||||
source: string | CarteNode
|
||||
target: string | CarteNode
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
nodes: CarteNode[]
|
||||
edges: CarteEdge[]
|
||||
familyColors?: Record<string, string>
|
||||
}>(), {
|
||||
familyColors: () => ({
|
||||
penseur: '#3b82f6',
|
||||
concept: '#10b981',
|
||||
methode: '#f59e0b',
|
||||
collectif: '#ef4444',
|
||||
ressource: '#8b5cf6',
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'node-click': [node: CarteNode]
|
||||
}>()
|
||||
|
||||
// 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 & CarteNode
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode>
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
||||
let gZoom: d3.Selection<SVGGElement, 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
|
||||
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | 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 truncate(str: string, max: number): string {
|
||||
if (!str) return ''
|
||||
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
||||
}
|
||||
|
||||
function buildSimNodes(): SimNode[] {
|
||||
return props.nodes.map(n => ({ ...n }))
|
||||
}
|
||||
|
||||
function buildSimLinks(simNodes: SimNode[]): SimLink[] {
|
||||
const byId = new Map(simNodes.map(n => [n.id, n]))
|
||||
return props.edges
|
||||
.map(e => {
|
||||
const s = typeof e.source === 'string' ? byId.get(e.source) : byId.get(e.source.id)
|
||||
const t = typeof e.target === 'string' ? byId.get(e.target) : byId.get(e.target.id)
|
||||
if (!s || !t) return null
|
||||
return { source: s, target: t } as SimLink
|
||||
})
|
||||
.filter((x): x is SimLink => x !== null)
|
||||
}
|
||||
|
||||
function makeDrag(sim: d3.Simulation<SimNode, SimLink>) {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
function initSvg() {
|
||||
if (!svgEl.value) return
|
||||
|
||||
svgRoot = d3.select(svgEl.value)
|
||||
.attr('width', width.value)
|
||||
.attr('height', height.value)
|
||||
.attr('role', 'img')
|
||||
.attr('aria-label', 'Carte O — mindmap de la pensée AEP')
|
||||
|
||||
svgRoot.selectAll('*').remove()
|
||||
|
||||
// Wrapper group for zoom/pan transform.
|
||||
gZoom = svgRoot.append('g').attr('class', 'zoom-layer')
|
||||
gLinks = gZoom.append('g').attr('class', 'links')
|
||||
gNodes = gZoom.append('g').attr('class', 'nodes')
|
||||
|
||||
// Zoom + pan behavior.
|
||||
zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.3, 4])
|
||||
.on('zoom', (event) => {
|
||||
gZoom!.attr('transform', event.transform.toString())
|
||||
})
|
||||
|
||||
svgRoot.call(zoomBehavior as any)
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!svgEl.value || props.nodes.length === 0) return
|
||||
|
||||
initSvg()
|
||||
|
||||
const simNodes = buildSimNodes()
|
||||
const simLinks = buildSimLinks(simNodes)
|
||||
|
||||
const r = nodeRadius.value
|
||||
const fontSize = isMobile.value ? 9 : 11
|
||||
|
||||
// Liens
|
||||
gLinks!
|
||||
.selectAll<SVGLineElement, SimLink>('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-opacity', 0.45)
|
||||
.attr('stroke-width', 1.2)
|
||||
|
||||
// Noeuds = groupe <g> par node
|
||||
const nodeGroups = gNodes!
|
||||
.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.data(simNodes, d => d.id)
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (_event, d) => emit('node-click', d))
|
||||
.on('mouseover', function () {
|
||||
d3.select(this).select('circle')
|
||||
.transition().duration(120)
|
||||
.attr('stroke-width', 2.5)
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
d3.select(this).select('circle')
|
||||
.transition().duration(120)
|
||||
.attr('stroke-width', 1.5)
|
||||
})
|
||||
|
||||
// Cercle (couleur famille)
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', r)
|
||||
.attr('fill', d => colorFor(d.family))
|
||||
.attr('stroke', '#ffffff')
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
// Label
|
||||
nodeGroups.append('text')
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('dx', r + 4)
|
||||
.attr('font-size', fontSize)
|
||||
.attr('font-family', 'system-ui, sans-serif')
|
||||
.attr('fill', '#1f2937')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => truncate(d.label, isMobile.value ? 18 : 30))
|
||||
|
||||
// Tooltip <title>
|
||||
nodeGroups.append('title')
|
||||
.text(d => `${d.label}\n[${d.family}]\n${truncate(d.intention || '', 200)}`)
|
||||
|
||||
// Simulation force
|
||||
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('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 6))
|
||||
.alphaDecay(0.03)
|
||||
.on('tick', tick)
|
||||
|
||||
// Bind drag once simulation exists.
|
||||
nodeGroups.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})`)
|
||||
}
|
||||
|
||||
// Public methods exposed (zoom controls).
|
||||
function zoomIn() {
|
||||
if (!svgRoot || !zoomBehavior) return
|
||||
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 1.3)
|
||||
}
|
||||
function zoomOut() {
|
||||
if (!svgRoot || !zoomBehavior) return
|
||||
svgRoot.transition().duration(250).call(zoomBehavior.scaleBy as any, 0.77)
|
||||
}
|
||||
function zoomReset() {
|
||||
if (!svgRoot || !zoomBehavior) return
|
||||
svgRoot.transition().duration(350).call(zoomBehavior.transform as any, d3.zoomIdentity)
|
||||
}
|
||||
defineExpose({ zoomIn, zoomOut, zoomReset })
|
||||
|
||||
// Re-render on data changes.
|
||||
watch(() => [props.nodes, props.edges], () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex items-center justify-center text-sm text-neutral-400">
|
||||
Carte O placeholder (PC3)
|
||||
<div ref="container" class="carte-o-wrap">
|
||||
<svg ref="svgEl" class="carte-o-svg" />
|
||||
|
||||
<!-- Zoom controls overlay -->
|
||||
<div class="zoom-controls">
|
||||
<button type="button" @click="zoomIn" aria-label="Zoom avant">+</button>
|
||||
<button type="button" @click="zoomReset" aria-label="Réinitialiser le zoom">⟲</button>
|
||||
<button type="button" @click="zoomOut" aria-label="Zoom arrière">−</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carte-o-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carte-o-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.carte-o-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user