/** * NodeGrouping - Gom Nhóm Node Theo OpType * Cung cấp toggle để gom nhóm các node cùng opType thành compound nodes trong Cytoscape. * Requirements: 32.1, 32.2, 32.3, 32.4, 32.5 */ const NodeGrouping = (function () { 'use strict'; class NodeGrouping { constructor() { /** @type {boolean} */ this._enabled = false; /** @type {HTMLButtonElement|null} */ this._toggleBtn = null; /** @type {Set} IDs of compound parent nodes we created */ this._groupIds = new Set(); /** @type {Set} IDs of collapsed groups */ this._collapsedGroups = new Set(); /** @type {Function|null} */ this._unsubscribeGraphRendered = null; /** @type {Function|null} */ this._compoundTapHandler = null; /** @type {boolean} */ this._initialized = false; } // ─── Public API ───────────────────────────────────────────────────── /** * Khởi tạo: tạo toggle button, lắng nghe graph:rendered. */ init() { if (this._initialized) return; this._createToggleButton(); if (window.EventBus) { this._unsubscribeGraphRendered = window.EventBus.on( CONFIG.EVENTS.GRAPH_RENDERED, this._onGraphRendered.bind(this) ); } this._initialized = true; console.log('[NodeGrouping] Initialized'); } /** * Bật chế độ gom nhóm theo opType. */ enableGrouping() { var cy = this._getCytoscapeInstance(); if (!cy) { console.warn('[NodeGrouping] Cytoscape instance not available'); return; } // Collect op-nodes grouped by opType var groups = {}; cy.nodes().forEach(function (node) { var opType = node.data('opType'); if (!opType) return; // Skip nodes that are already compound parents if (node.hasClass('group-parent')) return; if (!groups[opType]) { groups[opType] = []; } groups[opType].push(node); }); // Create compound parent for each opType group var opTypes = Object.keys(groups); for (var i = 0; i < opTypes.length; i++) { var opType = opTypes[i]; var nodes = groups[opType]; var groupId = '_group_' + opType; // Add compound parent node cy.add({ group: 'nodes', data: { id: groupId, label: opType + ' (' + nodes.length + ')', opType: opType, isGroup: true, childCount: nodes.length }, classes: 'group-parent' }); this._groupIds.add(groupId); // Move child nodes into the compound parent for (var j = 0; j < nodes.length; j++) { nodes[j].move({ parent: groupId }); } } this._enabled = true; this._collapsedGroups.clear(); // Bind compound node click handler this._bindCompoundTap(cy); // Re-layout this._runLayout(cy); // Update button state this._updateToggleButton(); console.log('[NodeGrouping] Grouping enabled with', opTypes.length, 'groups'); } /** * Tắt chế độ gom nhóm, trả nodes về trạng thái độc lập. */ disableGrouping() { var cy = this._getCytoscapeInstance(); if (!cy) return; // Unbind compound tap handler this._unbindCompoundTap(cy); // Restore collapsed groups first (show hidden children) this._collapsedGroups.forEach(function (groupId) { var parent = cy.getElementById(groupId); if (parent && parent.length > 0) { parent.children().style('display', 'element'); } }); // Move all children out of compound parents this._groupIds.forEach(function (groupId) { var parent = cy.getElementById(groupId); if (parent && parent.length > 0) { parent.children().move({ parent: null }); } }); // Remove compound parent nodes this._groupIds.forEach(function (groupId) { var parent = cy.getElementById(groupId); if (parent && parent.length > 0) { cy.remove(parent); } }); this._groupIds.clear(); this._collapsedGroups.clear(); this._enabled = false; // Re-layout this._runLayout(cy); // Update button state this._updateToggleButton(); console.log('[NodeGrouping] Grouping disabled'); } /** * Expand một compound group (hiển thị children). * @param {string} groupId */ expandGroup(groupId) { var cy = this._getCytoscapeInstance(); if (!cy) return; var parent = cy.getElementById(groupId); if (!parent || parent.length === 0) return; parent.children().style('display', 'element'); parent.removeClass('collapsed'); this._collapsedGroups.delete(groupId); this._runLayout(cy); } /** * Collapse một compound group (ẩn children, chỉ hiển thị parent). * @param {string} groupId */ collapseGroup(groupId) { var cy = this._getCytoscapeInstance(); if (!cy) return; var parent = cy.getElementById(groupId); if (!parent || parent.length === 0) return; parent.children().style('display', 'none'); parent.addClass('collapsed'); this._collapsedGroups.add(groupId); this._runLayout(cy); } /** * Kiểm tra chế độ gom nhóm có đang bật không. * @returns {boolean} */ isEnabled() { return this._enabled; } /** * Hủy và dọn dẹp. */ destroy() { if (this._enabled) { this.disableGrouping(); } if (this._unsubscribeGraphRendered) { this._unsubscribeGraphRendered(); this._unsubscribeGraphRendered = null; } if (this._toggleBtn && this._toggleBtn.parentNode) { this._toggleBtn.parentNode.removeChild(this._toggleBtn); } this._toggleBtn = null; this._initialized = false; } // ─── Private ──────────────────────────────────────────────────────── /** * Create the toggle button and insert into the graph card header. */ _createToggleButton() { var exportContainer = document.getElementById('graphExportContainer'); if (!exportContainer) { console.warn('[NodeGrouping] #graphExportContainer not found'); return; } var btn = document.createElement('button'); btn.className = 'btn btn-outline-secondary btn-sm me-2'; btn.id = 'nodeGroupingBtn'; btn.title = 'Group by OpType'; btn.type = 'button'; btn.innerHTML = 'Group'; var self = this; btn.addEventListener('click', function () { if (self._enabled) { self.disableGrouping(); } else { self.enableGrouping(); } }); // Insert at the beginning of the export container exportContainer.insertBefore(btn, exportContainer.firstChild); this._toggleBtn = btn; } /** * Update toggle button appearance based on enabled state. */ _updateToggleButton() { if (!this._toggleBtn) return; if (this._enabled) { this._toggleBtn.classList.remove('btn-outline-secondary'); this._toggleBtn.classList.add('btn-secondary'); this._toggleBtn.innerHTML = 'Ungroup'; this._toggleBtn.title = 'Disable grouping'; } else { this._toggleBtn.classList.remove('btn-secondary'); this._toggleBtn.classList.add('btn-outline-secondary'); this._toggleBtn.innerHTML = 'Group'; this._toggleBtn.title = 'Group by OpType'; } } /** * Bind tap handler on compound nodes for expand/collapse. * @param {object} cy - Cytoscape instance */ _bindCompoundTap(cy) { var self = this; this._compoundTapHandler = function (evt) { var node = evt.target; if (!node.data('isGroup')) return; var groupId = node.id(); if (self._collapsedGroups.has(groupId)) { self.expandGroup(groupId); } else { self.collapseGroup(groupId); } }; cy.on('tap', 'node.group-parent', this._compoundTapHandler); } /** * Unbind compound tap handler. * @param {object} cy - Cytoscape instance */ _unbindCompoundTap(cy) { if (this._compoundTapHandler) { cy.off('tap', 'node.group-parent', this._compoundTapHandler); this._compoundTapHandler = null; } } /** * Run layout on the graph after grouping changes. * @param {object} cy - Cytoscape instance */ _runLayout(cy) { var layout = { name: 'breadthfirst', directed: true, padding: 20, spacingFactor: 1.2, avoidOverlap: true, nodeDimensionsIncludeLabels: true, animate: true, animationDuration: 400 }; cy.layout(layout).run(); } /** * Handle graph:rendered event - reset grouping state for new graph. */ _onGraphRendered() { // Reset grouping state when a new graph is rendered this._groupIds.clear(); this._collapsedGroups.clear(); this._enabled = false; this._updateToggleButton(); } /** * Get the Cytoscape instance from the app's GraphVisualizer. * @returns {object|null} */ _getCytoscapeInstance() { try { if (window._onnxApp && typeof window._onnxApp.getGraphVisualizer === 'function') { var visualizer = window._onnxApp.getGraphVisualizer(); if (visualizer && visualizer._cy) { return visualizer._cy; } } } catch (err) { console.warn('[NodeGrouping] Could not access Cytoscape instance:', err); } return null; } } return NodeGrouping; })(); // Export as global for browser usage window.NodeGrouping = NodeGrouping;