diff --git a/ui/web/src/i18n/locales/en/memory.json b/ui/web/src/i18n/locales/en/memory.json index ab191f73..b6e7af04 100644 --- a/ui/web/src/i18n/locales/en/memory.json +++ b/ui/web/src/i18n/locales/en/memory.json @@ -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": { diff --git a/ui/web/src/i18n/locales/vi/memory.json b/ui/web/src/i18n/locales/vi/memory.json index 76500539..fe5c400a 100644 --- a/ui/web/src/i18n/locales/vi/memory.json +++ b/ui/web/src/i18n/locales/vi/memory.json @@ -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": { diff --git a/ui/web/src/i18n/locales/zh/memory.json b/ui/web/src/i18n/locales/zh/memory.json index c663e6f0..b27eb311 100644 --- a/ui/web/src/i18n/locales/zh/memory.json +++ b/ui/web/src/i18n/locales/zh/memory.json @@ -175,7 +175,8 @@ "loading": "正在加载图谱...", "empty": "没有可视化的实体", "nodes": "{{count}} 个节点", - "edges": "{{count}} 条边" + "edges": "{{count}} 条边", + "selected": "已选择: {{name}}" } }, "toast": { diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index 69e3c1ee..7f9efb25 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 } 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 = { - 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 = { + 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 = { + 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 { + const degrees = new Map(); + 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 ( - <> - -
-
- {data.label} -
-
- {data.type} -
+
+ {/* Hidden handles — required by ReactFlow for edge routing, zero visual/interaction cost */} + + +
+ {data.label}
- - +
+ {data.type} +
+
); } @@ -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(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([]); + 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 (
@@ -169,43 +286,68 @@ function KGGraphViewInner({ entities, relations, onEntityClick }: KGGraphViewPro ); } + if (!layoutReady) { + return ( +
+
{t("kg.graphView.loading")}
+
+ ); + } + return ( -
-
+
+ {/* Dark canvas with atmospheric depth gradient — center glow like GitNexus */} +
+ +
- + { 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" }} />
- {/* Legend */} -
+ {/* Legend + selected node info — dark bar */} +
{Object.entries(TYPE_COLORS).map(([type, colors]) => (
-
- {type} +
+ {type}
))} + {selectedNodeId && ( +
+ {t("kg.graphView.selected", { name: entities.find((e) => e.id === selectedNodeId)?.name ?? "" })} +
+ )}
);