| <!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 {
|
|
|
| --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 {
|
| 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 {
|
| 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 {
|
| display: flex;
|
| flex-direction: column;
|
| overflow: hidden;
|
| }
|
|
|
|
|
| .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; }
|
|
|
|
|
| .panel { display: none; flex: 1; overflow-y: auto; }
|
| .panel.active { display: flex; flex-direction: column; }
|
|
|
|
|
| .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; }
|
|
|
|
|
| .topo-container {
|
| display: flex;
|
| justify-content: center;
|
| padding: 8px 0;
|
| }
|
|
|
|
|
| .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-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-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;
|
| }
|
|
|
|
|
| .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);
|
| }
|
|
|
|
|
| .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;
|
| }
|
|
|
|
|
| .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; }
|
|
|
|
|
| .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-wrap {
|
| height: 80px;
|
| position: relative;
|
| overflow: hidden;
|
| }
|
|
|
| canvas.waveform {
|
| width: 100%;
|
| height: 80px;
|
| }
|
|
|
|
|
| .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;
|
| }
|
|
|
|
|
| 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-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;
|
| }
|
|
|
|
|
| .panel {
|
| scrollbar-width: thin;
|
| scrollbar-color: var(--border2) transparent;
|
| }
|
|
|
| .panel::-webkit-scrollbar {
|
| width: 8px;
|
| }
|
|
|
| .panel::-webkit-scrollbar-thumb {
|
| background: var(--border2);
|
| border-radius: 4px;
|
| }
|
|
|
|
|
| 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;
|
| }
|
|
|
|
|
| .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);
|
| }
|
|
|
|
|
| .card-body canvas {
|
| display: block;
|
| max-width: 100%;
|
| height: auto;
|
| }
|
|
|
| #distCanvas {
|
| width: 100%;
|
| max-width: 100%;
|
| height: auto;
|
| min-height: 80px;
|
| }
|
|
|
|
|
| a {
|
| color: var(--accent);
|
| text-decoration: none;
|
| }
|
|
|
| a:hover {
|
| text-decoration: underline;
|
| }
|
|
|
|
|
| .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; }
|
|
|
|
|
| @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;
|
| }
|
| }
|
|
|
|
|
| @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">
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <div class="main">
|
|
|
|
|
| <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>
|
|
|
|
|
| <div class="panel active" id="panel-acq">
|
| <div class="acq-layout">
|
|
|
|
|
| <div class="card acq-protocol-card">
|
| <div class="card-header">
|
| <span class="card-title">Protocol & 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 & 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 & 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 & 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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <div class="panel" id="panel-nf">
|
| <div class="nf-layout">
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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 < 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>
|
|
|
|
|
| <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>
|
|
|
| </div>
|
|
|
| <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>
|
|
|