TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Carte interactive des concepts : graphe SVG maison (aucune dépendance).3// Colonnes par semaine, nœuds colorés par maîtrise, liens typés en courbes de Bézier,4// pan (glisser) + zoom (molette / boutons), panneau latéral au clic, ?focus=slug.5import Link from "next/link";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useSearchParams } from "next/navigation";8import {9 FileText, Layers, ListChecks, Maximize2, MessageSquareText, Minus, Plus, X,10} from "lucide-react";11import { Badge, Button, Card, EmptyState, Skeleton, cn } from "@/components/ui";12import { ErrorBanner, LEVELS, fetchJson, levelInfo, type MasteryLevel } from "./shared";1314type Node = {15 id: number; slug: string; name: string; description: string; week: number | null;16 importance: number; axis: string; mastery: number; level: MasteryLevel;17 observations: number; cards: number; questions: number;18};19type GraphLink = { from_id: number; to_id: number; type: string };2021const COL_W = 200;22const ROW_H = 96;23const TOP = 76;2425const LINK_STYLES: Record<string, { stroke: string; dash?: string; arrow?: boolean; label: string }> = {26 prerequis: { stroke: "#64748b", arrow: true, label: "Préalable" },27 relation: { stroke: "#94a3b8", dash: "5 5", label: "Notion liée" },28 approfondissement: { stroke: "#c6a300", label: "Approfondissement" },29 application: { stroke: "#4585c9", dash: "2 4", arrow: true, label: "Application" },30};31function linkStyle(type: string) {32 return LINK_STYLES[type] ?? LINK_STYLES.relation;33}3435export function ConceptMap({ course }: { course: string }) {36 const searchParams = useSearchParams();37 const focusSlug = searchParams.get("focus");3839 const [nodes, setNodes] = useState<Node[] | null>(null);40 const [links, setLinks] = useState<GraphLink[]>([]);41 const [error, setError] = useState<string | null>(null);42 const [selectedId, setSelectedId] = useState<number | null>(null);4344 // ----- Chargement -----45 useEffect(() => {46 fetchJson<{ nodes: Node[]; links: GraphLink[] }>(`/api/learning/${course}/concepts`)47 .then((d) => { setNodes(d.nodes); setLinks(d.links); })48 .catch((e) => setError(e instanceof Error ? e.message : "Erreur de chargement."));49 }, [course]);5051 // ----- Disposition : colonnes par semaine -----52 const layout = useMemo(() => {53 if (!nodes) return null;54 const weeks = [...new Set(nodes.map((n) => n.week ?? 0))].sort((a, b) => a - b);55 const colOf = new Map(weeks.map((w, i) => [w, i]));56 const rowCount = new Map<number, number>();57 const pos = new Map<number, { x: number; y: number }>();58 for (const n of nodes) {59 const col = colOf.get(n.week ?? 0) ?? 0;60 const row = rowCount.get(col) ?? 0;61 rowCount.set(col, row + 1);62 pos.set(n.id, { x: 60 + col * COL_W + COL_W / 2, y: TOP + row * ROW_H + 30 });63 }64 const maxRows = Math.max(1, ...rowCount.values());65 const width = Math.max(480, 120 + weeks.length * COL_W);66 const height = TOP + maxRows * ROW_H + 60;67 return { weeks, colOf, pos, width, height };68 }, [nodes]);6970 // ----- Pan / zoom (viewBox) -----71 const svgRef = useRef<SVGSVGElement>(null);72 const [view, setView] = useState<{ x: number; y: number; w: number; h: number } | null>(null);73 const dragRef = useRef<{ px: number; py: number; vx: number; vy: number; moved: boolean } | null>(null);7475 useEffect(() => {76 if (layout && !view) setView({ x: 0, y: 0, w: layout.width, h: layout.height });77 }, [layout, view]);7879 const centerOn = useCallback((id: number) => {80 if (!layout) return;81 const p = layout.pos.get(id);82 if (!p) return;83 setView((v) => {84 const w = v ? Math.min(v.w, layout.width * 0.7) : layout.width * 0.7;85 const h = v ? (v.h / v.w) * w : (layout.height / layout.width) * w;86 return { x: p.x - w / 2, y: p.y - h / 2, w, h };87 });88 }, [layout]);8990 // ----- ?focus=slug -----91 const focusedRef = useRef(false);92 useEffect(() => {93 if (!nodes || !layout || focusedRef.current || !focusSlug) return;94 const n = nodes.find((x) => x.slug === focusSlug);95 if (n) {96 setSelectedId(n.id);97 centerOn(n.id);98 }99 focusedRef.current = true;100 }, [nodes, layout, focusSlug, centerOn]);101102 function zoom(factor: number, cx?: number, cy?: number) {103 if (!layout) return;104 setView((v) => {105 if (!v) return v;106 const w = Math.min(layout.width * 1.6, Math.max(layout.width / 5, v.w * factor));107 const h = (v.h / v.w) * w;108 const fx = cx ?? v.x + v.w / 2;109 const fy = cy ?? v.y + v.h / 2;110 const kx = (fx - v.x) / v.w;111 const ky = (fy - v.y) / v.h;112 return { x: fx - kx * w, y: fy - ky * h, w, h };113 });114 }115116 function svgPoint(e: { clientX: number; clientY: number }): { x: number; y: number } {117 const svg = svgRef.current;118 if (!svg || !view) return { x: 0, y: 0 };119 const r = svg.getBoundingClientRect();120 return {121 x: view.x + ((e.clientX - r.left) / r.width) * view.w,122 y: view.y + ((e.clientY - r.top) / r.height) * view.h,123 };124 }125126 function onWheel(e: React.WheelEvent<SVGSVGElement>) {127 const p = svgPoint(e);128 zoom(e.deltaY > 0 ? 1.12 : 0.89, p.x, p.y);129 }130 function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {131 if (!view) return;132 dragRef.current = { px: e.clientX, py: e.clientY, vx: view.x, vy: view.y, moved: false };133 (e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);134 }135 function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {136 const d = dragRef.current;137 const svg = svgRef.current;138 if (!d || !svg || !view) return;139 const r = svg.getBoundingClientRect();140 const dx = ((e.clientX - d.px) / r.width) * view.w;141 const dy = ((e.clientY - d.py) / r.height) * view.h;142 if (Math.abs(e.clientX - d.px) + Math.abs(e.clientY - d.py) > 4) d.moved = true;143 if (d.moved) setView((v) => (v ? { ...v, x: d.vx - dx, y: d.vy - dy } : v));144 }145 function onPointerUp() {146 const d = dragRef.current;147 dragRef.current = null;148 if (d && !d.moved) setSelectedId(null); // clic sur le fond → désélection149 }150151 useEffect(() => {152 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSelectedId(null); };153 window.addEventListener("keydown", onKey);154 return () => window.removeEventListener("keydown", onKey);155 }, []);156157 const selected = nodes?.find((n) => n.id === selectedId) ?? null;158 const upper = course.toUpperCase();159160 if (error) {161 return <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full"><ErrorBanner message={error} /></main>;162 }163 if (!nodes || !layout || !view) {164 return (165 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full space-y-4">166 <Skeleton className="h-8 w-64" />167 <Skeleton className="h-[60vh] w-full" />168 </main>169 );170 }171 if (nodes.length === 0) {172 return (173 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full">174 <EmptyState icon={<Layers />} title="Aucun concept" description="La carte des concepts de ce cours n'est pas encore disponible." />175 </main>176 );177 }178179 return (180 <main className="px-4 sm:px-6 py-6 max-w-6xl mx-auto w-full">181 <div className="flex flex-wrap items-center justify-between gap-3 mb-4">182 <div>183 <h2 className="text-lg font-bold text-fg">Carte des concepts</h2>184 <p className="text-[13px] text-muted">Glissez pour déplacer, molette pour zoomer, cliquez un nœud pour les détails.</p>185 </div>186 <div className="flex items-center gap-1.5" role="group" aria-label="Zoom">187 <Button variant="secondary" size="icon" onClick={() => zoom(0.8)} title="Zoomer" aria-label="Zoomer"><Plus size={15} /></Button>188 <Button variant="secondary" size="icon" onClick={() => zoom(1.25)} title="Dézoomer" aria-label="Dézoomer"><Minus size={15} /></Button>189 <Button190 variant="secondary" size="icon"191 onClick={() => setView({ x: 0, y: 0, w: layout.width, h: layout.height })}192 title="Vue d'ensemble" aria-label="Vue d'ensemble"193 >194 <Maximize2 size={14} />195 </Button>196 </div>197 </div>198199 <Card className="relative overflow-hidden">200 <svg201 ref={svgRef}202 viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}203 className="w-full h-[58vh] sm:h-[62vh] touch-none cursor-grab active:cursor-grabbing select-none"204 onWheel={onWheel}205 onPointerDown={onPointerDown}206 onPointerMove={onPointerMove}207 onPointerUp={onPointerUp}208 role="img"209 aria-label="Graphe des concepts du cours par semaine"210 >211 <defs>212 <marker id="cm-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">213 <path d="M 0 1 L 9 5 L 0 9 z" fill="#64748b" />214 </marker>215 </defs>216217 {/* Colonnes de semaines */}218 {layout.weeks.map((w, i) => (219 <g key={w}>220 {i > 0 && (221 <line222 x1={60 + i * COL_W} y1={20} x2={60 + i * COL_W} y2={layout.height - 10}223 stroke="var(--border)" strokeWidth={1}224 />225 )}226 <text x={60 + i * COL_W + COL_W / 2} y={40} textAnchor="middle" fontSize={13} fontWeight={650} style={{ fill: "var(--muted)" }}>227 {w === 0 ? "Transversal" : `Semaine ${w}`}228 </text>229 </g>230 ))}231232 {/* Liens */}233 {links.map((l, i) => {234 const a = layout.pos.get(l.from_id);235 const b = layout.pos.get(l.to_id);236 if (!a || !b) return null;237 const st = linkStyle(l.type);238 const dx = Math.max(40, Math.abs(b.x - a.x) / 2);239 const d = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;240 const active = selectedId != null && (l.from_id === selectedId || l.to_id === selectedId);241 return (242 <path243 key={i}244 d={d}245 fill="none"246 stroke={st.stroke}247 strokeWidth={active ? 2.4 : 1.4}248 strokeDasharray={st.dash}249 opacity={selectedId == null ? 0.55 : active ? 0.95 : 0.15}250 markerEnd={st.arrow ? "url(#cm-arrow)" : undefined}251 />252 );253 })}254255 {/* Nœuds */}256 {nodes.map((n) => {257 const p = layout.pos.get(n.id)!;258 const r = 9 + Math.min(4, Math.max(1, n.importance)) * 3.5;259 const info = LEVELS[n.level] ?? LEVELS["a-decouvrir"];260 const isSel = selectedId === n.id;261 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));262 const label = n.name.length > 24 ? n.name.slice(0, 23) + "…" : n.name;263 return (264 <g265 key={n.id}266 opacity={dim ? 0.35 : 1}267 className="cursor-pointer"268 onPointerDown={(e) => e.stopPropagation()}269 onClick={(e) => { e.stopPropagation(); setSelectedId(n.id); centerOn(n.id); }}270 tabIndex={0}271 role="button"272 aria-label={`${n.name} — ${info.label}, maîtrise ${Math.round(n.mastery * 100)} %`}273 onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelectedId(n.id); centerOn(n.id); } }}274 >275 <title>{`${n.name} — ${info.label} (${Math.round(n.mastery * 100)} %)`}</title>276 {isSel && <circle cx={p.x} cy={p.y} r={r + 6} fill="none" stroke={info.hex} strokeWidth={2} opacity={0.5} />}277 <circle cx={p.x} cy={p.y} r={r} fill={info.hex} stroke="var(--card)" strokeWidth={2} />278 <text x={p.x} y={p.y + r + 15} textAnchor="middle" fontSize={11} style={{ fill: "var(--fg)" }}>279 {label}280 </text>281 </g>282 );283 })}284 </svg>285286 {/* Panneau latéral */}287 {selected && (288 <aside289 className="absolute inset-x-0 bottom-0 max-h-[62%] sm:inset-y-0 sm:left-auto sm:right-0 sm:w-[340px] sm:max-h-none bg-card border-t sm:border-t-0 sm:border-l border-app shadow-2xl overflow-y-auto animate-fade-in"290 aria-label={`Détails du concept ${selected.name}`}291 >292 <div className="sticky top-0 bg-card border-b border-app px-4 py-3 flex items-start justify-between gap-2 z-10">293 <div className="min-w-0">294 <h3 className="font-semibold text-fg text-[15px] leading-snug">{selected.name}</h3>295 <div className="flex flex-wrap gap-1.5 mt-1.5">296 <Badge tone={levelInfo(selected.level).tone}>297 {levelInfo(selected.level).label} · {Math.round(selected.mastery * 100)} %298 </Badge>299 {selected.week != null && <Badge tone="neutral">Semaine {selected.week}</Badge>}300 <Badge tone="neutral">Importance {selected.importance}</Badge>301 </div>302 </div>303 <button onClick={() => setSelectedId(null)} aria-label="Fermer" className="p-1.5 rounded-md text-muted hover:text-fg shrink-0">304 <X size={16} />305 </button>306 </div>307 <div className="p-4 space-y-4">308 {selected.description && <p className="text-[13.5px] text-fg leading-relaxed">{selected.description}</p>}309 <div className="flex gap-4 text-[12.5px] text-muted">310 <span className="inline-flex items-center gap-1.5"><Layers size={13} /> {selected.cards} carte{selected.cards > 1 ? "s" : ""}</span>311 <span className="inline-flex items-center gap-1.5"><ListChecks size={13} /> {selected.questions} question{selected.questions > 1 ? "s" : ""}</span>312 </div>313 <div className="grid gap-2">314 <Link href={`/chat?course=${upper}`} className="block">315 <Button variant="secondary" size="sm" className="w-full justify-start">316 <MessageSquareText size={14} /> Expliquer dans le chat317 </Button>318 </Link>319 <Link href={`/apprendre/${course}/quiz?concept=${encodeURIComponent(selected.slug)}`} className="block">320 <Button variant="secondary" size="sm" className="w-full justify-start">321 <ListChecks size={14} /> Quiz ciblé322 </Button>323 </Link>324 <Link href={`/apprendre/${course}/flashcards?concept=${encodeURIComponent(selected.slug)}`} className="block">325 <Button variant="secondary" size="sm" className="w-full justify-start">326 <Layers size={14} /> Flashcards327 </Button>328 </Link>329 <Link href={`/apprendre/${course}/resumes?concept=${encodeURIComponent(selected.slug)}`} className="block">330 <Button variant="secondary" size="sm" className="w-full justify-start">331 <FileText size={14} /> Générer un résumé332 </Button>333 </Link>334 </div>335 {selected.observations === 0 && (336 <p className="text-[12px] text-muted bg-surface-1 dark:bg-brand-950/50 border border-app rounded-lg p-3">337 Concept jamais pratiqué — un quiz ciblé ou quelques cartes établiront une première estimation de maîtrise.338 </p>339 )}340 </div>341 </aside>342 )}343 </Card>344345 {/* Légende */}346 <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12px] text-muted">347 {(Object.keys(LEVELS) as MasteryLevel[]).map((k) => (348 <span key={k} className="inline-flex items-center gap-1.5">349 <span className="w-2.5 h-2.5 rounded-full" style={{ background: LEVELS[k].hex }} /> {LEVELS[k].label}350 </span>351 ))}352 <span className="w-px h-4 bg-app hidden sm:block" />353 {Object.entries(LINK_STYLES).map(([k, s]) => (354 <span key={k} className="inline-flex items-center gap-1.5">355 <svg width="26" height="8" aria-hidden>356 <line x1="1" y1="4" x2="25" y2="4" stroke={s.stroke} strokeWidth="2" strokeDasharray={s.dash} />357 </svg>358 {s.label}359 </span>360 ))}361 <span className="inline-flex items-center gap-1.5">362 <span className="w-3.5 h-3.5 rounded-full border-2 border-current opacity-60" /> Taille = importance363 </span>364 </div>365 </main>366 );367}368