feat(ui): enhance knowledge graph with depth visualization and performance fixes (#572)

- Dark canvas with atmospheric radial gradient for depth effect
- Pill-shaped nodes with frosted glass, glow on hub nodes (4+ connections)
- Mass hierarchy in d3-force: org/project anchor center, tasks orbit out
- Degree centrality sizing: more connections = larger node
- Edge labels hidden by default, revealed on node selection with animation
- Deferred force layout via setTimeout(0) — toggle INP: 920ms → ~100ms
- useTransition for node clicks — edge restyle INP: 568ms → 160ms
- Disabled nodesConnectable + pointerEvents:none on handles — zero hover cost
- Added i18n key kg.graphView.selected for en/vi/zh
This commit is contained in:
therichardngai-code authored and GitHub committed 2026-03-30 15:38:38 +07:00
1 parent 3a6c63904c
commit 540fc5e99b
4 files changed
+211 -66

No files matched your search

+2 -1
View File
@@ -175,7 +175,8 @@
"loading": "Loading graph...",
"empty": "No entities to visualize",
"nodes": "{{count}} nodes",
"edges": "{{count}} edges"
"edges": "{{count}} edges",
"selected": "Selected: {{name}}"
}
},
"toast": {
+2 -1
View File
@@ -175,7 +175,8 @@
"loading": "Đang tải đồ thị...",
"empty": "Không có thực thể nào để hiển thị",
"nodes": "{{count}} nút",
"edges": "{{count}} cạnh"
"edges": "{{count}} cạnh",
"selected": "Đã chọn: {{name}}"
}
},
"toast": {
+2 -1
View File
@@ -175,7 +175,8 @@
"loading": "正在加载图谱...",
"empty": "没有可视化的实体",
"nodes": "{{count}} 个节点",
"edges": "{{count}} 条边"
"edges": "{{count}} 条边",
"selected": "已选择: {{name}}"
}
},
"toast": {
+205 -63
View File
@@ -1,4 +1,4 @@
import { useMemo, useEffect, useCallback } from "react";
import { useMemo, useEffect, useCallback, useState, useTransition } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -10,47 +10,81 @@ import {
MiniMap,
type Node,
type Edge,
type ColorMode,
Handle,
Position,
} from "@xyflow/react";
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type SimulationNodeDatum } from "d3-force";
import "@xyflow/react/dist/style.css";
import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
// Color mapping for entity types
const TYPE_COLORS: Record<string, { bg: string; border: string; text: string }> = {
person: { bg: "#fde8d8", border: "#E85D24", text: "#7a2610" },
project: { bg: "#dcfce7", border: "#22c55e", text: "#166534" },
task: { bg: "#fef3c7", border: "#f59e0b", text: "#92400e" },
event: { bg: "#fce7f3", border: "#ec4899", text: "#9d174d" },
concept: { bg: "#fef5e0", border: "#F8D080", text: "#7a5010" },
location: { bg: "#ccfbf1", border: "#14b8a6", text: "#115e59" },
organization: { bg: "#fee2e2", border: "#ef4444", text: "#991b1b" },
// 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<string, { bg: string; border: string; text: string; glow: string }> = {
person: { bg: "rgba(232,93,36,0.12)", border: "#E85D24", text: "#f4a574", glow: "rgba(232,93,36,0.35)" },
project: { bg: "rgba(34,197,94,0.12)", border: "#22c55e", text: "#86efac", glow: "rgba(34,197,94,0.35)" },
task: { bg: "rgba(245,158,11,0.12)", border: "#f59e0b", text: "#fcd34d", glow: "rgba(245,158,11,0.35)" },
event: { bg: "rgba(236,72,153,0.12)", border: "#ec4899", text: "#f9a8d4", glow: "rgba(236,72,153,0.35)" },
concept: { bg: "rgba(248,208,128,0.10)", border: "#F8D080", text: "#fde68a", glow: "rgba(248,208,128,0.30)" },
location: { bg: "rgba(20,184,166,0.12)", border: "#14b8a6", text: "#5eead4", glow: "rgba(20,184,166,0.35)" },
organization: { bg: "rgba(239,68,68,0.12)", border: "#ef4444", text: "#fca5a5", glow: "rgba(239,68,68,0.35)" },
};
const DEFAULT_COLOR = { bg: "#f3f4f6", border: "#9ca3af", text: "#374151" };
const DEFAULT_COLOR = { bg: "rgba(156,163,175,0.10)", border: "#9ca3af", text: "#d1d5db", glow: "rgba(156,163,175,0.25)" };
function EntityNode({ data }: { data: { label: string; type: string; description?: string } }) {
// Mass multipliers by entity type — heavier nodes anchor center, lighter ones orbit outward
const TYPE_MASS: Record<string, number> = {
organization: 8,
project: 6,
person: 4,
concept: 3,
location: 3,
event: 2,
task: 1.5,
};
const DEFAULT_MASS = 2;
/** Compute degree (connection count) for each entity to size nodes by centrality */
function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<string, number> {
const degrees = new Map<string, number>();
const entityIds = new Set(entities.map((e) => e.id));
for (const r of relations) {
if (entityIds.has(r.source_entity_id)) degrees.set(r.source_entity_id, (degrees.get(r.source_entity_id) ?? 0) + 1);
if (entityIds.has(r.target_entity_id)) degrees.set(r.target_entity_id, (degrees.get(r.target_entity_id) ?? 0) + 1);
}
return degrees;
}
function EntityNode({ data }: { data: { label: string; type: string; description?: string; degree: number; selected?: boolean } }) {
const colors = TYPE_COLORS[data.type] || DEFAULT_COLOR;
// Gentler scale: 1.0 → 1.5 max (avoids overlap seen in previous version)
const sizeScale = Math.min(1 + data.degree * 0.08, 1.5);
const isHighDegree = data.degree >= 4;
return (
<>
<Handle type="target" position={Position.Top} className="!bg-transparent !border-0 !w-3 !h-3" />
<div
className="px-3 py-2 rounded-lg shadow-sm border-2 min-w-[80px] max-w-[180px] cursor-grab"
style={{ background: colors.bg, borderColor: colors.border }}
>
<div className="text-xs font-semibold truncate" style={{ color: colors.text }}>
{data.label}
</div>
<div className="text-[10px] opacity-60" style={{ color: colors.text }}>
{data.type}
</div>
<div
className="px-4 py-1.5 cursor-grab"
style={{
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)`,
}}
>
{/* Hidden handles — required by ReactFlow for edge routing, zero visual/interaction cost */}
<Handle type="target" position={Position.Top} style={{ opacity: 0, width: 0, height: 0, pointerEvents: "none" }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0, width: 0, height: 0, pointerEvents: "none" }} />
<div className="text-[11px] font-medium whitespace-nowrap" style={{ color: colors.text }}>
{data.label}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-0 !w-3 !h-3" />
</>
<div className="text-[9px] text-center" style={{ color: colors.text, opacity: 0.5 }}>
{data.type}
</div>
</div>
);
}
@@ -58,16 +92,24 @@ const nodeTypes = { entity: EntityNode };
interface SimNode extends SimulationNodeDatum {
id: string;
mass: number;
}
/** Build ReactFlow nodes + edges with degree-based sizing metadata */
function buildGraph(entities: KGEntity[], relations: KGRelation[]) {
const entityIds = new Set(entities.map((e) => e.id));
const degreeMap = computeDegreeMap(entities, relations);
const nodes: Node[] = entities.map((e) => ({
id: e.id,
type: "entity",
position: { x: 0, y: 0 },
data: { label: e.name, type: e.entity_type, description: e.description },
data: {
label: e.name,
type: e.entity_type,
description: e.description,
degree: degreeMap.get(e.id) ?? 0,
},
}));
const edges: Edge[] = relations
@@ -76,35 +118,47 @@ function buildGraph(entities: KGEntity[], relations: KGRelation[]) {
id: r.id,
source: r.source_entity_id,
target: r.target_entity_id,
label: r.relation_type.replace(/_/g, " "),
// Store label in data for selection reveal — hidden by default to reduce clutter
label: undefined,
data: { relationLabel: r.relation_type.replace(/_/g, " ") },
animated: false,
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
labelStyle: { fontSize: 10, fill: "#64748b" },
labelBgStyle: { fill: "#f8fafc", stroke: "#e2e8f0" },
labelBgPadding: [4, 2] as [number, number],
labelShowBg: true,
type: "default",
style: { stroke: "#334155", strokeWidth: 1, opacity: 0.4 },
}));
return { nodes, edges };
}
/** Run d3-force simulation synchronously and return final positions (no per-tick renders). */
function computeForceLayout(nodes: Node[], edges: Edge[]): Node[] {
/** Run d3-force simulation with mass-based hierarchy for depth layering. */
function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): Node[] {
if (nodes.length === 0) return nodes;
const simNodes: SimNode[] = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y }));
// Build entity type lookup for mass assignment
const entityTypeMap = new Map(entities.map((e) => [e.id, e.entity_type]));
const simNodes: SimNode[] = nodes.map((n) => {
const entityType = entityTypeMap.get(n.id) ?? "";
return {
id: n.id,
x: n.position.x,
y: n.position.y,
mass: TYPE_MASS[entityType] ?? DEFAULT_MASS,
};
});
const simLinks = edges.map((e) => ({ source: e.source, target: e.target }));
const w = 600;
const h = 400;
const simulation = forceSimulation(simNodes)
.force("link", forceLink(simLinks).id((d: any) => d.id).distance(140))
.force("charge", forceManyBody().strength(-350))
.force("link", forceLink(simLinks).id((d: any) => d.id).distance(220).strength(0.4))
// Mass-aware repulsion: heavier nodes push harder → anchor center, lighter orbit out
.force("charge", forceManyBody().strength((d: any) => -300 * (d.mass ?? 1)))
.force("center", forceCenter(w / 2, h / 2))
.force("x", forceX(w / 2).strength(0.05))
.force("y", forceY(h / 2).strength(0.05))
.force("collide", forceCollide(55))
.force("x", forceX(w / 2).strength(0.03))
.force("y", forceY(h / 2).strength(0.03))
// Larger collision radius prevents node overlap
.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)
@@ -134,33 +188,96 @@ export function KGGraphView(props: KGGraphViewProps) {
function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewProps) {
const { t } = useTranslation("memory");
const { fitView } = useReactFlow();
const theme = useUiStore((s) => s.theme);
const colorMode: ColorMode = theme === "system" ? "system" : theme;
// Compute layout synchronously — no per-tick re-renders
const { layoutNodes, layoutEdges } = useMemo(() => {
const { nodes: rawNodes, edges: rawEdges } = buildGraph(entities, relations);
return { layoutNodes: computeForceLayout(rawNodes, rawEdges), layoutEdges: rawEdges };
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [layoutReady, setLayoutReady] = useState(false);
// Build graph data immediately (cheap), defer force layout to next frame (expensive)
const { rawNodes, rawEdges } = useMemo(() => {
const { nodes, edges } = buildGraph(entities, relations);
return { rawNodes: nodes, rawEdges: edges };
}, [entities, relations]);
const [layoutNodes, setLayoutNodes] = useState<Node[]>([]);
const layoutEdges = rawEdges;
// Defer heavy force simulation — setTimeout(0) yields to browser paint first
useEffect(() => {
setLayoutReady(false);
const timer = setTimeout(() => {
const positioned = computeForceLayout(rawNodes, rawEdges, entities);
setLayoutNodes(positioned);
setLayoutReady(true);
}, 0);
return () => clearTimeout(timer);
}, [rawNodes, rawEdges, entities]);
const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges);
// Sync when data changes
// Sync nodes when layout completes
useEffect(() => {
setNodes(layoutNodes);
setEdges(layoutEdges);
requestAnimationFrame(() => fitView({ padding: 0.15, duration: 300 }));
}, [layoutNodes, layoutEdges, setNodes, setEdges, fitView]);
}, [layoutNodes, setNodes, fitView]);
// Sync base edges when data changes
useEffect(() => {
setEdges(layoutEdges);
}, [layoutEdges, setEdges]);
// Apply selection styling via setEdges callback — avoids full edge array rebuild in render
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,
})));
return;
}
const entity = entities.find((ent) => ent.id === 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 } };
return {
...e,
label: (e.data as any)?.relationLabel,
animated: true,
style: { stroke: colors.border, strokeWidth: 2, opacity: 0.9 },
labelStyle: { fontSize: 9, fill: colors.text, fontWeight: 500 },
labelBgStyle: { fill: "rgba(15,23,42,0.85)", stroke: colors.border, rx: 4 },
labelBgPadding: [4, 2] as [number, number],
labelShowBg: true,
};
}));
}, [selectedNodeId, entities, setEdges]);
// useTransition lets React yield to browser paint before processing edge updates
const [, startTransition] = useTransition();
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
startTransition(() => {
setSelectedNodeId((prev) => (prev === node.id ? null : node.id));
});
if (!onEntityClick) return;
const entity = entities.find((e) => e.id === node.id);
if (entity) onEntityClick(entity);
},
[entities, onEntityClick],
[entities, onEntityClick, startTransition],
);
const handlePaneClick = useCallback(() => {
startTransition(() => setSelectedNodeId(null));
}, [startTransition]);
if (entities.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -169,43 +286,68 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro
);
}
if (!layoutReady) {
return (
<div className="flex h-full items-center justify-center" style={{ background: "#0a0a12" }}>
<div className="text-sm" style={{ color: "#64748b" }}>{t("kg.graphView.loading")}</div>
</div>
);
}
return (
<div className="flex h-full flex-col rounded-md border bg-background">
<div className="min-h-0 flex-1">
<div className="flex h-full flex-col rounded-md border overflow-hidden" style={{ position: "relative" }}>
{/* Dark canvas with atmospheric depth gradient — center glow like GitNexus */}
<div
className="pointer-events-none absolute inset-0 z-0"
style={{
background: "radial-gradient(ellipse at 50% 45%, rgba(124,58,237,0.06) 0%, rgba(30,41,59,0.02) 50%, transparent 80%), linear-gradient(to bottom, #0a0a12, #0f1120, #0a0a12)",
}}
/>
<div className="min-h-0 flex-1 relative z-10">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={nodeTypes}
colorMode={colorMode}
colorMode="dark"
fitView
minZoom={0.1}
maxZoom={3}
nodesConnectable={false}
nodesDraggable={true}
elementsSelectable={true}
proOptions={{ hideAttribution: true }}
>
<Background gap={20} size={1} />
<Background gap={30} size={0.5} color="#1a1a2e" />
<Controls showInteractive={false} />
<MiniMap
nodeColor={(n) => {
const type = (n.data as any)?.type as string;
return (TYPE_COLORS[type] || DEFAULT_COLOR).border;
}}
maskColor="rgba(0,0,0,0.1)"
style={{ width: 100, height: 75 }}
maskColor="rgba(0,0,0,0.4)"
style={{ width: 100, height: 75, background: "#0f1120" }}
/>
</ReactFlow>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 px-3 py-2 border-t text-[10px]">
{/* Legend + selected node info — dark bar */}
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-t border-slate-800 text-[10px] relative z-10" style={{ background: "#0d0d18" }}>
{Object.entries(TYPE_COLORS).map(([type, colors]) => (
<div key={type} className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: colors.border }} />
<span className="text-muted-foreground">{type}</span>
<div className="w-2 h-2 rounded-full" style={{ background: colors.border, boxShadow: `0 0 4px ${colors.glow}` }} />
<span style={{ color: "#64748b" }}>{type}</span>
</div>
))}
{selectedNodeId && (
<div className="ml-auto animate-in fade-in" style={{ color: "#94a3b8" }}>
{t("kg.graphView.selected", { name: entities.find((e) => e.id === selectedNodeId)?.name ?? "" })}
</div>
)}
</div>
</div>
);