<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,user-scalable=no"
/>
<title>Vizij Control</title>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%237c4dff'/></svg>"
/>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-main: #0b0d18;
--bg-panel: #14162a;
--text: #ecedff;
--text-dim: #9ea3ba;
--accent: #7c4dff;
--accent-soft: #a78bff;
--ok: #4caf50;
--warn: #ff9800;
--err: #f44336;
--muted-line: #2a2e4c;
}
body {
font-family: -apple-system, system-ui, BlinkMacSystemFont, sans-serif;
background: radial-gradient(
circle at 0% 0%,
#171a2f 0%,
var(--bg-main) 45%,
#090b14 100%
);
color: var(--text);
overflow-x: hidden;
}
header {
position: sticky;
top: 0;
background: rgba(13, 16, 33, 0.95);
backdrop-filter: blur(8px);
padding: 10px 12px 12px;
z-index: 10;
border-bottom: 1px solid var(--muted-line);
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
}
.brand {
color: #f3f3ff;
letter-spacing: 0.02em;
font-size: 12px;
text-transform: uppercase;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.off {
background: var(--err);
}
.dot.on {
background: var(--ok);
}
.dot.wait {
background: var(--warn);
}
.conn-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.filters {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
align-items: center;
}
input[type="text"],
input[type="search"] {
background: #252845;
color: var(--text);
border: 1px solid #3a3f67;
padding: 7px 10px;
border-radius: 6px;
font-size: 15px;
flex: 1;
min-width: 100px;
}
input::placeholder {
color: #7580a0;
}
input[type="search"] {
min-width: 140px;
}
.port {
color: #7d86ad;
font-size: 14px;
flex-shrink: 0;
}
button {
padding: 7px 14px;
border-radius: 6px;
border: none;
font-size: 13px;
font-weight: 600;
transition: opacity 0.15s ease;
cursor: pointer;
flex-shrink: 0;
}
button:hover:not(:disabled) {
opacity: 0.92;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
.btn-on {
background: var(--ok);
color: #fff;
}
.btn-off {
background: var(--err);
color: #fff;
}
.btn-warn {
background: var(--warn);
color: #fff;
}
.btn-muted {
background: #2a3049;
color: var(--text);
}
nav {
display: flex;
background: #161a33;
overflow-x: auto;
border-bottom: 2px solid var(--muted-line);
}
.tab {
padding: 10px 18px;
cursor: pointer;
white-space: nowrap;
color: #8a91b2;
border-bottom: 2px solid transparent;
font-size: 14px;
user-select: none;
transition:
color 0.15s,
background-color 0.15s;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
background: #1a1f39;
}
main {
padding: 10px;
max-width: 560px;
margin: 0 auto;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
.panel {
margin-bottom: 6px;
background: var(--bg-panel);
border-radius: 8px;
overflow: hidden;
border: 1px solid #2d3356;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel-hdr {
padding: 10px 14px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: #191f3c;
user-select: none;
font-size: 14px;
}
.panel-hdr:active {
background: #1e2546;
}
.arrow {
transition: transform 0.15s;
font-size: 10px;
color: #7981a7;
}
.panel-hdr.shut .arrow {
transform: rotate(-90deg);
}
.panel-body {
padding: 6px 14px 10px;
}
.panel-body.shut {
display: none;
}
.slider-row {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0;
}
.slider-label {
font-size: 12px;
color: #a2a8c2;
min-width: 72px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slider-val {
font-size: 12px;
color: var(--accent);
min-width: 48px;
text-align: right;
font-family: monospace;
}
.num-input {
width: 74px;
background: #212850;
color: var(--text);
border: 1px solid #333961;
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
}
input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: #33395f;
border-radius: 3px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
border: none;
cursor: pointer;
}
.empty {
text-align: center;
color: #6f789a;
padding: 40px 16px;
font-size: 14px;
}
.muted {
color: var(--text-dim);
font-size: 12px;
}
</style>
</head>
<body>
<header>
<div class="status-row">
<div class="brand">Vizij Control</div>
<span class="dot off" id="dot"></span>
<span id="stxt">Disconnected</span>
</div>
<div class="conn-row">
<input type="text" id="ip" placeholder="IP address" />
<span class="port" id="port"></span>
<button class="btn-on" id="cbtn" onclick="toggleConnection()">
Connect
</button>
<button class="btn-warn" id="rbtn" onclick="invokeReset()" disabled>
Reset
</button>
</div>
<div class="filters">
<input
type="search"
id="search"
placeholder="Search controls (namespace/group/path)"
/>
<button class="btn-muted" id="clear-search" onclick="setSearch('')">
Clear
</button>
</div>
</header>
<nav id="tabs"></nav>
<main id="main"></main>
<script>
let ws = null;
let controls = [];
let controlByPath = new Map();
let namespaceFilter = "all";
let searchFilter = "";
let pending = {};
let flushTimer = null;
let connectTimeout = null;
const $ = (id) => document.getElementById(id);
const wsPort = location.port || "9000";
const ip = $("ip");
const dot = $("dot");
const stxt = $("stxt");
const cbtn = $("cbtn");
const rbtn = $("rbtn");
const searchInput = $("search");
const mainEl = $("main");
const tabsEl = $("tabs");
const numberSuffixes = ["f64", "f32", "i64", "i32", "u64", "u32"];
ip.value = location.hostname || "";
$("port").textContent = ":" + wsPort;
function status(s, t) {
dot.className = "dot " + s;
stxt.textContent = t;
}
function normalizePath(raw) {
return String(raw || "")
.trim()
.replace(/^\/+/, "")
.replace(/\/+/g, "/");
}
function extractNumeric(obj, fallback = 0) {
if (obj === null || obj === undefined) {
return fallback;
}
if (typeof obj === "number") {
return Number.isFinite(obj) ? obj : fallback;
}
if (typeof obj === "string") {
const n = Number.parseFloat(obj);
return Number.isFinite(n) ? n : fallback;
}
if (typeof obj === "object") {
for (const key of numberSuffixes) {
if (key in obj) {
const n = Number.parseFloat(obj[key]);
return Number.isFinite(n) ? n : fallback;
}
}
}
return fallback;
}
function hasMeaningfulNumber(obj) {
return Number.isFinite(extractNumeric(obj, Number.NaN));
}
function metadataScore(slot) {
let score = 0;
if (hasMeaningfulNumber(slot?.min)) {
score += 4;
}
if (hasMeaningfulNumber(slot?.max)) {
score += 4;
}
if (slot?.default_value !== undefined) {
score += 3;
}
if (typeof slot?.path === "string" && slot.path.trim().length) {
score += 1;
}
return score;
}
function setControls(nextControls) {
controls = nextControls;
controlByPath = new Map(
controls.map((control) => [control.path, control]),
);
}
function getControlByPath(path) {
return controlByPath.get(normalizePath(path));
}
function getRangeInput(path) {
return mainEl.querySelector(
'input[type="range"][data-p="' + cssEscape(path) + '"]',
);
}
function getNumberInput(path) {
return mainEl.querySelector(
'input[type="number"][data-p="' + cssEscape(path) + '"]',
);
}
function applyControlValue(path, value) {
const control = getControlByPath(path);
if (!control) {
return null;
}
const clamped = clampValue(value, control.min, control.max);
control.currentValue = clamped;
const rangeInput = getRangeInput(control.path);
if (rangeInput) {
rangeInput.value = String(clamped);
const valueText = rangeInput.nextElementSibling.nextElementSibling;
if (valueText && valueText.classList.contains("slider-val")) {
valueText.textContent = clamped.toFixed(2);
}
}
const numberInput = getNumberInput(control.path);
if (numberInput) {
numberInput.value = clamped;
}
return clamped;
}
function shouldReplaceCanonical(existingSlot, newSlot) {
const existingScore = metadataScore(existingSlot);
const newScore = metadataScore(newSlot);
if (newScore !== existingScore) {
return newScore > existingScore;
}
if (newSlot?.path && existingSlot?.path) {
return (
normalizePath(newSlot.path).length <
normalizePath(existingSlot.path).length
);
}
return false;
}
function clampValue(v, min, max) {
const value = Number.parseFloat(v);
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
function rangeStep(min, max) {
const span = max - min;
if (!Number.isFinite(span) || span <= 0.000001) {
return 0.0001;
}
return span / 2000;
}
function deriveControls(slots) {
const normalized = (slots || []).filter((s) => {
const kind = s && s.kind ? String(s.kind).toLowerCase() : "";
return kind === "input" && typeof s.path === "string";
});
const byCanonicalPath = new Map();
for (const s of normalized) {
const path = normalizePath(s.path);
const parts = path.split("/").filter(Boolean);
if (!path || !parts.length) {
continue;
}
const namespace = parts[0] || "unscoped";
const group =
parts.length >= 2
? `${parts[0]}/${parts[1]}`
: parts[0] || "unscoped";
const track = parts.length >= 3 ? parts.slice(2).join("/") : "";
const min = extractNumeric(s.min, 0);
const max = extractNumeric(s.max, min + 1);
const fallback = extractNumeric(s.default_value, min);
const control = {
path,
namespace,
group,
track: track || path,
min,
max,
defaultValue: fallback,
currentValue: fallback,
slot: s,
};
const existing = byCanonicalPath.get(path);
if (!existing || shouldReplaceCanonical(existing.slot, s)) {
byCanonicalPath.set(path, control);
}
}
const out = Array.from(byCanonicalPath.values());
out.sort((left, right) =>
left.namespace === right.namespace
? left.group === right.group
? left.track.localeCompare(right.track)
: left.group.localeCompare(right.group)
: left.namespace.localeCompare(right.namespace),
);
return out;
}
function filteredControls() {
const query = searchFilter.trim().toLowerCase();
return controls.filter((control) => {
if (
namespaceFilter !== "all" &&
control.namespace !== namespaceFilter
) {
return false;
}
if (!query) {
return true;
}
const haystack =
`${control.path} ${control.group} ${control.track} ${control.namespace}`.toLowerCase();
return haystack.includes(query);
});
}
function buildNamespaceTabs(namespaces) {
const names = ["all", ...namespaces];
const namespaceCounts = controls.reduce((counts, control) => {
counts[control.namespace] = (counts[control.namespace] || 0) + 1;
return counts;
}, {});
let tabs = "";
names.forEach((name) => {
const label = name === "all" ? "All" : name;
tabs +=
'<div class="tab ' +
(name === namespaceFilter ? "active" : "") +
'" data-namespace="' +
name +
'" onclick="setNamespace(\'' +
name +
"')\">" +
label +
" (" +
(name === "all" ? controls.length : namespaceCounts[name] || 0) +
")</div>";
});
tabsEl.innerHTML = names.length > 1 ? tabs : "";
}
function setNamespace(name) {
namespaceFilter = name;
rebuildUI();
}
function setSearch(value) {
searchFilter = value || "";
searchInput.value = searchFilter;
rebuildUI();
}
function cssEscape(value) {
return String(value).replace(/"/g, '\\"');
}
function rebuildUI() {
const visible = filteredControls();
const namespaces = Array.from(
new Set(controls.map((c) => c.namespace)),
).sort((a, b) => a.localeCompare(b));
buildNamespaceTabs(namespaces);
if (!controls.length) {
mainEl.innerHTML =
'<div class="empty">No input controls found.<br/>Make sure a model is loaded and published.</div>';
return;
}
if (!visible.length) {
mainEl.innerHTML =
'<div class="empty">No matching controls. Try a different filter.</div>';
return;
}
const byNamespace = {};
visible.forEach((control) => {
if (!byNamespace[control.namespace]) {
byNamespace[control.namespace] = {};
}
if (!byNamespace[control.namespace][control.group]) {
byNamespace[control.namespace][control.group] = [];
}
byNamespace[control.namespace][control.group].push(control);
});
let mainHTML = "";
const order =
namespaceFilter === "all" ? namespaces : [namespaceFilter];
order.forEach((ns) => {
const groups = byNamespace[ns];
if (!groups) {
return;
}
mainHTML += '<section class="tab-pane active" data-ns="' + ns + '">';
Object.keys(groups)
.sort((a, b) => a.localeCompare(b))
.forEach((group) => {
const rows = groups[group]
.sort((a, b) => a.track.localeCompare(b.track))
.map((control) => {
const value = control.currentValue;
const step = rangeStep(control.min, control.max);
return (
'<div class="slider-row">' +
'<span class="slider-label" title="' +
control.track +
'">' +
control.track +
"</span>" +
'<input type="range" min="' +
control.min +
'" max="' +
control.max +
'" step="' +
step +
'" value="' +
value +
'" data-p="' +
control.path +
'" oninput="onSlide(this)">' +
'<input type="number" class="num-input" step="' +
step +
'" value="' +
value +
'" data-p="' +
control.path +
'" oninput="onNumber(this)">' +
'<span class="slider-val">' +
Number(value).toFixed(2) +
"</span>" +
'<button class="btn-muted" type="button" onclick="resetOne(\'' +
cssEscape(control.path) +
"')\">↺</button>" +
"</div>"
);
})
.join("");
mainHTML +=
'<div class="panel">' +
'<div class="panel-hdr" onclick="togglePanel(this)">' +
"<span>" +
group.replace(/\//g, " / ") +
'</span><span class="arrow">â–¼</span>' +
"</div>" +
'<div class="panel-body">' +
rows +
"</div>" +
"</div>";
});
mainHTML += "</section>";
});
mainEl.innerHTML = mainHTML;
document.querySelectorAll(".tab-pane").forEach((pane) => {
pane.classList.toggle(
"active",
namespaceFilter === "all"
? pane === document.querySelector(".tab-pane")
: pane.dataset.ns === namespaceFilter,
);
});
if (
namespaceFilter !== "all" &&
!document.querySelector(".tab-pane.active")
) {
const first = document.querySelector(".tab-pane");
if (first) {
first.classList.add("active");
}
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.classList.toggle(
"active",
tab.dataset.namespace === namespaceFilter,
);
});
refreshCurrentValues();
}
function togglePanel(hdr) {
hdr.classList.toggle("shut");
hdr.nextElementSibling.classList.toggle("shut");
}
function refreshCurrentValues() {
for (const control of controls) {
const el = getRangeInput(control.path);
if (el) {
el.value = control.currentValue;
const valEl = el.nextElementSibling.nextElementSibling;
if (valEl && valEl.classList.contains("slider-val")) {
valEl.textContent = Number(control.currentValue).toFixed(2);
}
const numEl = getNumberInput(control.path);
if (numEl) {
numEl.value = control.currentValue;
}
}
}
}
function scheduleFlush() {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
if (!ws || ws.readyState !== 1) {
return;
}
const payload = {};
for (const path of Object.keys(pending)) {
payload[path] = { f64: pending[path] };
}
const keys = Object.keys(payload);
if (!keys.length) {
return;
}
ws.send(JSON.stringify({ type: "set_slot_values", values: payload }));
pending = {};
}, 25);
}
function setControlNumeric(controlPath, value) {
const numeric = Number.parseFloat(value);
if (!Number.isFinite(numeric)) {
return;
}
const clamped = applyControlValue(controlPath, numeric);
if (clamped === null) {
return;
}
pending[controlPath] = clamped;
scheduleFlush();
}
function onSlide(el) {
const path = el.dataset.p;
const value = Number.parseFloat(el.value);
if (!path || !Number.isFinite(value)) {
return;
}
setControlNumeric(path, value);
}
function onNumber(el) {
const path = el.dataset.p;
const value = Number.parseFloat(el.value);
if (!path || !Number.isFinite(value)) {
return;
}
setControlNumeric(path, value);
}
function resetOne(rawPath) {
const path = normalizePath(rawPath);
const control = getControlByPath(path);
if (!control) {
return;
}
setControlNumeric(path, control.defaultValue);
}
function invokeReset() {
if (!ws || ws.readyState !== 1) {
return;
}
ws.send(
JSON.stringify({
type: "invoke",
method: "reset",
request_id: "reset-" + Date.now(),
}),
);
controls.forEach((control) => {
setControlNumeric(control.path, control.defaultValue);
});
ws.send(
JSON.stringify({
type: "get_slot_values",
slots: controls.map((c) => c.path),
}),
);
}
function requestValuesForCurrentControls() {
if (!ws || ws.readyState !== 1 || !controls.length) {
return;
}
const paths = controls.map((c) => c.path);
ws.send(JSON.stringify({ type: "get_slot_values", slots: paths }));
}
function toggleConnection() {
if (ws && ws.readyState < 2) {
ws.close();
} else {
connect();
}
}
function connect() {
const host = ip.value.trim();
if (!host) {
ip.focus();
return;
}
status("wait", "Connecting...");
cbtn.disabled = true;
const url = "ws://" + host + ":" + wsPort;
ws = new WebSocket(url);
connectTimeout = setTimeout(() => {
if (ws && ws.readyState === 0) {
ws.close();
}
}, 10000);
ws.onopen = () => {
clearTimeout(connectTimeout);
status("on", "Connected");
cbtn.textContent = "Disconnect";
cbtn.className = "btn-off";
cbtn.disabled = false;
rbtn.disabled = false;
searchInput.value = "";
ws.send(JSON.stringify({ type: "list_slots" }));
requestValuesForCurrentControls();
};
ws.onmessage = (event) => {
let message = null;
try {
message = JSON.parse(event.data);
} catch (error) {
console.error("[vizij] Failed to parse websocket message:", error);
return;
}
if (message.type === "list_slots_resp") {
setControls(deriveControls(message.slots || []));
rebuildUI();
requestValuesForCurrentControls();
} else if (message.type === "get_slot_values_resp") {
const values = message.values || {};
Object.entries(values).forEach(([path, value]) => {
const numeric = extractNumeric(value);
const control = getControlByPath(path);
if (!control || !Number.isFinite(numeric)) {
return;
}
control.currentValue = numeric;
});
refreshCurrentValues();
}
};
ws.onclose = () => {
clearTimeout(connectTimeout);
status("off", "Disconnected");
cbtn.textContent = "Connect";
cbtn.className = "btn-on";
cbtn.disabled = false;
rbtn.disabled = true;
ws = null;
controls = [];
controlByPath = new Map();
rebuildUI();
};
ws.onerror = (err) => {
console.error("[vizij] WebSocket error:", err);
status("off", "Connection error");
};
}
searchInput.addEventListener("input", (event) => {
setSearch(event.target.value || "");
});
function resetUI() {
controls = [];
namespaceFilter = "all";
searchFilter = "";
searchInput.value = "";
controlByPath = new Map();
pending = {};
rebuildUI();
}
resetUI();
</script>
</body>
</html>