@@ -11,6 +11,7 @@ import type { BlogDetail, GraphData, GraphNode } from "../types/graph";
1111
1212const DEFAULT_GRAPH_LIMIT = 200 ;
1313const ESTIMATED_RENDER_TICKS_PER_SECOND = 60 ;
14+ type GraphDisplayMode = "compact" | "full" ;
1415
1516/**
1617 * Format a force-layout tick estimate as an approximate render duration.
@@ -23,6 +24,39 @@ function formatEstimatedRenderTime(ticks: number): string {
2324 return `约 ${ seconds } 秒` ;
2425}
2526
27+ /**
28+ * Keep only graph nodes connected to at least two distinct other nodes.
29+ *
30+ * @param graph Raw graph returned by the backend.
31+ * @returns Compact graph with filtered nodes and only edges between kept nodes.
32+ */
33+ export function compactGraphData ( graph : GraphData ) : GraphData {
34+ const neighborIdsByNodeId = new Map < number , Set < number > > ( ) ;
35+ for ( const node of graph . nodes ) {
36+ neighborIdsByNodeId . set ( node . id , new Set ( ) ) ;
37+ }
38+
39+ for ( const edge of graph . edges ) {
40+ if ( ! neighborIdsByNodeId . has ( edge . source ) || ! neighborIdsByNodeId . has ( edge . target ) || edge . source === edge . target ) {
41+ continue ;
42+ }
43+ neighborIdsByNodeId . get ( edge . source ) ?. add ( edge . target ) ;
44+ neighborIdsByNodeId . get ( edge . target ) ?. add ( edge . source ) ;
45+ }
46+
47+ const keptNodeIds = new Set (
48+ Array . from ( neighborIdsByNodeId . entries ( ) )
49+ . filter ( ( [ , neighborIds ] ) => neighborIds . size >= 2 )
50+ . map ( ( [ nodeId ] ) => nodeId ) ,
51+ ) ;
52+
53+ return {
54+ ...graph ,
55+ nodes : graph . nodes . filter ( ( node ) => keptNodeIds . has ( node . id ) ) ,
56+ edges : graph . edges . filter ( ( edge ) => keptNodeIds . has ( edge . source ) && keptNodeIds . has ( edge . target ) ) ,
57+ } ;
58+ }
59+
2660/**
2761 * Render the dedicated graph exploration route.
2862 *
@@ -42,7 +76,12 @@ export function VisualizationPage() {
4276 const [ maxGraphLimit , setMaxGraphLimit ] = useState ( 0 ) ;
4377 const [ pendingLimit , setPendingLimit ] = useState ( DEFAULT_GRAPH_LIMIT ) ;
4478 const [ selectedLimit , setSelectedLimit ] = useState < number | null > ( null ) ;
79+ const [ graphDisplayMode , setGraphDisplayMode ] = useState < GraphDisplayMode > ( "compact" ) ;
4580 const [ highlightNodeId , setHighlightNodeId ] = useState < number | undefined > ( ) ;
81+ const visibleGraphData = useMemo (
82+ ( ) => ( graphDisplayMode === "compact" ? compactGraphData ( graphData ) : graphData ) ,
83+ [ graphData , graphDisplayMode ] ,
84+ ) ;
4685 const shouldShowProgressOverlay = isLoading || isRendering ;
4786 const progressPercent = useMemo ( ( ) => {
4887 const loadingFloor = isLoading ? 0.08 : 0 ;
@@ -100,6 +139,7 @@ export function VisualizationPage() {
100139 * @returns Promise resolved after benchmark graph state updates.
101140 */
102141 async function loadBenchmarkGraph ( ) {
142+ setGraphDisplayMode ( "full" ) ;
103143 setSelectedLimit ( 100 ) ;
104144 setPendingLimit ( 100 ) ;
105145 setMaxGraphLimit ( 100 ) ;
@@ -167,7 +207,7 @@ export function VisualizationPage() {
167207 */
168208 async function openBlog ( blogId : number , options : { loadNeighborhood : boolean } ) {
169209 if ( isBenchmarkMode ) {
170- const node = graphData . nodes . find ( ( item ) => item . id === blogId ) ;
210+ const node = visibleGraphData . nodes . find ( ( item ) => item . id === blogId ) ;
171211 if ( ! node ) {
172212 return ;
173213 }
@@ -223,7 +263,7 @@ export function VisualizationPage() {
223263
224264 < div className = "relative min-h-0 flex-1" >
225265 < GraphVisualization
226- data = { graphData }
266+ data = { visibleGraphData }
227267 onNodeClick = { handleNodeClick }
228268 highlightNodeId = { highlightNodeId }
229269 useNodeIcons = { ! isBenchmarkMode }
@@ -261,6 +301,28 @@ export function VisualizationPage() {
261301 < div className = "text-sm text-slate-500" > 节点数量</ div >
262302 < div className = "text-3xl font-semibold tabular-nums text-slate-950" > { pendingLimit } </ div >
263303 </ div >
304+ < div className = "mt-5 grid grid-cols-2 overflow-hidden rounded-lg border border-slate-200 bg-slate-50 p-1" >
305+ < button
306+ type = "button"
307+ onClick = { ( ) => setGraphDisplayMode ( "compact" ) }
308+ className = { `rounded-md px-3 py-2 text-sm font-medium transition-colors ${
309+ graphDisplayMode === "compact" ? "bg-slate-950 text-white shadow-sm" : "text-slate-600 hover:bg-white"
310+ } `}
311+ aria-pressed = { graphDisplayMode === "compact" }
312+ >
313+ 精简
314+ </ button >
315+ < button
316+ type = "button"
317+ onClick = { ( ) => setGraphDisplayMode ( "full" ) }
318+ className = { `rounded-md px-3 py-2 text-sm font-medium transition-colors ${
319+ graphDisplayMode === "full" ? "bg-slate-950 text-white shadow-sm" : "text-slate-600 hover:bg-white"
320+ } `}
321+ aria-pressed = { graphDisplayMode === "full" }
322+ >
323+ 全
324+ </ button >
325+ </ div >
264326 < input
265327 type = "range"
266328 min = { 0 }
0 commit comments