Files
Infinity_Vis_Rust/web/v1/app.js

2504 lines
85 KiB
JavaScript

(function () {
const PREVIEW_RENDER_INTERVAL_MS = 90;
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 BRIGHTNESS_PARAM_KEYS = new Set(["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: "Laser Club",
options: [
{ value: "Laser Club", label: "Laser Club" },
{ value: "Magenta Drive", label: "Magenta Drive" },
{ value: "Warehouse Heat", label: "Warehouse Heat" },
{ value: "UV Riot", label: "UV Riot" },
{ value: "Redline", label: "Redline" },
{ value: "Sodium Haze", label: "Sodium Haze" },
{ value: "Afterhours", label: "Afterhours" },
{ value: "Voltage", label: "Voltage" },
],
},
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,
previewTimer: null,
lastStateRenderAt: 0,
lastPreviewRenderAt: 0,
stateRenderQueued: false,
previewRenderQueued: false,
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"),
brightnessParams: document.getElementById("brightness-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 now = window.performance.now();
const waitMs = Math.max(0, PREVIEW_RENDER_INTERVAL_MS - (now - apiState.ui.lastPreviewRenderAt));
window.setTimeout(() => {
apiState.ui.previewRenderQueued = false;
apiState.ui.lastPreviewRenderAt = window.performance.now();
renderPreview(false);
renderSelectedTile();
renderSnapshotJson();
}, waitMs);
}
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 = "";
dom.brightnessParams.innerHTML = "";
apiState.ui.parameterCards.clear();
return;
}
if (
!force &&
(dom.motionParams.contains(document.activeElement) ||
dom.colorParams.contains(document.activeElement) ||
dom.brightnessParams.contains(document.activeElement))
) {
return;
}
const parameters = scene.parameters || [];
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 = "";
dom.brightnessParams.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;
}
if (BRIGHTNESS_PARAM_KEYS.has(parameter.key)) {
return dom.brightnessParams;
}
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 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 = '<div class="empty-state">No presets available.</div>';
} else {
presets.forEach((preset) => {
const button = document.createElement("button");
button.type = "button";
button.className = "list-item";
button.dataset.presetId = preset.preset_id;
button.innerHTML =
`<strong>${escapeHtml(preset.preset_id)}</strong>` +
`<div class="list-item-meta">` +
`<span class="meta-pill">${escapeHtml(operatorModeLabelForPatternId(preset.pattern_id))}</span>` +
`<span class="meta-pill">${escapeHtml(preset.transition_style)}</span>` +
`<span class="meta-pill">${escapeHtml(preset.target_group || "all_panels")}</span>` +
`</div>`;
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 = '<div class="empty-state">No creative snapshots saved yet.</div>';
return;
}
snapshots.forEach((snapshot) => {
const button = document.createElement("button");
button.type = "button";
button.className = "list-item";
button.innerHTML =
`<strong>${escapeHtml(snapshot.label || snapshot.snapshot_id)}</strong>` +
`<div class="list-item-meta">` +
`<span class="meta-pill">${escapeHtml(snapshot.snapshot_id)}</span>` +
`<span class="meta-pill">${escapeHtml(operatorModeLabelForPatternId(snapshot.pattern_id))}</span>` +
`<span class="meta-pill">${snapshot.transition_duration_ms} ms</span>` +
`</div>`;
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 =
'<div class="empty-state">Preview stream is waiting for panel snapshots.</div>';
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 = uniqueNodeIds(panels).length;
dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(112px, 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 =
`<div class="selected-tile-title">${escapeHtml(panel.display_id)}</div>` +
`<div class="panel-meta">${escapeHtml(panel.display_caption)}</div>` +
'<div class="selected-tile-grid">' +
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") +
"</div>";
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 (
'<div class="summary-card">' +
`<strong>${escapeHtml(card.value)}</strong>` +
`<span>${escapeHtml(card.label)}</span>` +
`<div class="info-detail">${escapeHtml(card.detail)}</div>` +
"</div>"
);
})
.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
? '<div class="empty-state">No staged transition yet. Pattern, look, color and motion edits stay local until commit.</div>'
: '<div class="empty-state">Local test/edit mode is live. Pattern, look, color and motion changes apply immediately without Go or Fade Go.</div>');
if (client.lastError) {
dom.primitiveErrorBanner.classList.remove("hidden");
dom.primitiveErrorBanner.innerHTML =
`<strong>${escapeHtml(client.lastError.code)}</strong>` +
`<span>${escapeHtml(client.lastError.message)}</span>`;
} 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 (
'<div class="pending-card">' +
`<strong>${escapeHtml(label)}</strong>` +
`<span>${escapeHtml(detail)}</span>` +
"</div>"
);
}
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 (
'<div class="info-row">' +
`<div class="info-label">${escapeHtml(row.label)}</div>` +
`<div class="info-value">${escapeHtml(row.value)}</div>` +
"</div>"
);
})
.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 = '<div class="empty-state">No events match the current filter.</div>';
return;
}
dom.eventList.innerHTML = filtered
.map((entry) => {
return (
`<article class="event-item event-${escapeHtml(entry.kind)}">` +
`<div class="event-meta">${escapeHtml(entry.at)}</div>` +
(entry.code ? `<span class="event-code">${escapeHtml(entry.code)}</span>` : "") +
`<strong>${escapeHtml(entry.message)}</strong>` +
"</article>"
);
})
.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 (
'<div class="info-row">' +
`<div class="info-label">${escapeHtml(label)}</div>` +
`<div class="info-value">${escapeHtml(value)}</div>` +
"</div>"
);
}
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));
const palette = sampleColors.length
? sampleColors
: [normalizeColorHex(panel.representative_color_hex)];
if (palette.length === 1) {
return TILE_LED_GEOMETRY.map(() => palette[0]);
}
return TILE_LED_GEOMETRY.map((_led, index) => {
const factor = TILE_LED_GEOMETRY.length === 1 ? 0 : index / (TILE_LED_GEOMETRY.length - 1);
return interpolatePalette(palette, factor);
});
}
function interpolatePalette(palette, factor) {
const scaled = factor * (palette.length - 1);
const lowerIndex = Math.floor(scaled);
const upperIndex = Math.min(palette.length - 1, Math.ceil(scaled));
if (lowerIndex === upperIndex) {
return palette[lowerIndex];
}
return mixHexColors(palette[lowerIndex], palette[upperIndex], scaled - lowerIndex);
}
function mixHexColors(leftHex, rightHex, factor) {
const left = parseHexColor(leftHex);
const right = parseHexColor(rightHex);
return rgbToHex(
Math.round(lerp(left.r, right.r, factor)),
Math.round(lerp(left.g, right.g, factor)),
Math.round(lerp(left.b, right.b, factor))
);
}
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.18";
}
return String(Math.min(1, 0.42 + energyPercent / 160));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
init();
})();