From 1cb7d3cb70e8a54b18eb26b7a1155adf87689a1f Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 30 Mar 2026 15:54:34 +0700 Subject: [PATCH] perf(ui): optimize KG graph view for large graphs - Add KGEdgeData interface replacing `as any` casts for type safety - Use entityMap (useMemo Map) for O(1) entity lookup instead of O(n) find - Pre-compute EDGE_STYLE_DEFAULT/FADED constants to reduce GC pressure - Skip edge object recreation when style unchanged during selection - Wrap EntityNode in memo() with memoized container style - Cap force simulation ticks at 200 for graphs with 100+ nodes --- ui/web/src/pages/memory/kg-graph-view.tsx | 94 ++++++++++++----------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index 7f9efb25..7c43e777 100644 --- a/ui/web/src/pages/memory/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/kg-graph-view.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useCallback, useState, useTransition } from "react"; +import { useMemo, useEffect, useCallback, useState, useTransition, memo } from "react"; import { ReactFlow, ReactFlowProvider, @@ -18,6 +18,9 @@ import "@xyflow/react/dist/style.css"; import { useTranslation } from "react-i18next"; import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; +/** Edge data carrying the relation label for selection reveal */ +interface KGEdgeData { relationLabel: string } + // Color mapping for entity types — dark-first palette for depth contrast // bg uses semi-transparent dark tones so nodes float on dark canvas const TYPE_COLORS: Record = { @@ -55,29 +58,28 @@ function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map= 4; + const containerStyle = useMemo(() => ({ + background: colors.bg, + border: `1.5px solid ${colors.border}`, + borderRadius: 20, + transform: `scale(${sizeScale})`, + backdropFilter: "blur(8px)", + boxShadow: isHighDegree + ? `0 0 16px ${colors.glow}, 0 0 4px ${colors.glow}` + : `0 0 6px rgba(0,0,0,0.3)`, + }), [colors, sizeScale, isHighDegree]); + return ( -
- {/* Hidden handles — required by ReactFlow for edge routing, zero visual/interaction cost */} - - +
+ +
{data.label}
@@ -86,7 +88,7 @@ function EntityNode({ data }: { data: { label: string; type: string; description
); -} +}); const nodeTypes = { entity: EntityNode }; @@ -95,6 +97,10 @@ interface SimNode extends SimulationNodeDatum { mass: number; } +// Pre-computed edge styles — shared references avoid per-edge allocation during selection +const EDGE_STYLE_DEFAULT = { stroke: "#334155", strokeWidth: 1, opacity: 0.4 } as const; +const EDGE_STYLE_FADED = { stroke: "#1e293b", strokeWidth: 0.5, opacity: 0.15 } as const; + /** Build ReactFlow nodes + edges with degree-based sizing metadata */ function buildGraph(entities: KGEntity[], relations: KGRelation[]) { const entityIds = new Set(entities.map((e) => e.id)); @@ -123,7 +129,7 @@ function buildGraph(entities: KGEntity[], relations: KGRelation[]) { data: { relationLabel: r.relation_type.replace(/_/g, " ") }, animated: false, type: "default", - style: { stroke: "#334155", strokeWidth: 1, opacity: 0.4 }, + style: EDGE_STYLE_DEFAULT, })); return { nodes, edges }; @@ -161,8 +167,9 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): .force("collide", forceCollide().radius((d: any) => 55 + (d.mass ?? 1) * 5).strength(0.8)) .stop(); - // Run simulation to completion synchronously (~300 ticks → 1 render instead of 300) - const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); + // Run simulation synchronously — cap ticks for large graphs to avoid main thread blocking + const fullTicks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); + const ticks = nodes.length > 100 ? Math.min(fullTicks, 200) : fullTicks; for (let i = 0; i < ticks; i++) simulation.tick(); return nodes.map((n, i) => ({ @@ -190,6 +197,7 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro const { fitView } = useReactFlow(); const [selectedNodeId, setSelectedNodeId] = useState(null); const [layoutReady, setLayoutReady] = useState(false); + const entityMap = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]); // Build graph data immediately (cheap), defer force layout to next frame (expensive) const { rawNodes, rawEdges } = useMemo(() => { @@ -225,30 +233,30 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro setEdges(layoutEdges); }, [layoutEdges, setEdges]); - // Apply selection styling via setEdges callback — avoids full edge array rebuild in render + // Apply selection styling — shared style refs reduce GC pressure for large graphs useEffect(() => { if (!selectedNodeId) { - // Reset all edges to default - setEdges((eds) => eds.map((e) => ({ - ...e, - label: undefined, - animated: false, - style: { stroke: "#334155", strokeWidth: 1, opacity: 0.4 }, - labelStyle: undefined, - labelBgStyle: undefined, - labelBgPadding: undefined, - labelShowBg: undefined, - }))); + // Reset: skip edges already in default state (no label, not animated) + setEdges((eds) => eds.map((e) => + e.style === EDGE_STYLE_DEFAULT && !e.label && !e.animated ? e : { + ...e, label: undefined, animated: false, style: EDGE_STYLE_DEFAULT, + labelStyle: undefined, labelBgStyle: undefined, labelBgPadding: undefined, labelShowBg: undefined, + } + )); return; } - const entity = entities.find((ent) => ent.id === selectedNodeId); + const entity = entityMap.get(selectedNodeId); const colors = TYPE_COLORS[entity?.entity_type ?? ""] || DEFAULT_COLOR; setEdges((eds) => eds.map((e) => { const isConnected = e.source === selectedNodeId || e.target === selectedNodeId; - if (!isConnected) return { ...e, label: undefined, animated: false, style: { stroke: "#1e293b", strokeWidth: 0.5, opacity: 0.15 } }; + if (!isConnected) { + // Skip if already faded + if (e.style === EDGE_STYLE_FADED) return e; + return { ...e, label: undefined, animated: false, style: EDGE_STYLE_FADED }; + } return { ...e, - label: (e.data as any)?.relationLabel, + label: (e.data as KGEdgeData | undefined)?.relationLabel, animated: true, style: { stroke: colors.border, strokeWidth: 2, opacity: 0.9 }, labelStyle: { fontSize: 9, fill: colors.text, fontWeight: 500 }, @@ -257,7 +265,7 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro labelShowBg: true, }; })); - }, [selectedNodeId, entities, setEdges]); + }, [selectedNodeId, entityMap, setEdges]); // useTransition lets React yield to browser paint before processing edge updates const [, startTransition] = useTransition(); @@ -268,10 +276,10 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro setSelectedNodeId((prev) => (prev === node.id ? null : node.id)); }); if (!onEntityClick) return; - const entity = entities.find((e) => e.id === node.id); + const entity = entityMap.get(node.id); if (entity) onEntityClick(entity); }, - [entities, onEntityClick, startTransition], + [entityMap, onEntityClick, startTransition], ); const handlePaneClick = useCallback(() => { @@ -326,7 +334,7 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro { - const type = (n.data as any)?.type as string; + const type = (n.data as Record)?.type as string; return (TYPE_COLORS[type] || DEFAULT_COLOR).border; }} maskColor="rgba(0,0,0,0.4)" @@ -345,7 +353,7 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro ))} {selectedNodeId && (
- {t("kg.graphView.selected", { name: entities.find((e) => e.id === selectedNodeId)?.name ?? "" })} + {t("kg.graphView.selected", { name: entityMap.get(selectedNodeId)?.name ?? "" })}
)}