// 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 = `
${e.icon}
${e.label}
${e.id}
`; 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(); })();