Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/value-sources.js
alexei.dolgolyov c262ec0775 Replace all emoji icons with Lucide SVGs, add accent color picker
- Replace all emoji characters across WebUI with inline Lucide SVG icons
  for cross-platform consistency (icon paths in icon-paths.js)
- Add accent color picker popover with 9 preset colors + custom picker,
  persisted to localStorage, updates all CSS custom properties
- Remove subtab separator line for cleaner look
- Color badge icons with accent color for visual pop
- Remove processing badge from target cards
- Fix hardcoded #4CAF50 in FPS labels and active badges to use CSS vars
- Replace CSS content emoji (▶) with pure CSS triangle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:14:18 +03:00

609 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive_time, adaptive_scene).
*
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
* on LED targets. Five subtypes: static (constant), animated (waveform),
* audio (audio-reactive), adaptive_time (time-of-day schedule),
* adaptive_scene (scene brightness analysis).
*
* Card rendering is handled by streams.js (Value tab).
* This module manages the editor modal and API operations.
*/
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import {
getValueSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js';
import { loadPictureSources } from './streams.js';
export { getValueSourceIcon };
class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); }
snapshotValues() {
const type = document.getElementById('value-source-type').value;
return {
name: document.getElementById('value-source-name').value,
description: document.getElementById('value-source-description').value,
type,
value: document.getElementById('value-source-value').value,
waveform: document.getElementById('value-source-waveform').value,
speed: document.getElementById('value-source-speed').value,
minValue: document.getElementById('value-source-min-value').value,
maxValue: document.getElementById('value-source-max-value').value,
audioSource: document.getElementById('value-source-audio-source').value,
mode: document.getElementById('value-source-mode').value,
sensitivity: document.getElementById('value-source-sensitivity').value,
smoothing: document.getElementById('value-source-smoothing').value,
autoGain: document.getElementById('value-source-auto-gain').checked,
adaptiveMin: document.getElementById('value-source-adaptive-min-value').value,
adaptiveMax: document.getElementById('value-source-adaptive-max-value').value,
pictureSource: document.getElementById('value-source-picture-source').value,
sceneBehavior: document.getElementById('value-source-scene-behavior').value,
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
schedule: JSON.stringify(_getScheduleFromUI()),
};
}
}
const valueSourceModal = new ValueSourceModal();
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
const titleIcon = isEdit ? getValueSourceIcon(editData.source_type) : getValueSourceIcon('static');
document.getElementById('value-source-modal-title').innerHTML = `${titleIcon} ${t(titleKey)}`;
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none';
const typeSelect = document.getElementById('value-source-type');
document.getElementById('value-source-type-group').style.display = isEdit ? 'none' : '';
if (isEdit) {
document.getElementById('value-source-name').value = editData.name || '';
document.getElementById('value-source-description').value = editData.description || '';
typeSelect.value = editData.source_type || 'static';
onValueSourceTypeChange();
if (editData.source_type === 'static') {
_setSlider('value-source-value', editData.value ?? 1.0);
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'audio') {
_populateAudioSourceDropdown(editData.audio_source_id || '');
document.getElementById('value-source-mode').value = editData.mode || 'rms';
document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain;
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
_setSlider('value-source-audio-min-value', editData.min_value ?? 0);
_setSlider('value-source-audio-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'adaptive_time') {
_populateScheduleUI(editData.schedule);
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'adaptive_scene') {
_populatePictureSourceDropdown(editData.picture_source_id || '');
document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement';
_setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
_setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
}
} else {
document.getElementById('value-source-name').value = '';
document.getElementById('value-source-description').value = '';
typeSelect.value = 'static';
onValueSourceTypeChange();
_setSlider('value-source-value', 1.0);
_setSlider('value-source-speed', 10);
_setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine';
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
document.getElementById('value-source-auto-gain').checked = false;
_setSlider('value-source-sensitivity', 1.0);
_setSlider('value-source-smoothing', 0.3);
_setSlider('value-source-audio-min-value', 0);
_setSlider('value-source-audio-max-value', 1);
// Adaptive defaults
_populateScheduleUI([]);
_populatePictureSourceDropdown('');
document.getElementById('value-source-scene-behavior').value = 'complement';
_setSlider('value-source-scene-sensitivity', 1.0);
_setSlider('value-source-scene-smoothing', 0.3);
_setSlider('value-source-adaptive-min-value', 0);
_setSlider('value-source-adaptive-max-value', 1);
}
valueSourceModal.open();
valueSourceModal.snapshot();
}
export async function closeValueSourceModal() {
await valueSourceModal.close();
}
export function onValueSourceTypeChange() {
const type = document.getElementById('value-source-type').value;
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none';
document.getElementById('value-source-adaptive-range-section').style.display =
(type === 'adaptive_time' || type === 'adaptive_scene') ? '' : 'none';
// Populate audio dropdown when switching to audio type
if (type === 'audio') {
const select = document.getElementById('value-source-audio-source');
if (select && select.options.length === 0) {
_populateAudioSourceDropdown('');
}
}
// Populate picture source dropdown when switching to scene type
if (type === 'adaptive_scene') {
_populatePictureSourceDropdown('');
}
}
// ── Save ──────────────────────────────────────────────────────
export async function saveValueSource() {
const id = document.getElementById('value-source-id').value;
const name = document.getElementById('value-source-name').value.trim();
const sourceType = document.getElementById('value-source-type').value;
const description = document.getElementById('value-source-description').value.trim() || null;
const errorEl = document.getElementById('value-source-error');
if (!name) {
errorEl.textContent = t('value_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload = { name, source_type: sourceType, description };
if (sourceType === 'static') {
payload.value = parseFloat(document.getElementById('value-source-value').value);
} else if (sourceType === 'animated') {
payload.waveform = document.getElementById('value-source-waveform').value;
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
} else if (sourceType === 'audio') {
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
payload.mode = document.getElementById('value-source-mode').value;
payload.auto_gain = document.getElementById('value-source-auto-gain').checked;
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').value);
} else if (sourceType === 'adaptive_time') {
payload.schedule = _getScheduleFromUI();
if (payload.schedule.length < 2) {
errorEl.textContent = t('value_source.error.schedule_min');
errorEl.style.display = '';
return;
}
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
} else if (sourceType === 'adaptive_scene') {
payload.picture_source_id = document.getElementById('value-source-picture-source').value;
payload.scene_behavior = document.getElementById('value-source-scene-behavior').value;
payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value);
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/value-sources/${id}` : '/value-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
await showValueSourceModal(data);
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showValueSourceModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteValueSource(sourceId) {
const confirmed = await showConfirm(t('value_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('value_source.deleted'), 'success');
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Value Source Test (real-time output chart) ────────────────
const VS_HISTORY_SIZE = 200;
let _testVsWs = null;
let _testVsAnimFrame = null;
let _testVsLatest = null;
let _testVsHistory = [];
let _testVsMinObserved = Infinity;
let _testVsMaxObserved = -Infinity;
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
export function testValueSource(sourceId) {
const statusEl = document.getElementById('vs-test-status');
if (statusEl) {
statusEl.textContent = t('value_source.test.connecting');
statusEl.style.display = '';
}
// Reset state
_testVsLatest = null;
_testVsHistory = [];
_testVsMinObserved = Infinity;
_testVsMaxObserved = -Infinity;
document.getElementById('vs-test-current').textContent = '---';
document.getElementById('vs-test-min').textContent = '---';
document.getElementById('vs-test-max').textContent = '---';
testVsModal.open();
// Size canvas to container
const canvas = document.getElementById('vs-test-canvas');
_sizeVsCanvas(canvas);
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/value-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}`;
try {
_testVsWs = new WebSocket(wsUrl);
_testVsWs.onopen = () => {
if (statusEl) statusEl.style.display = 'none';
};
_testVsWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
_testVsLatest = data.value;
_testVsHistory.push(data.value);
if (_testVsHistory.length > VS_HISTORY_SIZE) {
_testVsHistory.shift();
}
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
} catch {}
};
_testVsWs.onclose = () => {
_testVsWs = null;
};
_testVsWs.onerror = () => {
showToast(t('value_source.test.error'), 'error');
_cleanupVsTest();
};
} catch {
showToast(t('value_source.test.error'), 'error');
_cleanupVsTest();
return;
}
// Start render loop
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
}
export function closeTestValueSourceModal() {
_cleanupVsTest();
testVsModal.forceClose();
}
function _cleanupVsTest() {
if (_testVsAnimFrame) {
cancelAnimationFrame(_testVsAnimFrame);
_testVsAnimFrame = null;
}
if (_testVsWs) {
_testVsWs.onclose = null;
_testVsWs.close();
_testVsWs = null;
}
_testVsLatest = null;
}
function _sizeVsCanvas(canvas) {
const rect = canvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = '200px';
canvas.getContext('2d').scale(dpr, dpr);
}
function _renderVsTestLoop() {
_renderVsChart();
if (testVsModal.isOpen) {
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
}
}
function _renderVsChart() {
const canvas = document.getElementById('vs-test-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Draw horizontal guide lines at 0.0, 0.5, 1.0
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
for (const frac of [0, 0.5, 1.0]) {
const y = h - frac * h;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
ctx.setLineDash([]);
// Draw Y-axis labels
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText('1.0', 4, 12);
ctx.fillText('0.5', 4, h / 2 - 2);
ctx.fillText('0.0', 4, h - 4);
const history = _testVsHistory;
if (history.length < 2) return;
// Draw filled area under the line
ctx.beginPath();
const stepX = w / (VS_HISTORY_SIZE - 1);
const startOffset = VS_HISTORY_SIZE - history.length;
ctx.moveTo(startOffset * stepX, h);
for (let i = 0; i < history.length; i++) {
const x = (startOffset + i) * stepX;
const y = h - history[i] * h;
ctx.lineTo(x, y);
}
ctx.lineTo((startOffset + history.length - 1) * stepX, h);
ctx.closePath();
ctx.fillStyle = 'rgba(76, 175, 80, 0.15)';
ctx.fill();
// Draw the line
ctx.beginPath();
for (let i = 0; i < history.length; i++) {
const x = (startOffset + i) * stepX;
const y = h - history[i] * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = '#4caf50';
ctx.lineWidth = 2;
ctx.stroke();
// Update stats
if (_testVsLatest !== null) {
document.getElementById('vs-test-current').textContent = (_testVsLatest * 100).toFixed(1) + '%';
}
if (_testVsMinObserved !== Infinity) {
document.getElementById('vs-test-min').textContent = (_testVsMinObserved * 100).toFixed(1) + '%';
}
if (_testVsMaxObserved !== -Infinity) {
document.getElementById('vs-test-max').textContent = (_testVsMaxObserved * 100).toFixed(1) + '%';
}
}
// ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src) {
const icon = getValueSourceIcon(src.source_type);
let propsHtml = '';
if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
} else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine';
propsHtml = `
<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
const audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : 'audio-multi') : 'audio-multi';
const modeLabel = src.mode || 'rms';
const audioBadge = audioSrc
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`;
propsHtml = `
${audioBadge}
<span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length;
propsHtml = `
<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'adaptive_scene') {
const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
const psName = ps ? ps.name : (src.picture_source_id || '-');
let psSubTab = 'raw', psSection = 'raw-streams';
if (ps) {
if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
}
const psBadge = ps
? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">${ICON_MONITOR} ${escapeHtml(psName)}</span>`
: `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`;
propsHtml = `
${psBadge}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
}
// ── Helpers ───────────────────────────────────────────────────
function _setSlider(id, value) {
const slider = document.getElementById(id);
if (slider) {
slider.value = value;
const display = document.getElementById(id + '-display');
if (display) display.textContent = value;
}
}
function _populateAudioSourceDropdown(selectedId) {
const select = document.getElementById('value-source-audio-source');
if (!select) return;
select.innerHTML = _cachedAudioSources.map(s => {
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join('');
}
// ── Adaptive helpers ──────────────────────────────────────────
function _populatePictureSourceDropdown(selectedId) {
const select = document.getElementById('value-source-picture-source');
if (!select) return;
select.innerHTML = _cachedStreams.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}
export function addSchedulePoint(time = '', value = 1.0) {
const list = document.getElementById('value-source-schedule-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'schedule-row';
row.innerHTML = `
<input type="time" class="schedule-time" value="${time || '12:00'}">
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
`;
list.appendChild(row);
}
function _getScheduleFromUI() {
const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row');
const schedule = [];
rows.forEach(row => {
const time = row.querySelector('.schedule-time').value;
const value = parseFloat(row.querySelector('.schedule-value').value);
if (time) schedule.push({ time, value });
});
return schedule;
}
function _populateScheduleUI(schedule) {
const list = document.getElementById('value-source-schedule-list');
if (!list) return;
list.innerHTML = '';
if (!schedule || schedule.length === 0) {
// Default: morning bright, night dim
addSchedulePoint('08:00', 1.0);
addSchedulePoint('22:00', 0.3);
} else {
schedule.forEach(p => addSchedulePoint(p.time, p.value));
}
}