CSS: add GradientColorStripSource with visual editor

- Backend: GradientColorStripSource storage model, GradientColorStripStream
  with numpy interpolation (bidirectional stops, auto-size from device LED count),
  ColorStop Pydantic schema, API create/update/guard routes
- Frontend: gradient editor modal (canvas preview, draggable markers, stop rows),
  CSS hard-edge card swatch, locale keys (en + ru)
- Fixes: stop row mousedown no longer rebuilds DOM (buttons now clickable),
  position input max-width, bidir/remove button static width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 19:35:41 +03:00
parent 2a8e2daefc
commit 7479b1fb8d
12 changed files with 731 additions and 29 deletions

View File

@@ -25,7 +25,8 @@ class CSSEditorModal extends Modal {
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
color: document.getElementById('css-editor-color').value,
led_count: type === 'static' ? '0' : document.getElementById('css-editor-led-count').value,
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
};
}
}
@@ -38,8 +39,13 @@ 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';
// LED count is only meaningful for picture sources; static uses device LED count automatically
document.getElementById('css-editor-led-count-group').style.display = type === 'static' ? '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' : '';
if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll());
}
}
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
@@ -58,6 +64,7 @@ function hexToRgbArray(hex) {
export function createColorStripCard(source, pictureSourceMap) {
const isStatic = source.source_type === 'static';
const isGradient = source.source_type === 'gradient';
let propsHtml;
if (isStatic) {
@@ -68,6 +75,26 @@ export function createColorStripCard(source, pictureSourceMap) {
</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 || [];
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
let cssGradient = '';
if (sortedStops.length >= 2) {
// Build CSS stops that mirror the interpolation algorithm:
// for each stop emit its primary color, then immediately emit color_right
// at the same position to produce a hard edge (bidirectional stop).
const parts = [];
sortedStops.forEach(s => {
const pct = Math.round(s.position * 100);
parts.push(`${rgbArrayToHex(s.color)} ${pct}%`);
if (s.color_right) parts.push(`${rgbArrayToHex(s.color_right)} ${pct}%`);
});
cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
}
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>
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -82,8 +109,10 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : '🎞️';
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
return `
<div class="card" data-css-id="${source.id}">
@@ -136,6 +165,11 @@ export async function showCSSEditor(cssId = null) {
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
} else if (sourceType === 'gradient') {
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -183,6 +217,10 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
}
document.getElementById('css-editor-error').style.display = 'none';
@@ -218,9 +256,21 @@ export async function saveCSSEditor() {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
};
if (!cssId) {
payload.source_type = 'static';
if (!cssId) payload.source_type = 'static';
} else if (sourceType === 'gradient') {
if (_gradientStops.length < 2) {
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
return;
}
payload = {
name,
stops: _gradientStops.map(s => ({
position: s.position,
color: s.color,
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
};
if (!cssId) payload.source_type = 'gradient';
} else {
payload = {
name,
@@ -233,9 +283,7 @@ export async function saveCSSEditor() {
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) {
payload.source_type = 'picture';
}
if (!cssId) payload.source_type = 'picture';
}
try {
@@ -329,3 +377,269 @@ export async function stopCSSOverlay(cssId) {
showToast(t('overlay.error.stop'), 'error');
}
}
/* ══════════════════════════════════════════════════════════════
GRADIENT EDITOR
══════════════════════════════════════════════════════════════ */
/**
* Internal state: array of stop objects.
* Each stop: { position: float 01, color: [R,G,B], colorRight: [R,G,B]|null }
*/
let _gradientStops = [];
let _gradientSelectedIdx = -1;
let _gradientDragging = null; // { idx, trackRect } while dragging
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
function _gradientInterpolate(stops, pos) {
if (!stops.length) return [128, 128, 128];
const sorted = [...stops].sort((a, b) => a.position - b.position);
if (pos <= sorted[0].position) return sorted[0].color.slice();
const last = sorted[sorted.length - 1];
if (pos >= last.position) return (last.colorRight || last.color).slice();
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
if (a.position <= pos && pos <= b.position) {
const span = b.position - a.position;
const t2 = span > 0 ? (pos - a.position) / span : 0;
const lc = a.colorRight || a.color;
const rc = b.color;
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
}
}
return [128, 128, 128];
}
/* ── Init ─────────────────────────────────────────────────────── */
export function gradientInit(stops) {
_gradientStops = stops.map(s => ({
position: parseFloat(s.position ?? 0),
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
}));
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
_gradientDragging = null;
_gradientSetupTrackClick();
gradientRenderAll();
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll() {
_gradientRenderCanvas();
_gradientRenderMarkers();
_gradientRenderStopList();
}
function _gradientRenderCanvas() {
const canvas = document.getElementById('gradient-canvas');
if (!canvas) return;
// Sync canvas pixel width to its CSS display width
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
if (canvas.width !== W) canvas.width = W;
const ctx = canvas.getContext('2d');
const H = canvas.height;
const imgData = ctx.createImageData(W, H);
for (let x = 0; x < W; x++) {
const pos = W > 1 ? x / (W - 1) : 0;
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
for (let y = 0; y < H; y++) {
const idx = (y * W + x) * 4;
imgData.data[idx] = r;
imgData.data[idx + 1] = g;
imgData.data[idx + 2] = b;
imgData.data[idx + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
}
function _gradientRenderMarkers() {
const track = document.getElementById('gradient-markers-track');
if (!track) return;
track.innerHTML = '';
_gradientStops.forEach((stop, idx) => {
const marker = document.createElement('div');
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
marker.style.left = `${stop.position * 100}%`;
marker.style.background = rgbArrayToHex(stop.color);
marker.title = `${(stop.position * 100).toFixed(0)}%`;
marker.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
_gradientSelectedIdx = idx;
_gradientStartDrag(e, idx);
_gradientRenderMarkers();
_gradientRenderStopList();
});
track.appendChild(marker);
});
}
/**
* Update the selected stop index and reflect it via CSS classes only —
* no DOM rebuild, so in-flight click events on child elements are preserved.
*/
function _gradientSelectStop(idx) {
_gradientSelectedIdx = idx;
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
}
function _gradientRenderStopList() {
const list = document.getElementById('gradient-stops-list');
if (!list) return;
list.innerHTML = '';
_gradientStops.forEach((stop, idx) => {
const row = document.createElement('div');
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
const hasBidir = !!stop.colorRight;
const rightColor = stop.colorRight || stop.color;
row.innerHTML = `
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
title="Left color">
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
<span class="gradient-stop-spacer"></span>
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
`;
// Select row on mousedown — CSS-only update so child click events are not interrupted
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
// Position
const posInput = row.querySelector('.gradient-stop-pos');
posInput.addEventListener('change', (e) => {
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
e.target.value = val.toFixed(2);
_gradientStops[idx].position = val;
gradientRenderAll();
});
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
// Left color
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
_gradientStops[idx].color = hexToRgbArray(e.target.value);
const markers = document.querySelectorAll('.gradient-marker');
if (markers[idx]) markers[idx].style.background = e.target.value;
_gradientRenderCanvas();
});
// Bidirectional toggle
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
e.stopPropagation();
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
? null
: [..._gradientStops[idx].color];
_gradientRenderStopList();
_gradientRenderCanvas();
});
// Right color
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
_gradientRenderCanvas();
});
// Remove
row.querySelector('.btn-danger').addEventListener('click', (e) => {
e.stopPropagation();
if (_gradientStops.length > 2) {
_gradientStops.splice(idx, 1);
if (_gradientSelectedIdx >= _gradientStops.length) {
_gradientSelectedIdx = _gradientStops.length - 1;
}
gradientRenderAll();
}
});
list.appendChild(row);
});
}
/* ── Add Stop ─────────────────────────────────────────────────── */
export function gradientAddStop(position) {
if (position === undefined) {
// Find the largest gap between adjacent stops and place in the middle
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
let maxGap = 0, gapMid = 0.5;
for (let i = 0; i < sorted.length - 1; i++) {
const gap = sorted[i + 1].position - sorted[i].position;
if (gap > maxGap) {
maxGap = gap;
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
}
}
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
}
position = Math.min(1, Math.max(0, position));
const color = _gradientInterpolate(_gradientStops, position);
_gradientStops.push({ position, color, colorRight: null });
_gradientSelectedIdx = _gradientStops.length - 1;
gradientRenderAll();
}
/* ── Drag ─────────────────────────────────────────────────────── */
function _gradientStartDrag(e, idx) {
const track = document.getElementById('gradient-markers-track');
if (!track) return;
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
const onMove = (me) => {
if (!_gradientDragging) return;
const { trackRect } = _gradientDragging;
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
gradientRenderAll();
};
const onUp = () => {
_gradientDragging = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
/* ── Track click → add stop ───────────────────────────────────── */
function _gradientSetupTrackClick() {
const track = document.getElementById('gradient-markers-track');
if (!track || track._gradientClickBound) return;
track._gradientClickBound = true;
track.addEventListener('click', (e) => {
if (_gradientDragging) return;
const rect = track.getBoundingClientRect();
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
// Ignore clicks very close to an existing marker
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
if (!tooClose) {
gradientAddStop(Math.round(pos * 100) / 100);
}
});
}