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:
@@ -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})">✕</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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user