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