Spaces:
Running
Running
| /** | |
| * 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 = '<i class="fas fa-map"></i>'; | |
| 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; | |