Spaces:
Running
Running
| /** | |
| * GraphSearch - Tìm Kiếm Node Trong Đồ Thị | |
| * Cung cấp ô tìm kiếm trong khu vực đồ thị, highlight và auto-zoom đến node tìm thấy. | |
| * Requirements: 30.1, 30.2, 30.3, 30.4, 30.5, 30.6 | |
| */ | |
| class GraphSearch { | |
| /** | |
| * @param {string} graphContainerSelector - CSS selector cho container đồ thị (ví dụ: '#graphContainer') | |
| */ | |
| constructor(graphContainerSelector) { | |
| /** @type {string} */ | |
| this._graphContainerSelector = graphContainerSelector; | |
| /** @type {HTMLElement|null} */ | |
| this._graphContainer = null; | |
| /** @type {HTMLElement|null} */ | |
| this._searchContainer = null; | |
| /** @type {HTMLInputElement|null} */ | |
| this._searchInput = null; | |
| /** @type {HTMLElement|null} */ | |
| this._countDisplay = null; | |
| /** @type {HTMLElement|null} */ | |
| this._noResultsDisplay = null; | |
| /** @type {HTMLElement|null} */ | |
| this._navContainer = null; | |
| /** @type {Array<string>} - List of matched node IDs */ | |
| this._results = []; | |
| /** @type {number} - Current result index */ | |
| this._currentIndex = -1; | |
| /** @type {number|null} - Debounce timer ID */ | |
| this._debounceTimer = null; | |
| /** @type {number} - Debounce delay in ms */ | |
| this._debounceDelay = 300; | |
| } | |
| /** | |
| * Khởi tạo: tạo ô tìm kiếm, gắn event listeners. | |
| */ | |
| init() { | |
| this._graphContainer = document.querySelector(this._graphContainerSelector); | |
| if (!this._graphContainer) { | |
| console.warn('[GraphSearch] Graph container not found:', this._graphContainerSelector); | |
| return; | |
| } | |
| this._createSearchUI(); | |
| this._bindEvents(); | |
| console.log('[GraphSearch] Initialized'); | |
| } | |
| /** | |
| * Tìm kiếm node theo từ khóa (tên hoặc opType). | |
| * @param {string} query - Từ khóa tìm kiếm | |
| * @returns {Array<string>} Danh sách node IDs khớp | |
| */ | |
| search(query) { | |
| var cy = this._getCy(); | |
| this._clearHighlights(); | |
| this._results = []; | |
| this._currentIndex = -1; | |
| if (!query || !query.trim() || !cy) { | |
| this._updateDisplay(); | |
| return this._results; | |
| } | |
| var lowerQuery = query.trim().toLowerCase(); | |
| cy.nodes().forEach(function (node) { | |
| var label = (node.data('label') || '').toLowerCase(); | |
| var name = (node.data('name') || '').toLowerCase(); | |
| var opType = (node.data('opType') || '').toLowerCase(); | |
| if (label.indexOf(lowerQuery) !== -1 || | |
| name.indexOf(lowerQuery) !== -1 || | |
| opType.indexOf(lowerQuery) !== -1) { | |
| this._results.push(node.id()); | |
| } | |
| }.bind(this)); | |
| // Highlight all matched nodes | |
| for (var i = 0; i < this._results.length; i++) { | |
| var node = cy.getElementById(this._results[i]); | |
| if (node && node.length > 0) { | |
| node.addClass('search-highlighted'); | |
| } | |
| } | |
| // Navigate to first result | |
| if (this._results.length > 0) { | |
| this._currentIndex = 0; | |
| this._goToCurrentResult(); | |
| } | |
| this._updateDisplay(); | |
| return this._results; | |
| } | |
| /** | |
| * Highlight và zoom đến node tiếp theo trong kết quả. | |
| */ | |
| next() { | |
| if (this._results.length === 0) return; | |
| this._currentIndex = (this._currentIndex + 1) % this._results.length; | |
| this._goToCurrentResult(); | |
| this._updateDisplay(); | |
| } | |
| /** | |
| * Highlight và zoom đến node trước đó trong kết quả. | |
| */ | |
| prev() { | |
| if (this._results.length === 0) return; | |
| this._currentIndex = (this._currentIndex - 1 + this._results.length) % this._results.length; | |
| this._goToCurrentResult(); | |
| this._updateDisplay(); | |
| } | |
| /** | |
| * Xóa tất cả highlight tìm kiếm. | |
| */ | |
| clearSearch() { | |
| this._clearHighlights(); | |
| this._results = []; | |
| this._currentIndex = -1; | |
| if (this._searchInput) { | |
| this._searchInput.value = ''; | |
| } | |
| this._updateDisplay(); | |
| } | |
| /** | |
| * Hủy và dọn dẹp. | |
| */ | |
| destroy() { | |
| if (this._debounceTimer) { | |
| clearTimeout(this._debounceTimer); | |
| this._debounceTimer = null; | |
| } | |
| this._clearHighlights(); | |
| if (this._searchContainer && this._searchContainer.parentNode) { | |
| this._searchContainer.parentNode.removeChild(this._searchContainer); | |
| } | |
| this._searchContainer = null; | |
| this._searchInput = null; | |
| this._countDisplay = null; | |
| this._noResultsDisplay = null; | |
| this._navContainer = null; | |
| this._results = []; | |
| this._currentIndex = -1; | |
| } | |
| // ─── Private Helpers ─────────────────────────────────────────────────────── | |
| /** | |
| * Get the Cytoscape instance from the app. | |
| * @returns {cytoscape.Core|null} | |
| * @private | |
| */ | |
| _getCy() { | |
| try { | |
| if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') { | |
| var gv = window._onnxApp.getGraphVisualizer(); | |
| if (gv && gv._cy) { | |
| return gv._cy; | |
| } | |
| } | |
| } catch (e) { | |
| console.warn('[GraphSearch] Could not access Cytoscape instance:', e); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Create the search UI elements and insert above the graph container. | |
| * @private | |
| */ | |
| _createSearchUI() { | |
| // Main container | |
| var container = document.createElement('div'); | |
| container.className = 'graph-search-container'; | |
| // Search input | |
| var input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.className = 'graph-search-input'; | |
| input.placeholder = 'Tìm kiếm node (tên hoặc opType)...'; | |
| input.setAttribute('aria-label', 'Search graph nodes'); | |
| this._searchInput = input; | |
| // Navigation container (hidden by default) | |
| var navContainer = document.createElement('div'); | |
| navContainer.className = 'graph-search-nav'; | |
| navContainer.style.display = 'none'; | |
| this._navContainer = navContainer; | |
| // Count display | |
| var countDisplay = document.createElement('span'); | |
| countDisplay.className = 'graph-search-count'; | |
| this._countDisplay = countDisplay; | |
| // Prev button | |
| var prevBtn = document.createElement('button'); | |
| prevBtn.type = 'button'; | |
| prevBtn.innerHTML = '<i class="fas fa-chevron-up"></i>'; | |
| prevBtn.title = 'Previous result'; | |
| prevBtn.setAttribute('aria-label', 'Previous search result'); | |
| prevBtn.addEventListener('click', this.prev.bind(this)); | |
| // Next button | |
| var nextBtn = document.createElement('button'); | |
| nextBtn.type = 'button'; | |
| nextBtn.innerHTML = '<i class="fas fa-chevron-down"></i>'; | |
| nextBtn.title = 'Next result'; | |
| nextBtn.setAttribute('aria-label', 'Next search result'); | |
| nextBtn.addEventListener('click', this.next.bind(this)); | |
| navContainer.appendChild(countDisplay); | |
| navContainer.appendChild(prevBtn); | |
| navContainer.appendChild(nextBtn); | |
| // No results display (hidden by default) | |
| var noResults = document.createElement('span'); | |
| noResults.className = 'graph-search-count'; | |
| noResults.textContent = 'Không tìm thấy kết quả'; | |
| noResults.style.display = 'none'; | |
| noResults.style.color = '#dc3545'; | |
| this._noResultsDisplay = noResults; | |
| container.appendChild(input); | |
| container.appendChild(navContainer); | |
| container.appendChild(noResults); | |
| // Insert above #graphContainer in the card body | |
| var parent = this._graphContainer.parentNode; | |
| if (parent) { | |
| parent.insertBefore(container, this._graphContainer); | |
| } | |
| this._searchContainer = container; | |
| } | |
| /** | |
| * Bind event listeners. | |
| * @private | |
| */ | |
| _bindEvents() { | |
| if (!this._searchInput) return; | |
| this._searchInput.addEventListener('input', function () { | |
| if (this._debounceTimer) { | |
| clearTimeout(this._debounceTimer); | |
| } | |
| this._debounceTimer = setTimeout(function () { | |
| var query = this._searchInput.value; | |
| this.search(query); | |
| }.bind(this), this._debounceDelay); | |
| }.bind(this)); | |
| // Allow Enter to go to next result | |
| this._searchInput.addEventListener('keydown', function (e) { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| if (e.shiftKey) { | |
| this.prev(); | |
| } else { | |
| this.next(); | |
| } | |
| } | |
| if (e.key === 'Escape') { | |
| this.clearSearch(); | |
| this._searchInput.blur(); | |
| } | |
| }.bind(this)); | |
| } | |
| /** | |
| * Navigate to the current result node (highlight + zoom). | |
| * @private | |
| */ | |
| _goToCurrentResult() { | |
| var cy = this._getCy(); | |
| if (!cy || this._currentIndex < 0 || this._currentIndex >= this._results.length) return; | |
| // Remove 'search-active' from all nodes | |
| cy.nodes().removeClass('search-active'); | |
| var nodeId = this._results[this._currentIndex]; | |
| var node = cy.getElementById(nodeId); | |
| if (node && node.length > 0) { | |
| node.addClass('search-active'); | |
| cy.animate({ | |
| center: { eles: node }, | |
| zoom: 1.5, | |
| duration: 300 | |
| }); | |
| } | |
| } | |
| /** | |
| * Clear all search highlights from the graph. | |
| * @private | |
| */ | |
| _clearHighlights() { | |
| var cy = this._getCy(); | |
| if (!cy) return; | |
| cy.nodes().removeClass('search-highlighted'); | |
| cy.nodes().removeClass('search-active'); | |
| } | |
| /** | |
| * Update the display (count, nav visibility, no-results message). | |
| * @private | |
| */ | |
| _updateDisplay() { | |
| var hasQuery = this._searchInput && this._searchInput.value.trim().length > 0; | |
| var hasResults = this._results.length > 0; | |
| // Navigation container | |
| if (this._navContainer) { | |
| this._navContainer.style.display = (hasQuery && hasResults) ? 'flex' : 'none'; | |
| } | |
| // Count display | |
| if (this._countDisplay) { | |
| if (hasResults) { | |
| this._countDisplay.textContent = (this._currentIndex + 1) + '/' + this._results.length; | |
| } else { | |
| this._countDisplay.textContent = ''; | |
| } | |
| } | |
| // No results message | |
| if (this._noResultsDisplay) { | |
| this._noResultsDisplay.style.display = (hasQuery && !hasResults) ? 'inline' : 'none'; | |
| } | |
| } | |
| } | |
| // Export as global for browser usage | |
| window.GraphSearch = GraphSearch; | |