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:
@@ -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 0–1, 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user