const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const sides = {
male: {
canvas: $("#maleCanvas"),
ctx: $("#maleCanvas").getContext("2d"),
surface: [],
skeleton: [],
neuron: null,
connections: [],
direction: "outputs",
mode: "demo",
color: "#7cff99",
activity: 0,
cognitiveState: null,
camera: { rx: -0.12, ry: 0.45, zoom: 1 },
},
female: {
canvas: $("#femaleCanvas"),
ctx: $("#femaleCanvas").getContext("2d"),
surface: [],
skeleton: [],
neuron: null,
connections: [],
direction: "outputs",
mode: "demo",
color: "#ff89c9",
activity: 0,
cognitiveState: null,
camera: { rx: -0.12, ry: 0.45, zoom: 1 },
},
};
const state = {
syncCamera: true,
autoRotate: true,
dragging: null,
lastX: 0,
lastY: 0,
chatRunId: 0,
autoChat: false,
arenaRunId: 0,
arenaAuto: false,
backtestJobId: null,
backtestPolling: false,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
async function fetchJSON(url) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "Request failed");
return data;
}
async function postJSON(url, payload, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
cache: "no-store",
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "Request failed");
return data;
} catch (error) {
if (error.name === "AbortError") {
throw new Error("回應逾時,已自動解除卡住狀態,請再試一次。");
}
throw error;
} finally {
clearTimeout(timer);
}
}
function showNotice(message) {
const el = $("#notice");
if (!message) {
el.classList.add("hidden");
el.textContent = "";
return;
}
el.textContent = message;
el.classList.remove("hidden");
}
async function loadSources() {
try {
const data = await fetchJSON("/api/source");
sides.male.mode = data.male_mode || data.active_mode || "demo";
sides.female.mode = data.female_mode || "demo";
$("#maleMode").textContent = sides.male.mode.toUpperCase();
$("#femaleMode").textContent = sides.female.mode.toUpperCase();
$("#sourceStatus").textContent =
`MALE ${sides.male.mode.toUpperCase()} · FEMALE ${sides.female.mode.toUpperCase()}`;
} catch (error) {
$("#sourceStatus").textContent = "SOURCE ERROR";
}
}
async function loadSurfaces() {
const [maleResult, femaleResult] = await Promise.allSettled([
fetchJSON("/api/cns-surface?max_edges=2200"),
fetchJSON("/api/banc/surface?max_edges=2200"),
]);
if (maleResult.status === "fulfilled") {
sides.male.surface = maleResult.value.segments || [];
}
if (femaleResult.status === "fulfilled") {
sides.female.surface = femaleResult.value.segments || [];
}
const problems = [];
if (maleResult.status === "rejected") problems.push("MaleCNS surface");
if (femaleResult.status === "rejected") problems.push("BANC surface");
if (problems.length) showNotice(problems.join("、") + " 載入失敗。");
}
async function compare() {
const query = $("#searchInput").value.trim();
if (!query) return;
$("#compareBtn").disabled = true;
$("#compareBtn").textContent = "比較中…";
showNotice("");
clearSide("male");
clearSide("female");
try {
const [maleData, femaleData] = await Promise.all([
fetchJSON("/api/neurons?q=" + encodeURIComponent(query) + "&limit=12"),
fetchJSON("/api/banc/neurons?q=" + encodeURIComponent(query) + "&limit=12"),
]);
const maleNeuron = pickBestNeuron(maleData.results || [], query);
const femaleNeuron = pickBestNeuron(femaleData.results || [], query);
sides.male.mode = maleData.mode || sides.male.mode;
sides.female.mode = femaleData.mode || sides.female.mode;
$("#maleMode").textContent = sides.male.mode.toUpperCase();
$("#femaleMode").textContent = sides.female.mode.toUpperCase();
if (!maleNeuron && !femaleNeuron) {
throw new Error("MaleCNS 與 BANC 都找不到這個 neuron。");
}
await Promise.all([
maleNeuron ? selectNeuron("male", maleNeuron) : Promise.resolve(),
femaleNeuron ? selectNeuron("female", femaleNeuron) : Promise.resolve(),
]);
updatePairSummary(query);
const notices = [maleData.notice, femaleData.notice].filter(Boolean);
if (notices.length) showNotice(notices.join(" | "));
} catch (error) {
showNotice(error.message);
} finally {
$("#compareBtn").disabled = false;
$("#compareBtn").textContent = "比較";
}
}
function pickBestNeuron(rows, query) {
if (!rows.length) return null;
const q = query.toLowerCase();
return rows.find((row) => String(row.type || "").toLowerCase() === q)
|| rows.find((row) => String(row.name || "").toLowerCase().includes(q))
|| rows[0];
}
function clearSide(sideName) {
const side = sides[sideName];
side.neuron = null;
side.skeleton = [];
side.connections = [];
$("#" + sideName + "Title").textContent = sideName === "male" ? "MaleCNS" : "BANC";
$("#" + sideName + "Meta").textContent = "等待搜尋";
$("#" + sideName + "Segments").textContent = "0 segments";
$("#" + sideName + "Connections").innerHTML = '
等待 neuron
';
}
async function selectNeuron(sideName, neuron) {
const side = sides[sideName];
side.neuron = neuron;
const label = neuron.name || neuron.type || neuron.bodyid;
$("#" + sideName + "Title").textContent = label;
renderMeta(sideName, neuron);
await Promise.all([
loadSkeleton(sideName),
loadConnections(sideName),
]);
}
function renderMeta(sideName, neuron) {
const values = [
["id", neuron.bodyid],
["type", neuron.type],
["class", neuron.superclass],
["side", neuron.somaSide],
].filter(([, value]) => value !== undefined && value !== null && value !== "");
$("#" + sideName + "Meta").innerHTML = values.length
? values.map(([key, value]) =>
`${escapeHtml(key)}: ${escapeHtml(value)}`
).join("")
: "無 metadata";
}
async function loadSkeleton(sideName) {
const side = sides[sideName];
if (!side.neuron) return;
const id = side.neuron.bodyid || side.neuron.type || side.neuron.name;
const url = sideName === "male"
? "/api/skeleton/" + encodeURIComponent(id) + "?max_segments=2800"
: "/api/banc/skeleton/" + encodeURIComponent(id) + "?max_segments=2800";
$("#" + sideName + "Segments").textContent = "loading…";
try {
const data = await fetchJSON(url);
side.skeleton = data.segments || [];
$("#" + sideName + "Segments").textContent =
(data.segment_count || side.skeleton.length) + " segments";
} catch (error) {
side.skeleton = [];
$("#" + sideName + "Segments").textContent = "0 segments";
}
}
async function loadConnections(sideName) {
const side = sides[sideName];
if (!side.neuron) return;
const id = side.neuron.bodyid || side.neuron.type || side.neuron.name;
const base = sideName === "male" ? "/api/connections/" : "/api/banc/connections/";
const url = base + encodeURIComponent(id)
+ "?direction=" + encodeURIComponent(side.direction)
+ "&limit=20";
$("#" + sideName + "Connections").innerHTML =
'讀取 connectivity…
';
try {
const data = await fetchJSON(url);
side.connections = data.results || [];
renderConnections(sideName);
updateDifference();
} catch (error) {
side.connections = [];
$("#" + sideName + "Connections").innerHTML =
'connectivity 讀取失敗
';
updateDifference();
}
}
function renderConnections(sideName) {
const side = sides[sideName];
const root = $("#" + sideName + "Connections");
if (!side.connections.length) {
root.innerHTML = '沒有可顯示的 partner
';
return;
}
const maxWeight = Math.max(
...side.connections.map((row) => Number(row.weight) || 0),
1
);
root.innerHTML = side.connections.map((row) => {
const weight = Number(row.weight) || 0;
const percent = Math.max(4, Math.round(weight / maxWeight * 100));
const label = row.name || row.type || row.partner || "unknown";
const sub = [row.type, row.superclass, row.somaSide]
.filter(Boolean)
.join(" · ");
return `
${escapeHtml(weight)}
${escapeHtml(label)}
${escapeHtml(sub)}
`;
}).join("");
}
function partnerKey(row) {
return String(row.type || row.name || row.partner || "").trim();
}
function updateDifference() {
const maleMap = new Map();
const femaleMap = new Map();
for (const row of sides.male.connections) {
const key = partnerKey(row);
if (key) maleMap.set(key, row);
}
for (const row of sides.female.connections) {
const key = partnerKey(row);
if (key) femaleMap.set(key, row);
}
const maleKeys = new Set(maleMap.keys());
const femaleKeys = new Set(femaleMap.keys());
const common = [...maleKeys].filter((key) => femaleKeys.has(key));
const maleOnly = [...maleKeys].filter((key) => !femaleKeys.has(key));
const femaleOnly = [...femaleKeys].filter((key) => !maleKeys.has(key));
$("#commonCount").textContent = common.length;
$("#maleOnlyCount").textContent = maleOnly.length;
$("#femaleOnlyCount").textContent = femaleOnly.length;
const maleTop = Number(sides.male.connections[0]?.weight) || 0;
const femaleTop = Number(sides.female.connections[0]?.weight) || 0;
if (maleTop) {
const delta = ((femaleTop - maleTop) / maleTop) * 100;
$("#weightDelta").textContent =
(delta >= 0 ? "+" : "") + delta.toFixed(1) + "%";
} else {
$("#weightDelta").textContent = "—";
}
renderChips("#commonPartners", common);
renderChips("#maleOnlyPartners", maleOnly);
renderChips("#femaleOnlyPartners", femaleOnly);
}
function renderChips(selector, values) {
const root = $(selector);
root.innerHTML = values.length
? values.slice(0, 18).map((value) =>
`${escapeHtml(value)}`
).join("")
: '—';
}
function updatePairSummary(query) {
const male = sides.male.neuron;
const female = sides.female.neuron;
if (male && female) {
const sameType = male.type && female.type
&& String(male.type).toLowerCase() === String(female.type).toLowerCase();
$("#pairTitle").textContent = sameType
? `${male.type} · shared cell type`
: `${male.type || male.name || query} ↔ ${female.type || female.name || query}`;
} else if (male) {
$("#pairTitle").textContent = "只有 MaleCNS 找到配對";
} else if (female) {
$("#pairTitle").textContent = "只有 BANC 找到配對";
} else {
$("#pairTitle").textContent = "沒有配對";
}
}
function getBounds(side) {
const source = side.surface.length ? side.surface : side.skeleton;
if (!source.length) return { cx: 0, cy: 0, cz: 0, extent: 1 };
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const seg of source) {
for (const p of [
[Number(seg.x1), Number(seg.y1), Number(seg.z1)],
[Number(seg.x2), Number(seg.y2), Number(seg.z2)],
]) {
if (!p.every(Number.isFinite)) continue;
minX = Math.min(minX, p[0]);
minY = Math.min(minY, p[1]);
minZ = Math.min(minZ, p[2]);
maxX = Math.max(maxX, p[0]);
maxY = Math.max(maxY, p[1]);
maxZ = Math.max(maxZ, p[2]);
}
}
if (!Number.isFinite(minX)) return { cx: 0, cy: 0, cz: 0, extent: 1 };
return {
cx: (minX + maxX) / 2,
cy: (minY + maxY) / 2,
cz: (minZ + maxZ) / 2,
extent: Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1),
};
}
function rotatePoint(point, camera) {
let [x, y, z] = point;
const cy = Math.cos(camera.ry);
const sy = Math.sin(camera.ry);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cx = Math.cos(camera.rx);
const sx = Math.sin(camera.rx);
const y1 = y * cx - z1 * sx;
const z2 = y * sx + z1 * cx;
return [x1, y1, z2];
}
function project(point, bounds, camera, width, height) {
const normalized = [
(point[0] - bounds.cx) / bounds.extent * 2.15,
-(point[1] - bounds.cy) / bounds.extent * 2.15,
(point[2] - bounds.cz) / bounds.extent * 2.15,
];
const [x, y, z] = rotatePoint(normalized, camera);
const cameraDistance = 4.2;
const perspective = cameraDistance / Math.max(.8, cameraDistance - z);
const base = Math.min(width, height) * .38 * camera.zoom;
return {
x: width / 2 + x * base * perspective,
y: height / 2 + y * base * perspective,
z,
p: perspective,
};
}
function resizeCanvas(canvas, ctx) {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const pixelWidth = Math.max(1, Math.round(rect.width * dpr));
const pixelHeight = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { width: rect.width, height: rect.height };
}
function hexToRgba(hex, alpha) {
const clean = hex.replace("#", "");
const value = parseInt(clean, 16);
return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
}
function drawSegments(ctx, segments, bounds, camera, width, height, color, alphaMin, alphaMax, lineWidth) {
const projected = [];
for (const seg of segments) {
const aRaw = [Number(seg.x1), Number(seg.y1), Number(seg.z1)];
const bRaw = [Number(seg.x2), Number(seg.y2), Number(seg.z2)];
if (![...aRaw, ...bRaw].every(Number.isFinite)) continue;
const a = project(aRaw, bounds, camera, width, height);
const b = project(bRaw, bounds, camera, width, height);
projected.push({ a, b, z: (a.z + b.z) / 2 });
}
projected.sort((a, b) => a.z - b.z);
for (const line of projected) {
const depth = Math.max(0, Math.min(1, (line.z + 2.2) / 4.4));
const alpha = alphaMin + depth * (alphaMax - alphaMin);
ctx.beginPath();
ctx.moveTo(line.a.x, line.a.y);
ctx.lineTo(line.b.x, line.b.y);
ctx.strokeStyle = hexToRgba(color, alpha);
ctx.lineWidth = Math.max(.35, lineWidth * ((line.a.p + line.b.p) / 2));
ctx.stroke();
}
}
function drawSide(sideName) {
const side = sides[sideName];
const { width, height } = resizeCanvas(side.canvas, side.ctx);
const ctx = side.ctx;
ctx.clearRect(0, 0, width, height);
const bounds = getBounds(side);
if (side.surface.length) {
drawSegments(
ctx,
side.surface,
bounds,
side.camera,
width,
height,
"#7b857e",
.025,
.12,
.55
);
}
if (side.skeleton.length) {
const boost = Math.max(0, Math.min(1, side.activity || 0));
drawSegments(
ctx,
side.skeleton,
bounds,
side.camera,
width,
height,
side.color,
.34 + boost * .18,
Math.min(1, .98 + boost * .02),
1.25 + boost * 1.15
);
}
if (!side.surface.length && !side.skeleton.length) {
ctx.fillStyle = "rgba(255,255,255,.20)";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText("等待 CNS 資料", width / 2, height / 2);
}
}
function animate() {
if (state.autoRotate && !state.dragging) {
if (state.syncCamera) {
sides.male.camera.ry += .0015;
syncCameraFrom("male");
} else {
sides.male.camera.ry += .0015;
sides.female.camera.ry += .0015;
}
}
sides.male.activity *= .985;
sides.female.activity *= .985;
drawSide("male");
drawSide("female");
requestAnimationFrame(animate);
}
function syncCameraFrom(sourceName) {
const targetName = sourceName === "male" ? "female" : "male";
sides[targetName].camera = { ...sides[sourceName].camera };
}
function attachCanvasControls(sideName) {
const side = sides[sideName];
const wrap = side.canvas.closest(".viewer-wrap");
side.canvas.addEventListener("pointerdown", (event) => {
state.dragging = sideName;
state.lastX = event.clientX;
state.lastY = event.clientY;
wrap.classList.add("dragging");
side.canvas.setPointerCapture(event.pointerId);
});
side.canvas.addEventListener("pointermove", (event) => {
if (state.dragging !== sideName) return;
const dx = event.clientX - state.lastX;
const dy = event.clientY - state.lastY;
state.lastX = event.clientX;
state.lastY = event.clientY;
side.camera.ry += dx * .008;
side.camera.rx += dy * .008;
if (state.syncCamera) syncCameraFrom(sideName);
});
const finish = (event) => {
if (state.dragging !== sideName) return;
state.dragging = null;
wrap.classList.remove("dragging");
if (side.canvas.hasPointerCapture(event.pointerId)) {
side.canvas.releasePointerCapture(event.pointerId);
}
};
side.canvas.addEventListener("pointerup", finish);
side.canvas.addEventListener("pointercancel", finish);
side.canvas.addEventListener("wheel", (event) => {
event.preventDefault();
side.camera.zoom *= event.deltaY > 0 ? .92 : 1.08;
side.camera.zoom = Math.max(.38, Math.min(3.5, side.camera.zoom));
if (state.syncCamera) syncCameraFrom(sideName);
}, { passive: false });
}
function renderActivity(sideName, activations, behavior) {
const root = $("#" + sideName + "Activity");
const behaviorEl = $("#" + sideName + "Behavior");
behaviorEl.textContent = behavior || "等待刺激";
root.innerHTML = (activations || []).map((item) => {
const level = Math.max(0, Math.min(100, Number(item.level) || 0));
return `
${escapeHtml(item.pathway)}
${level}%
`;
}).join("");
}
function renderMindState(sideName, cognitiveState, goal, decision) {
const root = $("#" + sideName + "Mind");
if (!root || !cognitiveState) return;
const fields = [
["飢餓", cognitiveState.hunger],
["警戒", cognitiveState.alertness],
["好奇", cognitiveState.curiosity],
["社交", cognitiveState.social_drive],
];
const memory = Array.isArray(cognitiveState.memory) && cognitiveState.memory.length
? cognitiveState.memory[cognitiveState.memory.length - 1]
: "尚無短期記憶";
root.innerHTML = fields.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value ?? 0)}%
`).join("") + `
目標:${escapeHtml(goal || cognitiveState.goal || "觀察")}
決策:${escapeHtml(decision || cognitiveState.decision || "等待")}
記憶:${escapeHtml(memory)}
`;
}
function appendChatMessage(sideName, text, meta = "") {
const log = $("#chatLog");
const empty = log.querySelector(".chat-empty");
if (empty) empty.remove();
const row = document.createElement("div");
row.className = "chat-message " + sideName;
const label = sideName === "male" ? "♂ MaleCNS" : "♀ BANC";
row.innerHTML = `
${escapeHtml(text)}
${escapeHtml(label)}${meta ? " · " + escapeHtml(meta) : ""}
`;
log.appendChild(row);
log.scrollTop = log.scrollHeight;
}
function pulseBrain(sideName, strength = 1) {
const card = document.querySelector(`.brain-card[data-side="${sideName}"]`);
sides[sideName].activity = Math.max(sides[sideName].activity || 0, strength);
card.classList.remove("pulse-male", "pulse-female");
void card.offsetWidth;
card.classList.add(sideName === "male" ? "pulse-male" : "pulse-female");
setTimeout(() => {
card.classList.remove("pulse-male", "pulse-female");
}, 900);
}
function setSignalActive(active, sender = null) {
const channel = document.querySelector(".signal-channel");
channel.classList.toggle("active", active);
$("#signalStatus").textContent = active
? (sender === "male" ? "♂ → ♀ SIGNAL TRANSMISSION" : "♀ → ♂ SIGNAL TRANSMISSION")
: "NEURAL SIGNAL CHANNEL";
}
async function sendBrainSignal(sender, text, turn = 0, appendInput = true) {
const receiver = sender === "male" ? "female" : "male";
if (appendInput) appendChatMessage(sender, text, "傳出刺激");
setSignalActive(true, sender);
pulseBrain(sender, .45);
try {
const data = await postJSON("/api/brain-chat", {
sender,
text,
turn,
male_neuron_type: sides.male.neuron?.type || sides.male.neuron?.name || null,
female_neuron_type: sides.female.neuron?.type || sides.female.neuron?.name || null,
receiver_state: sides[receiver].cognitiveState,
});
sides[receiver].cognitiveState = data.cognitive_state || sides[receiver].cognitiveState;
renderActivity(receiver, data.activations || [], data.behavior);
renderMindState(receiver, data.cognitive_state, data.goal, data.decision);
pulseBrain(receiver, .95);
appendChatMessage(
receiver,
data.reply,
`${data.behavior} · 目標:${data.goal || "—"} · 決策:${data.decision || "—"}`
);
return {
sender: receiver,
text: data.reply,
nextSignal: data.next_signal || data.reply,
result: data,
};
} finally {
setSignalActive(false);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runAutoChat() {
state.chatRunId += 1;
const runId = state.chatRunId;
state.autoChat = true;
$("#autoChatBtn").disabled = true;
$("#stopChatBtn").classList.add("active");
let sender = "male";
let text = "我偵測到你正在附近移動。";
appendChatMessage(sender, text, "自動對話起始");
pulseBrain(sender, .55);
for (let turn = 0; turn < 8; turn += 1) {
if (!state.autoChat || runId !== state.chatRunId) break;
try {
const next = await sendBrainSignal(sender, text, turn, false);
sender = next.sender;
text = next.nextSignal;
} catch (error) {
showNotice("Brain-to-Brain 模擬失敗:" + error.message);
break;
}
await sleep(850);
}
if (runId === state.chatRunId) {
state.autoChat = false;
$("#autoChatBtn").disabled = false;
$("#stopChatBtn").classList.remove("active");
}
}
function stopAutoChat() {
state.autoChat = false;
state.chatRunId += 1;
$("#autoChatBtn").disabled = false;
$("#stopChatBtn").classList.remove("active");
setSignalActive(false);
}
function clearChat() {
stopAutoChat();
$("#chatLog").innerHTML =
'按「讓牠們自己聊」開始,或在下方輸入一個刺激訊息。
';
$("#maleActivity").innerHTML = "";
$("#femaleActivity").innerHTML = "";
$("#maleMind").innerHTML = "";
$("#femaleMind").innerHTML = "";
$("#maleBehavior").textContent = "等待刺激";
$("#femaleBehavior").textContent = "等待刺激";
sides.male.activity = 0;
sides.female.activity = 0;
sides.male.cognitiveState = null;
sides.female.cognitiveState = null;
}
async function sendManualChat() {
const sender = $("#chatSender").value;
const input = $("#chatInput");
const button = $("#sendChatBtn");
const text = input.value.trim();
if (!text) return;
input.value = "";
const originalText = button.textContent;
button.disabled = true;
button.textContent = "傳送中…";
$("#signalStatus").textContent = "PROCESSING SIGNAL…";
try {
await sendBrainSignal(sender, text, 0, true);
} catch (error) {
showNotice("訊息模擬失敗:" + error.message);
setSignalActive(false);
} finally {
button.disabled = false;
button.textContent = originalText;
if (!document.querySelector(".signal-channel").classList.contains("active")) {
$("#signalStatus").textContent = "NEURAL SIGNAL CHANNEL";
}
}
}
function formatMoney(value) {
const number = Number(value) || 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(number);
}
function renderSensoryChannels(sideName, trader) {
const root = $("#" + sideName + "Sensory");
if (!root) return;
const channels = trader.sensory_channels || {};
const labels = {
trend: "趨勢",
momentum: "動能",
flow: "資金流",
structure: "結構",
location: "位置",
risk: "風險",
};
root.innerHTML = Object.entries(labels).map(([key, label]) => {
const value = Number(channels[key] ?? 0);
return `
${escapeHtml(label)}
${Number.isFinite(value) ? value.toFixed(2) : "—"}
`;
}).join("");
}
function formatHoldBars(bars) {
const totalMinutes = Math.max(0, Number(bars) || 0) * 15;
if (totalMinutes < 60) return totalMinutes + "m";
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
}
function renderConnectomeRuntime(sideName, trader, connectome) {
const root = $("#" + sideName + "Connectome");
if (!root) return;
if (!connectome || connectome.available === false) {
root.innerHTML = `CONNECTOME OFFLINE
${escapeHtml(connectome?.error || "尚未載入")}`;
return;
}
const readout = trader.connectome_readout || {};
const scores = readout.scores || {};
const dynamics = readout.dynamics || {};
const nt = readout.neurotransmitters || {};
const plasticity = readout.plasticity || {};
const circuit = readout.circuit_spikes || {};
const decoder = readout.raw_rates_hz || {};
const fmt = (value) => {
const n = Number(value);
return Number.isFinite(n) ? n.toFixed(4) : "—";
};
root.innerHTML = `
WHOLE CONNECTOME · LIF
${Number(connectome.node_count || 0).toLocaleString()} neurons ·
${Number(connectome.edge_count || 0).toLocaleString()} edges ·
active ${Number(readout.active_neurons || connectome.active_neurons || 0).toLocaleString()}
${escapeHtml(dynamics.backend || "python_scipy")} ·
${fmt(readout.dt_ms)} ms step ·
${fmt(readout.window_ms)} ms neural window ·
${Number(readout.total_spikes || 0).toLocaleString()} spikes
NT + ${Number(nt.fast_excitatory_or_fallback || 0).toLocaleString()} ·
NT − ${Number(nt.fast_inhibitory || 0).toLocaleString()} ·
mod ${Number(nt.modulatory || 0).toLocaleString()} ·
uncertain ${Number(nt.uncertain_sign || 0).toLocaleString()}
KC ${Number(circuit.kc || 0).toLocaleString()} spikes ·
MBON ${Number(circuit.mbon07_11 || 0).toLocaleString()} ·
PAM11 ${Number(circuit.pam11 || 0).toLocaleString()} ·
PPL101 ${Number(circuit.ppl101 || 0).toLocaleString()}
decoder Δactual ${fmt(decoder.actual_difference_hz)} Hz ·
Δmirror ${fmt(decoder.mirror_difference_hz)} Hz ·
contrast ${fmt(decoder.contrast_hz)} Hz ·
gate ${Number(decoder.paired_gate_spikes || 0)}
plastic ${Number(plasticity.changed_edges || 0).toLocaleString()} /
${Number(plasticity.plastic_edges || 0).toLocaleString()} edges ·
reward ${fmt(readout.reward_signal)} ·
η ${fmt(plasticity.eta || 0.001)}
HOLD
${fmt(scores.HOLD)}
LONG
${fmt(scores.LONG)}
SHORT
${fmt(scores.SHORT)}
`;
}
function renderTradeAction(sideName, trader, connectome) {
const el = $("#" + sideName + "StockAction");
const action = String(trader.last_action || "HOLD").toUpperCase();
el.textContent = action;
el.className = "trade-action " + action.toLowerCase();
$("#" + sideName + "StockEquity").textContent = formatMoney(trader.equity);
$("#" + sideName + "StockPnl").textContent = formatMoney(trader.pnl);
$("#" + sideName + "StockTrades").textContent = trader.trades ?? 0;
$("#" + sideName + "StockWL").textContent = `${trader.wins ?? 0} / ${trader.losses ?? 0}`;
$("#" + sideName + "StockPosition").textContent = trader.position || "FLAT";
$("#" + sideName + "StockHold").textContent = formatHoldBars(trader.hold_bars);
$("#" + sideName + "StockUnrealized").textContent = formatMoney(trader.unrealized_pnl);
$("#" + sideName + "StockMemory").textContent = trader.long_term_memory_count ?? 0;
$("#" + sideName + "StockReason").textContent =
`${trader.last_event || "WAIT"} · ${trader.last_reason || "等待市場"}`;
renderConnectomeRuntime(sideName, trader, connectome);
renderSensoryChannels(sideName, trader);
}
function renderMarketIndicators(features) {
const root = $("#arenaIndicators");
if (!root) return;
const items = [
["H4", "h4_trend", 3],
["Delta", "delta_ratio", 3],
["OBI", "obi", 3],
["MACD H", "macd_hist", 4],
["ADX", "adx", 3],
["CHOP", "choppiness", 3],
["VWAP Δ", "vwap_dist", 4],
["Donchian", "donchian_pos", 3],
["Stoch RSI", "stoch_rsi", 3],
["Volume Z", "volume_zscore", 3],
["BOS", "bos", 2],
["CHOCH", "choch", 2],
["Sweep", "sweep", 2],
["ATR norm", "atr_norm", 4],
["BB pos", "bb_position", 3],
["Realized Vol", "realized_vol_pct", 3],
];
root.innerHTML = items.map(([label, key, digits]) => {
const raw = Number(features?.[key]);
const value = Number.isFinite(raw) ? raw.toFixed(digits) : "—";
return `
${escapeHtml(label)}
${escapeHtml(value)}
`;
}).join("");
}
function buildArenaChartPoints(data) {
const history = [...(data?.trade_history || [])]
.filter((row) => Number.isFinite(Number(row?.ts)))
.sort((a, b) => Number(a.ts) - Number(b.ts));
const points = [];
let maleArchive = 100000;
let femaleArchive = 100000;
if (history.length) {
points.push({
phase: "archive",
male: maleArchive,
female: femaleArchive,
ts: Number(history[0].ts) - 1,
});
history.forEach((row) => {
const pnl = Number(row.trade_pnl || 0);
if (row.side === "male") maleArchive += pnl;
if (row.side === "female") femaleArchive += pnl;
points.push({
phase: "archive",
male: maleArchive,
female: femaleArchive,
ts: Number(row.ts),
});
});
}
const liveRows = [...(data?.timeline || [])]
.filter((row) => row && (row.male || row.female))
.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
const liveStartIndex = points.length;
liveRows.forEach((row) => {
points.push({
phase: "live",
male: Number.isFinite(Number(row.male?.equity))
? Number(row.male.equity)
: 100000,
female: Number.isFinite(Number(row.female?.equity))
? Number(row.female.equity)
: 100000,
ts: Number(row.ts || 0),
});
});
if (!points.length) {
const maleEquity = Number(data?.male?.equity);
const femaleEquity = Number(data?.female?.equity);
if (Number.isFinite(maleEquity) || Number.isFinite(femaleEquity)) {
points.push({
phase: "live",
male: Number.isFinite(maleEquity) ? maleEquity : 100000,
female: Number.isFinite(femaleEquity) ? femaleEquity : 100000,
ts: Date.now() / 1000,
});
}
}
return { points, liveStartIndex, hasArchive: history.length > 0, hasLive: liveRows.length > 0 };
}
function drawArenaChart(data) {
const canvas = $("#arenaChart");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
const chart = buildArenaChartPoints(data);
const points = chart.points;
if (!points.length) {
ctx.fillStyle = "rgba(255,255,255,.22)";
ctx.font = "11px system-ui";
ctx.textAlign = "center";
ctx.fillText("尚無可繪製的 equity 資料", rect.width / 2, rect.height / 2);
return;
}
const values = [
...points.map((row) => Number(row.male)),
...points.map((row) => Number(row.female)),
100000,
].filter(Number.isFinite);
let min = Math.min(...values);
let max = Math.max(...values);
let span = Math.max(1, max - min);
const yMargin = Math.max(8, span * 0.12);
min -= yMargin;
max += yMargin;
span = Math.max(1, max - min);
const padLeft = 54;
const padRight = 18;
const padTop = 24;
const padBottom = 24;
const xFor = (index) => {
if (points.length <= 1) return rect.width / 2;
return padLeft + (index / (points.length - 1)) * (rect.width - padLeft - padRight);
};
const yFor = (value) =>
rect.height - padBottom -
((value - min) / span) * (rect.height - padTop - padBottom);
ctx.font = "9px system-ui";
ctx.textAlign = "right";
ctx.fillStyle = "rgba(255,255,255,.30)";
[min, (min + max) / 2, max].forEach((value) => {
const y = yFor(value);
ctx.strokeStyle = "rgba(255,255,255,.045)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(rect.width - padRight, y);
ctx.stroke();
ctx.fillText("$" + Math.round(value).toLocaleString(), padLeft - 6, y + 3);
});
const baseY = yFor(100000);
ctx.strokeStyle = "rgba(255,255,255,.13)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padLeft, baseY);
ctx.lineTo(rect.width - padRight, baseY);
ctx.stroke();
const segments = [];
let start = 0;
for (let i = 1; i < points.length; i += 1) {
if (points[i].phase !== points[i - 1].phase) {
segments.push([start, i - 1]);
start = i;
}
}
segments.push([start, points.length - 1]);
const drawSeries = (key, color) => {
segments.forEach(([from, to]) => {
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = from; i <= to; i += 1) {
const x = xFor(i);
const y = yFor(Number(points[i][key]));
if (i === from) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
if (to > from) ctx.stroke();
for (let i = from; i <= to; i += 1) {
const x = xFor(i);
const y = yFor(Number(points[i][key]));
ctx.beginPath();
ctx.arc(x, y, points.length === 1 ? 4 : 2.5, 0, Math.PI * 2);
ctx.fill();
}
});
};
drawSeries("male", "#7cff99");
drawSeries("female", "#ff89c9");
if (chart.hasArchive && chart.hasLive && chart.liveStartIndex < points.length) {
const dividerX = xFor(chart.liveStartIndex) - 5;
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = "rgba(255,255,255,.22)";
ctx.beginPath();
ctx.moveTo(dividerX, padTop);
ctx.lineTo(dividerX, rect.height - padBottom);
ctx.stroke();
ctx.restore();
ctx.fillStyle = "rgba(255,255,255,.42)";
ctx.font = "9px system-ui";
ctx.textAlign = "left";
ctx.fillText("v4 reset", Math.min(dividerX + 5, rect.width - 52), padTop + 9);
}
ctx.font = "10px system-ui";
ctx.textAlign = "left";
ctx.fillStyle = "#7cff99";
ctx.fillText("♂ Male", padLeft, 13);
ctx.fillStyle = "#ff89c9";
ctx.fillText("♀ BANC", padLeft + 58, 13);
if (chart.hasArchive && !chart.hasLive) {
ctx.fillStyle = "rgba(255,255,255,.35)";
ctx.textAlign = "right";
ctx.fillText("歷史已完成交易重建", rect.width - padRight, 13);
} else if (chart.hasArchive && chart.hasLive) {
ctx.fillStyle = "rgba(255,255,255,.35)";
ctx.textAlign = "right";
ctx.fillText("歷史 + v4 Live", rect.width - padRight, 13);
} else {
ctx.fillStyle = "rgba(255,255,255,.35)";
ctx.textAlign = "right";
ctx.fillText("v4 Live equity", rect.width - padRight, 13);
}
}
function renderArenaLog(timeline, data = null) {
const root = $("#arenaLog");
if (!root) return;
const rows = (timeline || []).slice(-14).reverse();
const history = (data?.trade_history || []).slice(0, 40);
const historySummary = data?.trade_history_summary || {};
const hasSavedState = Number(data?.decision_count || 0) > 0 ||
["LONG", "SHORT"].includes(String(data?.male?.position || "")) ||
["LONG", "SHORT"].includes(String(data?.female?.position || ""));
const liveHtml = rows.length
? rows.map((row) => `
#${escapeHtml(row.step)}
${Number(row.price).toFixed(2)}
♂ ${escapeHtml(row.male?.position || "FLAT")} · ${escapeHtml(row.male?.event || row.male?.action || "—")} · ${formatHoldBars(row.male?.hold_bars)}
♀ ${escapeHtml(row.female?.position || "FLAT")} · ${escapeHtml(row.female?.event || row.female?.action || "—")} · ${formatHoldBars(row.female?.hold_bars)}
`).join("")
: hasSavedState
? '已有交易狀態 · 等待下一根 15m K 產生新紀錄
'
: '尚未開始交易
';
const formatTs = (ts) => {
const value = Number(ts);
if (!Number.isFinite(value) || value <= 0) return "—";
return new Date(value * 1000).toLocaleString("zh-TW", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const maleSummary = historySummary.male || {};
const femaleSummary = historySummary.female || {};
const archiveHeader = history.length
? `
`
: "";
const archiveHtml = history.length
? history.map((row) => {
const side = row.side === "male" ? "♂" : "♀";
const pnl = Number(row.trade_pnl || 0);
const pnlClass = pnl >= 0 ? "history-win" : "history-loss";
return `
${escapeHtml(formatTs(row.ts))}
${side} ${escapeHtml(row.action || "—")}
${formatHoldBars(row.hold_bars)} · ${escapeHtml(row.reason || "—")}
${formatMoney(pnl)}
`;
}).join("")
: "";
root.innerHTML = `
${liveHtml}
${archiveHeader}
${archiveHtml}
`;
}
function formatDecisionCountdown(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const secs = total % 60;
return `${minutes}m ${String(secs).padStart(2, "0")}s`;
}
function renderStockArena(data) {
if (!data?.available) {
showNotice(data?.error || data?.message || "Live 交易競技場資料不可用。");
return;
}
$("#arenaSymbol").textContent = data.symbol || "—";
$("#arenaPrice").textContent = Number(data.price || data.bar?.close || 0).toFixed(2);
$("#arenaBar").textContent = data.live
? formatDecisionCountdown(data.next_decision_in_sec)
: `${data.step ?? 0} / ${data.steps_total ?? 0}`;
$("#arenaMomentum").textContent =
((Number(data.features?.momentum_5) || 0) * 100).toFixed(2) + "%";
$("#arenaAdvisor").textContent = data.brain_mode === "whole_connectome_live"
? `WHOLE CONNECTOME · LIVE · #${data.decision_count ?? 0}`
: data.live
? `LIVE · #${data.decision_count ?? 0}`
: "SELF-LEARNING ×2";
const execution = data.execution || {};
const execRoot = $("#arenaExecutionInfo");
if (execRoot) {
const allocationPct = (Number(execution.allocation || 0) * 100).toFixed(0);
const feePct = (Number(execution.fee_rate_per_side || 0) * 100).toFixed(3);
const worker = data.worker || {};
const workerText = worker.ok === true ? "SERVER WORKER OK" : worker.ok === false ? "SERVER WORKER ERROR" : "SERVER WORKER STARTING";
execRoot.textContent = `${workerText} · EXEC ${execution.model || "—"} · ${allocationPct}% allocation · fee ${feePct}%/side · ${Number(execution.slippage_bps || 0).toFixed(1)} bp slip · fresh-price causal fill`;
}
renderMarketIndicators(data.features || {});
renderTradeAction("male", data.male || {}, data.connectome?.male);
renderTradeAction("female", data.female || {}, data.connectome?.female);
renderArenaLog(data.timeline || [], data);
drawArenaChart(data);
if (data.live) {
$("#stockAutoBtn").textContent = state.arenaAuto ? "LIVE RUNNING" : "啟動 Live";
$("#stockAutoBtn").classList.toggle("active", state.arenaAuto);
}
}
function renderBacktest(data) {
const statusEl = $("#backtestStatus");
const barEl = $("#backtestProgressBar");
const resultEl = $("#backtestResult");
const btn = $("#backtestBtn");
if (!statusEl || !barEl || !resultEl || !btn) return;
const status = String(data?.status || "idle");
const completed = Number(data?.completed || 0);
const total = Number(data?.total || data?.bars || 0);
const percent = Number.isFinite(Number(data?.percent))
? Math.max(0, Math.min(100, Number(data.percent)))
: total > 0
? Math.max(0, Math.min(100, completed / total * 100))
: 0;
barEl.style.width = percent + "%";
if (status === "running" || status === "queued") {
statusEl.textContent = (data.symbol || "—") + " · " + completed + "/" + (total || "?") + " · " + percent.toFixed(0) + "%";
btn.disabled = true;
btn.textContent = "回測執行中";
} else if (status === "completed") {
statusEl.textContent = (data.symbol || "—") + " · 完成 " + (data.bars || total) + " 根";
btn.disabled = false;
btn.textContent = "再跑一次回測";
} else if (status === "error") {
statusEl.textContent = "回測失敗";
btn.disabled = false;
btn.textContent = "重新跑回測";
} else {
statusEl.textContent = "尚未執行";
btn.disabled = false;
btn.textContent = "跑歷史回測";
}
if (status === "error") {
resultEl.innerHTML = "錯誤:" + escapeHtml(data.error || "未知錯誤") + "";
return;
}
const male = data?.male || {};
const female = data?.female || {};
const latest = data?.latest || (Array.isArray(data?.timeline) ? data.timeline.at(-1) : null);
if (!Object.keys(male).length && !Object.keys(female).length) {
resultEl.innerHTML = "" + escapeHtml(data?.message || "正在載入完整 connectome…") + "";
return;
}
const card = (label, metrics, sideClass) => {
return '' +
"
" + escapeHtml(label) + "" +
'
Equity' + formatMoney(metrics.equity ?? 100000) + "
" +
'
P&L' + formatMoney(metrics.pnl ?? 0) + "
" +
'
Return' + Number(metrics.return_pct || 0).toFixed(3) + "%
" +
'
Closed Trades' + Number(metrics.closed_trades || 0) + "
" +
'
W/L' + Number(metrics.wins || 0) + " / " + Number(metrics.losses || 0) + "
" +
'
Max DD' + Number(metrics.max_drawdown_pct || 0).toFixed(3) + "%
" +
'
Fee' + formatMoney(metrics.fees || 0) + "
" +
'
Position' + escapeHtml(metrics.position || "FLAT") + "
" +
"
";
};
let latestText = "等待第一根歷史 K 完成";
if (latest) {
latestText =
"#" + (latest.step || completed) +
" · signal " + Number(latest.signal_close || 0).toFixed(2) +
" → next open " + Number(latest.execution_open || 0).toFixed(2) +
" · ♂ " + escapeHtml(latest.male?.action || "—") +
" (" + Number(latest.male?.contrast_hz || 0).toFixed(1) + "Hz)" +
" · ♀ " + escapeHtml(latest.female?.action || "—") +
" (" + Number(latest.female?.contrast_hz || 0).toFixed(1) + "Hz)";
}
resultEl.innerHTML =
card("♂ MaleCNS Backtest", male, "male-bt") +
card("♀ BANC Backtest", female, "female-bt") +
'' + latestText + "
";
}
async function pollBacktestJob(jobId) {
if (!jobId || state.backtestPolling) return;
state.backtestPolling = true;
state.backtestJobId = jobId;
try {
while (state.backtestJobId === jobId) {
const data = await fetchJSON(
"/api/stock-arena/backtest/status/" + encodeURIComponent(jobId)
);
renderBacktest(data);
if (data.status === "completed" || data.status === "error") {
state.backtestJobId = null;
break;
}
await sleep(1500);
}
} catch (error) {
showNotice("回測狀態讀取失敗:" + error.message);
} finally {
state.backtestPolling = false;
if (!state.backtestJobId) {
const btn = $("#backtestBtn");
if (btn) btn.disabled = false;
}
}
}
async function startBacktest() {
const symbol = $("#stockSymbol")?.value || "BTC/USDT";
const bars = Number($("#backtestBars")?.value || 96);
const btn = $("#backtestBtn");
if (btn) {
btn.disabled = true;
btn.textContent = "建立回測…";
}
try {
const data = await postJSON(
"/api/stock-arena/backtest/start",
{ symbol, bars },
12000
);
state.backtestJobId = data.job_id;
renderBacktest({
...data,
completed: 0,
total: data.bars || bars,
percent: 0,
message: data.message || "後端已開始載入完整 connectome",
});
await pollBacktestJob(data.job_id);
} catch (error) {
if (btn) {
btn.disabled = false;
btn.textContent = "跑歷史回測";
}
showNotice("回測啟動失敗:" + error.message);
}
}
async function loadStockArena() {
const symbol = $("#stockSymbol")?.value || "BTC/USDT";
try {
const data = await fetchJSON(
"/api/stock-arena/live/status?symbol=" + encodeURIComponent(symbol)
);
renderStockArena(data);
return data;
} catch (error) {
showNotice("Live 市場載入失敗:" + error.message);
throw error;
}
}
async function resetStockArena() {
const wasRunning = state.arenaAuto;
stopArenaAuto();
const symbol = $("#stockSymbol").value;
try {
const data = await postJSON("/api/stock-arena/live/reset", { symbol }, 35000);
renderStockArena(data);
} catch (error) {
showNotice("重置 Live 大腦失敗:" + error.message);
}
if (wasRunning) runArenaAuto();
}
async function stepStockArena() {
return loadStockArena();
}
async function runArenaAuto() {
if (state.arenaAuto) return;
state.arenaRunId += 1;
const runId = state.arenaRunId;
state.arenaAuto = true;
$("#stockAutoBtn").textContent = "LIVE RUNNING";
$("#stockAutoBtn").classList.add("active");
try {
while (state.arenaAuto && runId === state.arenaRunId) {
await loadStockArena();
await sleep(5000);
}
} catch (error) {
if (state.arenaAuto && runId === state.arenaRunId) {
showNotice("Live 更新暫停:" + error.message);
}
} finally {
if (runId === state.arenaRunId) {
state.arenaAuto = false;
$("#stockAutoBtn").textContent = "啟動 Live";
$("#stockAutoBtn").classList.remove("active");
}
}
}
function stopArenaAuto() {
state.arenaAuto = false;
state.arenaRunId += 1;
if ($("#stockAutoBtn")) {
$("#stockAutoBtn").textContent = "啟動 Live";
$("#stockAutoBtn").classList.remove("active");
}
}
function resetViews() {
sides.male.camera = { rx: -0.12, ry: 0.45, zoom: 1 };
sides.female.camera = { rx: -0.12, ry: 0.45, zoom: 1 };
}
$("#compareBtn").addEventListener("click", compare);
$("#searchInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") compare();
});
$("#syncCameraBtn").addEventListener("click", () => {
state.syncCamera = !state.syncCamera;
$("#syncCameraBtn").classList.toggle("active", state.syncCamera);
if (state.syncCamera) syncCameraFrom("male");
});
$("#autoRotateBtn").addEventListener("click", () => {
state.autoRotate = !state.autoRotate;
$("#autoRotateBtn").classList.toggle("active", state.autoRotate);
});
$("#resetViewBtn").addEventListener("click", resetViews);
$("#autoChatBtn").addEventListener("click", runAutoChat);
$("#stopChatBtn").addEventListener("click", stopAutoChat);
$("#clearChatBtn").addEventListener("click", clearChat);
$("#sendChatBtn").addEventListener("click", sendManualChat);
$("#chatInput").addEventListener("keydown", (event) => {
if (event.key === "Enter") sendManualChat();
});
$("#stockResetBtn").addEventListener("click", resetStockArena);
$("#stockStepBtn").addEventListener("click", stepStockArena);
$("#stock10Btn").addEventListener("click", stopArenaAuto);
$("#stockAutoBtn").addEventListener("click", () => {
if (state.arenaAuto) stopArenaAuto();
else runArenaAuto();
});
$("#backtestBtn").addEventListener("click", startBacktest);
$("#stockSymbol").addEventListener("change", async () => {
try {
await loadStockArena();
} catch (error) {
showNotice("切換幣別失敗:" + error.message);
}
});
$$(".direction-toggle").forEach((group) => {
const sideName = group.dataset.target;
group.querySelectorAll(".direction").forEach((button) => {
button.addEventListener("click", async () => {
group.querySelectorAll(".direction").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
sides[sideName].direction = button.dataset.direction;
await loadConnections(sideName);
});
});
});
attachCanvasControls("male");
attachCanvasControls("female");
async function boot() {
await Promise.all([loadSources(), loadSurfaces()]);
await compare();
await loadStockArena();
runArenaAuto();
}
boot();
animate();