2504 lines
85 KiB
JavaScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
init();
|
|
})();
|