model-explorer / js /ui /graphAnnotation.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* 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;