Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions app/src/components/schema/SchemaCanvasControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = ({
Expand All @@ -86,6 +93,8 @@ const SchemaCanvasControls = ({
onFocusModeChange,
selectedTableId,
onSelectTable,
onFrameNodes,
onLayoutChanged,
}: SchemaCanvasControlsProps) => {
const [layout, setLayout] = useState<LayoutMode>('force');
const [direction, setDirection] = useState<string>('');
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -177,7 +186,7 @@ const SchemaCanvasControls = ({
};

const handleCenter = () => {
canvasRef.current?.zoomToFit();
onFrameNodes();
};

const applyDirection = (mode: LayoutMode, value: string) => {
Expand Down Expand Up @@ -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) => {
Expand Down
184 changes: 162 additions & 22 deletions app/src/components/schema/SchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<FalkorDBCanvas>(null);
const resizeRef = useRef<HTMLDivElement>(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<SchemaData | null>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Comment thread
Anchel123 marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -580,6 +718,8 @@ const SchemaViewer = ({ isOpen, onClose, onWidthChange, sidebarWidth = 64 }: Sch
onFocusModeChange={setFocusMode}
selectedTableId={selectedTableId}
onSelectTable={setSelectedTableId}
onFrameNodes={frameNodes}
onLayoutChanged={frameCurrentTarget}
/>

{/* Highlight status */}
Expand Down
Loading