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:
+9
-9
File diff suppressed because one or more lines are too long
+59
-59
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D18lzyOG.js"></script>
|
<script type="module" crossorigin src="/assets/index-gapT4XnB.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css">
|
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import ForceGraph2D from "react-force-graph-2d"
|
import ForceGraph2D from "react-force-graph-2d"
|
||||||
import { forceCollide, forceX, forceY } from "d3-force"
|
import { forceCollide, forceX, forceY } from "d3-force"
|
||||||
import { RefreshCw } from "lucide-react"
|
import { RefreshCw } from "lucide-react"
|
||||||
@@ -12,13 +12,17 @@ const CAT_LABEL: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
||||||
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
|
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
|
||||||
|
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.25)"
|
||||||
|
|
||||||
// Hilfsfunktion zur Konvertierung von Hex in RGBA für farbkodierte Verbindungen
|
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
const CAT_EDGE_COLOR: Record<string, string> = {
|
||||||
const r = parseInt(hex.slice(1, 3), 16)
|
identity: "rgba(0, 245, 255, 0.25)", knowledge: "rgba(59, 130, 246, 0.25)",
|
||||||
const g = parseInt(hex.slice(3, 5), 16)
|
rules: "rgba(217, 70, 239, 0.25)", events: "rgba(251, 191, 36, 0.25)",
|
||||||
const b = parseInt(hex.slice(5, 7), 16)
|
}
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
// Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist)
|
||||||
|
const CAT_HALO_COLOR: Record<string, string> = {
|
||||||
|
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
|
||||||
|
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||||
@@ -49,28 +53,50 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
return () => resizeObserver.disconnect()
|
return () => resizeObserver.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Graphology-Daten in react-force-graph-2d-kompatibles Format konvertieren
|
// Knoten-Objekte über Daten-Updates hinweg WIEDERVERWENDEN: D3 behält x/y auf dem
|
||||||
|
// Objekt — nur so überleben die Positionen Refetches und Suchfilter, statt dass der
|
||||||
|
// ganze Graph bei jedem Update neu explodiert (und Knoten unterm Cursor wegwandern).
|
||||||
|
const nodeCacheRef = useRef(new Map<string, any>())
|
||||||
|
|
||||||
const graphData = useMemo(() => {
|
const graphData = useMemo(() => {
|
||||||
return {
|
const cache = nodeCacheRef.current
|
||||||
nodes: data.nodes.map((n) => ({ ...n })),
|
const seen = new Set<string>()
|
||||||
links: data.edges.map((e) => ({
|
const nodes = data.nodes.map((n) => {
|
||||||
|
seen.add(n.id)
|
||||||
|
let node = cache.get(n.id)
|
||||||
|
if (!node) {
|
||||||
|
node = { ...n }
|
||||||
|
cache.set(n.id, node)
|
||||||
|
} else if (node.content !== n.content || node.category !== n.category) {
|
||||||
|
node.content = n.content
|
||||||
|
node.category = n.category
|
||||||
|
node.__baseTextWidth = undefined
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
for (const id of Array.from(cache.keys())) {
|
||||||
|
if (!seen.has(id)) cache.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const catById = new Map(data.nodes.map((n) => [n.id, n.category]))
|
||||||
|
const links = data.edges.map((e) => ({
|
||||||
source: e.source,
|
source: e.source,
|
||||||
target: e.target,
|
target: e.target,
|
||||||
weight: e.weight || 0,
|
weight: e.weight || 0,
|
||||||
})),
|
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
|
||||||
}
|
__sid: e.source,
|
||||||
|
__tid: e.target,
|
||||||
|
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
|
||||||
|
}))
|
||||||
|
return { nodes, links }
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
// Grad (Degree) berechnen – fängt D3-Objektmutationen robust ab
|
// Grad (Degree) berechnen
|
||||||
const degree = useMemo(() => {
|
const degree = useMemo(() => {
|
||||||
const d: Record<string, number> = {}
|
const d: Record<string, number> = {}
|
||||||
data.edges.forEach((e) => {
|
data.edges.forEach((e) => {
|
||||||
const sId = typeof e.source === "object" ? (e.source as any).id : e.source
|
d[e.source] = (d[e.source] || 0) + 1
|
||||||
const tId = typeof e.target === "object" ? (e.target as any).id : e.target
|
d[e.target] = (d[e.target] || 0) + 1
|
||||||
if (sId && tId) {
|
|
||||||
d[sId] = (d[sId] || 0) + 1
|
|
||||||
d[tId] = (d[tId] || 0) + 1
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return d
|
return d
|
||||||
}, [data])
|
}, [data])
|
||||||
@@ -94,8 +120,12 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
}[category] || 0
|
}[category] || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// D3 Force Engine initialisieren – NUR wenn sich die Daten tatsächlich ändern!
|
// Reheat nur, wenn sich die STRUKTUR ändert (Knoten-/Kantenmenge) — nicht bei jeder
|
||||||
// Minimiert CPU-Zyklen und verhindert Reheat-Zucken bei Mausbewegung.
|
// neuen Objekt-Identität der Daten. Verhindert Layout-Neustarts bei blossen Refetches.
|
||||||
|
const structureKey = useMemo(() => {
|
||||||
|
return `${data.nodes.map((n) => n.id).sort().join(",")}#${data.edges.length}`
|
||||||
|
}, [data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fg = fgRef.current
|
const fg = fgRef.current
|
||||||
if (!fg) return
|
if (!fg) return
|
||||||
@@ -111,16 +141,16 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
const charge = fg.d3Force("charge")
|
const charge = fg.d3Force("charge")
|
||||||
if (charge) charge.strength(-380)
|
if (charge) charge.strength(-380)
|
||||||
|
|
||||||
// 4. Konstanter, stabiler Kollisionsradius (verhindert Reheat-Zucken bei Mausbewegung)
|
// 4. Konstanter, stabiler Kollisionsradius
|
||||||
fg.d3Force("collide", forceCollide().radius(28).iterations(2))
|
fg.d3Force("collide", forceCollide().radius(28).iterations(2))
|
||||||
|
|
||||||
// 5. Federkraft (Links)
|
// 5. Federkraft (Links)
|
||||||
const link = fg.d3Force("link")
|
const link = fg.d3Force("link")
|
||||||
if (link) link.distance(85)
|
if (link) link.distance(85)
|
||||||
|
|
||||||
// Simulation einmalig heiss starten für die neuen Daten
|
// Simulation einmalig heiss starten für die neue Struktur
|
||||||
fg.d3ReheatSimulation()
|
fg.d3ReheatSimulation()
|
||||||
}, [graphData])
|
}, [structureKey])
|
||||||
|
|
||||||
const activeNode = hovered || selectedNodeId
|
const activeNode = hovered || selectedNodeId
|
||||||
|
|
||||||
@@ -130,80 +160,53 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
fgRef.current.zoomToFit(400, 40)
|
fgRef.current.zoomToFit(400, 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachbarmenge für extrem schnellen Lookup – fängt D3-Objektmutationen robust ab
|
// Nachbarmenge für extrem schnellen Lookup
|
||||||
const connectedNodes = useMemo(() => {
|
const connectedNodes = useMemo(() => {
|
||||||
if (!activeNode) return new Set<string>()
|
if (!activeNode) return new Set<string>()
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
data.edges.forEach((e) => {
|
data.edges.forEach((e) => {
|
||||||
const sId = typeof e.source === "object" ? (e.source as any).id : e.source
|
if (e.source === activeNode) set.add(e.target)
|
||||||
const tId = typeof e.target === "object" ? (e.target as any).id : e.target
|
if (e.target === activeNode) set.add(e.source)
|
||||||
if (sId === activeNode) set.add(tId)
|
|
||||||
if (tId === activeNode) set.add(sId)
|
|
||||||
})
|
})
|
||||||
return set
|
return set
|
||||||
}, [activeNode, data])
|
}, [activeNode, data])
|
||||||
|
|
||||||
return (
|
// Stabile Accessor-Identitäten: nur neu, wenn sich der aktive Knoten ändert.
|
||||||
<div className="absolute inset-0" ref={containerRef}
|
// Nutzt die vorberechneten __sid/__tid/__restColor — KEIN nodes.find() pro Frame mehr.
|
||||||
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
const linkColor = useCallback((link: any) => {
|
||||||
|
if (activeNode) {
|
||||||
|
return (link.__sid === activeNode || link.__tid === activeNode) ? EDGE_HI : DIM_EDGE
|
||||||
|
}
|
||||||
|
return link.__restColor
|
||||||
|
}, [activeNode])
|
||||||
|
|
||||||
<ForceGraph2D
|
const linkWidth = useCallback((link: any) => {
|
||||||
ref={fgRef}
|
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 2.5 : 1.4
|
||||||
width={dimensions.width}
|
}, [activeNode])
|
||||||
height={dimensions.height}
|
|
||||||
graphData={graphData}
|
|
||||||
backgroundColor="rgba(0,0,0,0)"
|
|
||||||
cooldownTicks={120}
|
|
||||||
|
|
||||||
// Exakte Hit-Map auf unsichtbarem Canvas proportional zur visuellen Knotengröße zeichnen (inkl. 5px Puffer)
|
const linkParticles = useCallback((link: any) => {
|
||||||
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
|
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
|
||||||
ctx.fillStyle = color
|
}, [activeNode])
|
||||||
ctx.beginPath()
|
|
||||||
const nId = typeof node === "object" ? node.id : node
|
|
||||||
const size = 6 + Math.min(degree[nId] || 0, 15) * 0.8
|
|
||||||
ctx.arc(node.x ?? 0, node.y ?? 0, size + 5, 0, 2 * Math.PI, false)
|
|
||||||
ctx.fill()
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Hover- und Klick-Logik
|
const handleNodeHover = useCallback((node: any) => {
|
||||||
onNodeClick={(node: any) => onNodeSelect(node.id)}
|
|
||||||
onBackgroundClick={() => onNodeSelect(null)}
|
|
||||||
onNodeHover={(node: any) => {
|
|
||||||
setHovered(node ? node.id : null)
|
setHovered(node ? node.id : null)
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
containerRef.current.style.cursor = node ? "pointer" : "default"
|
containerRef.current.style.cursor = node ? "pointer" : "default"
|
||||||
}
|
}
|
||||||
}}
|
}, [])
|
||||||
|
|
||||||
// Kanten-Animationen & Partikelfluss
|
// Hit-Map: Trefferfläche wird mit dem Zoom NICHT beliebig klein — mindestens ~14
|
||||||
linkColor={(link: any) => {
|
// Bildschirm-Pixel Radius, sonst sind Knoten rausgezoomt unmöglich zu treffen.
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
const nodePointerAreaPaint = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
|
||||||
|
const r = Math.max(size + 5, 14 / Math.max(globalScale || 1, 0.01))
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI, false)
|
||||||
|
ctx.fill()
|
||||||
|
}, [degree])
|
||||||
|
|
||||||
if (activeNode === sId || activeNode === tId) return EDGE_HI
|
const nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
if (activeNode) return DIM_EDGE
|
|
||||||
|
|
||||||
const sourceNode = graphData.nodes.find(n => n.id === sId)
|
|
||||||
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
|
|
||||||
return hexToRgba(catColor, 0.25)
|
|
||||||
}}
|
|
||||||
linkWidth={(link: any) => {
|
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
|
||||||
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Daten-Partikel fließen auf den aktiven Verbindungen
|
|
||||||
linkDirectionalParticles={(link: any) => {
|
|
||||||
const sId = typeof link.source === "object" ? link.source.id : link.source
|
|
||||||
const tId = typeof link.target === "object" ? link.target.id : link.target
|
|
||||||
return (activeNode === sId || activeNode === tId) ? 4 : 0
|
|
||||||
}}
|
|
||||||
linkDirectionalParticleWidth={2.2}
|
|
||||||
linkDirectionalParticleSpeed={0.006}
|
|
||||||
|
|
||||||
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
|
|
||||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
||||||
const x = node.x ?? 0
|
const x = node.x ?? 0
|
||||||
const y = node.y ?? 0
|
const y = node.y ?? 0
|
||||||
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
|
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
|
||||||
@@ -214,12 +217,20 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
|
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
|
||||||
const finalColor = isDimmed ? "#1e293b" : color
|
const finalColor = isDimmed ? "#1e293b" : color
|
||||||
|
|
||||||
// 1. Zeichne den farbigen Knotenpunkt (Kreis)
|
// 1. Billiger Glow: transparenter Halo-Kreis statt ctx.shadowBlur (das kostet bei
|
||||||
|
// hunderten Knoten pro Frame die gesamte Framerate). Echter Blur nur für den
|
||||||
|
// EINEN aktiven Knoten.
|
||||||
if (!isDimmed) {
|
if (!isDimmed) {
|
||||||
ctx.shadowColor = color
|
ctx.beginPath()
|
||||||
ctx.shadowBlur = isHovered || isSelected ? 15 : 6
|
ctx.arc(x, y, size * 1.9, 0, 2 * Math.PI, false)
|
||||||
|
ctx.fillStyle = CAT_HALO_COLOR[node.category] || "rgba(100, 116, 139, 0.14)"
|
||||||
|
ctx.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHovered || isSelected) {
|
||||||
|
ctx.shadowColor = color
|
||||||
|
ctx.shadowBlur = 15
|
||||||
|
}
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
|
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
|
||||||
ctx.fillStyle = finalColor
|
ctx.fillStyle = finalColor
|
||||||
@@ -234,14 +245,13 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Zeichne das Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn)
|
// 2. Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn)
|
||||||
// Verhindert massives Laggen bei Zoom, da niemals Hunderte Labels gleichzeitig gerastert werden!
|
|
||||||
const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id))
|
const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id))
|
||||||
|
|
||||||
if (showLabel) {
|
if (showLabel) {
|
||||||
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
|
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
|
||||||
|
|
||||||
// Textbreite EINMALIG auf dem Objekt cachen (verhindert massive Frame-Einbrüche durch ctx.measureText)
|
// Textbreite EINMALIG auf dem Objekt cachen (ctx.measureText ist teuer)
|
||||||
if (node.__baseTextWidth === undefined) {
|
if (node.__baseTextWidth === undefined) {
|
||||||
const prevFont = ctx.font
|
const prevFont = ctx.font
|
||||||
ctx.font = '500 10px "Space Grotesk", sans-serif'
|
ctx.font = '500 10px "Space Grotesk", sans-serif'
|
||||||
@@ -254,11 +264,10 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
|
|
||||||
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
|
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
|
||||||
|
|
||||||
// Breite linear zur geänderten FontSize skalieren – KEIN ctx.measureText Aufruf mehr pro Frame!
|
// Breite linear zur geänderten FontSize skalieren — kein measureText pro Frame
|
||||||
const textWidth = node.__baseTextWidth * (fontSize / 10)
|
const textWidth = node.__baseTextWidth * (fontSize / 10)
|
||||||
const bpad = 4.5 / Math.max(0.5, globalScale)
|
const bpad = 4.5 / Math.max(0.5, globalScale)
|
||||||
|
|
||||||
// Schnelle, hochperformante rechteckige Sci-Fi-Pille zeichnen (Faktor 10x schneller als abgerundete Pfade)
|
|
||||||
const rx = x - textWidth / 2 - bpad
|
const rx = x - textWidth / 2 - bpad
|
||||||
const ry = y + size + (6 / Math.max(0.5, globalScale))
|
const ry = y + size + (6 / Math.max(0.5, globalScale))
|
||||||
const rw = textWidth + bpad * 2
|
const rw = textWidth + bpad * 2
|
||||||
@@ -277,7 +286,29 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
|
|
||||||
ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale)))
|
ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale)))
|
||||||
}
|
}
|
||||||
}}
|
}, [degree, hovered, selectedNodeId, activeNode, connectedNodes])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0" ref={containerRef}
|
||||||
|
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
||||||
|
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={fgRef}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
graphData={graphData}
|
||||||
|
backgroundColor="rgba(0,0,0,0)"
|
||||||
|
cooldownTicks={120}
|
||||||
|
nodePointerAreaPaint={nodePointerAreaPaint}
|
||||||
|
onNodeClick={(node: any) => onNodeSelect(node.id)}
|
||||||
|
onBackgroundClick={() => onNodeSelect(null)}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
linkColor={linkColor}
|
||||||
|
linkWidth={linkWidth}
|
||||||
|
linkDirectionalParticles={linkParticles}
|
||||||
|
linkDirectionalParticleWidth={2.2}
|
||||||
|
linkDirectionalParticleSpeed={0.006}
|
||||||
|
nodeCanvasObject={nodeCanvasObject}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button onClick={relayout} title="Zentrieren"
|
<button onClick={relayout} title="Zentrieren"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, lazy, Suspense, useEffect } from "react"
|
import { useState, useMemo, lazy, Suspense, useEffect, useDeferredValue } from "react"
|
||||||
import {
|
import {
|
||||||
Trash2, Sparkles, User, Scroll, Shield, Clock, Search, Plus, BookOpen,
|
Trash2, Sparkles, User, Scroll, Shield, Clock, Search, Plus, BookOpen,
|
||||||
Share2, List, MessagesSquare, X, Copy, Check, Terminal as TerminalIcon, Send,
|
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 [selected, setSelected] = useState<string | null>(null)
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
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 qc = useQueryClient()
|
||||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||||
const { data: allItems = [] } = useMemory({})
|
const { data: allItems = [] } = useMemory({})
|
||||||
@@ -84,14 +97,17 @@ export function MemoryView() {
|
|||||||
const empty = allItems.length === 0
|
const empty = allItems.length === 0
|
||||||
|
|
||||||
// Graph clientseitig nach Suchtext filtern (Knoten + deren Kanten).
|
// 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 graphData = useMemo(() => {
|
||||||
const g = graph ?? { nodes: [], edges: [] }
|
const g = graph ?? { nodes: [], edges: [] }
|
||||||
if (!q.trim()) return g
|
if (!deferredQ.trim()) return g
|
||||||
const ql = q.toLowerCase()
|
const ql = deferredQ.toLowerCase()
|
||||||
const ns = g.nodes.filter((n) => n.content.toLowerCase().includes(ql))
|
const ns = g.nodes.filter((n) => n.content.toLowerCase().includes(ql))
|
||||||
const ids = new Set(ns.map((n) => n.id))
|
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)) }
|
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).
|
// Liste nach Kategorie gruppieren (skaliert: klare Sektionen statt einer flachen Wand).
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
@@ -224,6 +240,7 @@ export function MemoryView() {
|
|||||||
)}>
|
)}>
|
||||||
|
|
||||||
{/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
|
{/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
|
||||||
|
{isDesktop && (
|
||||||
<div className="absolute inset-0 z-0 hidden lg:block">
|
<div className="absolute inset-0 z-0 hidden lg:block">
|
||||||
<GraphErrorBoundary>
|
<GraphErrorBoundary>
|
||||||
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen…</div>}>
|
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen…</div>}>
|
||||||
@@ -231,6 +248,7 @@ export function MemoryView() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</GraphErrorBoundary>
|
</GraphErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
|
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -396,14 +414,16 @@ export function MemoryView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. Mobiler Fallback für den Graph (unter lg) */}
|
{/* 3. Mobiler Fallback für den Graph (unter lg) — nur mounten, wenn wirklich sichtbar */}
|
||||||
<div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
|
{!isDesktop && view === "graph" && (
|
||||||
|
<div className="w-full h-[500px] relative lg:hidden">
|
||||||
<GraphErrorBoundary>
|
<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>}>
|
<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} />
|
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</GraphErrorBoundary>
|
</GraphErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
|
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
|
||||||
{sel && (
|
{sel && (
|
||||||
|
|||||||
Reference in New Issue
Block a user