Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 73 additions & 21 deletions graph-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand All @@ -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>

Expand All @@ -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"
>
×
Expand All @@ -71,10 +126,7 @@ export function App() {
<ControlTab />
) : (
<StatsTab
onSelectProject={(p) => {
setSelectedProject(p);
setActiveTab("graph");
}}
onSelectProject={(p) => navigate("graph", p)}
/>
)}
</main>
Expand Down
132 changes: 72 additions & 60 deletions graph-ui/src/components/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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>
Expand All @@ -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>
);
}
22 changes: 19 additions & 3 deletions graph-ui/src/components/GraphScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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}>
Expand Down
4 changes: 2 additions & 2 deletions graph-ui/src/components/GraphTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down Expand Up @@ -309,7 +309,7 @@ export function GraphTab({ project }: GraphTabProps) {
setCameraTarget(null);
}}
>
Clear
Clear selection
</Button>
)}
<Button
Expand Down
7 changes: 6 additions & 1 deletion graph-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions graph-ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const messages = {
selectedLabel: "Graph",
search: "Search...",
clearSelection: "Clear selection",
folders: "Folders",
},
projects: {
indexedProjects: "Indexed Projects",
Expand Down Expand Up @@ -91,6 +92,7 @@ export const messages = {
selectedLabel: "图谱",
search: "搜索...",
clearSelection: "清除选择",
folders: "目录",
},
projects: {
indexedProjects: "已索引项目",
Expand Down
Loading