UNPKG

ngraph.svg

Version:

SVG-based graph visualization library with adaptive rendering

180 lines (154 loc) 5.9 kB
import RBush from 'rbush'; /** * R-tree based overlap removal algorithm. * * Uses rbush for O(n log n) spatial queries instead of O(n^2) pairwise checks. * * Two-phase approach: * * **Phase 1: Separation** * - Build R-tree with all rectangle bounding boxes * - For each rectangle, query overlapping neighbors * - Separate overlapping pairs along axis with minimum overlap * - Repeat until no overlaps remain (or max iterations reached) * * **Phase 2: Relaxation** * - For each rectangle, attempt to move toward original position * - Only apply movement if it doesn't create new overlaps * - This preserves graph structure while eliminating overlaps * * @param {Array<{id: any, x: number, y: number, width: number, height: number}>} rects * Array of rectangles to process. Positions are mutated in place. * @param {Map<any, {x: number, y: number}>} originalPositions * Original positions to relax toward (preserves structure) * @param {Object} options * @param {number} [options.iterations=100] - Max separation iterations * @param {number} [options.padding=4] - Minimum gap between rectangles */ export function removeOverlaps(rects, originalPositions, options = {}) { const iterations = options.iterations ?? 100; const padding = options.padding ?? 4; const tree = new RBush(); // Phase 1: Remove all overlaps completely for (let iter = 0; iter < iterations; iter++) { tree.clear(); const items = rects.map(r => ({ minX: r.x - r.width / 2, minY: r.y - r.height / 2, maxX: r.x + r.width / 2, maxY: r.y + r.height / 2, rect: r })); tree.load(items); let hasOverlap = false; const processed = new Set(); for (const item of items) { // Search with padding to find nearby nodes const candidates = tree.search({ minX: item.minX - padding, minY: item.minY - padding, maxX: item.maxX + padding, maxY: item.maxY + padding }); for (const other of candidates) { if (other.rect === item.rect) continue; // Use consistent pair key for deduplication const ids = [item.rect.id, other.rect.id]; ids.sort(); const pairKey = ids[0] + '|' + ids[1]; if (processed.has(pairKey)) continue; processed.add(pairKey); if (separatePair(item.rect, other.rect, padding)) { hasOverlap = true; } } } if (!hasOverlap) break; } // Phase 2: Try to move nodes back toward original positions without creating overlaps const relaxIterations = 20; const relaxStrength = 0.1; for (let iter = 0; iter < relaxIterations; iter++) { tree.clear(); const items = rects.map(r => ({ minX: r.x - r.width / 2, minY: r.y - r.height / 2, maxX: r.x + r.width / 2, maxY: r.y + r.height / 2, rect: r })); tree.load(items); for (const rect of rects) { const orig = originalPositions.get(rect.id); if (!orig) continue; // Proposed movement toward original const dx = (orig.x - rect.x) * relaxStrength; const dy = (orig.y - rect.y) * relaxStrength; if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) continue; // Check if movement would create overlap const newX = rect.x + dx; const newY = rect.y + dy; const candidates = tree.search({ minX: newX - rect.width / 2 - padding, minY: newY - rect.height / 2 - padding, maxX: newX + rect.width / 2 + padding, maxY: newY + rect.height / 2 + padding }); let canMove = true; for (const other of candidates) { if (other.rect === rect) continue; // Check if new position would overlap const ox = (rect.width / 2 + other.rect.width / 2 + padding) - Math.abs(newX - other.rect.x); const oy = (rect.height / 2 + other.rect.height / 2 + padding) - Math.abs(newY - other.rect.y); if (ox > 0 && oy > 0) { canMove = false; break; } } if (canMove) { rect.x = newX; rect.y = newY; } } } } /** * Separate two overlapping rectangles along the axis with minimum overlap. * * Strategy: Move along the axis with less overlap to minimize displacement. * Movement is split inversely proportional to each rectangle's `degree` field, * so high-degree hub nodes stay put while low-degree leaves absorb the shift. * * @param {{x: number, y: number, width: number, height: number, degree?: number}} a - First rectangle (mutated) * @param {{x: number, y: number, width: number, height: number, degree?: number}} b - Second rectangle (mutated) * @param {number} padding - Additional gap to enforce between rectangles * @returns {boolean} True if separation was needed, false if no overlap */ export function separatePair(a, b, padding = 0) { // Calculate overlap on each axis const ox = (a.width / 2 + b.width / 2 + padding) - Math.abs(a.x - b.x); const oy = (a.height / 2 + b.height / 2 + padding) - Math.abs(a.y - b.y); // No overlap if either axis has no intersection if (ox <= 0 || oy <= 0) return false; // Weight: nodes with more connections move less. // Inverse of (1 + degree) so a leaf (degree 1) has weight 0.5, // a hub (degree 20) has weight ~0.048, etc. const aWeight = 1 / (1 + (a.degree || 0)); const bWeight = 1 / (1 + (b.degree || 0)); const total = aWeight + bWeight; const aRatio = aWeight / total; const bRatio = bWeight / total; // Move along axis with minimum overlap (less disruption) if (ox < oy) { const sign = (a.x > b.x) ? 1 : -1; const shift = ox + 0.2; // Total separation needed a.x += shift * sign * aRatio; b.x -= shift * sign * bRatio; } else { const sign = (a.y > b.y) ? 1 : -1; const shift = oy + 0.2; a.y += shift * sign * aRatio; b.y -= shift * sign * bRatio; } return true; }