feat: add math_wave color strip source type
Lint & Test / test (push) Has been cancelled

Mathematical wave generator that produces per-LED colors from
configurable waveform layers (sine, triangle, sawtooth, square) with
superposition, mapped through a gradient palette. Supports sync clocks,
bindable speed, and up to 8 wave layers.

- Storage model with wave validation and apply_update
- Numpy-vectorized stream with gradient LUT color mapping
- API schemas (create/update/response) and route registration
- Frontend editor with dynamic wave layer rows, gradient picker,
  speed widget, and IconSelect waveform selectors
- i18n in en/ru/zh
This commit is contained in:
2026-04-05 00:41:07 +03:00
parent edc6d27e2e
commit ace24715c8
16 changed files with 977 additions and 5 deletions
@@ -144,6 +144,7 @@ import {
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
mathWaveAddLayer, mathWaveRemoveLayer,
} from './features/color-strips.ts';
// Layer 5: audio sources
@@ -489,6 +490,7 @@ Object.assign(window, {
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
mathWaveAddLayer, mathWaveRemoveLayer,
// audio sources
showAudioSourceModal,
@@ -30,6 +30,7 @@ const _colorStripTypeIcons = {
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
game_event: _svg(P.gamepad2),
math_wave: _svg(P.activity),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -91,6 +91,9 @@ class CSSEditorModal extends Modal {
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
_destroyCSSGameMappingIconSelects();
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
if (_mathWaveSpeedWidget) { _mathWaveSpeedWidget.destroy(); _mathWaveSpeedWidget = null; }
if (_mathWaveGradientEntitySelect) { _mathWaveGradientEntitySelect.destroy(); _mathWaveGradientEntitySelect = null; }
_mathWaveWaveformIconSelects.forEach(s => s.destroy()); _mathWaveWaveformIconSelects = [];
compositeDestroyEntitySelects();
}
@@ -149,6 +152,9 @@ class CSSEditorModal extends Modal {
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
ge_mappings: JSON.stringify(_cssGameMappings),
mw_gradient: (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement)?.value || '',
mw_speed: _mathWaveSpeedWidget ? JSON.stringify(_mathWaveSpeedWidget.getValue()) : '1.0',
mw_waves: JSON.stringify(_mathWaveGetLayers()),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -168,6 +174,9 @@ let _candlelightSpeedWidget: BindableScalarWidget | null = null;
let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
let _mathWaveSpeedWidget: BindableScalarWidget | null = null;
let _mathWaveGradientEntitySelect: EntitySelect | null = null;
let _mathWaveWaveformIconSelects: IconSelect[] = [];
// ── BindableColorWidget instances for CSS editor ──
let _staticColorWidget: BindableColorWidget | null = null;
@@ -260,7 +269,7 @@ const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event',
'game_event', 'math_wave',
];
function _buildCSSTypeItems() {
@@ -309,6 +318,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
'game_event': 'css-editor-game-event-section',
'math_wave': 'css-editor-math-wave-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -321,6 +331,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
math_wave: () => { _ensureMathWaveGradientEntitySelect(); },
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
composite: () => compositeRenderList(),
mapped: () => _mappedRenderList(),
@@ -376,7 +387,7 @@ export function onCSSTypeChange() {
hasLedCount.includes(type) ? '' : 'none';
// Sync clock — shown for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -1118,6 +1129,127 @@ function _ensureCandleTypeIconSelect() {
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
/* ── Math Wave helpers ──────────────────────────────────────────── */
function _ensureMathWaveSpeedWidget(): BindableScalarWidget {
if (!_mathWaveSpeedWidget) {
_mathWaveSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-math-wave-speed-container')!,
min: 0.1, max: 10.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-math-wave-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _mathWaveSpeedWidget;
}
function _ensureMathWaveGradientEntitySelect() {
const sel = document.getElementById('css-editor-math-wave-gradient') as HTMLSelectElement | null;
if (!sel) return;
const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items);
if (_mathWaveGradientEntitySelect) { _mathWaveGradientEntitySelect.refresh(); return; }
_mathWaveGradientEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
}
function _buildMathWaveWaveformItems(): IconSelectItem[] {
return [
{ value: 'sine', icon: _icon(P.activity), label: t('color_strip.math_wave.waveform.sine'), desc: '' },
{ value: 'triangle', icon: _icon(P.trendingUp), label: t('color_strip.math_wave.waveform.triangle'), desc: '' },
{ value: 'sawtooth', icon: _icon(P.rotateCw), label: t('color_strip.math_wave.waveform.sawtooth'), desc: '' },
{ value: 'square', icon: _icon(P.layoutDashboard), label: t('color_strip.math_wave.waveform.square'), desc: '' },
];
}
function _mathWaveRenderLayers(waves: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>) {
const container = document.getElementById('css-editor-math-wave-layers');
if (!container) return;
// Destroy old waveform icon selects
_mathWaveWaveformIconSelects.forEach(s => s.destroy());
_mathWaveWaveformIconSelects = [];
container.innerHTML = '';
waves.forEach((wave, idx) => {
const row = document.createElement('div');
row.className = 'math-wave-layer-row';
row.style.cssText = 'border:1px solid var(--border-color,#444);border-radius:6px;padding:8px;margin-bottom:6px;position:relative';
row.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<strong>#${idx + 1}</strong>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="mathWaveRemoveLayer(${idx})" title="${t('common.delete')}" style="margin-left:auto">${ICON_TRASH}</button>
</div>
<div class="form-group" style="margin-bottom:4px">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.waveform">${t('color_strip.math_wave.waveform')}</label>
<select class="mw-waveform" data-idx="${idx}">
<option value="sine">${t('color_strip.math_wave.waveform.sine')}</option>
<option value="triangle">${t('color_strip.math_wave.waveform.triangle')}</option>
<option value="sawtooth">${t('color_strip.math_wave.waveform.sawtooth')}</option>
<option value="square">${t('color_strip.math_wave.waveform.square')}</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.frequency">${t('color_strip.math_wave.frequency')}</label>
<input type="number" class="mw-frequency" min="0.1" max="20" step="0.1" value="${wave.frequency}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.amplitude">${t('color_strip.math_wave.amplitude')}</label>
<input type="number" class="mw-amplitude" min="0.0" max="2.0" step="0.1" value="${wave.amplitude}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.phase">${t('color_strip.math_wave.phase')}</label>
<input type="number" class="mw-phase" min="0.0" max="6.28" step="0.1" value="${wave.phase}">
</div>
<div class="form-group" style="margin-bottom:0">
<label style="font-size:0.85em" data-i18n="color_strip.math_wave.offset">${t('color_strip.math_wave.offset')}</label>
<input type="number" class="mw-offset" min="-1.0" max="1.0" step="0.1" value="${wave.offset}">
</div>
</div>
`;
container.appendChild(row);
// Set waveform value and attach IconSelect
const wfSelect = row.querySelector('.mw-waveform') as HTMLSelectElement;
wfSelect.value = wave.waveform || 'sine';
const iconSel = new IconSelect({
target: wfSelect,
items: _buildMathWaveWaveformItems(),
columns: 2,
});
_mathWaveWaveformIconSelects.push(iconSel);
});
}
function _mathWaveGetLayers(): Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }> {
const container = document.getElementById('css-editor-math-wave-layers');
if (!container) return [];
const rows = container.querySelectorAll('.math-wave-layer-row');
return Array.from(rows).map(row => ({
waveform: (row.querySelector('.mw-waveform') as HTMLSelectElement).value,
frequency: parseFloat((row.querySelector('.mw-frequency') as HTMLInputElement).value) || 1.0,
amplitude: parseFloat((row.querySelector('.mw-amplitude') as HTMLInputElement).value) || 1.0,
phase: parseFloat((row.querySelector('.mw-phase') as HTMLInputElement).value) || 0.0,
offset: parseFloat((row.querySelector('.mw-offset') as HTMLInputElement).value) || 0.0,
}));
}
export function mathWaveAddLayer() {
const current = _mathWaveGetLayers();
current.push({ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 });
_mathWaveRenderLayers(current);
}
export function mathWaveRemoveLayer(idx: number) {
const current = _mathWaveGetLayers();
current.splice(idx, 1);
_mathWaveRenderLayers(current);
}
function _ensureAudioPaletteEntitySelect() {
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
if (!sel) return;
@@ -1177,13 +1309,14 @@ function _ensureGradientPresetEntitySelect() {
/** Rebuild the gradient picker after entity changes. */
export function refreshGradientPresetPicker() {
// Re-sync select options before refreshing entity selects
for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) {
for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette', 'css-editor-math-wave-gradient']) {
const sel = document.getElementById(selId) as HTMLSelectElement | null;
if (sel) _syncSelectOptions(sel, _buildGradientEntityItems());
}
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh();
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh();
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.refresh();
}
/** Render the user-created gradient list below the save button. */
@@ -1615,6 +1748,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'math_wave',
]);
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
@@ -1775,6 +1909,18 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
<span class="stream-card-prop">${mode}</span>
`;
},
math_wave: (source, { clockBadge }) => {
const waveCount = (source.waves || []).length;
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
const gr = source.gradient_id ? _getGradients().find(g => g.id === source.gradient_id) : null;
const grName = gr?.name || '—';
return `
<span class="stream-card-prop" title="${t('color_strip.math_wave.waves')}">${ICON_ACTIVITY} ${waveCount} wave${waveCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('color_strip.math_wave.gradient')}">${ICON_PALETTE} ${escapeHtml(grName)}</span>
<span class="stream-card-prop">${ICON_FAST_FORWARD} ${speedVal}x</span>
${clockBadge}
`;
},
processed: (source) => {
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
const inputName = inputSrc?.name || source.input_source_id || '—';
@@ -2382,6 +2528,39 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
math_wave: {
load(css: any) {
_ensureMathWaveGradientEntitySelect();
const gradientId = css.gradient_id || '';
(document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value = gradientId;
if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.setValue(gradientId);
_ensureMathWaveSpeedWidget().setValue(css.speed ?? 1.0);
_mathWaveRenderLayers(css.waves || [{ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 }]);
},
reset() {
_ensureMathWaveGradientEntitySelect();
const gradients = _getGradients();
const defaultId = gradients.length > 0 ? gradients[0].id : '';
(document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value = defaultId;
if (_mathWaveGradientEntitySelect) _mathWaveGradientEntitySelect.setValue(defaultId);
_ensureMathWaveSpeedWidget().setValue(1.0);
_mathWaveRenderLayers([{ waveform: 'sine', frequency: 1.0, amplitude: 1.0, phase: 0.0, offset: 0.0 }]);
},
getPayload(name: any) {
const gradientId = (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value;
const waves = _mathWaveGetLayers();
if (waves.length === 0) {
cssEditorModal.showError(t('color_strip.math_wave.error.no_waves'));
return null;
}
return {
name,
gradient_id: gradientId || null,
speed: _ensureMathWaveSpeedWidget().getValue(),
waves,
};
},
},
game_event: {
load(css: any) {
_populateGameIntegrationDropdownCSS(css.game_integration_id || '');
@@ -2579,7 +2758,7 @@ export async function saveCSSEditor() {
payload.source_type = knownType ? sourceType : 'picture';
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
payload.clock_id = clockVal || null;
@@ -136,7 +136,7 @@ export type CSSSourceType =
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
| 'game_event';
| 'game_event' | 'math_wave';
export interface ColorStop {
position: number;
@@ -288,6 +288,10 @@ export interface ColorStripSource {
game_integration_id?: string;
idle_color?: BindableColor;
event_mappings?: GameEventMapping[];
// Math Wave
waves?: Array<{ waveform: string; frequency: number; amplitude: number; phase: number; offset: number }>;
gradient_id?: string;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -2254,6 +2254,26 @@
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
"color_strip.game_event.error.no_integration": "Please select a game integration.",
"color_strip.type.math_wave": "Math Wave",
"color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping",
"color_strip.math_wave.gradient": "Color Gradient:",
"color_strip.math_wave.gradient.hint": "The gradient used to color the wave output. Wave values are mapped to positions along this gradient.",
"color_strip.math_wave.speed": "Speed:",
"color_strip.math_wave.speed.hint": "Animation speed multiplier. Higher values make the wave move faster.",
"color_strip.math_wave.waves": "Wave Layers:",
"color_strip.math_wave.waves.hint": "Add multiple wave layers that are combined together. Each wave has its own waveform, frequency, amplitude, phase and offset.",
"color_strip.math_wave.add_wave": "+ Add Wave",
"color_strip.math_wave.waveform": "Waveform",
"color_strip.math_wave.waveform.sine": "Sine",
"color_strip.math_wave.waveform.triangle": "Triangle",
"color_strip.math_wave.waveform.sawtooth": "Sawtooth",
"color_strip.math_wave.waveform.square": "Square",
"color_strip.math_wave.frequency": "Frequency",
"color_strip.math_wave.amplitude": "Amplitude",
"color_strip.math_wave.phase": "Phase",
"color_strip.math_wave.offset": "Offset",
"color_strip.math_wave.error.no_waves": "Add at least one wave layer.",
"value_source.type.game_event": "Game Event",
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
"value_source.game_event.integration": "Game Integration:",
@@ -1970,6 +1970,26 @@
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
"color_strip.type.math_wave": "Математическая волна",
"color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом",
"color_strip.math_wave.gradient": "Цветовой градиент:",
"color_strip.math_wave.gradient.hint": "Градиент для окраски волнового выхода. Значения волны отображаются на позиции вдоль этого градиента.",
"color_strip.math_wave.speed": "Скорость:",
"color_strip.math_wave.speed.hint": "Множитель скорости анимации. Более высокие значения ускоряют движение волны.",
"color_strip.math_wave.waves": "Слои волн:",
"color_strip.math_wave.waves.hint": "Добавьте несколько слоёв волн, которые комбинируются вместе. Каждая волна имеет собственную форму, частоту, амплитуду, фазу и смещение.",
"color_strip.math_wave.add_wave": "+ Добавить волну",
"color_strip.math_wave.waveform": "Форма волны",
"color_strip.math_wave.waveform.sine": "Синусоида",
"color_strip.math_wave.waveform.triangle": "Треугольная",
"color_strip.math_wave.waveform.sawtooth": "Пилообразная",
"color_strip.math_wave.waveform.square": "Прямоугольная",
"color_strip.math_wave.frequency": "Частота",
"color_strip.math_wave.amplitude": "Амплитуда",
"color_strip.math_wave.phase": "Фаза",
"color_strip.math_wave.offset": "Смещение",
"color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.",
"value_source.type.game_event": "Игровое событие",
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
"value_source.game_event.integration": "Игровая интеграция:",
@@ -1968,6 +1968,26 @@
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
"color_strip.type.math_wave": "数学波",
"color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器",
"color_strip.math_wave.gradient": "颜色渐变:",
"color_strip.math_wave.gradient.hint": "用于着色波形输出的渐变。波形值映射到此渐变的位置。",
"color_strip.math_wave.speed": "速度:",
"color_strip.math_wave.speed.hint": "动画速度倍数。较高的值使波移动更快。",
"color_strip.math_wave.waves": "波形层:",
"color_strip.math_wave.waves.hint": "添加多个组合在一起的波形层。每个波形有自己的波形、频率、振幅、相位和偏移。",
"color_strip.math_wave.add_wave": "+ 添加波形",
"color_strip.math_wave.waveform": "波形",
"color_strip.math_wave.waveform.sine": "正弦波",
"color_strip.math_wave.waveform.triangle": "三角波",
"color_strip.math_wave.waveform.sawtooth": "锯齿波",
"color_strip.math_wave.waveform.square": "方波",
"color_strip.math_wave.frequency": "频率",
"color_strip.math_wave.amplitude": "振幅",
"color_strip.math_wave.phase": "相位",
"color_strip.math_wave.offset": "偏移",
"color_strip.math_wave.error.no_waves": "请至少添加一个波形层。",
"value_source.type.game_event": "游戏事件",
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
"value_source.game_event.integration": "游戏集成:",