"use client"; // Carte interactive des concepts : graphe SVG maison (aucune dépendance). // Colonnes par semaine, nœuds colorés par maîtrise, liens typés en courbes de Bézier, // pan (glisser) + zoom (molette / boutons), panneau latéral au clic, ?focus=slug. import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { FileText, Layers, ListChecks, Maximize2, MessageSquareText, Minus, Plus, X, } from "lucide-react"; import { Badge, Button, Card, EmptyState, Skeleton, cn } from "@/components/ui"; import { ErrorBanner, LEVELS, fetchJson, levelInfo, type MasteryLevel } from "./shared"; type Node = { id: number; slug: string; name: string; description: string; week: number | null; importance: number; axis: string; mastery: number; level: MasteryLevel; observations: number; cards: number; questions: number; }; type GraphLink = { from_id: number; to_id: number; type: string }; const COL_W = 200; const ROW_H = 96; const TOP = 76; const LINK_STYLES: Record = { prerequis: { stroke: "#64748b", arrow: true, label: "Préalable" }, relation: { stroke: "#94a3b8", dash: "5 5", label: "Notion liée" }, approfondissement: { stroke: "#c6a300", label: "Approfondissement" }, application: { stroke: "#4585c9", dash: "2 4", arrow: true, label: "Application" }, }; function linkStyle(type: string) { return LINK_STYLES[type] ?? LINK_STYLES.relation; } export function ConceptMap({ course }: { course: string }) { const searchParams = useSearchParams(); const focusSlug = searchParams.get("focus"); const [nodes, setNodes] = useState(null); const [links, setLinks] = useState([]); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); // ----- Chargement ----- useEffect(() => { fetchJson<{ nodes: Node[]; links: GraphLink[] }>(`/api/learning/${course}/concepts`) .then((d) => { setNodes(d.nodes); setLinks(d.links); }) .catch((e) => setError(e instanceof Error ? e.message : "Erreur de chargement.")); }, [course]); // ----- Disposition : colonnes par semaine ----- const layout = useMemo(() => { if (!nodes) return null; const weeks = [...new Set(nodes.map((n) => n.week ?? 0))].sort((a, b) => a - b); const colOf = new Map(weeks.map((w, i) => [w, i])); const rowCount = new Map(); const pos = new Map(); for (const n of nodes) { const col = colOf.get(n.week ?? 0) ?? 0; const row = rowCount.get(col) ?? 0; rowCount.set(col, row + 1); pos.set(n.id, { x: 60 + col * COL_W + COL_W / 2, y: TOP + row * ROW_H + 30 }); } const maxRows = Math.max(1, ...rowCount.values()); const width = Math.max(480, 120 + weeks.length * COL_W); const height = TOP + maxRows * ROW_H + 60; return { weeks, colOf, pos, width, height }; }, [nodes]); // ----- Pan / zoom (viewBox) ----- const svgRef = useRef(null); const [view, setView] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean } | null>(null); useEffect(() => { if (layout && !view) setView({ x: 0, y: 0, w: layout.width, h: layout.height }); }, [layout, view]); const centerOn = useCallback((id: number) => { if (!layout) return; const p = layout.pos.get(id); if (!p) return; setView((v) => { const w = v ? Math.min(v.w, layout.width * 0.7) : layout.width * 0.7; const h = v ? (v.h / v.w) * w : (layout.height / layout.width) * w; return { x: p.x - w / 2, y: p.y - h / 2, w, h }; }); }, [layout]); // ----- ?focus=slug ----- const focusedRef = useRef(false); useEffect(() => { if (!nodes || !layout || focusedRef.current || !focusSlug) return; const n = nodes.find((x) => x.slug === focusSlug); if (n) { setSelectedId(n.id); centerOn(n.id); } focusedRef.current = true; }, [nodes, layout, focusSlug, centerOn]); function zoom(factor: number, cx?: number, cy?: number) { if (!layout) return; setView((v) => { if (!v) return v; const w = Math.min(layout.width * 1.6, Math.max(layout.width / 5, v.w * factor)); const h = (v.h / v.w) * w; const fx = cx ?? v.x + v.w / 2; const fy = cy ?? v.y + v.h / 2; const kx = (fx - v.x) / v.w; const ky = (fy - v.y) / v.h; return { x: fx - kx * w, y: fy - ky * h, w, h }; }); } function svgPoint(e: { clientX: number; clientY: number }): { x: number; y: number } { const svg = svgRef.current; if (!svg || !view) return { x: 0, y: 0 }; const r = svg.getBoundingClientRect(); return { x: view.x + ((e.clientX - r.left) / r.width) * view.w, y: view.y + ((e.clientY - r.top) / r.height) * view.h, }; } function onWheel(e: React.WheelEvent) { const p = svgPoint(e); zoom(e.deltaY > 0 ? 1.12 : 0.89, p.x, p.y); } function onPointerDown(e: React.PointerEvent) { if (!view) return; dragRef.current = { px: e.clientX, py: e.clientY, vx: view.x, vy: view.y, moved: false }; (e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId); } function onPointerMove(e: React.PointerEvent) { const d = dragRef.current; const svg = svgRef.current; if (!d || !svg || !view) return; const r = svg.getBoundingClientRect(); const dx = ((e.clientX - d.px) / r.width) * view.w; const dy = ((e.clientY - d.py) / r.height) * view.h; if (Math.abs(e.clientX - d.px) + Math.abs(e.clientY - d.py) > 4) d.moved = true; if (d.moved) setView((v) => (v ? { ...v, x: d.vx - dx, y: d.vy - dy } : v)); } function onPointerUp() { const d = dragRef.current; dragRef.current = null; if (d && !d.moved) setSelectedId(null); // clic sur le fond → désélection } useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSelectedId(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const selected = nodes?.find((n) => n.id === selectedId) ?? null; const upper = course.toUpperCase(); if (error) { return
; } if (!nodes || !layout || !view) { return (
); } if (nodes.length === 0) { return (
} title="Aucun concept" description="La carte des concepts de ce cours n'est pas encore disponible." />
); } return (

Carte des concepts

Glissez pour déplacer, molette pour zoomer, cliquez un nœud pour les détails.

{/* Colonnes de semaines */} {layout.weeks.map((w, i) => ( {i > 0 && ( )} {w === 0 ? "Transversal" : `Semaine ${w}`} ))} {/* Liens */} {links.map((l, i) => { const a = layout.pos.get(l.from_id); const b = layout.pos.get(l.to_id); if (!a || !b) return null; const st = linkStyle(l.type); const dx = Math.max(40, Math.abs(b.x - a.x) / 2); const d = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; const active = selectedId != null && (l.from_id === selectedId || l.to_id === selectedId); return ( ); })} {/* Nœuds */} {nodes.map((n) => { const p = layout.pos.get(n.id)!; const r = 9 + Math.min(4, Math.max(1, n.importance)) * 3.5; const info = LEVELS[n.level] ?? LEVELS["a-decouvrir"]; const isSel = selectedId === n.id; const dim = selectedId != null && !isSel && !links.some((l) => (l.from_id === selectedId && l.to_id === n.id) || (l.to_id === selectedId && l.from_id === n.id)); const label = n.name.length > 24 ? n.name.slice(0, 23) + "…" : n.name; return ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setSelectedId(n.id); centerOn(n.id); }} tabIndex={0} role="button" aria-label={`${n.name} — ${info.label}, maîtrise ${Math.round(n.mastery * 100)} %`} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelectedId(n.id); centerOn(n.id); } }} > {`${n.name} — ${info.label} (${Math.round(n.mastery * 100)} %)`} {isSel && } {label} ); })} {/* Panneau latéral */} {selected && ( )} {/* Légende */}
{(Object.keys(LEVELS) as MasteryLevel[]).map((k) => ( {LEVELS[k].label} ))} {Object.entries(LINK_STYLES).map(([k, s]) => ( {s.label} ))} Taille = importance
); }