Feat: Graph-Knoten verschieben (Drag) + Neu-anordnen-Button

Sigma-Drag-Muster (downNode/mousemovebody/mouseup) → Knoten frei ziehen; 'Neu anordnen' rechnet das
ForceAtlas2-Layout neu (aufräumen nach manuellem Verschieben).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 11:19:53 +02:00
parent de3c452f20
commit 88661545b4
7 changed files with 354 additions and 320 deletions
+37 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
import Sigma from "sigma"
import { Trash2, Sparkles, Share2 } from "lucide-react"
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react"
import { type MemoryGraph } from "@/lib/api"
// Sigma.js v3 + graphology: graph-optimiertes WebGL (kein three.js-Ballast) → skaliert auf tausende
@@ -80,11 +80,33 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
},
})
sigmaRef.current = renderer
let dragged: string | null = null // gerade gezogener Knoten (für Hover/Drag-Logik)
renderer.on("clickNode", ({ node }) => setSelected(node))
renderer.on("clickStage", () => setSelected(null))
renderer.on("enterNode", ({ node }) => { active.current = node; renderer.refresh(); containerRef.current!.style.cursor = "pointer" })
renderer.on("leaveNode", () => { active.current = selectedRef.current; renderer.refresh(); containerRef.current!.style.cursor = "default" })
renderer.on("enterNode", ({ node }) => { if (!dragged) { active.current = node; renderer.refresh() } containerRef.current!.style.cursor = dragged ? "grabbing" : "grab" })
renderer.on("leaveNode", () => { if (!dragged) { active.current = selectedRef.current; renderer.refresh(); containerRef.current!.style.cursor = "default" } })
// Knoten ziehen (Sigma hat kein eingebautes Dragging → Standard-Muster mit dem Mouse-Captor).
renderer.on("downNode", (e) => {
dragged = e.node
graph.setNodeAttribute(dragged, "highlighted", true)
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox())
containerRef.current!.style.cursor = "grabbing"
})
const mc = renderer.getMouseCaptor()
mc.on("mousemovebody", (e) => {
if (!dragged) return
const pos = renderer.viewportToGraph(e)
graph.setNodeAttribute(dragged, "x", pos.x)
graph.setNodeAttribute(dragged, "y", pos.y)
e.preventSigmaDefault(); e.original.preventDefault(); e.original.stopPropagation()
})
mc.on("mouseup", () => {
if (dragged) graph.removeNodeAttribute(dragged, "highlighted")
dragged = null
if (containerRef.current) containerRef.current.style.cursor = "default"
})
return () => { renderer.kill(); sigmaRef.current = null; graphRef.current = null }
}, [data, degree])
@@ -96,6 +118,14 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
sigmaRef.current?.refresh()
}, [selected])
// Layout neu berechnen (nach manuellem Verschieben „aufräumen").
const relayout = () => {
const g = graphRef.current, s = sigmaRef.current
if (!g || !s) return
forceAtlas2.assign(g, { iterations: 220, settings: forceAtlas2.inferSettings(g) })
s.refresh()
}
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
const neighbors = useMemo(() => {
if (!selected) return []
@@ -119,6 +149,10 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3">
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 bg-[#070a0f] overflow-hidden">
<div ref={containerRef} className="absolute inset-0" />
<button onClick={relayout} title="Knoten neu anordnen"
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors">
<RefreshCw className="h-3.5 w-3.5" /> Neu anordnen
</button>
{/* Legende */}
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(CAT_LABEL).map(([k, l]) => (