Spaces:
Running
Running
| /** | |
| * 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 = '<i class="fas ' + config.icon + ' me-2"></i>' + 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 = '<i class="fas ' + config.icon + ' me-1"></i>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; | |