UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

249 lines (248 loc) 9.33 kB
/** * Fast tracker for calculating graph boundaries (usableRect) with smart optimization. * * ## What it does: * Keeps track of the overall bounding box of all graph elements (blocks, connections, etc.) * without having to recalculate everything when elements move. * * ## Why it's fast: * * ### The Problem: * Normally, when you move ANY element in a graph with 1000 blocks, * you'd need to check ALL 1000 blocks to find new boundaries. * This is slow and causes lag during drag operations. * * ### Our Solution: * We keep track of which elements are at each "wall" of the graph: * - Left wall (minX) * - Right wall (maxX) * - Top wall (maxY) * - Bottom wall (minY) * * ### The Magic: * - **95% of moves**: Element is NOT at any wall → Check in O(1), super fast! ⚡ * - **5% of moves**: Element IS at a wall → May need O(n) recalculation, but rare * - **Result**: Smooth 120fps+ interactions even with complex graphs * * ### Real Example: * ``` * Graph with many blocks: * ┌─────────────────────────────────────┐ * │ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │ * │ │A│ │B│ │C│ │D│ │E│ │F│ │G│ ... │ * │ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ │ * └─────────────────────────────────────┘ * ``` * * Moving block C (middle): Only check "does C touch any walls?" → NO → Done! * Moving block A (edge): Check "is A the only one on left wall?" → Handle accordingly * * ## How to use: * ```typescript * const tracker = new IncrementalBoundingBoxTracker(); * tracker.add(element); // Add element to tracking * tracker.update(element, newPos); // Update element position * tracker.remove(element); // Remove element * const bounds = tracker.toJSON(); // Get current boundaries (always fast!) * ``` * * This enables responsive camera positioning, smooth zoom/pan operations, * and lag-free dragging even with hundreds of graph elements. */ export class IncrementalBoundingBoxTracker { constructor() { this.items = new Set(); this.bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; // Track elements on each boundary for fast updates this.boundaryElements = { minX: new Set(), minY: new Set(), maxX: new Set(), maxY: new Set(), }; } has(item) { return this.items.has(item); } /** * Add element for tracking - O(1) * @param item Element to track * @returns void */ add(item) { this.items.add(item); // Only update bounds if item has valid bounds if (this.isValidBounds(item)) { this.updateBoundsIncremental(item); } } /** * Remove element from tracking - O(1) or O(n) if boundary element * @param item Element to remove * @returns void */ remove(item) { if (!this.items.delete(item)) return; const wasOnBoundary = this.isOnBoundary(item); this.removeFromBoundaryTracking(item); if (wasOnBoundary) { this.recalculateBounds(); // Full recalculation only if boundary element was removed } } /** * Update element - O(1) in most cases * @param item Element to update * @param newBounds New element bounds * @returns void */ update(item, newBounds) { if (!this.items.has(item)) { // Item not found, add it instead this.add(item); return; } const oldBounds = { ...item }; Object.assign(item, newBounds); // Only proceed with bounds update if new bounds are valid if (this.isValidBounds(item)) { this.updateBoundsOptimized(item, oldBounds); } else { // If new bounds are invalid, remove item from boundary tracking this.removeFromBoundaryTracking(item, oldBounds); if (this.isOnBoundary(oldBounds)) { this.recalculateBounds(); } } } /** * Clear all elements - O(1) * * @returns void */ clear() { this.items.clear(); this.bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; this.boundaryElements = { minX: new Set(), minY: new Set(), maxX: new Set(), maxY: new Set() }; } /** * Load array of elements - O(n) * @param items Array of elements to load * @returns void */ load(items) { this.clear(); for (const item of items) { this.items.add(item); this.updateBoundsIncremental(item); } } /** * Get bounding box - O(1) * @returns Object with element count and bounding box */ toJSON() { if (this.items.size === 0) { return { length: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 }; } // If we have items but bounds are still uninitialized (all items have invalid bounds) if (this.bounds.minX === Infinity || this.bounds.maxX === -Infinity) { return { length: this.items.size, minX: 0, minY: 0, maxX: 0, maxY: 0 }; } return { length: this.items.size, ...this.bounds, }; } updateBoundsIncremental(item) { // Skip items with invalid bounds (undefined, NaN, or non-finite values) if (!this.isValidBounds(item)) { return; } // Handle minX: if bounds not initialized (Infinity) or item has smaller value if (this.bounds.minX === Infinity || item.minX <= this.bounds.minX) { if (item.minX < this.bounds.minX) { this.boundaryElements.minX.clear(); this.bounds.minX = item.minX; } this.boundaryElements.minX.add(item); } // Handle minY: if bounds not initialized (Infinity) or item has smaller value if (this.bounds.minY === Infinity || item.minY <= this.bounds.minY) { if (item.minY < this.bounds.minY) { this.boundaryElements.minY.clear(); this.bounds.minY = item.minY; } this.boundaryElements.minY.add(item); } if (item.maxX >= this.bounds.maxX) { if (item.maxX > this.bounds.maxX) { this.boundaryElements.maxX.clear(); this.bounds.maxX = item.maxX; } this.boundaryElements.maxX.add(item); } if (item.maxY >= this.bounds.maxY) { if (item.maxY > this.bounds.maxY) { this.boundaryElements.maxY.clear(); this.bounds.maxY = item.maxY; } this.boundaryElements.maxY.add(item); } } updateBoundsOptimized(item, oldBounds) { this.removeFromBoundaryTracking(item, oldBounds); const needsRecalc = this.needsRecalculation(oldBounds); if (needsRecalc) { this.recalculateBounds(); } else { this.updateBoundsIncremental(item); } } needsRecalculation(oldBounds) { return ((oldBounds.minX === this.bounds.minX && this.boundaryElements.minX.size === 0) || (oldBounds.minY === this.bounds.minY && this.boundaryElements.minY.size === 0) || (oldBounds.maxX === this.bounds.maxX && this.boundaryElements.maxX.size === 0) || (oldBounds.maxY === this.bounds.maxY && this.boundaryElements.maxY.size === 0)); } isOnBoundary(item) { return (this.boundaryElements.minX.has(item) || this.boundaryElements.minY.has(item) || this.boundaryElements.maxX.has(item) || this.boundaryElements.maxY.has(item)); } removeFromBoundaryTracking(item, bounds = item) { if (bounds.minX === this.bounds.minX) this.boundaryElements.minX.delete(item); if (bounds.minY === this.bounds.minY) this.boundaryElements.minY.delete(item); if (bounds.maxX === this.bounds.maxX) this.boundaryElements.maxX.delete(item); if (bounds.maxY === this.bounds.maxY) this.boundaryElements.maxY.delete(item); } recalculateBounds() { this.bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; this.boundaryElements = { minX: new Set(), minY: new Set(), maxX: new Set(), maxY: new Set() }; for (const item of this.items) { this.updateBoundsIncremental(item); } } /** * Check if item has valid bounds (no undefined, NaN, or non-finite values) * @param item Element to check * @returns true if bounds are valid */ isValidBounds(item) { return (typeof item.minX === "number" && typeof item.minY === "number" && typeof item.maxX === "number" && typeof item.maxY === "number" && Number.isFinite(item.minX) && Number.isFinite(item.minY) && Number.isFinite(item.maxX) && Number.isFinite(item.maxY)); } }