mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 15:53:42 +00:00
feat: add auto-zoom to filtered search results
Implements automatic viewport adjustment to focus on filtered nodes when search or filter criteria change, providing immediate visual feedback. Features: - Auto-zoom triggers when search text or type filters are applied - 300ms debouncing prevents excessive zooming while typing - Only zooms when some (but not all) nodes match filters - Smooth 300ms animation with 20% padding and 2.5x max zoom - Toggle control via compact icon button in search header - Enabled by default, persists user preference in search store UI Changes: - Added CenterFocusStrong icon toggle in LeftPanel search header - Icon is blue when enabled, gray when disabled - Tooltip shows current state - Positioned after "Reset filters" button Implementation: - Search store tracks autoZoomEnabled state - GraphEditor monitors filter changes and calculates matching nodes - Uses React Flow's fitView() with smart node filtering - Skips zoom when disabled, no nodes, or no active filters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f9c208d7ac
commit
58e04650c0
3 changed files with 126 additions and 10 deletions
|
|
@ -24,6 +24,7 @@ import "reactflow/dist/style.css";
|
||||||
import { useGraphWithHistory } from "../../hooks/useGraphWithHistory";
|
import { useGraphWithHistory } from "../../hooks/useGraphWithHistory";
|
||||||
import { useDocumentHistory } from "../../hooks/useDocumentHistory";
|
import { useDocumentHistory } from "../../hooks/useDocumentHistory";
|
||||||
import { useEditorStore } from "../../stores/editorStore";
|
import { useEditorStore } from "../../stores/editorStore";
|
||||||
|
import { useSearchStore } from "../../stores/searchStore";
|
||||||
import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
|
import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
|
||||||
import { useWorkspaceStore } from "../../stores/workspaceStore";
|
import { useWorkspaceStore } from "../../stores/workspaceStore";
|
||||||
import { useCreateDocument } from "../../hooks/useCreateDocument";
|
import { useCreateDocument } from "../../hooks/useCreateDocument";
|
||||||
|
|
@ -99,8 +100,17 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
setViewport,
|
setViewport,
|
||||||
getViewport: getCurrentViewport,
|
getViewport: getCurrentViewport,
|
||||||
|
fitView,
|
||||||
} = useReactFlow();
|
} = useReactFlow();
|
||||||
|
|
||||||
|
// Search and filter state for auto-zoom
|
||||||
|
const {
|
||||||
|
searchText,
|
||||||
|
visibleActorTypes,
|
||||||
|
visibleRelationTypes,
|
||||||
|
autoZoomEnabled,
|
||||||
|
} = useSearchStore();
|
||||||
|
|
||||||
// Track previous document ID to save viewport before switching
|
// Track previous document ID to save viewport before switching
|
||||||
const prevDocumentIdRef = useRef<string | null>(null);
|
const prevDocumentIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -237,6 +247,83 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
||||||
return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus);
|
return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-zoom to filtered results when search/filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip if auto-zoom is disabled
|
||||||
|
if (!autoZoomEnabled) return;
|
||||||
|
|
||||||
|
// Skip if there are no nodes
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasSearchText = searchText.trim() !== '';
|
||||||
|
const hasTypeFilters =
|
||||||
|
Object.values(visibleActorTypes).some(v => v === false) ||
|
||||||
|
Object.values(visibleRelationTypes).some(v => v === false);
|
||||||
|
|
||||||
|
// Skip if no filters are active
|
||||||
|
if (!hasSearchText && !hasTypeFilters) return;
|
||||||
|
|
||||||
|
// Debounce to avoid excessive viewport changes while typing
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const searchLower = searchText.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Calculate matching nodes (same logic as LeftPanel and CustomNode)
|
||||||
|
const matchingNodeIds = nodes
|
||||||
|
.filter((node) => {
|
||||||
|
const actor = node as Actor;
|
||||||
|
const actorType = actor.data?.type || '';
|
||||||
|
|
||||||
|
// Filter by actor type visibility
|
||||||
|
const isTypeVisible = visibleActorTypes[actorType] !== false;
|
||||||
|
if (!isTypeVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search text
|
||||||
|
if (searchLower) {
|
||||||
|
const label = actor.data?.label?.toLowerCase() || '';
|
||||||
|
const description = actor.data?.description?.toLowerCase() || '';
|
||||||
|
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType);
|
||||||
|
const typeName = nodeTypeConfig?.label?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const matches =
|
||||||
|
label.includes(searchLower) ||
|
||||||
|
description.includes(searchLower) ||
|
||||||
|
typeName.includes(searchLower);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((node) => node.id);
|
||||||
|
|
||||||
|
// Only zoom if there are matching nodes and not all nodes match
|
||||||
|
if (matchingNodeIds.length > 0 && matchingNodeIds.length < nodes.length) {
|
||||||
|
fitView({
|
||||||
|
nodes: matchingNodeIds.map((id) => ({ id })),
|
||||||
|
padding: 0.2, // 20% padding around selection
|
||||||
|
duration: 300, // 300ms smooth animation
|
||||||
|
maxZoom: 2.5, // Allow more zoom in
|
||||||
|
minZoom: 0.5, // Don't zoom out too much
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300); // Debounce 300ms
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [
|
||||||
|
searchText,
|
||||||
|
visibleActorTypes,
|
||||||
|
visibleRelationTypes,
|
||||||
|
autoZoomEnabled,
|
||||||
|
nodes,
|
||||||
|
nodeTypeConfigs,
|
||||||
|
fitView,
|
||||||
|
]);
|
||||||
|
|
||||||
// Save viewport periodically (debounced)
|
// Save viewport periodically (debounced)
|
||||||
const handleViewportChange = useCallback(
|
const handleViewportChange = useCallback(
|
||||||
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
|
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
|
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
|
||||||
|
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong';
|
||||||
import { usePanelStore } from '../../stores/panelStore';
|
import { usePanelStore } from '../../stores/panelStore';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
import { useEditorStore } from '../../stores/editorStore';
|
import { useEditorStore } from '../../stores/editorStore';
|
||||||
|
|
@ -86,6 +87,8 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
||||||
setActorTypeVisible,
|
setActorTypeVisible,
|
||||||
visibleRelationTypes,
|
visibleRelationTypes,
|
||||||
setRelationTypeVisible,
|
setRelationTypeVisible,
|
||||||
|
autoZoomEnabled,
|
||||||
|
setAutoZoomEnabled,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
} = useSearchStore();
|
} = useSearchStore();
|
||||||
|
|
@ -401,16 +404,33 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
||||||
<label className="block text-xs font-medium text-gray-600">
|
<label className="block text-xs font-medium text-gray-600">
|
||||||
Search
|
Search
|
||||||
</label>
|
</label>
|
||||||
{/* Reset Filters Link */}
|
<div className="flex items-center space-x-1">
|
||||||
{hasActiveFilters() && (
|
{/* Reset Filters Link */}
|
||||||
<button
|
{hasActiveFilters() && (
|
||||||
onClick={clearFilters}
|
<button
|
||||||
className="flex items-center space-x-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
|
onClick={clearFilters}
|
||||||
>
|
className="flex items-center space-x-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
|
||||||
<FilterAltOffIcon sx={{ fontSize: 14 }} />
|
>
|
||||||
<span>Reset filters</span>
|
<FilterAltOffIcon sx={{ fontSize: 14 }} />
|
||||||
</button>
|
<span>Reset filters</span>
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Auto-zoom toggle icon */}
|
||||||
|
<Tooltip title={autoZoomEnabled ? "Auto-zoom enabled" : "Auto-zoom disabled"} placement="top">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setAutoZoomEnabled(!autoZoomEnabled)}
|
||||||
|
sx={{ padding: '4px' }}
|
||||||
|
>
|
||||||
|
<CenterFocusStrongIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: autoZoomEnabled ? '#3b82f6' : '#9ca3af'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none">
|
<div className="absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { create } from 'zustand';
|
||||||
* - Search text for filtering both actors (by label, description, or type) and relations (by label or type)
|
* - Search text for filtering both actors (by label, description, or type) and relations (by label or type)
|
||||||
* - Filter by actor types (show/hide specific node types)
|
* - Filter by actor types (show/hide specific node types)
|
||||||
* - Filter by relation types (show/hide specific edge types)
|
* - Filter by relation types (show/hide specific edge types)
|
||||||
|
* - Auto-zoom to filtered results (optional, enabled by default)
|
||||||
* - Results tracking
|
* - Results tracking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -27,6 +28,10 @@ interface SearchStore {
|
||||||
toggleRelationType: (typeId: string) => void;
|
toggleRelationType: (typeId: string) => void;
|
||||||
setAllRelationTypesVisible: (visible: boolean) => void;
|
setAllRelationTypesVisible: (visible: boolean) => void;
|
||||||
|
|
||||||
|
// Auto-zoom to filtered results
|
||||||
|
autoZoomEnabled: boolean;
|
||||||
|
setAutoZoomEnabled: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
clearFilters: () => void;
|
clearFilters: () => void;
|
||||||
|
|
||||||
|
|
@ -38,6 +43,7 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
|
||||||
searchText: '',
|
searchText: '',
|
||||||
visibleActorTypes: {},
|
visibleActorTypes: {},
|
||||||
visibleRelationTypes: {},
|
visibleRelationTypes: {},
|
||||||
|
autoZoomEnabled: true,
|
||||||
|
|
||||||
setSearchText: (text: string) =>
|
setSearchText: (text: string) =>
|
||||||
set({ searchText: text }),
|
set({ searchText: text }),
|
||||||
|
|
@ -92,6 +98,9 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
|
||||||
return { visibleRelationTypes: updated };
|
return { visibleRelationTypes: updated };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setAutoZoomEnabled: (enabled: boolean) =>
|
||||||
|
set({ autoZoomEnabled: enabled }),
|
||||||
|
|
||||||
clearFilters: () =>
|
clearFilters: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Reset all actor types to visible
|
// Reset all actor types to visible
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue