Add color_cycle as standalone source type; UI polish

- color_cycle is now a top-level source type (alongside picture/static/gradient)
  with a configurable color list and cycle_speed; defaults to full rainbow spectrum
- ColorCycleColorStripSource + ColorCycleColorStripStream: smooth 30 fps interpolation
  between user-defined colors, one full cycle every 20s at speed=1.0
- Removed color_cycle animation sub-type from StaticColorStripStream
- Color cycle editor: compact horizontal swatch layout, proper module-scope fix
  (colorCycleAdd/Remove now exposed on window, DOM-synced before mutations)
- Animation enabled + Frame interpolation checkboxes use toggle-switch style
- Removed Potential FPS metric from targets and KC targets metric grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:14:42 +03:00
parent 872949a7e1
commit c31818a20d
14 changed files with 674 additions and 40 deletions

View File

@@ -90,7 +90,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange,
onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor,
} from './features/color-strips.js';
// Layer 5: calibration
@@ -276,6 +276,8 @@ Object.assign(window, {
saveCSSEditor,
deleteColorStrip,
onCSSTypeChange,
colorCycleAddColor,
colorCycleRemoveColor,
// calibration
showCalibration,

View File

@@ -26,8 +26,13 @@ class CSSEditorModal extends Modal {
gamma: document.getElementById('css-editor-gamma').value,
color: document.getElementById('css-editor-color').value,
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
led_count: (type === 'static' || type === 'gradient' || type === 'color_cycle') ? '0' : document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
animation_enabled: document.getElementById('css-editor-animation-enabled').checked,
animation_type: document.getElementById('css-editor-animation-type').value,
animation_speed: document.getElementById('css-editor-animation-speed').value,
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
cycle_colors: JSON.stringify(_colorCycleColors),
};
}
}
@@ -40,15 +45,114 @@ export function onCSSTypeChange() {
const type = document.getElementById('css-editor-type').value;
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
// LED count is only meaningful for picture sources; static/gradient auto-size from device
document.getElementById('css-editor-led-count-group').style.display = (type === 'static' || type === 'gradient') ? 'none' : '';
// LED count is only meaningful for picture sources; static/gradient/color_cycle auto-size from device
document.getElementById('css-editor-led-count-group').style.display =
(type === 'static' || type === 'gradient' || type === 'color_cycle') ? 'none' : '';
// Animation section — shown for static/gradient only (color_cycle is always animating)
const animSection = document.getElementById('css-editor-animation-section');
const animTypeSelect = document.getElementById('css-editor-animation-type');
if (type === 'static') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>`;
} else if (type === 'gradient') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
`<option value="wave">${t('color_strip.animation.type.wave')}</option>`;
} else {
animSection.style.display = 'none';
}
if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll());
}
}
function _getAnimationPayload() {
return {
enabled: document.getElementById('css-editor-animation-enabled').checked,
type: document.getElementById('css-editor-animation-type').value,
speed: parseFloat(document.getElementById('css-editor-animation-speed').value),
};
}
function _loadAnimationState(anim) {
document.getElementById('css-editor-animation-enabled').checked = !!(anim && anim.enabled);
const speedEl = document.getElementById('css-editor-animation-speed');
speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0;
document.getElementById('css-editor-animation-speed-val').textContent =
parseFloat(speedEl.value).toFixed(1);
// Set type after onCSSTypeChange() has populated the dropdown
if (anim && anim.type) {
document.getElementById('css-editor-animation-type').value = anim.type;
}
}
/* ── Color Cycle helpers ──────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
function _syncColorCycleFromDom() {
const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
if (inputs.length > 0) {
_colorCycleColors = Array.from(inputs).map(el => el.value);
}
}
function _colorCycleRenderList() {
const list = document.getElementById('color-cycle-colors-list');
if (!list) return;
const canRemove = _colorCycleColors.length > 2;
list.innerHTML = _colorCycleColors.map((hex, i) => `
<div class="color-cycle-item">
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">&#x2715;</button>`
: `<div style="height:14px"></div>`}
</div>
`).join('');
}
export function colorCycleAddColor() {
_syncColorCycleFromDom();
_colorCycleColors.push('#ffffff');
_colorCycleRenderList();
}
export function colorCycleRemoveColor(i) {
_syncColorCycleFromDom();
if (_colorCycleColors.length <= 2) return;
_colorCycleColors.splice(i, 1);
_colorCycleRenderList();
}
function _colorCycleGetColors() {
const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]');
return Array.from(inputs).map(el => hexToRgbArray(el.value));
}
function _loadColorCycleState(css) {
const raw = css && css.colors;
_colorCycleColors = (raw && raw.length >= 2)
? raw.map(c => rgbArrayToHex(c))
: [..._DEFAULT_CYCLE_COLORS];
_colorCycleRenderList();
const speed = (css && css.cycle_speed != null) ? css.cycle_speed : 1.0;
const speedEl = document.getElementById('css-editor-cycle-speed');
if (speedEl) {
speedEl.value = speed;
document.getElementById('css-editor-cycle-speed-val').textContent =
parseFloat(speed).toFixed(1);
}
}
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
function rgbArrayToHex(rgb) {
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
@@ -66,6 +170,11 @@ function hexToRgbArray(hex) {
export function createColorStripCard(source, pictureSourceMap) {
const isStatic = source.source_type === 'static';
const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle';
const animBadge = ((isStatic || isGradient) && source.animation && source.animation.enabled)
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">✨ ${t('color_strip.animation.type.' + source.animation.type) || source.animation.type}</span>`
: '';
let propsHtml;
if (isStatic) {
@@ -75,6 +184,17 @@ export function createColorStripCard(source, pictureSourceMap) {
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
${animBadge}
`;
} else if (isColorCycle) {
const colors = source.colors || [];
const swatches = colors.slice(0, 8).map(c =>
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
).join('');
propsHtml = `
<span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">🔄 ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isGradient) {
const stops = source.stops || [];
@@ -95,6 +215,7 @@ export function createColorStripCard(source, pictureSourceMap) {
propsHtml = `
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${animBadge}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
@@ -110,8 +231,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -166,11 +287,15 @@ export async function showCSSEditor(cssId = null) {
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation);
} else if (sourceType === 'color_cycle') {
_loadColorCycleState(css);
} else if (sourceType === 'gradient') {
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -220,6 +345,8 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-frame-interpolation').checked = false;
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
_loadAnimationState(null);
_loadColorCycleState(null);
document.getElementById('css-editor-title').textContent = t('color_strip.add');
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
@@ -259,8 +386,21 @@ export async function saveCSSEditor() {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'static';
} else if (sourceType === 'color_cycle') {
const cycleColors = _colorCycleGetColors();
if (cycleColors.length < 2) {
cssEditorModal.showError(t('color_strip.color_cycle.min_colors'));
return;
}
payload = {
name,
colors: cycleColors,
cycle_speed: parseFloat(document.getElementById('css-editor-cycle-speed').value),
};
if (!cssId) payload.source_type = 'color_cycle';
} else if (sourceType === 'gradient') {
if (_gradientStops.length < 2) {
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
@@ -273,6 +413,7 @@ export async function saveCSSEditor() {
color: s.color,
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'gradient';
} else {

View File

@@ -102,10 +102,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>

View File

@@ -466,10 +466,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>