diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 732a9d18a..18392667c 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -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("stats"); - const [selectedProject, setSelectedProject] = useState(null); + const [route, setRoute] = useState(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 */} @@ -54,7 +109,7 @@ export function App() { {selectedProject} - ); - })} - - + {/* Scrollable filter groups */} + +
+ {/* Node types */} + {labelCounts.length > 0 && ( +
+

Node types

+
+ {labelCounts.map(([label, count]) => { + const on = enabledLabels.has(label); + const c = colorForLabel(label); + return ( + + ); + })} +
+
+ )} - {/* Edge types */} -
-

Edges

-
- {edgeTypeCounts.map(([type, count]) => { - const on = enabledEdgeTypes.has(type); - return ( - - ); - })} + {/* Relationships */} + {edgeTypeCounts.length > 0 && ( +
+

Relationships

+
+ {edgeTypeCounts.map(([type, count]) => { + const on = enabledEdgeTypes.has(type); + return ( + + ); + })} +
+
+ )}
-
+ - {/* Show labels toggle */} - + {/* Display options — pinned footer */} +
+ +
); } diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index 6aeceb6b2..81ec5c9bc 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -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; +}) { const { camera } = useThree(); const targetRef = useRef(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 && } - + diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 0e6c8fb08..cff896622 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -181,7 +181,7 @@ export function GraphTab({ project }: GraphTabProps) { return (

- Select a project from the Stats tab + Select a project from the Projects tab

); @@ -309,7 +309,7 @@ export function GraphTab({ project }: GraphTabProps) { setCameraTarget(null); }} > - Clear + Clear selection )}