Skip to content

Commit 9987a4e

Browse files
authored
Merge pull request DeusData#740 from rarepops/feat/graph-ui-routing-and-filter-ux
feat(graph-ui): URL routing, filter panel UX, and camera fly-to fix
2 parents 7884ccb + c751240 commit 9987a4e

6 files changed

Lines changed: 174 additions & 87 deletions

File tree

graph-ui/src/App.tsx

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,62 @@
1-
import { useState } from "react";
1+
import { useCallback, useEffect, useState } from "react";
22
import { GraphTab } from "./components/GraphTab";
33
import { StatsTab } from "./components/StatsTab";
44
import { ControlTab } from "./components/ControlTab";
55
import type { TabId } from "./lib/types";
66
import { useUiMessages } from "./lib/i18n";
77

8+
const TAB_IDS: TabId[] = ["graph", "stats", "control"];
9+
10+
interface RouteState {
11+
tab: TabId;
12+
project: string | null;
13+
}
14+
15+
/* Read the active tab + selected project from the URL query string so the
16+
* current view survives refreshes and can be bookmarked or shared. */
17+
function readRoute(): RouteState {
18+
const params = new URLSearchParams(window.location.search);
19+
const rawTab = params.get("tab");
20+
const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats";
21+
const project = params.get("project");
22+
return { tab, project: project ? project : null };
23+
}
24+
25+
/* Build the canonical URL for a route, preserving the path and hash. */
26+
function routeUrl(tab: TabId, project: string | null): string {
27+
const params = new URLSearchParams();
28+
params.set("tab", tab);
29+
if (project) params.set("project", project);
30+
return `${window.location.pathname}?${params.toString()}${window.location.hash}`;
31+
}
32+
833
export function App() {
934
const t = useUiMessages();
10-
const [activeTab, setActiveTab] = useState<TabId>("stats");
11-
const [selectedProject, setSelectedProject] = useState<string | null>(null);
35+
const [route, setRoute] = useState<RouteState>(readRoute);
36+
const { tab: activeTab, project: selectedProject } = route;
37+
38+
/* Normalize the URL on first load so it always carries the current route. */
39+
useEffect(() => {
40+
const initial = readRoute();
41+
window.history.replaceState(null, "", routeUrl(initial.tab, initial.project));
42+
}, []);
43+
44+
/* Sync state when the user navigates with the browser back/forward buttons. */
45+
useEffect(() => {
46+
const onPopState = () => setRoute(readRoute());
47+
window.addEventListener("popstate", onPopState);
48+
return () => window.removeEventListener("popstate", onPopState);
49+
}, []);
50+
51+
/* Change the route and push a history entry (skips no-op navigations). */
52+
const navigate = useCallback((tab: TabId, project: string | null) => {
53+
const url = routeUrl(tab, project);
54+
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
55+
if (url === current) return;
56+
window.history.pushState(null, "", url);
57+
setRoute({ tab, project });
58+
}, []);
59+
1260
const tabs: { id: TabId; label: string }[] = [
1361
{ id: "graph", label: t.tabs.graph },
1462
{ id: "stats", label: t.tabs.projects },
@@ -29,19 +77,26 @@ export function App() {
2977

3078
{/* Tabs inline in header */}
3179
<nav className="flex items-center gap-0.5">
32-
{tabs.map((tab) => (
33-
<button
34-
key={tab.id}
35-
onClick={() => setActiveTab(tab.id)}
36-
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
37-
activeTab === tab.id
38-
? "bg-primary/15 text-primary"
39-
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
40-
}`}
41-
>
42-
{tab.label}
43-
</button>
44-
))}
80+
{tabs.map((tab) => {
81+
const disabled = tab.id === "graph" && !selectedProject;
82+
return (
83+
<button
84+
key={tab.id}
85+
onClick={() => navigate(tab.id, tab.id === "stats" ? null : selectedProject)}
86+
disabled={disabled}
87+
title={disabled ? "Select a project first" : undefined}
88+
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
89+
disabled
90+
? "text-muted-foreground/30 cursor-not-allowed"
91+
: activeTab === tab.id
92+
? "bg-primary/15 text-primary"
93+
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
94+
}`}
95+
>
96+
{tab.label}
97+
</button>
98+
);
99+
})}
45100
</nav>
46101
</div>
47102

@@ -54,7 +109,7 @@ export function App() {
54109
{selectedProject}
55110
</span>
56111
<button
57-
onClick={() => { setSelectedProject(null); setActiveTab("stats"); }}
112+
onClick={() => navigate("stats", null)}
58113
className="text-foreground/20 hover:text-foreground/50 text-[12px] ml-1 transition-colors"
59114
>
60115
×
@@ -71,10 +126,7 @@ export function App() {
71126
<ControlTab />
72127
) : (
73128
<StatsTab
74-
onSelectProject={(p) => {
75-
setSelectedProject(p);
76-
setActiveTab("graph");
77-
}}
129+
onSelectProject={(p) => navigate("graph", p)}
78130
/>
79131
)}
80132
</main>
Lines changed: 72 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useMemo } from "react";
2+
import { ScrollArea } from "@/components/ui/scroll-area";
23
import { colorForLabel } from "../lib/colors";
34
import type { GraphData } from "../lib/types";
45

@@ -37,9 +38,9 @@ export function FilterPanel({
3738
}, [data]);
3839

3940
return (
40-
<div className="px-4 py-3 border-b border-border/40 space-y-3">
41-
{/* Header row */}
42-
<div className="flex items-center justify-between">
41+
<div className="flex flex-col shrink-0 max-h-[45%] border-b border-border/40">
42+
{/* Header row — always visible */}
43+
<div className="flex items-center justify-between px-4 pt-3 pb-2 shrink-0">
4344
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
4445
Filters
4546
</span>
@@ -50,66 +51,77 @@ export function FilterPanel({
5051
</div>
5152
</div>
5253

53-
{/* Node labels */}
54-
<div>
55-
<p className="text-[10px] text-foreground/30 mb-1.5">Nodes</p>
56-
<div className="flex flex-wrap gap-1">
57-
{labelCounts.map(([label, count]) => {
58-
const on = enabledLabels.has(label);
59-
const c = colorForLabel(label);
60-
return (
61-
<button
62-
key={label}
63-
onClick={() => onToggleLabel(label)}
64-
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
65-
on ? "border-white/[0.08] bg-white/[0.04]" : "border-transparent opacity-25"
66-
}`}
67-
>
68-
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
69-
<span style={{ color: on ? c : "#555" }}>{label}</span>
70-
<span className="text-foreground/20 tabular-nums">{count.toLocaleString()}</span>
71-
</button>
72-
);
73-
})}
74-
</div>
75-
</div>
54+
{/* Scrollable filter groups */}
55+
<ScrollArea className="flex-1 min-h-0">
56+
<div className="px-4 pb-3 space-y-3">
57+
{/* Node types */}
58+
{labelCounts.length > 0 && (
59+
<div>
60+
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Node types</p>
61+
<div className="flex flex-wrap gap-1">
62+
{labelCounts.map(([label, count]) => {
63+
const on = enabledLabels.has(label);
64+
const c = colorForLabel(label);
65+
return (
66+
<button
67+
key={label}
68+
onClick={() => onToggleLabel(label)}
69+
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
70+
on ? "border-white/[0.08] bg-white/[0.04]" : "border-transparent opacity-25"
71+
}`}
72+
>
73+
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
74+
<span style={{ color: on ? c : "#555" }}>{label}</span>
75+
<span className="text-foreground/20 tabular-nums">{count.toLocaleString()}</span>
76+
</button>
77+
);
78+
})}
79+
</div>
80+
</div>
81+
)}
7682

77-
{/* Edge types */}
78-
<div>
79-
<p className="text-[10px] text-foreground/30 mb-1.5">Edges</p>
80-
<div className="flex flex-wrap gap-1">
81-
{edgeTypeCounts.map(([type, count]) => {
82-
const on = enabledEdgeTypes.has(type);
83-
return (
84-
<button
85-
key={type}
86-
onClick={() => onToggleEdgeType(type)}
87-
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
88-
on ? "border-white/[0.06] bg-white/[0.03] text-foreground/60" : "border-transparent opacity-20 text-foreground/30"
89-
}`}
90-
>
91-
{type.replace(/_/g, " ").toLowerCase()}
92-
<span className="text-foreground/15 tabular-nums">{count.toLocaleString()}</span>
93-
</button>
94-
);
95-
})}
83+
{/* Relationships */}
84+
{edgeTypeCounts.length > 0 && (
85+
<div>
86+
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Relationships</p>
87+
<div className="flex flex-wrap gap-1">
88+
{edgeTypeCounts.map(([type, count]) => {
89+
const on = enabledEdgeTypes.has(type);
90+
return (
91+
<button
92+
key={type}
93+
onClick={() => onToggleEdgeType(type)}
94+
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
95+
on ? "border-white/[0.06] bg-white/[0.03] text-foreground/60" : "border-transparent opacity-20 text-foreground/30"
96+
}`}
97+
>
98+
{type.replace(/_/g, " ").toLowerCase()}
99+
<span className="text-foreground/15 tabular-nums">{count.toLocaleString()}</span>
100+
</button>
101+
);
102+
})}
103+
</div>
104+
</div>
105+
)}
96106
</div>
97-
</div>
107+
</ScrollArea>
98108

99-
{/* Show labels toggle */}
100-
<button
101-
onClick={onToggleShowLabels}
102-
className={`inline-flex items-center gap-1.5 text-[11px] font-medium transition-all ${
103-
showLabels ? "text-primary" : "text-foreground/30"
104-
}`}
105-
>
106-
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
107-
showLabels ? "border-primary bg-primary/20" : "border-foreground/15"
108-
}`}>
109-
{showLabels && <span className="text-primary text-[9px]"></span>}
110-
</span>
111-
Show labels
112-
</button>
109+
{/* Display options — pinned footer */}
110+
<div className="px-4 py-2.5 border-t border-border/20 shrink-0">
111+
<button
112+
onClick={onToggleShowLabels}
113+
className={`inline-flex items-center gap-1.5 text-[11px] font-medium transition-all ${
114+
showLabels ? "text-primary" : "text-foreground/30"
115+
}`}
116+
>
117+
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
118+
showLabels ? "border-primary bg-primary/20" : "border-foreground/15"
119+
}`}>
120+
{showLabels && <span className="text-primary text-[9px]"></span>}
121+
</span>
122+
Show labels
123+
</button>
124+
</div>
113125
</div>
114126
);
115127
}

graph-ui/src/components/GraphScene.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ interface CameraTarget {
1717
lookAt: THREE.Vector3;
1818
}
1919

20-
function CameraAnimator({ target }: { target: CameraTarget | null }) {
20+
function CameraAnimator({
21+
target,
22+
controlsRef,
23+
}: {
24+
target: CameraTarget | null;
25+
controlsRef: React.RefObject<OrbitControlsImpl | null>;
26+
}) {
2127
const { camera } = useThree();
2228
const targetRef = useRef<CameraTarget | null>(null);
2329
const progress = useRef(1);
@@ -36,7 +42,17 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) {
3642
const t = 1 - Math.pow(1 - progress.current, 3); /* ease-out cubic */
3743

3844
camera.position.lerp(targetRef.current.position, t * 0.08);
39-
camera.lookAt(targetRef.current.lookAt);
45+
46+
/* Move the OrbitControls pivot to the focus point as well. Otherwise the
47+
* controls keep their target at the origin and re-center the view on the
48+
* next frame, snapping the camera back to the middle after the fly-to. */
49+
const controls = controlsRef.current;
50+
if (controls) {
51+
controls.target.lerp(targetRef.current.lookAt, t * 0.08);
52+
controls.update();
53+
} else {
54+
camera.lookAt(targetRef.current.lookAt);
55+
}
4056
});
4157

4258
return null;
@@ -179,7 +195,7 @@ export function GraphScene({
179195

180196
{hovered && <NodeTooltip node={hovered} />}
181197

182-
<CameraAnimator target={cameraTarget} />
198+
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} />
183199
<IdleAutoRotate controlsRef={controlsRef} />
184200

185201
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>

graph-ui/src/components/GraphTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export function GraphTab({ project }: GraphTabProps) {
181181
return (
182182
<div className="flex items-center justify-center h-full">
183183
<p className="text-white/30 text-sm">
184-
Select a project from the Stats tab
184+
Select a project from the Projects tab
185185
</p>
186186
</div>
187187
);
@@ -312,7 +312,7 @@ export function GraphTab({ project }: GraphTabProps) {
312312
setCameraTarget(null);
313313
}}
314314
>
315-
Clear
315+
Clear selection
316316
</Button>
317317
)}
318318
<Button

graph-ui/src/components/Sidebar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
120120

121121
return (
122122
<div className="flex flex-col flex-1 min-h-0">
123-
<div className="px-3 py-2.5 border-b border-border/30">
123+
<div className="px-4 pt-3 pb-2 shrink-0">
124+
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
125+
{t.graph.folders}
126+
</span>
127+
</div>
128+
<div className="px-3 pb-2.5 border-b border-border/30 shrink-0">
124129
<div className="relative">
125130
<input
126131
type="text"

graph-ui/src/lib/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const messages = {
2323
selectedLabel: "Graph",
2424
search: "Search...",
2525
clearSelection: "Clear selection",
26+
folders: "Folders",
2627
},
2728
projects: {
2829
indexedProjects: "Indexed Projects",
@@ -95,6 +96,7 @@ export const messages = {
9596
selectedLabel: "图谱",
9697
search: "搜索...",
9798
clearSelection: "清除选择",
99+
folders: "目录",
98100
},
99101
projects: {
100102
indexedProjects: "已索引项目",

0 commit comments

Comments
 (0)