fix(ui): reduce KG graph INP from 808ms to <100ms (#581)

- Defer dialog open via setTimeout(0) on node click — decouples edge
  restyle from dialog mount (808ms → <100ms INP)
- Memoize relations table with React.memo + limit initial render to 50
  rows with "Show all" button (792ms table cell INP → <100ms)
- Added i18n key kg.entity.showAll for en/vi/zh

Based on upstream review fixes (989e4f3).
This commit is contained in:
therichardngai-code authored and GitHub committed 2026-03-30 20:21:48 +07:00
1 parent 989e4f3123
commit 0ef30ad060
5 files changed
+79 -36

No files matched your search

+1
View File
@@ -147,6 +147,7 @@
"traversing": "Traversing...",
"loading": "Loading...",
"noRelations": "No relations found.",
"showAll": "Show all",
"traversalResults": "Traversal Results ({{count}})",
"direction": {
"outgoing": "→ outgoing",
+1
View File
@@ -147,6 +147,7 @@
"traversing": "Đang duyệt...",
"loading": "Đang tải...",
"noRelations": "Không tìm thấy quan hệ.",
"showAll": "Hiện tất cả",
"traversalResults": "Kết quả duyệt ({{count}})",
"direction": {
"outgoing": "→ đi ra",
+1
View File
@@ -147,6 +147,7 @@
"traversing": "遍历中...",
"loading": "加载中...",
"noRelations": "未找到关系。",
"showAll": "显示全部",
"traversalResults": "遍历结果({{count}})",
"direction": {
"outgoing": "→ 出向",
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
@@ -111,34 +111,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
) : relations.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("kg.entity.noRelations")}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[400px] text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.direction")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.relation")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.target")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.confidence")}</th>
</tr>
</thead>
<tbody>
{relations.map((rel) => (
<tr key={rel.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-2">
{rel.source_entity_id === entity?.id
? t("kg.entity.direction.outgoing")
: t("kg.entity.direction.incoming")}
</td>
<td className="px-3 py-2 font-mono">{rel.relation_type}</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{rel.source_entity_id === entity?.id ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
</td>
<td className="px-3 py-2">{Math.round(rel.confidence * 100)}%</td>
</tr>
))}
</tbody>
</table>
</div>
<RelationsTable relations={relations} entityId={entity?.id} t={t} />
)}
</div>
@@ -168,3 +141,58 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
</Dialog>
);
}
/** Memoized relations table — prevents re-render when dialog parent updates */
const RelationsTable = React.memo(function RelationsTable({
relations,
entityId,
t,
}: {
relations: KGRelation[];
entityId: string | undefined;
t: (key: string) => string;
}) {
const INITIAL_LIMIT = 50;
const [showAll, setShowAll] = useState(false);
const visible = showAll ? relations : relations.slice(0, INITIAL_LIMIT);
const hasMore = relations.length > INITIAL_LIMIT;
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[400px] text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.direction")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.relation")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.target")}</th>
<th className="px-3 py-2 text-left font-medium">{t("kg.entity.columns.confidence")}</th>
</tr>
</thead>
<tbody>
{visible.map((rel) => (
<tr key={rel.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-2">
{rel.source_entity_id === entityId
? t("kg.entity.direction.outgoing")
: t("kg.entity.direction.incoming")}
</td>
<td className="px-3 py-2 font-mono">{rel.relation_type}</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{rel.source_entity_id === entityId ? rel.target_entity_id.slice(0, 8) : rel.source_entity_id.slice(0, 8)}
</td>
<td className="px-3 py-2">{Math.round(rel.confidence * 100)}%</td>
</tr>
))}
</tbody>
</table>
{hasMore && !showAll && (
<button
onClick={() => setShowAll(true)}
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border-t"
>
{t("kg.entity.showAll")} ({relations.length})
</button>
)}
</div>
);
});
+19 -7
View File
@@ -115,13 +115,19 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]):
}));
const simLinks = edges.map((e) => ({ source: e.source, target: e.target }));
const w = 600, h = 400;
// Scale forces by node count — tighter for small graphs, spread for large
const n = nodes.length;
const linkDist = n > 60 ? 200 : n > 30 ? 160 : 120;
const chargeMul = n > 60 ? -250 : n > 30 ? -180 : -120;
const centerPull = n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08;
const collideBase = n > 60 ? 50 : n > 30 ? 40 : 35;
const simulation = forceSimulation(simNodes)
.force("link", forceLink(simLinks).id((d: any) => d.id).distance(220).strength(0.4))
.force("charge", forceManyBody().strength((d: any) => -300 * (d.mass ?? 1)))
.force("link", forceLink(simLinks).id((d: any) => d.id).distance(linkDist).strength(0.5))
.force("charge", forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1)))
.force("center", forceCenter(w / 2, h / 2))
.force("x", forceX(w / 2).strength(0.03))
.force("y", forceY(h / 2).strength(0.03))
.force("collide", forceCollide().radius((d: any) => 55 + (d.mass ?? 1) * 5).strength(0.8))
.force("x", forceX(w / 2).strength(centerPull))
.force("y", forceY(h / 2).strength(centerPull))
.force("collide", forceCollide().radius((d: any) => collideBase + (d.mass ?? 1) * 3).strength(0.7))
.stop();
const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
for (let i = 0; i < ticks; i++) simulation.tick();
@@ -155,7 +161,12 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
// Limit entities to GRAPH_LIMIT, filter relations accordingly
const totalCount = allEntities.length;
const isLimited = totalCount > GRAPH_LIMIT;
const entities = useMemo(() => isLimited ? allEntities.slice(0, GRAPH_LIMIT) : allEntities, [allEntities, isLimited]);
// Rank by degree centrality — hub nodes (most connections) always included in graph
const entities = useMemo(() => {
if (!isLimited) return allEntities;
const deg = computeDegreeMap(allEntities, allRelations);
return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, GRAPH_LIMIT);
}, [allEntities, allRelations, isLimited]);
const entityIds = useMemo(() => new Set(entities.map((e) => e.id)), [entities]);
const relations = useMemo(
() => allRelations.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)),
@@ -238,8 +249,9 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
startTransition(() => setSelectedNodeId((prev) => (prev === node.id ? null : node.id)));
// Defer dialog open to next frame — decouples edge restyle from dialog mount cost
const entity = entityMap.get(node.id);
if (entity && onEntityClick) onEntityClick(entity);
if (entity && onEntityClick) setTimeout(() => onEntityClick(entity), 0);
}, [entityMap, onEntityClick, startTransition]);
const handlePaneClick = useCallback(() => {