BEYOND / site.html
opsecsystems's picture
Upload 3 files
dc713f7 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neural State Classifier · EEG Mental Imagery</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@200;300;400;500&display=swap');
:root {
/* Semantic tokens (referenced throughout) */
--color-background-primary: #FAFAF8;
--color-background-secondary: #F3F2EE;
--color-background-tertiary: #EAE8E3;
--color-text-primary: #1C1B19;
--color-text-secondary: #4D4C47;
--color-text-tertiary: #8E8C86;
--color-border-tertiary: #DAD7D1;
--color-border-secondary: #C5C2BB;
--font-sans: 'DM Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--border-radius-lg: 10px;
--border-radius-md: 8px;
--shadow-sm: 0 1px 2px rgba(24, 22, 18, 0.06);
--shadow-md: 0 8px 32px rgba(24, 22, 18, 0.08);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
--bg: var(--color-background-primary);
--bg2: var(--color-background-secondary);
--bg3: var(--color-background-tertiary);
--txt: var(--color-text-primary);
--txt2: var(--color-text-secondary);
--txt3: var(--color-text-tertiary);
--border: var(--color-border-tertiary);
--border2: var(--color-border-secondary);
--accent: #185FA5;
--accent-light: #E6F1FB;
--teal: #0F6E56;
--teal-light: #E1F5EE;
--amber: #854F0B;
--amber-light: #FAEEDA;
--coral: #993C1D;
--coral-light: #FAECE7;
--purple: #3C3489;
--purple-light: #EEEDFE;
--mono: 'DM Mono', ui-monospace, 'Cascadia Mono', monospace;
--sans: var(--font-sans);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html {
height: 100%;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--sans);
min-height: 100%;
background: linear-gradient(165deg, var(--color-background-secondary) 0%, var(--color-background-primary) 45%, #EFECE6 100%);
background-attachment: fixed;
color: var(--txt);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-light);
color: var(--txt);
}
.app {
display: grid;
grid-template-columns: minmax(180px, 220px) 1fr;
grid-template-rows: auto 1fr;
min-height: min(720px, 100vh);
max-width: 1400px;
margin: 12px auto;
padding: 0 12px 12px;
border: 0.5px solid var(--border);
border-radius: var(--border-radius-lg);
overflow: hidden;
background: var(--bg);
box-shadow: var(--shadow-md);
}
/* HEADER */
.header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 0.5px solid var(--border);
background: var(--bg);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo-mark {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--txt2);
}
.header-sub {
font-size: 11px;
color: var(--txt3);
font-family: var(--mono);
margin-top: 1px;
}
.status-row {
display: flex;
align-items: center;
gap: 16px;
}
.status-pill {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-family: var(--mono);
color: var(--txt3);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-border-secondary);
}
.dot.active {
background: #1D9E75;
animation: pulse 2s ease-in-out infinite;
}
.dot.warn {
background: #BA7517;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* SIDEBAR */
.sidebar {
border-right: 0.5px solid var(--border);
background: var(--bg2);
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-section {
padding: 14px 14px 8px;
border-bottom: 0.5px solid var(--border);
}
.sidebar-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--txt3);
font-family: var(--mono);
margin-bottom: 8px;
}
.phase-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
transition: background 0.15s;
}
.phase-item:hover { background: var(--bg3); }
.phase-item.active { background: var(--accent-light); }
.phase-num {
width: 18px;
height: 18px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-family: var(--mono);
font-weight: 500;
background: var(--border);
color: var(--txt2);
flex-shrink: 0;
}
.phase-item.active .phase-num {
background: var(--accent);
color: white;
}
.phase-item.done .phase-num {
background: #1D9E75;
color: white;
}
.phase-text {
font-size: 11px;
color: var(--txt2);
line-height: 1.3;
}
.phase-item.active .phase-text { color: var(--accent); font-weight: 500; }
.class-pair {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.class-tag {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 8px;
border-radius: 6px;
border: 0.5px solid var(--border2);
background: var(--bg);
cursor: pointer;
}
.class-tag.a { border-color: #B5D4F4; background: #E6F1FB; }
.class-tag.b { border-color: #9FE1CB; background: #E1F5EE; }
.class-name {
font-size: 11px;
font-weight: 500;
color: var(--txt);
}
.class-tag.a .class-name { color: #0C447C; }
.class-tag.b .class-name { color: #085041; }
.class-trials {
font-size: 10px;
font-family: var(--mono);
color: var(--txt3);
}
.class-tag.a .class-trials { color: #185FA5; }
.class-tag.b .class-trials { color: #0F6E56; }
.metric-mini {
display: flex;
flex-direction: column;
padding: 6px 8px;
border-radius: 6px;
background: var(--bg);
border: 0.5px solid var(--border);
margin-bottom: 4px;
}
.metric-mini-label { font-size: 10px; color: var(--txt3); font-family: var(--mono); }
.metric-mini-value { font-size: 15px; font-weight: 500; color: var(--txt); margin-top: 1px; }
.lib-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
margin-bottom: 2px;
cursor: pointer;
}
.lib-entry:hover { background: var(--bg3); }
.lib-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.lib-text { font-size: 11px; color: var(--txt2); }
.lib-score { font-size: 10px; font-family: var(--mono); color: var(--txt3); margin-left: auto; }
/* MAIN CONTENT */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* TAB BAR */
.tabbar {
display: flex;
border-bottom: 0.5px solid var(--border);
background: var(--bg);
padding: 0 16px;
gap: 0;
}
.tab {
padding: 10px 14px;
font-size: 12px;
color: var(--txt3);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -0.5px;
transition: color 0.15s;
white-space: nowrap;
}
.tab:hover { color: var(--txt2); }
.tab.active { color: var(--txt); border-bottom-color: var(--txt); font-weight: 500; }
/* PANELS */
.panel { display: none; flex: 1; overflow-y: auto; }
.panel.active { display: flex; flex-direction: column; }
/* ACQUISITION PANEL */
.acq-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
flex: 1;
}
.acq-protocol-card {
grid-column: 1 / -1;
}
.acq-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
align-items: start;
}
@media (max-width: 900px) {
.acq-settings-grid {
grid-template-columns: 1fr;
}
}
.field-hint {
display: block;
font-size: 10px;
color: var(--txt3);
font-family: var(--mono);
margin-top: 4px;
line-height: 1.35;
}
.check-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--txt2);
margin-bottom: 8px;
cursor: pointer;
}
.check-row input {
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--accent);
}
.acq-details {
margin-top: 10px;
border: 0.5px solid var(--border);
border-radius: var(--border-radius-md);
background: var(--bg2);
padding: 0;
overflow: hidden;
}
.acq-details > summary {
list-style: none;
cursor: pointer;
padding: 8px 12px;
font-size: 11px;
font-weight: 500;
font-family: var(--mono);
color: var(--txt2);
user-select: none;
}
.acq-details > summary::-webkit-details-marker {
display: none;
}
.acq-details > summary::before {
content: '▸ ';
color: var(--txt3);
}
.acq-details[open] > summary::before {
content: '▾ ';
}
.acq-details .acq-details-body {
padding: 0 12px 12px;
border-top: 0.5px solid var(--border);
}
.protocol-desc {
font-size: 11px;
color: var(--txt2);
line-height: 1.45;
padding: 8px 10px;
border-radius: var(--border-radius-md);
background: var(--accent-light);
border-left: 3px solid var(--accent);
margin-top: 6px;
}
.acq-section-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-family: var(--mono);
color: var(--txt3);
margin: 14px 0 8px;
grid-column: 1 / -1;
}
.acq-section-label:first-child {
margin-top: 0;
}
.card {
border: 0.5px solid var(--border);
border-radius: var(--border-radius-lg);
background: var(--bg);
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--border);
background: var(--bg2);
}
.card-title {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--txt2);
font-family: var(--mono);
}
.card-badge {
font-size: 10px;
font-family: var(--mono);
padding: 2px 6px;
border-radius: 4px;
background: var(--accent-light);
color: var(--accent);
}
.card-body { padding: 14px; }
/* EEG topography */
.topo-container {
display: flex;
justify-content: center;
padding: 8px 0;
}
/* Trial timeline */
.trial-row {
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 0.5px solid var(--border);
}
.trial-row:last-child { border-bottom: none; }
.trial-num {
font-size: 11px;
font-family: var(--mono);
color: var(--txt3);
text-align: right;
}
.trial-bar-wrap {
position: relative;
height: 8px;
border-radius: 4px;
background: var(--bg3);
overflow: hidden;
}
.trial-bar {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.trial-score {
font-size: 11px;
font-family: var(--mono);
font-weight: 500;
min-width: 16px;
text-align: center;
}
.trial-badge {
font-size: 9px;
font-family: var(--mono);
padding: 1px 5px;
border-radius: 3px;
min-width: 28px;
text-align: center;
}
.trial-badge.keep { background: #E1F5EE; color: #085041; }
.trial-badge.drop { background: #F1EFE8; color: #5F5E5A; }
/* Rating stars */
.rating-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
}
.star-group {
display: flex;
gap: 6px;
}
.star {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 0.5px solid var(--border2);
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
background: var(--bg);
}
.star:hover, .star.sel {
background: #FAEEDA;
border-color: #FAC775;
transform: scale(1.05);
}
.current-trial-info {
background: var(--bg2);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.trial-class-label {
font-size: 10px;
font-family: var(--mono);
color: var(--txt3);
margin-bottom: 4px;
}
.trial-object {
font-size: 20px;
font-weight: 300;
color: var(--txt);
letter-spacing: 0.02em;
}
.trial-timer {
font-size: 11px;
font-family: var(--mono);
color: var(--txt3);
margin-top: 4px;
}
/* Progress bar */
.progress-wrap {
height: 3px;
background: var(--bg3);
border-radius: 2px;
margin: 10px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s;
}
/* Neurofeedback panel */
.nf-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.nf-full { grid-column: 1 / -1; }
.convergence-track {
position: relative;
height: 60px;
border: 0.5px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg2);
}
.conv-fill {
position: absolute;
top: 0; left: 0; bottom: 0;
border-radius: 8px 0 0 8px;
transition: width 1s ease;
opacity: 0.6;
}
.conv-label {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 10px;
font-size: 11px;
font-family: var(--mono);
color: var(--txt2);
z-index: 2;
}
.conv-pct {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 10px;
font-size: 13px;
font-weight: 500;
font-family: var(--mono);
color: var(--txt);
z-index: 2;
}
.audio-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
background: var(--bg2);
margin-bottom: 6px;
}
.audio-icon {
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.audio-icon.pos { background: #E1F5EE; }
.audio-icon.neg { background: #FAECE7; }
.audio-text {
flex: 1;
font-size: 11px;
color: var(--txt2);
}
.audio-freq {
font-size: 10px;
font-family: var(--mono);
color: var(--txt3);
}
/* Library panel */
.lib-layout {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.lib-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.lib-card {
border: 0.5px solid var(--border);
border-radius: var(--border-radius-lg);
padding: 12px;
background: var(--bg);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.lib-card:hover { background: var(--bg2); border-color: var(--border2); }
.lib-card-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
color: var(--txt);
}
.lib-card-meta {
font-size: 10px;
font-family: var(--mono);
color: var(--txt3);
margin-bottom: 8px;
line-height: 1.6;
}
.lib-card-bar {
height: 3px;
border-radius: 2px;
background: var(--bg3);
overflow: hidden;
}
.lib-card-fill {
height: 100%;
border-radius: 2px;
}
.subject-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg2);
margin-bottom: 4px;
}
.subject-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 500;
flex-shrink: 0;
}
.subject-info { flex: 1; }
.subject-name { font-size: 12px; font-weight: 500; color: var(--txt); }
.subject-meta { font-size: 10px; font-family: var(--mono); color: var(--txt3); }
.subject-acc {
font-size: 12px;
font-family: var(--mono);
font-weight: 500;
}
/* Action buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 6px;
border: 0.5px solid var(--border2);
background: var(--bg);
color: var(--txt);
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
font-family: var(--sans);
}
.btn:hover { background: var(--bg2); }
.btn.primary { background: var(--accent); color: white; border-color: var(--accent); }
.btn.primary:hover { opacity: 0.9; }
/* Bottom actions */
.panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 0.5px solid var(--border);
background: var(--bg2);
}
/* Waveform */
.waveform-wrap {
height: 80px;
position: relative;
overflow: hidden;
}
canvas.waveform {
width: 100%;
height: 80px;
}
/* General info */
.info-row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 5px 0;
border-bottom: 0.5px solid var(--border);
font-size: 12px;
}
.info-row:last-child { border-bottom: none; }
.info-key { color: var(--txt3); font-family: var(--mono); }
.info-val { color: var(--txt); font-weight: 500; }
.section-title {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--txt3);
font-family: var(--mono);
margin-bottom: 8px;
}
/* ─── Forms & inputs ─── */
input[type="text"],
input[type="number"],
select,
textarea {
font-family: var(--sans);
font-size: 12px;
padding: 7px 10px;
border-radius: 6px;
border: 0.5px solid var(--border2);
background: var(--bg);
color: var(--txt);
width: 100%;
max-width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
label.field-label {
display: block;
font-size: 10px;
font-family: var(--mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--txt3);
margin-bottom: 4px;
}
.field-group {
margin-bottom: 10px;
}
/* ─── Sidebar scroll & flex ─── */
.sidebar-section:last-child {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border2) transparent;
}
.sidebar-section:last-child::-webkit-scrollbar {
width: 6px;
}
.sidebar-section:last-child::-webkit-scrollbar-thumb {
background: var(--border2);
border-radius: 4px;
}
/* ─── Panels scroll ─── */
.panel {
scrollbar-width: thin;
scrollbar-color: var(--border2) transparent;
}
.panel::-webkit-scrollbar {
width: 8px;
}
.panel::-webkit-scrollbar-thumb {
background: var(--border2);
border-radius: 4px;
}
/* ─── Buttons ─── */
button.btn {
appearance: none;
border: 0.5px solid var(--border2);
}
button.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
button.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn.danger {
border-color: #E8B4A8;
background: var(--coral-light);
color: var(--coral);
}
.btn.danger:hover {
background: #F5DCD4;
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.btn.ghost:hover {
background: var(--bg2);
border-color: var(--border);
}
.btn.sm {
padding: 5px 10px;
font-size: 11px;
}
/* ─── Tabs accessibility ─── */
.tab:focus-visible {
outline: none;
box-shadow: inset 0 -2px 0 var(--accent);
color: var(--txt);
}
.phase-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--accent-light);
}
/* ─── Cards & canvases ─── */
.card-body canvas {
display: block;
max-width: 100%;
height: auto;
}
#distCanvas {
width: 100%;
max-width: 100%;
height: auto;
min-height: 80px;
}
/* ─── Links ─── */
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ─── Utility ─── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.muted { color: var(--txt3); }
.mono { font-family: var(--mono); }
.stack { display: flex; flex-direction: column; gap: 8px; }
/* ─── Responsive ─── */
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
margin: 0;
padding: 0;
border-radius: 0;
border-left: none;
border-right: none;
min-height: 100vh;
max-width: none;
}
.header {
flex-wrap: wrap;
gap: 10px;
}
.sidebar {
border-right: none;
border-bottom: 0.5px solid var(--border);
flex-direction: row;
flex-wrap: wrap;
max-height: none;
}
.sidebar-section {
flex: 1 1 140px;
border-bottom: 0.5px solid var(--border);
}
.sidebar-section:last-child {
flex: 1 1 100%;
max-height: 160px;
}
.acq-layout,
.nf-layout {
grid-template-columns: 1fr;
}
.lib-grid {
grid-template-columns: repeat(2, 1fr);
}
.tabbar {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
@media (max-width: 520px) {
.lib-grid {
grid-template-columns: 1fr;
}
.status-row {
flex-wrap: wrap;
gap: 8px;
}
.star {
width: 28px;
height: 28px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.dot.active {
animation: none;
}
}
/* ─── Print ─── */
@media print {
body {
background: white;
}
.app {
box-shadow: none;
margin: 0;
max-width: none;
min-height: auto;
}
.panel-footer,
.tabbar .tab:not(.active),
.btn {
display: none !important;
}
.panel {
display: flex !important;
overflow: visible;
}
}
</style>
</head>
<body>
<h2 class="sr-only">EEG mental-imagery classification paradigm — acquisition, neurofeedback, and object library dashboard</h2>
<div class="app">
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo-mark">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="13" stroke="var(--border2)" stroke-width="0.5"/>
<circle cx="14" cy="14" r="9" stroke="var(--accent)" stroke-width="0.5" stroke-dasharray="2 2"/>
<circle cx="14" cy="14" r="3" fill="var(--accent)"/>
<circle cx="14" cy="6" r="1.5" fill="var(--txt3)"/>
<circle cx="22" cy="10" r="1.5" fill="var(--txt3)"/>
<circle cx="22" cy="18" r="1.5" fill="var(--txt3)"/>
<circle cx="14" cy="22" r="1.5" fill="var(--txt3)"/>
<circle cx="6" cy="18" r="1.5" fill="var(--txt3)"/>
<circle cx="6" cy="10" r="1.5" fill="var(--txt3)"/>
</svg>
</div>
<div>
<div class="header-title">Neural State Classifier</div>
<div class="header-sub">EEG Mental Imagery Paradigm · 16ch Parieto-Occipital</div>
</div>
</div>
<div class="status-row">
<div class="status-pill"><span class="dot" id="dot-phase"></span> <span id="txt-phase">IDLE</span></div>
<div class="status-pill"><span class="dot" id="dot-session"></span> <span id="txt-session">— · —</span></div>
<div class="status-pill"><span class="dot" id="dot-board"></span> <span id="txt-hz">250 Hz · 16 ch</span></div>
</div>
</div>
<!-- SIDEBAR -->
<div class="sidebar">
<div class="sidebar-section">
<div class="sidebar-label">Backend</div>
<div id="backend-status" style="font-size:10px;font-family:var(--mono);color:var(--txt3);line-height:1.5;margin-bottom:10px">REST: … · WS: … · Board: …</div>
<label class="field-label" for="cfg-subject">Subject ID</label>
<input id="cfg-subject" type="text" value="S001" autocomplete="off">
<label class="field-label" for="cfg-class-a">Class A label</label>
<input id="cfg-class-a" type="text" value="Chair" autocomplete="off">
<label class="field-label" for="cfg-class-b">Class B label</label>
<input id="cfg-class-b" type="text" value="Spiral" autocomplete="off">
<label class="field-label" for="cfg-min-q">Min quality (1–5)</label>
<input id="cfg-min-q" type="number" min="1" max="5" value="4">
<label class="field-label" for="cfg-port">Serial port (hardware)</label>
<input id="cfg-port" type="text" placeholder="/dev/ttyUSB0 or COM3" autocomplete="off">
<div style="display:flex;flex-direction:column;gap:6px;margin-top:10px">
<button type="button" class="btn primary sm" onclick="applyConfiguration()">Apply configuration</button>
<button type="button" class="btn sm" onclick="connectBoard(true)">Connect · simulate</button>
<button type="button" class="btn sm" onclick="connectBoard(false)">Connect · hardware</button>
<button type="button" class="btn sm" onclick="disconnectBoard()">Disconnect board</button>
<button type="button" class="btn ghost sm" onclick="resetSession()">Reset session</button>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Phases</div>
<div class="phase-item done" onclick="showTab('acq')">
<div class="phase-num">1</div>
<div class="phase-text">Acquisition + self-rating</div>
</div>
<div class="phase-item done" onclick="showTab('acq')">
<div class="phase-num">2</div>
<div class="phase-text">Filtering · stable state</div>
</div>
<div class="phase-item active" onclick="showTab('nf')">
<div class="phase-num">3</div>
<div class="phase-text">Real-time neurofeedback</div>
</div>
<div class="phase-item" onclick="showTab('lib')">
<div class="phase-num">4</div>
<div class="phase-text">Multi-class object library</div>
</div>
<div class="phase-item" onclick="showTab('lib')">
<div class="phase-num">5</div>
<div class="phase-text">Cross-subject generalization</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Current session</div>
<div class="class-pair">
<div class="class-tag a">
<span class="class-name" id="ui-class-a-name">Chair</span>
<span class="class-trials" id="ui-class-a-n">0 trials</span>
</div>
<div class="class-tag b">
<span class="class-name" id="ui-class-b-name">Spiral</span>
<span class="class-trials" id="ui-class-b-n">0 trials</span>
</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-label">Metrics</div>
<div class="metric-mini">
<span class="metric-mini-label">Convergence (mean)</span>
<span class="metric-mini-value" id="metric-conv"></span>
</div>
<div class="metric-mini">
<span class="metric-mini-label">Trials kept</span>
<span class="metric-mini-value" id="metric-kept"></span>
</div>
<div class="metric-mini">
<span class="metric-mini-label">Mean score</span>
<span class="metric-mini-value" id="metric-mean"></span>
</div>
<div class="metric-mini">
<span class="metric-mini-label">Classifier</span>
<span class="metric-mini-value" id="metric-lda"></span>
</div>
</div>
<div class="sidebar-section" style="flex:1; overflow-y:auto;">
<div class="sidebar-label">Neural states</div>
<div id="sidebar-library-list"></div>
</div>
</div><!-- /sidebar -->
<!-- MAIN -->
<div class="main">
<!-- TABBAR -->
<div class="tabbar">
<div class="tab active" id="tab-acq" onclick="showTab('acq')">Acquisition</div>
<div class="tab" id="tab-nf" onclick="showTab('nf')">Neurofeedback</div>
<div class="tab" id="tab-model" onclick="showTab('model')">Model</div>
<div class="tab" id="tab-lib" onclick="showTab('lib')">Library</div>
</div>
<!-- ======== PANEL: ACQUISITION ======== -->
<div class="panel active" id="panel-acq">
<div class="acq-layout">
<!-- Protocol & stimulus (index.html / index-modular parity) -->
<div class="card acq-protocol-card">
<div class="card-header">
<span class="card-title">Protocol &amp; stimulus</span>
<span class="card-badge">Timing · markers</span>
</div>
<div class="card-body">
<div class="acq-settings-grid">
<div>
<label class="field-label" for="acq-protocol-mode">Protocol mode</label>
<select id="acq-protocol-mode" aria-describedby="acq-protocol-desc">
<option value="visual">Visual stimuli (real colors)</option>
<option value="imagery">Mental visualization (imagery)</option>
<option value="custom">Custom configuration</option>
</select>
<div id="acq-protocol-desc" class="protocol-desc" role="note">
<strong>Visual:</strong> short stimulus + gray ISI; align band powers with onsets.
</div>
</div>
<div>
<label class="field-label" for="acq-stimulus-type">Stimulus type</label>
<select id="acq-stimulus-type">
<option value="color">Color baseline</option>
<option value="shape">3D primitives</option>
<option value="combined" selected>Combined protocol</option>
</select>
<span class="field-hint">Same taxonomy as the standalone stimulus pages (color / shape / both).</span>
</div>
<div class="acq-section-label">Timing</div>
<div>
<label class="field-label" for="acq-prep-sec">Prep countdown before recording</label>
<input id="acq-prep-sec" type="number" min="0" max="120" step="0.5" value="3">
<span class="field-hint">Seconds before the backend trial starts (LSL marker + beep fire at trial onset).</span>
</div>
<div>
<label class="field-label" for="acq-stim-duration-ms">Stimulus duration (ms)</label>
<input id="acq-stim-duration-ms" type="number" min="100" max="30000" step="100" value="300">
<span class="field-hint">Displayed &amp; sent in LSL marker (external VR/stimulus app).</span>
</div>
<div>
<label class="field-label" for="acq-isi-ms">Inter-stimulus interval / gray mask (ms)</label>
<input id="acq-isi-ms" type="number" min="200" max="10000" step="100" value="2500">
</div>
<div>
<label class="field-label" for="acq-repetitions">Repetitions per block</label>
<input id="acq-repetitions" type="number" min="1" max="200" value="30">
</div>
<div>
<label class="field-label" for="acq-epoch-sec">Recording epoch (display)</label>
<input id="acq-epoch-sec" type="number" min="1" max="30" step="0.5" value="4">
<span class="field-hint">Progress bar only; backend epoch extraction stays at 4 s unless the server is updated.</span>
</div>
<div>
<label class="field-label" for="acq-analysis-window-ms">EEG analysis window (ms post-onset)</label>
<input id="acq-analysis-window-ms" type="number" min="200" max="3000" step="100" value="1000">
</div>
<div id="acq-imagery-block" style="display:none;grid-column:1/-1;width:100%">
<div class="acq-settings-grid" style="margin-top:4px">
<div>
<label class="check-row">
<input type="checkbox" id="acq-show-imagery-instructions" checked>
<span>Show imagery instructions on external display</span>
</label>
<label class="field-label" for="acq-instruction-duration-ms">Instruction duration (ms)</label>
<input id="acq-instruction-duration-ms" type="number" min="500" max="8000" step="100" value="2000">
</div>
</div>
</div>
<div class="acq-section-label">Recording &amp; cues</div>
<div>
<label class="check-row">
<input type="checkbox" id="acq-lsl-markers">
<span>Send LSL markers via backend (<code style="font-size:10px">POST /lsl/marker</code>)</span>
</label>
<label class="check-row">
<input type="checkbox" id="acq-audio-cue" checked>
<span>Audio cue at trial onset (short beep)</span>
</label>
</div>
<div>
<label class="field-label" for="acq-angular-size">Angular size (visual °)</label>
<select id="acq-angular-size">
<option value="5">5° · central</option>
<option value="7" selected>7° · standard</option>
<option value="10">10° · larger field</option>
<option value="33">33° · wide field</option>
<option value="full">Full screen</option>
</select>
</div>
<div class="acq-section-label">Blocks &amp; modular options</div>
<div>
<label class="check-row">
<input type="checkbox" id="acq-enable-breaks" checked>
<span>Enable breaks between series</span>
</label>
<label class="field-label" for="acq-trials-per-series">Trials per series (before break)</label>
<input id="acq-trials-per-series" type="number" min="5" max="100" step="5" value="15">
</div>
<div>
<label class="check-row">
<input type="checkbox" id="acq-randomize">
<span>Randomize trial order (logged locally)</span>
</label>
<label class="check-row">
<input type="checkbox" id="acq-enable-vr">
<span>WebXR / VR session (use external fullscreen stimulus page)</span>
</label>
</div>
<div>
<label class="field-label" for="acq-rotation-speed">3D rotation speed (rad/s)</label>
<input id="acq-rotation-speed" type="number" min="0" max="2" step="0.1" value="0.5">
<label class="check-row" style="margin-top:8px">
<input type="checkbox" id="acq-tracking-dots">
<span>Tracking dots (pilot testing)</span>
</label>
</div>
</div>
<details class="acq-details">
<summary>Ganzfeld pre-experiment (optional)</summary>
<div class="acq-details-body">
<label class="check-row">
<input type="checkbox" id="acq-ganzfeld-enable">
<span>Enable Ganzfeld sequence before color trials (long run-in)</span>
</label>
<span class="field-hint">Durations are stored for your external runner; not executed in this dashboard.</span>
<div id="acq-ganzfeld-fields" style="margin-top:10px;opacity:0.55;pointer-events:none">
<div class="field-group">
<label class="field-label" for="acq-ganzfeld-color-sec">Duration per color (s)</label>
<input id="acq-ganzfeld-color-sec" type="number" min="60" max="600" step="30" value="300">
</div>
<div class="field-group">
<label class="field-label" for="acq-ganzfeld-transition-sec">HSV transition (s)</label>
<input id="acq-ganzfeld-transition-sec" type="number" min="5" max="60" step="5" value="15">
</div>
<div class="field-group">
<label class="field-label" for="acq-ganzfeld-black-sec">Final black (s)</label>
<input id="acq-ganzfeld-black-sec" type="number" min="30" max="180" step="10" value="60">
</div>
</div>
</div>
</details>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;align-items:center">
<button type="button" class="btn sm danger" id="cancel-prep-btn" onclick="cancelPrep()" style="display:none">Cancel countdown</button>
<span id="acq-settings-status" class="muted" style="font-size:10px;font-family:var(--mono)">Settings saved in this browser</span>
</div>
</div>
</div>
<!-- Current trial -->
<div class="card">
<div class="card-header">
<span class="card-title">Current trial</span>
<span class="card-badge" id="badge-trial-id"></span>
</div>
<div class="card-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<button type="button" class="btn sm primary" onclick="startTrialClass('a')">Start · Class A</button>
<button type="button" class="btn sm primary" onclick="startTrialClass('b')">Start · Class B</button>
</div>
<div class="current-trial-info">
<div class="trial-class-label" id="trial-class-heading"></div>
<div class="trial-object" id="trial-object-name"></div>
<div class="trial-timer" id="timer-display">No active trial — start Class A or B</div>
</div>
<div class="progress-wrap">
<div class="progress-bar" id="trial-progress" style="width:0%"></div>
</div>
<div class="section-title" style="margin-top:12px">Self-rating</div>
<div class="rating-row">
<div class="star-group" id="stars">
<div class="star" data-v="1" onclick="rateTrial(1)">1</div>
<div class="star" data-v="2" onclick="rateTrial(2)">2</div>
<div class="star" data-v="3" onclick="rateTrial(3)">3</div>
<div class="star" data-v="4" onclick="rateTrial(4)">4</div>
<div class="star sel" data-v="5" onclick="rateTrial(5)">5</div>
</div>
<span id="rate-label" style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Excellent</span>
</div>
<div style="display:flex;gap:8px;margin-top:10px">
<button class="btn" onclick="sendPrompt('How can I improve EEG trial quality in a mental-imagery paradigm?')">↗ Help</button>
<button class="btn primary" onclick="confirmTrial()">Confirm trial →</button>
</div>
</div>
</div>
<!-- Topographie EEG -->
<div class="card">
<div class="card-header">
<span class="card-title">EEG topomap</span>
<span class="card-badge">Live</span>
</div>
<div class="card-body">
<div class="topo-container">
<canvas id="topoCanvas" width="160" height="160" style="border-radius:50%"></canvas>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px">
<div class="info-row"><span class="info-key">Alpha</span><span class="info-val" id="live-alpha" style="color:#185FA5"></span></div>
<div class="info-row"><span class="info-key">Beta</span><span class="info-val" id="live-beta" style="color:#0F6E56"></span></div>
<div class="info-row"><span class="info-key">Theta</span><span class="info-val" id="live-theta"></span></div>
<div class="info-row"><span class="info-key">Gamma</span><span class="info-val" id="live-gamma"></span></div>
</div>
</div>
</div>
<!-- Trial history -->
<div class="card" style="grid-column:1/-1">
<div class="card-header">
<span class="card-title" id="history-card-title">History · Class A</span>
<span class="card-badge" id="history-card-badge">0 trials · 0 kept</span>
</div>
<div class="card-body">
<div id="trial-history"></div>
</div>
</div>
</div>
<div class="panel-footer">
<span id="acq-footer-summary" style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Prep 3 s · stim 300 ms · ISI 2500 ms · epoch display 4 s</span>
<div style="display:flex;gap:8px">
<button class="btn" onclick="showTab('nf')">→ Neurofeedback</button>
</div>
</div>
</div>
<!-- ======== PANEL: NEUROFEEDBACK ======== -->
<div class="panel" id="panel-nf">
<div class="nf-layout">
<!-- Convergence tracking -->
<div class="card nf-full">
<div class="card-header">
<span class="card-title">Convergence to stable state</span>
<span class="card-badge">Phase 3 · Active</span>
</div>
<div class="card-body">
<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
<span class="section-title" id="nf-a-title">Class A</span>
<span id="nf-a-pct" style="font-size:11px;font-family:var(--mono);color:#185FA5"></span>
</div>
<div class="convergence-track">
<div class="conv-fill" id="nf-a-fill" style="width:0%;background:linear-gradient(to right,#E6F1FB,#B5D4F4)"></div>
<div class="conv-pct" id="nf-a-pct-bar">0%</div>
<div class="conv-label">Target</div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
<span class="section-title" id="nf-b-title">Class B</span>
<span id="nf-b-pct" style="font-size:11px;font-family:var(--mono);color:#0F6E56"></span>
</div>
<div class="convergence-track">
<div class="conv-fill" id="nf-b-fill" style="width:0%;background:linear-gradient(to right,#E1F5EE,#9FE1CB)"></div>
<div class="conv-pct" id="nf-b-pct-bar">0%</div>
<div class="conv-label">Target</div>
</div>
</div>
</div>
</div>
<!-- Signal waveform -->
<div class="card">
<div class="card-header">
<span class="card-title" id="wave-card-title">Raw signal · preview</span>
<span class="card-badge">250 Hz</span>
</div>
<div class="card-body" style="padding:8px">
<div class="waveform-wrap">
<canvas id="waveCanvas" width="300" height="80"></canvas>
</div>
</div>
</div>
<!-- Audio feedback -->
<div class="card">
<div class="card-header">
<span class="card-title">Audio feedback (NF)</span>
</div>
<div class="card-body">
<div class="audio-row">
<div class="audio-icon pos">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7h8M7 3l4 4-4 4" stroke="#0F6E56" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<div class="audio-text" id="nf-audio-target-title" style="color:#085041;font-weight:500">On target</div>
<div class="audio-freq" id="nf-audio-target-detail"></div>
</div>
</div>
<div class="audio-row">
<div class="audio-icon neg">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M11 7H3M7 3L3 7l4 4" stroke="#993C1D" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<div class="audio-text" id="nf-audio-neg-title" style="color:#712B13;font-weight:500">Divergence detected</div>
<div class="audio-freq" id="nf-audio-neg-detail"></div>
</div>
</div>
<div class="audio-row">
<div class="audio-icon" style="background:#EEEDFE">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="4" stroke="#534AB7" stroke-width="1.2"/>
<circle cx="7" cy="7" r="1.5" fill="#534AB7"/>
</svg>
</div>
<div>
<div class="audio-text" id="nf-audio-neutral-title" style="color:#3C3489;font-weight:500">Neutral band</div>
<div class="audio-freq" id="nf-audio-neutral-detail">Last trial NF · —</div>
</div>
</div>
</div>
</div>
<!-- Similarity score history -->
<div class="card nf-full">
<div class="card-header">
<span class="card-title">Distance to reference model · history</span>
</div>
<div class="card-body" style="padding:8px">
<canvas id="distCanvas" width="580" height="80"></canvas>
</div>
</div>
</div>
<div class="panel-footer">
<span style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Metric: cosine distance · 8D CSP space</span>
<button class="btn primary" onclick="sendPrompt('Which algorithms do you recommend for EEG neurofeedback in mental imagery?')">↗ NF algorithm notes</button>
</div>
</div>
<!-- ======== PANEL: MODEL ======== -->
<div class="panel" id="panel-model">
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;flex:1">
<div class="card">
<div class="card-header">
<span class="card-title">Feature extraction pipeline</span>
</div>
<div class="card-body">
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;padding-bottom:4px">
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:var(--accent-light);border:0.5px solid #B5D4F4;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#185FA5;text-align:center;padding:4px">EEG raw<br>16 ch</div>
</div>
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px"></div>
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:#E1F5EE;border:0.5px solid #9FE1CB;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#085041;text-align:center;padding:4px">Filter<br>1–40 Hz</div>
</div>
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px"></div>
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:#EEEDFE;border:0.5px solid #CECBF6;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#3C3489;text-align:center;padding:4px">CSP<br>8 comp.</div>
</div>
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px"></div>
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:#FAEEDA;border:0.5px solid #FAC775;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#633806;text-align:center;padding:4px">Log-var<br>features</div>
</div>
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px"></div>
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:#FAECE7;border:0.5px solid #F5C4B3;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#712B13;text-align:center;padding:4px">LDA<br>classifier</div>
</div>
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px"></div>
<div style="text-align:center;min-width:90px">
<div style="width:60px;height:44px;border-radius:8px;background:#EAF3DE;border:0.5px solid #C0DD97;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#27500A;text-align:center;padding:4px">Stable<br>state</div>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="card">
<div class="card-header"><span class="card-title">CSP parameters</span></div>
<div class="card-body">
<div class="info-row"><span class="info-key">n_components</span><span class="info-val">8</span></div>
<div class="info-row"><span class="info-key">reg</span><span class="info-val">0.05 (Ledoit-Wolf)</span></div>
<div class="info-row"><span class="info-key">log</span><span class="info-val">True</span></div>
<div class="info-row"><span class="info-key">norm_trace</span><span class="info-val">False</span></div>
<div class="info-row"><span class="info-key">epoch window</span><span class="info-val">2–8 s</span></div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Convergence criterion</span></div>
<div class="card-body">
<div class="info-row"><span class="info-key">Metric</span><span class="info-val">Cosine distance</span></div>
<div class="info-row"><span class="info-key">Convergence threshold</span><span class="info-val">d &lt; 0.12</span></div>
<div class="info-row"><span class="info-key">Rolling window</span><span class="info-val">5 trials</span></div>
<div class="info-row"><span class="info-key">Min. trials kept</span><span class="info-val">8</span></div>
<div class="info-row"><span class="info-key">Min. score</span><span class="info-val">≥ 4 / 5</span></div>
</div>
</div>
</div>
<button class="btn" style="align-self:flex-start" onclick="sendPrompt('Beyond CSP+LDA, which EEG mental-imagery classifiers work well with few channels?')">↗ Algorithm alternatives</button>
</div>
</div>
<!-- ======== PANEL: LIBRARY ======== -->
<div class="panel" id="panel-lib">
<div class="lib-layout">
<div class="section-title" id="lib-section-title">Library · session</div>
<div class="lib-grid">
<div class="lib-card" onclick="sendPrompt('How do you compare stable mental states for two imagined objects in a BCI?')">
<div class="lib-card-name">Apple</div>
<div class="lib-card-meta">Session 1 · 23 trials<br>11 kept · 3 NF sessions</div>
<div class="lib-card-bar"><div class="lib-card-fill" style="width:94%;background:#1D9E75"></div></div>
</div>
<div class="lib-card">
<div class="lib-card-name">Cube</div>
<div class="lib-card-meta">Session 1 · 20 trials<br>9 kept · 2 NF sessions</div>
<div class="lib-card-bar"><div class="lib-card-fill" style="width:88%;background:#185FA5"></div></div>
</div>
<div class="lib-card">
<div class="lib-card-name">Flame</div>
<div class="lib-card-meta">Session 2 · 25 trials<br>13 kept · 4 NF sessions</div>
<div class="lib-card-bar"><div class="lib-card-fill" style="width:91%;background:#534AB7"></div></div>
</div>
<div class="lib-card" style="border-color:#FAC775;background:#FAEEDA">
<div class="lib-card-name" style="color:#633806">Chair</div>
<div class="lib-card-meta" style="color:#854F0B">Session 3 · in progress<br>18 trials · NF active</div>
<div class="lib-card-bar"><div class="lib-card-fill" style="width:76%;background:#BA7517"></div></div>
</div>
<div class="lib-card" style="opacity:0.5;cursor:default">
<div class="lib-card-name">Spiral</div>
<div class="lib-card-meta">Session 3 · in progress<br>17 trials · phase 2</div>
<div class="lib-card-bar"><div class="lib-card-fill" style="width:31%;background:#888"></div></div>
</div>
<div class="lib-card" style="border-style:dashed;cursor:pointer;display:flex;align-items:center;justify-content:center;min-height:80px" onclick="sendPrompt('Which visual objects work best for a mental-imagery BCI paradigm?')">
<div style="text-align:center;color:var(--txt3)">
<div style="font-size:20px;margin-bottom:4px">+</div>
<div style="font-size:11px">New class</div>
</div>
</div>
</div>
<div class="section-title" style="margin-top:4px">Cross-subject generalization</div>
<div>
<div class="subject-row">
<div class="subject-avatar" style="background:#E6F1FB;color:#0C447C">S01</div>
<div class="subject-info">
<div class="subject-name">Subject 01</div>
<div class="subject-meta">4 classes · 86 trials · Ref</div>
</div>
<span class="subject-acc" style="color:#185FA5">91%</span>
</div>
<div class="subject-row">
<div class="subject-avatar" style="background:#E1F5EE;color:#085041">S02</div>
<div class="subject-info">
<div class="subject-name">Subject 02</div>
<div class="subject-meta">4 classes · 72 trials · Transfer</div>
</div>
<span class="subject-acc" style="color:#0F6E56">78%</span>
</div>
<div class="subject-row" style="opacity:0.5">
<div class="subject-avatar" style="background:#F1EFE8;color:#5F5E5A">S03</div>
<div class="subject-info">
<div class="subject-name">Subject 03</div>
<div class="subject-meta">In progress · session 1</div>
</div>
<span class="subject-acc" style="color:#888"></span>
</div>
</div>
<button class="btn" style="align-self:flex-start" onclick="sendPrompt('How should I implement transfer learning for cross-subject EEG BCI generalization?')">↗ Cross-subject transfer learning</button>
</div>
</div>
</div><!-- /main -->
</div><!-- /app -->
<script>
(function () {
'use strict';
const API = '';
const ACQ_STORAGE_KEY = 'eeg-site-acq-settings-v1';
let channelInfo = null;
let lastLive = null;
let lastState = null;
let ws = null;
let wsConnected = false;
let restOk = false;
let selectedQuality = 5;
let nfHistory = [];
let lastTrialFeedback = null;
let lastNfScore = null;
/** @type {{ endTime: number, ab: string, totalSec: number } | null} */
let prepState = null;
function getRecordingEpochSec() {
var el = document.getElementById('acq-epoch-sec');
var v = el ? parseFloat(el.value) : 4;
return isFinite(v) && v > 0 ? v : 4;
}
function playBeep() {
try {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
var ctx = new Ctx();
var o = ctx.createOscillator();
var g = ctx.createGain();
o.connect(g);
g.connect(ctx.destination);
o.frequency.value = 880;
o.type = 'sine';
g.gain.setValueAtTime(0.07, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.07);
o.start(ctx.currentTime);
o.stop(ctx.currentTime + 0.08);
} catch (e) {
console.warn(e);
}
}
function acqDefaults() {
return {
'acq-protocol-mode': 'visual',
'acq-stimulus-type': 'combined',
'acq-prep-sec': '3',
'acq-stim-duration-ms': '300',
'acq-isi-ms': '2500',
'acq-repetitions': '30',
'acq-epoch-sec': '4',
'acq-analysis-window-ms': '1000',
'acq-show-imagery-instructions': true,
'acq-instruction-duration-ms': '2000',
'acq-lsl-markers': false,
'acq-audio-cue': true,
'acq-angular-size': '7',
'acq-enable-breaks': true,
'acq-trials-per-series': '15',
'acq-randomize': false,
'acq-enable-vr': false,
'acq-rotation-speed': '0.5',
'acq-tracking-dots': false,
'acq-ganzfeld-enable': false,
'acq-ganzfeld-color-sec': '300',
'acq-ganzfeld-transition-sec': '15',
'acq-ganzfeld-black-sec': '60',
};
}
function loadAcqSettings() {
var defs = acqDefaults();
try {
var raw = localStorage.getItem(ACQ_STORAGE_KEY);
var parsed = raw ? JSON.parse(raw) : {};
Object.keys(defs).forEach(function (key) {
var el = document.getElementById(key);
if (!el) return;
var val = parsed[key] !== undefined ? parsed[key] : defs[key];
if (el.type === 'checkbox') el.checked = !!val;
else el.value = String(val);
});
} catch (e) {
console.warn(e);
}
syncProtocolDesc();
syncImageryVisibility();
syncGanzfeldPanel();
updateAcqFooter();
}
function saveAcqSettings() {
var defs = acqDefaults();
var out = {};
Object.keys(defs).forEach(function (key) {
var el = document.getElementById(key);
if (!el) return;
out[key] = el.type === 'checkbox' ? el.checked : el.value;
});
try {
localStorage.setItem(ACQ_STORAGE_KEY, JSON.stringify(out));
} catch (e) {}
var st = document.getElementById('acq-settings-status');
if (st) {
st.textContent = 'Saved · ' + new Date().toLocaleTimeString();
setTimeout(function () {
if (st) st.textContent = 'Settings saved in this browser';
}, 2500);
}
updateAcqFooter();
}
function bindAcqSettings() {
var defs = acqDefaults();
Object.keys(defs).forEach(function (key) {
var el = document.getElementById(key);
if (!el) return;
var ev = el.type === 'checkbox' ? 'change' : 'input';
el.addEventListener(ev, function () {
if (key === 'acq-protocol-mode') {
syncProtocolDesc();
syncImageryVisibility();
}
if (key === 'acq-ganzfeld-enable') syncGanzfeldPanel();
saveAcqSettings();
});
});
var proto = document.getElementById('acq-protocol-mode');
if (proto) {
proto.addEventListener('change', function () {
syncImageryVisibility();
});
}
}
function syncProtocolDesc() {
var sel = document.getElementById('acq-protocol-mode');
var box = document.getElementById('acq-protocol-desc');
if (!sel || !box) return;
var v = sel.value;
if (v === 'visual') {
box.innerHTML =
'<strong>Visual:</strong> Short on-screen stimulus + gray ISI (typically 300 ms + 2–3 s mask). Angular size 5–10°; align EEG window with onset.';
} else if (v === 'imagery') {
box.innerHTML =
'<strong>Imagery:</strong> Subject imagines the class without external flash; use instruction screen timing below.';
} else {
box.innerHTML =
'<strong>Custom:</strong> Combine timings below with your VR / Three.js runner (<code>index.html</code> / <code>index-modular.html</code>).';
}
}
function syncImageryVisibility() {
var sel = document.getElementById('acq-protocol-mode');
var block = document.getElementById('acq-imagery-block');
if (!block) return;
block.style.display = sel && sel.value === 'imagery' ? 'block' : 'none';
}
function syncGanzfeldPanel() {
var en = document.getElementById('acq-ganzfeld-enable');
var wrap = document.getElementById('acq-ganzfeld-fields');
if (!wrap) return;
var on = en && en.checked;
wrap.style.opacity = on ? '1' : '0.55';
wrap.style.pointerEvents = on ? 'auto' : 'none';
}
function updateAcqFooter() {
var el = document.getElementById('acq-footer-summary');
if (!el) return;
var prep = document.getElementById('acq-prep-sec');
var stim = document.getElementById('acq-stim-duration-ms');
var isi = document.getElementById('acq-isi-ms');
var ep = document.getElementById('acq-epoch-sec');
el.textContent =
'Prep ' +
(prep ? prep.value : '?') +
' s · stim ' +
(stim ? stim.value : '?') +
' ms · ISI ' +
(isi ? isi.value : '?') +
' ms · epoch display ' +
(ep ? ep.value : '?') +
' s';
}
function sendPrompt(text) {
console.info('[Help]', text);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () {});
}
}
async function api(path, opts) {
var r = await fetch(API + path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts || {}));
var ct = r.headers.get('content-type') || '';
var j = {};
if (ct.indexOf('application/json') !== -1) {
try { j = await r.json(); } catch (e) { j = {}; }
}
if (!r.ok) {
var msg = (j.detail && (typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail))) || j.error || r.statusText;
throw new Error(msg);
}
if (j.error) throw new Error(j.error);
return j;
}
async function loadChannelInfo() {
try {
channelInfo = await api('/channels/info', { method: 'GET' });
} catch (e) {
console.warn('channels/info', e);
}
}
function wsUrl() {
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
return p + '//' + location.host + '/ws';
}
function connectWS() {
if (ws && ws.readyState === WebSocket.OPEN) return;
try {
ws = new WebSocket(wsUrl());
} catch (e) {
console.warn(e);
return;
}
ws.onopen = function () {
wsConnected = true;
updateBackendStatus();
try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {}
};
ws.onmessage = function (ev) {
var d = JSON.parse(ev.data);
if (d.type === 'live_eeg') {
lastLive = d;
drawTopoFromLive();
drawWaveFromLive();
updateBandDisplays(d);
} else if (d.type === 'state' || d.type === 'state_update') {
var copy = Object.assign({}, d);
delete copy.type;
renderState(copy);
} else if (d.type === 'trial_ended') {
if (typeof d.nf_score === 'number') {
nfHistory.push(d.nf_score);
if (nfHistory.length > 80) nfHistory.shift();
lastNfScore = d.nf_score;
lastTrialFeedback = d.feedback || null;
updateAudioFeedbackUI();
}
refreshState();
drawDistCanvas();
} else if (d.type === 'pong') {
/* ignore */
}
};
ws.onclose = function () {
wsConnected = false;
updateBackendStatus();
setTimeout(connectWS, 2500);
};
ws.onerror = function () {};
}
function updateBackendStatus() {
var el = document.getElementById('backend-status');
if (!el) return;
var board = lastState && lastState.board_connected;
el.textContent =
'REST: ' + (restOk ? 'OK' : '…') +
' · WS: ' + (wsConnected ? 'live' : '…') +
' · Board: ' + (board ? 'on' : 'off');
}
async function refreshState() {
try {
var st = await api('/state', { method: 'GET' });
restOk = true;
renderState(st);
} catch (e) {
restOk = false;
console.warn('/state', e);
}
updateBackendStatus();
}
function findActiveTrial(st) {
var all = (st.class_a_trials || []).concat(st.class_b_trials || []);
var pending = all.filter(function (t) { return t.quality_score == null; });
if (!pending.length) return null;
return pending.reduce(function (a, b) { return a.trial_id > b.trial_id ? a : b; });
}
function meanBand(arr) {
if (!arr || !arr.length) return 0;
var s = 0;
for (var i = 0; i < arr.length; i++) s += arr[i];
return s / arr.length;
}
function fmtUvSq(v) {
if (!isFinite(v) || v === 0) return '—';
return v.toExponential(2) + ' (arb.)';
}
function renderState(st) {
lastState = st;
var cfg = st.config || {};
var phase = st.phase || 'IDLE';
var dotPhase = document.getElementById('dot-phase');
if (dotPhase) {
dotPhase.className = 'dot' + (phase !== 'IDLE' ? ' active' : '');
}
var txtPhase = document.getElementById('txt-phase');
if (txtPhase) txtPhase.textContent = phase;
var sid = cfg.subject_id || '—';
var sess = (cfg.session_id || '').split('_').pop() || '—';
var txtSession = document.getElementById('txt-session');
if (txtSession) txtSession.textContent = sid + ' · ' + sess;
var dotBoard = document.getElementById('dot-board');
if (dotBoard) dotBoard.className = 'dot' + (st.board_connected ? ' active' : '');
var dotSession = document.getElementById('dot-session');
if (dotSession) dotSession.className = 'dot' + (st.total_trials > 0 ? ' warn' : '');
document.getElementById('ui-class-a-name').textContent = cfg.class_a || 'A';
document.getElementById('ui-class-b-name').textContent = cfg.class_b || 'B';
var aTrials = st.class_a_trials || [];
var bTrials = st.class_b_trials || [];
document.getElementById('ui-class-a-n').textContent = aTrials.length + ' trials';
document.getElementById('ui-class-b-n').textContent = bTrials.length + ' trials';
var ns = st.neural_states || {};
var keys = Object.keys(ns);
var convSum = 0;
var convN = 0;
keys.forEach(function (k) {
convSum += ns[k].convergence_score || 0;
convN++;
});
document.getElementById('metric-conv').textContent =
convN ? Math.round((convSum / convN) * 100) + '%' : '—';
var minQ = cfg.min_quality_for_model != null ? cfg.min_quality_for_model : 4;
var allRated = aTrials.concat(bTrials).filter(function (t) { return t.quality_score != null; });
var kept = allRated.filter(function (t) { return t.quality_score >= minQ; }).length;
document.getElementById('metric-kept').textContent = kept + ' / ' + allRated.length;
var scN = 0;
var totalSc = 0;
allRated.forEach(function (t) {
totalSc += t.quality_score;
scN++;
});
document.getElementById('metric-mean').textContent =
scN ? (totalSc / scN).toFixed(1) + ' / 5' : '—';
document.getElementById('metric-lda').textContent = st.classifier_trained ? 'trained' : 'not trained';
var libList = document.getElementById('sidebar-library-list');
if (libList) {
libList.innerHTML = '';
keys.forEach(function (label) {
var info = ns[label];
var row = document.createElement('div');
row.className = 'lib-entry';
row.innerHTML =
'<span class="lib-dot" style="background:#185FA5"></span>' +
'<span class="lib-text">' +
label +
'</span>' +
'<span class="lib-score">' +
Math.round((info.convergence_score || 0) * 100) +
'% · n=' +
info.n_trials +
'</span>';
libList.appendChild(row);
});
if (!keys.length) {
libList.innerHTML = '<div class="muted" style="font-size:11px;padding:4px">No stable states yet</div>';
}
}
var ca = cfg.class_a || 'Class A';
var cb = cfg.class_b || 'Class B';
var pctA = ns[ca] ? Math.round((ns[ca].convergence_score || 0) * 100) : 0;
var pctB = ns[cb] ? Math.round((ns[cb].convergence_score || 0) * 100) : 0;
var nfAT = document.getElementById('nf-a-title');
var nfBT = document.getElementById('nf-b-title');
if (nfAT) nfAT.textContent = ca + ' (Class A)';
if (nfBT) nfBT.textContent = cb + ' (Class B)';
var elAp = document.getElementById('nf-a-pct');
var elBp = document.getElementById('nf-b-pct');
if (elAp) elAp.textContent = ns[ca] ? pctA + '% converged' : '—';
if (elBp) elBp.textContent = ns[cb] ? pctB + '% converged' : '—';
var fillA = document.getElementById('nf-a-fill');
var fillB = document.getElementById('nf-b-fill');
var barA = document.getElementById('nf-a-pct-bar');
var barB = document.getElementById('nf-b-pct-bar');
if (fillA) fillA.style.width = pctA + '%';
if (fillB) fillB.style.width = pctB + '%';
if (barA) barA.textContent = pctA + '%';
if (barB) barB.textContent = pctB + '%';
var active = findActiveTrial(st);
var badge = document.getElementById('badge-trial-id');
var obj = document.getElementById('trial-object-name');
var head = document.getElementById('trial-class-heading');
if (badge) badge.textContent = active ? 'Trial ' + active.trial_id : '—';
if (obj) obj.textContent = active ? active.class_label : '—';
if (head) {
if (!active) head.textContent = '—';
else if (active.class_label === ca) head.textContent = 'CLASS A · Imagine';
else if (active.class_label === cb) head.textContent = 'CLASS B · Imagine';
else head.textContent = active.class_label;
}
var ht = document.getElementById('history-card-title');
var hb = document.getElementById('history-card-badge');
if (ht) ht.textContent = 'History · Class A (' + ca + ')';
var ratedA = aTrials.filter(function (t) { return t.quality_score != null; });
var keptA = ratedA.filter(function (t) { return t.quality_score >= minQ; }).length;
if (hb) hb.textContent = ratedA.length + ' trials · ' + keptA + ' kept';
renderHistoryClassA(st, minQ, ca);
var libTitle = document.getElementById('lib-section-title');
if (libTitle) libTitle.textContent = 'Library · ' + sid;
updateBackendStatus();
updateTrialTimer(st);
updateAudioFeedbackUI();
}
function updateAudioFeedbackUI() {
var fb = lastTrialFeedback;
var t1 = document.getElementById('nf-audio-target-detail');
var t2 = document.getElementById('nf-audio-neg-detail');
var t3 = document.getElementById('nf-audio-neutral-detail');
if (fb && t1) {
t1.textContent = fb.freq_hz + ' Hz · vol ' + fb.volume + ' · ' + fb.tone;
} else if (t1) t1.textContent = '—';
if (t2) t2.textContent = lastNfScore != null && lastNfScore < 0.35 ? 'Low NF score' : '—';
if (t3) {
t3.textContent =
'Last trial NF · ' + (lastNfScore != null ? lastNfScore.toFixed(3) : '—');
}
}
function updateTrialTimer(st) {
var el = document.getElementById('timer-display');
var prog = document.getElementById('trial-progress');
var badge = document.getElementById('badge-trial-id');
if (prepState) {
var left = Math.max(0, (prepState.endTime - Date.now()) / 1000);
var pct =
prepState.totalSec > 0
? Math.min(100, ((prepState.totalSec - left) / prepState.totalSec) * 100)
: 0;
var cls = prepState.ab === 'a' ? 'Class A' : 'Class B';
if (el) {
el.textContent =
'Prep: ' +
left.toFixed(1) +
' s · then ' +
cls +
' recording';
}
if (prog) prog.style.width = pct + '%';
if (badge) badge.textContent = 'Prep';
var ca = document.getElementById('cfg-class-a').value.trim();
var cb = document.getElementById('cfg-class-b').value.trim();
var upcoming = prepState.ab === 'a' ? ca : cb;
var objEl = document.getElementById('trial-object-name');
var headEl = document.getElementById('trial-class-heading');
if (objEl) objEl.textContent = upcoming || '…';
if (headEl) {
headEl.textContent =
prepState.ab === 'a' ? 'CLASS A · upcoming' : 'CLASS B · upcoming';
}
return;
}
var active = findActiveTrial(st);
if (!active) {
if (el) el.textContent = 'No active trial — start Class A or B';
if (prog) prog.style.width = '0%';
return;
}
var epochSec = getRecordingEpochSec();
var onsetMs = active.onset_time * 1000;
var elapsed = (Date.now() - onsetMs) / 1000;
var left = Math.max(0, epochSec - elapsed);
if (el) {
el.textContent =
left.toFixed(1) +
' s left · Trial #' +
active.trial_id +
' · ' +
active.class_label;
}
if (prog) prog.style.width = Math.min(100, (elapsed / epochSec) * 100) + '%';
}
function renderHistoryClassA(st, minQ, classAName) {
var h = document.getElementById('trial-history');
if (!h) return;
h.innerHTML = '';
var trials = (st.class_a_trials || []).filter(function (t) {
return t.quality_score != null;
});
var colors = ['', '#E24B4A', '#EF9F27', '#888', '#1D9E75', '#185FA5'];
trials.forEach(function (t, i) {
var r = t.quality_score;
var keep = r >= minQ;
var barW = (r / 5) * 100;
var row = document.createElement('div');
row.className = 'trial-row';
row.innerHTML =
'<div class="trial-num">' +
t.trial_id +
'</div>' +
'<div class="trial-bar-wrap"><div class="trial-bar" style="width:' +
barW +
'%;background:' +
colors[r] +
'"></div></div>' +
'<div class="trial-score" style="color:' +
colors[r] +
'">' +
r +
'</div>' +
'<div class="trial-badge ' +
(keep ? 'keep' : 'drop') +
'">' +
(keep ? '✓ keep' : '✗ drop') +
'</div>';
h.appendChild(row);
});
if (!trials.length) {
h.innerHTML =
'<div class="muted" style="font-size:11px;padding:8px">No Class A trials rated yet (' +
classAName +
').</div>';
}
}
function channelPositionsForTopo() {
if (!channelInfo || !channelInfo.channel_names) return null;
var pos = channelInfo.positions || {};
var list = [];
channelInfo.channel_names.forEach(function (name) {
var xy = pos[name];
if (!xy) return;
var px = Array.isArray(xy) ? xy[0] : xy.x;
var py = Array.isArray(xy) ? xy[1] : xy.y;
list.push({ name: name, x: 80 + px * 100, y: 80 - py * 100 });
});
return list.length ? list : null;
}
function drawTopoFromLive() {
var c = document.getElementById('topoCanvas');
if (!c) return;
var ctx = c.getContext('2d');
var W = 160;
var H = 160;
var cx = 80;
var cy = 80;
var R = 72;
var topo = lastLive && lastLive.topomap;
var base = channelPositionsForTopo();
var channels;
if (base && topo) {
var vals = base.map(function (ch) {
return topo[ch.name] != null ? topo[ch.name] : 0;
});
var vmin = Math.min.apply(null, vals);
var vmax = Math.max.apply(null, vals);
var span = vmax - vmin || 1;
channels = base.map(function (ch, i) {
return {
x: ch.x,
y: ch.y,
v: (vals[i] - vmin) / span,
};
});
} else {
channels = [
{ x: 80, y: 20, v: 0.35 },
{ x: 120, y: 35, v: 0.5 },
{ x: 140, y: 70, v: 0.55 },
{ x: 140, y: 110, v: 0.6 },
{ x: 120, y: 135, v: 0.5 },
{ x: 80, y: 148, v: 0.45 },
{ x: 40, y: 135, v: 0.5 },
{ x: 20, y: 110, v: 0.55 },
{ x: 20, y: 70, v: 0.58 },
{ x: 40, y: 35, v: 0.4 },
{ x: 80, y: 60, v: 0.3 },
{ x: 110, y: 75, v: 0.48 },
{ x: 110, y: 105, v: 0.52 },
{ x: 80, y: 118, v: 0.5 },
{ x: 50, y: 105, v: 0.53 },
{ x: 50, y: 75, v: 0.47 },
];
}
var imgData = ctx.createImageData(W, H);
for (var px = 0; px < W; px++) {
for (var py = 0; py < H; py++) {
var dx = px - cx;
var dy = py - cy;
if (dx * dx + dy * dy > R * R) continue;
var wSum = 0;
var vSum = 0;
channels.forEach(function (ch) {
var d2 = (px - ch.x) * (px - ch.x) + (py - ch.y) * (py - ch.y) + 0.001;
var w = 1 / d2;
wSum += w;
vSum += w * ch.v;
});
var v = vSum / wSum;
var r;
var g;
var b;
if (v < 0.5) {
var t = v * 2;
r = Math.round(t * 100);
g = Math.round(t * 150);
b = Math.round(180 + t * 50);
} else {
var t2 = (v - 0.5) * 2;
r = Math.round(100 + t2 * 155);
g = Math.round(150 - t2 * 100);
b = Math.round(230 - t2 * 210);
}
var idx = (py * W + px) * 4;
imgData.data[idx] = r;
imgData.data[idx + 1] = g;
imgData.data[idx + 2] = b;
imgData.data[idx + 3] = 200;
}
}
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.clip();
ctx.putImageData(imgData, 0, 0);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(128,128,128,0.3)';
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(4, 80, 4, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(128,128,128,0.3)';
ctx.fill();
ctx.beginPath();
ctx.arc(156, 80, 4, 0, Math.PI * 2);
ctx.fill();
channels.forEach(function (ch) {
ctx.beginPath();
ctx.arc(ch.x, ch.y, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.5;
ctx.stroke();
});
}
function drawWaveFromLive() {
var c = document.getElementById('waveCanvas');
if (!c) return;
var parent = c.parentElement;
var rect = parent ? parent.getBoundingClientRect() : { width: 300 };
var dpr = window.devicePixelRatio || 1;
var W = Math.floor(rect.width * dpr) || 300;
var H = Math.floor(80 * dpr);
if (c.width !== W) c.width = W;
if (c.height !== H) c.height = H;
c.style.width = W / dpr + 'px';
c.style.height = H / dpr + 'px';
var ctx = c.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.scale(dpr, dpr);
var drawW = W / dpr;
var drawH = H / dpr;
var raw = lastLive && lastLive.raw_samples;
if (!raw || !raw.length) {
ctx.strokeStyle = 'rgba(24,95,165,0.25)';
ctx.beginPath();
ctx.moveTo(0, drawH / 2);
ctx.lineTo(drawW, drawH / 2);
ctx.stroke();
return;
}
var colors = ['#185FA5', '#0F6E56', '#3C3489', '#BA7517'];
for (var ch = 0; ch < raw.length; ch++) {
var samp = raw[ch];
if (!samp || !samp.length) continue;
ctx.beginPath();
var minV = samp[0];
var maxV = samp[0];
for (var i = 1; i < samp.length; i++) {
if (samp[i] < minV) minV = samp[i];
if (samp[i] > maxV) maxV = samp[i];
}
var span = maxV - minV || 1;
for (var x = 0; x < samp.length; x++) {
var nx = (x / (samp.length - 1)) * drawW;
var ny = drawH * 0.15 + (1 - (samp[x] - minV) / span) * (drawH * 0.7) + ch * 4;
if (x === 0) ctx.moveTo(nx, ny);
else ctx.lineTo(nx, ny);
}
ctx.strokeStyle = colors[ch % colors.length];
ctx.lineWidth = 1;
ctx.stroke();
}
}
function updateBandDisplays(d) {
if (!d) return;
var a = document.getElementById('live-alpha');
var b = document.getElementById('live-beta');
var t = document.getElementById('live-theta');
var g = document.getElementById('live-gamma');
if (a) a.textContent = d.alpha_power ? fmtUvSq(meanBand(d.alpha_power)) : '—';
if (b) b.textContent = d.beta_power ? fmtUvSq(meanBand(d.beta_power)) : '—';
if (t) t.textContent = d.theta_power ? fmtUvSq(meanBand(d.theta_power)) : '—';
if (g) g.textContent = d.gamma_power ? fmtUvSq(meanBand(d.gamma_power)) : '—';
var names = d.channels || (channelInfo && channelInfo.channel_names);
var wtitle = document.getElementById('wave-card-title');
if (wtitle && names && names.length >= 4) {
wtitle.textContent =
'Raw signal · ' + names[0] + ' · ' + names[4] + ' · ' + names[8] + ' · ' + names[12];
}
}
function drawDistCanvas() {
var c = document.getElementById('distCanvas');
if (!c) return;
var ctx = c.getContext('2d');
var rect = c.parentElement ? c.parentElement.getBoundingClientRect() : { width: 580 };
var W = Math.floor(rect.width) || 580;
var H = 80;
if (c.width !== W) c.width = W;
c.height = H;
var data = nfHistory.slice();
ctx.clearRect(0, 0, W, H);
var pad = 20;
var maxD = 1;
ctx.beginPath();
ctx.setLineDash([3, 3]);
var ty = H - pad - (0.35 / maxD) * (H - 2 * pad);
ctx.moveTo(pad, ty);
ctx.lineTo(W - pad, ty);
ctx.strokeStyle = 'rgba(128,128,128,0.4)';
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.setLineDash([]);
if (data.length < 2) {
ctx.font = '11px monospace';
ctx.fillStyle = 'rgba(128,128,128,0.7)';
ctx.fillText('NF scores appear after rated trials', pad, H / 2);
return;
}
ctx.beginPath();
data.forEach(function (d, i) {
var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
var y = H - pad - (d / maxD) * (H - 2 * pad);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#185FA5';
ctx.lineWidth = 1.2;
ctx.stroke();
data.forEach(function (d, i) {
var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
var y = H - pad - (d / maxD) * (H - 2 * pad);
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d >= 0.65 ? '#1D9E75' : '#185FA5';
ctx.fill();
});
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(128,128,128,0.7)';
ctx.fillText('NF similarity (higher is better)', pad, 12);
}
window.showTab = function (t) {
document.querySelectorAll('.tab').forEach(function (x) {
x.classList.remove('active');
});
document.querySelectorAll('.panel').forEach(function (x) {
x.classList.remove('active');
});
document.getElementById('tab-' + t).classList.add('active');
document.getElementById('panel-' + t).classList.add('active');
if (t === 'nf') drawDistCanvas();
if (t === 'acq') drawTopoFromLive();
if (t === 'nf') drawWaveFromLive();
};
window.rateTrial = function (v) {
selectedQuality = v;
document.querySelectorAll('.star').forEach(function (s) {
s.classList.toggle('sel', parseInt(s.dataset.v, 10) === v);
});
var labels = ['', 'Poor', 'Fair', 'Average', 'Good', 'Excellent'];
document.getElementById('rate-label').textContent = labels[v];
};
window.applyConfiguration = async function () {
var subject_id = document.getElementById('cfg-subject').value.trim();
var class_a = document.getElementById('cfg-class-a').value.trim();
var class_b = document.getElementById('cfg-class-b').value.trim();
var min_quality = parseInt(document.getElementById('cfg-min-q').value, 10) || 4;
var q =
'/session/configure?subject_id=' +
encodeURIComponent(subject_id) +
'&class_a=' +
encodeURIComponent(class_a) +
'&class_b=' +
encodeURIComponent(class_b) +
'&min_quality=' +
min_quality;
await api(q, { method: 'POST' });
await refreshState();
};
window.connectBoard = async function (simulate) {
var port =
document.getElementById('cfg-port').value.trim() || '/dev/ttyUSB0';
var q =
'/board/connect?port=' +
encodeURIComponent(port) +
'&simulate=' +
(!!simulate);
await api(q, { method: 'POST' });
connectWS();
await refreshState();
};
window.disconnectBoard = async function () {
await api('/board/disconnect', { method: 'POST' });
await refreshState();
};
window.resetSession = async function () {
await api('/session/reset', { method: 'POST' });
nfHistory = [];
lastNfScore = null;
lastTrialFeedback = null;
await refreshState();
drawDistCanvas();
};
async function doStartTrial(ab) {
var ca = document.getElementById('cfg-class-a').value.trim();
var cb = document.getElementById('cfg-class-b').value.trim();
var label = ab === 'a' ? ca : cb;
if (!label) return;
var proto = document.getElementById('acq-protocol-mode');
var stim = document.getElementById('acq-stimulus-type');
var stimMs = document.getElementById('acq-stim-duration-ms');
var isi = document.getElementById('acq-isi-ms');
var rep = document.getElementById('acq-repetitions');
var epochEl = document.getElementById('acq-epoch-sec');
var analysis = document.getElementById('acq-analysis-window-ms');
var instr = document.getElementById('acq-instruction-duration-ms');
var angular = document.getElementById('acq-angular-size');
var rot = document.getElementById('acq-rotation-speed');
var marker =
'UI_TRIAL_ONSET;class=' +
label +
';protocol=' +
(proto ? proto.value : '') +
';stimulus=' +
(stim ? stim.value : '') +
';stimMs=' +
(stimMs ? stimMs.value : '') +
';isiMs=' +
(isi ? isi.value : '') +
';reps=' +
(rep ? rep.value : '') +
';epochSec=' +
(epochEl ? epochEl.value : '') +
';analysisMs=' +
(analysis ? analysis.value : '') +
';instrMs=' +
(instr ? instr.value : '') +
';ang=' +
(angular ? angular.value : '') +
';rot=' +
(rot ? rot.value : '');
var lslEl = document.getElementById('acq-lsl-markers');
if (lslEl && lslEl.checked) {
try {
await api('/lsl/marker?marker=' + encodeURIComponent(marker), { method: 'POST' });
} catch (e) {
console.warn('lsl marker', e);
}
}
var audioEl = document.getElementById('acq-audio-cue');
if (audioEl && audioEl.checked) {
playBeep();
}
await api('/trial/start?class_label=' + encodeURIComponent(label), { method: 'POST' });
await refreshState();
var cancelBtn = document.getElementById('cancel-prep-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
}
window.cancelPrep = function () {
prepState = null;
var cancelBtn = document.getElementById('cancel-prep-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
updateTrialTimer(lastState);
};
window.startTrialClass = async function (ab) {
var prepSec = parseFloat(document.getElementById('acq-prep-sec').value);
if (!isFinite(prepSec) || prepSec < 0) prepSec = 0;
if (prepSec > 0) {
prepState = {
endTime: Date.now() + prepSec * 1000,
ab: ab,
totalSec: prepSec,
};
var cancelBtn = document.getElementById('cancel-prep-btn');
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
updateTrialTimer(lastState);
return;
}
await doStartTrial(ab);
};
window.confirmTrial = async function () {
try {
var res = await api('/trial/end?quality=' + selectedQuality, { method: 'POST' });
if (typeof res.nf_score === 'number') {
nfHistory.push(res.nf_score);
if (nfHistory.length > 80) nfHistory.shift();
lastNfScore = res.nf_score;
lastTrialFeedback = res.feedback || null;
updateAudioFeedbackUI();
}
await refreshState();
drawDistCanvas();
} catch (e) {
alert(e.message || String(e));
}
};
function tickLoop() {
if (prepState) {
var left = (prepState.endTime - Date.now()) / 1000;
if (left <= 0) {
var ab = prepState.ab;
prepState = null;
var cancelBtn = document.getElementById('cancel-prep-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
doStartTrial(ab).catch(function (e) {
alert(e.message || String(e));
});
}
updateTrialTimer(lastState);
return;
}
if (lastState) updateTrialTimer(lastState);
}
window.addEventListener('load', async function () {
await loadChannelInfo();
loadAcqSettings();
bindAcqSettings();
try {
await api('/health', { method: 'GET' });
restOk = true;
} catch (e) {
restOk = false;
}
connectWS();
await refreshState();
setInterval(refreshState, 4000);
setInterval(tickLoop, 100);
setInterval(function () {
if (lastLive && document.getElementById('panel-acq').classList.contains('active')) {
drawTopoFromLive();
}
}, 200);
drawTopoFromLive();
drawDistCanvas();
});
window.addEventListener('resize', function () {
drawWaveFromLive();
drawDistCanvas();
});
})();
</script>
</body>
</html>