helva-robot/face/var/www/html/control/control.js

230 lines
6.8 KiB
JavaScript

// Control UI -> POST /api/state
// Also subscribes to /events to show current state/connection.
(() => {
const EMOTIONS = [
{ id: "neutral", label: "Neutral", icon: "•" },
{ id: "happy", label: "Happy", icon: "😊" },
{ id: "sad", label: "Sad", icon: "☹️" },
{ id: "angry", label: "Angry", icon: "😠" },
{ id: "sleepy", label: "Sleepy", icon: "😴" },
{ id: "surprised", label: "Surprised", icon: "😲" },
{ id: "excited", label: "Excited", icon: "⚡" },
];
const clamp = (n,a,b) => Math.max(a, Math.min(b, n));
const clamp01 = (v) => clamp(Number(v) || 0, 0, 1);
const ui = {
grid: document.getElementById("emotionGrid"),
conn: document.getElementById("conn"),
current: document.getElementById("current"),
speaking: document.getElementById("speaking"),
eyesMoving: document.getElementById("eyesMoving"),
intensity: document.getElementById("intensity"),
intensityVal: document.getElementById("intensityVal"),
lookpad: document.getElementById("lookpad"),
lookdot: document.getElementById("lookdot"),
center: document.getElementById("center"),
stareOff: document.getElementById("stareOff"),
};
const state = {
emotion: "neutral",
intensity: 0.85,
speaking: false,
eyesMoving: true,
look: { x: 0, y: 0 },
// stare/lock is represented by sending/omitting look.
stare: false,
};
function renderEmotionButtons() {
ui.grid.innerHTML = "";
for (const e of EMOTIONS) {
const b = document.createElement("button");
b.className = "emobtn";
b.dataset.emotion = e.id;
b.innerHTML = `
<div class="icon">${e.icon}</div>
<div>
<div style="font-weight:700">${e.label}</div>
<div style="opacity:.6;font-size:12px">${e.id}</div>
</div>
`;
b.addEventListener("click", () => {
state.emotion = e.id;
setActiveEmotion();
sendState({ emotion: state.emotion });
});
ui.grid.appendChild(b);
}
setActiveEmotion();
}
function setActiveEmotion() {
for (const btn of ui.grid.querySelectorAll(".emobtn")) {
btn.classList.toggle("active", btn.dataset.emotion === state.emotion);
}
ui.current.textContent = state.emotion;
}
async function sendState(partial) {
// Build payload. If stare=false -> do NOT send look (so face can drift).
const payload = {
emotion: state.emotion,
intensity: state.intensity,
speaking: state.speaking,
eyesMoving: state.eyesMoving,
...partial,
};
if (state.stare) payload.look = { x: state.look.x, y: state.look.y };
else delete payload.look;
try {
await fetch("/api/state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} catch (_) {
// ignore; SSE status will show disconnected
}
}
// intensity
ui.intensity.addEventListener("input", () => {
state.intensity = clamp01(ui.intensity.value);
ui.intensityVal.textContent = state.intensity.toFixed(2);
sendState({ intensity: state.intensity });
});
// toggles
ui.speaking.addEventListener("change", () => {
state.speaking = !!ui.speaking.checked;
sendState({ speaking: state.speaking });
});
ui.eyesMoving.addEventListener("change", () => {
state.eyesMoving = !!ui.eyesMoving.checked;
sendState({ eyesMoving: state.eyesMoving });
});
// Look pad (touch/mouse)
function setDotFromLook() {
const r = ui.lookpad.getBoundingClientRect();
const px = (state.look.x + 1) / 2 * r.width;
const py = (state.look.y + 1) / 2 * r.height;
ui.lookdot.style.left = `${px}px`;
ui.lookdot.style.top = `${py}px`;
ui.lookdot.style.transform = "translate(-50%,-50%)";
}
function setLookFromEvent(ev) {
const r = ui.lookpad.getBoundingClientRect();
const x = clamp((ev.clientX - r.left) / r.width, 0, 1);
const y = clamp((ev.clientY - r.top) / r.height, 0, 1);
state.look.x = (x * 2) - 1;
state.look.y = (y * 2) - 1;
state.stare = true; // touching pad implies stare
setDotFromLook();
sendState({}); // will include look because stare=true
}
let pointerDown = false;
ui.lookpad.addEventListener("pointerdown", (ev) => {
pointerDown = true;
ui.lookpad.setPointerCapture(ev.pointerId);
setLookFromEvent(ev);
});
ui.lookpad.addEventListener("pointermove", (ev) => {
if (!pointerDown) return;
setLookFromEvent(ev);
});
ui.lookpad.addEventListener("pointerup", () => { pointerDown = false; });
// dblclick / double tap center
let lastTap = 0;
ui.lookpad.addEventListener("pointerdown", () => {
const now = Date.now();
if (now - lastTap < 280) {
state.look = { x: 0, y: 0 };
state.stare = true;
setDotFromLook();
sendState({});
}
lastTap = now;
});
ui.center.addEventListener("click", () => {
state.look = { x: 0, y: 0 };
state.stare = true;
setDotFromLook();
sendState({});
});
ui.stareOff.addEventListener("click", () => {
state.stare = false; // omit look in next send
sendState({});
});
// SSE subscribe to reflect current state + connection
(function connectSSE(){
let es;
function open(){
ui.conn.textContent = "connecting…";
es = new EventSource("/events");
es.onopen = () => ui.conn.textContent = "online";
const apply = (msg) => {
if (!msg || typeof msg !== "object") return;
if (typeof msg.emotion === "string") state.emotion = msg.emotion;
if (msg.intensity !== undefined) state.intensity = clamp01(msg.intensity);
if (typeof msg.speaking === "boolean") state.speaking = msg.speaking;
if (typeof msg.eyesMoving === "boolean") state.eyesMoving = msg.eyesMoving;
if (msg.look && typeof msg.look === "object") {
state.look = {
x: clamp(Number(msg.look.x ?? 0), -1, 1),
y: clamp(Number(msg.look.y ?? 0), -1, 1),
};
state.stare = true;
}
ui.intensity.value = String(state.intensity);
ui.intensityVal.textContent = state.intensity.toFixed(2);
ui.speaking.checked = state.speaking;
ui.eyesMoving.checked = state.eyesMoving;
setActiveEmotion();
setDotFromLook();
};
es.addEventListener("state", (ev) => {
try { apply(JSON.parse(ev.data)); } catch (_) {}
});
es.onmessage = (ev) => {
try { apply(JSON.parse(ev.data)); } catch (_) {}
};
es.onerror = () => {
ui.conn.textContent = "offline";
try { es.close(); } catch (_) {}
setTimeout(open, 1200);
};
}
open();
})();
// init
renderEmotionButtons();
ui.intensityVal.textContent = state.intensity.toFixed(2);
setDotFromLook();
})();