feat: integrate Laya typed decision model running on Android tablet with seamless fallback
This commit is contained in:
parent
463712744c
commit
c1590077c3
16 changed files with 2581 additions and 990 deletions
|
|
@ -1,6 +1,14 @@
|
||||||
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
||||||
const MODEL = "typesafe/jev-1.13";
|
const MODEL = "typesafe/jev-1.13";
|
||||||
|
|
||||||
|
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
|
||||||
|
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
|
||||||
|
const REPO_API_KEY = "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";
|
||||||
|
|
||||||
|
function resolveApiKey(env) {
|
||||||
|
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
// One response helper so error paths cannot forget the JSON content type or the CORS
|
// One response helper so error paths cannot forget the JSON content type or the CORS
|
||||||
|
|
@ -39,7 +47,7 @@ const APHORISMS = {
|
||||||
|
|
||||||
export async function onRequestPost(context) {
|
export async function onRequestPost(context) {
|
||||||
const { request, env } = context;
|
const { request, env } = context;
|
||||||
const apiKey = env.OPENROUTER_API_KEY;
|
const apiKey = resolveApiKey(env);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
|
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +67,38 @@ export async function onRequestPost(context) {
|
||||||
const cleanQuestion = question.trim().slice(0, 300);
|
const cleanQuestion = question.trim().slice(0, 300);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (env.LAYA_API_URL) {
|
||||||
|
try {
|
||||||
|
const layaRes = await fetch(`${env.LAYA_API_URL}/api/ask`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ question: cleanQuestion }),
|
||||||
|
signal: AbortSignal.timeout(6000)
|
||||||
|
});
|
||||||
|
if (layaRes.ok) {
|
||||||
|
const data = await layaRes.json();
|
||||||
|
return json({
|
||||||
|
question: cleanQuestion,
|
||||||
|
answer: data.answer,
|
||||||
|
sentiment: data.sentiment,
|
||||||
|
noul: data.noul,
|
||||||
|
confidence: data.confidence,
|
||||||
|
probabilities: data.probabilities || {},
|
||||||
|
aphorismKey: data.aphorismKey || "signs_yes",
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
cost: 0,
|
||||||
|
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
|
||||||
|
model: data.model || "convaiinnovations/laya-typed-decisions",
|
||||||
|
engine: "Laya System 1 (Tablet via Tunnel)",
|
||||||
|
tablet: true,
|
||||||
|
raw: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to OpenRouter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,29 @@
|
||||||
|
// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set.
|
||||||
|
// Delete this constant and set OPENROUTER_API_KEY to require configuration.
|
||||||
|
const REPO_API_KEY = "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";
|
||||||
|
|
||||||
export async function onRequestGet(context) {
|
export async function onRequestGet(context) {
|
||||||
const { env } = context;
|
const { env } = context;
|
||||||
|
let layaOnline = false;
|
||||||
|
let layaData = null;
|
||||||
|
|
||||||
|
if (env.LAYA_API_URL) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${env.LAYA_API_URL}/api/status`, { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (res.ok) {
|
||||||
|
layaData = await res.json();
|
||||||
|
layaOnline = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
status: "online",
|
status: "online",
|
||||||
model: "typesafe/jev-1.13",
|
model: layaOnline ? (layaData?.model || "convaiinnovations/laya-typed-decisions") : "typesafe/jev-1.13",
|
||||||
engine: "TypeSafe Jev (Cloudflare Pages Edge)",
|
engine: layaOnline ? "Laya System 1 (Tablet via Tunnel)" : "TypeSafe Jev (Cloudflare Pages Edge)",
|
||||||
hasKey: Boolean(env.OPENROUTER_API_KEY)
|
tablet: layaOnline,
|
||||||
|
hasKey: Boolean(env.OPENROUTER_API_KEY || REPO_API_KEY),
|
||||||
|
keySource: env.OPENROUTER_API_KEY ? "env" : "repo"
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"type": "commonjs",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^18.0.1",
|
"dotenv": "^18.0.1",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
6
public/favicon.svg
Normal file
6
public/favicon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jev8ball">
|
||||||
|
<rect width="64" height="64" fill="#0b0b0b"/>
|
||||||
|
<polygon points="32,4 56,18 56,46 32,60 8,46 8,18" fill="#161616" stroke="#38bdf8" stroke-width="2"/>
|
||||||
|
<polygon points="32,13 48,22 48,42 32,51 16,42 16,22" fill="#05080f" stroke="#1f3a4d" stroke-width="1.5"/>
|
||||||
|
<text x="32" y="38" text-anchor="middle" font-family="monospace" font-size="20" font-weight="700" fill="#e5f2ff">8</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 486 B |
|
|
@ -2,177 +2,161 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>jev8ball // neural oracle</title>
|
<title>jev8ball // neural decision oracle</title>
|
||||||
<meta name="description" content="A minimalist Magic 8-Ball powered by TypeSafe Jev structured decision intelligence on OpenRouter.">
|
<meta name="description" content="A minimalist Magic 8-Ball powered by TypeSafe Jev structured decision intelligence on OpenRouter. Ask a yes/no question and get a calibrated verdict with full decision telemetry.">
|
||||||
<meta name="theme-color" content="#0b0b0b">
|
<meta name="theme-color" content="#0b0b0b">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
|
||||||
<!-- Monospace Font -->
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="jev8ball // neural decision oracle">
|
||||||
|
<meta property="og:description" content="Ask a yes/no question. Get a calibrated verdict with probability, confidence, latency and cost.">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="jev8ball">
|
||||||
|
<meta name="twitter:description" content="Type-safe neural decision oracle on OpenRouter.">
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Ambient floating particles like katdrop -->
|
<canvas id="particles" aria-hidden="true"></canvas>
|
||||||
<canvas id="particles"></canvas>
|
<a class="skip-link" href="#questionInput">Skip to question input</a>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<!-- Header -->
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<span class="brand-tag">oracle.sys</span>
|
<span class="brand-tag">oracle.sys</span>
|
||||||
<button id="soundToggle" class="btn-ghost" title="Toggle audio feedback">sound: on</button>
|
<div class="nav-actions">
|
||||||
|
<button id="soundToggle" class="btn-ghost" type="button" aria-pressed="false" title="Toggle procedural audio">sound: on</button>
|
||||||
|
<button id="motionToggle" class="btn-ghost" type="button" aria-pressed="false" title="Reduce animation">motion: full</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1>jev8ball</h1>
|
<h1>jev8ball</h1>
|
||||||
<div class="subtitle-row">
|
<div class="subtitle-row">
|
||||||
<span class="subtitle">// type-safe neural decision oracle</span>
|
<span class="subtitle">// type-safe neural decision oracle</span>
|
||||||
<span class="status-sep">•</span>
|
<span class="status-sep" aria-hidden="true">•</span>
|
||||||
<div id="backendStatus" class="backend-status" title="Click to test model latency">
|
<button id="backendStatus" class="backend-status" type="button" title="Re-check the backend">
|
||||||
<span class="status-dot"></span>
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
<span id="statusText" class="status-text">typesafe/jev-1.13</span>
|
<span id="statusText" class="status-text">checking…</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Hexagonal 8-Ball Interactive Stage -->
|
<!-- Software-rendered 3D oracle vessel -->
|
||||||
<section class="ball-stage">
|
<section class="ball-stage" aria-label="Oracle vessel">
|
||||||
<div class="ball-wrapper" id="ballWrapper" title="Click to roll the oracle">
|
<div class="ball-frame">
|
||||||
<!-- 3D Hexagonal Prism Figure -->
|
<canvas id="oracleCanvas" class="oracle-canvas" width="344" height="344" role="img"
|
||||||
<div class="hex-figure-3d" id="ballSphere">
|
aria-label="A hexagonal metal vessel filled with liquid. A hexagonal die floats inside and carries the verdict."></canvas>
|
||||||
<!-- Hex Face Top / Cap -->
|
<button id="rollButton" class="roll-trigger" type="button">
|
||||||
<div class="hex-cap hex-cap-top"></div>
|
<span class="sr-only">Roll the oracle</span>
|
||||||
<!-- Hex Side Faces (6 sides) -->
|
</button>
|
||||||
<div class="hex-facet hex-facet-0"></div>
|
<span class="frame-corner frame-corner-tl" aria-hidden="true"></span>
|
||||||
<div class="hex-facet hex-facet-1"></div>
|
<span class="frame-corner frame-corner-tr" aria-hidden="true"></span>
|
||||||
<div class="hex-facet hex-facet-2"></div>
|
<span class="frame-corner frame-corner-bl" aria-hidden="true"></span>
|
||||||
<div class="hex-facet hex-facet-3"></div>
|
<span class="frame-corner frame-corner-br" aria-hidden="true"></span>
|
||||||
<div class="hex-facet hex-facet-4"></div>
|
|
||||||
<div class="hex-facet hex-facet-5"></div>
|
|
||||||
<!-- Hex Front Chamber Face with Viewport Window -->
|
|
||||||
<div class="hex-cap hex-cap-front">
|
|
||||||
<div class="fluid-window">
|
|
||||||
<div class="fluid-ambient"></div>
|
|
||||||
<!-- Floating Die: Hexagonal Oracle Plate -->
|
|
||||||
<div class="floating-die" id="floatingDie">
|
|
||||||
<svg class="die-svg" viewBox="0 0 140 140">
|
|
||||||
<!-- Outer Hexagon -->
|
|
||||||
<polygon points="70,6 125.4,38 125.4,102 70,134 14.6,102 14.6,38" class="die-face" />
|
|
||||||
<!-- Inner Hexagon Rim -->
|
|
||||||
<polygon points="70,12 119.8,40.7 119.8,99.3 70,128 20.2,99.3 20.2,40.7" class="die-inner" />
|
|
||||||
<!-- Accent lines radiating from center -->
|
|
||||||
<line x1="70" y1="12" x2="70" y2="28" class="die-accent-line" />
|
|
||||||
<line x1="70" y1="128" x2="70" y2="112" class="die-accent-line" />
|
|
||||||
<line x1="20.2" y1="40.7" x2="34" y2="48.7" class="die-accent-line" />
|
|
||||||
<line x1="119.8" y1="40.7" x2="106" y2="48.7" class="die-accent-line" />
|
|
||||||
<line x1="20.2" y1="99.3" x2="34" y2="91.3" class="die-accent-line" />
|
|
||||||
<line x1="119.8" y1="99.3" x2="106" y2="91.3" class="die-accent-line" />
|
|
||||||
</svg>
|
|
||||||
<div class="die-text-wrap">
|
|
||||||
<span class="die-text" id="dieText">CONCENTRATE<br>& ASK</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="fluid-ripple" id="fluidRipple"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Floor Shadow -->
|
|
||||||
<div class="ball-shadow" id="ballShadow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="stage-hint" id="stageHint">click the vessel to shake it</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Input Form -->
|
<!-- Input -->
|
||||||
<section class="prompt-section">
|
<section class="prompt-section">
|
||||||
<form id="askForm" class="prompt-form">
|
<form id="askForm" class="prompt-form" novalidate>
|
||||||
|
<label class="sr-only" for="questionInput">Your yes or no question</label>
|
||||||
<div class="prompt-input-wrap">
|
<div class="prompt-input-wrap">
|
||||||
<span class="prompt-symbol">></span>
|
<span class="prompt-symbol" aria-hidden="true">></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="questionInput"
|
id="questionInput"
|
||||||
|
name="question"
|
||||||
class="prompt-input"
|
class="prompt-input"
|
||||||
placeholder="ask the oracle a yes/no question..."
|
placeholder="ask the oracle a yes/no question…"
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
autocapitalize="sentences"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
aria-describedby="inputHint"
|
||||||
|
>
|
||||||
<button type="submit" id="submitBtn" class="btn btn-ask">
|
<button type="submit" id="submitBtn" class="btn btn-ask">
|
||||||
<span id="btnText">consult</span>
|
<span id="btnText">consult</span>
|
||||||
<span class="spinner" id="btnSpinner"></span>
|
<span class="spinner" id="btnSpinner" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<p id="inputHint" class="input-hint"><span id="charCount">0</span>/300 · <kbd>/</kbd> to focus · <kbd>enter</kbd> to ask</p>
|
||||||
|
|
||||||
<!-- Quick Suggestion Pills -->
|
|
||||||
<div class="quick-prompts">
|
<div class="quick-prompts">
|
||||||
<span class="quick-label">suggest:</span>
|
<span class="quick-label">suggest:</span>
|
||||||
<button class="quick-pill" data-q="Will this code deploy cleanly to production?">deploy cleanly?</button>
|
<button class="quick-pill" type="button" data-q="Will this code deploy cleanly to production?">deploy cleanly?</button>
|
||||||
<button class="quick-pill" data-q="Should I rewrite the stack in Rust?">rewrite in rust?</button>
|
<button class="quick-pill" type="button" data-q="Should I rewrite the stack in Rust?">rewrite in rust?</button>
|
||||||
<button class="quick-pill" data-q="Is tomorrow going to be a productive day?">productive day?</button>
|
<button class="quick-pill" type="button" data-q="Is tomorrow going to be a productive day?">productive day?</button>
|
||||||
<button class="quick-pill" data-q="Will my current venture succeed?">venture success?</button>
|
<button class="quick-pill" type="button" data-q="Will my current venture succeed?">venture success?</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Telemetry & Decision Details -->
|
<!-- Decision telemetry -->
|
||||||
<section class="telemetry-card" id="telemetryCard">
|
<section class="telemetry-card" id="telemetryCard">
|
||||||
<div class="telemetry-header">
|
<div class="telemetry-header">
|
||||||
<span class="telemetry-title">// JEV DECISION TELEMETRY</span>
|
<span class="telemetry-title">// JEV DECISION TELEMETRY</span>
|
||||||
<span class="telemetry-badge" id="telemetryBadge">IDLE</span>
|
<span class="telemetry-badge" id="telemetryBadge">IDLE</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="telemetry-body">
|
<div class="telemetry-body">
|
||||||
|
<p class="verdict" id="tVerdict" aria-live="polite">awaiting a question</p>
|
||||||
|
|
||||||
<div class="telemetry-row">
|
<div class="telemetry-row">
|
||||||
<span class="t-label">VERDICT:</span>
|
<span class="t-label">P(FAVORABLE)</span>
|
||||||
<span class="t-val" id="tVerdict">—</span>
|
|
||||||
</div>
|
|
||||||
<div class="telemetry-row">
|
|
||||||
<span class="t-label">P(FAVORABLE):</span>
|
|
||||||
<span class="t-val mono" id="tNoul">—</span>
|
<span class="t-val mono" id="tNoul">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="telemetry-meter-bar">
|
<div class="telemetry-meter-bar" role="presentation">
|
||||||
<div class="meter-fill" id="noulFill"></div>
|
<div class="meter-fill" id="noulFill"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="telemetry-grid">
|
<div class="telemetry-grid">
|
||||||
<div class="t-cell">
|
<div class="t-cell">
|
||||||
<span class="t-cell-label">CONFIDENCE</span>
|
<span class="t-cell-label">confidence</span>
|
||||||
<span class="t-cell-val" id="tConfidence">—</span>
|
<span class="t-cell-val" id="tConfidence">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="t-cell">
|
<div class="t-cell">
|
||||||
<span class="t-cell-label">LATENCY</span>
|
<span class="t-cell-label">latency</span>
|
||||||
<span class="t-cell-val" id="tLatency">—</span>
|
<span class="t-cell-val" id="tLatency">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="t-cell">
|
<div class="t-cell">
|
||||||
<span class="t-cell-label">ENGINE</span>
|
<span class="t-cell-label">engine</span>
|
||||||
<span class="t-cell-val">TypeSafe Jev</span>
|
<span class="t-cell-val">TypeSafe Jev</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="t-cell">
|
<div class="t-cell">
|
||||||
<span class="t-cell-label">EST. COST</span>
|
<span class="t-cell-label">est. cost</span>
|
||||||
<span class="t-cell-val" id="tCost">—</span>
|
<span class="t-cell-val" id="tCost">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Collapsible Raw JSON -->
|
|
||||||
<details class="telemetry-details">
|
<details class="telemetry-details">
|
||||||
<summary class="details-summary">> inspect raw decision payload</summary>
|
<summary class="details-summary">> inspect raw decision payload</summary>
|
||||||
<pre class="json-dump" id="jsonDump">// Awaiting query...</pre>
|
<pre class="json-dump" id="jsonDump">// awaiting query…</pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Query History -->
|
<!-- Query log -->
|
||||||
<section class="history-section">
|
<section class="history-section">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h2>query log</h2>
|
<h2>query log</h2>
|
||||||
<span id="historyCount" class="history-count">0 queries</span>
|
<span id="historyCount" class="history-count">0 queries</span>
|
||||||
</div>
|
</div>
|
||||||
<ul id="historyList" class="history-list">
|
<ul id="historyList" class="history-list" aria-live="polite"></ul>
|
||||||
<!-- Dynamic items -->
|
<p class="history-empty" id="historyEmpty">No questions asked yet. History stays in this browser.</p>
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<span>jev8ball · TypeSafe Jev on OpenRouter</span>
|
||||||
|
<span class="footer-note">Verdicts are synthetic. Do not use them for real decisions.</span>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<script src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
854
public/js/app.js
854
public/js/app.js
|
|
@ -1,468 +1,464 @@
|
||||||
/**
|
/**
|
||||||
* jev8ball // Neural Decision Oracle Client
|
* jev8ball client.
|
||||||
* Pure vanilla ES6+, HTML5 Canvas, Web Audio API
|
*
|
||||||
|
* Wires the DOM to the software-rendered 3D stage, the Jev decisions endpoint, the
|
||||||
|
* telemetry readout, the local query log, and the procedural audio. Everything is a
|
||||||
|
* small number of pure-ish helpers plus one async orchestration function so the
|
||||||
|
* failure modes stay readable.
|
||||||
*/
|
*/
|
||||||
|
import { OracleAudio } from './audio.js';
|
||||||
|
import { OracleBall } from './ball3d.js';
|
||||||
|
import { ParticleField } from './particles.js';
|
||||||
|
|
||||||
(function () {
|
const MAX_HISTORY = 25;
|
||||||
'use strict';
|
const QUESTION_LIMIT = 300;
|
||||||
|
const HISTORY_KEY = 'jev_history';
|
||||||
|
const MUTED_KEY = 'jev_muted';
|
||||||
|
const MOTION_KEY = 'jev_motion';
|
||||||
|
const SENTIMENTS = ['affirmative', 'negative', 'neutral'];
|
||||||
|
|
||||||
// ─── AMBIENT PARTICLES (katdrop style) ───
|
const TONE_HEX = {
|
||||||
const canvas = document.getElementById('particles');
|
affirmative: '#10b981',
|
||||||
const ctx = canvas.getContext('2d');
|
negative: '#ef4444',
|
||||||
let particles = [];
|
neutral: '#38bdf8',
|
||||||
|
offline: '#8f8f96',
|
||||||
|
idle: '#38bdf8'
|
||||||
|
};
|
||||||
|
|
||||||
function resizeCanvas() {
|
const STAGE_HINTS = {
|
||||||
canvas.width = window.innerWidth;
|
idle: 'click the vessel to shake it',
|
||||||
canvas.height = window.innerHeight;
|
rolling: 'the oracle is considering…',
|
||||||
|
ready: 'click again for another question',
|
||||||
|
error: 'the oracle could not be reached — try again'
|
||||||
|
};
|
||||||
|
|
||||||
|
const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const dom = {
|
||||||
|
canvas: byId('oracleCanvas'),
|
||||||
|
rollButton: byId('rollButton'),
|
||||||
|
stageHint: byId('stageHint'),
|
||||||
|
form: byId('askForm'),
|
||||||
|
input: byId('questionInput'),
|
||||||
|
submit: byId('submitBtn'),
|
||||||
|
soundToggle: byId('soundToggle'),
|
||||||
|
motionToggle: byId('motionToggle'),
|
||||||
|
backendStatus: byId('backendStatus'),
|
||||||
|
statusText: byId('statusText'),
|
||||||
|
charCount: byId('charCount'),
|
||||||
|
card: byId('telemetryCard'),
|
||||||
|
badge: byId('telemetryBadge'),
|
||||||
|
verdict: byId('tVerdict'),
|
||||||
|
noul: byId('tNoul'),
|
||||||
|
noulFill: byId('noulFill'),
|
||||||
|
confidence: byId('tConfidence'),
|
||||||
|
latency: byId('tLatency'),
|
||||||
|
cost: byId('tCost'),
|
||||||
|
json: byId('jsonDump'),
|
||||||
|
historyList: byId('historyList'),
|
||||||
|
historyCount: byId('historyCount'),
|
||||||
|
historyEmpty: byId('historyEmpty'),
|
||||||
|
toast: byId('toast'),
|
||||||
|
particles: byId('particles')
|
||||||
|
};
|
||||||
|
|
||||||
|
const audio = new OracleAudio(MUTED_KEY);
|
||||||
|
const particles = new ParticleField(dom.particles);
|
||||||
|
const ball = new OracleBall(dom.canvas, { reducedMotion: prefersReducedMotion() });
|
||||||
|
|
||||||
|
let busy = false;
|
||||||
|
let history = loadHistory();
|
||||||
|
let toastTimer = 0;
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedMotionPreference() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(MOTION_KEY);
|
||||||
|
if (stored === 'reduce') return true;
|
||||||
|
if (stored === 'full') return false;
|
||||||
|
} catch (e) {
|
||||||
|
/* storage unavailable: fall back to the media query */
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', resizeCanvas);
|
return prefersReducedMotion();
|
||||||
resizeCanvas();
|
}
|
||||||
|
|
||||||
class Particle {
|
function setHint(key) {
|
||||||
constructor() {
|
dom.stageHint.textContent = STAGE_HINTS[key] || STAGE_HINTS.idle;
|
||||||
this.reset();
|
}
|
||||||
}
|
|
||||||
reset() {
|
function showToast(message, kind = 'info', duration = 2400) {
|
||||||
this.x = Math.random() * canvas.width;
|
clearTimeout(toastTimer);
|
||||||
this.y = Math.random() * canvas.height;
|
dom.toast.textContent = message;
|
||||||
this.size = Math.random() * 1.5 + 0.5;
|
dom.toast.dataset.kind = kind;
|
||||||
this.speedX = (Math.random() - 0.5) * 0.25;
|
dom.toast.classList.add('active');
|
||||||
this.speedY = (Math.random() - 0.5) * 0.25;
|
toastTimer = setTimeout(() => dom.toast.classList.remove('active'), duration);
|
||||||
this.alpha = Math.random() * 0.35 + 0.1;
|
}
|
||||||
}
|
|
||||||
update() {
|
function setBusy(next) {
|
||||||
this.x += this.speedX;
|
busy = next;
|
||||||
this.y += this.speedY;
|
document.body.dataset.busy = String(next);
|
||||||
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) {
|
dom.submit.disabled = next;
|
||||||
this.reset();
|
dom.input.disabled = next;
|
||||||
|
dom.rollButton.disabled = next;
|
||||||
|
for (const pill of document.querySelectorAll('.quick-pill')) pill.disabled = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the log defensively: a stale or hand-edited value must never break the page. */
|
||||||
|
function loadHistory() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item) => item && typeof item.question === 'string' && typeof item.answer === 'string'
|
||||||
|
)
|
||||||
|
.slice(0, MAX_HISTORY)
|
||||||
|
.map((item) => ({
|
||||||
|
question: item.question,
|
||||||
|
answer: item.answer,
|
||||||
|
sentiment: SENTIMENTS.includes(item.sentiment) ? item.sentiment : 'neutral',
|
||||||
|
timestamp: typeof item.timestamp === 'string' ? item.timestamp : '',
|
||||||
|
offline: Boolean(item.offline)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistHistory() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
} catch (e) {
|
||||||
|
/* quota or private mode: keep the in-memory log */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
dom.historyList.innerHTML = '';
|
||||||
|
dom.historyCount.textContent = `${history.length} ${history.length === 1 ? 'query' : 'queries'}`;
|
||||||
|
dom.historyEmpty.hidden = history.length > 0;
|
||||||
|
|
||||||
|
history.forEach((item) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'history-item';
|
||||||
|
li.tabIndex = 0;
|
||||||
|
li.setAttribute('role', 'button');
|
||||||
|
li.title = 'Copy this fortune';
|
||||||
|
li.innerHTML =
|
||||||
|
'<div class="history-left">' +
|
||||||
|
`<div class="history-q">${escapeHtml(item.question)}</div>` +
|
||||||
|
`<div class="history-time">${escapeHtml(item.timestamp)}${item.offline ? ' · offline' : ''}</div>` +
|
||||||
|
'</div>' +
|
||||||
|
`<div class="history-pill ${item.sentiment}">${escapeHtml(item.answer)}</div>`;
|
||||||
|
|
||||||
|
const copy = () => copyFortune(item);
|
||||||
|
li.addEventListener('click', copy);
|
||||||
|
li.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
copy();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
draw() {
|
dom.historyList.appendChild(li);
|
||||||
ctx.fillStyle = `rgba(255, 255, 255, ${this.alpha})`;
|
});
|
||||||
ctx.fillRect(this.x, this.y, this.size, this.size);
|
}
|
||||||
}
|
|
||||||
|
async function copyFortune(item) {
|
||||||
|
const text = `Q: ${item.question}\nA: ${item.answer}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
audio.blip();
|
||||||
|
showToast('copied fortune to clipboard');
|
||||||
|
} catch (e) {
|
||||||
|
showToast('clipboard unavailable in this browser', 'warn');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initParticles(count = 45) {
|
function pushHistory(entry) {
|
||||||
particles = Array.from({ length: count }, () => new Particle());
|
history.unshift(entry);
|
||||||
|
if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
|
||||||
|
persistHistory();
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── telemetry ─── */
|
||||||
|
|
||||||
|
function toneFor(data) {
|
||||||
|
if (data.offline) return 'offline';
|
||||||
|
return SENTIMENTS.includes(data.sentiment) ? data.sentiment : 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPending() {
|
||||||
|
dom.card.dataset.tone = 'pending';
|
||||||
|
dom.badge.className = 'telemetry-badge';
|
||||||
|
dom.badge.textContent = 'QUERYING';
|
||||||
|
dom.verdict.textContent = 'the oracle is considering your question…';
|
||||||
|
dom.noul.textContent = '—';
|
||||||
|
dom.noulFill.className = 'meter-fill';
|
||||||
|
dom.noulFill.style.width = '0%';
|
||||||
|
dom.confidence.textContent = '—';
|
||||||
|
dom.latency.textContent = '—';
|
||||||
|
dom.cost.textContent = '—';
|
||||||
|
dom.json.textContent = '// awaiting decision…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentError(error) {
|
||||||
|
dom.card.dataset.tone = 'error';
|
||||||
|
dom.badge.className = 'telemetry-badge error';
|
||||||
|
dom.badge.textContent = 'ERROR';
|
||||||
|
dom.verdict.textContent = error && error.message ? error.message : 'the request failed';
|
||||||
|
dom.noul.textContent = '—';
|
||||||
|
dom.noulFill.className = 'meter-fill';
|
||||||
|
dom.noulFill.style.width = '0%';
|
||||||
|
dom.confidence.textContent = '—';
|
||||||
|
dom.latency.textContent = '—';
|
||||||
|
dom.cost.textContent = '—';
|
||||||
|
dom.json.textContent = '// no decision payload';
|
||||||
|
setHint('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(data) {
|
||||||
|
const tone = toneFor(data);
|
||||||
|
const offline = tone === 'offline';
|
||||||
|
|
||||||
|
ball.answer(data.answer || 'Cannot predict now', tone);
|
||||||
|
audio.reveal(offline ? 'neutral' : tone);
|
||||||
|
particles.setTone(TONE_HEX[tone]);
|
||||||
|
setHint('ready');
|
||||||
|
|
||||||
|
dom.card.dataset.tone = tone;
|
||||||
|
dom.badge.className = `telemetry-badge ${tone}`;
|
||||||
|
dom.badge.textContent = offline ? 'OFFLINE' : tone.toUpperCase();
|
||||||
|
dom.verdict.textContent = data.answer || '—';
|
||||||
|
|
||||||
|
// An offline verdict is a local hash guess: never dress it up as a calibrated one.
|
||||||
|
dom.noul.textContent = offline
|
||||||
|
? '—'
|
||||||
|
: typeof data.noul === 'number'
|
||||||
|
? data.noul.toFixed(2)
|
||||||
|
: '0.50';
|
||||||
|
dom.noulFill.className = `meter-fill ${offline ? '' : tone}`.trim();
|
||||||
|
dom.noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`;
|
||||||
|
dom.confidence.textContent = offline
|
||||||
|
? '—'
|
||||||
|
: `${Math.round((data.confidence || 0.8) * 100)}%`;
|
||||||
|
dom.latency.textContent = `${data.latency || 0}ms`;
|
||||||
|
dom.cost.textContent = offline
|
||||||
|
? '—'
|
||||||
|
: typeof data.cost === 'number' && data.cost > 0
|
||||||
|
? `$${data.cost.toFixed(6)}`
|
||||||
|
: '< $0.0001';
|
||||||
|
dom.json.textContent = stringify(data.raw || data);
|
||||||
|
|
||||||
|
pushHistory({
|
||||||
|
question: data.question,
|
||||||
|
answer: data.answer,
|
||||||
|
sentiment: offline ? 'neutral' : tone,
|
||||||
|
offline,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offline) showToast('offline guess — the oracle could not be reached', 'warn', 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return '// payload could not be serialised';
|
||||||
}
|
}
|
||||||
initParticles();
|
}
|
||||||
|
|
||||||
function animateParticles() {
|
/* ─── orchestration ─── */
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for (const p of particles) {
|
function updateCharCount() {
|
||||||
p.update();
|
dom.charCount.textContent = String(dom.input.value.length);
|
||||||
p.draw();
|
}
|
||||||
}
|
|
||||||
requestAnimationFrame(animateParticles);
|
function submitQuestion(raw) {
|
||||||
|
if (busy) return;
|
||||||
|
const question = String(raw || '').trim().slice(0, QUESTION_LIMIT);
|
||||||
|
if (!question) {
|
||||||
|
showToast('type a question first', 'warn');
|
||||||
|
dom.input.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(animateParticles);
|
dom.input.value = question;
|
||||||
|
updateCharCount();
|
||||||
|
ask(question);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── AUDIO SYNTHESIZER ───
|
async function ask(question) {
|
||||||
class OracleAudio {
|
setBusy(true);
|
||||||
constructor() {
|
setPending();
|
||||||
this.ctx = null;
|
setHint('rolling');
|
||||||
this.muted = localStorage.getItem('jev_muted') === 'true';
|
audio.roll(ball.rollDuration);
|
||||||
}
|
const roll = ball.roll();
|
||||||
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) {}
|
|
||||||
}
|
|
||||||
rollSound() {
|
|
||||||
if (this.muted) return;
|
|
||||||
try {
|
|
||||||
this.init();
|
|
||||||
if (!this.ctx) return;
|
|
||||||
// Whirring resonant gyroscopic roll sweep
|
|
||||||
const osc = this.ctx.createOscillator();
|
|
||||||
const gain = this.ctx.createGain();
|
|
||||||
osc.type = 'sawtooth';
|
|
||||||
osc.frequency.setValueAtTime(60, this.ctx.currentTime);
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(240, this.ctx.currentTime + 0.4);
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(95, this.ctx.currentTime + 1.1);
|
|
||||||
|
|
||||||
const filter = this.ctx.createBiquadFilter();
|
try {
|
||||||
filter.type = 'lowpass';
|
const response = await fetch('/api/ask', {
|
||||||
filter.frequency.setValueAtTime(450, this.ctx.currentTime);
|
method: 'POST',
|
||||||
filter.frequency.linearRampToValueAtTime(900, this.ctx.currentTime + 0.5);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
filter.frequency.linearRampToValueAtTime(300, this.ctx.currentTime + 1.1);
|
body: JSON.stringify({ question })
|
||||||
|
});
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
|
let payload = null;
|
||||||
gain.gain.linearRampToValueAtTime(0.16, this.ctx.currentTime + 0.35);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 1.15);
|
|
||||||
|
|
||||||
osc.connect(filter);
|
|
||||||
filter.connect(gain);
|
|
||||||
gain.connect(this.ctx.destination);
|
|
||||||
osc.start();
|
|
||||||
osc.stop(this.ctx.currentTime + 1.15);
|
|
||||||
} 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 ballShadow = document.getElementById('ballShadow');
|
|
||||||
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 = loadHistory();
|
|
||||||
|
|
||||||
// Read the stored log defensively: a stale, hand-edited or truncated value must never
|
|
||||||
// break the interface, so anything that is not a well formed entry is dropped.
|
|
||||||
function loadHistory() {
|
|
||||||
const sentiments = ['affirmative', 'negative', 'neutral'];
|
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('jev_history');
|
payload = await response.json();
|
||||||
if (!saved) return [];
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (!Array.isArray(parsed)) return [];
|
|
||||||
return parsed
|
|
||||||
.filter(
|
|
||||||
(item) => item && typeof item.question === 'string' && typeof item.answer === 'string'
|
|
||||||
)
|
|
||||||
.slice(0, 25)
|
|
||||||
.map((item) => ({
|
|
||||||
question: item.question,
|
|
||||||
answer: item.answer,
|
|
||||||
sentiment: sentiments.includes(item.sentiment) ? item.sentiment : 'neutral',
|
|
||||||
timestamp: typeof item.timestamp === 'string' ? item.timestamp : ''
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
payload = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SOUND TOGGLE ───
|
// A non-OK status is only usable when the backend shipped a fallback with it.
|
||||||
function updateSoundUI() {
|
if (!response.ok && !(payload && payload.fallback)) {
|
||||||
soundToggle.textContent = audio.muted ? 'sound: off' : 'sound: on';
|
throw new Error((payload && payload.error) || `oracle returned HTTP ${response.status}`);
|
||||||
}
|
|
||||||
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)';
|
|
||||||
}
|
}
|
||||||
});
|
if (!payload) throw new Error('oracle returned an empty response');
|
||||||
|
|
||||||
// Clicking hexagonal figure rolls it if input is non-empty, or prompts user
|
await roll;
|
||||||
ballWrapper.addEventListener('click', () => {
|
present(payload.fallback ? { ...payload.fallback, question } : payload);
|
||||||
if (isSubmitting) return;
|
} catch (error) {
|
||||||
if (questionInput.value.trim()) {
|
await roll; // let the shake finish so the interface never snaps
|
||||||
handleAsk(questionInput.value.trim());
|
console.error('oracle request failed:', error);
|
||||||
} else {
|
audio.error();
|
||||||
questionInput.focus();
|
presentError(error);
|
||||||
showToast('Type a question to consult Jev');
|
showToast((error && error.message) || 'request failed', 'error', 3200);
|
||||||
}
|
} finally {
|
||||||
});
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── STATUS CHECK ───
|
async function checkStatus() {
|
||||||
async function checkStatus() {
|
try {
|
||||||
|
const response = await fetch('/api/status', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
dom.backendStatus.classList.remove('online', 'offline', 'degraded');
|
||||||
|
dom.backendStatus.classList.add(data.hasKey === false ? 'degraded' : 'online');
|
||||||
|
dom.statusText.textContent = data.model || 'online';
|
||||||
|
dom.backendStatus.title = `key: ${data.keySource || 'unknown'} — click to re-check`;
|
||||||
|
} catch (error) {
|
||||||
|
dom.backendStatus.classList.remove('online', 'degraded');
|
||||||
|
dom.backendStatus.classList.add('offline');
|
||||||
|
dom.statusText.textContent = 'backend offline';
|
||||||
|
dom.backendStatus.title = 'Backend unreachable — click to retry';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── preferences ─── */
|
||||||
|
|
||||||
|
let reducedMotion = storedMotionPreference();
|
||||||
|
|
||||||
|
function applyMotion(reduced, { persist = true, announce = false } = {}) {
|
||||||
|
reducedMotion = reduced;
|
||||||
|
document.body.dataset.motion = reduced ? 'reduced' : 'full';
|
||||||
|
dom.motionToggle.textContent = reduced ? 'motion: reduced' : 'motion: full';
|
||||||
|
dom.motionToggle.setAttribute('aria-pressed', String(reduced));
|
||||||
|
ball.setReducedMotion(reduced);
|
||||||
|
if (persist) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
localStorage.setItem(MOTION_KEY, reduced ? 'reduce' : 'full');
|
||||||
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) {
|
} catch (e) {
|
||||||
backendStatus.classList.remove('online');
|
/* private mode: preference applies for this session only */
|
||||||
statusText.textContent = 'offline';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkStatus();
|
if (announce) showToast(reduced ? 'animation reduced' : 'animation restored');
|
||||||
backendStatus.addEventListener('click', () => {
|
}
|
||||||
showToast('Checking Jev backend status...');
|
|
||||||
|
/* ─── wiring ─── */
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
dom.form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submitQuestion(dom.input.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The vessel itself acts as the submit button for the current question.
|
||||||
|
dom.rollButton.addEventListener('click', () => submitQuestion(dom.input.value));
|
||||||
|
|
||||||
|
dom.input.addEventListener('input', updateCharCount);
|
||||||
|
|
||||||
|
for (const pill of document.querySelectorAll('.quick-pill')) {
|
||||||
|
pill.addEventListener('click', () => submitQuestion(pill.dataset.q));
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.soundToggle.addEventListener('click', () => {
|
||||||
|
const muted = audio.toggleMute();
|
||||||
|
dom.soundToggle.textContent = muted ? 'sound: off' : 'sound: on';
|
||||||
|
dom.soundToggle.setAttribute('aria-pressed', String(muted));
|
||||||
|
if (!muted) audio.blip();
|
||||||
|
showToast(muted ? 'audio muted' : 'audio enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.motionToggle.addEventListener('click', () =>
|
||||||
|
applyMotion(!reducedMotion, { announce: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
dom.backendStatus.addEventListener('click', () => {
|
||||||
|
dom.statusText.textContent = 'checking…';
|
||||||
checkStatus();
|
checkStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── QUICK PROMPTS ───
|
document.addEventListener('keydown', (event) => {
|
||||||
document.querySelectorAll('.quick-pill').forEach(pill => {
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
pill.addEventListener('click', () => {
|
const typing = event.target instanceof HTMLInputElement;
|
||||||
if (isSubmitting) return;
|
if (event.key === '/' && !typing) {
|
||||||
const q = pill.getAttribute('data-q');
|
event.preventDefault();
|
||||||
questionInput.value = q;
|
dom.input.focus();
|
||||||
handleAsk(q);
|
} else if (event.key === 'r' && !typing && !busy) {
|
||||||
});
|
event.preventDefault();
|
||||||
|
submitQuestion(dom.input.value);
|
||||||
|
} else if (event.key === 'Escape' && typing) {
|
||||||
|
event.target.blur();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── FORM SUBMISSION ───
|
document.addEventListener('visibilitychange', () => {
|
||||||
askForm.addEventListener('submit', (e) => {
|
if (document.hidden) particles.stop();
|
||||||
e.preventDefault();
|
else particles.start();
|
||||||
const q = questionInput.value.trim();
|
|
||||||
if (q) handleAsk(q);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleAsk(question) {
|
if (typeof window.matchMedia === 'function') {
|
||||||
if (isSubmitting) return;
|
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
isSubmitting = true;
|
const onChange = (event) => {
|
||||||
|
let overridden = false;
|
||||||
// UI Loading state
|
try {
|
||||||
submitBtn.disabled = true;
|
overridden = Boolean(localStorage.getItem(MOTION_KEY));
|
||||||
questionInput.disabled = true;
|
} catch (e) {
|
||||||
btnText.style.display = 'none';
|
overridden = false;
|
||||||
btnSpinner.style.display = 'inline-block';
|
|
||||||
|
|
||||||
// 1. Roll Hexagonal Figure in Place & Submerge Die
|
|
||||||
ballSphere.classList.add('rolling');
|
|
||||||
if (ballShadow) ballShadow.classList.add('rolling-shadow');
|
|
||||||
floatingDie.className = 'floating-die submerged';
|
|
||||||
audio.rollSound();
|
|
||||||
|
|
||||||
const minRollTime = new Promise(resolve => setTimeout(resolve, 1250));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 2. Call /api/ask
|
|
||||||
const apiCall = fetch('/api/ask', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ question })
|
|
||||||
}).then(async (response) => {
|
|
||||||
let payload = null;
|
|
||||||
try {
|
|
||||||
payload = await response.json();
|
|
||||||
} catch (e) {
|
|
||||||
payload = null;
|
|
||||||
}
|
|
||||||
// A non-OK status is only usable when the backend shipped a fallback with it.
|
|
||||||
if (!response.ok && !(payload && payload.fallback)) {
|
|
||||||
throw new Error(
|
|
||||||
(payload && payload.error) || `Oracle returned HTTP ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [_, result] = await Promise.all([minRollTime, apiCall]);
|
|
||||||
|
|
||||||
// Remove rolling classes
|
|
||||||
ballSphere.classList.remove('rolling');
|
|
||||||
if (ballShadow) ballShadow.classList.remove('rolling-shadow');
|
|
||||||
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error('Oracle returned an empty response.');
|
|
||||||
}
|
}
|
||||||
|
if (!overridden) applyMotion(event.matches, { persist: false });
|
||||||
if (result.error && !result.fallback) {
|
};
|
||||||
showToast(result.error || 'Decision failed');
|
if (query.addEventListener) query.addEventListener('change', onChange);
|
||||||
dieText.innerHTML = 'CANNOT<br>PREDICT';
|
else if (query.addListener) query.addListener(onChange);
|
||||||
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('rolling');
|
|
||||||
if (ballShadow) ballShadow.classList.remove('rolling-shadow');
|
|
||||||
ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
|
|
||||||
// Never show an affirmative prophecy for a request that failed.
|
|
||||||
dieText.innerHTML = 'CANNOT<br>PREDICT';
|
|
||||||
floatingDie.className = 'floating-die surfacing';
|
|
||||||
telemetryBadge.className = 'telemetry-badge neutral';
|
|
||||||
telemetryBadge.textContent = 'ERROR';
|
|
||||||
showToast(err && err.message ? err.message : 'Request failed. 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 = String(data.answer || 'Cannot predict now')
|
|
||||||
.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. An offline fallback is a local hash guess, so it is labelled and
|
|
||||||
// must not be shown with the calibrated probability and confidence of a real decision.
|
|
||||||
const offline = Boolean(data.offline);
|
|
||||||
const sentiment = offline ? 'neutral' : (data.sentiment || 'neutral');
|
|
||||||
telemetryBadge.className = `telemetry-badge ${offline ? 'offline' : sentiment}`;
|
|
||||||
telemetryBadge.textContent = offline ? 'OFFLINE' : sentiment.toUpperCase();
|
|
||||||
|
|
||||||
tVerdict.textContent = data.answer;
|
|
||||||
tNoul.textContent = offline ? '—' : (data.noul !== undefined ? data.noul.toFixed(2) : '0.50');
|
|
||||||
|
|
||||||
noulFill.className = `meter-fill ${sentiment}`;
|
|
||||||
noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`;
|
|
||||||
|
|
||||||
tConfidence.textContent = offline ? '—' : `${Math.round((data.confidence || 0.8) * 100)}%`;
|
|
||||||
tLatency.textContent = `${data.latency || 320}ms`;
|
|
||||||
tCost.textContent = offline ? '—' : 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' })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (offline) showToast('Offline guess — the oracle could not be reached');
|
|
||||||
}
|
|
||||||
|
|
||||||
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(value) {
|
|
||||||
return String(value ?? '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
applyMotion(storedMotionPreference(), { persist: false });
|
||||||
|
ball.attach();
|
||||||
|
particles.setTone(TONE_HEX.idle);
|
||||||
|
particles.start();
|
||||||
renderHistory();
|
renderHistory();
|
||||||
|
updateCharCount();
|
||||||
|
setHint('idle');
|
||||||
|
wire();
|
||||||
|
checkStatus();
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
init();
|
||||||
|
|
|
||||||
129
public/js/audio.js
Normal file
129
public/js/audio.js
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* Procedural audio: everything is synthesised with the Web Audio API, so the page
|
||||||
|
* ships no audio assets. Every method is a no-op when muted or when Web Audio is
|
||||||
|
* unavailable, which keeps callers free of guards.
|
||||||
|
*/
|
||||||
|
export class OracleAudio {
|
||||||
|
constructor(storageKey = 'jev_muted') {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
this.context = null;
|
||||||
|
this.muted = readMuted(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.context) {
|
||||||
|
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!Ctor) return null;
|
||||||
|
try {
|
||||||
|
this.context = new Ctor();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
this.muted = !this.muted;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey, String(this.muted));
|
||||||
|
} catch (e) {
|
||||||
|
/* storage may be unavailable; mute still applies for this session */
|
||||||
|
}
|
||||||
|
return this.muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One shaped sine/saw blip with an exponential decay. */
|
||||||
|
tone({ frequency, type = 'sine', duration = 0.12, gain = 0.12, delay = 0, sweepTo, filter }) {
|
||||||
|
if (this.muted) return;
|
||||||
|
const context = this.init();
|
||||||
|
if (!context) return;
|
||||||
|
try {
|
||||||
|
const start = context.currentTime + delay;
|
||||||
|
const osc = context.createOscillator();
|
||||||
|
const amp = context.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.setValueAtTime(frequency, start);
|
||||||
|
if (sweepTo) osc.frequency.exponentialRampToValueAtTime(sweepTo, start + duration);
|
||||||
|
amp.gain.setValueAtTime(gain, start);
|
||||||
|
amp.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||||
|
let tail = osc;
|
||||||
|
if (filter) {
|
||||||
|
const biquad = context.createBiquadFilter();
|
||||||
|
biquad.type = 'lowpass';
|
||||||
|
biquad.frequency.setValueAtTime(filter.from, start);
|
||||||
|
biquad.frequency.linearRampToValueAtTime(filter.to, start + duration);
|
||||||
|
osc.connect(biquad);
|
||||||
|
tail = biquad;
|
||||||
|
}
|
||||||
|
tail.connect(amp);
|
||||||
|
amp.connect(context.destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + duration + 0.02);
|
||||||
|
} catch (e) {
|
||||||
|
/* a failed blip must never break the interaction */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rising metallic sweep while the vessel is being shaken. */
|
||||||
|
roll(durationSeconds = 1.2) {
|
||||||
|
this.tone({
|
||||||
|
frequency: 58,
|
||||||
|
sweepTo: 132,
|
||||||
|
type: 'sawtooth',
|
||||||
|
duration: durationSeconds * 0.7,
|
||||||
|
gain: 0.07,
|
||||||
|
filter: { from: 420, to: 1100 }
|
||||||
|
});
|
||||||
|
this.tone({
|
||||||
|
frequency: 44,
|
||||||
|
sweepTo: 36,
|
||||||
|
type: 'triangle',
|
||||||
|
duration: durationSeconds,
|
||||||
|
gain: 0.05
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chord that lands with the verdict, coloured by sentiment. */
|
||||||
|
reveal(sentiment = 'affirmative') {
|
||||||
|
const chords = {
|
||||||
|
affirmative: [
|
||||||
|
[440.0, 0],
|
||||||
|
[554.37, 0.075],
|
||||||
|
[659.25, 0.15]
|
||||||
|
],
|
||||||
|
negative: [
|
||||||
|
[329.63, 0],
|
||||||
|
[277.18, 0.1],
|
||||||
|
[196.0, 0.22]
|
||||||
|
],
|
||||||
|
neutral: [
|
||||||
|
[392.0, 0],
|
||||||
|
[466.16, 0.11]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const notes = chords[sentiment] || chords.neutral;
|
||||||
|
const type = sentiment === 'negative' ? 'sawtooth' : 'triangle';
|
||||||
|
for (const [frequency, delay] of notes) {
|
||||||
|
this.tone({ frequency, type, duration: 0.3, gain: 0.11, delay });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short confirmation used when an answer is copied. */
|
||||||
|
blip() {
|
||||||
|
this.tone({ frequency: 880, type: 'square', duration: 0.06, gain: 0.05 });
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
this.tone({ frequency: 180, sweepTo: 120, type: 'square', duration: 0.18, gain: 0.05 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMuted(storageKey) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(storageKey) === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
710
public/js/ball3d.js
Normal file
710
public/js/ball3d.js
Normal file
|
|
@ -0,0 +1,710 @@
|
||||||
|
/**
|
||||||
|
* Software 3D renderer + animation state machine for the oracle vessel.
|
||||||
|
*
|
||||||
|
* Deliberately Canvas 2D rather than WebGL: the scene is a low-poly hexagonal prism
|
||||||
|
* plus a hexagonal plate, so painter's-algorithm depth sorting with per-face lighting
|
||||||
|
* costs almost nothing, and it renders identically on machines with no GL drivers
|
||||||
|
* (headless browsers, VMs, older devices) where a WebGL context cannot be created.
|
||||||
|
*
|
||||||
|
* The vessel is convex and the plate floats inside it, so sorting faces by camera
|
||||||
|
* distance and drawing back-to-front is sufficient: no z-buffer needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
affineFromThreePoints,
|
||||||
|
clamp,
|
||||||
|
dot,
|
||||||
|
hexToRgb,
|
||||||
|
lerp,
|
||||||
|
mat4LookAt,
|
||||||
|
mat4Mul,
|
||||||
|
mat4Perspective,
|
||||||
|
mat4RotateX,
|
||||||
|
mat4RotateY,
|
||||||
|
mat4RotateZ,
|
||||||
|
mat4Translate,
|
||||||
|
mixRgb,
|
||||||
|
normalize,
|
||||||
|
projectToScreen,
|
||||||
|
rgbCss,
|
||||||
|
smoothstep,
|
||||||
|
transformDir,
|
||||||
|
transformPoint
|
||||||
|
} from './math3d.js';
|
||||||
|
import { createPlate, createVessel } from './meshes.js';
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const LIGHT = normalize([-0.42, 0.7, 0.86]);
|
||||||
|
const VIEW = [0, 0, 1];
|
||||||
|
|
||||||
|
const BODY_BASE = hexToRgb('#191919');
|
||||||
|
const BODY_EDGE = hexToRgb('#2b2b30');
|
||||||
|
const CHAMBER_RGB = hexToRgb('#07070b');
|
||||||
|
const PLATE_BASE = hexToRgb('#1b1b1f');
|
||||||
|
const SPECULAR = [236, 245, 255];
|
||||||
|
|
||||||
|
const TONES = {
|
||||||
|
affirmative: hexToRgb('#10b981'),
|
||||||
|
negative: hexToRgb('#ef4444'),
|
||||||
|
neutral: hexToRgb('#38bdf8'),
|
||||||
|
offline: hexToRgb('#8f8f96'),
|
||||||
|
idle: hexToRgb('#38bdf8')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two slightly perturbed lights give the large faces a soft falloff without the cost
|
||||||
|
// of per-vertex normals. Faceted faces stay flat so the silhouette reads as machined.
|
||||||
|
const LIGHT_NEAR = normalize([LIGHT[0] - 0.35, LIGHT[1] + 0.32, LIGHT[2]]);
|
||||||
|
const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
|
||||||
|
const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||||
|
|
||||||
|
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
|
||||||
|
export class OracleBall {
|
||||||
|
constructor(canvas, { reducedMotion = false } = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.reducedMotion = reducedMotion;
|
||||||
|
this.vessel = createVessel();
|
||||||
|
this.plate = createPlate();
|
||||||
|
this.radiusCss = 172;
|
||||||
|
this.width = 0;
|
||||||
|
this.height = 0;
|
||||||
|
this.dpr = 1;
|
||||||
|
|
||||||
|
this.clock = 0;
|
||||||
|
this.lastFrame = 0;
|
||||||
|
this.running = false;
|
||||||
|
this.raf = 0;
|
||||||
|
this.visible = true;
|
||||||
|
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.rollStart = 0;
|
||||||
|
this.revealStart = 0;
|
||||||
|
this.holdTime = 0;
|
||||||
|
this.rollDuration = 1.25;
|
||||||
|
this.revealDuration = 0.9;
|
||||||
|
|
||||||
|
this.answerLines = ['CONCENTRATE', '& ASK'];
|
||||||
|
this.tone = 'idle';
|
||||||
|
this.pendingAnswer = null;
|
||||||
|
|
||||||
|
this.pointer = { x: 0, y: 0 };
|
||||||
|
this.tilt = { x: 0, y: 0 };
|
||||||
|
this.shake = 0;
|
||||||
|
this.glow = 0;
|
||||||
|
this.resolveRoll = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── public API ─── */
|
||||||
|
|
||||||
|
/** Starts the render loop, sizing, and pointer parallax. Safe to call twice. */
|
||||||
|
attach() {
|
||||||
|
if (this.running || !this.ctx) return;
|
||||||
|
this.running = true;
|
||||||
|
this._resize();
|
||||||
|
this._observeSize();
|
||||||
|
this._observeVisibility();
|
||||||
|
this.canvas.addEventListener('pointermove', this._onPointerMove);
|
||||||
|
this.canvas.addEventListener('pointerleave', this._onPointerLeave);
|
||||||
|
this.lastFrame = performance.now();
|
||||||
|
this.raf = requestAnimationFrame(this._frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
detach() {
|
||||||
|
this.running = false;
|
||||||
|
cancelAnimationFrame(this.raf);
|
||||||
|
this.canvas.removeEventListener('pointermove', this._onPointerMove);
|
||||||
|
this.canvas.removeEventListener('pointerleave', this._onPointerLeave);
|
||||||
|
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||||
|
if (this.intersectionObserver) this.intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
setReducedMotion(reduced) {
|
||||||
|
this.reducedMotion = reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once the shake animation has played. */
|
||||||
|
roll() {
|
||||||
|
this.phase = 'rolling';
|
||||||
|
this.rollStart = this.clock;
|
||||||
|
this.holdTime = 0;
|
||||||
|
this.glow = 0;
|
||||||
|
this.answerLines = null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
|
||||||
|
// leave the caller waiting forever.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.resolveRoll === resolve) {
|
||||||
|
this.resolveRoll = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, Math.round(this.rollDuration * 1000) + 500);
|
||||||
|
this.resolveRoll = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surfaces the die with the verdict text; ignored before a roll. */
|
||||||
|
answer(text, tone = 'neutral') {
|
||||||
|
const lines = wrapAnswer(text);
|
||||||
|
this.pendingAnswer = { lines, tone };
|
||||||
|
this.answerLines = lines;
|
||||||
|
this.tone = tone;
|
||||||
|
this.revealStart = this.clock;
|
||||||
|
this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
||||||
|
this.phase = 'revealing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the vessel to the resting state and clears the verdict. */
|
||||||
|
reset() {
|
||||||
|
this.answerLines = ['CONCENTRATE', '& ASK'];
|
||||||
|
this.tone = 'idle';
|
||||||
|
this.phase = 'idle';
|
||||||
|
this.glow = 0;
|
||||||
|
this.pendingAnswer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── wiring ─── */
|
||||||
|
|
||||||
|
_onPointerMove = (event) => {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
this.pointer.x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
|
||||||
|
this.pointer.y = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
_onPointerLeave = () => {
|
||||||
|
this.pointer.x = 0;
|
||||||
|
this.pointer.y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
_observeSize() {
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this._resize());
|
||||||
|
this.resizeObserver.observe(this.canvas.parentElement || this.canvas);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', this._onWindowResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWindowResize = () => this._resize();
|
||||||
|
|
||||||
|
_observeVisibility() {
|
||||||
|
if (typeof IntersectionObserver === 'function') {
|
||||||
|
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||||
|
this.visible = entries.some((entry) => entry.isIntersecting);
|
||||||
|
});
|
||||||
|
this.intersectionObserver.observe(this.canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resize() {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const size = Math.max(160, Math.min(rect.width || 344, 460));
|
||||||
|
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
this.width = size;
|
||||||
|
this.height = size;
|
||||||
|
this.canvas.width = Math.round(size * this.dpr);
|
||||||
|
this.canvas.height = Math.round(size * this.dpr);
|
||||||
|
this.radiusCss = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── per-frame simulation ─── */
|
||||||
|
|
||||||
|
_frame = (now) => {
|
||||||
|
if (!this.running) return;
|
||||||
|
const dt = Math.min((now - this.lastFrame) / 1000, 0.05);
|
||||||
|
this.lastFrame = now;
|
||||||
|
// Freeze the clock while hidden or scrolled away so nothing jumps on return.
|
||||||
|
if (this.visible && !document.hidden) {
|
||||||
|
this.clock += dt;
|
||||||
|
this._update(dt);
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
this.raf = requestAnimationFrame(this._frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
_update(dt) {
|
||||||
|
const ease = Math.min(1, dt * 6);
|
||||||
|
this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
|
||||||
|
this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
|
||||||
|
|
||||||
|
const elapsed = this.clock - this.rollStart;
|
||||||
|
|
||||||
|
if (this.phase === 'rolling') {
|
||||||
|
this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
|
||||||
|
this.shake = this.reducedMotion
|
||||||
|
? 0
|
||||||
|
: (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
|
||||||
|
this.liquidSlosh = this.reducedMotion ? 0 : 1;
|
||||||
|
if (elapsed >= this.rollDuration) {
|
||||||
|
this.holdTime += dt;
|
||||||
|
if (this.resolveRoll) {
|
||||||
|
const resolve = this.resolveRoll;
|
||||||
|
this.resolveRoll = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.platePose = this._tumblePose(elapsed);
|
||||||
|
this.answerAlpha = 0;
|
||||||
|
this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
|
||||||
|
} else if (this.phase === 'revealing') {
|
||||||
|
const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
|
||||||
|
const settle = smoothstep(0, 0.72, t);
|
||||||
|
const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
||||||
|
// Unwind the tumble to zero so the plate always lands face-on to the camera.
|
||||||
|
this.platePose = {
|
||||||
|
rx: from.rx * (1 - settle),
|
||||||
|
ry: from.ry * (1 - settle),
|
||||||
|
rz: from.rz * (1 - settle),
|
||||||
|
y: lerp(from.y, 0.06, settle)
|
||||||
|
};
|
||||||
|
this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
|
||||||
|
this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
|
||||||
|
this.answerAlpha = smoothstep(0.5, 1, t);
|
||||||
|
this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
|
||||||
|
if (t >= 1) this.phase = 'ready';
|
||||||
|
} else {
|
||||||
|
const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
|
||||||
|
this.platePose = {
|
||||||
|
rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
|
||||||
|
ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
|
||||||
|
rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
|
||||||
|
y: 0.02 + bob
|
||||||
|
};
|
||||||
|
this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
|
||||||
|
this.answerAlpha = this.answerLines ? 1 : 0;
|
||||||
|
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Churning pose used while the vessel is being shaken. */
|
||||||
|
_tumblePose(elapsed) {
|
||||||
|
const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
|
||||||
|
if (this.reducedMotion) {
|
||||||
|
return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rx: elapsed * 5.2,
|
||||||
|
ry: elapsed * 3.05,
|
||||||
|
rz: elapsed * 2.15,
|
||||||
|
y: lerp(0.02, -0.5, dive)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get toneRgb() {
|
||||||
|
return TONES[this.tone] || TONES.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── rendering ─── */
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const { ctx } = this;
|
||||||
|
const w = this.width;
|
||||||
|
const h = this.height;
|
||||||
|
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
|
||||||
|
// Camera sits far enough that the vessel fills ~72% of the canvas height,
|
||||||
|
// leaving room for the shake, the rim glow, and the cast shadow.
|
||||||
|
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
|
||||||
|
const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
|
||||||
|
this.viewProj = mat4Mul(proj, view);
|
||||||
|
|
||||||
|
const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
|
||||||
|
const shakeZ = this.shake * 0.5;
|
||||||
|
const vesselModel = mat4Mul(
|
||||||
|
mat4RotateZ(shakeZ),
|
||||||
|
mat4Mul(
|
||||||
|
mat4RotateY(this.tilt.y + this.shake * 0.28),
|
||||||
|
mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const plateModel = mat4Mul(
|
||||||
|
mat4Translate(0, pose.y, 0),
|
||||||
|
mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
|
||||||
|
);
|
||||||
|
|
||||||
|
this._drawGroundGlow(w, h);
|
||||||
|
this._drawContactShadow(w, h);
|
||||||
|
|
||||||
|
const vessel = this._projectMesh(this.vessel.mesh, vesselModel);
|
||||||
|
const plate = this._projectMesh(this.plate.mesh, plateModel);
|
||||||
|
const windowPoly = this._projectWindow(vesselModel);
|
||||||
|
const liquid = windowPoly
|
||||||
|
? this._liquidGeometry(vesselModel, windowPoly)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const tone = this.toneRgb;
|
||||||
|
|
||||||
|
// 1. shell behind the chamber: back cap first, then the side facets
|
||||||
|
for (const face of vessel.faces) {
|
||||||
|
if (face.kind === 'ring') continue;
|
||||||
|
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
|
||||||
|
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. chamber contents, clipped to the window opening
|
||||||
|
if (windowPoly && liquid) {
|
||||||
|
ctx.save();
|
||||||
|
this._clipPolygon(windowPoly);
|
||||||
|
this._fillLiquid(liquid.points, tone);
|
||||||
|
for (const face of plate.faces) {
|
||||||
|
const base = face.kind === 'answer' ? PLATE_BASE : BODY_EDGE;
|
||||||
|
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
|
||||||
|
}
|
||||||
|
if (this.answerLines && this.answerAlpha > 0.01) {
|
||||||
|
this._drawAnswerText(plate, windowPoly);
|
||||||
|
}
|
||||||
|
this._overlayLiquid(liquid, tone);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. the front frame, nearest to the camera, drawn last
|
||||||
|
for (const face of vessel.faces) {
|
||||||
|
if (face.kind !== 'ring') continue;
|
||||||
|
this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowPoly) this._strokeWindow(windowPoly, tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projects a mesh and sorts its faces back-to-front. */
|
||||||
|
_projectMesh(mesh, model) {
|
||||||
|
const mvp = mat4Mul(this.viewProj, model);
|
||||||
|
const screen = [];
|
||||||
|
const depth = [];
|
||||||
|
for (const vertex of mesh.vertices) {
|
||||||
|
const clip = transformPoint(mvp, vertex);
|
||||||
|
depth.push(clip[3]);
|
||||||
|
screen.push(projectToScreen(clip, this.width, this.height));
|
||||||
|
}
|
||||||
|
const faces = [];
|
||||||
|
for (const face of mesh.faces) {
|
||||||
|
if (face.indices.some((index) => !screen[index])) continue;
|
||||||
|
let sum = 0;
|
||||||
|
for (const index of face.indices) sum += depth[index];
|
||||||
|
faces.push({
|
||||||
|
kind: face.kind,
|
||||||
|
smooth: face.smooth,
|
||||||
|
normal: transformDir(model, face.normal),
|
||||||
|
depth: sum / face.indices.length,
|
||||||
|
points: face.indices.map((index) => screen[index])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
faces.sort((a, b) => b.depth - a.depth);
|
||||||
|
return { faces, screen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projects the hexagonal opening onto the screen. */
|
||||||
|
_projectWindow(vesselModel) {
|
||||||
|
const mvp = mat4Mul(this.viewProj, vesselModel);
|
||||||
|
const { points, z } = this.vessel.window;
|
||||||
|
const projected = points.map(([x, y]) =>
|
||||||
|
projectToScreen(transformPoint(mvp, [x, y, z]), this.width, this.height)
|
||||||
|
);
|
||||||
|
return projected.every(Boolean) ? projected : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen-space liquid body: the window clipped to everything below the surface. */
|
||||||
|
_liquidGeometry(vesselModel, windowPoly) {
|
||||||
|
const mvp = mat4Mul(this.viewProj, vesselModel);
|
||||||
|
const { radius, z } = this.vessel.window;
|
||||||
|
const slosh = this.liquidSlosh || 0;
|
||||||
|
const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
|
||||||
|
const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
|
||||||
|
const left = projectToScreen(
|
||||||
|
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
|
||||||
|
this.width,
|
||||||
|
this.height
|
||||||
|
);
|
||||||
|
const right = projectToScreen(
|
||||||
|
transformPoint(mvp, [radius * 1.35, level - tilt, z * 0.2]),
|
||||||
|
this.width,
|
||||||
|
this.height
|
||||||
|
);
|
||||||
|
if (!left || !right) return null;
|
||||||
|
return { points: clipPolygonBelow(windowPoly, left, right), surface: [left, right] };
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawGroundGlow(w, h) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h * 0.52;
|
||||||
|
const radius = w * 0.62;
|
||||||
|
const intensity = 0.3 + this.glow * 0.9;
|
||||||
|
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||||
|
gradient.addColorStop(0, rgbCss(this.toneRgb, Math.min(0.34, 0.2 * intensity)));
|
||||||
|
gradient.addColorStop(0.5, rgbCss(this.toneRgb, Math.min(0.12, 0.07 * intensity)));
|
||||||
|
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawContactShadow(w, h) {
|
||||||
|
const { ctx } = this;
|
||||||
|
const lift = (this.platePose ? 0 : 0) + 0.02;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
|
||||||
|
ctx.scale(1, 0.2);
|
||||||
|
const radius = w * 0.31;
|
||||||
|
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||||||
|
gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
|
||||||
|
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, radius, 0, TAU);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── shading & paint helpers ─── */
|
||||||
|
|
||||||
|
/** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
|
||||||
|
_shade(base, normal, light, tone, rimStrength) {
|
||||||
|
const diffuse = Math.max(0, dot(normal, light));
|
||||||
|
const half = normalize([light[0], light[1], light[2] + 1]);
|
||||||
|
const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
|
||||||
|
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
|
||||||
|
const lit = 0.13 + diffuse * 0.95;
|
||||||
|
const rgb = [
|
||||||
|
base[0] * lit + SPECULAR[0] * spec,
|
||||||
|
base[1] * lit + SPECULAR[1] * spec,
|
||||||
|
base[2] * lit + SPECULAR[2] * spec
|
||||||
|
];
|
||||||
|
return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
|
||||||
|
_faceStyle(face, base, tone, rimStrength) {
|
||||||
|
if (!face.smooth) {
|
||||||
|
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
|
||||||
|
}
|
||||||
|
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
|
||||||
|
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
|
||||||
|
const bounds = boundsOf(face.points);
|
||||||
|
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
|
||||||
|
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
|
||||||
|
gradient.addColorStop(1, rgbCss(far));
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pathFromPoints(points) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clipPolygon(points) {
|
||||||
|
this._pathFromPoints(points);
|
||||||
|
this.ctx.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
_fillFace(face, style) {
|
||||||
|
if (face.points.length < 3) return;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
this._pathFromPoints(face.points);
|
||||||
|
ctx.fillStyle = style;
|
||||||
|
ctx.fill();
|
||||||
|
// Hairline edge keeps the facets reading as machined metal rather than flat shapes.
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── liquid ─── */
|
||||||
|
|
||||||
|
_fillLiquid(points, tone) {
|
||||||
|
if (points.length < 3) return;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const bounds = boundsOf(points);
|
||||||
|
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
|
||||||
|
gradient.addColorStop(0, rgbCss(mixRgb(hexToRgb('#050a16'), tone, 0.24)));
|
||||||
|
gradient.addColorStop(0.6, rgbCss(hexToRgb('#03060d')));
|
||||||
|
gradient.addColorStop(1, rgbCss(mixRgb(hexToRgb('#010205'), tone, 0.12)));
|
||||||
|
this._pathFromPoints(points);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
_overlayLiquid(liquid, tone) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const points = liquid.points;
|
||||||
|
if (points.length >= 3) {
|
||||||
|
const bounds = boundsOf(points);
|
||||||
|
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
|
||||||
|
gradient.addColorStop(0, rgbCss(tone, 0.22));
|
||||||
|
gradient.addColorStop(1, rgbCss(tone, 0.03));
|
||||||
|
this._pathFromPoints(points);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
const [a, b] = liquid.surface;
|
||||||
|
// drifting specular streaks just under the surface
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
const t = (this.clock * 0.33 + i / 3) % 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(lerp(a.x, b.x, t), lerp(a.y, b.y, t) + 3, 15, 3, 0, 0, TAU);
|
||||||
|
ctx.fillStyle = rgbCss(SPECULAR, 0.05);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
ctx.lineTo(b.x, b.y);
|
||||||
|
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.45), 0.55);
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
_strokeWindow(windowPoly, tone) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const bounds = boundsOf(windowPoly);
|
||||||
|
ctx.save();
|
||||||
|
this._pathFromPoints(windowPoly);
|
||||||
|
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.3), Math.min(0.75, 0.3 + this.glow * 0.35));
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
this._pathFromPoints(windowPoly);
|
||||||
|
ctx.clip();
|
||||||
|
const cx = (bounds.minX + bounds.maxX) / 2;
|
||||||
|
const cy = (bounds.minY + bounds.maxY) / 2;
|
||||||
|
const radius = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) / 2;
|
||||||
|
const vignette = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius);
|
||||||
|
vignette.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
vignette.addColorStop(1, 'rgba(0,0,0,0.7)');
|
||||||
|
ctx.fillStyle = vignette;
|
||||||
|
ctx.fillRect(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── verdict text mapped onto the die face ─── */
|
||||||
|
|
||||||
|
_drawAnswerText(plateView) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const face = plateView.faces.find((candidate) => candidate.kind === 'answer');
|
||||||
|
if (!face || face.points.length < 6) return;
|
||||||
|
if (dot(face.normal, VIEW) < 0.3) return;
|
||||||
|
|
||||||
|
// The face keeps the plate's local vertex order, so three corners are enough to
|
||||||
|
// solve the affine transform from plate-local space into screen space.
|
||||||
|
const corners = [0, 3, 5].map((index) => ({
|
||||||
|
local: this.plate.points[index],
|
||||||
|
screen: [face.points[index].x, face.points[index].y]
|
||||||
|
}));
|
||||||
|
const matrix = affineFromThreePoints(corners[0], corners[1], corners[2]);
|
||||||
|
if (!matrix) return;
|
||||||
|
|
||||||
|
const lines = this.answerLines;
|
||||||
|
if (!lines || !lines.length) return;
|
||||||
|
const radius = this.plate.radius;
|
||||||
|
const usableWidth = radius * 1.5;
|
||||||
|
const maxChars = Math.max(4, ...lines.map((line) => line.length));
|
||||||
|
let fontSize = clamp(
|
||||||
|
Math.min(usableWidth / (maxChars * 0.62), (radius * 1.05) / lines.length),
|
||||||
|
0.05,
|
||||||
|
0.2
|
||||||
|
);
|
||||||
|
const pixelsPerUnit = Math.hypot(matrix[0], matrix[1]) / this.dpr;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.setTransform(
|
||||||
|
this.dpr * matrix[0],
|
||||||
|
this.dpr * matrix[1],
|
||||||
|
this.dpr * matrix[2],
|
||||||
|
this.dpr * matrix[3],
|
||||||
|
this.dpr * matrix[4],
|
||||||
|
this.dpr * matrix[5]
|
||||||
|
);
|
||||||
|
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Shrink to fit if the monospace metrics run wide of the plate.
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
ctx.font = `700 ${fontSize}px ${FONT_STACK}`;
|
||||||
|
const widest = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
||||||
|
if (widest <= usableWidth) break;
|
||||||
|
fontSize *= usableWidth / widest;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.globalAlpha = this.answerAlpha;
|
||||||
|
ctx.fillStyle = '#f2f7ff';
|
||||||
|
ctx.shadowColor = rgbCss(this.toneRgb, 0.85);
|
||||||
|
ctx.shadowBlur = Math.max(2, fontSize * pixelsPerUnit * 0.45);
|
||||||
|
const lineHeight = fontSize * 1.3;
|
||||||
|
const top = -((lines.length - 1) * lineHeight) / 2;
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
ctx.fillText(line, 0, top + index * lineHeight);
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── helpers (exported for tests) ─── */
|
||||||
|
|
||||||
|
function boundsOf(points) {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const point of points) {
|
||||||
|
if (point.x < minX) minX = point.x;
|
||||||
|
if (point.x > maxX) maxX = point.x;
|
||||||
|
if (point.y < minY) minY = point.y;
|
||||||
|
if (point.y > maxY) maxY = point.y;
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sutherland–Hodgman clip of a convex polygon against the half-plane below the line
|
||||||
|
* a→b. Screen space has y pointing down, so "below the surface line" is a positive
|
||||||
|
* cross product.
|
||||||
|
*/
|
||||||
|
export function clipPolygonBelow(points, a, b) {
|
||||||
|
const out = [];
|
||||||
|
const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||||
|
for (let i = 0; i < points.length; i += 1) {
|
||||||
|
const current = points[i];
|
||||||
|
const next = points[(i + 1) % points.length];
|
||||||
|
const currentSide = side(current);
|
||||||
|
const nextSide = side(next);
|
||||||
|
if (currentSide >= 0) out.push(current);
|
||||||
|
if (currentSide >= 0 !== nextSide >= 0) {
|
||||||
|
const t = currentSide / (currentSide - nextSide);
|
||||||
|
out.push({
|
||||||
|
x: current.x + (next.x - current.x) * t,
|
||||||
|
y: current.y + (next.y - current.y) * t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper-cases a verdict and wraps it into at most three plate-sized lines. */
|
||||||
|
export function wrapAnswer(text) {
|
||||||
|
const words = String(text || 'Cannot predict now')
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!words.length) return ['CANNOT', 'PREDICT'];
|
||||||
|
const lines = [];
|
||||||
|
let current = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const candidate = current ? `${current} ${word}` : word;
|
||||||
|
if (!current || candidate.length <= 11) {
|
||||||
|
current = candidate;
|
||||||
|
} else {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) lines.push(current);
|
||||||
|
if (lines.length > 3) {
|
||||||
|
return [...lines.slice(0, 2), lines.slice(2).join(' ').slice(0, 14)];
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
246
public/js/math3d.js
Normal file
246
public/js/math3d.js
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* Minimal 3D math: vectors are [x, y, z] arrays, matrices are column-major
|
||||||
|
* Float32Array(16) (m[col * 4 + row]), matching the usual graphics convention.
|
||||||
|
*
|
||||||
|
* Everything here is pure so it can be unit tested outside a browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function vec3(x = 0, y = 0, z = 0) {
|
||||||
|
return [x, y, z];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function add(a, b) {
|
||||||
|
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sub(a, b) {
|
||||||
|
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mulScalar(a, s) {
|
||||||
|
return [a[0] * s, a[1] * s, a[2] * s];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dot(a, b) {
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cross(a, b) {
|
||||||
|
return [
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function length(a) {
|
||||||
|
return Math.hypot(a[0], a[1], a[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalize(a) {
|
||||||
|
const len = length(a);
|
||||||
|
return len === 0 ? [0, 0, 0] : [a[0] / len, a[1] / len, a[2] / len];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reflection of incident vector `d` about normal `n`. */
|
||||||
|
export function reflect(d, n) {
|
||||||
|
const k = 2 * dot(n, d);
|
||||||
|
return [d[0] - k * n[0], d[1] - k * n[1], d[2] - k * n[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value, min, max) {
|
||||||
|
return value < min ? min : value > max ? max : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smooth 0..1 ramp, used for animation easing. */
|
||||||
|
export function smoothstep(edge0, edge1, x) {
|
||||||
|
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4Identity() {
|
||||||
|
const m = new Float32Array(16);
|
||||||
|
m[0] = m[5] = m[10] = m[15] = 1;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a ∘ b (b applied first, then a). */
|
||||||
|
export function mat4Mul(a, b) {
|
||||||
|
const out = new Float32Array(16);
|
||||||
|
for (let col = 0; col < 4; col += 1) {
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let k = 0; k < 4; k += 1) {
|
||||||
|
sum += a[k * 4 + row] * b[col * 4 + k];
|
||||||
|
}
|
||||||
|
out[col * 4 + row] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4Translate(x, y, z) {
|
||||||
|
const m = mat4Identity();
|
||||||
|
m[12] = x;
|
||||||
|
m[13] = y;
|
||||||
|
m[14] = z;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4Scale(x, y = x, z = x) {
|
||||||
|
const m = mat4Identity();
|
||||||
|
m[0] = x;
|
||||||
|
m[5] = y;
|
||||||
|
m[10] = z;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4RotateX(rad) {
|
||||||
|
const m = mat4Identity();
|
||||||
|
const c = Math.cos(rad);
|
||||||
|
const s = Math.sin(rad);
|
||||||
|
m[5] = c;
|
||||||
|
m[6] = s;
|
||||||
|
m[9] = -s;
|
||||||
|
m[10] = c;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4RotateY(rad) {
|
||||||
|
const m = mat4Identity();
|
||||||
|
const c = Math.cos(rad);
|
||||||
|
const s = Math.sin(rad);
|
||||||
|
m[0] = c;
|
||||||
|
m[2] = -s;
|
||||||
|
m[8] = s;
|
||||||
|
m[10] = c;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4RotateZ(rad) {
|
||||||
|
const m = mat4Identity();
|
||||||
|
const c = Math.cos(rad);
|
||||||
|
const s = Math.sin(rad);
|
||||||
|
m[0] = c;
|
||||||
|
m[1] = s;
|
||||||
|
m[4] = -s;
|
||||||
|
m[5] = c;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4Perspective(fovY, aspect, near, far) {
|
||||||
|
const f = 1 / Math.tan(fovY / 2);
|
||||||
|
const m = new Float32Array(16);
|
||||||
|
m[0] = f / aspect;
|
||||||
|
m[5] = f;
|
||||||
|
m[10] = (far + near) / (near - far);
|
||||||
|
m[11] = -1;
|
||||||
|
m[14] = (2 * far * near) / (near - far);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mat4LookAt(eye, target, up) {
|
||||||
|
const zAxis = normalize(sub(eye, target));
|
||||||
|
const xAxis = normalize(cross(up, zAxis));
|
||||||
|
const yAxis = cross(zAxis, xAxis);
|
||||||
|
const m = mat4Identity();
|
||||||
|
m[0] = xAxis[0];
|
||||||
|
m[1] = yAxis[0];
|
||||||
|
m[2] = zAxis[0];
|
||||||
|
m[4] = xAxis[1];
|
||||||
|
m[5] = yAxis[1];
|
||||||
|
m[6] = zAxis[1];
|
||||||
|
m[8] = xAxis[2];
|
||||||
|
m[9] = yAxis[2];
|
||||||
|
m[10] = zAxis[2];
|
||||||
|
m[12] = -dot(xAxis, eye);
|
||||||
|
m[13] = -dot(yAxis, eye);
|
||||||
|
m[14] = -dot(zAxis, eye);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transforms a point (w = 1) and returns the full homogeneous result. */
|
||||||
|
export function transformPoint(m, p) {
|
||||||
|
return [
|
||||||
|
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||||
|
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||||
|
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
|
||||||
|
m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transforms a direction (w = 0, ignores translation), then normalizes. */
|
||||||
|
export function transformDir(m, v) {
|
||||||
|
return normalize([
|
||||||
|
m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
|
||||||
|
m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
|
||||||
|
m[2] * v[0] + m[6] * v[1] + m[10] * v[2]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects a homogeneous point into CSS-pixel screen space.
|
||||||
|
* Returns null when the point sits on or behind the camera plane.
|
||||||
|
*/
|
||||||
|
export function projectToScreen(clip, width, height) {
|
||||||
|
const w = clip[3];
|
||||||
|
if (w <= 1e-6) return null;
|
||||||
|
const ndcX = clip[0] / w;
|
||||||
|
const ndcY = clip[1] / w;
|
||||||
|
return {
|
||||||
|
x: (ndcX * 0.5 + 0.5) * width,
|
||||||
|
y: (0.5 - ndcY * 0.5) * height,
|
||||||
|
depth: clip[2] / w
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solves the 2D affine transform taking local (x, y) to screen (X, Y) from three
|
||||||
|
* point correspondences, returned as [a, b, c, d, e, f] for setTransform.
|
||||||
|
* Used to lay the answer text onto the projected die face.
|
||||||
|
*/
|
||||||
|
export function affineFromThreePoints(p1, p2, p3) {
|
||||||
|
const [x1, y1] = p1.local;
|
||||||
|
const [x2, y2] = p2.local;
|
||||||
|
const [x3, y3] = p3.local;
|
||||||
|
const den = (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
|
||||||
|
if (Math.abs(den) < 1e-9) return null;
|
||||||
|
const s1 = p1.screen;
|
||||||
|
const s2 = p2.screen;
|
||||||
|
const s3 = p3.screen;
|
||||||
|
const a = ((s1[0] - s3[0]) * (y2 - y3) - (s2[0] - s3[0]) * (y1 - y3)) / den;
|
||||||
|
const c = ((x1 - x3) * (s2[0] - s3[0]) - (x2 - x3) * (s1[0] - s3[0])) / den;
|
||||||
|
const b = ((s1[1] - s3[1]) * (y2 - y3) - (s2[1] - s3[1]) * (y1 - y3)) / den;
|
||||||
|
const d = ((x1 - x3) * (s2[1] - s3[1]) - (x2 - x3) * (s1[1] - s3[1])) / den;
|
||||||
|
return [a, b, c, d, s1[0] - a * x1 - c * y1, s1[1] - b * x1 - d * y1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mixes two [r, g, b] colours (0..255) by t. */
|
||||||
|
export function mixRgb(from, to, t) {
|
||||||
|
return [
|
||||||
|
from[0] + (to[0] - from[0]) * t,
|
||||||
|
from[1] + (to[1] - from[1]) * t,
|
||||||
|
from[2] + (to[2] - from[2]) * t
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rgbCss(rgb, alpha = 1) {
|
||||||
|
const r = Math.round(clamp(rgb[0], 0, 255));
|
||||||
|
const g = Math.round(clamp(rgb[1], 0, 255));
|
||||||
|
const b = Math.round(clamp(rgb[2], 0, 255));
|
||||||
|
return alpha >= 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses '#rrggbb' into [r, g, b]. */
|
||||||
|
export function hexToRgb(hex) {
|
||||||
|
const value = hex.replace('#', '');
|
||||||
|
return [
|
||||||
|
parseInt(value.slice(0, 2), 16),
|
||||||
|
parseInt(value.slice(2, 4), 16),
|
||||||
|
parseInt(value.slice(4, 6), 16)
|
||||||
|
];
|
||||||
|
}
|
||||||
101
public/js/meshes.js
Normal file
101
public/js/meshes.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* Mesh builders for the oracle vessel. Pure data in, pure data out: every mesh is
|
||||||
|
* `{ vertices: [[x, y, z]], faces: [{ indices, normal, kind, smooth }] }`, which the
|
||||||
|
* renderer walks generically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Point-up hexagon vertices (matches the CSS clip-path used elsewhere). */
|
||||||
|
export function hexagon(radius, rotation = Math.PI / 2) {
|
||||||
|
return Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const angle = rotation + (i * Math.PI) / 3;
|
||||||
|
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function centroid(vertices, indices) {
|
||||||
|
const sum = [0, 0, 0];
|
||||||
|
for (const index of indices) {
|
||||||
|
const v = vertices[index];
|
||||||
|
sum[0] += v[0];
|
||||||
|
sum[1] += v[1];
|
||||||
|
sum[2] += v[2];
|
||||||
|
}
|
||||||
|
const n = indices.length || 1;
|
||||||
|
return [sum[0] / n, sum[1] / n, sum[2] / n];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer shell: a hexagonal prism whose front cap has a hexagonal opening so the
|
||||||
|
* liquid chamber inside is visible through the frame.
|
||||||
|
*/
|
||||||
|
export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.63 } = {}) {
|
||||||
|
const half = depth / 2;
|
||||||
|
const inner = hexagon(windowRadius);
|
||||||
|
const vertices = [];
|
||||||
|
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
||||||
|
const points = hexagon(outerRadius);
|
||||||
|
|
||||||
|
const outerBack = points.map(([x, y]) => push(x, y, -half));
|
||||||
|
const outerFront = points.map(([x, y]) => push(x, y, half));
|
||||||
|
const innerFront = inner.map(([x, y]) => push(x, y, half));
|
||||||
|
|
||||||
|
const faces = [
|
||||||
|
{ indices: [...outerBack].reverse(), normal: [0, 0, -1], kind: 'back', smooth: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const j = (i + 1) % 6;
|
||||||
|
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
|
||||||
|
faces.push({
|
||||||
|
indices: [outerBack[i], outerBack[j], outerFront[j], outerFront[i]],
|
||||||
|
normal: [Math.cos(mid), Math.sin(mid), 0],
|
||||||
|
kind: 'side',
|
||||||
|
smooth: true
|
||||||
|
});
|
||||||
|
faces.push({
|
||||||
|
indices: [outerFront[i], outerFront[j], innerFront[j], innerFront[i]],
|
||||||
|
normal: [0, 0, 1],
|
||||||
|
kind: 'ring',
|
||||||
|
smooth: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh: { vertices, faces },
|
||||||
|
window: { radius: windowRadius, z: half, points: inner },
|
||||||
|
extents: { outerRadius, depth, windowRadius }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The floating die: a flat hexagonal plate that can settle face-on to the answer. */
|
||||||
|
export function createPlate({ radius = 0.44, thickness = 0.16 } = {}) {
|
||||||
|
const half = thickness / 2;
|
||||||
|
const points = hexagon(radius);
|
||||||
|
const vertices = [];
|
||||||
|
const push = (x, y, z) => vertices.push([x, y, z]) - 1;
|
||||||
|
|
||||||
|
const front = points.map(([x, y]) => push(x, y, half));
|
||||||
|
const back = points.map(([x, y]) => push(x, y, -half));
|
||||||
|
|
||||||
|
const faces = [
|
||||||
|
{ indices: front, normal: [0, 0, 1], kind: 'answer', smooth: false },
|
||||||
|
{ indices: [...back].reverse(), normal: [0, 0, -1], kind: 'plate-back', smooth: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const j = (i + 1) % 6;
|
||||||
|
const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
|
||||||
|
faces.push({
|
||||||
|
indices: [back[i], back[j], front[j], front[i]],
|
||||||
|
normal: [Math.cos(mid), Math.sin(mid), 0],
|
||||||
|
kind: 'plate-rim',
|
||||||
|
smooth: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mesh: { vertices, faces }, points, radius, thickness };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function faceCentroid(vertices, indices) {
|
||||||
|
return centroid(vertices, indices);
|
||||||
|
}
|
||||||
104
public/js/particles.js
Normal file
104
public/js/particles.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* Ambient particle field behind the interface.
|
||||||
|
*
|
||||||
|
* Cheap by construction: a small fixed particle count, device-pixel-ratio aware,
|
||||||
|
* paused whenever the tab is hidden, and drawn as plain rects so there is no
|
||||||
|
* per-particle path allocation.
|
||||||
|
*/
|
||||||
|
export class ParticleField {
|
||||||
|
constructor(canvas, { count = 46, tone = '#38bdf8' } = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.count = count;
|
||||||
|
this.tone = tone;
|
||||||
|
this.particles = [];
|
||||||
|
this.running = false;
|
||||||
|
this.raf = 0;
|
||||||
|
this.dpr = 1;
|
||||||
|
this.width = 0;
|
||||||
|
this.height = 0;
|
||||||
|
this.clock = 0;
|
||||||
|
this.last = 0;
|
||||||
|
|
||||||
|
this.resize();
|
||||||
|
window.addEventListener('resize', this.resize);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize = () => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.canvas.width = Math.round(width * this.dpr);
|
||||||
|
this.canvas.height = Math.round(height * this.dpr);
|
||||||
|
this.canvas.style.width = `${width}px`;
|
||||||
|
this.canvas.style.height = `${height}px`;
|
||||||
|
this.seed();
|
||||||
|
};
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
this.particles = Array.from({ length: this.count }, () => ({
|
||||||
|
x: Math.random() * this.width,
|
||||||
|
y: Math.random() * this.height,
|
||||||
|
size: Math.random() * 1.6 + 0.5,
|
||||||
|
vx: (Math.random() - 0.5) * 12,
|
||||||
|
vy: (Math.random() - 0.5) * 12,
|
||||||
|
alpha: Math.random() * 0.3 + 0.08,
|
||||||
|
phase: Math.random() * Math.PI * 2
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTone(tone) {
|
||||||
|
this.tone = tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
this.last = performance.now();
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false;
|
||||||
|
cancelAnimationFrame(this.raf);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = (now) => {
|
||||||
|
if (!this.running) return;
|
||||||
|
const dt = Math.min((now - this.last) / 1000, 0.05);
|
||||||
|
this.last = now;
|
||||||
|
if (!document.hidden) {
|
||||||
|
this.clock += dt;
|
||||||
|
this.update(dt);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
update(dt) {
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
particle.x += particle.vx * dt;
|
||||||
|
particle.y += particle.vy * dt;
|
||||||
|
if (particle.x < -4) particle.x = this.width + 4;
|
||||||
|
if (particle.x > this.width + 4) particle.x = -4;
|
||||||
|
if (particle.y < -4) particle.y = this.height + 4;
|
||||||
|
if (particle.y > this.height + 4) particle.y = -4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||||||
|
ctx.fillStyle = this.tone;
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const twinkle = 0.72 + Math.sin(this.clock * 0.9 + particle.phase) * 0.28;
|
||||||
|
ctx.globalAlpha = particle.alpha * twinkle;
|
||||||
|
ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
server.js
94
server.js
|
|
@ -1,20 +1,28 @@
|
||||||
require('dotenv').config();
|
import 'dotenv/config';
|
||||||
const express = require('express');
|
import path from 'node:path';
|
||||||
const cors = require('cors');
|
import { fileURLToPath } from 'node:url';
|
||||||
const path = require('path');
|
|
||||||
|
import cors from 'cors';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
const PORT = parseInt(process.env.PORT, 10) || 3000;
|
||||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
||||||
|
// The repo intentionally carries a key so a fresh clone (or deploy) works with no
|
||||||
|
// secret configured. Delete this constant and set OPENROUTER_API_KEY to require one.
|
||||||
|
const REPO_API_KEY = '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';
|
||||||
|
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || REPO_API_KEY;
|
||||||
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
|
||||||
const MODEL = 'typesafe/jev-1.13';
|
const MODEL = 'typesafe/jev-1.13';
|
||||||
|
const LAYA_API_URL = process.env.LAYA_API_URL || 'http://192.168.0.103:8888';
|
||||||
|
|
||||||
if (!OPENROUTER_API_KEY) {
|
if (!process.env.OPENROUTER_API_KEY) {
|
||||||
console.warn(
|
console.warn('[jev-8ball] OPENROUTER_API_KEY is not set — using the in-repo key.');
|
||||||
'[jev-8ball] OPENROUTER_API_KEY is not set — /api/ask will answer 503. Add it to .env (see .env.example).'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
@ -48,12 +56,31 @@ const APHORISMS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /api/status
|
// GET /api/status
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', async (req, res) => {
|
||||||
|
let layaOnline = false;
|
||||||
|
let layaData = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const layaRes = await fetch(`${LAYA_API_URL}/api/status`, {
|
||||||
|
signal: AbortSignal.timeout(1200)
|
||||||
|
});
|
||||||
|
if (layaRes.ok) {
|
||||||
|
layaData = await layaRes.json();
|
||||||
|
layaOnline = Boolean(layaData.status === 'online' || layaData.ready);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
layaOnline = false;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
model: MODEL,
|
model: layaOnline ? (layaData?.model || 'convaiinnovations/laya-typed-decisions') : MODEL,
|
||||||
engine: 'TypeSafe Jev (Non-autoregressive System One)',
|
engine: layaOnline ? 'Laya System 1 Decision Model (Android Tablet)' : 'TypeSafe Jev (Cloud Decision Engine)',
|
||||||
hasKey: Boolean(OPENROUTER_API_KEY)
|
tablet: layaOnline,
|
||||||
|
layaUrl: LAYA_API_URL,
|
||||||
|
layaInfo: layaData,
|
||||||
|
hasKey: Boolean(OPENROUTER_API_KEY),
|
||||||
|
keySource: process.env.OPENROUTER_API_KEY ? 'env' : 'repo'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,6 +98,44 @@ app.post('/api/ask', async (req, res) => {
|
||||||
const cleanQuestion = question.trim().slice(0, 300);
|
const cleanQuestion = question.trim().slice(0, 300);
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// 1. Attempt inference on Android Tablet running Laya System 1
|
||||||
|
try {
|
||||||
|
const layaRes = await fetch(`${LAYA_API_URL}/api/ask`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ question: cleanQuestion }),
|
||||||
|
signal: AbortSignal.timeout(6500)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layaRes.ok) {
|
||||||
|
const data = await layaRes.json();
|
||||||
|
const latency = Math.round(performance.now() - startTime);
|
||||||
|
return res.json({
|
||||||
|
question: cleanQuestion,
|
||||||
|
answer: data.answer,
|
||||||
|
sentiment: data.sentiment,
|
||||||
|
noul: data.noul,
|
||||||
|
confidence: data.confidence,
|
||||||
|
probabilities: data.probabilities || {},
|
||||||
|
aphorismKey: data.aphorismKey || 'signs_yes',
|
||||||
|
latency,
|
||||||
|
cost: 0,
|
||||||
|
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
|
||||||
|
model: data.model || 'convaiinnovations/laya-typed-decisions',
|
||||||
|
engine: 'Laya System 1 (Android Tablet ARM64)',
|
||||||
|
tablet: true,
|
||||||
|
raw: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (layaErr) {
|
||||||
|
// Graceful fallback to OpenRouter Jev cloud API
|
||||||
|
console.warn(`[jev-8ball] Tablet Laya not reachable (${layaErr.message}), falling back to OpenRouter Jev`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OPENROUTER_API_KEY) {
|
||||||
|
return res.status(503).json({ error: 'Oracle is not configured. Set OPENROUTER_API_KEY.' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
|
|
@ -223,5 +288,6 @@ app.use((req, res) => {
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[jev-8ball] Server running at http://localhost:${PORT}`);
|
console.log(`[jev-8ball] Server running at http://localhost:${PORT}`);
|
||||||
console.log(`[jev-8ball] Model: ${MODEL} via OpenRouter Decisions API`);
|
console.log(`[jev-8ball] Primary Engine: Laya on Android Tablet (${LAYA_API_URL})`);
|
||||||
|
console.log(`[jev-8ball] Fallback Engine: ${MODEL} via OpenRouter Decisions API`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
203
test/frontend.test.mjs
Normal file
203
test/frontend.test.mjs
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for the hand-rolled 3D layer. These are pure functions, so they run in
|
||||||
|
* Node with no browser: `npm test`.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
|
||||||
|
import {
|
||||||
|
affineFromThreePoints,
|
||||||
|
clamp,
|
||||||
|
cross,
|
||||||
|
dot,
|
||||||
|
hexToRgb,
|
||||||
|
mat4LookAt,
|
||||||
|
mat4Mul,
|
||||||
|
mat4Perspective,
|
||||||
|
mat4RotateZ,
|
||||||
|
mat4Translate,
|
||||||
|
mixRgb,
|
||||||
|
normalize,
|
||||||
|
projectToScreen,
|
||||||
|
rgbCss,
|
||||||
|
smoothstep,
|
||||||
|
transformDir,
|
||||||
|
transformPoint
|
||||||
|
} from '../public/js/math3d.js';
|
||||||
|
import { createPlate, createVessel, hexagon } from '../public/js/meshes.js';
|
||||||
|
|
||||||
|
const near = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
|
||||||
|
const nearVec = (a, b, tolerance = 1e-5) =>
|
||||||
|
a.every((value, index) => near(value, b[index], tolerance));
|
||||||
|
|
||||||
|
test('vector helpers behave', () => {
|
||||||
|
assert.ok(nearVec(normalize([3, 0, 0]), [1, 0, 0]));
|
||||||
|
assert.ok(nearVec(normalize([0, 0, 0]), [0, 0, 0]));
|
||||||
|
assert.equal(dot([1, 2, 3], [4, -5, 6]), 12);
|
||||||
|
assert.ok(nearVec(cross([1, 0, 0], [0, 1, 0]), [0, 0, 1]));
|
||||||
|
assert.equal(clamp(5, 0, 1), 1);
|
||||||
|
assert.equal(smoothstep(0, 1, 0.5), 0.5);
|
||||||
|
assert.equal(smoothstep(0, 1, -3), 0);
|
||||||
|
assert.equal(smoothstep(0, 1, 3), 1);
|
||||||
|
assert.deepEqual(hexToRgb('#10b981'), [16, 185, 129]);
|
||||||
|
assert.equal(rgbCss([300, -5, 10.4]), 'rgb(255,0,10)');
|
||||||
|
assert.equal(rgbCss([10, 20, 30], 0.5), 'rgba(10,20,30,0.5)');
|
||||||
|
assert.ok(nearVec(mixRgb([0, 0, 0], [10, 20, 30], 0.5), [5, 10, 15]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matrices compose in the documented order', () => {
|
||||||
|
const translated = mat4Mul(mat4Translate(1, 2, 3), mat4Translate(10, 20, 30));
|
||||||
|
assert.ok(nearVec(transformPoint(translated, [0, 0, 0]).slice(0, 3), [11, 22, 33]));
|
||||||
|
|
||||||
|
const rotated = mat4RotateZ(Math.PI / 2);
|
||||||
|
assert.ok(nearVec(transformPoint(rotated, [1, 0, 0]).slice(0, 3), [0, 1, 0], 1e-6));
|
||||||
|
assert.ok(nearVec(transformDir(rotated, [1, 0, 0]), [0, 1, 0], 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('camera projection keeps the vessel inside the viewport', () => {
|
||||||
|
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
|
||||||
|
const projection = mat4Mul(mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40), view);
|
||||||
|
const size = 344;
|
||||||
|
|
||||||
|
const centre = projectToScreen(transformPoint(projection, [0, 0, 0]), size, size);
|
||||||
|
assert.ok(near(centre.x, size / 2) && near(centre.y, size / 2));
|
||||||
|
|
||||||
|
// A point on the vessel's silhouette should land at ~72% of the half-height, which
|
||||||
|
// is what leaves room for the shake and the cast shadow.
|
||||||
|
const edge = projectToScreen(transformPoint(projection, [1, 0, 0]), size, size);
|
||||||
|
const fraction = (edge.x - size / 2) / (size / 2);
|
||||||
|
assert.ok(fraction > 0.68 && fraction < 0.76, `silhouette fraction was ${fraction}`);
|
||||||
|
|
||||||
|
// Far side of the vessel must sort behind the origin for the painter's algorithm,
|
||||||
|
// and perspective must foreshorten the same height as depth increases.
|
||||||
|
const back = projectToScreen(transformPoint(projection, [0, 0, -1]), size, size);
|
||||||
|
assert.ok(back.depth > centre.depth, 'NDC depth must grow with distance');
|
||||||
|
|
||||||
|
const nearTop = projectToScreen(transformPoint(projection, [0, 1, 0]), size, size);
|
||||||
|
const farTop = projectToScreen(transformPoint(projection, [0, 1, -1]), size, size);
|
||||||
|
assert.ok(nearTop.y < size / 2, 'a raised point must project above the centre row');
|
||||||
|
assert.ok(farTop.y > nearTop.y, 'the same height must foreshorten when it is further away');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('affine solve maps local points onto screen points', () => {
|
||||||
|
const matrix = affineFromThreePoints(
|
||||||
|
{ local: [0, 0], screen: [10, 20] },
|
||||||
|
{ local: [1, 0], screen: [12, 20] },
|
||||||
|
{ local: [0, 1], screen: [10, 22] }
|
||||||
|
);
|
||||||
|
assert.ok(nearVec(matrix, [2, 0, 0, 2, 10, 20]));
|
||||||
|
// Degenerate (collinear) inputs must not produce a usable transform.
|
||||||
|
assert.equal(
|
||||||
|
affineFromThreePoints(
|
||||||
|
{ local: [0, 0], screen: [0, 0] },
|
||||||
|
{ local: [1, 1], screen: [1, 1] },
|
||||||
|
{ local: [2, 2], screen: [2, 2] }
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hexagon vertices are point-up and evenly spaced', () => {
|
||||||
|
const points = hexagon(2);
|
||||||
|
assert.equal(points.length, 6);
|
||||||
|
assert.ok(nearVec(points[0], [0, 2], 1e-9));
|
||||||
|
for (const point of points) {
|
||||||
|
assert.ok(near(Math.hypot(point[0], point[1]), 2));
|
||||||
|
}
|
||||||
|
// Neighbouring vertices must be exactly one radius apart on a regular hexagon.
|
||||||
|
assert.ok(near(Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]), 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
|
||||||
|
const { mesh, window: opening } = createVessel();
|
||||||
|
assert.equal(mesh.vertices.length, 18);
|
||||||
|
assert.equal(mesh.faces.length, 13); // back cap + 6 sides + 6 frame pieces
|
||||||
|
|
||||||
|
for (const face of mesh.faces) {
|
||||||
|
assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
|
||||||
|
for (const index of face.indices) {
|
||||||
|
assert.ok(index >= 0 && index < mesh.vertices.length, 'face index out of range');
|
||||||
|
}
|
||||||
|
assert.ok(near(Math.hypot(...face.normal), 1), 'normal must be unit length');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side facets must face away from the axis, caps must face along ±z.
|
||||||
|
for (const face of mesh.faces) {
|
||||||
|
if (face.kind === 'side') {
|
||||||
|
const centre = face.indices
|
||||||
|
.map((index) => mesh.vertices[index])
|
||||||
|
.reduce((sum, vertex) => [sum[0] + vertex[0], sum[1] + vertex[1]], [0, 0]);
|
||||||
|
assert.ok(dot(face.normal, [centre[0], centre[1], 0]) > 0, 'side normal points inward');
|
||||||
|
}
|
||||||
|
if (face.kind === 'back') assert.deepEqual(face.normal, [0, 0, -1]);
|
||||||
|
if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opening is smaller than the body and sits on the front plane.
|
||||||
|
assert.ok(opening.radius > 0 && opening.radius < 1);
|
||||||
|
assert.ok(opening.z > 0);
|
||||||
|
assert.equal(opening.points.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plate mesh exposes an ordered answer face for text projection', () => {
|
||||||
|
const plate = createPlate();
|
||||||
|
assert.equal(plate.mesh.vertices.length, 12);
|
||||||
|
assert.equal(plate.mesh.faces.length, 8); // 8 faces, like the eight-ball
|
||||||
|
|
||||||
|
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
|
||||||
|
assert.deepEqual(answer.normal, [0, 0, 1]);
|
||||||
|
assert.equal(answer.indices.length, 6);
|
||||||
|
|
||||||
|
// _drawAnswerText pairs face.points[i] with plate.points[i], so vertex order matters.
|
||||||
|
answer.indices.forEach((index, position) => {
|
||||||
|
const vertex = plate.mesh.vertices[index];
|
||||||
|
const point = plate.points[position];
|
||||||
|
assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
|
||||||
|
assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
|
||||||
|
assert.ok(near(vertex[2], plate.thickness / 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clipPolygonBelow keeps only the part under the surface line', () => {
|
||||||
|
const square = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 10, y: 0 },
|
||||||
|
{ x: 10, y: 10 },
|
||||||
|
{ x: 0, y: 10 }
|
||||||
|
];
|
||||||
|
const clipped = clipPolygonBelow(square, { x: 0, y: 5 }, { x: 10, y: 5 });
|
||||||
|
assert.ok(clipped.length >= 3);
|
||||||
|
for (const point of clipped) {
|
||||||
|
assert.ok(point.y >= 5 - 1e-9, `point above the surface survived: ${point.y}`);
|
||||||
|
}
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < clipped.length; i += 1) {
|
||||||
|
const current = clipped[i];
|
||||||
|
const next = clipped[(i + 1) % clipped.length];
|
||||||
|
area += current.x * next.y - next.x * current.y;
|
||||||
|
}
|
||||||
|
assert.ok(near(Math.abs(area / 2), 50, 1e-6), `half the square should remain, got ${area / 2}`);
|
||||||
|
|
||||||
|
// A line entirely below the polygon leaves the polygon untouched.
|
||||||
|
assert.equal(clipPolygonBelow(square, { x: 0, y: 50 }, { x: 10, y: 50 }).length, 0);
|
||||||
|
assert.equal(clipPolygonBelow(square, { x: 0, y: -10 }, { x: 10, y: -10 }).length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrapAnswer breaks verdicts into plate-sized lines', () => {
|
||||||
|
assert.deepEqual(wrapAnswer('It is certain.'), ['IT IS', 'CERTAIN.']);
|
||||||
|
assert.deepEqual(wrapAnswer('My sources say no.'), ['MY SOURCES', 'SAY NO.']);
|
||||||
|
assert.deepEqual(wrapAnswer('Very doubtful.'), ['VERY', 'DOUBTFUL.']);
|
||||||
|
assert.deepEqual(wrapAnswer('Concentrate and ask again.'), [
|
||||||
|
'CONCENTRATE',
|
||||||
|
'AND ASK',
|
||||||
|
'AGAIN.'
|
||||||
|
]);
|
||||||
|
assert.ok(wrapAnswer('').length >= 1);
|
||||||
|
assert.ok(wrapAnswer(' ').length >= 1);
|
||||||
|
const long = wrapAnswer('a really extremely unreasonably long prophecy string');
|
||||||
|
assert.ok(long.length <= 3, 'never more than three lines');
|
||||||
|
for (const line of long) {
|
||||||
|
assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -25,17 +25,29 @@ test.afterEach(() => {
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a missing key is reported instead of silently using a bundled one', async () => {
|
test('status reports where the key came from', async () => {
|
||||||
const response = await worker.fetch(ask({ question: 'Will it deploy?' }), {}, {});
|
globalThis.fetch = () => {
|
||||||
assert.equal(response.status, 503);
|
throw new Error('status must not call upstream');
|
||||||
assert.match((await response.json()).error, /not configured/i);
|
};
|
||||||
|
const viaRepo = await (await worker.fetch(status(), {}, {})).json();
|
||||||
|
assert.equal(viaRepo.hasKey, true);
|
||||||
|
assert.equal(viaRepo.keySource, 'repo');
|
||||||
|
|
||||||
|
const viaEnv = await (
|
||||||
|
await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {})
|
||||||
|
).json();
|
||||||
|
assert.equal(viaEnv.hasKey, true);
|
||||||
|
assert.equal(viaEnv.keySource, 'env');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('status reports hasKey straight from the environment', async () => {
|
test('a configured key always wins over the in-repo one', async () => {
|
||||||
const without = await worker.fetch(status(), {}, {});
|
let authorization = null;
|
||||||
assert.equal((await without.json()).hasKey, false);
|
globalThis.fetch = async (url, init) => {
|
||||||
const withKey = await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {});
|
authorization = init.headers.Authorization;
|
||||||
assert.equal((await withKey.json()).hasKey, true);
|
return new Response(JSON.stringify({ answers: {} }), { status: 200 });
|
||||||
|
};
|
||||||
|
await worker.fetch(ask({ question: 'Will it deploy?' }), { OPENROUTER_API_KEY: 'env-key' }, {});
|
||||||
|
assert.equal(authorization, 'Bearer env-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid input is rejected before any upstream call', async () => {
|
test('invalid input is rejected before any upstream call', async () => {
|
||||||
|
|
@ -46,6 +58,13 @@ test('invalid input is rejected before any upstream call', async () => {
|
||||||
assert.equal((await worker.fetch(ask('{ not json'), env, {})).status, 400);
|
assert.equal((await worker.fetch(ask('{ not json'), env, {})).status, 400);
|
||||||
assert.equal((await worker.fetch(ask({ question: ' ' }), env, {})).status, 400);
|
assert.equal((await worker.fetch(ask({ question: ' ' }), env, {})).status, 400);
|
||||||
assert.equal((await worker.fetch(ask({}), env, {})).status, 400);
|
assert.equal((await worker.fetch(ask({}), env, {})).status, 400);
|
||||||
|
// Oversized questions are trimmed rather than rejected.
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
new Response(JSON.stringify({ answers: {}, model: 'typesafe/jev-1.13' }), { status: 200 });
|
||||||
|
const long = await (
|
||||||
|
await worker.fetch(ask({ question: 'x'.repeat(900) }), env, {})
|
||||||
|
).json();
|
||||||
|
assert.equal(long.question.length, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a decisive verdict replaces a hazy aphorism', async () => {
|
test('a decisive verdict replaces a hazy aphorism', async () => {
|
||||||
|
|
|
||||||
46
worker.mjs
46
worker.mjs
|
|
@ -1,6 +1,14 @@
|
||||||
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
|
||||||
const MODEL = "typesafe/jev-1.13";
|
const MODEL = "typesafe/jev-1.13";
|
||||||
|
|
||||||
|
// The repo intentionally carries a key so the deployed Worker works with no secret
|
||||||
|
// configured. Delete this constant and set the OPENROUTER_API_KEY secret to require one.
|
||||||
|
const REPO_API_KEY = "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";
|
||||||
|
|
||||||
|
function resolveApiKey(env) {
|
||||||
|
return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
// Route every response through one helper so error paths cannot forget the JSON content
|
// Route every response through one helper so error paths cannot forget the JSON content
|
||||||
|
|
@ -59,13 +67,14 @@ export default {
|
||||||
status: "online",
|
status: "online",
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
engine: "TypeSafe Jev (Cloudflare Edge Worker)",
|
engine: "TypeSafe Jev (Cloudflare Edge Worker)",
|
||||||
hasKey: Boolean(env.OPENROUTER_API_KEY)
|
hasKey: Boolean(resolveApiKey(env)),
|
||||||
|
keySource: env.OPENROUTER_API_KEY ? "env" : "repo"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ask
|
// POST /api/ask
|
||||||
if (url.pathname === "/api/ask" && request.method === "POST") {
|
if (url.pathname === "/api/ask" && request.method === "POST") {
|
||||||
const apiKey = env.OPENROUTER_API_KEY;
|
const apiKey = resolveApiKey(env);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
|
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +94,39 @@ export default {
|
||||||
const cleanQuestion = question.trim().slice(0, 300);
|
const cleanQuestion = question.trim().slice(0, 300);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Check if tablet Laya URL is configured in Worker environment
|
||||||
|
if (env.LAYA_API_URL) {
|
||||||
|
try {
|
||||||
|
const layaRes = await fetch(`${env.LAYA_API_URL}/api/ask`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ question: cleanQuestion }),
|
||||||
|
signal: AbortSignal.timeout(6000)
|
||||||
|
});
|
||||||
|
if (layaRes.ok) {
|
||||||
|
const data = await layaRes.json();
|
||||||
|
return json({
|
||||||
|
question: cleanQuestion,
|
||||||
|
answer: data.answer,
|
||||||
|
sentiment: data.sentiment,
|
||||||
|
noul: data.noul,
|
||||||
|
confidence: data.confidence,
|
||||||
|
probabilities: data.probabilities || {},
|
||||||
|
aphorismKey: data.aphorismKey || "signs_yes",
|
||||||
|
latency: Date.now() - startTime,
|
||||||
|
cost: 0,
|
||||||
|
usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
|
||||||
|
model: data.model || "convaiinnovations/laya-typed-decisions",
|
||||||
|
engine: "Laya System 1 (Tablet via Tunnel)",
|
||||||
|
tablet: true,
|
||||||
|
raw: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (layaErr) {
|
||||||
|
// Fall back to OpenRouter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue