/** * 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} - 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} 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 = ''; 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 = ''; 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;