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 { useDocumentHistory } from "../../hooks/useDocumentHistory";
|
||||
import { useEditorStore } from "../../stores/editorStore";
|
||||
import { useSearchStore } from "../../stores/searchStore";
|
||||
import { useActiveDocument } from "../../stores/workspace/useActiveDocument";
|
||||
import { useWorkspaceStore } from "../../stores/workspaceStore";
|
||||
import { useCreateDocument } from "../../hooks/useCreateDocument";
|
||||
|
|
@ -99,8 +100,17 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
screenToFlowPosition,
|
||||
setViewport,
|
||||
getViewport: getCurrentViewport,
|
||||
fitView,
|
||||
} = useReactFlow();
|
||||
|
||||
// Search and filter state for auto-zoom
|
||||
const {
|
||||
searchText,
|
||||
visibleActorTypes,
|
||||
visibleRelationTypes,
|
||||
autoZoomEnabled,
|
||||
} = useSearchStore();
|
||||
|
||||
// Track previous document ID to save viewport before switching
|
||||
const prevDocumentIdRef = useRef<string | null>(null);
|
||||
|
||||
|
|
@ -237,6 +247,83 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq
|
|||
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)
|
||||
const handleViewportChange = useCallback(
|
||||
(_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 ClearIcon from '@mui/icons-material/Clear';
|
||||
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
|
||||
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong';
|
||||
import { usePanelStore } from '../../stores/panelStore';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import { useEditorStore } from '../../stores/editorStore';
|
||||
|
|
@ -86,6 +87,8 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
setActorTypeVisible,
|
||||
visibleRelationTypes,
|
||||
setRelationTypeVisible,
|
||||
autoZoomEnabled,
|
||||
setAutoZoomEnabled,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = useSearchStore();
|
||||
|
|
@ -401,6 +404,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
<label className="block text-xs font-medium text-gray-600">
|
||||
Search
|
||||
</label>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Reset Filters Link */}
|
||||
{hasActiveFilters() && (
|
||||
<button
|
||||
|
|
@ -411,6 +415,22 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
|
|||
<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 className="relative">
|
||||
<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)
|
||||
* - Filter by actor types (show/hide specific node types)
|
||||
* - Filter by relation types (show/hide specific edge types)
|
||||
* - Auto-zoom to filtered results (optional, enabled by default)
|
||||
* - Results tracking
|
||||
*/
|
||||
|
||||
|
|
@ -27,6 +28,10 @@ interface SearchStore {
|
|||
toggleRelationType: (typeId: string) => void;
|
||||
setAllRelationTypesVisible: (visible: boolean) => void;
|
||||
|
||||
// Auto-zoom to filtered results
|
||||
autoZoomEnabled: boolean;
|
||||
setAutoZoomEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Clear all filters
|
||||
clearFilters: () => void;
|
||||
|
||||
|
|
@ -38,6 +43,7 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
|
|||
searchText: '',
|
||||
visibleActorTypes: {},
|
||||
visibleRelationTypes: {},
|
||||
autoZoomEnabled: true,
|
||||
|
||||
setSearchText: (text: string) =>
|
||||
set({ searchText: text }),
|
||||
|
|
@ -92,6 +98,9 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
|
|||
return { visibleRelationTypes: updated };
|
||||
}),
|
||||
|
||||
setAutoZoomEnabled: (enabled: boolean) =>
|
||||
set({ autoZoomEnabled: enabled }),
|
||||
|
||||
clearFilters: () =>
|
||||
set((state) => {
|
||||
// Reset all actor types to visible
|
||||
|
|
|
|||
Loading…
Reference in a new issue