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:
+73
-21
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "已索引项目",
|
||||
|
||||
Reference in New Issue
Block a user