Spaces:
Running
Running
| /** | |
| * GraphAnnotation - Ghi Chú Đồ Thị | |
| * Cho phép thêm/sửa/xóa ghi chú trên node trong đồ thị Cytoscape | |
| * Lưu/khôi phục annotations từ localStorage | |
| * Requirements: 33.1, 33.2, 33.3, 33.4, 33.5 | |
| */ | |
| class GraphAnnotation { | |
| /** | |
| * @param {string} graphContainerSelector - CSS selector cho container đồ thị | |
| * @param {string} [storagePrefix='onnx_explorer_annotations_'] - Prefix key localStorage | |
| */ | |
| constructor(graphContainerSelector, storagePrefix) { | |
| this._containerSelector = graphContainerSelector || '#graphContainer'; | |
| this._storagePrefix = storagePrefix || 'onnx_explorer_annotations_'; | |
| /** @type {Object<string, string>} nodeId → annotation text */ | |
| this._annotations = {}; | |
| /** @type {string|null} current model file name */ | |
| this._modelFileName = null; | |
| /** @type {HTMLElement|null} popup element */ | |
| this._popup = null; | |
| /** @type {Function|null} EventBus unsubscribe for model:loaded */ | |
| this._unsubModelLoaded = null; | |
| /** @type {Function|null} EventBus unsubscribe for graph:rendered */ | |
| this._unsubGraphRendered = null; | |
| } | |
| /** | |
| * Khởi tạo: gắn event listeners cho context menu và model:loaded | |
| */ | |
| init() { | |
| this._bindModelLoadedEvent(); | |
| this._bindGraphRenderedEvent(); | |
| console.log('[GraphAnnotation] Initialized'); | |
| } | |
| /** | |
| * Hiển thị popup để thêm/sửa ghi chú cho một node | |
| * @param {string} nodeId - ID của node | |
| */ | |
| showAnnotationPopup(nodeId) { | |
| // Remove existing popup | |
| this._removePopup(); | |
| const existing = this.getAnnotation(nodeId); | |
| // Create popup element | |
| const popup = document.createElement('div'); | |
| popup.className = 'annotation-popup'; | |
| popup.setAttribute('role', 'dialog'); | |
| popup.setAttribute('aria-label', 'Ghi chú node'); | |
| const header = document.createElement('div'); | |
| header.className = 'annotation-popup-header'; | |
| header.textContent = 'Ghi chú: ' + nodeId; | |
| const textarea = document.createElement('textarea'); | |
| textarea.className = 'annotation-popup-textarea'; | |
| textarea.placeholder = 'Nhập ghi chú cho node này...'; | |
| textarea.value = existing || ''; | |
| textarea.rows = 4; | |
| const btnContainer = document.createElement('div'); | |
| btnContainer.className = 'annotation-popup-buttons'; | |
| const saveBtn = document.createElement('button'); | |
| saveBtn.className = 'annotation-btn annotation-btn-save'; | |
| saveBtn.textContent = 'Lưu'; | |
| saveBtn.addEventListener('click', () => { | |
| const text = textarea.value.trim(); | |
| if (text) { | |
| this.saveAnnotation(nodeId, text); | |
| } | |
| this._removePopup(); | |
| }); | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = 'annotation-btn annotation-btn-delete'; | |
| deleteBtn.textContent = 'Xóa'; | |
| deleteBtn.addEventListener('click', () => { | |
| this.deleteAnnotation(nodeId); | |
| this._removePopup(); | |
| }); | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.className = 'annotation-btn annotation-btn-close'; | |
| closeBtn.textContent = 'Đóng'; | |
| closeBtn.addEventListener('click', () => { | |
| this._removePopup(); | |
| }); | |
| btnContainer.appendChild(saveBtn); | |
| btnContainer.appendChild(deleteBtn); | |
| btnContainer.appendChild(closeBtn); | |
| popup.appendChild(header); | |
| popup.appendChild(textarea); | |
| popup.appendChild(btnContainer); | |
| // Position popup in the graph container | |
| const container = document.querySelector(this._containerSelector); | |
| if (container) { | |
| container.style.position = 'relative'; | |
| popup.style.position = 'absolute'; | |
| popup.style.top = '50%'; | |
| popup.style.left = '50%'; | |
| popup.style.transform = 'translate(-50%, -50%)'; | |
| popup.style.zIndex = '1000'; | |
| container.appendChild(popup); | |
| } else { | |
| document.body.appendChild(popup); | |
| } | |
| this._popup = popup; | |
| // Focus textarea | |
| requestAnimationFrame(() => textarea.focus()); | |
| } | |
| /** | |
| * Lưu ghi chú cho một node | |
| * @param {string} nodeId - ID của node | |
| * @param {string} text - Nội dung ghi chú | |
| */ | |
| saveAnnotation(nodeId, text) { | |
| if (!nodeId || !text) return; | |
| this._annotations[nodeId] = text; | |
| this._persistToStorage(); | |
| this._addBadgeToNode(nodeId); | |
| console.log('[GraphAnnotation] Saved annotation for', nodeId); | |
| } | |
| /** | |
| * Xóa ghi chú của một node | |
| * @param {string} nodeId - ID của node | |
| */ | |
| deleteAnnotation(nodeId) { | |
| if (!nodeId || !(nodeId in this._annotations)) return; | |
| delete this._annotations[nodeId]; | |
| this._persistToStorage(); | |
| this._removeBadgeFromNode(nodeId); | |
| console.log('[GraphAnnotation] Deleted annotation for', nodeId); | |
| } | |
| /** | |
| * Lấy ghi chú của một node | |
| * @param {string} nodeId - ID của node | |
| * @returns {string|null} | |
| */ | |
| getAnnotation(nodeId) { | |
| return this._annotations[nodeId] || null; | |
| } | |
| /** | |
| * Lấy tất cả annotations cho model hiện tại | |
| * @returns {Object} Map nodeId → annotation text | |
| */ | |
| getAllAnnotations() { | |
| return Object.assign({}, this._annotations); | |
| } | |
| /** | |
| * Tải annotations từ localStorage cho model hiện tại | |
| * @param {string} modelFileName - Tên tệp mô hình | |
| */ | |
| loadAnnotations(modelFileName) { | |
| if (!modelFileName) { | |
| this._annotations = {}; | |
| this._modelFileName = null; | |
| return; | |
| } | |
| this._modelFileName = modelFileName; | |
| const key = this._storagePrefix + modelFileName; | |
| try { | |
| const raw = localStorage.getItem(key); | |
| this._annotations = raw ? JSON.parse(raw) : {}; | |
| } catch (err) { | |
| console.warn('[GraphAnnotation] Failed to load annotations:', err); | |
| this._annotations = {}; | |
| } | |
| console.log('[GraphAnnotation] Loaded annotations for', modelFileName, | |
| '- count:', Object.keys(this._annotations).length); | |
| } | |
| /** | |
| * Khôi phục badges trên tất cả node có annotation (gọi sau khi graph rendered) | |
| */ | |
| restoreBadges() { | |
| const cy = this._getCyInstance(); | |
| if (!cy) return; | |
| const nodeIds = Object.keys(this._annotations); | |
| for (var i = 0; i < nodeIds.length; i++) { | |
| this._addBadgeToNode(nodeIds[i]); | |
| } | |
| } | |
| /** | |
| * Hủy và dọn dẹp | |
| */ | |
| destroy() { | |
| this._removePopup(); | |
| this._removeCxttapListener(); | |
| if (this._unsubModelLoaded) { | |
| this._unsubModelLoaded(); | |
| this._unsubModelLoaded = null; | |
| } | |
| if (this._unsubGraphRendered) { | |
| this._unsubGraphRendered(); | |
| this._unsubGraphRendered = null; | |
| } | |
| this._annotations = {}; | |
| this._modelFileName = null; | |
| console.log('[GraphAnnotation] Destroyed'); | |
| } | |
| // ─── Private Helpers ─────────────────────────────────────────────────────── | |
| /** | |
| * Get the Cytoscape instance | |
| * @returns {cytoscape.Core|null} | |
| * @private | |
| */ | |
| _getCyInstance() { | |
| try { | |
| if (window._onnxApp && window._onnxApp.getGraphVisualizer) { | |
| var gv = window._onnxApp.getGraphVisualizer(); | |
| return gv ? gv._cy : null; | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| return null; | |
| } | |
| /** | |
| * Get current model file name from StateManager | |
| * @returns {string|null} | |
| * @private | |
| */ | |
| _getModelFileName() { | |
| try { | |
| if (window.StateManager) { | |
| var model = window.StateManager.getCurrentModel(); | |
| if (model && model.metadata && model.metadata.fileName) { | |
| return model.metadata.fileName; | |
| } | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| return null; | |
| } | |
| /** | |
| * Persist annotations to localStorage | |
| * @private | |
| */ | |
| _persistToStorage() { | |
| if (!this._modelFileName) return; | |
| var key = this._storagePrefix + this._modelFileName; | |
| try { | |
| var data = JSON.stringify(this._annotations); | |
| localStorage.setItem(key, data); | |
| } catch (err) { | |
| console.warn('[GraphAnnotation] Failed to persist annotations:', err); | |
| } | |
| } | |
| /** | |
| * Add 'has-annotation' class to a Cytoscape node | |
| * @param {string} nodeId | |
| * @private | |
| */ | |
| _addBadgeToNode(nodeId) { | |
| var cy = this._getCyInstance(); | |
| if (!cy) return; | |
| var node = cy.getElementById(nodeId); | |
| if (node && node.length > 0) { | |
| node.addClass('has-annotation'); | |
| } | |
| } | |
| /** | |
| * Remove 'has-annotation' class from a Cytoscape node | |
| * @param {string} nodeId | |
| * @private | |
| */ | |
| _removeBadgeFromNode(nodeId) { | |
| var cy = this._getCyInstance(); | |
| if (!cy) return; | |
| var node = cy.getElementById(nodeId); | |
| if (node && node.length > 0) { | |
| node.removeClass('has-annotation'); | |
| } | |
| } | |
| /** | |
| * Bind cxttap (right-click) event on Cytoscape nodes | |
| * @private | |
| */ | |
| _bindCxttapListener() { | |
| var cy = this._getCyInstance(); | |
| if (!cy) return; | |
| // Remove previous listener to avoid duplicates | |
| this._removeCxttapListener(); | |
| var self = this; | |
| this._cxttapHandler = function (evt) { | |
| var node = evt.target; | |
| var nodeId = node.data('id'); | |
| if (nodeId) { | |
| self.showAnnotationPopup(nodeId); | |
| } | |
| }; | |
| cy.on('cxttap', 'node', this._cxttapHandler); | |
| } | |
| /** | |
| * Remove cxttap listener | |
| * @private | |
| */ | |
| _removeCxttapListener() { | |
| var cy = this._getCyInstance(); | |
| if (cy && this._cxttapHandler) { | |
| cy.off('cxttap', 'node', this._cxttapHandler); | |
| } | |
| this._cxttapHandler = null; | |
| } | |
| /** | |
| * Listen for model:loaded event to auto-load annotations | |
| * @private | |
| */ | |
| _bindModelLoadedEvent() { | |
| if (!window.EventBus) return; | |
| var self = this; | |
| var eventName = (typeof CONFIG !== 'undefined' && CONFIG.EVENTS) | |
| ? CONFIG.EVENTS.MODEL_LOADED | |
| : 'model:loaded'; | |
| this._unsubModelLoaded = window.EventBus.on(eventName, function (payload) { | |
| var fileName = self._getModelFileName(); | |
| if (fileName) { | |
| self.loadAnnotations(fileName); | |
| } | |
| }); | |
| } | |
| /** | |
| * Listen for graph:rendered event to bind cxttap and restore badges | |
| * @private | |
| */ | |
| _bindGraphRenderedEvent() { | |
| if (!window.EventBus) return; | |
| var self = this; | |
| var eventName = (typeof CONFIG !== 'undefined' && CONFIG.EVENTS) | |
| ? CONFIG.EVENTS.GRAPH_RENDERED | |
| : 'graph:rendered'; | |
| this._unsubGraphRendered = window.EventBus.on(eventName, function () { | |
| self._bindCxttapListener(); | |
| self.restoreBadges(); | |
| }); | |
| } | |
| /** | |
| * Remove popup from DOM | |
| * @private | |
| */ | |
| _removePopup() { | |
| if (this._popup && this._popup.parentNode) { | |
| this._popup.parentNode.removeChild(this._popup); | |
| } | |
| this._popup = null; | |
| } | |
| } | |
| // Export as global for browser usage | |
| window.GraphAnnotation = GraphAnnotation; | |