/** * GraphMinimap - Bản đồ thu nhỏ toàn bộ đồ thị * Hiển thị minimap ở góc màn hình, cho phép click/drag để điều hướng đồ thị chính. * Tự động ẩn khi số node < 20. Cung cấp nút toggle ẩn/hiện. * Requirements: 17.1, 17.2, 17.3, 17.4, 17.5 */ class GraphMinimap { /** * @param {string} graphContainerId - ID of the main graph container element */ constructor(graphContainerId) { /** @type {string} */ this._graphContainerId = graphContainerId; /** @type {HTMLElement|null} Minimap overlay wrapper */ this._wrapper = null; /** @type {HTMLElement|null} Canvas for minimap rendering */ this._canvas = null; /** @type {HTMLElement|null} Viewport indicator rectangle */ this._viewport = null; /** @type {HTMLElement|null} Toggle button */ this._toggleBtn = null; /** @type {boolean} User preference: minimap visible */ this._userVisible = true; /** @type {boolean} Auto-hidden because node count < threshold */ this._autoHidden = false; /** @type {number} Threshold below which minimap auto-hides */ this._autoHideThreshold = 20; /** @type {boolean} Whether user is dragging on the minimap */ this._isDragging = false; /** @type {Function|null} Bound handler refs for cleanup */ this._onMouseMoveBound = null; this._onMouseUpBound = null; /** @type {Function|null} EventBus unsubscribe for graph:rendered */ this._unsubGraphRendered = null; /** @type {Function|null} EventBus unsubscribe for model:loaded */ this._unsubModelLoaded = null; /** @type {number|null} requestAnimationFrame id */ this._rafId = null; this._createDOM(); this._bindEvents(); } // ─── DOM Creation ────────────────────────────────────────────────────────── /** * Create the minimap overlay, canvas, viewport indicator, and toggle button. * @private */ _createDOM() { // Wrapper – positioned in bottom-right corner of the graph container const wrapper = document.createElement('div'); wrapper.className = 'graph-minimap-wrapper'; wrapper.setAttribute('role', 'navigation'); wrapper.setAttribute('aria-label', 'Graph minimap'); wrapper.style.cssText = [ 'position: absolute', 'bottom: 12px', 'right: 12px', 'width: 180px', 'height: 130px', 'background: rgba(255,255,255,0.95)', 'border: 1px solid #ccc', 'border-radius: 6px', 'box-shadow: 0 2px 8px rgba(0,0,0,0.15)', 'overflow: hidden', 'z-index: 100', 'display: none', 'cursor: crosshair', 'user-select: none' ].join(';'); this._wrapper = wrapper; // Canvas for drawing the minimap graph const canvas = document.createElement('canvas'); canvas.className = 'graph-minimap-canvas'; canvas.width = 180; canvas.height = 130; canvas.style.cssText = 'width:100%;height:100%;display:block;'; wrapper.appendChild(canvas); this._canvas = canvas; // Viewport indicator (semi-transparent rectangle showing visible area) const viewport = document.createElement('div'); viewport.className = 'graph-minimap-viewport'; viewport.style.cssText = [ 'position: absolute', 'border: 2px solid #0d6efd', 'background: rgba(13,110,253,0.1)', 'pointer-events: none', 'transition: none' ].join(';'); wrapper.appendChild(viewport); this._viewport = viewport; // Toggle button – always visible near the minimap position const toggleBtn = document.createElement('button'); toggleBtn.className = 'graph-minimap-toggle btn btn-sm btn-outline-secondary'; toggleBtn.setAttribute('aria-label', 'Toggle minimap'); toggleBtn.setAttribute('title', 'Toggle minimap'); toggleBtn.innerHTML = ''; toggleBtn.style.cssText = [ 'position: absolute', 'bottom: 12px', 'right: 12px', 'z-index: 101', 'display: none', 'opacity: 0.8' ].join(';'); this._toggleBtn = toggleBtn; // Append to graph container's parent (so they overlay the graph) const graphContainer = document.getElementById(this._graphContainerId); if (graphContainer) { // Ensure parent is positioned for absolute children const parent = graphContainer.parentElement; if (parent) { const pos = window.getComputedStyle(parent).position; if (pos === 'static') { parent.style.position = 'relative'; } parent.appendChild(wrapper); parent.appendChild(toggleBtn); } } } // ─── Event Binding ───────────────────────────────────────────────────────── /** * Bind all event listeners. * @private */ _bindEvents() { // Toggle button click if (this._toggleBtn) { this._toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); } // Minimap click → navigate main graph if (this._wrapper) { this._wrapper.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this._isDragging = true; this._navigateToPosition(e); }); this._onMouseMoveBound = (e) => { if (this._isDragging) { e.preventDefault(); this._navigateToPosition(e); } }; this._onMouseUpBound = () => { this._isDragging = false; }; document.addEventListener('mousemove', this._onMouseMoveBound); document.addEventListener('mouseup', this._onMouseUpBound); } // Listen for graph:rendered to update minimap if (typeof EventBus !== 'undefined' && typeof CONFIG !== 'undefined') { this._unsubGraphRendered = EventBus.on(CONFIG.EVENTS.GRAPH_RENDERED, () => { this._onGraphUpdated(); }); this._unsubModelLoaded = EventBus.on(CONFIG.EVENTS.MODEL_LOADED, () => { // Small delay to let graph render finish setTimeout(() => this._onGraphUpdated(), 300); }); } } // ─── Core Logic ──────────────────────────────────────────────────────────── /** * Called when the main graph is updated/rendered. * Determines visibility and redraws the minimap. * @private */ _onGraphUpdated() { const cy = this._getCytoscapeInstance(); if (!cy) { this._hide(); return; } const nodeCount = cy.nodes().length; // Auto-hide when fewer than threshold nodes if (nodeCount < this._autoHideThreshold) { this._autoHidden = true; this._hide(); if (this._toggleBtn) { this._toggleBtn.style.display = 'none'; } return; } this._autoHidden = false; // Show toggle button if (this._toggleBtn) { this._toggleBtn.style.display = 'block'; } // Show minimap if user preference is visible if (this._userVisible) { this._show(); } this._drawMinimap(cy); this._updateViewport(cy); this._listenToCytoscapeEvents(cy); } /** * Draw the minimap representation of the graph onto the canvas. * @param {cytoscape.Core} cy * @private */ _drawMinimap(cy) { if (!this._canvas) return; const ctx = this._canvas.getContext('2d'); if (!ctx) return; const cw = this._canvas.width; const ch = this._canvas.height; ctx.clearRect(0, 0, cw, ch); // Get bounding box of all elements const bb = cy.elements().boundingBox(); if (!bb || bb.w === 0 || bb.h === 0) return; // Compute scale to fit graph into minimap with padding const padding = 8; const scaleX = (cw - padding * 2) / bb.w; const scaleY = (ch - padding * 2) / bb.h; const scale = Math.min(scaleX, scaleY); const offsetX = padding + ((cw - padding * 2) - bb.w * scale) / 2; const offsetY = padding + ((ch - padding * 2) - bb.h * scale) / 2; // Draw edges ctx.strokeStyle = 'rgba(150,150,150,0.4)'; ctx.lineWidth = 0.5; cy.edges().forEach(function (edge) { const src = edge.source().position(); const tgt = edge.target().position(); const x1 = (src.x - bb.x1) * scale + offsetX; const y1 = (src.y - bb.y1) * scale + offsetY; const x2 = (tgt.x - bb.x1) * scale + offsetX; const y2 = (tgt.y - bb.y1) * scale + offsetY; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); }); // Draw nodes cy.nodes().forEach(function (node) { var pos = node.position(); var x = (pos.x - bb.x1) * scale + offsetX; var y = (pos.y - bb.y1) * scale + offsetY; var color = '#87CEEB'; // default if (node.hasClass('input-node')) color = '#28a745'; else if (node.hasClass('output-node')) color = '#dc3545'; else if (node.hasClass('initializer-node')) color = '#6c757d'; else if (node.hasClass('highlighted')) color = '#FFD700'; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); }); // Store transform info for click-to-navigate this._transform = { bb: bb, scale: scale, offsetX: offsetX, offsetY: offsetY }; } /** * Update the viewport indicator rectangle to reflect the main graph's visible area. * @param {cytoscape.Core} cy * @private */ _updateViewport(cy) { if (!this._viewport || !this._canvas || !this._transform) return; var t = this._transform; var ext = cy.extent(); // visible area in model coordinates var left = (ext.x1 - t.bb.x1) * t.scale + t.offsetX; var top = (ext.y1 - t.bb.y1) * t.scale + t.offsetY; var width = ext.w * t.scale; var height = ext.h * t.scale; // Clamp to canvas bounds var cw = this._canvas.width; var ch = this._canvas.height; left = Math.max(0, Math.min(left, cw)); top = Math.max(0, Math.min(top, ch)); width = Math.min(width, cw - left); height = Math.min(height, ch - top); this._viewport.style.left = left + 'px'; this._viewport.style.top = top + 'px'; this._viewport.style.width = Math.max(width, 4) + 'px'; this._viewport.style.height = Math.max(height, 4) + 'px'; } /** * Listen to Cytoscape viewport events (zoom, pan) to keep minimap in sync. * @param {cytoscape.Core} cy * @private */ _listenToCytoscapeEvents(cy) { // Remove previous listeners to avoid duplicates cy.off('viewport.minimap'); cy.on('viewport.minimap', () => { if (this._rafId) cancelAnimationFrame(this._rafId); this._rafId = requestAnimationFrame(() => { this._updateViewport(cy); }); }); } /** * Navigate the main graph to the position clicked/dragged on the minimap. * @param {MouseEvent} e * @private */ _navigateToPosition(e) { if (!this._wrapper || !this._transform) return; var cy = this._getCytoscapeInstance(); if (!cy) return; var rect = this._wrapper.getBoundingClientRect(); var clickX = e.clientX - rect.left; var clickY = e.clientY - rect.top; var t = this._transform; // Convert minimap coordinates back to graph model coordinates var modelX = (clickX - t.offsetX) / t.scale + t.bb.x1; var modelY = (clickY - t.offsetY) / t.scale + t.bb.y1; // Pan so the clicked model point is centered in the viewport var zoom = cy.zoom(); cy.pan({ x: cy.width() / 2 - modelX * zoom, y: cy.height() / 2 - modelY * zoom }); // Update viewport indicator immediately this._updateViewport(cy); } /** * Get the Cytoscape instance from the GraphVisualizer. * @returns {cytoscape.Core|null} * @private */ _getCytoscapeInstance() { // Try via the exposed _onnxApp debug interface if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') { var gv = window._onnxApp.getGraphVisualizer(); if (gv && gv._cy) { return gv._cy; } } return null; } // ─── Visibility ──────────────────────────────────────────────────────────── /** * Show the minimap overlay. * @private */ _show() { if (this._wrapper) { this._wrapper.style.display = 'block'; } // Shift toggle button to the left of the minimap if (this._toggleBtn) { this._toggleBtn.style.right = '198px'; } } /** * Hide the minimap overlay. * @private */ _hide() { if (this._wrapper) { this._wrapper.style.display = 'none'; } // Reset toggle button position if (this._toggleBtn) { this._toggleBtn.style.right = '12px'; } } // ─── Public API ──────────────────────────────────────────────────────────── /** * Toggle minimap visibility (user preference). */ toggle() { if (this._autoHidden) return; // Can't toggle when auto-hidden this._userVisible = !this._userVisible; if (this._userVisible) { this._show(); // Redraw in case graph changed while hidden var cy = this._getCytoscapeInstance(); if (cy) { this._drawMinimap(cy); this._updateViewport(cy); } } else { this._hide(); } } /** * Show the minimap (programmatic). */ show() { this._userVisible = true; if (!this._autoHidden) { this._show(); } } /** * Hide the minimap (programmatic). */ hide() { this._userVisible = false; this._hide(); } /** * Force a redraw of the minimap. Call after external graph changes. */ refresh() { this._onGraphUpdated(); } /** * Check if the minimap is currently visible. * @returns {boolean} */ isVisible() { return this._wrapper ? this._wrapper.style.display !== 'none' : false; } /** * Destroy and clean up all resources. */ destroy() { // Cancel pending animation frame if (this._rafId) { cancelAnimationFrame(this._rafId); this._rafId = null; } // Remove document-level listeners if (this._onMouseMoveBound) { document.removeEventListener('mousemove', this._onMouseMoveBound); this._onMouseMoveBound = null; } if (this._onMouseUpBound) { document.removeEventListener('mouseup', this._onMouseUpBound); this._onMouseUpBound = null; } // Unsubscribe EventBus if (this._unsubGraphRendered) { this._unsubGraphRendered(); this._unsubGraphRendered = null; } if (this._unsubModelLoaded) { this._unsubModelLoaded(); this._unsubModelLoaded = null; } // Remove Cytoscape viewport listener var cy = this._getCytoscapeInstance(); if (cy) { cy.off('viewport.minimap'); } // Remove DOM elements if (this._wrapper && this._wrapper.parentNode) { this._wrapper.parentNode.removeChild(this._wrapper); } if (this._toggleBtn && this._toggleBtn.parentNode) { this._toggleBtn.parentNode.removeChild(this._toggleBtn); } this._wrapper = null; this._canvas = null; this._viewport = null; this._toggleBtn = null; this._transform = null; } } // Export as global for browser usage window.GraphMinimap = GraphMinimap;