jev-8ball/public/js/app.js

405 lines
13 KiB
JavaScript

/**
* jev8ball // Neural Decision Oracle Client
* Pure vanilla ES6+, HTML5 Canvas, Web Audio API
*/
(function () {
'use strict';
// ─── AMBIENT PARTICLES (katdrop style) ───
const canvas = document.getElementById('particles');
const ctx = canvas.getContext('2d');
let particles = [];
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
class Particle {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.size = Math.random() * 1.5 + 0.5;
this.speedX = (Math.random() - 0.5) * 0.25;
this.speedY = (Math.random() - 0.5) * 0.25;
this.alpha = Math.random() * 0.35 + 0.1;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) {
this.reset();
}
}
draw() {
ctx.fillStyle = `rgba(255, 255, 255, ${this.alpha})`;
ctx.fillRect(this.x, this.y, this.size, this.size);
}
}
function initParticles(count = 45) {
particles = Array.from({ length: count }, () => new Particle());
}
initParticles();
function animateParticles() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
p.update();
p.draw();
}
requestAnimationFrame(animateParticles);
}
requestAnimationFrame(animateParticles);
// ─── AUDIO SYNTHESIZER ───
class OracleAudio {
constructor() {
this.ctx = null;
this.muted = localStorage.getItem('jev_muted') === 'true';
}
init() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) this.ctx = new AC();
}
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
}
toggleMute() {
this.muted = !this.muted;
localStorage.setItem('jev_muted', this.muted);
return this.muted;
}
playTone(freq, type = 'sine', duration = 0.1, vol = 0.1) {
if (this.muted) return;
try {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
} catch (e) {}
}
shakeSound() {
if (this.muted) return;
try {
this.init();
if (!this.ctx) return;
// Fluid swish pulse
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(90, this.ctx.currentTime);
osc.frequency.linearRampToValueAtTime(160, this.ctx.currentTime + 0.2);
osc.frequency.linearRampToValueAtTime(80, this.ctx.currentTime + 0.4);
gain.gain.setValueAtTime(0.12, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.45);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.45);
} catch (e) {}
}
revealChime(sentiment = 'affirmative') {
if (this.muted) return;
if (sentiment === 'affirmative') {
this.playTone(440, 'triangle', 0.12, 0.15);
setTimeout(() => this.playTone(554.37, 'triangle', 0.15, 0.15), 80);
setTimeout(() => this.playTone(659.25, 'triangle', 0.25, 0.18), 160);
} else if (sentiment === 'negative') {
this.playTone(329.63, 'sawtooth', 0.14, 0.12);
setTimeout(() => this.playTone(277.18, 'sawtooth', 0.22, 0.14), 100);
} else {
this.playTone(392.00, 'sine', 0.18, 0.14);
setTimeout(() => this.playTone(440.00, 'sine', 0.22, 0.14), 110);
}
}
}
const audio = new OracleAudio();
// ─── DOM ELEMENTS ───
const ballWrapper = document.getElementById('ballWrapper');
const ballSphere = document.getElementById('ballSphere');
const floatingDie = document.getElementById('floatingDie');
const dieText = document.getElementById('dieText');
const askForm = document.getElementById('askForm');
const questionInput = document.getElementById('questionInput');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const btnSpinner = document.getElementById('btnSpinner');
const soundToggle = document.getElementById('soundToggle');
const backendStatus = document.getElementById('backendStatus');
const statusText = document.getElementById('statusText');
const telemetryCard = document.getElementById('telemetryCard');
const telemetryBadge = document.getElementById('telemetryBadge');
const tVerdict = document.getElementById('tVerdict');
const tNoul = document.getElementById('tNoul');
const noulFill = document.getElementById('noulFill');
const tConfidence = document.getElementById('tConfidence');
const tLatency = document.getElementById('tLatency');
const tCost = document.getElementById('tCost');
const jsonDump = document.getElementById('jsonDump');
const historyList = document.getElementById('historyList');
const historyCount = document.getElementById('historyCount');
const toast = document.getElementById('toast');
let isSubmitting = false;
let historyData = [];
// Load history from localStorage
try {
const saved = localStorage.getItem('jev_history');
if (saved) historyData = JSON.parse(saved);
} catch (e) {}
// ─── SOUND TOGGLE ───
function updateSoundUI() {
soundToggle.textContent = audio.muted ? 'sound: off' : 'sound: on';
}
updateSoundUI();
soundToggle.addEventListener('click', () => {
audio.toggleMute();
updateSoundUI();
showToast(audio.muted ? 'Audio muted' : 'Audio enabled');
});
// ─── TOAST NOTIFICATION ───
let toastTimer = null;
function showToast(msg) {
clearTimeout(toastTimer);
toast.textContent = msg;
toast.classList.add('active');
toastTimer = setTimeout(() => {
toast.classList.remove('active');
}, 2200);
}
// ─── 3D PARALLAX TILT ON HOVER ───
window.addEventListener('pointermove', (e) => {
if (isSubmitting) return;
const rect = ballWrapper.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = (e.clientX - centerX) / (window.innerWidth / 2);
const deltaY = (e.clientY - centerY) / (window.innerHeight / 2);
const tiltX = Math.max(-14, Math.min(14, -deltaY * 16));
const tiltY = Math.max(-14, Math.min(14, deltaX * 16));
ballSphere.style.transform = `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`;
});
ballWrapper.addEventListener('mouseleave', () => {
if (!isSubmitting) {
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
}
});
// Clicking ball shakes it if input is non-empty, or prompts user
ballWrapper.addEventListener('click', () => {
if (isSubmitting) return;
if (questionInput.value.trim()) {
handleAsk(questionInput.value.trim());
} else {
questionInput.focus();
showToast('Type a question to consult Jev');
}
});
// ─── STATUS CHECK ───
async function checkStatus() {
try {
const res = await fetch('/api/status');
if (res.ok) {
const data = await res.json();
backendStatus.classList.add('online');
statusText.textContent = data.model;
} else {
backendStatus.classList.remove('online');
statusText.textContent = 'status error';
}
} catch (e) {
backendStatus.classList.remove('online');
statusText.textContent = 'offline';
}
}
checkStatus();
backendStatus.addEventListener('click', () => {
showToast('Checking Jev backend status...');
checkStatus();
});
// ─── QUICK PROMPTS ───
document.querySelectorAll('.quick-pill').forEach(pill => {
pill.addEventListener('click', () => {
if (isSubmitting) return;
const q = pill.getAttribute('data-q');
questionInput.value = q;
handleAsk(q);
});
});
// ─── FORM SUBMISSION ───
askForm.addEventListener('submit', (e) => {
e.preventDefault();
const q = questionInput.value.trim();
if (q) handleAsk(q);
});
async function handleAsk(question) {
if (isSubmitting) return;
isSubmitting = true;
// UI Loading state
submitBtn.disabled = true;
questionInput.disabled = true;
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
// 1. Shake Ball & Submerge Die
ballSphere.classList.add('shaking');
floatingDie.className = 'floating-die submerged';
audio.shakeSound();
const minShakeTime = new Promise(resolve => setTimeout(resolve, 750));
try {
// 2. Call /api/ask
const apiCall = fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question })
}).then(r => r.json());
const [_, result] = await Promise.all([minShakeTime, apiCall]);
// Remove shaking
ballSphere.classList.remove('shaking');
if (result.error && !result.fallback) {
showToast(result.error || 'Decision failed');
dieText.innerHTML = 'CANNOT<br>PREDICT';
floatingDie.className = 'floating-die surfacing';
return;
}
const data = result.fallback ? { ...result.fallback, question } : result;
displayOracleAnswer(data);
} catch (err) {
console.error('Request failed:', err);
ballSphere.classList.remove('shaking');
dieText.innerHTML = 'REPLY HAZY<br>TRY AGAIN';
floatingDie.className = 'floating-die surfacing';
showToast('Network timeout. Please retry.');
} finally {
isSubmitting = false;
submitBtn.disabled = false;
questionInput.disabled = false;
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
questionInput.focus();
}
}
function displayOracleAnswer(data) {
const formattedText = data.answer.replace(/\s+/g, ' ').toUpperCase();
const words = formattedText.split(' ');
let htmlAnswer = formattedText;
if (words.length > 2) {
const mid = Math.ceil(words.length / 2);
htmlAnswer = words.slice(0, mid).join(' ') + '<br>' + words.slice(mid).join(' ');
}
dieText.innerHTML = htmlAnswer;
floatingDie.className = 'floating-die surfacing';
// Audio chime
audio.revealChime(data.sentiment);
// Update Telemetry
telemetryBadge.className = `telemetry-badge ${data.sentiment}`;
telemetryBadge.textContent = data.sentiment.toUpperCase();
tVerdict.textContent = data.answer;
tNoul.textContent = (data.noul !== undefined ? data.noul.toFixed(2) : '0.50');
noulFill.className = `meter-fill ${data.sentiment}`;
noulFill.style.width = `${Math.round((data.noul || 0.5) * 100)}%`;
tConfidence.textContent = `${Math.round((data.confidence || 0.8) * 100)}%`;
tLatency.textContent = `${data.latency || 320}ms`;
tCost.textContent = data.cost ? `$${data.cost.toFixed(6)}` : '< $0.0001';
jsonDump.textContent = JSON.stringify(data.raw || data, null, 2);
// Append to History
addHistoryItem({
question: data.question,
answer: data.answer,
sentiment: data.sentiment,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
}
function addHistoryItem(item) {
historyData.unshift(item);
if (historyData.length > 25) historyData.pop();
try {
localStorage.setItem('jev_history', JSON.stringify(historyData));
} catch (e) {}
renderHistory();
}
function renderHistory() {
historyList.innerHTML = '';
historyCount.textContent = `${historyData.length} ${historyData.length === 1 ? 'query' : 'queries'}`;
historyData.forEach((item, idx) => {
const li = document.createElement('li');
li.className = 'history-item';
li.innerHTML = `
<div class="history-left">
<div class="history-q" title="${escapeHtml(item.question)}">${escapeHtml(item.question)}</div>
<div class="history-time">${item.timestamp}</div>
</div>
<div class="history-pill ${item.sentiment}">
${escapeHtml(item.answer)}
</div>
`;
li.style.cursor = 'pointer';
li.addEventListener('click', () => {
navigator.clipboard.writeText(`Q: ${item.question}\nA: ${item.answer}`).then(() => {
showToast('Copied fortune to clipboard');
});
});
historyList.appendChild(li);
});
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
renderHistory();
})();