/** * 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, _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, getAudioSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect, showTypePicker } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { loadPictureSources } from './streams.js'; export { getValueSourceIcon }; // ── EntitySelect instances for value source editor ── let _vsAudioSourceEntitySelect = null; let _vsPictureSourceEntitySelect = null; let _vsTagsInput = null; class ValueSourceModal extends Modal { constructor() { super('value-source-modal'); } onForceClose() { if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } } 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()), daylightSpeed: document.getElementById('value-source-daylight-speed').value, daylightRealTime: document.getElementById('value-source-daylight-real-time').checked, daylightLatitude: document.getElementById('value-source-daylight-latitude').value, tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []), }; } } const valueSourceModal = new ValueSourceModal(); /* ── Name auto-generation ────────────────────────────────────── */ let _vsNameManuallyEdited = false; function _autoGenerateVSName() { if (_vsNameManuallyEdited) return; if (document.getElementById('value-source-id').value) return; const type = document.getElementById('value-source-type').value; const typeLabel = t(`value_source.type.${type}`); let detail = ''; if (type === 'animated') { const wf = document.getElementById('value-source-waveform').value; detail = t(`value_source.waveform.${wf}`); } else if (type === 'audio') { const mode = document.getElementById('value-source-mode').value; detail = t(`value_source.mode.${mode}`); } else if (type === 'adaptive_scene') { const sel = document.getElementById('value-source-picture-source'); const name = sel?.selectedOptions[0]?.textContent?.trim(); if (name) detail = name; } document.getElementById('value-source-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel; } /* ── Icon-grid type selector ──────────────────────────────────── */ const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight']; function _buildVSTypeItems() { return VS_TYPE_KEYS.map(key => ({ value: key, icon: getValueSourceIcon(key), label: t(`value_source.type.${key}`), desc: t(`value_source.type.${key}.desc`), })); } let _vsTypeIconSelect = null; let _waveformIconSelect = null; const _WAVEFORM_SVG = { sine: '', triangle: '', square: '', sawtooth: '', }; function _ensureWaveformIconSelect() { const sel = document.getElementById('value-source-waveform'); if (!sel) return; const items = [ { value: 'sine', icon: _WAVEFORM_SVG.sine, label: t('value_source.waveform.sine') }, { value: 'triangle', icon: _WAVEFORM_SVG.triangle, label: t('value_source.waveform.triangle') }, { value: 'square', icon: _WAVEFORM_SVG.square, label: t('value_source.waveform.square') }, { value: 'sawtooth', icon: _WAVEFORM_SVG.sawtooth, label: t('value_source.waveform.sawtooth') }, ]; if (_waveformIconSelect) { _waveformIconSelect.updateItems(items); return; } _waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 }); } /* ── Waveform canvas preview ──────────────────────────────────── */ /** * Draw a waveform preview on the canvas element #value-source-waveform-preview. * Shows one full cycle of the selected waveform shape. */ function _drawWaveformPreview(waveformType) { const canvas = document.getElementById('value-source-waveform-preview'); if (!canvas) return; const dpr = window.devicePixelRatio || 1; const cssW = canvas.offsetWidth || 200; const cssH = 60; canvas.width = cssW * dpr; canvas.height = cssH * dpr; canvas.style.height = cssH + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const W = cssW; const H = cssH; const padX = 8; const padY = 8; const drawW = W - padX * 2; const drawH = H - padY * 2; const midY = padY + drawH / 2; // Draw zero line ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(padX, midY); ctx.lineTo(padX + drawW, midY); ctx.stroke(); ctx.setLineDash([]); // Draw waveform const N = 120; ctx.beginPath(); for (let i = 0; i <= N; i++) { const t = i / N; // 0..1 over one cycle let v; // -1..1 switch (waveformType) { case 'triangle': v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t); break; case 'square': v = t < 0.5 ? 1 : -1; break; case 'sawtooth': v = 2 * t - 1; break; case 'sine': default: v = Math.sin(2 * Math.PI * t); break; } const x = padX + t * drawW; const y = midY - v * (drawH / 2); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } // Glow effect: draw thick translucent line first ctx.strokeStyle = 'rgba(99,179,237,0.25)'; ctx.lineWidth = 4; ctx.stroke(); // Crisp line on top ctx.strokeStyle = '#63b3ed'; ctx.lineWidth = 1.5; ctx.stroke(); } export function updateWaveformPreview() { const wf = document.getElementById('value-source-waveform')?.value || 'sine'; _drawWaveformPreview(wf); } /* ── Audio mode icon-grid selector ────────────────────────────── */ const _AUDIO_MODE_SVG = { rms: '', peak: '', beat: '', }; let _audioModeIconSelect = null; function _ensureAudioModeIconSelect() { const sel = document.getElementById('value-source-mode'); if (!sel) return; const items = [ { value: 'rms', icon: _AUDIO_MODE_SVG.rms, label: t('value_source.mode.rms'), desc: t('value_source.mode.rms.desc') }, { value: 'peak', icon: _AUDIO_MODE_SVG.peak, label: t('value_source.mode.peak'), desc: t('value_source.mode.peak.desc') }, { value: 'beat', icon: _AUDIO_MODE_SVG.beat, label: t('value_source.mode.beat'), desc: t('value_source.mode.beat.desc') }, ]; if (_audioModeIconSelect) { _audioModeIconSelect.updateItems(items); return; } _audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 }); } function _ensureVSTypeIconSelect() { const sel = document.getElementById('value-source-type'); if (!sel) return; if (_vsTypeIconSelect) { _vsTypeIconSelect.updateItems(_buildVSTypeItems()); return; } _vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 }); } // ── Modal ───────────────────────────────────────────────────── export async function showValueSourceModal(editData, presetType = null) { // When creating new: show type picker first, then re-enter with presetType if (!editData && !presetType) { showTypePicker({ title: t('value_source.select_type'), items: _buildVSTypeItems(), onPick: (type) => showValueSourceModal(null, type), }); return; } const hasId = editData?.id; const isEdit = !!hasId; const sourceType = editData?.source_type || presetType || 'static'; const titleIcon = getValueSourceIcon(sourceType); const titleKey = isEdit ? 'value_source.edit' : 'value_source.add'; const typeName = t(`value_source.type.${sourceType}`); document.getElementById('value-source-modal-title').innerHTML = isEdit ? `${titleIcon} ${t(titleKey)}` : `${titleIcon} ${t(titleKey)}: ${typeName}`; document.getElementById('value-source-id').value = isEdit ? editData.id : ''; document.getElementById('value-source-error').style.display = 'none'; _vsNameManuallyEdited = !!(isEdit || editData); document.getElementById('value-source-name').oninput = () => { _vsNameManuallyEdited = true; }; _ensureVSTypeIconSelect(); const typeSelect = document.getElementById('value-source-type'); // Type is chosen before the modal opens — always hide selector document.getElementById('value-source-type-group').style.display = 'none'; if (editData) { 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'; if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine'); _drawWaveformPreview(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'; if (_audioModeIconSelect) _audioModeIconSelect.setValue(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 if (editData.source_type === 'daylight') { _setSlider('value-source-daylight-speed', editData.speed ?? 1.0); document.getElementById('value-source-daylight-real-time').checked = !!editData.use_real_time; _setSlider('value-source-daylight-latitude', editData.latitude ?? 50); _syncDaylightVSSpeedVisibility(); _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 = presetType || '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'; _drawWaveformPreview('sine'); _populateAudioSourceDropdown(''); document.getElementById('value-source-mode').value = 'rms'; if (_audioModeIconSelect) _audioModeIconSelect.setValue('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); // Daylight defaults _setSlider('value-source-daylight-speed', 1.0); document.getElementById('value-source-daylight-real-time').checked = false; _setSlider('value-source-daylight-latitude', 50); _syncDaylightVSSpeedVisibility(); _autoGenerateVSName(); } // Wire up auto-name triggers document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }; document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName(); document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName(); // Tags if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } _vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') }); _vsTagsInput.setValue(editData ? (editData.tags || []) : []); valueSourceModal.open(); valueSourceModal.snapshot(); } export async function closeValueSourceModal() { await valueSourceModal.close(); } export function onValueSourceTypeChange() { const type = document.getElementById('value-source-type').value; if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type); document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none'; document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none'; if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); } document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none'; if (type === 'audio') _ensureAudioModeIconSelect(); 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-daylight-section').style.display = type === 'daylight' ? '' : 'none'; document.getElementById('value-source-adaptive-range-section').style.display = (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : '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(''); } _autoGenerateVSName(); } // ── Daylight helpers ────────────────────────────────────────── export function onDaylightVSRealTimeChange() { _syncDaylightVSSpeedVisibility(); } function _syncDaylightVSSpeedVisibility() { const rt = document.getElementById('value-source-daylight-real-time').checked; document.getElementById('value-source-daylight-speed-group').style.display = rt ? 'none' : ''; } // ── 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, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] }; 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); } else if (sourceType === 'daylight') { payload.speed = parseFloat(document.getElementById('value-source-daylight-speed').value); payload.use_real_time = document.getElementById('value-source-daylight-real-time').checked; payload.latitude = parseFloat(document.getElementById('value-source-daylight-latitude').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(t('value_source.error.load')); const data = await resp.json(); await showValueSourceModal(data); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } // ── Clone ───────────────────────────────────────────────────── export async function cloneValueSource(sourceId) { try { const resp = await fetchWithAuth(`/value-sources/${sourceId}`); if (!resp.ok) throw new Error(t('value_source.error.load')); 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 = `${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}`; } else if (src.source_type === 'animated') { const waveLabel = src.waveform || 'sine'; propsHtml = ` ${ICON_ACTIVITY} ${escapeHtml(waveLabel)} ${ICON_TIMER} ${src.speed ?? 10} cpm ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1} `; } 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 ? `${ICON_MUSIC} ${escapeHtml(audioName)}` : `${ICON_MUSIC} ${escapeHtml(audioName)}`; propsHtml = ` ${audioBadge} ${ICON_TRENDING_UP} ${modeLabel.toUpperCase()} ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1} `; } else if (src.source_type === 'adaptive_time') { const pts = (src.schedule || []).length; propsHtml = ` ${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')} ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1} `; } else if (src.source_type === 'daylight') { if (src.use_real_time) { propsHtml = `${ICON_CLOCK} ${t('value_source.daylight.real_time')}`; } else { propsHtml = `${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x`; } propsHtml += `${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}`; } 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 ? `${ICON_MONITOR} ${escapeHtml(psName)}` : `${ICON_MONITOR} ${escapeHtml(psName)}`; propsHtml = ` ${psBadge} ${ICON_REFRESH} ${src.scene_behavior || 'complement'} `; } return wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, removeOnclick: `deleteValueSource('${src.id}')`, removeTitle: t('common.delete'), content: `
${icon} ${escapeHtml(src.name)}
${propsHtml}
${renderTagChips(src.tags)} ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, actions: ` `, }); } // ── 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 ``; }).join(''); if (_vsAudioSourceEntitySelect) _vsAudioSourceEntitySelect.destroy(); if (_cachedAudioSources.length > 0) { _vsAudioSourceEntitySelect = new EntitySelect({ target: select, getItems: () => _cachedAudioSources.map(s => ({ value: s.id, label: s.name, icon: getAudioSourceIcon(s.source_type), desc: s.source_type, })), placeholder: t('palette.search'), }); } } // ── Adaptive helpers ────────────────────────────────────────── function _populatePictureSourceDropdown(selectedId) { const select = document.getElementById('value-source-picture-source'); if (!select) return; select.innerHTML = _cachedStreams.map(s => `` ).join(''); if (_vsPictureSourceEntitySelect) _vsPictureSourceEntitySelect.destroy(); if (_cachedStreams.length > 0) { _vsPictureSourceEntitySelect = new EntitySelect({ target: select, getItems: () => _cachedStreams.map(s => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), })), placeholder: t('palette.search'), }); } } 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 = ` ${value} `; 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)); } }