SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
16.4 KB · 368 lines tsx
Raw Blame History
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