diff --git a/app/src/components/schema/SchemaCanvasControls.tsx b/app/src/components/schema/SchemaCanvasControls.tsx index 3ff47d9c..cb36eeda 100644 --- a/app/src/components/schema/SchemaCanvasControls.tsx +++ b/app/src/components/schema/SchemaCanvasControls.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KeyboardEvent, RefObject } from 'react'; import type { FalkorDBCanvas, - GraphNode, HierarchyDirection, LayoutMode, RadialDirection, @@ -76,6 +75,14 @@ interface SchemaCanvasControlsProps { onFocusModeChange: (enabled: boolean) => void; selectedTableId: number | null; onSelectTable: (tableId: number | null) => void; + /** Frames the matching tables (all of them when no predicate is given). */ + onFrameNodes: (match?: (nodeId: number) => boolean) => void; + /** + * Re-frames the current view once the new layout has settled. The canvas runs + * its own centre-based fit after a layout change, which clips tall cards and + * discards any highlight framing. + */ + onLayoutChanged: () => void; } const SchemaCanvasControls = ({ @@ -86,6 +93,8 @@ const SchemaCanvasControls = ({ onFocusModeChange, selectedTableId, onSelectTable, + onFrameNodes, + onLayoutChanged, }: SchemaCanvasControlsProps) => { const [layout, setLayout] = useState('force'); const [direction, setDirection] = useState(''); @@ -124,9 +133,9 @@ const SchemaCanvasControls = ({ onSelectTable(table.id); setSearch(table.name); setSuggestionsOpen(false); - canvasRef.current?.zoomToFit(4, (node: GraphNode) => node.id === table.id); + onFrameNodes((nodeId) => nodeId === table.id); }, - [canvasRef, onSelectTable] + [onFrameNodes, onSelectTable] ); const clearSearch = useCallback(() => { @@ -177,7 +186,7 @@ const SchemaCanvasControls = ({ }; const handleCenter = () => { - canvasRef.current?.zoomToFit(); + onFrameNodes(); }; const applyDirection = (mode: LayoutMode, value: string) => { @@ -214,12 +223,15 @@ const SchemaCanvasControls = ({ setAnimation(false); canvasRef.current?.setAnimation(false); } + + onLayoutChanged(); }; const handleDirectionChange = (value: string, targetLayout: LayoutMode) => { directionsRef.current = { ...directionsRef.current, [targetLayout]: value }; setDirection(value); applyDirection(targetLayout, value); + onLayoutChanged(); }; const handleAnimationToggle = (checked: boolean) => { diff --git a/app/src/components/schema/SchemaViewer.tsx b/app/src/components/schema/SchemaViewer.tsx index 1bc1715b..cd8780e0 100644 --- a/app/src/components/schema/SchemaViewer.tsx +++ b/app/src/components/schema/SchemaViewer.tsx @@ -38,6 +38,23 @@ const HIGHLIGHT_COLOR = '#8b5cf6'; /** Opacity applied to schema elements the selected query does not touch. */ const DIMMED_OPACITY = 0.25; +// Geometry of a rendered table card, shared by the drawing code and the +// viewport framing below. +const NODE_WIDTH = 160; +const NODE_LINE_HEIGHT = 14; +const NODE_PADDING = 8; +const NODE_HEADER_HEIGHT = 20; + +const tableNodeHeight = (columnCount: number): number => + NODE_HEADER_HEIGHT + columnCount * NODE_LINE_HEIGHT + NODE_PADDING * 2; + +/** Gap in pixels kept between the framed tables and the canvas edges. */ +const FIT_PADDING_PX = 32; +/** Upper bound on the framing zoom, so a single small table is not blown up. */ +const FIT_MAX_ZOOM = 1.5; +const FIT_MIN_ZOOM = 0.05; +const FIT_ANIMATION_MS = 300; + /** Link endpoints are ids before the layout runs and node objects afterwards. */ const endpointId = (endpoint: unknown): string => { if (endpoint && typeof endpoint === 'object' && 'id' in endpoint) { @@ -53,10 +70,30 @@ const linkKey = (source: unknown, target: unknown): string => // Must stay above the canvas' `interaction.zoomToFitDelay` (50ms default) so the // highlight framing is applied after the canvas' own initial fit. const HIGHLIGHT_ZOOM_DELAY_MS = 100; +/** How long to keep waiting for the layout to settle before framing anyway. */ +const SETTLE_TIMEOUT_MS = 2000; +/** World-unit movement below which the layout counts as settled. */ +const SETTLE_EPSILON = 0.5; + +interface Bounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +const boundsSettled = (a: Bounds, b: Bounds): boolean => + Math.abs(a.minX - b.minX) < SETTLE_EPSILON && + Math.abs(a.maxX - b.maxX) < SETTLE_EPSILON && + Math.abs(a.minY - b.minY) < SETTLE_EPSILON && + Math.abs(a.maxY - b.maxY) < SETTLE_EPSILON; const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: SchemaViewerProps) => { const canvasRef = useRef(null); const resizeRef = useRef(null); + // Handle of the in-flight "wait for the layout to settle" loop, so a new + // framing request cancels the previous one instead of racing it. + const settleFrameRef = useRef(0); // Schema snapshot currently seeded into the canvas, used to avoid re-seeding // (and losing node positions) when only the highlight changed. const renderedSchemaRef = useRef(null); @@ -176,7 +213,6 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return () => observer.disconnect(); }, []); - const NODE_WIDTH = 160; const MIN_WIDTH = 300; const MAX_WIDTH_PERCENT = 0.6; const DEFAULT_WIDTH_PERCENT = 0.5; @@ -290,15 +326,117 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch return theme === 'light' ? '#9ca3af' : '#4b5563'; }, [theme, hasHighlight, highlightedLinkKeys]); + // Bounding box of the matching table cards in world units, or null when the + // layout has not produced coordinates for any of them yet. + const nodeBounds = useCallback((match?: (nodeId: number) => boolean): Bounds | null => { + const canvas = canvasRef.current; + + if (!canvas || !schemaData) return null; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + canvas.getGraphData()?.nodes.forEach((node) => { + if (node.x === undefined || node.y === undefined) return; + if (match && !match(Number(node.id))) return; + + const columns = schemaData.nodesMap.get(Number(node.id))?.columns ?? []; + const halfHeight = tableNodeHeight(columns.length) / 2; + + minX = Math.min(minX, node.x - NODE_WIDTH / 2); + maxX = Math.max(maxX, node.x + NODE_WIDTH / 2); + minY = Math.min(minY, node.y - halfHeight); + maxY = Math.max(maxY, node.y + halfHeight); + }); + + // Nothing matched, or the layout has not produced coordinates yet. + if (!Number.isFinite(minX)) return null; + + return { minX, maxX, minY, maxY }; + }, [schemaData]); + + // The canvas' own zoomToFit frames node centres, so a tall table gets clipped + // and a single table is zoomed to the configured maximum whatever its size. + // Frame the rendered cards instead. + const frameNodes = useCallback((match?: (nodeId: number) => boolean): boolean => { + const canvas = canvasRef.current; + + if (!canvas) return false; + + const rect = canvas.getBoundingClientRect(); + + if (!rect.width || !rect.height) return false; + + const bounds = nodeBounds(match); + + if (!bounds) return false; + + const { minX, maxX, minY, maxY } = bounds; + + // A panel narrower than the padding would otherwise ask for a negative area. + const fitZoom = Math.min( + Math.max(rect.width - FIT_PADDING_PX * 2, 1) / (maxX - minX), + Math.max(rect.height - FIT_PADDING_PX * 2, 1) / (maxY - minY) + ); + + canvas.centerAt((minX + maxX) / 2, (minY + maxY) / 2, FIT_ANIMATION_MS); + + const zoom = Math.min(Math.max(fitZoom, FIT_MIN_ZOOM), FIT_MAX_ZOOM); + // The canvas' own zoom() is instant, so go through force-graph to keep the + // pan and the zoom on the same animation. + const graph = canvas.getGraph(); + + if (graph) { + graph.zoom(zoom, FIT_ANIMATION_MS); + } else { + canvas.zoom(zoom); + } + + return true; + }, [nodeBounds]); + + // A running layout keeps moving after it has produced its first coordinates, + // so framing them aims at an already-stale target. Wait for the bounds to stop + // changing. Only one wait runs at a time: a later request supersedes an + // earlier one rather than racing it. + const frameWhenSettled = useCallback((match?: (nodeId: number) => boolean) => { + cancelAnimationFrame(settleFrameRef.current); + + const deadline = Date.now() + SETTLE_TIMEOUT_MS; + let previous: Bounds | null = null; + + const attempt = () => { + // The viewer can close mid-wait, which unmounts the canvas. + if (!canvasRef.current) return; + + const bounds = nodeBounds(match); + + if ((bounds && previous && boundsSettled(bounds, previous)) || Date.now() >= deadline) { + frameNodes(match); + return; + } + + previous = bounds; + settleFrameRef.current = requestAnimationFrame(attempt); + }; + + settleFrameRef.current = requestAnimationFrame(attempt); + }, [nodeBounds, frameNodes]); + + // Switching layout or direction re-runs the canvas' own centre-based fit, + // which clips tall cards and discards any highlight framing. + const frameCurrentTarget = useCallback(() => { + frameWhenSettled( + hasHighlight ? (nodeId: number) => highlightedNodeIds.has(nodeId) : undefined + ); + }, [frameWhenSettled, hasHighlight, highlightedNodeIds]); + // Convert schema data to canvas format const convertToCanvasData = useCallback((data: SchemaData): Data => { const nodes = data.nodes.map((node) => { - // Calculate node size based on height (same calculation as in nodeCanvasObject) - const columns = node.columns || []; - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight((node.columns || []).length); // Use the larger dimension as collision radius (in pixels) const size = Math.max(NODE_WIDTH / 2, nodeHeight / 2); @@ -340,9 +478,9 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!canvas || !canvasLoaded || !schemaData) return; const nodeCanvasObject = (node: GraphNode, ctx: CanvasRenderingContext2D) => { - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; + const lineHeight = NODE_LINE_HEIGHT; + const padding = NODE_PADDING; + const headerHeight = NODE_HEADER_HEIGHT; const fontSize = 12; // Theme-aware colors @@ -364,7 +502,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch const columns = schemaNode.columns || []; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight(columns.length); const previousAlpha = ctx.globalAlpha; if (isDimmed) { @@ -448,10 +586,7 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch if (!schemaNode) return; const columns = schemaNode.columns || []; - const lineHeight = 14; - const padding = 8; - const headerHeight = 20; - const nodeHeight = headerHeight + columns.length * lineHeight + padding * 2; + const nodeHeight = tableNodeHeight(columns.length); ctx.fillStyle = color; const areaPadding = 5; @@ -520,18 +655,21 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch canvas.setGraphData(canvasData); }, [schemaData, canvasLoaded, convertToCanvasData]); - // Bring the highlighted tables into view when a query is selected + // Bring the highlighted tables into view when a query is selected. The layout + // may still be moving, so frame it once it has stopped. + // Reopening the viewer re-runs this, since the canvas is unmounted while closed. useEffect(() => { - const canvas = canvasRef.current; - - if (!canvas || !canvasLoaded || !hasHighlight) return; + if (!isOpen || !canvasLoaded || !hasHighlight) return; const timer = setTimeout(() => { - canvas.zoomToFit(1.5, (node: GraphNode) => highlightedNodeIds.has(node.id)); + frameWhenSettled((nodeId) => highlightedNodeIds.has(nodeId)); }, HIGHLIGHT_ZOOM_DELAY_MS); - return () => clearTimeout(timer); - }, [canvasLoaded, hasHighlight, highlightedNodeIds]); + return () => { + clearTimeout(timer); + cancelAnimationFrame(settleFrameRef.current); + }; + }, [isOpen, canvasLoaded, hasHighlight, highlightedNodeIds, frameWhenSettled]); if (!isOpen) return null; @@ -580,6 +718,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch onFocusModeChange={setFocusMode} selectedTableId={selectedTableId} onSelectTable={setSelectedTableId} + onFrameNodes={frameNodes} + onLayoutChanged={frameCurrentTarget} /> {/* Highlight status */}