Skip to content

Commit 8482837

Browse files
committed
✨ feat: 可视化新增“精简”模式
1 parent 4856a02 commit 8482837

2 files changed

Lines changed: 147 additions & 6 deletions

File tree

frontend/src/App.test.tsx

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,67 @@ beforeEach(() => {
194194
domain: "graph.example.com",
195195
title: "Graph Example",
196196
icon_url: null,
197+
incoming_count: 2,
198+
outgoing_count: 1,
199+
},
200+
{
201+
id: 2,
202+
url: "https://two.example.com/",
203+
domain: "two.example.com",
204+
title: "Two Example",
205+
icon_url: null,
206+
incoming_count: 1,
207+
outgoing_count: 1,
208+
},
209+
{
210+
id: 3,
211+
url: "https://three.example.com/",
212+
domain: "three.example.com",
213+
title: "Three Example",
214+
icon_url: null,
215+
incoming_count: 1,
216+
outgoing_count: 1,
217+
},
218+
{
219+
id: 4,
220+
url: "https://leaf.example.com/",
221+
domain: "leaf.example.com",
222+
title: "Leaf Example",
223+
icon_url: null,
197224
incoming_count: 0,
198-
outgoing_count: 0,
225+
outgoing_count: 1,
226+
},
227+
],
228+
edges: [
229+
{
230+
id: "edge-1-2",
231+
from_blog_id: 1,
232+
to_blog_id: 2,
233+
link_text: null,
234+
link_url_raw: "https://two.example.com/",
235+
},
236+
{
237+
id: "edge-2-3",
238+
from_blog_id: 2,
239+
to_blog_id: 3,
240+
link_text: null,
241+
link_url_raw: "https://three.example.com/",
242+
},
243+
{
244+
id: "edge-3-1",
245+
from_blog_id: 3,
246+
to_blog_id: 1,
247+
link_text: null,
248+
link_url_raw: "https://graph.example.com/",
249+
},
250+
{
251+
id: "edge-1-4",
252+
from_blog_id: 1,
253+
to_blog_id: 4,
254+
link_text: null,
255+
link_url_raw: "https://leaf.example.com/",
199256
},
200257
],
201-
edges: [],
202258
meta: {
203259
strategy: "degree",
204260
limit: 200,
@@ -349,6 +405,8 @@ test("lets visualization users choose a graph size with a blog-count slider", as
349405
expect(screen.queryByText(//)).not.toBeInTheDocument();
350406
expect(screen.queryByText("该功能仍不成熟!")).not.toBeInTheDocument();
351407
expect(screen.queryByText("数据统计")).not.toBeInTheDocument();
408+
expect(screen.getByRole("button", { name: "精简" })).toHaveAttribute("aria-pressed", "true");
409+
expect(screen.getByRole("button", { name: "全" })).toHaveAttribute("aria-pressed", "false");
352410

353411
fireEvent.change(slider, { target: { value: "20" } });
354412
expect(slider).toHaveValue("20");
@@ -362,9 +420,13 @@ test("lets visualization users choose a graph size with a blog-count slider", as
362420
expect.stringContaining("/api/graph/views/core?strategy=seed&limit=20"),
363421
expect.anything(),
364422
);
423+
expect(forceGraphProps.at(-1)!.graphData.nodes.map((node: { id: string }) => node.id)).toEqual(["1", "2", "3"]);
424+
expect(forceGraphProps.at(-1)!.graphData.links).toHaveLength(3);
365425
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "12");
366-
expect(screen.getByText("预计需要 120 ticks")).toBeInTheDocument();
367-
expect(screen.getByText("预估所需渲染时间:约 2 秒")).toBeInTheDocument();
426+
await waitFor(() => {
427+
expect(screen.getByText("预计需要 126 ticks")).toBeInTheDocument();
428+
});
429+
expect(screen.getByText("预估所需渲染时间:约 3 秒")).toBeInTheDocument();
368430
act(() => {
369431
forceGraphProps.at(-1)!.onEngineTick();
370432
forceGraphProps.at(-1)!.onEngineTick();
@@ -382,6 +444,23 @@ test("lets visualization users choose a graph size with a blog-count slider", as
382444
expect(screen.queryByRole("button", { name: //i })).not.toBeInTheDocument();
383445
});
384446

447+
test("lets visualization users load the full graph without compact filtering", async () => {
448+
window.history.replaceState({}, "", "/visualization");
449+
450+
render(<App />);
451+
452+
const fullButton = await screen.findByRole("button", { name: "全" });
453+
fireEvent.click(fullButton);
454+
expect(fullButton).toHaveAttribute("aria-pressed", "true");
455+
456+
fireEvent.click(screen.getByRole("button", { name: "确认" }));
457+
458+
await waitFor(() => {
459+
expect(forceGraphProps.at(-1)!.graphData.nodes).toHaveLength(4);
460+
});
461+
expect(forceGraphProps.at(-1)!.graphData.links).toHaveLength(4);
462+
});
463+
385464
test("ignores stale cached visualization graph data and reloads sampled sizes online", async () => {
386465
window.history.replaceState({}, "", "/visualization");
387466
window.localStorage.setItem(

frontend/src/pages/VisualizationPage.tsx

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { BlogDetail, GraphData, GraphNode } from "../types/graph";
1111

1212
const DEFAULT_GRAPH_LIMIT = 200;
1313
const 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

Comments
 (0)