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