(function () { const PREVIEW_RENDER_INTERVAL_MS = 16; const STATE_RENDER_INTERVAL_MS = 180; const POSITION_ORDER = { top: 0, middle: 1, bottom: 2 }; const COLOR_PARAM_KEYS = new Set(["color_mode", "palette", "primary_color", "secondary_color"]); const HIDDEN_PARAMETER_KEYS = new Set(["speed", "brightness"]); const TEMPO_BPM_MIN = 10; const TEMPO_BPM_MAX = 300; const TEMPO_BPM_DEFAULT = 120; const SPEED_TO_BPM_FACTOR = 60; const TILE_LED_GEOMETRY = buildTileLedGeometry(); const OPERATOR_MODES = [ { mode_id: "arrow", label: "Arrow", pattern_id: "arrow", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "breathing", label: "Breathing", pattern_id: "breathing", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, { mode_id: "center_pulse", label: "Center Pulse", pattern_id: "center_pulse", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "checker", label: "Checkerd", pattern_id: "checker", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "column_gradient", label: "Column Gradient", pattern_id: "column_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, { mode_id: "row_gradient", label: "Row Gradient", pattern_id: "row_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, { mode_id: "saw", label: "Saw", pattern_id: "saw", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, { mode_id: "scan", label: "Scan", pattern_id: "scan", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "scan_dual", label: "Scan Dual", pattern_id: "scan_dual", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "snake", label: "Snake", pattern_id: "snake", defaults: { speed: 1.0, randomness: 0.35, intensity: 1.0 }, canonical: true }, { mode_id: "solid", label: "Solid", pattern_id: "solid", defaults: { speed: 0.0, intensity: 1.0 }, canonical: true }, { mode_id: "sparkle", label: "Sparkle", pattern_id: "sparkle", defaults: { speed: 1.0, strobe_duty_cycle: 0.72, intensity: 0.86 }, canonical: true }, { mode_id: "stopwatch", label: "Stopwatch", pattern_id: "stopwatch", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "strobe", label: "Strobe", pattern_id: "strobe", defaults: { speed: 1.0, strobe_duty_cycle: 0.5, intensity: 1.0 }, canonical: true }, { mode_id: "sweep", label: "Sweep", pattern_id: "sweep", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "two_dots", label: "Two Dots", pattern_id: "two_dots", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "wave_line", label: "Wave Line", pattern_id: "wave_line", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, ]; const OPERATOR_MODE_BY_ID = new Map(OPERATOR_MODES.map((mode) => [mode.mode_id, mode])); const LEGACY_PARAMETER_CONTRACT = { color_mode: { control: "enum", label: "Color Mode", default_value: "dual", options: [ { value: "dual", label: "Dual" }, { value: "palette", label: "Palette" }, { value: "mono", label: "Mono" }, { value: "complementary", label: "Complementary" }, { value: "random_colors", label: "Random Colors" }, { value: "custom_random", label: "Custom Random" }, ], }, palette: { control: "enum", label: "Palette", default_value: "Deep Blue", groups: [ { label: "White / Temperature Presets", options: [ { value: "Candle", label: "Candle (#FF9329)" }, { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, { value: "Halogen", label: "Halogen (#FFF1E0)" }, { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, ], }, { label: "Club / Show Presets", options: [ { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, { value: "Emerald", label: "Emerald (#00C853)" }, { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, { value: "Amber", label: "Amber (#FF9D00)" }, { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, { value: "Violet", label: "Violet (#7A3CFF)" }, { value: "Warm White", label: "Warm White (#FFD6AA)" }, { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, { value: "Cool White", label: "Cool White (#C9E2FF)" }, { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, ], }, ], options: [ { value: "Candle", label: "Candle (#FF9329)" }, { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, { value: "Halogen", label: "Halogen (#FFF1E0)" }, { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, { value: "Emerald", label: "Emerald (#00C853)" }, { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, { value: "Amber", label: "Amber (#FF9D00)" }, { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, { value: "Violet", label: "Violet (#7A3CFF)" }, { value: "Warm White", label: "Warm White (#FFD6AA)" }, { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, { value: "Cool White", label: "Cool White (#C9E2FF)" }, { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, ], }, direction: { control: "enum", label: "Direction", default_value: "left_to_right", options: [ { value: "left_to_right", label: "Left to Right" }, { value: "right_to_left", label: "Right to Left" }, { value: "top_to_bottom", label: "Top to Bottom" }, { value: "bottom_to_top", label: "Bottom to Top" }, { value: "outward", label: "Outward" }, { value: "inward", label: "Inward" }, ], }, symmetry: { control: "enum", label: "Mirror", default_value: "none", options: [ { value: "none", label: "None" }, { value: "horizontal", label: "Horizontal" }, { value: "vertical", label: "Vertical" }, { value: "both", label: "Both" }, ], }, checker_mode: { control: "enum", label: "Checker Mode", default_value: "classic", options: [ { value: "classic", label: "Classic" }, { value: "diagonal", label: "Diagonal Split" }, { value: "checkerd", label: "Checkerd" }, ], }, scan_style: { control: "enum", label: "Scan Style", default_value: "line", options: [ { value: "line", label: "Line" }, { value: "bands", label: "Bands" }, ], }, strobe_mode: { control: "enum", label: "Strobe Mode", default_value: "global", options: [ { value: "global", label: "Global" }, { value: "random_pixels", label: "Random Pixels" }, { value: "random_leds", label: "Random LEDs" }, ], }, stopwatch_mode: { control: "enum", label: "Stopwatch Mode", default_value: "sync", options: [ { value: "sync", label: "Sync" }, { value: "random", label: "Random" }, ], }, center_pulse_mode: { control: "enum", label: "Pulse Mode", default_value: "expand", options: [ { value: "expand", label: "Expand" }, { value: "reverse", label: "Reverse" }, { value: "outline", label: "Outline" }, { value: "outline_reverse", label: "Outline Reverse" }, ], }, angle: { control: "enum", label: "Angle", default_value: "0", value_kind: "scalar", options: [0, 45, 90, 135, 180, 225, 270, 315].map((value) => ({ value: String(value), label: `${value}°`, })), }, primary_color: { control: "color", label: "Primary Color", default_value: "#4D7CFF" }, secondary_color: { control: "color", label: "Secondary Color", default_value: "#0E1630" }, flip_horizontal: { control: "toggle", label: "Flip Horizontal", default_value: false }, flip_vertical: { control: "toggle", label: "Flip Vertical", default_value: false }, }; const apiState = { stateResponse: null, previewResponse: null, catalog: null, events: [], ws: null, commandTimers: new Map(), controlClient: null, ui: { previewMode: "leds", workMode: "test_edit", selectedPanelKey: null, previewNodes: new Map(), previewSignatures: new Map(), parameterCards: new Map(), lastSelectedModeId: null, patternsSignature: null, presetsSignature: null, selectedPresetId: null, snapshotsSignature: null, parameterSignature: null, previewLayoutSignature: null, viewOutputSignature: null, stateTimer: null, lastStateRenderAt: 0, lastPreviewRenderAt: 0, stateRenderQueued: false, previewRenderQueued: false, previewAnimationFrame: 0, eventFilterSignature: null, }, }; const dom = { projectName: document.getElementById("project-name"), topologyLabel: document.getElementById("topology-label"), connectionPill: document.getElementById("connection-pill"), editContextLabel: document.getElementById("edit-context-label"), previewUpdated: document.getElementById("preview-updated"), refreshButton: document.getElementById("refresh-button"), tempoBpmInput: document.getElementById("tempo-bpm-input"), tempoBpmLabel: document.getElementById("tempo-bpm-label"), workModeSelect: document.getElementById("work-mode-select"), previewModeLabel: document.getElementById("preview-mode-label"), controlModePill: document.getElementById("control-mode-pill"), pendingPanelDescription: document.getElementById("pending-panel-description"), sessionScopeLabel: document.getElementById("session-scope-label"), pendingCommitPill: document.getElementById("pending-commit-pill"), pendingCompactLabel: document.getElementById("pending-compact-label"), pendingSessionSummary: document.getElementById("pending-session-summary"), primitiveErrorBanner: document.getElementById("primitive-error-banner"), triggerTransitionButton: document.getElementById("trigger-transition-button"), clearStagedButton: document.getElementById("clear-staged-button"), goButton: document.getElementById("go-button"), fadeGoButton: document.getElementById("fade-go-button"), utilityGoButton: document.getElementById("utility-go-button"), utilityFadeGoButton: document.getElementById("utility-fade-go-button"), patternSelect: document.getElementById("pattern-select"), transitionSecondsInput: document.getElementById("transition-seconds-input"), transitionSecondsLabel: document.getElementById("transition-seconds-label"), transitionStyleSelect: document.getElementById("transition-style-select"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), utilityBlackoutButton: document.getElementById("utility-blackout-button"), presetList: document.getElementById("preset-list"), presetIdInput: document.getElementById("preset-id-input"), presetOverwriteInput: document.getElementById("preset-overwrite-input"), savePresetButton: document.getElementById("save-preset-button"), loadPresetButton: document.getElementById("load-preset-button"), deletePresetButton: document.getElementById("delete-preset-button"), snapshotIdInput: document.getElementById("snapshot-id-input"), snapshotLabelInput: document.getElementById("snapshot-label-input"), snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"), saveSnapshotButton: document.getElementById("save-snapshot-button"), snapshotList: document.getElementById("snapshot-list"), motionParams: document.getElementById("motion-params"), colorParams: document.getElementById("color-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), selectedTileCard: document.getElementById("selected-tile-card"), whiteTestButton: document.getElementById("white-test-button"), livePatternButton: document.getElementById("live-pattern-button"), viewOutputList: document.getElementById("view-output-list"), snapshotJson: document.getElementById("snapshot-json"), eventKindFilter: document.getElementById("event-kind-filter"), eventSearchFilter: document.getElementById("event-search-filter"), eventList: document.getElementById("event-list"), }; function init() { apiState.controlClient = createShowControlClient(); bindControls(); syncPresetActionButtons(); refreshAll(); connectStream(); } function bindControls() { dom.refreshButton.addEventListener("click", () => refreshAll()); dom.workModeSelect.addEventListener("change", (event) => { setWorkMode(event.target.value); }); dom.patternSelect.addEventListener("change", async (event) => { await applyPatternSelection(event.target.value); }); dom.tempoBpmInput.addEventListener("change", async (event) => { await applyTempoBpm(Number(event.target.value)); }); dom.tempoBpmInput.addEventListener("input", (event) => { const bpm = normalizeTempoBpm(Number(event.target.value)); dom.tempoBpmLabel.textContent = `${bpm} BPM`; }); dom.transitionSecondsInput.addEventListener("change", async (event) => { await applyTransitionSettings(dom.transitionStyleSelect.value, Number(event.target.value), true); }); dom.transitionSecondsInput.addEventListener("input", (event) => { const seconds = normalizeTransitionSeconds(Number(event.target.value)); dom.transitionSecondsLabel.textContent = `${seconds.toFixed(1)} s`; }); dom.transitionStyleSelect.addEventListener("change", async (event) => { await applyTransitionSettings(event.target.value, Number(dom.transitionSecondsInput.value), true); }); dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; debounceCommand("master_brightness", async () => { await handlePrimitive({ primitive: "set_master_brightness", payload: { value }, }); }); }); dom.blackoutButton.addEventListener("click", async () => toggleBlackout()); dom.utilityBlackoutButton.addEventListener("click", async () => toggleBlackout()); dom.goButton.addEventListener("click", async () => commitTransition({ cut: true })); dom.utilityGoButton.addEventListener("click", async () => commitTransition({ cut: true })); dom.fadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.utilityFadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.triggerTransitionButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.clearStagedButton.addEventListener("click", () => { apiState.controlClient.clearPending(); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "staged_transition_cleared", message: "Staged transition buffer cleared.", }); renderLocalUi(); }); dom.savePresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_id_required", message: "Preset ID is required before saving.", }); return; } try { await sendCommand({ type: "save_preset", payload: { preset_id: presetId, overwrite: dom.presetOverwriteInput.checked, }, }); } catch (error) { handleClientError(error, "save_preset"); renderLocalUi(); } }); dom.loadPresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_selection_required", message: "Select a preset or provide a preset ID before loading.", }); return; } await handlePrimitive({ primitive: "recall_preset", payload: { preset_id: presetId }, }); }); dom.deletePresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_selection_required", message: "Select a preset or provide a preset ID before deleting.", }); return; } try { await sendCommand({ type: "delete_preset", payload: { preset_id: presetId, }, }); if (apiState.ui.selectedPresetId === presetId) { apiState.ui.selectedPresetId = null; } } catch (error) { handleClientError(error, "delete_preset"); renderLocalUi(); } }); dom.saveSnapshotButton.addEventListener("click", async () => { const snapshotId = dom.snapshotIdInput.value.trim(); if (!snapshotId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "snapshot_id_required", message: "Snapshot ID is required before saving a creative variant.", }); return; } try { await sendCommand({ type: "save_creative_snapshot", payload: { snapshot_id: snapshotId, label: dom.snapshotLabelInput.value.trim() || null, overwrite: dom.snapshotOverwriteInput.checked, }, }); } catch (error) { handleClientError(error, "save_creative_snapshot"); renderLocalUi(); } }); dom.eventKindFilter.addEventListener("change", () => renderEvents(true)); dom.eventSearchFilter.addEventListener("input", () => renderEvents(true)); dom.presetIdInput.addEventListener("input", () => { syncPresetActionButtons(); }); dom.whiteTestButton.addEventListener("click", async () => { const selected = selectedPanel(); if (!selected) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "panel_selection_required", message: "Select a tile before triggering a white test.", }); return; } try { await sendCommand( { type: "trigger_panel_test", payload: { node_id: selected.node_id, panel_position: selected.panel_position, pattern: "walking_pixel_106", }, }, { refresh: false } ); } catch (error) { handleClientError(error, "trigger_panel_test"); } }); dom.livePatternButton.addEventListener("click", () => { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "live_pattern_notice", message: "Live Pattern follows the host preview automatically once any temporary panel test expires.", }); }); } async function refreshAll() { setConnectionState("warning", "syncing"); try { const responses = await Promise.all([ fetchJson("/api/v1/state"), fetchJson("/api/v1/preview"), fetchJson("/api/v1/catalog"), ]); apiState.stateResponse = responses[0]; apiState.previewResponse = responses[1]; apiState.catalog = responses[2]; renderAll(true); setConnectionState("live", "stream ready"); } catch (error) { setConnectionState("alert", "sync failed"); handleClientError(error, "http_refresh_failed"); renderLocalUi(); } } function connectStream() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = `${protocol}//${window.location.host}/api/v1/stream`; const socket = new WebSocket(url); apiState.ws = socket; socket.addEventListener("open", () => { setConnectionState("live", "stream connected"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "stream_connected", message: "WebSocket stream connected.", }); }); socket.addEventListener("message", (event) => { const envelope = JSON.parse(event.data); handleStreamEnvelope(envelope); }); socket.addEventListener("close", () => { setConnectionState("warning", "reconnecting"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "stream_reconnect", message: "WebSocket stream closed, retrying.", }); window.setTimeout(connectStream, 1500); }); socket.addEventListener("error", () => { setConnectionState("warning", "stream error"); }); } function handleStreamEnvelope(envelope) { const message = envelope.message; if (!message) { return; } if (message.type === "snapshot") { apiState.stateResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, state: message.payload, }; scheduleStateRender(); return; } if (message.type === "preview") { apiState.previewResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, preview: message.payload, }; schedulePreviewRender(); return; } if (message.type === "event") { pushEvent({ at: `${envelope.generated_at_millis} ms`, kind: message.payload.kind || "info", code: message.payload.code || null, message: message.payload.message, }); } } function scheduleStateRender() { if (apiState.ui.stateRenderQueued) { return; } apiState.ui.stateRenderQueued = true; const now = window.performance.now(); const waitMs = Math.max(0, STATE_RENDER_INTERVAL_MS - (now - apiState.ui.lastStateRenderAt)); window.setTimeout(() => { apiState.ui.stateRenderQueued = false; apiState.ui.lastStateRenderAt = window.performance.now(); renderState(false); }, waitMs); } function schedulePreviewRender() { if (apiState.ui.previewRenderQueued) { return; } apiState.ui.previewRenderQueued = true; const scheduleFrame = () => { apiState.ui.previewAnimationFrame = window.requestAnimationFrame(() => { const now = window.performance.now(); if (now - apiState.ui.lastPreviewRenderAt < PREVIEW_RENDER_INTERVAL_MS) { scheduleFrame(); return; } apiState.ui.previewAnimationFrame = 0; apiState.ui.previewRenderQueued = false; apiState.ui.lastPreviewRenderAt = now; renderPreview(false); renderSelectedTile(); renderSnapshotJson(); }); }; if (apiState.ui.previewAnimationFrame) { apiState.ui.previewRenderQueued = false; return; } scheduleFrame(); } async function handlePrimitive(primitive, options) { const settings = options || {}; const announceBuffered = settings.announceBuffered !== false; const rerenderState = settings.rerenderState === true; try { const outcome = await apiState.controlClient.applyPrimitive(primitive); if (outcome.kind === "buffered" && announceBuffered) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: primitive.primitive, message: outcome.summary, }); } else if (outcome.kind === "command" && outcome.summary) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: primitive.primitive, message: outcome.summary, }); } if (rerenderState) { renderState(true); } else { renderLocalUi(); } return outcome; } catch (error) { handleClientError(error, primitive.primitive); renderLocalUi(); return null; } } async function commitTransition(options) { const config = options || {}; const client = apiState.controlClient; if (apiState.ui.workMode !== "show_event") { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "direct_mode_active", message: "Test/Edit mode applies changes immediately. Go and Fade Go are only used in Show/Event mode.", }); return; } if (!client.hasPending()) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "no_pending_transition", message: "Stage a pattern or parameter change before committing.", }); return; } if (config.cut) { await handlePrimitive( { primitive: "set_transition_style", payload: { style: "snap", duration_ms: 0, }, }, { announceBuffered: false, rerenderState: false } ); } await handlePrimitive({ primitive: "trigger_transition" }, { rerenderState: true }); } async function toggleBlackout() { const enabled = !(apiState.stateResponse && apiState.stateResponse.state ? apiState.stateResponse.state.global.blackout : false); await handlePrimitive({ primitive: "blackout", payload: { enabled: enabled }, }); } async function sendCommand(command, options) { const settings = options || {}; const announce = settings.announce !== false; const refresh = settings.refresh !== false; const response = await fetchJson("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: `web-${Date.now()}`, command: command, }), }); if (announce) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: response.command_type, message: response.summary, }); } if (refresh) { await refreshAll(); } return response; } function debounceCommand(key, callback) { const existing = apiState.commandTimers.get(key); if (existing) { window.clearTimeout(existing); } const timeoutId = window.setTimeout(async () => { try { await callback(); } finally { apiState.commandTimers.delete(key); } }, 120); apiState.commandTimers.set(key, timeoutId); } async function fetchJson(url, options) { const response = await window.fetch(url, options); const body = await response.text(); let payload = null; try { payload = body ? JSON.parse(body) : null; } catch (_error) { throw createClientError("invalid_json", `Invalid JSON from ${url}`); } if (!response.ok) { const code = payload && payload.error ? payload.error.code : "request_failed"; const message = payload && payload.error ? payload.error.message : response.statusText; throw createClientError(code, message); } return payload; } function createShowControlClient() { const urlMode = new URLSearchParams(window.location.search).get("show_control_mode"); const mode = urlMode === "stateless" ? "stateless" : "stateful"; return { mode: mode, sessionLabel: mode === "stateful" ? "local browser session" : "stateless direct port", commitState: "idle", lastError: null, pending: createEmptyPendingState(), clearPending() { this.pending = createEmptyPendingState(); this.commitState = "idle"; this.lastError = null; }, hasPending() { return hasPendingState(this.pending); }, effectiveGroupId(liveGroupId) { return this.pending.hasGroupTarget ? this.pending.groupId : liveGroupId; }, stageGroupTarget(groupId) { ensureStatefulSession(this); this.pending.hasGroupTarget = true; this.pending.groupId = groupId; this.lastError = null; this.commitState = "staged"; return { kind: "buffered", summary: `group target staged: ${groupId || "all_panels"}`, }; }, async applyPrimitive(primitive) { this.lastError = null; try { const outcome = await applyPrimitiveWithClient(this, primitive); this.lastError = null; if (outcome.kind === "buffered") { this.commitState = "staged"; } else if (primitive.primitive !== "trigger_transition") { this.commitState = this.hasPending() ? "staged" : "idle"; } return outcome; } catch (error) { this.lastError = { code: error.code || "primitive_failed", message: error.message, }; throw error; } }, }; } async function applyPrimitiveWithClient(client, primitive) { switch (primitive.primitive) { case "blackout": return { kind: "direct", summary: primitive.payload.enabled ? "blackout enabled" : "blackout released", response: await sendCommand({ type: "set_blackout", payload: primitive.payload, }), }; case "recall_preset": return { kind: "direct", summary: `preset recalled: ${primitive.payload.preset_id}`, response: await sendCommand({ type: "recall_preset", payload: primitive.payload, }), }; case "recall_creative_snapshot": return { kind: "direct", summary: `creative snapshot recalled: ${primitive.payload.snapshot_id}`, response: await sendCommand({ type: "recall_creative_snapshot", payload: primitive.payload, }), }; case "set_master_brightness": return { kind: "direct", summary: `master brightness set to ${Math.round(primitive.payload.value * 100)}%`, response: await sendCommand({ type: "set_master_brightness", payload: primitive.payload, }), }; case "upsert_group": return { kind: "direct", summary: `group ${primitive.payload.overwrite ? "updated" : "saved"}: ${primitive.payload.group_id}`, response: await sendCommand({ type: "upsert_group", payload: primitive.payload, }), }; case "request_snapshot": return { kind: "snapshot", snapshot: await fetchJson("/api/v1/snapshot"), }; case "set_pattern": ensureStatefulSession(client); if (!primitive.payload.pattern_id || !primitive.payload.pattern_id.trim()) { throw createClientError("invalid_pattern_id", "pattern_id must not be empty"); } client.pending.patternId = primitive.payload.pattern_id; return { kind: "buffered", summary: `pattern staged: ${primitive.payload.pattern_id}`, }; case "set_group_parameter": ensureStatefulSession(client); if (!primitive.payload.key || !primitive.payload.key.trim()) { throw createClientError( "invalid_group_parameter_key", "group parameter key must not be empty" ); } client.pending.hasGroupTarget = true; client.pending.groupId = primitive.payload.group_id == null ? null : primitive.payload.group_id; client.pending.parameters[primitive.payload.key] = primitive.payload.value; return { kind: "buffered", summary: `group parameter staged: ${primitive.payload.key} for ` + `${primitive.payload.group_id || "all_panels"}`, }; case "set_transition_style": ensureStatefulSession(client); client.pending.transitionStyle = primitive.payload.style; if (typeof primitive.payload.duration_ms === "number") { client.pending.transitionDurationMs = primitive.payload.duration_ms; } return { kind: "buffered", summary: `transition style staged: ${primitive.payload.style}`, }; case "trigger_transition": ensureStatefulSession(client); if (!client.pending.patternId) { throw createClientError( "transition_pattern_required", "trigger_transition requires a staged pattern" ); } client.commitState = "committing"; if (client.pending.hasGroupTarget) { await sendCommand( { type: "select_group", payload: { group_id: client.pending.groupId }, }, { announce: false, refresh: false } ); } if (client.pending.transitionDurationMs !== null) { await sendCommand( { type: "set_transition_duration_ms", payload: { duration_ms: client.pending.transitionDurationMs }, }, { announce: false, refresh: false } ); } if (client.pending.transitionStyle) { await sendCommand( { type: "set_transition_style", payload: { style: client.pending.transitionStyle }, }, { announce: false, refresh: false } ); } await sendCommand( { type: "select_pattern", payload: { pattern_id: client.pending.patternId }, }, { announce: false, refresh: false } ); const parameterEntries = Object.entries(client.pending.parameters); for (let index = 0; index < parameterEntries.length; index += 1) { const entry = parameterEntries[index]; await sendCommand( { type: "set_scene_parameter", payload: { key: entry[0], value: entry[1] }, }, { announce: false, refresh: false } ); } const summary = client.pending.hasGroupTarget ? `transition triggered: ${client.pending.patternId} on ${client.pending.groupId || "all_panels"}` : `transition triggered: ${client.pending.patternId}`; client.clearPending(); client.commitState = "committed"; await refreshAll(); return { kind: "command", summary: summary, }; default: throw createClientError("unknown_primitive", `unknown primitive '${primitive.primitive}'`); } } function ensureStatefulSession(client) { if (client.mode !== "stateful") { throw createClientError( "show_control_session_required", "staged show-control primitives require a stateful session" ); } } function createEmptyPendingState() { return { patternId: null, hasGroupTarget: false, groupId: null, parameters: {}, transitionStyle: null, transitionDurationMs: null, }; } function hasPendingState(pending) { return Boolean( pending.patternId || pending.hasGroupTarget || Object.keys(pending.parameters).length || pending.transitionStyle || pending.transitionDurationMs !== null ); } function createClientError(code, message) { const error = new Error(message); error.code = code; return error; } function handleClientError(error, fallbackCode) { apiState.controlClient.commitState = "error"; pushEvent({ at: new Date().toLocaleTimeString(), kind: "error", code: error.code || fallbackCode || "primitive_failed", message: error.message, }); } function renderAll(force) { renderState(force === true); renderPreview(force === true); renderEvents(true); } function renderLocalUi() { const state = apiState.stateResponse ? apiState.stateResponse.state : null; renderTopBar(state); renderPendingSession(); renderPatternSelection(state); renderParameterSections(state ? state.active_scene : null, state ? state.global : null, false); renderSelectedTile(); renderViewOutput(state, false); renderSnapshotJson(); } function renderState(force) { const state = apiState.stateResponse ? apiState.stateResponse.state : null; if (!state) { renderPendingSession(); return; } reconcileSelection(); renderTopBar(state); renderPatternSelection(state); renderPresets(state, force); renderCreativeSnapshots(force); renderParameterSections(state.active_scene, state.global, force); renderSummaryCards(state); renderSelectedTile(); renderViewOutput(state, force); renderPendingSession(); renderSnapshotJson(); } function renderTopBar(state) { if (!state) { return; } const global = state.global; const scene = state.active_scene; const pending = apiState.controlClient.pending; const displayedTransitionDurationMs = pending.transitionDurationMs !== null ? pending.transitionDurationMs : global.transition_duration_ms; const displayedTransitionStyle = pending.transitionStyle || global.transition_style; const displayedTempoBpm = displayedTempoFromState(state, pending); const displayedTransitionSeconds = durationMsToSeconds(displayedTransitionDurationMs); if (dom.projectName) { dom.projectName.textContent = state.system.project_name; } if (dom.topologyLabel) { dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; } if (document.activeElement !== dom.tempoBpmInput) { dom.tempoBpmInput.value = String(displayedTempoBpm); } dom.tempoBpmLabel.textContent = `${displayedTempoBpm} BPM`; if (document.activeElement !== dom.transitionSecondsInput) { dom.transitionSecondsInput.value = displayedTransitionSeconds.toFixed(1); } dom.transitionSecondsLabel.textContent = `${displayedTransitionSeconds.toFixed(1)} s`; if (document.activeElement !== dom.transitionStyleSelect) { dom.transitionStyleSelect.value = displayedTransitionStyle; } if (document.activeElement !== dom.brightnessSlider) { dom.brightnessSlider.value = String(global.master_brightness); } dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; if (dom.previewModeLabel) { dom.previewModeLabel.textContent = "LEDs Only"; } dom.workModeSelect.value = apiState.ui.workMode; const hasPending = apiState.controlClient.hasPending(); if (apiState.ui.workMode === "show_event" && hasPending) { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Next (${operatorModeLabelForPatternId(pending.patternId || scene.pattern_id)})`; } } else if (apiState.ui.workMode === "show_event") { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Live (${scene.preset_id || operatorModeLabelForPatternId(scene.pattern_id)})`; } } else { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Live (${operatorModeLabelForPatternId(scene.pattern_id)})`; } } if (dom.controlModePill) { dom.controlModePill.textContent = apiState.ui.workMode === "show_event" ? "Show/Event" : "Test/Edit"; dom.controlModePill.className = apiState.ui.workMode === "show_event" ? "status-chip status-chip-warning" : "status-chip status-chip-live"; } const blackoutLabel = global.blackout ? "Blackout Active" : "Blackout"; dom.blackoutButton.textContent = blackoutLabel; dom.utilityBlackoutButton.textContent = blackoutLabel; dom.blackoutButton.classList.toggle("is-active", global.blackout); dom.utilityBlackoutButton.classList.toggle("is-active", global.blackout); } function renderPatternSelection(state) { if (!state) { return; } const modes = availableOperatorModes(); const signature = modes.map((mode) => `${mode.mode_id}:${mode.pattern_id}`).join("|"); if (signature !== apiState.ui.patternsSignature) { apiState.ui.patternsSignature = signature; dom.patternSelect.innerHTML = ""; modes.forEach((mode) => { const option = document.createElement("option"); option.value = mode.mode_id; option.textContent = mode.label; dom.patternSelect.appendChild(option); }); } const selectedModeId = displayedOperatorModeId(state, modes); if (document.activeElement !== dom.patternSelect) { dom.patternSelect.value = selectedModeId; } } function renderParameterSections(scene, global, force) { if (!scene || !global) { dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; apiState.ui.parameterCards.clear(); return; } if ( !force && (dom.motionParams.contains(document.activeElement) || dom.colorParams.contains(document.activeElement)) ) { return; } const parameters = (scene.parameters || []).filter( (parameter) => !HIDDEN_PARAMETER_KEYS.has(parameter.key) ); const signature = parameters .map((parameter) => `${parameter.key}:${parameter.kind}`) .join("|"); if (signature !== apiState.ui.parameterSignature) { apiState.ui.parameterSignature = signature; apiState.ui.parameterCards.clear(); dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; parameters.forEach((parameter) => { const card = createParameterCard(parameter, global); apiState.ui.parameterCards.set(parameter.key, card); parameterContainer(parameter).appendChild(card.root); }); } parameters.forEach((parameter) => { const card = apiState.ui.parameterCards.get(parameter.key); if (card) { updateParameterCard(card, parameter, global); } }); } function parameterContainer(parameter) { if (COLOR_PARAM_KEYS.has(parameter.key)) { return dom.colorParams; } return dom.motionParams; } function createParameterCard(parameter, global) { const card = document.createElement("div"); card.className = "parameter-card"; card.dataset.key = parameter.key; const contract = controlContractFor(parameter.key); const controlKind = contract ? contract.control : inferControlKind(parameter); const header = document.createElement("div"); header.className = "parameter-header"; const title = document.createElement("strong"); title.textContent = contract && contract.label ? contract.label : parameter.label; const key = document.createElement("span"); key.className = "parameter-key"; key.textContent = parameter.key; header.appendChild(title); header.appendChild(key); card.appendChild(header); const readout = document.createElement("div"); readout.className = "parameter-readout"; let primaryInput = null; let secondaryInput = null; if (controlKind === "scalar") { primaryInput = document.createElement("input"); primaryInput.type = "range"; primaryInput.min = String(parameter.min_scalar == null ? 0 : parameter.min_scalar); primaryInput.max = String(parameter.max_scalar == null ? 1 : parameter.max_scalar); primaryInput.step = String(parameter.step == null ? 0.01 : parameter.step); primaryInput.addEventListener("input", async (event) => { const value = Number(event.target.value); readout.textContent = value.toFixed(2); await applySceneParameterChange(global.selected_group, parameter.key, { kind: "scalar", value: value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else if (controlKind === "toggle") { primaryInput = document.createElement("input"); primaryInput.type = "checkbox"; primaryInput.addEventListener("change", async (event) => { await applySceneParameterChange(global.selected_group, parameter.key, { kind: "toggle", value: event.target.checked, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else if (controlKind === "color") { const row = document.createElement("div"); row.className = "color-input-row"; primaryInput = document.createElement("input"); primaryInput.type = "color"; secondaryInput = document.createElement("input"); secondaryInput.type = "text"; primaryInput.addEventListener("input", async (event) => { secondaryInput.value = event.target.value.toUpperCase(); await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: event.target.value.toUpperCase(), }); }); secondaryInput.addEventListener("change", async (event) => { const normalized = normalizeColorHex(event.target.value); secondaryInput.value = normalized; primaryInput.value = normalized; await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: normalized, }); }); row.appendChild(primaryInput); row.appendChild(secondaryInput); card.appendChild(row); card.appendChild(readout); } else if (controlKind === "enum") { primaryInput = document.createElement("select"); const groupedOptions = contract && Array.isArray(contract.groups) ? contract.groups : []; if (groupedOptions.length) { groupedOptions.forEach((group) => { const groupNode = document.createElement("optgroup"); groupNode.label = group.label; group.options.forEach((option) => { const node = document.createElement("option"); node.value = option.value; node.textContent = option.label; groupNode.appendChild(node); }); primaryInput.appendChild(groupNode); }); } else { const options = contract && Array.isArray(contract.options) ? contract.options : []; options.forEach((option) => { const node = document.createElement("option"); node.value = option.value; node.textContent = option.label; primaryInput.appendChild(node); }); } primaryInput.addEventListener("change", async (event) => { const optionValue = event.target.value; const payloadValue = contract && contract.value_kind === "scalar" ? { kind: "scalar", value: Number(optionValue) } : { kind: "text", value: optionValue }; await applySceneParameterChange(global.selected_group, parameter.key, { kind: payloadValue.kind, value: payloadValue.value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else { primaryInput = document.createElement("input"); primaryInput.type = "text"; primaryInput.addEventListener("change", async (event) => { await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: event.target.value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } return { root: card, input: primaryInput, auxiliary: secondaryInput, readout: readout, kind: parameter.kind, controlKind: controlKind, contract: contract, }; } function updateParameterCard(card, parameter) { const hasStagedValue = Object.prototype.hasOwnProperty.call( apiState.controlClient.pending.parameters, parameter.key ); const stagedValue = hasStagedValue ? apiState.controlClient.pending.parameters[parameter.key] : null; const displayValue = hasStagedValue ? stagedValue : parameter.value; card.root.classList.toggle("is-staged", hasStagedValue); if (card.controlKind === "scalar") { const fallback = parameterDefaultScalar(parameter, card.contract); const value = parameterScalarValue(displayValue, fallback); if (document.activeElement !== card.input) { card.input.value = String(value); } card.readout.textContent = value.toFixed(2); return; } if (card.controlKind === "toggle") { const fallback = parameterDefaultToggle(card.contract); const value = parameterToggleValue(displayValue, fallback); if (document.activeElement !== card.input) { card.input.checked = value; } card.readout.textContent = card.input.checked ? "On" : "Off"; return; } if (card.controlKind === "color") { const normalized = normalizeColorHex( parameterTextValue(displayValue, parameterDefaultText(card.contract, "#000000")) ); if (document.activeElement !== card.input) { card.input.value = normalized; } if (document.activeElement !== card.auxiliary) { card.auxiliary.value = normalized; } card.readout.textContent = normalized; return; } if (card.controlKind === "enum") { const options = card.contract && Array.isArray(card.contract.options) ? card.contract.options : []; const fallbackValue = parameterDefaultText(card.contract, options[0] ? options[0].value : ""); const value = card.contract && card.contract.value_kind === "scalar" ? String(parameterScalarValue(displayValue, Number(fallbackValue))) : parameterTextValue(displayValue, fallbackValue); if (document.activeElement !== card.input) { card.input.value = value; } card.readout.textContent = optionLabelForValue(card.contract, value) || value; return; } const textValue = parameterTextValue(displayValue, parameterDefaultText(card.contract, "")); if (card.auxiliary) { const normalized = normalizeColorHex(textValue); if (document.activeElement !== card.input) { card.input.value = normalized; } if (document.activeElement !== card.auxiliary) { card.auxiliary.value = normalized; } card.readout.textContent = normalized; return; } if (document.activeElement !== card.input) { card.input.value = textValue; } card.readout.textContent = textValue || "Text"; } function controlContractFor(key) { return LEGACY_PARAMETER_CONTRACT[key] || null; } function inferControlKind(parameter) { if (parameter.kind === "scalar") { return "scalar"; } if (parameter.kind === "toggle") { return "toggle"; } if (looksLikeColorParameter(parameter)) { return "color"; } return "text"; } function optionLabelForValue(contract, value) { if (!contract || !Array.isArray(contract.options)) { return null; } const found = contract.options.find((option) => option.value === value); return found ? found.label : null; } function parameterScalarValue(parameterValue, fallback) { if (parameterValue && typeof parameterValue.value === "number" && Number.isFinite(parameterValue.value)) { return parameterValue.value; } return fallback; } function parameterToggleValue(parameterValue, fallback) { if (parameterValue && typeof parameterValue.value === "boolean") { return parameterValue.value; } return fallback; } function parameterTextValue(parameterValue, fallback) { if (parameterValue && parameterValue.value != null) { return String(parameterValue.value); } return fallback; } function parameterDefaultScalar(parameter, contract) { if (contract && typeof contract.default_value === "number") { return contract.default_value; } if ( parameter.default_value && typeof parameter.default_value.value === "number" && Number.isFinite(parameter.default_value.value) ) { return parameter.default_value.value; } if (parameter.min_scalar != null) { return Number(parameter.min_scalar); } return 0; } function parameterDefaultToggle(contract) { return contract ? Boolean(contract.default_value) : false; } function parameterDefaultText(contract, fallback) { if (contract && contract.default_value != null) { return String(contract.default_value); } return fallback; } function renderPresets(state, force) { const presets = apiState.catalog ? apiState.catalog.presets || [] : []; const signature = presets.map((preset) => preset.preset_id).join("|"); if ( apiState.ui.selectedPresetId && !presets.some((preset) => preset.preset_id === apiState.ui.selectedPresetId) ) { apiState.ui.selectedPresetId = null; } if (force || signature !== apiState.ui.presetsSignature) { apiState.ui.presetsSignature = signature; dom.presetList.innerHTML = ""; if (!presets.length) { dom.presetList.innerHTML = '
No presets available.
'; } else { presets.forEach((preset) => { const button = document.createElement("button"); button.type = "button"; button.className = "list-item"; button.dataset.presetId = preset.preset_id; button.innerHTML = `${escapeHtml(preset.preset_id)}` + `
` + `${escapeHtml(operatorModeLabelForPatternId(preset.pattern_id))}` + `${escapeHtml(preset.transition_style)}` + `${escapeHtml(preset.target_group || "all_panels")}` + `
`; button.addEventListener("click", () => { apiState.ui.selectedPresetId = preset.preset_id; dom.presetIdInput.value = preset.preset_id; renderPresets(state, true); }); dom.presetList.appendChild(button); }); } } Array.from(dom.presetList.querySelectorAll("[data-preset-id]")).forEach((node) => { const presetId = node.dataset.presetId; const isSelected = apiState.ui.selectedPresetId === presetId; const isLive = state.active_scene.preset_id === presetId; node.classList.toggle("active", isSelected || isLive); node.classList.toggle("staged", isLive); }); syncPresetActionButtons(); } function syncPresetActionButtons() { const hasPresetTarget = Boolean(apiState.ui.selectedPresetId || dom.presetIdInput.value.trim()); dom.loadPresetButton.disabled = !hasPresetTarget; dom.deletePresetButton.disabled = !hasPresetTarget; } function renderCreativeSnapshots(force) { const snapshots = apiState.catalog ? apiState.catalog.creative_snapshots || [] : []; const signature = snapshots.map((snapshot) => snapshot.snapshot_id).join("|"); if (!force && signature === apiState.ui.snapshotsSignature) { return; } apiState.ui.snapshotsSignature = signature; dom.snapshotList.innerHTML = ""; if (!snapshots.length) { dom.snapshotList.innerHTML = '
No creative snapshots saved yet.
'; return; } snapshots.forEach((snapshot) => { const button = document.createElement("button"); button.type = "button"; button.className = "list-item"; button.innerHTML = `${escapeHtml(snapshot.label || snapshot.snapshot_id)}` + `
` + `${escapeHtml(snapshot.snapshot_id)}` + `${escapeHtml(operatorModeLabelForPatternId(snapshot.pattern_id))}` + `${snapshot.transition_duration_ms} ms` + `
`; button.addEventListener("click", async () => { await handlePrimitive({ primitive: "recall_creative_snapshot", payload: { snapshot_id: snapshot.snapshot_id }, }); }); dom.snapshotList.appendChild(button); }); } function renderPreview(force) { const panels = renderablePanels(); if (!panels.length) { dom.previewGrid.innerHTML = '
Preview stream is waiting for panel snapshots.
'; apiState.ui.previewNodes.clear(); apiState.ui.previewSignatures.clear(); return; } reconcileSelection(panels); dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`; dom.previewGrid.className = `preview-grid preview-grid-mode-${apiState.ui.previewMode}`; const columnCount = 6; dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; const layoutSignature = panels.map((panel) => panel.key).join("|"); if (force || layoutSignature !== apiState.ui.previewLayoutSignature) { apiState.ui.previewLayoutSignature = layoutSignature; buildPreviewTiles(panels); } panels.forEach((panel) => patchPreviewTile(panel)); renderPreviewSelection(); } function buildPreviewTiles(panels) { apiState.ui.previewNodes.clear(); apiState.ui.previewSignatures.clear(); dom.previewGrid.innerHTML = ""; panels.forEach((panel) => { const tile = document.createElement("button"); tile.type = "button"; tile.className = "preview-tile"; tile.dataset.key = panel.key; tile.style.gridColumn = String(panel.col); tile.style.gridRow = String(panel.row); const previewShell = document.createElement("div"); previewShell.className = "tile-preview-shell"; const ledRing = document.createElement("div"); ledRing.className = "tile-led-ring"; const leds = TILE_LED_GEOMETRY.map((ledSpec) => { const led = document.createElement("span"); led.className = `tile-led tile-led-${ledSpec.side}`; led.style.left = `${(ledSpec.x * 100).toFixed(3)}%`; led.style.top = `${(ledSpec.y * 100).toFixed(3)}%`; ledRing.appendChild(led); return led; }); previewShell.appendChild(ledRing); const overlay = document.createElement("div"); overlay.className = "tile-overlay"; const label = document.createElement("div"); label.className = "tile-label"; const caption = document.createElement("div"); caption.className = "tile-caption"; const meta = document.createElement("div"); meta.className = "tile-meta"; const metaLeft = document.createElement("span"); const metaRight = document.createElement("span"); meta.appendChild(metaLeft); meta.appendChild(metaRight); overlay.appendChild(label); overlay.appendChild(caption); overlay.appendChild(meta); tile.appendChild(previewShell); tile.appendChild(overlay); tile.addEventListener("click", () => { apiState.ui.selectedPanelKey = panel.key; renderPreviewSelection(); renderSelectedTile(); }); dom.previewGrid.appendChild(tile); apiState.ui.previewNodes.set(panel.key, { root: tile, previewShell: previewShell, label: label, caption: caption, metaLeft: metaLeft, metaRight: metaRight, leds: leds, }); }); } function patchPreviewTile(panel) { const nodes = apiState.ui.previewNodes.get(panel.key); if (!nodes) { return; } const sampleSignature = panel.sample_led_hex.join(","); const signature = [ panel.display_id, panel.display_caption, panel.representative_color_hex, panel.energy_percent, panel.source, sampleSignature, panel.connection, ].join("|"); if (signature !== apiState.ui.previewSignatures.get(panel.key)) { apiState.ui.previewSignatures.set(panel.key, signature); const ledColors = buildPreviewLedColors(panel); nodes.root.style.setProperty("--tile-color", panel.representative_color_hex); nodes.root.style.setProperty("--tile-glow", panel.representative_color_hex); nodes.root.style.setProperty("--led-opacity", previewLedOpacity(panel.energy_percent)); nodes.label.textContent = panel.display_id; nodes.caption.textContent = panel.display_caption; nodes.metaLeft.textContent = `${panel.panel_position} / ${panel.source}`; nodes.metaRight.textContent = `${panel.energy_percent}%`; nodes.leds.forEach((led, index) => { const color = ledColors[index] || panel.representative_color_hex; led.style.backgroundColor = color; led.style.boxShadow = `0 0 5px ${color}`; }); } nodes.root.classList.toggle("is-panel-test", panel.source === "panel_test"); nodes.root.classList.toggle("is-blackout", panel.source === "blackout" || panel.energy_percent === 0); nodes.root.title = `${panel.node_id} / ${panel.panel_position} / ${panel.source}`; } function renderPreviewSelection() { apiState.ui.previewNodes.forEach((nodes, key) => { nodes.root.classList.toggle("is-selected", key === apiState.ui.selectedPanelKey); }); } function renderSelectedTile() { const panel = selectedPanel(); if (!panel) { dom.selectedTileCard.className = "selected-tile-card empty-state"; dom.selectedTileCard.textContent = "Click a tile in the preview."; dom.whiteTestButton.disabled = true; dom.livePatternButton.disabled = true; return; } dom.selectedTileCard.className = "selected-tile-card"; dom.selectedTileCard.innerHTML = `
${escapeHtml(panel.display_id)}
` + `
${escapeHtml(panel.display_caption)}
` + '
' + infoRow("Node", panel.node_id) + infoRow("Position", panel.panel_position) + infoRow("Source", panel.source) + infoRow("Energy", `${panel.energy_percent}%`) + infoRow("LEDs", panel.led_count ? String(panel.led_count) : "n/a") + infoRow("Output", panel.physical_output_name || "n/a") + infoRow("Driver", panel.driver_reference || "n/a") + infoRow("Connection", panel.connection || "unknown") + infoRow("Validation", panel.validation_state || "n/a") + "
"; dom.whiteTestButton.disabled = false; dom.livePatternButton.disabled = false; dom.livePatternButton.textContent = panel.source === "panel_test" ? "Live Pattern Auto" : "Live Pattern"; } function renderSummaryCards(state) { if (!dom.summaryCards) { return; } const scene = state.active_scene; const global = state.global; const engine = state.engine; const nodeStats = summarizeNodes(state.nodes || []); const cards = [ { label: "Pattern", value: operatorModeLabelForPatternId(scene.pattern_id), detail: scene.preset_id ? `Preset ${scene.preset_id}` : "Live scene", }, { label: "Target", value: scene.target_group || "all_panels", detail: apiState.controlClient.pending.patternId ? "Pending edit buffer armed" : "No staged pattern", }, { label: "Transition", value: `${global.transition_style} / ${global.transition_duration_ms} ms`, detail: engine.active_transition ? `${engine.active_transition.to_pattern_id} ${Math.round(engine.active_transition.progress * 100)}%` : "Idle", }, { label: "Nodes", value: `${nodeStats.online}/${state.nodes.length} online`, detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`, }, ]; dom.summaryCards.innerHTML = cards .map((card) => { return ( '
' + `${escapeHtml(card.value)}` + `${escapeHtml(card.label)}` + `
${escapeHtml(card.detail)}
` + "
" ); }) .join(""); } function renderPendingSession() { const client = apiState.controlClient; const pending = client.pending; const stagedMode = apiState.ui.workMode === "show_event"; dom.sessionScopeLabel.textContent = stagedMode ? client.sessionLabel : "immediate local apply"; dom.pendingPanelDescription.textContent = stagedMode ? "Stage direct edits locally and commit them consciously." : "Pattern and parameter changes apply immediately in local test/edit mode."; let commitLabel = "idle"; let commitClass = "status-chip status-chip-idle"; if (!stagedMode) { commitLabel = "direct"; commitClass = "status-chip status-chip-live"; } else if (client.commitState === "committing") { commitLabel = "committing"; commitClass = "status-chip status-chip-warning"; } else if (client.commitState === "committed") { commitLabel = "committed"; commitClass = "status-chip status-chip-success"; } else if (client.commitState === "error") { commitLabel = "error"; commitClass = "status-chip status-chip-alert"; } else if (client.hasPending()) { commitLabel = "staged"; commitClass = "status-chip status-chip-warning"; } dom.pendingCommitPill.textContent = commitLabel; dom.pendingCommitPill.className = commitClass; dom.pendingCompactLabel.textContent = stagedMode && client.hasPending() ? `${Object.keys(pending.parameters).length + (pending.patternId ? 1 : 0)} edits` : (stagedMode ? "empty" : "live"); const cards = []; if (stagedMode && pending.patternId) { cards.push(renderPendingCard("Pattern", operatorModeLabelForPatternId(pending.patternId))); } if (stagedMode && pending.hasGroupTarget) { cards.push(renderPendingCard("Target Group", pending.groupId || "all_panels")); } if (stagedMode && (pending.transitionStyle || pending.transitionDurationMs !== null)) { const detail = [ pending.transitionStyle || "inherit", pending.transitionDurationMs !== null ? `${pending.transitionDurationMs} ms` : "duration unchanged", ].join(" / "); cards.push(renderPendingCard("Transition", detail)); } const parameterKeys = stagedMode ? Object.keys(pending.parameters) : []; if (parameterKeys.length) { cards.push(renderPendingCard("Parameters", parameterKeys.join(", "))); } dom.pendingSessionSummary.innerHTML = cards.length ? cards.join("") : (stagedMode ? '
No staged transition yet. Pattern, look, color and motion edits stay local until commit.
' : '
Local test/edit mode is live. Pattern, look, color and motion changes apply immediately without Go or Fade Go.
'); if (client.lastError) { dom.primitiveErrorBanner.classList.remove("hidden"); dom.primitiveErrorBanner.innerHTML = `${escapeHtml(client.lastError.code)}` + `${escapeHtml(client.lastError.message)}`; } else { dom.primitiveErrorBanner.classList.add("hidden"); dom.primitiveErrorBanner.innerHTML = ""; } dom.triggerTransitionButton.disabled = !stagedMode || client.commitState === "committing" || !client.hasPending(); dom.clearStagedButton.disabled = !stagedMode || !client.hasPending(); dom.goButton.disabled = !stagedMode; dom.utilityGoButton.disabled = !stagedMode; dom.fadeGoButton.disabled = !stagedMode; dom.utilityFadeGoButton.disabled = !stagedMode; } function renderPendingCard(label, detail) { return ( '
' + `${escapeHtml(label)}` + `${escapeHtml(detail)}` + "
" ); } function renderViewOutput(state, force) { if (!state) { return; } const selected = selectedPanel(); const nodeStats = summarizeNodes(state.nodes || []); const signature = [ apiState.ui.previewMode, state.engine.frame_hz, state.engine.preview_hz, state.global.blackout, state.global.master_brightness, nodeStats.online, nodeStats.degraded, nodeStats.offline, selected ? selected.key : "none", ].join("|"); if (!force && signature === apiState.ui.viewOutputSignature) { return; } apiState.ui.viewOutputSignature = signature; const rows = [ { label: "Preview", value: previewModeLabel(apiState.ui.previewMode) }, { label: "Render FPS", value: `${state.engine.frame_hz} fps` }, { label: "Preview FPS", value: `${state.engine.preview_hz} fps` }, { label: "Logic Rate", value: `${state.engine.logic_hz} hz` }, { label: "Output", value: state.global.blackout ? "Blackout Active" : "Output Enabled" }, { label: "Master", value: `${Math.round(state.global.master_brightness * 100)}%` }, { label: "Nodes", value: `${nodeStats.online} online / ${state.nodes.length} total` }, { label: "Selected", value: selected ? selected.display_id : "No tile selected" }, ]; dom.viewOutputList.innerHTML = rows .map((row) => { return ( '
' + `
${escapeHtml(row.label)}
` + `
${escapeHtml(row.value)}
` + "
" ); }) .join(""); } function renderSnapshotJson() { dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2); } function buildComposedSnapshot() { return { api_version: apiState.stateResponse ? apiState.stateResponse.api_version : "v1", generated_at_millis: (apiState.previewResponse && apiState.previewResponse.generated_at_millis) || (apiState.stateResponse && apiState.stateResponse.generated_at_millis) || 0, state: apiState.stateResponse ? apiState.stateResponse.state : null, preview: apiState.previewResponse ? apiState.previewResponse.preview : null, catalog: apiState.catalog || null, show_control_client: { mode: apiState.controlClient.mode, pending: apiState.controlClient.pending, last_error: apiState.controlClient.lastError, }, }; } function pushEvent(entry) { apiState.events.unshift({ kind: entry.kind || "info", code: entry.code || null, at: entry.at, message: entry.message, }); apiState.events = apiState.events.slice(0, 60); renderEvents(false); } function renderEvents(force) { const kindFilter = dom.eventKindFilter.value; const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase(); const signature = `${kindFilter}|${searchFilter}|${apiState.events.length}|${apiState.events[0] ? apiState.events[0].message : ""}`; if (!force && signature === apiState.ui.eventFilterSignature) { return; } apiState.ui.eventFilterSignature = signature; const filtered = apiState.events.filter((entry) => { const kindMatches = kindFilter === "all" || entry.kind === kindFilter; const searchMatches = !searchFilter || entry.message.toLowerCase().includes(searchFilter) || String(entry.code || "").toLowerCase().includes(searchFilter); return kindMatches && searchMatches; }); if (!filtered.length) { dom.eventList.innerHTML = '
No events match the current filter.
'; return; } dom.eventList.innerHTML = filtered .map((entry) => { return ( `
` + `
${escapeHtml(entry.at)}
` + (entry.code ? `${escapeHtml(entry.code)}` : "") + `${escapeHtml(entry.message)}` + "
" ); }) .join(""); } function setConnectionState(kind, message) { if (!dom.connectionPill) { return; } dom.connectionPill.textContent = message; if (kind === "live") { dom.connectionPill.className = "status-chip status-chip-live"; } else if (kind === "alert") { dom.connectionPill.className = "status-chip status-chip-alert"; } else { dom.connectionPill.className = "status-chip status-chip-warning"; } } function renderablePanels() { const previewPanels = apiState.previewResponse && apiState.previewResponse.preview ? apiState.previewResponse.preview.panels || [] : []; const statePanels = apiState.stateResponse && apiState.stateResponse.state ? apiState.stateResponse.state.panels || [] : []; const stateMap = new Map(); statePanels.forEach((panel) => { stateMap.set(panelKey(panel.node_id, panel.panel_position), panel); }); const sorted = previewPanels.slice().sort(comparePreviewPanels); const nodeIds = uniqueNodeIds(sorted); return sorted.map((panel) => { const statePanel = stateMap.get(panelKey(panel.node_id, panel.panel_position)); const row = (POSITION_ORDER[panel.panel_position] || 0) + 1; const col = nodeIds.indexOf(panel.node_id) + 1; const displayId = `r${row}c${col}`; return { key: panelKey(panel.node_id, panel.panel_position), row: row, col: col, display_id: displayId, display_caption: `R${row} C${col}`, node_id: panel.node_id, panel_position: panel.panel_position, representative_color_hex: panel.representative_color_hex, sample_led_hex: panel.sample_led_hex || [], energy_percent: panel.energy_percent, source: panel.source, led_count: statePanel ? statePanel.led_count : null, physical_output_name: statePanel ? statePanel.physical_output_name : null, driver_reference: statePanel ? statePanel.driver_reference : null, connection: statePanel ? statePanel.connection : null, validation_state: statePanel ? statePanel.validation_state : null, }; }); } function reconcileSelection(panels) { const currentPanels = panels || renderablePanels(); if (!currentPanels.length) { apiState.ui.selectedPanelKey = null; return; } const exists = currentPanels.some((panel) => panel.key === apiState.ui.selectedPanelKey); if (!exists) { apiState.ui.selectedPanelKey = currentPanels[0].key; } } function selectedPanel() { const panels = renderablePanels(); reconcileSelection(panels); return panels.find((panel) => panel.key === apiState.ui.selectedPanelKey) || null; } function panelKey(nodeId, panelPosition) { return `${nodeId}:${panelPosition}`; } function uniqueNodeIds(panels) { const seen = new Set(); const nodeIds = []; panels.forEach((panel) => { if (!seen.has(panel.node_id)) { seen.add(panel.node_id); nodeIds.push(panel.node_id); } }); return nodeIds; } function comparePreviewPanels(left, right) { const leftNode = left.node_id.localeCompare(right.node_id, undefined, { numeric: true }); if (leftNode !== 0) { return leftNode; } return panelPositionRank(left.panel_position) - panelPositionRank(right.panel_position); } function panelPositionRank(position) { return POSITION_ORDER[position] || 0; } function looksLikeColorParameter(parameter) { const value = parameter.value ? parameter.value.value : ""; return parameter.kind === "text" && (parameter.key.indexOf("color") >= 0 || isHexColorString(value)); } function isHexColorString(value) { return typeof value === "string" && /^#?[0-9a-f]{6}$/i.test(value.trim()); } function normalizeColorHex(value) { const trimmed = String(value || "").trim(); if (!trimmed) { return "#000000"; } const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; return /^#[0-9a-f]{6}$/i.test(withHash) ? withHash.toUpperCase() : "#000000"; } function infoRow(label, value) { return ( '
' + `
${escapeHtml(label)}
` + `
${escapeHtml(value)}
` + "
" ); } function summarizeNodes(nodes) { return nodes.reduce( (summary, node) => { if (node.connection === "degraded") { summary.degraded += 1; } else if (node.connection === "offline") { summary.offline += 1; } else { summary.online += 1; } return summary; }, { online: 0, degraded: 0, offline: 0 } ); } function previewModeLabel(mode) { return mode === "leds" ? "LEDs Only" : "LEDs Only"; } function normalizeTempoBpm(value) { if (!Number.isFinite(value)) { return TEMPO_BPM_DEFAULT; } return Math.round(Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, value))); } function speedToTempoBpm(speedValue) { if (!Number.isFinite(speedValue)) { return TEMPO_BPM_DEFAULT; } return normalizeTempoBpm(speedValue * SPEED_TO_BPM_FACTOR); } function tempoBpmToSpeed(bpmValue) { return normalizeTempoBpm(bpmValue) / SPEED_TO_BPM_FACTOR; } function normalizeTransitionSeconds(value) { if (!Number.isFinite(value)) { return 2.0; } return Math.min(30, Math.max(0.1, value)); } function durationMsToSeconds(durationMs) { return normalizeTransitionSeconds(Number(durationMs) / 1000); } function secondsToDurationMs(seconds) { return Math.round(normalizeTransitionSeconds(seconds) * 1000); } function displayedTempoFromState(state, pending) { if ( pending && Object.prototype.hasOwnProperty.call(pending.parameters, "speed") && pending.parameters.speed ) { return speedToTempoBpm(parameterScalarValue(pending.parameters.speed, TEMPO_BPM_DEFAULT / SPEED_TO_BPM_FACTOR)); } const speedParameter = (state.active_scene.parameters || []).find( (parameter) => parameter.key === "speed" ); if (speedParameter) { return speedToTempoBpm(parameterScalarValue(speedParameter.value, TEMPO_BPM_DEFAULT / SPEED_TO_BPM_FACTOR)); } return TEMPO_BPM_DEFAULT; } function setWorkMode(mode) { const nextMode = mode === "show_event" ? "show_event" : "test_edit"; if (apiState.ui.workMode === nextMode) { renderLocalUi(); return; } if (nextMode === "test_edit" && apiState.controlClient.hasPending()) { apiState.controlClient.clearPending(); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "staged_transition_cleared", message: "Staged transition buffer cleared while switching to Test/Edit mode.", }); } apiState.ui.workMode = nextMode; renderLocalUi(); } async function applyPatternSelection(modeId) { const mode = OPERATOR_MODE_BY_ID.get(modeId); if (!mode) { return; } apiState.ui.lastSelectedModeId = modeId; if (apiState.ui.workMode === "show_event") { const patternOutcome = await handlePrimitive( { primitive: "set_pattern", payload: { pattern_id: mode.pattern_id }, }, { announceBuffered: false, rerenderState: false } ); if (!patternOutcome) { return; } } else { try { await sendCommand( { type: "select_pattern", payload: { pattern_id: mode.pattern_id }, }, { announce: false, refresh: false } ); await refreshAll(); } catch (error) { handleClientError(error, "select_pattern"); renderLocalUi(); return; } } pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "set_pattern", message: apiState.ui.workMode === "show_event" ? `mode staged: ${mode.label}` : `mode applied: ${mode.label}`, }); renderLocalUi(); } async function applyTransitionSettings(style, durationSeconds, announceBuffered) { const durationMs = secondsToDurationMs(durationSeconds); if (apiState.ui.workMode === "show_event") { await handlePrimitive( { primitive: "set_transition_style", payload: { style: style, duration_ms: durationMs, }, }, { announceBuffered: announceBuffered, rerenderState: false } ); return; } try { await sendCommand( { type: "set_transition_duration_ms", payload: { duration_ms: durationMs }, }, { announce: false, refresh: false } ); await sendCommand( { type: "set_transition_style", payload: { style: style }, }, { announce: false, refresh: false } ); await refreshAll(); } catch (error) { handleClientError(error, "set_transition_style"); renderLocalUi(); } } async function applyTempoBpm(rawBpmValue) { const bpm = normalizeTempoBpm(rawBpmValue); const speed = tempoBpmToSpeed(bpm); dom.tempoBpmInput.value = String(bpm); dom.tempoBpmLabel.textContent = `${bpm} BPM`; if (apiState.ui.workMode === "show_event") { const outcome = await handlePrimitive( { primitive: "set_group_parameter", payload: { group_id: null, key: "speed", value: { kind: "scalar", value: speed, }, }, }, { announceBuffered: false, rerenderState: false } ); if (outcome) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "set_tempo_bpm", message: `tempo staged: ${bpm} BPM`, }); } return; } try { await sendCommand( { type: "set_scene_parameter", payload: { key: "speed", value: { kind: "scalar", value: speed, }, }, }, { announce: false, refresh: false } ); await refreshAll(); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "set_tempo_bpm", message: `tempo applied: ${bpm} BPM`, }); } catch (error) { handleClientError(error, "set_tempo_bpm"); renderLocalUi(); } } async function applySceneParameterChange(globalGroupId, key, value) { if (apiState.ui.workMode === "show_event") { return handlePrimitive( { primitive: "set_group_parameter", payload: { group_id: apiState.controlClient.effectiveGroupId(globalGroupId), key: key, value: value, }, }, { announceBuffered: false, rerenderState: false } ); } try { await sendCommand( { type: "set_scene_parameter", payload: { key: key, value: value, }, }, { announce: false, refresh: false } ); await refreshAll(); return { kind: "command" }; } catch (error) { handleClientError(error, "set_scene_parameter"); renderLocalUi(); return null; } } function availableOperatorModes() { const patterns = apiState.catalog ? apiState.catalog.patterns || [] : []; const availablePatternIds = new Set(patterns.map((pattern) => pattern.pattern_id)); return OPERATOR_MODES.filter((mode) => availablePatternIds.has(mode.pattern_id)); } function displayedOperatorModeId(state, modes) { const activePatternId = canonicalUiPatternId( apiState.controlClient.pending.patternId || state.global.selected_pattern ); const lastSelectedMode = apiState.ui.lastSelectedModeId ? OPERATOR_MODE_BY_ID.get(apiState.ui.lastSelectedModeId) : null; if (lastSelectedMode && activePatternId === lastSelectedMode.pattern_id) { return lastSelectedMode.mode_id; } const canonicalMode = modes.find((mode) => mode.pattern_id === activePatternId && mode.canonical) || modes.find((mode) => mode.pattern_id === activePatternId) || modes[0]; return canonicalMode ? canonicalMode.mode_id : ""; } function operatorModeLabelForPatternId(patternId) { const normalizedPatternId = canonicalUiPatternId(patternId); const canonicalMode = OPERATOR_MODES.find((mode) => mode.pattern_id === normalizedPatternId && mode.canonical) || OPERATOR_MODES.find((mode) => mode.pattern_id === normalizedPatternId); return canonicalMode ? canonicalMode.label : patternId; } function canonicalUiPatternId(patternId) { switch (patternId) { case "solid_color": return "solid"; case "gradient": return "column_gradient"; case "pulse": return "breathing"; case "walking_pixel": return "scan"; case "noise": return "sparkle"; case "chase": return "sweep"; default: return patternId; } } function buildTileLedGeometry() { return [] .concat(perimeterSide("left", 27, 0.02, 0.02, 0.02, 0.98)) .concat(perimeterSide("bottom", 27, 0.02, 0.98, 0.98, 0.98)) .concat(perimeterSide("right", 27, 0.98, 0.98, 0.98, 0.02)) .concat(perimeterSide("top", 25, 0.98, 0.02, 0.02, 0.02)); } function perimeterSide(side, count, startX, startY, endX, endY) { const positions = []; for (let index = 0; index < count; index += 1) { const factor = count === 1 ? 0 : index / (count - 1); positions.push({ side: side, x: lerp(startX, endX, factor), y: lerp(startY, endY, factor), }); } return positions; } function lerp(start, end, factor) { return start + (end - start) * factor; } function buildPreviewLedColors(panel) { if (!panel.energy_percent) { return TILE_LED_GEOMETRY.map(() => "#070B10"); } const sampleColors = (panel.sample_led_hex || []) .map((hex) => normalizeColorHex(hex)) .filter((hex) => isHexColorString(hex)); if (!sampleColors.length) { return TILE_LED_GEOMETRY.map(() => normalizeColorHex(panel.representative_color_hex)); } return TILE_LED_GEOMETRY.map((_led, index) => { const sourceIndex = Math.round((index / Math.max(1, TILE_LED_GEOMETRY.length - 1)) * (sampleColors.length - 1)); return sampleColors[sourceIndex] || normalizeColorHex(panel.representative_color_hex); }); } function parseHexColor(hex) { const normalized = normalizeColorHex(hex).slice(1); return { r: Number.parseInt(normalized.slice(0, 2), 16), g: Number.parseInt(normalized.slice(2, 4), 16), b: Number.parseInt(normalized.slice(4, 6), 16), }; } function rgbToHex(r, g, b) { return `#${[r, g, b].map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase()}`; } function previewLedOpacity(energyPercent) { if (!energyPercent) { return "0.1"; } const steppedPercent = Math.round(Math.max(0, Math.min(100, energyPercent)) / 10) * 10; return String(Math.max(0.1, steppedPercent / 100)); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } init(); })();