Files
vna_system/vna_system/web_ui/static/js/modules/utils.js
2025-11-19 16:22:44 +03:00

388 lines
12 KiB
JavaScript

import { renderIcons } from './icons.js';
/**
* Format processor name: convert snake_case to Title Case
* @param {string} name - Processor name in snake_case
* @returns {string} Formatted name in Title Case
*/
export function formatProcessorName(name) {
return name
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Debounce function execution
* @param {Function} func - Function to debounce
* @param {number} wait - Delay in milliseconds
* @returns {Function} Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Debouncer class for managing multiple debounced operations
*/
export class Debouncer {
constructor() {
this.timers = new Map();
}
debounce(key, fn, delay = 300) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
}
const timer = setTimeout(() => {
this.timers.delete(key);
fn();
}, delay);
this.timers.set(key, timer);
}
cancel(key) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
this.timers.delete(key);
}
}
cancelAll() {
this.timers.forEach(clearTimeout);
this.timers.clear();
}
}
/**
* Request Guard: prevents concurrent execution of the same operation
*/
export class RequestGuard {
constructor() {
this.locks = new Set();
}
isLocked(key) {
return this.locks.has(key);
}
async runExclusive(key, fn) {
if (this.isLocked(key)) {
return;
}
this.locks.add(key);
try {
return await fn();
} finally {
this.locks.delete(key);
}
}
}
/**
* Button State Manager: manages button states (loading, disabled, normal)
*/
export class ButtonState {
static set(element, { state = 'normal', icon = '', text = '', disabled = false }) {
if (!element) return;
switch (state) {
case 'loading':
element.disabled = true;
element.innerHTML = `<span data-icon="${icon || 'loader'}"></span> ${text || 'Загрузка...'}`;
break;
case 'disabled':
element.disabled = true;
element.innerHTML = icon ? `<span data-icon="${icon}"></span> ${text}` : text;
break;
case 'normal':
default:
element.disabled = !!disabled;
element.innerHTML = icon ? `<span data-icon="${icon}"></span> ${text}` : text;
break;
}
renderIcons(element);
}
}
/**
* Set button to disabled state during request
* @param {HTMLElement} button - Button element
* @param {boolean} loading - Whether to disable button
* @param {Object} originalState - Original button state to restore
*/
export function setButtonLoading(button, loading, originalState = {}) {
if (!button) return originalState;
if (loading) {
if (!originalState.disabled) {
originalState.disabled = button.disabled;
}
button.disabled = true;
} else {
button.disabled = originalState.disabled || false;
}
return originalState;
}
/**
* Create parameter control HTML for processor settings
* @param {Object} param - Parameter configuration
* @param {string} processorId - Processor ID
* @param {string} idPrefix - Prefix for element IDs
* @returns {string} HTML string for parameter control
*/
export function createParameterControl(param, processorId, idPrefix = 'param') {
const paramId = `${idPrefix}_${processorId}_${param.name}`;
const value = param.value;
const opts = param.options || {};
switch (param.type) {
case 'slider':
case 'range':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">
${param.label}
<span class="chart-setting__value">${value}</span>
</label>
<input
type="range"
class="chart-setting__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="chart-setting__toggle-slider"></span>
<span class="chart-setting__label">${param.label}</span>
</label>
</div>
`;
case 'select':
case 'dropdown': {
let choices = [];
if (Array.isArray(opts)) choices = opts;
else if (Array.isArray(opts.choices)) choices = opts.choices;
const optionsHtml = choices.map(option =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<select class="chart-setting__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
case 'button':
const buttonText = param.label || 'Нажать';
const actionDesc = opts.action ? `title="${opts.action}"` : '';
return `
<div class="chart-setting" data-param="${param.name}">
<button
type="button"
class="chart-setting__button"
id="${paramId}"
data-processor="${processorId}"
data-param="${param.name}"
${actionDesc}
>
${buttonText}
</button>
</div>
`;
default:
return `
<div class="chart-setting" data-param="${param.name}">
<label class="chart-setting__label">${param.label}</label>
<input
type="text"
class="chart-setting__input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/**
* Safe clone object avoiding circular references
* @param {*} obj - Object to clone
* @param {WeakSet} seen - Set of already seen objects
* @returns {*} Cloned object
*/
export function safeClone(obj, seen = new WeakSet()) {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj)) return '[Циклическая ссылка]';
seen.add(obj);
if (Array.isArray(obj)) {
return obj.map(item => safeClone(item, seen));
}
const cloned = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
try {
cloned[key] = safeClone(obj[key], seen);
} catch (e) {
cloned[key] = `[Ошибка: ${e.message}]`;
}
}
}
return cloned;
}
/**
* Download JSON data as a file
* @param {Object} data - Data to download
* @param {string} filename - Filename for download
*/
export function downloadJSON(data, filename) {
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Escape HTML to prevent XSS attacks
* @param {string} unsafe - Unsafe string
* @returns {string} Escaped string
*/
export function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Format bytes to human readable format
* @param {number} bytes - Number of bytes
* @returns {string} Formatted string
*/
export function formatBytes(bytes) {
if (bytes === 0) return '0 байт';
const k = 1024;
const sizes = ['байт', 'КБ', 'МБ', 'ГБ'];
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/**
* Show a confirmation dialog with custom options
* @param {Object} options - Dialog options
* @param {string} options.title - Dialog title
* @param {string} options.message - Dialog message
* @param {Array<Object>} options.buttons - Array of button configurations
* @returns {Promise<string>} Resolves with the button value that was clicked
*/
export function showConfirmDialog({ title, message, buttons }) {
return new Promise((resolve) => {
// Create modal structure
const modal = document.createElement('div');
modal.className = 'modal modal--active';
const buttonsHtml = buttons.map(btn =>
`<button class="btn ${btn.class || 'btn--secondary'}" data-value="${btn.value}">${btn.text}</button>`
).join('');
modal.innerHTML = `
<div class="modal__backdrop" style="background-color: rgba(0, 0, 0, 0.85);"></div>
<div class="modal__content" style="background-color: #1e293b;">
<div class="modal__header">
<h3 class="modal__title">${escapeHtml(title)}</h3>
</div>
<div class="modal__body" style="padding: var(--space-6);">
<p style="margin: 0; color: var(--color-text-secondary);">${escapeHtml(message)}</p>
</div>
<div style="padding: var(--space-6); padding-top: 0; display: flex; gap: var(--space-3); justify-content: flex-end;">
${buttonsHtml}
</div>
</div>
`;
document.body.appendChild(modal);
// Handle button clicks
const handleClick = (e) => {
const button = e.target.closest('[data-value]');
if (button) {
const value = button.dataset.value;
cleanup();
resolve(value);
}
};
// Handle backdrop click
const handleBackdrop = (e) => {
if (e.target.classList.contains('modal__backdrop')) {
cleanup();
resolve(null);
}
};
// Handle escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
cleanup();
resolve(null);
}
};
const cleanup = () => {
modal.removeEventListener('click', handleClick);
modal.removeEventListener('click', handleBackdrop);
document.removeEventListener('keydown', handleEscape);
modal.remove();
};
modal.addEventListener('click', handleClick);
modal.addEventListener('click', handleBackdrop);
document.addEventListener('keydown', handleEscape);
});
}