Merge pull request #740 from rarepops/feat/graph-ui-routing-and-filter-ux

feat(graph-ui): URL routing, filter panel UX, and camera fly-to fix
This commit is contained in:
Martin Vogel
2026-07-04 21:36:29 +02:00
committed by GitHub
6 changed files with 174 additions and 87 deletions
+73 -21
View File
@@ -1,14 +1,62 @@
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { GraphTab } from "./components/GraphTab";
import { StatsTab } from "./components/StatsTab";
import { ControlTab } from "./components/ControlTab";
import type { TabId } from "./lib/types";
import { useUiMessages } from "./lib/i18n";
const TAB_IDS: TabId[] = ["graph", "stats", "control"];
interface RouteState {
tab: TabId;
project: string | null;
}
/* Read the active tab + selected project from the URL query string so the
* current view survives refreshes and can be bookmarked or shared. */
function readRoute(): RouteState {
const params = new URLSearchParams(window.location.search);
const rawTab = params.get("tab");
const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats";
const project = params.get("project");
return { tab, project: project ? project : null };
}
/* Build the canonical URL for a route, preserving the path and hash. */
function routeUrl(tab: TabId, project: string | null): string {
const params = new URLSearchParams();
params.set("tab", tab);
if (project) params.set("project", project);
return `${window.location.pathname}?${params.toString()}${window.location.hash}`;
}
export function App() {
const t = useUiMessages();
const [activeTab, setActiveTab] = useState<TabId>("stats");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [route, setRoute] = useState<RouteState>(readRoute);
const { tab: activeTab, project: selectedProject } = route;
/* Normalize the URL on first load so it always carries the current route. */
useEffect(() => {
const initial = readRoute();
window.history.replaceState(null, "", routeUrl(initial.tab, initial.project));
}, []);
/* Sync state when the user navigates with the browser back/forward buttons. */
useEffect(() => {
const onPopState = () => setRoute(readRoute());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
/* Change the route and push a history entry (skips no-op navigations). */
const navigate = useCallback((tab: TabId, project: string | null) => {
const url = routeUrl(tab, project);
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (url === current) return;
window.history.pushState(null, "", url);
setRoute({ tab, project });
}, []);
const tabs: { id: TabId; label: string }[] = [
{ id: "graph", label: t.tabs.graph },
{ id: "stats", label: t.tabs.projects },
@@ -29,19 +77,26 @@ export function App() {
{/* Tabs inline in header */}
<nav className="flex items-center gap-0.5">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{tab.label}
</button>
))}
{tabs.map((tab) => {
const disabled = tab.id === "graph" && !selectedProject;
return (
<button
key={tab.id}
onClick={() => navigate(tab.id, tab.id === "stats" ? null : selectedProject)}
disabled={disabled}
title={disabled ? "Select a project first" : undefined}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
disabled
? "text-muted-foreground/30 cursor-not-allowed"
: activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{tab.label}
</button>
);
})}
</nav>
</div>
@@ -54,7 +109,7 @@ export function App() {
{selectedProject}
</span>
<button
onClick={() => { setSelectedProject(null); setActiveTab("stats"); }}
onClick={() => navigate("stats", null)}
className="text-foreground/20 hover:text-foreground/50 text-[12px] ml-1 transition-colors"
>
×
@@ -71,10 +126,7 @@ export function App() {
<ControlTab />
) : (
<StatsTab
onSelectProject={(p) => {
setSelectedProject(p);
setActiveTab("graph");
}}
onSelectProject={(p) => navigate("graph", p)}
/>
)}
</main>
+72 -60
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { colorForLabel } from "../lib/colors";
import type { GraphData } from "../lib/types";
@@ -37,9 +38,9 @@ export function FilterPanel({
}, [data]);
return (
<div className="px-4 py-3 border-b border-border/40 space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex flex-col shrink-0 max-h-[45%] border-b border-border/40">
{/* Header row — always visible */}
<div className="flex items-center justify-between px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Filters
</span>
@@ -50,66 +51,77 @@ export function FilterPanel({
</div>
</div>
{/* Node labels */}
<div>
<p className="text-[10px] text-foreground/30 mb-1.5">Nodes</p>
<div className="flex flex-wrap gap-1">
{labelCounts.map(([label, count]) => {
const on = enabledLabels.has(label);
const c = colorForLabel(label);
return (
<button
key={label}
onClick={() => onToggleLabel(label)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.08] bg-white/[0.04]" : "border-transparent opacity-25"
}`}
>
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
<span style={{ color: on ? c : "#555" }}>{label}</span>
<span className="text-foreground/20 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
</div>
</div>
{/* Scrollable filter groups */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 pb-3 space-y-3">
{/* Node types */}
{labelCounts.length > 0 && (
<div>
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Node types</p>
<div className="flex flex-wrap gap-1">
{labelCounts.map(([label, count]) => {
const on = enabledLabels.has(label);
const c = colorForLabel(label);
return (
<button
key={label}
onClick={() => onToggleLabel(label)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.08] bg-white/[0.04]" : "border-transparent opacity-25"
}`}
>
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
<span style={{ color: on ? c : "#555" }}>{label}</span>
<span className="text-foreground/20 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
</div>
</div>
)}
{/* Edge types */}
<div>
<p className="text-[10px] text-foreground/30 mb-1.5">Edges</p>
<div className="flex flex-wrap gap-1">
{edgeTypeCounts.map(([type, count]) => {
const on = enabledEdgeTypes.has(type);
return (
<button
key={type}
onClick={() => onToggleEdgeType(type)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.06] bg-white/[0.03] text-foreground/60" : "border-transparent opacity-20 text-foreground/30"
}`}
>
{type.replace(/_/g, " ").toLowerCase()}
<span className="text-foreground/15 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
{/* Relationships */}
{edgeTypeCounts.length > 0 && (
<div>
<p className="text-[10px] font-medium text-foreground/40 mb-1.5 uppercase tracking-wider">Relationships</p>
<div className="flex flex-wrap gap-1">
{edgeTypeCounts.map(([type, count]) => {
const on = enabledEdgeTypes.has(type);
return (
<button
key={type}
onClick={() => onToggleEdgeType(type)}
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[10px] font-medium transition-all border ${
on ? "border-white/[0.06] bg-white/[0.03] text-foreground/60" : "border-transparent opacity-20 text-foreground/30"
}`}
>
{type.replace(/_/g, " ").toLowerCase()}
<span className="text-foreground/15 tabular-nums">{count.toLocaleString()}</span>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</ScrollArea>
{/* Show labels toggle */}
<button
onClick={onToggleShowLabels}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium transition-all ${
showLabels ? "text-primary" : "text-foreground/30"
}`}
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
showLabels ? "border-primary bg-primary/20" : "border-foreground/15"
}`}>
{showLabels && <span className="text-primary text-[9px]"></span>}
</span>
Show labels
</button>
{/* Display options — pinned footer */}
<div className="px-4 py-2.5 border-t border-border/20 shrink-0">
<button
onClick={onToggleShowLabels}
className={`inline-flex items-center gap-1.5 text-[11px] font-medium transition-all ${
showLabels ? "text-primary" : "text-foreground/30"
}`}
>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
showLabels ? "border-primary bg-primary/20" : "border-foreground/15"
}`}>
{showLabels && <span className="text-primary text-[9px]"></span>}
</span>
Show labels
</button>
</div>
</div>
);
}
+19 -3
View File
@@ -17,7 +17,13 @@ interface CameraTarget {
lookAt: THREE.Vector3;
}
function CameraAnimator({ target }: { target: CameraTarget | null }) {
function CameraAnimator({
target,
controlsRef,
}: {
target: CameraTarget | null;
controlsRef: React.RefObject<OrbitControlsImpl | null>;
}) {
const { camera } = useThree();
const targetRef = useRef<CameraTarget | null>(null);
const progress = useRef(1);
@@ -36,7 +42,17 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) {
const t = 1 - Math.pow(1 - progress.current, 3); /* ease-out cubic */
camera.position.lerp(targetRef.current.position, t * 0.08);
camera.lookAt(targetRef.current.lookAt);
/* Move the OrbitControls pivot to the focus point as well. Otherwise the
* controls keep their target at the origin and re-center the view on the
* next frame, snapping the camera back to the middle after the fly-to. */
const controls = controlsRef.current;
if (controls) {
controls.target.lerp(targetRef.current.lookAt, t * 0.08);
controls.update();
} else {
camera.lookAt(targetRef.current.lookAt);
}
});
return null;
@@ -179,7 +195,7 @@ export function GraphScene({
{hovered && <NodeTooltip node={hovered} />}
<CameraAnimator target={cameraTarget} />
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} />
<IdleAutoRotate controlsRef={controlsRef} />
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
+2 -2
View File
@@ -181,7 +181,7 @@ export function GraphTab({ project }: GraphTabProps) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">
Select a project from the Stats tab
Select a project from the Projects tab
</p>
</div>
);
@@ -312,7 +312,7 @@ export function GraphTab({ project }: GraphTabProps) {
setCameraTarget(null);
}}
>
Clear
Clear selection
</Button>
)}
<Button
+6 -1
View File
@@ -120,7 +120,12 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="px-3 py-2.5 border-b border-border/30">
<div className="px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
{t.graph.folders}
</span>
</div>
<div className="px-3 pb-2.5 border-b border-border/30 shrink-0">
<div className="relative">
<input
type="text"
+2
View File
@@ -23,6 +23,7 @@ export const messages = {
selectedLabel: "Graph",
search: "Search...",
clearSelection: "Clear selection",
folders: "Folders",
},
projects: {
indexedProjects: "Indexed Projects",
@@ -95,6 +96,7 @@ export const messages = {
selectedLabel: "图谱",
search: "搜索...",
clearSelection: "清除选择",
folders: "目录",
},
projects: {
indexedProjects: "已索引项目",