Politur: Gedaechtnis-Graph Performance + Treffsicherheit grundsaniert

- Doppel-Mount behoben: GraphView lief unsichtbar doppelt (Desktop+Mobile), jetzt nur eine Instanz via matchMedia
- ctx.shadowBlur pro Knoten/Frame durch billigen Halo-Kreis ersetzt (Blur nur fuer aktiven Knoten)
- Kantenfarben beim Datenaufbau vorberechnet statt nodes.find() pro Kante pro Frame
- Knoten-Objekte ueber Daten-Updates wiederverwendet: Positionen ueberleben Refetch/Suche, Reheat nur bei Strukturaenderung
- Hit-Zone zoom-kompensiert (min. ~14 Bildschirm-Pixel) - Knoten sind rausgezoomt jetzt klick- und hoverbar
- Suche via useDeferredValue vom Graph-Umbau entkoppelt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-09 13:07:31 +02:00
parent 6ad6c09cc0
commit dfcf86902a
5 changed files with 298 additions and 247 deletions
+39 -19
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, lazy, Suspense, useEffect } from "react"
import { useState, useMemo, lazy, Suspense, useEffect, useDeferredValue } from "react"
import {
Trash2, Sparkles, User, Scroll, Shield, Clock, Search, Plus, BookOpen,
Share2, List, MessagesSquare, X, Copy, Check, Terminal as TerminalIcon, Send,
@@ -52,6 +52,19 @@ export function MemoryView() {
const [selected, setSelected] = useState<string | null>(null)
const [isExpanded, setIsExpanded] = useState(false)
// Nur EINE Graph-Instanz mounten: die CSS-Klassen (hidden/lg:hidden) verstecken zwar,
// aber eine per display:none versteckte ForceGraph2D-Instanz simuliert und rendert
// trotzdem weiter — das hat die CPU-Last der Ansicht glatt verdoppelt.
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
)
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)")
const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
mq.addEventListener("change", onChange)
return () => mq.removeEventListener("change", onChange)
}, [])
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const { data: allItems = [] } = useMemory({})
@@ -84,14 +97,17 @@ export function MemoryView() {
const empty = allItems.length === 0
// Graph clientseitig nach Suchtext filtern (Knoten + deren Kanten).
// useDeferredValue: der Graph-Umbau (Re-Layout) hinkt der Tipp-Eingabe nach,
// statt bei jedem Tastendruck die D3-Simulation neu anzuwerfen.
const deferredQ = useDeferredValue(q)
const graphData = useMemo(() => {
const g = graph ?? { nodes: [], edges: [] }
if (!q.trim()) return g
const ql = q.toLowerCase()
if (!deferredQ.trim()) return g
const ql = deferredQ.toLowerCase()
const ns = g.nodes.filter((n) => n.content.toLowerCase().includes(ql))
const ids = new Set(ns.map((n) => n.id))
return { nodes: ns, edges: g.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) }
}, [graph, q])
}, [graph, deferredQ])
// Liste nach Kategorie gruppieren (skaliert: klare Sektionen statt einer flachen Wand).
const grouped = useMemo(() => {
@@ -224,13 +240,15 @@ export function MemoryView() {
)}>
{/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
<div className="absolute inset-0 z-0 hidden lg:block">
<GraphErrorBoundary>
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
{isDesktop && (
<div className="absolute inset-0 z-0 hidden lg:block">
<GraphErrorBoundary>
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
)}
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
<div className={cn(
@@ -396,14 +414,16 @@ export function MemoryView() {
</div>
</div>
{/* 3. Mobiler Fallback für den Graph (unter lg) */}
<div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
<GraphErrorBoundary>
<Suspense fallback={<div className="h-full rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
{/* 3. Mobiler Fallback für den Graph (unter lg) — nur mounten, wenn wirklich sichtbar */}
{!isDesktop && view === "graph" && (
<div className="w-full h-[500px] relative lg:hidden">
<GraphErrorBoundary>
<Suspense fallback={<div className="h-full rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
)}
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
{sel && (