Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/tabs.js
alexei.dolgolyov bd7a315c2c Add visual graph editor for entity interconnections
SVG-based node graph with ELK.js autolayout showing all 13 entity types
and their relationships. Features include:

- Pan/zoom canvas with bounds clamping and dead-zone click detection
- Interactive minimap with viewport rectangle, click-to-pan, drag-to-move,
  and dual resize handles (bottom-left/bottom-right)
- Movable toolbar with drag handle and inline zoom percentage indicator
- Entity-type SVG icons from Lucide icon set with subtype-specific overrides
- Command palette search (/) with keyboard navigation and fly-to
- Node selection with upstream/downstream chain highlighting
- Double-click to zoom-to-card, edit/delete overlay on hover
- Legend panel, orphan node detection, running state indicators
- Full i18n support with languageChanged re-render
- Catmull-Rom-to-cubic bezier edge routing for smooth curves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:01:47 +03:00

166 lines
6.2 KiB
JavaScript

/**
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
*/
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js';
/** Parse location.hash into {tab, subTab}. */
export function parseHash() {
const hash = location.hash.replace(/^#/, '');
if (!hash) return {};
const [tab, subTab] = hash.split('/');
return { tab, subTab };
}
/** Update the URL hash without triggering popstate. */
function _setHash(tab, subTab) {
const hash = '#' + (subTab ? `${tab}/${subTab}` : tab);
history.replaceState(null, '', hash);
}
let _suppressHashUpdate = false;
let _activeTab = null;
const _tabScrollPositions = {};
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (_activeTab === name) return;
// Save scroll position of the tab we're leaving
if (_activeTab) _tabScrollPositions[_activeTab] = window.scrollY;
_activeTab = name;
document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-selected', String(isActive));
});
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
// Update background tab indicator
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(name);
// Restore scroll position for this tab
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
if (updateHash && !_suppressHashUpdate) {
const subTab = name === 'targets'
? (localStorage.getItem('activeTargetSubTab') || 'led')
: name === 'streams'
? (localStorage.getItem('activeStreamTab') || 'raw')
: null;
_setHash(name, subTab);
}
if (name === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
if (!skipLoad && apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab
if (name !== 'targets') {
if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets();
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
}
if (!apiKey || skipLoad) return;
if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (name === 'automations') {
if (typeof window.loadAutomations === 'function') window.loadAutomations();
} else if (name === 'graph') {
if (typeof window.loadGraphEditor === 'function') window.loadGraphEditor();
}
}
}
export function initTabs() {
// Hash takes priority over localStorage
const hashRoute = parseHash();
let saved;
if (hashRoute.tab && document.getElementById(`tab-${hashRoute.tab}`)) {
saved = hashRoute.tab;
// Pre-set sub-tab so the sub-tab switch functions pick it up
if (hashRoute.subTab) {
if (saved === 'targets') localStorage.setItem('activeTargetSubTab', hashRoute.subTab);
if (saved === 'streams') localStorage.setItem('activeStreamTab', hashRoute.subTab);
}
} else {
saved = localStorage.getItem('activeTab');
}
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
switchTab(saved);
}
/** Update hash when sub-tab changes. Called from targets.js / streams.js. */
export function updateSubTabHash(tab, subTab) {
_setHash(tab, subTab);
}
/** Update the count badge on a main tab button. Hidden when count is 0. */
export function updateTabBadge(tabName, count) {
const badge = document.getElementById(`tab-badge-${tabName}`);
if (!badge) return;
if (count > 0) {
badge.textContent = count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
export function startAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
setRefreshInterval(setInterval(() => {
if (!apiKey || document.hidden) return;
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'targets') {
// Skip refresh while user interacts with a picker or slider
const panel = document.getElementById('targets-panel-content');
if (panel && panel.contains(document.activeElement) && document.activeElement.matches('input')) return;
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (activeTab === 'dashboard') {
if (typeof window.loadDashboard === 'function') window.loadDashboard();
}
}, dashboardPollInterval));
}
/**
* Handle browser back/forward via popstate.
* Called from app.js.
*/
export function handlePopState() {
const hashRoute = parseHash();
if (!hashRoute.tab) return;
const currentTab = localStorage.getItem('activeTab');
_suppressHashUpdate = true;
if (hashRoute.tab !== currentTab) {
switchTab(hashRoute.tab, { updateHash: false });
}
if (hashRoute.subTab) {
if (hashRoute.tab === 'targets') {
const currentSub = localStorage.getItem('activeTargetSubTab');
if (hashRoute.subTab !== currentSub && typeof window.switchTargetSubTab === 'function') {
window.switchTargetSubTab(hashRoute.subTab);
}
} else if (hashRoute.tab === 'streams') {
const currentSub = localStorage.getItem('activeStreamTab');
if (hashRoute.subTab !== currentSub && typeof window.switchStreamTab === 'function') {
window.switchStreamTab(hashRoute.subTab);
}
}
}
_suppressHashUpdate = false;
}