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:
-44
File diff suppressed because one or more lines are too long
+44
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-BGmfM64G.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css">
|
||||
<script type="module" crossorigin src="/assets/index-BfahO8B0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSNo2FSN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -11,19 +11,52 @@ const CAT_LABEL: Record<string, string> = {
|
||||
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||
}
|
||||
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.25)"
|
||||
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.3)"
|
||||
|
||||
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
|
||||
const CAT_EDGE_COLOR: Record<string, string> = {
|
||||
identity: "rgba(0, 245, 255, 0.25)", knowledge: "rgba(59, 130, 246, 0.25)",
|
||||
rules: "rgba(217, 70, 239, 0.25)", events: "rgba(251, 191, 36, 0.25)",
|
||||
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)",
|
||||
}
|
||||
// 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)",
|
||||
}
|
||||
// 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 }: {
|
||||
data: MemoryGraph
|
||||
@@ -31,6 +64,8 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
onNodeSelect: (id: string | null) => void
|
||||
}) {
|
||||
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 fgRef = useRef<any>(null)
|
||||
|
||||
@@ -86,6 +121,7 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
|
||||
__sid: e.source,
|
||||
__tid: e.target,
|
||||
__sameCat: catById.get(e.source) === catById.get(e.target),
|
||||
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
|
||||
}))
|
||||
return { nodes, links }
|
||||
@@ -133,9 +169,9 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
// 1. Standard-Zentrumskraft entfernen
|
||||
fg.d3Force("center", null)
|
||||
|
||||
// 2. Clustering-Kräfte
|
||||
fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.07))
|
||||
fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.07))
|
||||
// 2. Clustering-Kräfte — etwas stärker, damit die Inseln klar auseinanderziehen
|
||||
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.1))
|
||||
|
||||
// 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz
|
||||
const charge = fg.d3Force("charge")
|
||||
@@ -144,9 +180,15 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
// 4. Konstanter, stabiler Kollisionsradius
|
||||
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")
|
||||
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
|
||||
fg.d3ReheatSimulation()
|
||||
@@ -154,6 +196,11 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
|
||||
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
|
||||
const relayout = () => {
|
||||
if (!fgRef.current) return
|
||||
@@ -171,12 +218,16 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
return set
|
||||
}, [activeNode, data])
|
||||
|
||||
// Stabile Accessor-Identitäten: nur neu, wenn sich der aktive Knoten ändert.
|
||||
// Nutzt die vorberechneten __sid/__tid/__restColor — KEIN nodes.find() pro Frame mehr.
|
||||
// Sichtbarkeit statt Durcheinander: im Ruhezustand nur Insel-Kanten (gleiche
|
||||
// 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) => {
|
||||
if (activeNode) {
|
||||
return (link.__sid === activeNode || link.__tid === activeNode) ? EDGE_HI : DIM_EDGE
|
||||
}
|
||||
if (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) return EDGE_HI
|
||||
return link.__restColor
|
||||
}, [activeNode])
|
||||
|
||||
@@ -243,6 +294,59 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
onNodeSelect(node ? node.id : null)
|
||||
}, [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 x = node.x ?? 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.
|
||||
enablePointerInteraction={false}
|
||||
enableNodeDrag={false}
|
||||
onRenderFramePre={paintHulls}
|
||||
linkVisibility={linkVisibility}
|
||||
linkColor={linkColor}
|
||||
linkWidth={linkWidth}
|
||||
linkDirectionalParticles={linkParticles}
|
||||
@@ -357,13 +463,25 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
|
||||
</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">
|
||||
{/* Legende + Verbindungs-Regler */}
|
||||
<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]) => (
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user