Politur: Gedaechtnis-Graph als Themen-Inseln statt Hairball

Datenbefund: 336 der 679 Kanten laufen quer zwischen Kategorien und uebermalten
das Quadranten-Clustering komplett; Kantengewichte (0,45-1,0) wurden nie genutzt.

- Querkanten im Ruhezustand ausgeblendet, erscheinen nur am aktiven Knoten
  (Hover/Auswahl zeigt ALLE seine Verbindungen, auch schwache/quere)
- Gewichts-Schwelle mit Regler in der Legende (alle <-> stark, Default 0,75,
  Live-Zaehler): 679 -> 114 sichtbare Kanten im Standard
- Cluster-Huellen: konvexe, geglaettete, getoente Flaeche + Landkarten-Label
  je Kategorie via onRenderFramePre (Monotone-Chain-Hull, pro Frame billig)
- Insel-Physik: Intra-Kanten ziehen nach Gewicht (distance 70), Querkanten
  fast gar nicht (strength 0,01, distance 200); Cluster-Kraefte 0,07 -> 0,1

E2E verifiziert (Preview mit gepatchtem rAF): Huellen zeichnen ueber viele
Frames fehlerfrei, Regler 0,45=342/0,95=1 Kanten, Hover+Klick unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-09 13:35:47 +02:00
parent 35be5e13f6
commit 042c3f16d9
7 changed files with 182 additions and 64 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<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-BGmfM64G.js"></script> <script type="module" crossorigin src="/assets/index-BfahO8B0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css"> <link rel="stylesheet" crossorigin href="/assets/index-BSNo2FSN.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+134 -16
View File
@@ -11,19 +11,52 @@ const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse", identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
} }
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 FALLBACK_EDGE = "rgba(100, 116, 139, 0.3)"
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.25)"
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen. // Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
const CAT_EDGE_COLOR: Record<string, string> = { const CAT_EDGE_COLOR: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.25)", knowledge: "rgba(59, 130, 246, 0.25)", identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
rules: "rgba(217, 70, 239, 0.25)", events: "rgba(251, 191, 36, 0.25)", rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
} }
// Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist) // Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist)
const CAT_HALO_COLOR: Record<string, string> = { const CAT_HALO_COLOR: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)", 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)", rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
} }
// Cluster-Hüllen: Fläche, Rand und Landkarten-Label je Kategorie
const CAT_HULL_FILL: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.05)", knowledge: "rgba(59, 130, 246, 0.05)",
rules: "rgba(217, 70, 239, 0.05)", events: "rgba(251, 191, 36, 0.05)",
}
const CAT_HULL_STROKE: 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)",
}
const CAT_HULL_LABEL: Record<string, string> = {
identity: "rgba(0, 245, 255, 0.3)", knowledge: "rgba(59, 130, 246, 0.3)",
rules: "rgba(217, 70, 239, 0.3)", events: "rgba(251, 191, 36, 0.3)",
}
// Konvexe Hülle (Andrew Monotone Chain) — bei ~300 Punkten pro Frame vernachlässigbar
function convexHull(pts: [number, number][]): [number, number][] {
if (pts.length < 3) return pts
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1])
const cross = (o: number[], a: number[], b: number[]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
const lower: [number, number][] = []
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop()
lower.push(pt)
}
const upper: [number, number][] = []
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i]
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop()
upper.push(pt)
}
lower.pop(); upper.pop()
return lower.concat(upper)
}
export function GraphView({ data, selectedNodeId, onNodeSelect }: { export function GraphView({ data, selectedNodeId, onNodeSelect }: {
data: MemoryGraph data: MemoryGraph
@@ -31,6 +64,8 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
onNodeSelect: (id: string | null) => void onNodeSelect: (id: string | null) => void
}) { }) {
const [hovered, setHovered] = useState<string | null>(null) const [hovered, setHovered] = useState<string | null>(null)
// Gewichts-Schwelle: nur Kanten ab dieser Ähnlichkeit werden im Ruhezustand gezeichnet
const [threshold, setThreshold] = useState(0.75)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<any>(null) const fgRef = useRef<any>(null)
@@ -86,6 +121,7 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen) // Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
__sid: e.source, __sid: e.source,
__tid: e.target, __tid: e.target,
__sameCat: catById.get(e.source) === catById.get(e.target),
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE, __restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
})) }))
return { nodes, links } return { nodes, links }
@@ -133,9 +169,9 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
// 1. Standard-Zentrumskraft entfernen // 1. Standard-Zentrumskraft entfernen
fg.d3Force("center", null) fg.d3Force("center", null)
// 2. Clustering-Kräfte // 2. Clustering-Kräfte — etwas stärker, damit die Inseln klar auseinanderziehen
fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.07)) fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.1))
fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.07)) fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.1))
// 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz // 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz
const charge = fg.d3Force("charge") const charge = fg.d3Force("charge")
@@ -144,9 +180,15 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
// 4. Konstanter, stabiler Kollisionsradius // 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): Kanten innerhalb einer Kategorie ziehen stark an
// (gewichtet nach Ähnlichkeit), Querkanten fast gar nicht — so trennen sich
// die Inseln räumlich, statt vom Quernetz zusammengezurrt zu werden.
const link = fg.d3Force("link") const link = fg.d3Force("link")
if (link) link.distance(85) if (link) {
link
.distance((l: any) => (l.__sameCat ? 70 : 200))
.strength((l: any) => (l.__sameCat ? 0.3 * (l.weight || 0.6) : 0.01))
}
// Simulation einmalig heiss starten für die neue Struktur // Simulation einmalig heiss starten für die neue Struktur
fg.d3ReheatSimulation() fg.d3ReheatSimulation()
@@ -154,6 +196,11 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
const activeNode = hovered || selectedNodeId const activeNode = hovered || selectedNodeId
// Anzahl der im Ruhezustand sichtbaren Kanten (für die Anzeige am Regler)
const visibleEdges = useMemo(() => {
return graphData.links.filter((l: any) => l.__sameCat && (l.weight || 0) >= threshold).length
}, [graphData, threshold])
// Layout zurücksetzen/neu anschubsen // Layout zurücksetzen/neu anschubsen
const relayout = () => { const relayout = () => {
if (!fgRef.current) return if (!fgRef.current) return
@@ -171,12 +218,16 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
return set return set
}, [activeNode, data]) }, [activeNode, data])
// Stabile Accessor-Identitäten: nur neu, wenn sich der aktive Knoten ändert. // Sichtbarkeit statt Durcheinander: im Ruhezustand nur Insel-Kanten (gleiche
// Nutzt die vorberechneten __sid/__tid/__restColor — KEIN nodes.find() pro Frame mehr. // Kategorie) ab der Gewichts-Schwelle. Querkanten erscheinen NUR am aktiven
// Knoten (Hover/Auswahl) — dort dann alle seine Verbindungen, auch schwache.
const linkVisibility = useCallback((link: any) => {
if (activeNode) return link.__sid === activeNode || link.__tid === activeNode
return link.__sameCat && (link.weight || 0) >= threshold
}, [activeNode, threshold])
const linkColor = useCallback((link: any) => { const linkColor = useCallback((link: any) => {
if (activeNode) { if (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) return EDGE_HI
return (link.__sid === activeNode || link.__tid === activeNode) ? EDGE_HI : DIM_EDGE
}
return link.__restColor return link.__restColor
}, [activeNode]) }, [activeNode])
@@ -243,6 +294,59 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
onNodeSelect(node ? node.id : null) onNodeSelect(node ? node.id : null)
}, [pickNode, onNodeSelect]) }, [pickNode, onNodeSelect])
// Landkarten-Look: weiche, getönte Hülle + großes Label hinter jeder Kategorie-Insel.
// Läuft VOR Kanten/Knoten (onRenderFramePre); Konvexhülle über ~300 Punkte ist billig.
const paintHulls = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => {
const byCat: Record<string, [number, number][]> = {}
for (const n of graphData.nodes as any[]) {
if (n.x == null || n.y == null) continue
;(byCat[n.category] ??= []).push([n.x, n.y])
}
for (const [cat, pts] of Object.entries(byCat)) {
const fill = CAT_HULL_FILL[cat]
if (!fill || pts.length === 0) continue
const hull = convexHull(pts)
let cx = 0, cy = 0
hull.forEach((p) => { cx += p[0]; cy += p[1] })
cx /= hull.length; cy /= hull.length
const PAD = 34
ctx.beginPath()
if (hull.length < 3) {
ctx.arc(cx, cy, PAD + 16, 0, 2 * Math.PI)
} else {
// Hülle vom Schwerpunkt weg aufpolstern und durch Mittelpunkte glätten
const exp = hull.map(([x, y]) => {
const dx = x - cx, dy = y - cy
const d = Math.hypot(dx, dy) || 1
return [x + (dx / d) * PAD, y + (dy / d) * PAD] as [number, number]
})
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
const m0 = mid(exp[exp.length - 1], exp[0])
ctx.moveTo(m0[0], m0[1])
for (let i = 0; i < exp.length; i++) {
const cur = exp[i]
const m = mid(cur, exp[(i + 1) % exp.length])
ctx.quadraticCurveTo(cur[0], cur[1], m[0], m[1])
}
ctx.closePath()
}
ctx.fillStyle = fill
ctx.fill()
ctx.strokeStyle = CAT_HULL_STROKE[cat]
ctx.lineWidth = 1.2 / Math.max(globalScale, 0.3)
ctx.stroke()
// Insel-Label in Weltkoordinaten — zoomt wie eine Landkarten-Beschriftung mit
const minY = Math.min(...hull.map((p) => p[1]))
ctx.font = '600 26px "Space Grotesk", sans-serif'
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillStyle = CAT_HULL_LABEL[cat]
ctx.fillText(CAT_LABEL[cat] || cat, cx, minY - PAD - 8)
}
}, [graphData])
const nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const nodeCanvasObject = useCallback((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
@@ -344,6 +448,8 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
// ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an. // ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an.
enablePointerInteraction={false} enablePointerInteraction={false}
enableNodeDrag={false} enableNodeDrag={false}
onRenderFramePre={paintHulls}
linkVisibility={linkVisibility}
linkColor={linkColor} linkColor={linkColor}
linkWidth={linkWidth} linkWidth={linkWidth}
linkDirectionalParticles={linkParticles} linkDirectionalParticles={linkParticles}
@@ -357,13 +463,25 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren <RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button> </button>
{/* Legende */} {/* Legende + Verbindungs-Regler */}
<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"> <div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(CAT_LABEL).map(([k, l]) => ( {Object.entries(CAT_LABEL).map(([k, l]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground"> <span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l} <span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span> </span>
))} ))}
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground border-l border-border/40 pl-3">
<span>Verbindungen</span>
<span className="text-[9px] text-muted-foreground/60">alle</span>
<input
type="range" min={0.45} max={0.95} step={0.05} value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="w-20 accent-primary cursor-pointer"
title="Nur Verbindungen ab dieser Stärke zeigen"
/>
<span className="text-[9px] text-muted-foreground/60">stark</span>
<span className="font-mono text-[9px] text-muted-foreground/80 min-w-[3ch] text-right">{visibleEdges}</span>
</label>
</div> </div>
</div> </div>
) )