/** * GraphLayoutSwitcher - Chuyển Đổi Layout Đồ Thị * Cung cấp dropdown cho phép chọn layout đồ thị: Top-Down, Left-Right, Circle, Force-Directed. * Lưu/khôi phục layout preference từ localStorage. * Requirements: 31.1, 31.2, 31.3, 31.4, 31.5 */ const GraphLayoutSwitcher = (function () { 'use strict'; // ─── Layout Configurations ──────────────────────────────────────────────── var LAYOUTS = { 'top-down': { name: 'breadthfirst', label: 'Top-Down', icon: 'fa-arrow-down', options: { directed: true, spacingFactor: 1.2, padding: 20, avoidOverlap: true, nodeDimensionsIncludeLabels: true, animate: true, animationDuration: 500 } }, 'left-right': { name: 'breadthfirst', label: 'Left-Right', icon: 'fa-arrow-right', options: { directed: true, spacingFactor: 1.5, padding: 20, avoidOverlap: true, nodeDimensionsIncludeLabels: true, animate: true, animationDuration: 500, transform: function (node, position) { return { x: position.y, y: position.x }; } } }, 'circle': { name: 'circle', label: 'Circle', icon: 'fa-circle-notch', options: { padding: 20, avoidOverlap: true, nodeDimensionsIncludeLabels: true, animate: true, animationDuration: 500 } }, 'force-directed': { name: 'cose', label: 'Force-Directed', icon: 'fa-project-diagram', options: { animate: true, animationDuration: 500, nodeDimensionsIncludeLabels: true, nodeRepulsion: function () { return 4500; }, idealEdgeLength: function () { return 50; }, edgeElasticity: function () { return 100; } } } }; var DEFAULT_LAYOUT = 'top-down'; // ─── Class ──────────────────────────────────────────────────────────────── class GraphLayoutSwitcher { /** * @param {string} [graphContainerSelector='#graphContainer'] - CSS selector cho container đồ thị * @param {string} [storageKey='onnx_explorer_graph_layout'] - Key localStorage */ constructor(graphContainerSelector, storageKey) { /** @type {string} */ this._containerSelector = graphContainerSelector || '#graphContainer'; /** @type {string} */ this._storageKey = storageKey || 'onnx_explorer_graph_layout'; /** @type {string} */ this._currentLayout = DEFAULT_LAYOUT; /** @type {HTMLElement|null} */ this._dropdownWrapper = null; /** @type {HTMLElement|null} */ this._dropdownMenu = null; /** @type {HTMLButtonElement|null} */ this._toggleBtn = null; /** @type {Function|null} */ this._unsubscribeGraphRendered = null; /** @type {Function|null} */ this._documentClickHandler = null; /** @type {boolean} */ this._initialized = false; } // ─── Public API ───────────────────────────────────────────────────── /** * Khởi tạo: tạo dropdown, khôi phục layout đã lưu, lắng nghe graph:rendered. */ init() { if (this._initialized) return; // Restore saved layout preference this._currentLayout = this._loadPreference() || DEFAULT_LAYOUT; // Create the dropdown UI this._createDropdown(); // Listen for graph:rendered to apply saved layout if (window.EventBus) { this._unsubscribeGraphRendered = window.EventBus.on( CONFIG.EVENTS.GRAPH_RENDERED, this._onGraphRendered.bind(this) ); } this._initialized = true; console.log('[GraphLayoutSwitcher] Initialized with layout:', this._currentLayout); } /** * Áp dụng layout mới cho đồ thị. * @param {string} layoutName - 'top-down', 'left-right', 'circle', 'force-directed' */ applyLayout(layoutName) { var layoutConfig = LAYOUTS[layoutName]; if (!layoutConfig) { console.warn('[GraphLayoutSwitcher] Unknown layout:', layoutName); return; } var cy = this._getCytoscapeInstance(); if (!cy) { console.warn('[GraphLayoutSwitcher] Cytoscape instance not available'); return; } // Build layout options with the cytoscape layout name var options = Object.assign({}, layoutConfig.options, { name: layoutConfig.name }); // Run the layout cy.layout(options).run(); // Update current layout this._currentLayout = layoutName; // Save preference this._savePreference(layoutName); // Update dropdown UI this._updateDropdownLabel(); console.log('[GraphLayoutSwitcher] Applied layout:', layoutName); } /** * Lấy layout hiện tại. * @returns {string} */ getCurrentLayout() { return this._currentLayout; } /** * Hủy và dọn dẹp. */ destroy() { if (this._unsubscribeGraphRendered) { this._unsubscribeGraphRendered(); this._unsubscribeGraphRendered = null; } if (this._documentClickHandler) { document.removeEventListener('click', this._documentClickHandler); this._documentClickHandler = null; } if (this._dropdownWrapper && this._dropdownWrapper.parentNode) { this._dropdownWrapper.parentNode.removeChild(this._dropdownWrapper); } this._dropdownWrapper = null; this._dropdownMenu = null; this._toggleBtn = null; this._initialized = false; } // ─── Private ──────────────────────────────────────────────────────── /** * Create the dropdown UI and insert into the graph card header. */ _createDropdown() { var exportContainer = document.getElementById('graphExportContainer'); if (!exportContainer) { console.warn('[GraphLayoutSwitcher] #graphExportContainer not found'); return; } // Wrapper var wrapper = document.createElement('div'); wrapper.className = 'graph-layout-switcher d-inline-block'; wrapper.style.position = 'relative'; // Toggle button var btn = document.createElement('button'); btn.className = 'btn btn-outline-secondary btn-sm me-2'; btn.id = 'graphLayoutBtn'; btn.title = 'Change Graph Layout'; btn.type = 'button'; this._updateBtnContent(btn, this._currentLayout); wrapper.appendChild(btn); // Dropdown menu var menu = document.createElement('div'); menu.className = 'graph-layout-dropdown'; menu.id = 'graphLayoutDropdown'; menu.style.display = 'none'; var layoutKeys = Object.keys(LAYOUTS); for (var i = 0; i < layoutKeys.length; i++) { var key = layoutKeys[i]; var config = LAYOUTS[key]; var option = document.createElement('button'); option.className = 'graph-layout-option'; option.type = 'button'; option.dataset.layout = key; option.innerHTML = '' + config.label; if (key === this._currentLayout) { option.classList.add('active'); } menu.appendChild(option); } wrapper.appendChild(menu); // Insert before the export container's first child (so it appears to the left) exportContainer.insertBefore(wrapper, exportContainer.firstChild); this._dropdownWrapper = wrapper; this._dropdownMenu = menu; this._toggleBtn = btn; // Bind events this._bindEvents(); } /** * Bind click events for the dropdown. */ _bindEvents() { var self = this; // Toggle dropdown on button click if (this._toggleBtn) { this._toggleBtn.addEventListener('click', function (e) { e.stopPropagation(); var isVisible = self._dropdownMenu.style.display !== 'none'; self._dropdownMenu.style.display = isVisible ? 'none' : 'block'; }); } // Handle layout option click if (this._dropdownMenu) { this._dropdownMenu.addEventListener('click', function (e) { var option = e.target.closest('.graph-layout-option'); if (!option) return; var layoutName = option.dataset.layout; self._dropdownMenu.style.display = 'none'; self.applyLayout(layoutName); }); } // Close dropdown when clicking outside this._documentClickHandler = function () { if (self._dropdownMenu) { self._dropdownMenu.style.display = 'none'; } }; document.addEventListener('click', this._documentClickHandler); } /** * Handle graph:rendered event - apply saved layout. * @param {Object} payload - { cy: cytoscape instance } */ _onGraphRendered(payload) { // Only apply non-default layout (default is already breadthfirst top-down) if (this._currentLayout && this._currentLayout !== DEFAULT_LAYOUT) { // Small delay to let the initial layout settle var self = this; setTimeout(function () { self.applyLayout(self._currentLayout); }, 100); } } /** * Update the toggle button content to show current layout. * @param {HTMLButtonElement} btn * @param {string} layoutName */ _updateBtnContent(btn, layoutName) { var config = LAYOUTS[layoutName] || LAYOUTS[DEFAULT_LAYOUT]; btn.innerHTML = 'Layout'; } /** * Update dropdown label and active state after layout change. */ _updateDropdownLabel() { if (this._toggleBtn) { this._updateBtnContent(this._toggleBtn, this._currentLayout); } // Update active state on options if (this._dropdownMenu) { var options = this._dropdownMenu.querySelectorAll('.graph-layout-option'); for (var i = 0; i < options.length; i++) { if (options[i].dataset.layout === this._currentLayout) { options[i].classList.add('active'); } else { options[i].classList.remove('active'); } } } } /** * 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('[GraphLayoutSwitcher] Could not access Cytoscape instance:', err); } return null; } /** * Save layout preference to localStorage. * @param {string} layoutName */ _savePreference(layoutName) { try { localStorage.setItem(this._storageKey, layoutName); } catch (_) { /* ignore quota/security errors */ } } /** * Load layout preference from localStorage. * @returns {string|null} */ _loadPreference() { try { var value = localStorage.getItem(this._storageKey); if (value && LAYOUTS[value]) { return value; } } catch (_) { /* ignore */ } return null; } } return GraphLayoutSwitcher; })(); // Export as global for browser usage window.GraphLayoutSwitcher = GraphLayoutSwitcher;