Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/tutorials.js
alexei.dolgolyov 6a7ba3d0b7 Add CPU/GPU names on perf charts, reusable color picker, and header toolbar redesign
- Show CPU and GPU model names as overlays on performance chart cards
- Add cpu_name field to performance API with cross-platform detection
- Extract reusable color-picker popover module (9 presets + custom picker)
- Per-chart color customization for CPU/RAM/GPU performance charts
- Redesign header: compact toolbar container with icon-only buttons
- Compact language dropdown (EN/RU/ZH), icon-only login/logout
- Use accent color for FPS charts, range slider accent, dashboard icons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:12:13 +03:00

334 lines
13 KiB
JavaScript

/**
* Tutorial system — generic engine, steps, tooltip positioning.
*/
import { activeTutorial, setActiveTutorial } from '../core/state.js';
import { t } from '../core/i18n.js';
const calibrationTutorialSteps = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '#calibration-overlay-btn', textKey: 'calibration.tip.overlay', position: 'bottom' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
];
const TOUR_KEY = 'tour_completed';
const gettingStartedSteps = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' },
{ selector: '[onclick*="openSettingsModal"]', textKey: 'tour.settings', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
];
const dashboardTutorialSteps = [
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
{ selector: '[data-dashboard-section="profiles"]', textKey: 'tour.dash.profiles', position: 'bottom' }
];
const targetsTutorialSteps = [
{ selector: '[data-target-sub-tab="led"]', textKey: 'tour.tgt.led_tab', position: 'bottom' },
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
{ selector: '[data-card-section="led-css"]', textKey: 'tour.tgt.css', position: 'bottom' },
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
{ selector: '[data-target-sub-tab="key_colors"]', textKey: 'tour.tgt.kc_tab', position: 'bottom' }
];
const sourcesTourSteps = [
{ selector: '[data-stream-tab="raw"]', textKey: 'tour.src.raw', position: 'bottom' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
{ selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' },
{ selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' },
{ selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' },
{ selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' }
];
const profilesTutorialSteps = [
{ selector: '[data-card-section="profiles"]', textKey: 'tour.prof.list', position: 'bottom' },
{ selector: '[data-cs-add="profiles"]', textKey: 'tour.prof.add', position: 'bottom' },
{ selector: '.card[data-profile-id]', textKey: 'tour.prof.card', position: 'bottom' }
];
const _fixedResolve = (step) => {
const el = document.querySelector(step.selector);
if (!el) return null;
// offsetParent is null when element or any ancestor has display:none
if (!el.offsetParent) return null;
return el;
};
const deviceTutorialSteps = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
export function startTutorial(config) {
closeTutorial();
const overlay = document.getElementById(config.overlayId);
if (!overlay) return;
setActiveTutorial({
steps: config.steps,
overlay: overlay,
mode: config.mode,
step: 0,
resolveTarget: config.resolveTarget,
container: config.container,
onClose: config.onClose || null
});
overlay.classList.add('active');
document.addEventListener('keydown', handleTutorialKey);
showTutorialStep(0);
}
export function startCalibrationTutorial() {
const container = document.querySelector('#calibration-modal .modal-body');
if (!container) return;
startTutorial({
steps: calibrationTutorialSteps,
overlayId: 'tutorial-overlay',
mode: 'absolute',
container: container,
resolveTarget: (step) => {
const el = document.querySelector(step.selector);
if (!el) return null;
// Skip elements hidden via display:none (e.g. overlay btn in device mode)
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') return null;
return el;
}
});
}
export function startDeviceTutorial(deviceId) {
const selector = deviceId
? `.card[data-device-id="${deviceId}"]`
: '.card[data-device-id]';
if (!document.querySelector(selector)) return;
startTutorial({
steps: deviceTutorialSteps,
overlayId: 'device-tutorial-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step) => {
const card = document.querySelector(selector);
if (!card) return null;
return step.global
? document.querySelector(step.selector)
: card.querySelector(step.selector);
}
});
}
export function startGettingStartedTutorial() {
startTutorial({
steps: gettingStartedSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: (step) => document.querySelector(step.selector) || null,
onClose: () => localStorage.setItem(TOUR_KEY, '1')
});
}
export function startDashboardTutorial() {
startTutorial({
steps: dashboardTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startTargetsTutorial() {
startTutorial({
steps: targetsTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startSourcesTutorial() {
startTutorial({
steps: sourcesTourSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function startProfilesTutorial() {
startTutorial({
steps: profilesTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
});
}
export function closeTutorial() {
if (!activeTutorial) return;
const onClose = activeTutorial.onClose;
activeTutorial.overlay.classList.remove('active');
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
document.removeEventListener('keydown', handleTutorialKey);
setActiveTutorial(null);
if (onClose) onClose();
}
export function tutorialNext() {
if (!activeTutorial) return;
if (activeTutorial.step < activeTutorial.steps.length - 1) {
showTutorialStep(activeTutorial.step + 1);
} else {
closeTutorial();
}
}
export function tutorialPrev() {
if (!activeTutorial) return;
if (activeTutorial.step > 0) {
showTutorialStep(activeTutorial.step - 1, -1);
}
}
function showTutorialStep(index, direction = 1) {
if (!activeTutorial) return;
activeTutorial.step = index;
const step = activeTutorial.steps[index];
const overlay = activeTutorial.overlay;
const isFixed = activeTutorial.mode === 'fixed';
document.querySelectorAll('.tutorial-target').forEach(el => {
el.classList.remove('tutorial-target');
el.style.zIndex = '';
});
const target = activeTutorial.resolveTarget(step);
if (!target) {
// Auto-skip hidden/missing targets in the current direction
const next = index + direction;
if (next >= 0 && next < activeTutorial.steps.length) showTutorialStep(next, direction);
else closeTutorial();
return;
}
target.classList.add('tutorial-target');
if (isFixed) target.style.zIndex = '10001';
const targetRect = target.getBoundingClientRect();
const pad = 6;
let x, y, w, h;
if (isFixed) {
x = targetRect.left - pad;
y = targetRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
} else {
const containerRect = activeTutorial.container.getBoundingClientRect();
x = targetRect.left - containerRect.left - pad;
y = targetRect.top - containerRect.top - pad;
w = targetRect.width + pad * 2;
h = targetRect.height + pad * 2;
}
const backdrop = overlay.querySelector('.tutorial-backdrop');
if (backdrop) {
backdrop.style.clipPath = `polygon(
0% 0%, 0% 100%,
${x}px 100%, ${x}px ${y}px,
${x + w}px ${y}px, ${x + w}px ${y + h}px,
${x}px ${y + h}px, ${x}px 100%,
100% 100%, 100% 0%)`;
}
const ring = overlay.querySelector('.tutorial-ring');
if (ring) {
ring.style.left = x + 'px';
ring.style.top = y + 'px';
ring.style.width = w + 'px';
ring.style.height = h + 'px';
}
const tooltip = overlay.querySelector('.tutorial-tooltip');
const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey);
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`;
const prevBtn = overlay.querySelector('.tutorial-prev-btn');
const nextBtn = overlay.querySelector('.tutorial-next-btn');
if (prevBtn) prevBtn.disabled = (index === 0);
if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192';
if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
}
}
function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) {
const gap = 12;
const tooltipW = 260;
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150;
const positions = {
top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap },
bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap },
left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 },
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
};
let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth;
const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight;
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const alt = positions[opposite[preferred]];
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt;
}
}
pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x));
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
}
function handleTutorialKey(e) {
if (!activeTutorial) return;
if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}