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
+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"