| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>Neuraxon 2.0 — Sphero Brain + Word Writer</title> |
| | <style> |
| | @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap'); |
| | :root { |
| | --bg:#06080e;--panel:#0b1019;--panel2:#0f1623;--border:rgba(120,200,255,0.08); |
| | --border2:rgba(120,200,255,0.18);--txt:#c8ddf0;--txt2:#6a8ca8;--txt3:#384f66; |
| | --exc:#ff3d5a;--inh:#3d7aff;--neu:#2a3548;--cyan:#00e5ff;--green:#00e676; |
| | --amber:#ffab00;--da:#ff6d00;--sht:#aa00ff;--ach:#00e676;--na:#ff1744; |
| | } |
| | *{box-sizing:border-box;margin:0;padding:0} |
| | html,body{height:100%;background:var(--bg);color:var(--txt);font-family:'JetBrains Mono',monospace;overflow-x:hidden} |
| |
|
| | .header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,rgba(11,16,25,.95),rgba(6,8,14,.9));backdrop-filter:blur(12px);position:sticky;top:0;z-index:100} |
| | .header h1{font-family:'Syne',sans-serif;font-size:15px;font-weight:700} |
| | .header h1 span{color:var(--cyan)} .header h1 em{font-style:normal;color:var(--txt2);font-weight:400;font-size:10px;margin-left:8px} |
| | .conn-badge{font-size:9px;font-weight:600;padding:4px 12px;border-radius:20px;border:1px solid var(--border2);text-transform:uppercase;letter-spacing:.1em} |
| | .conn-badge.off{color:var(--exc);border-color:rgba(255,61,90,.3)} .conn-badge.on{color:var(--green);border-color:rgba(0,230,118,.3);box-shadow:0 0 12px rgba(0,230,118,.15)} |
| |
|
| | .tabs{display:flex;border-bottom:1px solid var(--border);background:var(--panel)} |
| | .tab{flex:1;padding:10px;text-align:center;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--txt3);cursor:pointer;border-bottom:2px solid transparent;font-family:'Syne',sans-serif} |
| | .tab:hover{color:var(--txt2)} .tab.active{color:var(--cyan);border-bottom-color:var(--cyan);background:rgba(0,229,255,.03)} |
| |
|
| | .app{display:grid;grid-template-columns:1fr 290px;height:calc(100vh - 83px)} |
| | @media(max-width:900px){.app{grid-template-columns:1fr}} |
| |
|
| | .view-panel{position:relative;overflow:hidden;border-right:1px solid var(--border)} |
| | .view-panel canvas{width:100%;height:100%;display:block} |
| | .net-overlay{position:absolute;top:10px;left:10px;pointer-events:none;font-size:9px;color:var(--txt3);line-height:1.6} |
| | .net-overlay b{color:var(--txt2)} |
| | .view-panel[data-hidden]{display:none} |
| |
|
| | .sidebar{overflow-y:auto;display:flex;flex-direction:column;gap:0;background:var(--panel);scrollbar-width:thin;scrollbar-color:var(--border) transparent} |
| | .sb-section{padding:12px 14px;border-bottom:1px solid var(--border)} |
| | .sb-section h3{font-family:'Syne',sans-serif;font-size:9px;text-transform:uppercase;letter-spacing:.12em;color:var(--txt3);margin-bottom:8px;font-weight:600} |
| |
|
| | .btn-row{display:flex;gap:6px;flex-wrap:wrap} |
| | .btn{padding:7px 14px;border:1px solid var(--border2);border-radius:6px;background:var(--panel2);color:var(--txt);font-family:inherit;font-size:10px;font-weight:500;cursor:pointer;transition:all .2s;flex:1;text-align:center} |
| | .btn:hover{background:rgba(0,229,255,.06);border-color:var(--cyan);color:var(--cyan)} |
| | .btn:disabled{opacity:.3;cursor:not-allowed} |
| | .btn.primary{background:linear-gradient(135deg,rgba(0,229,255,.15),rgba(61,122,255,.15));border-color:rgba(0,229,255,.3);color:var(--cyan)} |
| | .btn.danger{border-color:rgba(255,61,90,.3);color:var(--exc)} |
| | .btn.write-btn{background:linear-gradient(135deg,rgba(255,171,0,.2),rgba(255,109,0,.2));border-color:rgba(255,171,0,.4);color:var(--amber);font-size:11px;font-weight:700} |
| | .btn.write-btn:hover{box-shadow:0 0 16px rgba(255,171,0,.2)} |
| |
|
| | .slider-group{margin-bottom:6px} |
| | .slider-label{display:flex;justify-content:space-between;font-size:9px;color:var(--txt2);margin-bottom:2px} |
| | .slider-label .val{color:var(--cyan);font-weight:600} |
| | input[type=range]{-webkit-appearance:none;width:100%;height:3px;border-radius:2px;background:var(--border);outline:none} |
| | input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--cyan);cursor:pointer;border:2px solid var(--bg)} |
| |
|
| | .sensor-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px} |
| | .sensor-btn{padding:8px 4px;border:1px solid var(--border);border-radius:5px;background:var(--panel2);color:var(--txt2);font-family:inherit;font-size:8px;font-weight:500;cursor:pointer;text-align:center;user-select:none} |
| | .sensor-btn.active-exc{background:rgba(255,61,90,.12);border-color:var(--exc);color:var(--exc)} |
| | .sensor-btn.active-inh{background:rgba(61,122,255,.12);border-color:var(--inh);color:var(--inh)} |
| |
|
| | .nm-slider{margin-bottom:5px} |
| | .nm-label{display:flex;align-items:center;gap:5px;font-size:9px;margin-bottom:1px} |
| | .nm-dot{width:7px;height:7px;border-radius:50%;display:inline-block} |
| |
|
| | .output-row{display:flex;gap:6px;align-items:center;margin-bottom:5px} |
| | .output-bar-wrap{flex:1;height:8px;background:var(--border);border-radius:4px;overflow:hidden} |
| | .output-bar{height:100%;border-radius:4px;transition:width .1s} |
| | .output-label{font-size:9px;color:var(--txt2);width:50px} |
| | .output-val{font-size:9px;color:var(--cyan);width:40px;text-align:right} |
| |
|
| | .log{font-size:8px;line-height:1.5;color:var(--txt3);padding:8px 14px;max-height:100px;overflow-y:auto;border-top:1px solid var(--border);background:rgba(0,0,0,.3);flex-shrink:0} |
| |
|
| | .stats-row{display:flex;gap:6px} .stat{text-align:center;flex:1} |
| | .stat .num{font-size:14px;font-weight:700;color:var(--cyan);font-family:'Syne',sans-serif} |
| | .stat .lbl{font-size:7px;color:var(--txt3);text-transform:uppercase;letter-spacing:.1em} |
| |
|
| | .mode-row{display:flex;gap:3px;margin-bottom:6px} |
| | .mode-btn{flex:1;padding:5px;font-family:inherit;font-size:8px;font-weight:600;text-align:center;border:1px solid var(--border);border-radius:4px;background:transparent;color:var(--txt3);cursor:pointer} |
| | .mode-btn.active{background:rgba(0,229,255,.08);border-color:var(--cyan);color:var(--cyan)} |
| |
|
| | .word-input-wrap{display:flex;gap:6px;margin-bottom:8px} |
| | .word-input{flex:1;padding:8px 10px;border:1px solid var(--border2);border-radius:6px;background:var(--bg);color:var(--amber);font-family:'Syne',sans-serif;font-size:18px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;outline:none;text-align:center} |
| | .word-input::placeholder{color:var(--txt3);font-size:12px;font-weight:400} |
| | .word-input:focus{border-color:var(--amber);box-shadow:0 0 12px rgba(255,171,0,.1)} |
| |
|
| | .progress-wrap{margin-top:8px} |
| | .progress-bar-outer{width:100%;height:6px;background:var(--border);border-radius:3px;overflow:hidden} |
| | .progress-bar-inner{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--cyan),var(--amber));transition:width .2s;width:0%} |
| | .progress-label{display:flex;justify-content:space-between;font-size:8px;color:var(--txt3);margin-top:3px} |
| |
|
| | .letter-preview{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap} |
| | .letter-chip{padding:4px 8px;border:1px solid var(--border);border-radius:4px;font-family:'Syne',sans-serif;font-size:11px;font-weight:700;color:var(--txt3);transition:all .3s} |
| | .letter-chip.done{color:var(--green);border-color:rgba(0,230,118,.3);background:rgba(0,230,118,.05)} |
| | .letter-chip.active{color:var(--amber);border-color:rgba(255,171,0,.5);background:rgba(255,171,0,.08);box-shadow:0 0 8px rgba(255,171,0,.15)} |
| |
|
| | .led-preview{width:100%;height:20px;border-radius:6px;margin-top:6px;border:1px solid var(--border);transition:background .3s} |
| | .write-status-live{font-size:9px;color:var(--amber);text-align:center;padding:6px;font-weight:600;font-family:'Syne',sans-serif} |
| | </style> |
| | </head> |
| | <body> |
| |
|
| | <div class="header"> |
| | <h1><span><a href="https://github.com/DavidVivancos/Neuraxon"> Neuraxon 2.0</a> Mini Writer By <a href="https://www.vivancos.com/">David Vivancos</a> & <a href="https://josesanchezgarcia.com/">Jose Sanchez</a> for <a href="https://qubic.org/">Qubic</a> Open Science </span> <em>Using a <a href="https://sphero.com/collections/mini"> Sphero Mini<a> with a Neuraxon 2.0 Brain</em></h1> |
| | <div class="conn-badge off" id="badge">OFFLINE</div> |
| | </div> |
| |
|
| | <div class="tabs"> |
| | <div class="tab active" data-tab="brain" id="tabBrain">🧠 Brain View</div> |
| | <div class="tab" data-tab="writer" id="tabWriter">✍️ Word Writer</div> |
| | </div> |
| |
|
| | <div class="app"> |
| | <div class="view-panel" id="panelBrain"> |
| | <canvas id="netCanvas"></canvas> |
| | <div class="net-overlay" id="overlay"> |
| | <b>t</b>=<span id="oTime">0.000</span>s <b>step</b>=<span id="oStep">0</span> |
| | <b>energy</b>=<span id="oEnergy">0.00</span> <b>active</b>=<span id="oActive">0</span>/<span id="oTotal">0</span> |
| | </div> |
| | </div> |
| | <div class="view-panel" id="panelWriter" data-hidden> |
| | <canvas id="pathCanvas"></canvas> |
| | <div class="net-overlay"> |
| | <b>Word Path</b> — <span style="color:var(--green)">━</span> done |
| | <span style="color:var(--txt3)">┈</span> planned |
| | <span style="color:var(--amber)">●</span> current |
| | </div> |
| | </div> |
| |
|
| | <div class="sidebar"> |
| | <div class="sb-section"> |
| | <h3>Connection</h3> |
| | <div class="btn-row"> |
| | <button class="btn primary" id="btnConnect">Connect BLE</button> |
| | <button class="btn danger" id="btnDisconnect" disabled>Disconnect</button> |
| | </div> |
| | <div class="btn-row" style="margin-top:6px"> |
| | <button class="btn" id="btnTestMotor" disabled>🔧 Test Motor</button> |
| | <button class="btn" id="btnTestLED" disabled>💡 Test LED</button> |
| | </div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>✍️ Write a Word on the Ground</h3> |
| | <div class="word-input-wrap"> |
| | <input class="word-input" id="wordInput" placeholder="QUBIC" maxlength="12" value="QUBIC"> |
| | </div> |
| | <div class="slider-group"> |
| | <div class="slider-label"><span>Letter Size (cm)</span><span class="val" id="vSize">45</span></div> |
| | <input type="range" min="15" max="100" value="45" id="sSize"> |
| | </div> |
| | <div class="slider-group"> |
| | <div class="slider-label"><span>Speed (0-255)</span><span class="val" id="vWSpeed">70</span></div> |
| | <input type="range" min="20" max="180" value="70" id="sWSpeed"> |
| | </div> |
| | <div class="slider-group"> |
| | <div class="slider-label"><span>Segment time (ms)</span><span class="val" id="vSegTime">550</span></div> |
| | <input type="range" min="100" max="1500" value="550" id="sSegTime"> |
| | </div> |
| | <div class="btn-row" style="margin-top:6px"> |
| | <button class="btn write-btn" id="btnWrite">✍️ WRITE</button> |
| | <button class="btn danger" id="btnAbort" disabled>Stop</button> |
| | </div> |
| | <div class="letter-preview" id="letterPreview"></div> |
| | <div class="progress-wrap"> |
| | <div class="progress-bar-outer"><div class="progress-bar-inner" id="writeProgress"></div></div> |
| | <div class="progress-label"><span id="writeStatusText">Ready</span><span id="writePercent">0%</span></div> |
| | </div> |
| | <div class="write-status-live" id="writeLive"></div> |
| | <div class="led-preview" id="ledPreview" style="background:#111"></div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>Simulation</h3> |
| | <div class="btn-row" style="margin-bottom:6px"> |
| | <button class="btn" id="btnRun">▶ Run</button> |
| | <button class="btn" id="btnStep">Step</button> |
| | <button class="btn" id="btnReset">Reset</button> |
| | </div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>Network</h3> |
| | <div class="stats-row"> |
| | <div class="stat"><div class="num" id="sExc">0</div><div class="lbl">Exc +1</div></div> |
| | <div class="stat"><div class="num" id="sNeu">0</div><div class="lbl">Neu 0</div></div> |
| | <div class="stat"><div class="num" id="sInh">0</div><div class="lbl">Inh −1</div></div> |
| | </div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>Sensory Input</h3> |
| | <div class="mode-row"> |
| | <button class="mode-btn active" data-mode="manual" id="modeManual">Manual</button> |
| | <button class="mode-btn" data-mode="auto" id="modeAuto">Auto</button> |
| | </div> |
| | <div class="sensor-grid" id="sensorGrid"> |
| | <div class="sensor-btn" data-sensor="0" data-val="0">S0<br><b>0</b></div> |
| | <div class="sensor-btn" data-sensor="1" data-val="0">S1<br><b>0</b></div> |
| | <div class="sensor-btn" data-sensor="2" data-val="0">S2<br><b>0</b></div> |
| | <div class="sensor-btn" data-sensor="3" data-val="0">S3<br><b>0</b></div> |
| | <div class="sensor-btn" data-sensor="4" data-val="0">S4<br><b>0</b></div> |
| | <div class="sensor-btn" data-sensor="5" data-val="0">S5<br><b>0</b></div> |
| | </div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>Neuromodulation</h3> |
| | <div class="nm-slider"><div class="nm-label"><div class="nm-dot" style="background:var(--da)"></div>DA<span class="val" id="vDA" style="margin-left:auto;color:var(--da)">0.75</span></div><input type="range" min="0" max="100" value="75" id="sDA"></div> |
| | <div class="nm-slider"><div class="nm-label"><div class="nm-dot" style="background:var(--sht)"></div>5-HT<span class="val" id="v5HT" style="margin-left:auto;color:var(--sht)">0.30</span></div><input type="range" min="0" max="100" value="30" id="s5HT"></div> |
| | <div class="nm-slider"><div class="nm-label"><div class="nm-dot" style="background:var(--ach)"></div>ACh<span class="val" id="vACh" style="margin-left:auto;color:var(--ach)">0.75</span></div><input type="range" min="0" max="100" value="75" id="sACh"></div> |
| | <div class="nm-slider"><div class="nm-label"><div class="nm-dot" style="background:var(--na)"></div>NA<span class="val" id="vNA" style="margin-left:auto;color:var(--na)">0.30</span></div><input type="range" min="0" max="100" value="30" id="sNA"></div> |
| | </div> |
| |
|
| | <div class="sb-section"> |
| | <h3>Motor → Sphero</h3> |
| | <div class="output-row"><div class="output-label">Speed</div><div class="output-bar-wrap"><div class="output-bar" id="barSpeed" style="width:0%;background:var(--green)"></div></div><div class="output-val" id="valSpeed">0</div></div> |
| | <div class="output-row"><div class="output-label">Heading</div><div class="output-bar-wrap"><div class="output-bar" id="barHead" style="width:50%;background:var(--amber)"></div></div><div class="output-val" id="valHead">0°</div></div> |
| | <div class="output-row"><div class="output-label">NX Mod</div><div class="output-bar-wrap"><div class="output-bar" id="barMod" style="width:50%;background:var(--sht)"></div></div><div class="output-val" id="valMod">0</div></div> |
| | </div> |
| |
|
| | <div class="log" id="logPanel">Neuraxon 2.0 — Sphero Writer\n</div> |
| | </div> |
| | </div> |
| |
|
| | <script> |
| | |
| | |
| | |
| | const $=id=>document.getElementById(id); |
| | const clamp=(v,lo,hi)=>Math.max(lo,Math.min(hi,v)); |
| | const rand=(lo=0,hi=1)=>lo+Math.random()*(hi-lo); |
| | const randInt=(lo,hi)=>Math.floor(rand(lo,hi+1)); |
| | const pick=a=>a[randInt(0,a.length-1)]; |
| | const logEl=$('logPanel'); |
| | function log(m){const t=new Date().toLocaleTimeString('en-US',{hour12:false});logEl.textContent+=`[${t}] ${m}\n`;logEl.scrollTop=logEl.scrollHeight} |
| | |
| | |
| | |
| | |
| | |
| | const FONT={ |
| | Q:[[[.5,0],[.15,.1],[0,.4],[0,.6],[.15,.9],[.5,1],[.85,.9],[1,.6],[1,.4],[.85,.1],[.5,0]],[[.65,.75],[1,1.05]]], |
| | U:[[[0,0],[0,.7],[.1,.9],[.3,1],[.7,1],[.9,.9],[1,.7],[1,0]]], |
| | B:[[[0,1],[0,0],[.65,0],[.85,.1],[.85,.35],[.65,.48],[0,.48]],[[.65,.48],[.9,.6],[.9,.85],[.65,1],[0,1]]], |
| | I:[[[.25,0],[.75,0]],[[.5,0],[.5,1]],[[.25,1],[.75,1]]], |
| | C:[[[.95,.15],[.7,0],[.3,0],[.05,.25],[0,.5],[.05,.75],[.3,1],[.7,1],[.95,.85]]], |
| | A:[[[0,1],[.5,0],[1,1]],[[.2,.6],[.8,.6]]], |
| | D:[[[0,1],[0,0],[.55,0],[.85,.2],[1,.5],[.85,.8],[.55,1],[0,1]]], |
| | E:[[[.9,0],[0,0],[0,.5],[.65,.5]],[[0,.5],[0,1],[.9,1]]], |
| | F:[[[.9,0],[0,0],[0,.5],[.65,.5]],[[0,.5],[0,1]]], |
| | G:[[[.9,.15],[.6,0],[.3,0],[.05,.25],[0,.5],[.05,.75],[.3,1],[.7,1],[.95,.85],[.95,.55],[.5,.55]]], |
| | H:[[[0,0],[0,1]],[[0,.5],[1,.5]],[[1,0],[1,1]]], |
| | J:[[[.3,0],[.8,0]],[[.6,0],[.6,.8],[.45,1],[.2,1],[.05,.85]]], |
| | K:[[[0,0],[0,1]],[[.9,0],[0,.5],[.9,1]]], |
| | L:[[[0,0],[0,1],[.9,1]]], |
| | M:[[[0,1],[0,0],[.5,.45],[1,0],[1,1]]], |
| | N:[[[0,1],[0,0],[1,1],[1,0]]], |
| | O:[[[.5,0],[.15,.1],[0,.4],[0,.6],[.15,.9],[.5,1],[.85,.9],[1,.6],[1,.4],[.85,.1],[.5,0]]], |
| | P:[[[0,1],[0,0],[.7,0],[.95,.15],[.95,.35],[.7,.5],[0,.5]]], |
| | R:[[[0,1],[0,0],[.7,0],[.95,.15],[.95,.35],[.7,.5],[0,.5]],[[.5,.5],[.95,1]]], |
| | S:[[[.9,.1],[.65,0],[.35,0],[.1,.1],[0,.25],[.1,.42],[.35,.5],[.65,.55],[.9,.65],[1,.8],[.9,.95],[.65,1],[.35,1],[.1,.9]]], |
| | T:[[[0,0],[1,0]],[[.5,0],[.5,1]]], |
| | V:[[[0,0],[.5,1],[1,0]]], |
| | W:[[[0,0],[.25,1],[.5,.5],[.75,1],[1,0]]], |
| | X:[[[0,0],[1,1]],[[1,0],[0,1]]], |
| | Y:[[[0,0],[.5,.5],[1,0]],[[.5,.5],[.5,1]]], |
| | Z:[[[0,0],[1,0],[0,1],[1,1]]], |
| | ' ':[] |
| | }; |
| | |
| | |
| | |
| | |
| | |
| | function wordToSegments(word, sizeCm, baseSpeed, segTimeMs) { |
| | const segments = []; |
| | const scale = sizeCm; |
| | const spacing = scale * 1.3; |
| | let curX = 0, curY = 0; |
| | const upper = word.toUpperCase(); |
| | |
| | for (let ci = 0; ci < upper.length; ci++) { |
| | const ch = upper[ci]; |
| | const strokes = FONT[ch]; |
| | const hue = (ci / Math.max(1, upper.length)) * 360; |
| | if (!strokes || strokes.length === 0) { curX += spacing * 0.5; continue; } |
| | |
| | const ox = curX; |
| | |
| | for (let si = 0; si < strokes.length; si++) { |
| | const stroke = strokes[si]; |
| | |
| | const startX = ox + stroke[0][0] * scale; |
| | const startY = stroke[0][1] * scale; |
| | const dx0 = startX - curX, dy0 = startY - curY; |
| | const dist0 = Math.sqrt(dx0*dx0 + dy0*dy0); |
| | if (dist0 > 0.5) { |
| | const heading = ((Math.atan2(dx0, -dy0) * 180 / Math.PI) % 360 + 360) % 360; |
| | |
| | const t = Math.max(150, (dist0 / scale) * segTimeMs * 0.7); |
| | segments.push({ heading: Math.round(heading), penDown: false, duration: Math.round(t), hue, letter: ch, letterIdx: ci, x: startX, y: startY }); |
| | curX = startX; curY = startY; |
| | } |
| | |
| | for (let pi = 1; pi < stroke.length; pi++) { |
| | const px = ox + stroke[pi][0] * scale; |
| | const py = stroke[pi][1] * scale; |
| | const ddx = px - curX, ddy = py - curY; |
| | const dd = Math.sqrt(ddx*ddx + ddy*ddy); |
| | if (dd < 0.3) continue; |
| | const heading = ((Math.atan2(ddx, -ddy) * 180 / Math.PI) % 360 + 360) % 360; |
| | const t = Math.max(100, (dd / scale) * segTimeMs); |
| | segments.push({ heading: Math.round(heading), penDown: true, duration: Math.round(t), hue, letter: ch, letterIdx: ci, x: px, y: py }); |
| | curX = px; curY = py; |
| | } |
| | |
| | segments.push({ heading: 0, penDown: false, duration: 80, hue, letter: ch, letterIdx: ci, x: curX, y: curY, pause: true }); |
| | } |
| | curX = ox + spacing; |
| | |
| | segments.push({ heading: 0, penDown: false, duration: 150, hue, letter: ch, letterIdx: ci, x: curX, y: curY, pause: true }); |
| | } |
| | return segments; |
| | } |
| | |
| | |
| | function wordToPlannedPath(word, sizeCm) { |
| | const pts = []; |
| | const scale = sizeCm, spacing = scale * 1.3; |
| | let curX = 0; |
| | const upper = word.toUpperCase(); |
| | for (let ci = 0; ci < upper.length; ci++) { |
| | const ch = upper[ci]; |
| | const strokes = FONT[ch]; |
| | const hue = (ci / Math.max(1, upper.length)) * 360; |
| | if (!strokes || strokes.length === 0) { curX += spacing * 0.5; continue; } |
| | for (const stroke of strokes) { |
| | for (let pi = 0; pi < stroke.length; pi++) { |
| | pts.push({ x: curX + stroke[pi][0] * scale, y: stroke[pi][1] * scale, penDown: pi > 0, hue, letter: ch, letterIdx: ci }); |
| | } |
| | } |
| | curX += spacing; |
| | } |
| | return pts; |
| | } |
| | |
| | |
| | |
| | |
| | |
| | class ReceptorSubtype{constructor(n,t,g,i){this.threshold=t;this.gain=g;this.isTonic=i;this.activation=0}computeActivation(c){const k=this.isTonic?20:10;this.activation=this.gain/(1+Math.exp(-k*(c-this.threshold)));return this.activation}} |
| | class OscillatorBank{constructor(){this.bands={infraslow:{freq:.05,phase:rand(0,6.28),amp:.1},slow:{freq:.5,phase:rand(0,6.28),amp:.15},theta:{freq:6,phase:rand(0,6.28),amp:.25},gamma:{freq:40,phase:rand(0,6.28),amp:.3}};this.coupling=.15}update(dt){for(const b of Object.values(this.bands)){b.phase+=2*Math.PI*b.freq*dt/1000;b.phase%=2*Math.PI}}getDrive(n,N){const p=2*Math.PI*n/N;const g=Math.max(0,Math.cos(this.bands.theta.phase+p));return this.coupling*(this.bands.gamma.amp*g*Math.sin(this.bands.gamma.phase+2*p)+.5*this.bands.slow.amp*Math.sin(this.bands.slow.phase+.3*p)+.3*this.bands.infraslow.amp*Math.sin(this.bands.infraslow.phase))}} |
| | class NeuromodulatorSystem{constructor(){this.modulators={DA:{tonic:.5,phasic:0,tauP:200,rel:.3},SHT:{tonic:.5,phasic:0,tauP:500,rel:.2},ACh:{tonic:.5,phasic:0,tauP:150,rel:.25},NA:{tonic:.5,phasic:0,tauP:300,rel:.35}};this.receptors={D1:new ReceptorSubtype('D1',.3,1,false),D2:new ReceptorSubtype('D2',.5,.8,true),SHT1A:new ReceptorSubtype('5HT1A',.2,1,true),SHT2A:new ReceptorSubtype('5HT2A',.6,.9,false),M1:new ReceptorSubtype('M1',.3,1,false),M2:new ReceptorSubtype('M2',.5,.8,true),B1:new ReceptorSubtype('B1',.3,1,false),A2:new ReceptorSubtype('A2',.4,.9,true)}}setBaselines(d,s,a,n){this.modulators.DA.tonic=d;this.modulators.SHT.tonic=s;this.modulators.ACh.tonic=a;this.modulators.NA.tonic=n}update(act,dt){const d=dt/1000;for(const m of Object.values(this.modulators))m.phasic*=Math.exp(-d/(m.tauP/1000));this.modulators.DA.phasic+=this.modulators.DA.rel*act.stateChangeRate*d;this.modulators.ACh.phasic+=this.modulators.ACh.rel*act.excFrac*d;this.modulators.NA.phasic+=this.modulators.NA.rel*act.stateChangeRate*d;for(const m of Object.values(this.modulators)){m.tonic=clamp(m.tonic,0,1);m.phasic=clamp(m.phasic,0,1)}}computeReceptorActivations(){const R={},map={D1:'DA',D2:'DA',SHT1A:'SHT',SHT2A:'SHT',M1:'ACh',M2:'ACh',B1:'NA',A2:'NA'};for(const[rn,rec]of Object.entries(this.receptors)){const m=this.modulators[map[rn]];R[rn]=rec.computeActivation(rec.isTonic?m.tonic:m.tonic+m.phasic)}return R}} |
| | |
| | class Synapse{constructor(p,q,b){this.preId=p;this.postId=q;this.branch=b;this.wf=rand(-.5,.5);this.ws=rand(-.3,.3);this.wm=rand(-.1,.1);this.tauF=50;this.tauS=500;this.tauM=5000;this.silent=Math.random()<.1;this.modulatory=Math.random()<.15;this.preTrace=0;this.postTrace=0;this.recentDw=0;this.integrity=1}computeInput(s){return this.silent?0:(this.wf+this.ws)*s}getModulatoryEffect(){return this.modulatory?this.wm:0}update(pre,post,R,dt){const d=dt/1000;this.preTrace+=(-(this.preTrace)/20+(pre===1?1:0))*d;this.postTrace+=(-(this.postTrace)/20+(post===1?1:0))*d;const eta=.05;let dw=eta*this.preTrace*(post===1?1:0)*(R.D1||.5)-eta*this.postTrace*(pre===1?1:0)*(R.D2||.5);this.recentDw=dw;this.wf+=(d/(this.tauF/1000))*(-.001*this.wf+.3*dw);this.ws+=(d/(this.tauS/1000))*(-.001*this.ws+.1*dw);const sf=.5*(R.SHT2A||.5)+.1*(1-(R.SHT1A||.5));this.wm+=(d/(this.tauM/1000))*(-.001*this.wm+.05*dw*sf);this.wf=clamp(this.wf,-1,1);this.ws=clamp(this.ws,-1,1);this.wm=clamp(this.wm,-.5,.5);this.integrity-=.0001*d;this.integrity=clamp(this.integrity,0,1);if(this.silent&&Math.abs(pre)===1&&Math.abs(post)===1&&Math.random()<.05)this.silent=false}} |
| | |
| | class Neuraxon{constructor(id,type){this.id=id;this.type=type;this.s=0;this.state=0;this.theta1=.5;this.theta2=-.5;this.adapt=0;this.rBar=0;this.auto=0;this.health=1;this.tau=rand(15,40);this.numBranches=3;this.h=0;this.x=0;this.y=0;this.radius=0;this.label=''} |
| | update(inputs,modIn,Iext,osc,R,dt){const d=dt/1000;this.rBar+=.01*(Math.abs(this.state)-this.rBar)*d;const gNA=1+.5*(R.B1||.5)+.2*(R.A2||.5);let D=0;for(const v of inputs)D+=v;const sp=(.02+.3*(R.A2||.3))*(Math.random()<.1*d?pick([-1,1]):0);this.s+=(d/(this.tau/1000))*(-this.s+gNA*D+Iext+osc-this.adapt+sp);this.h=.95*this.h+.05*.1*Iext;const sT=this.s+this.h;let rawMod=0;for(const m of modIn)rawMod+=m;rawMod+=.3*(R.M1||.5)-.2*(R.M2||.5);const t1=this.theta1-.3*Math.tanh(rawMod)+.01*(this.rBar-.3)-.1*this.auto;const t2=this.theta2-.3*Math.tanh(rawMod)+.01*(this.rBar-.3)+.1*this.auto;if(sT>t1)this.state=1;else if(sT<t2)this.state=-1;else this.state=0;this.adapt+=(d/(.1))*(-this.adapt+.1*Math.abs(this.state));this.auto+=(d/(.3))*(-this.auto+.2*this.state)}} |
| | |
| | class NeuraxonNetwork{ |
| | constructor(nI,nH,nO){ |
| | this.neurons=[];this.synapses=[];this.oscillators=new OscillatorBank();this.neuromod=new NeuromodulatorSystem(); |
| | this.time=0;this.step=0;this.energy=0;this.prevStates=[]; |
| | for(let i=0;i<nI;i++)this.neurons.push(new Neuraxon(i,'input')); |
| | for(let i=0;i<nH;i++)this.neurons.push(new Neuraxon(nI+i,'hidden')); |
| | for(let i=0;i<nO;i++)this.neurons.push(new Neuraxon(nI+nH+i,'output')); |
| | this.nI=nI;this.nH=nH;this.nO=nO;this.N=this.neurons.length; |
| | this._buildSW(4,.3);this.prevStates=this.neurons.map(n=>n.state); |
| | } |
| | _buildSW(k,beta){ |
| | const N=this.N,hk=k>>1,edges=new Set(); |
| | for(let i=0;i<N;i++)for(let j=1;j<=hk;j++)edges.add(`${i}-${(i+j)%N}`); |
| | for(const e of[...edges])if(Math.random()<beta){const[s]=e.split('-').map(Number);edges.delete(e);let t;do{t=randInt(0,N-1)}while(t===s||edges.has(`${s}-${t}`));edges.add(`${s}-${t}`)} |
| | for(let i=0;i<this.nI;i++)for(let h=0;h<Math.min(4,this.nH);h++)edges.add(`${i}-${this.nI+((i*3+h)%this.nH)}`); |
| | for(let h=0;h<this.nH;h++)for(let o=0;o<this.nO;o++)if(Math.random()<.6)edges.add(`${this.nI+h}-${this.nI+this.nH+o}`); |
| | for(const e of edges){const[p,q]=e.split('-').map(Number);this.synapses.push(new Synapse(p,q,randInt(0,2)))} |
| | } |
| | computeActivity(){let e=0,a=0,c=0;for(let i=0;i<this.N;i++){if(this.neurons[i].state===1)e++;if(this.neurons[i].state!==0)a++;if(this.neurons[i].state!==this.prevStates[i])c++}return{excFrac:e/this.N,meanActivity:a/this.N,stateChangeRate:c/this.N}} |
| | simulateStep(ext,dt){ |
| | this.prevStates=this.neurons.map(n=>n.state); |
| | const act=this.computeActivity();this.neuromod.update(act,dt);const R=this.neuromod.computeReceptorActivations();this.oscillators.update(dt); |
| | for(let i=0;i<this.nI;i++)if(ext[i]!==undefined){this.neurons[i].state=ext[i];this.neurons[i].s=ext[i]*.8} |
| | const inp=this.neurons.map(()=>[]),mod=this.neurons.map(()=>[]); |
| | for(const s of this.synapses){inp[s.postId].push(s.computeInput(this.neurons[s.preId].state));mod[s.postId].push(s.getModulatoryEffect())} |
| | for(let i=this.nI;i<this.N;i++){const n=this.neurons[i];n.update(inp[i],mod[i],0,this.oscillators.getDrive(n.id,this.N),R,dt)} |
| | for(const s of this.synapses)s.update(this.neurons[s.preId].state,this.neurons[s.postId].state,R,dt); |
| | this.synapses=this.synapses.filter(s=>s.integrity>.05); |
| | this.energy+=.01*this.neurons.filter(n=>n.state!==0).length*(dt/1000);this.time+=dt/1000;this.step++; |
| | } |
| | getOutputContinuous(){const o=[];for(let i=this.nI+this.nH;i<this.N;i++)o.push(this.neurons[i].s);return o} |
| | |
| | getExcitationLevel(){let e=0;for(const n of this.neurons)e+=n.state;return e/this.N} |
| | } |
| | |
| | |
| | |
| | |
| | |
| | |
| | const UUID_SPHERO_SERVICE = '00010001-574f-4f20-5370-6865726f2121'; |
| | const UUID_SPHERO_SERVICE_INIT = '00020001-574f-4f20-5370-6865726f2121'; |
| | const UUID_CHAR_HANDLE = '00010002-574f-4f20-5370-6865726f2121'; |
| | const UUID_CHAR_USETHEFORCE = '00020005-574f-4f20-5370-6865726f2121'; |
| | const UseTheForceBytes = new Uint8Array([0x75,0x73,0x65,0x74,0x68,0x65,0x66,0x6f,0x72,0x63,0x65,0x2e,0x2e,0x2e,0x62,0x61,0x6e,0x64]); |
| | const API = { ESC: 0xAB, SOP: 0x8D, EOP: 0xD8, ESC_MASK: 0x88 }; |
| | const DeviceId = { powerInfo: 0x13, driving: 0x16, userIO: 0x1A }; |
| | const PowerCmd = { wake: 0x0D, sleep: 0x01 }; |
| | const DrivingCmd = { resetYaw: 0x06, driveWithHeading: 0x07 }; |
| | const UserIOCmd = { allLEDs: 0x0E }; |
| | const Flags = { requestsResponse: 2, requestsOnlyErrorResponse: 4, resetsInactivityTimeout: 8 }; |
| | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| | |
| | class SpheroMiniBLE { |
| | constructor() { |
| | this.device = null; this.server = null; this.ch = new Map(); this.seq = 0; |
| | this._chain = Promise.resolve(); this._qDepth = 0; this._closed = false; |
| | this.onDisconnect = null; |
| | } |
| | _pushEscaped(out, b) { |
| | if (b === API.SOP || b === API.EOP || b === API.ESC) { out.push(API.ESC); out.push(b & (~API.ESC_MASK)); } |
| | else out.push(b); |
| | } |
| | _buildPacket(did, cid, dataBytes, cmdFlags) { |
| | this.seq = (this.seq + 1) & 0xFF; let sum = 0; const out = []; |
| | out.push(API.SOP); out.push(cmdFlags); sum += cmdFlags; |
| | this._pushEscaped(out, did); sum += did; |
| | this._pushEscaped(out, cid); sum += cid; |
| | this._pushEscaped(out, this.seq); sum += this.seq; |
| | for (const b of dataBytes) { this._pushEscaped(out, b); sum += b; } |
| | const chk = (~sum) & 0xFF; this._pushEscaped(out, chk); out.push(API.EOP); |
| | return new Uint8Array(out); |
| | } |
| | _enqueueWrite(fn) { |
| | if (this._closed) return Promise.reject(new Error('BLE closed')); |
| | this._qDepth++; |
| | const run = async () => { try { return await fn(); } finally { this._qDepth = Math.max(0, this._qDepth - 1); } }; |
| | this._chain = this._chain.then(run, run); return this._chain; |
| | } |
| | async connect() { |
| | log('Requesting Bluetooth device...'); |
| | this.device = await navigator.bluetooth.requestDevice({ |
| | filters: [{ services: [UUID_SPHERO_SERVICE] }], |
| | optionalServices: [UUID_SPHERO_SERVICE_INIT] |
| | }); |
| | this.device.addEventListener('gattserverdisconnected', () => { |
| | log('BLE disconnected'); |
| | if (this.onDisconnect) this.onDisconnect(); |
| | }); |
| | log(`Connecting to: ${this.device.name || 'Sphero Mini'}`); |
| | this.server = await this.device.gatt.connect(); |
| | const svcCmd = await this.server.getPrimaryService(UUID_SPHERO_SERVICE); |
| | const svcInit = await this.server.getPrimaryService(UUID_SPHERO_SERVICE_INIT); |
| | const chHandle = await svcCmd.getCharacteristic(UUID_CHAR_HANDLE); |
| | const chForce = await svcInit.getCharacteristic(UUID_CHAR_USETHEFORCE); |
| | this.ch.set('handle', chHandle); |
| | log('Waking up Sphero...'); |
| | await this._enqueueWrite(() => chForce.writeValue(UseTheForceBytes)); |
| | await this.send(DeviceId.powerInfo, PowerCmd.wake, [], { response: 'full' }); |
| | await sleep(200); |
| | await this.send(DeviceId.powerInfo, PowerCmd.wake, [], { response: 'full' }); |
| | } |
| | async disconnect() { |
| | this._closed = true; |
| | if (this.device && this.device.gatt.connected) this.device.gatt.disconnect(); |
| | } |
| | |
| | |
| | async send(did, cid, data, opts = { response: 'errorOnly' }) { |
| | const chHandle = this.ch.get('handle'); if (!chHandle) throw new Error('Handle missing'); |
| | let cmdFlags = Flags.resetsInactivityTimeout; |
| | if (opts.response === 'full') cmdFlags |= Flags.requestsResponse; |
| | else if (opts.response === 'errorOnly') cmdFlags |= Flags.requestsOnlyErrorResponse; |
| | |
| | const pkt = this._buildPacket(did, cid, data, cmdFlags); |
| | return this._enqueueWrite(async () => { |
| | if (chHandle.writeValueWithoutResponse && opts.response !== 'full') await chHandle.writeValueWithoutResponse(pkt); |
| | else await chHandle.writeValue(pkt); |
| | return this.seq; |
| | }); |
| | } |
| | async setMainLED(r, g, b) { return this.send(DeviceId.userIO, UserIOCmd.allLEDs, [0x00, 0x70, r & 255, g & 255, b & 255], { response: 'none' }); } |
| | async roll(speed, headingDeg) { |
| | const head = ((headingDeg % 360) + 360) % 360; |
| | return this.send(DeviceId.driving, DrivingCmd.driveWithHeading, [speed & 255, (head >> 8) & 0xFF, head & 0xFF, 0x01], { response: 'none' }); |
| | } |
| | async resetYaw() { return this.send(DeviceId.driving, DrivingCmd.resetYaw, [], { response: 'full' }); } |
| | async stop() { return this.roll(0, 0); } |
| | } |
| | |
| | |
| | |
| | |
| | |
| | const net = new NeuraxonNetwork(6, 12, 3); |
| | let sphero=null, bleConnected=false, running=false, simInterval=null, mode='manual'; |
| | let sensorValues=[0,0,0,0,0,0], activeTab='brain'; |
| | |
| | |
| | let writerActive=false, writerSegments=[], writerIdx=0, writerDone=false; |
| | let writerPlanned=[], trailHistory=[]; |
| | let segStartTime=0, currentLedR=0, currentLedG=0, currentLedB=0; |
| | let writerTimerId=null; |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function startWriting(){ |
| | const word=$('wordInput').value.trim(); |
| | if(!word){log('Enter a word');return} |
| | const size=parseInt($('sSize').value); |
| | const speed=parseInt($('sWSpeed').value); |
| | const segTime=parseInt($('sSegTime').value); |
| | |
| | writerSegments=wordToSegments(word,size,speed,segTime); |
| | writerPlanned=wordToPlannedPath(word,size); |
| | writerIdx=0;writerDone=false;writerActive=true; |
| | trailHistory=[]; |
| | |
| | updateLetterChips(word); |
| | $('writeStatusText').textContent=`Writing "${word}"…`; |
| | $('btnWrite').disabled=true;$('btnAbort').disabled=false; |
| | $('writeLive').textContent=''; |
| | |
| | |
| | $('sDA').value=75;$('vDA').textContent='0.75'; |
| | $('sACh').value=70;$('vACh').textContent='0.70'; |
| | $('sNA').value=65;$('vNA').textContent='0.65'; |
| | |
| | log(`Writing "${word}" — ${writerSegments.length} segments, size=${size}cm, speed=${speed}`); |
| | |
| | if(sphero&&bleConnected){await sphero.resetYaw();await new Promise(r=>setTimeout(r,300))} |
| | |
| | |
| | if(!running)$('btnRun').click(); |
| | |
| | |
| | $('tabWriter').click(); |
| | |
| | |
| | executeNextSegment(); |
| | } |
| | |
| | async function executeNextSegment(){ |
| | if(!writerActive||writerIdx>=writerSegments.length){ |
| | finishWriting();return; |
| | } |
| | |
| | const seg=writerSegments[writerIdx]; |
| | const baseSpeed=parseInt($('sWSpeed').value); |
| | |
| | |
| | |
| | const headNorm=seg.heading/360; |
| | const penSignal=seg.penDown?1:-1; |
| | const progressSignal=(writerIdx/writerSegments.length)*2-1; |
| | const letterSignal=(seg.letterIdx||0)/12; |
| | const excLevel=net.getExcitationLevel(); |
| | const durationNorm=clamp(seg.duration/1000,-1,1); |
| | sensorValues=[ |
| | headNorm>0.5?1:(headNorm<0.25?-1:0), |
| | penSignal>0?1:-1, |
| | progressSignal>0.5?1:(progressSignal<-0.5?-1:0), |
| | Math.round(clamp(letterSignal*2-1,-1,1)), |
| | excLevel>0.1?1:(excLevel<-0.1?-1:0), |
| | durationNorm>0.3?1:(durationNorm<-0.3?-1:0) |
| | ]; |
| | updateSensorUI(); |
| | |
| | |
| | const nxOut=net.getOutputContinuous(); |
| | const speedMod=1.0+clamp(nxOut[0]||0,-0.3,0.3); |
| | const actualSpeed=seg.pause?0:Math.round(clamp(baseSpeed*speedMod*(seg.penDown?1:1.4),0,200)); |
| | |
| | |
| | const[lr,lg,lb]=hslToRgb((seg.hue||0)/360,.85,.5); |
| | currentLedR=lr;currentLedG=lg;currentLedB=lb; |
| | $('ledPreview').style.background=`rgb(${lr},${lg},${lb})`; |
| | |
| | |
| | updateMotorUI(actualSpeed,seg.heading,nxOut[0]||0); |
| | const pct=Math.round((writerIdx/writerSegments.length)*100); |
| | $('writeProgress').style.width=pct+'%';$('writePercent').textContent=pct+'%'; |
| | $('writeLive').textContent=`[${seg.letter}] seg ${writerIdx+1}/${writerSegments.length} h=${seg.heading}° ${seg.penDown?'✏️PEN':'✈️MOVE'} ${seg.duration}ms spd=${actualSpeed}`; |
| | |
| | |
| | updateChipState(seg.letterIdx); |
| | |
| | |
| | trailHistory.push({segIdx:writerIdx,penDown:seg.penDown,hue:seg.hue,heading:seg.heading,duration:seg.duration,speed:actualSpeed,x:seg.x,y:seg.y}); |
| | |
| | |
| | if(sphero&&bleConnected){ |
| | try{ |
| | await sphero.setMainLED(seg.penDown?lr:Math.round(lr*.3),seg.penDown?lg:Math.round(lg*.3),seg.penDown?lb:Math.round(lb*.3)); |
| | await sleep(30); |
| | if(seg.pause){ |
| | await sphero.stop(); |
| | }else{ |
| | await sphero.roll(actualSpeed,seg.heading); |
| | log(` → roll(${actualSpeed}, ${seg.heading}°) for ${seg.duration}ms`); |
| | } |
| | }catch(e){log('BLE err: '+e.message)} |
| | } |
| | |
| | |
| | writerTimerId=setTimeout(()=>{ |
| | writerIdx++; |
| | executeNextSegment(); |
| | },seg.duration); |
| | } |
| | |
| | function finishWriting(){ |
| | writerActive=false;writerDone=true; |
| | $('writeStatusText').textContent='✅ Complete!'; |
| | $('writeProgress').style.width='100%';$('writePercent').textContent='100%'; |
| | $('btnWrite').disabled=false;$('btnAbort').disabled=true; |
| | $('writeLive').textContent='Done!'; |
| | if(sphero&&bleConnected){sphero.stop().catch(()=>{});sphero.setMainLED(0,255,80).catch(()=>{});} |
| | log('Writing complete!'); |
| | } |
| | |
| | function abortWriting(){ |
| | writerActive=false;writerDone=true; |
| | if(writerTimerId){clearTimeout(writerTimerId);writerTimerId=null} |
| | $('writeStatusText').textContent='Aborted'; |
| | $('btnWrite').disabled=false;$('btnAbort').disabled=true; |
| | $('writeLive').textContent=''; |
| | if(sphero&&bleConnected)sphero.stop().catch(()=>{}); |
| | log('Aborted'); |
| | } |
| | |
| | function updateLetterChips(word){ |
| | const c=$('letterPreview');c.innerHTML=''; |
| | for(let i=0;i<word.length;i++){const d=document.createElement('div');d.className='letter-chip pending';d.textContent=word[i].toUpperCase();d.id=`chip${i}`;c.appendChild(d)} |
| | } |
| | function updateChipState(letterIdx){ |
| | document.querySelectorAll('.letter-chip').forEach((ch,i)=>{ |
| | if(i<letterIdx)ch.className='letter-chip done'; |
| | else if(i===letterIdx)ch.className='letter-chip active'; |
| | else ch.className='letter-chip pending'; |
| | }); |
| | } |
| | function updateMotorUI(speed,heading,nxMod){ |
| | $('barSpeed').style.width=(speed/200*100)+'%';$('valSpeed').textContent=speed; |
| | $('barHead').style.width=(heading/360*100)+'%';$('valHead').textContent=heading+'°'; |
| | const modNorm=clamp((nxMod+1)/2*100,0,100); |
| | $('barMod').style.width=modNorm+'%';$('valMod').textContent=nxMod.toFixed(2); |
| | } |
| | |
| | function hslToRgb(h,s,l){let r,g,b;if(s===0){r=g=b=l}else{const q=l<.5?l*(1+s):l+s-l*s;const p=2*l-q;const f=(p,q,t)=>{if(t<0)t+=1;if(t>1)t-=1;if(t<1/6)return p+(q-p)*6*t;if(t<.5)return q;if(t<2/3)return p+(q-p)*(2/3-t)*6;return p};r=f(p,q,h+1/3);g=f(p,q,h);b=f(p,q,h-1/3)}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)]} |
| | |
| | |
| | |
| | |
| | |
| | function layoutNeurons(){ |
| | const c=$('netCanvas'),W=c.width,H=c.height,cx=W/2,cy=H/2; |
| | const rI=Math.min(W,H)*.37,rH=Math.min(W,H)*.21,rO=Math.min(W,H)*.07; |
| | const IL=['💡L','💡R','🚧','🔊','→','↻'],OL=['Spd','TnL','TnR']; |
| | for(let i=0;i<net.nI;i++){const a=(i/net.nI)*Math.PI*2-Math.PI/2;const n=net.neurons[i];n.x=cx+rI*Math.cos(a);n.y=cy+rI*Math.sin(a);n.radius=13;n.label=IL[i]} |
| | for(let i=0;i<net.nH;i++){const a=(i/net.nH)*Math.PI*2-Math.PI/2;const n=net.neurons[net.nI+i];n.x=cx+rH*Math.cos(a);n.y=cy+rH*Math.sin(a);n.radius=9;n.label='H'+i} |
| | for(let i=0;i<net.nO;i++){const a=(i/net.nO)*Math.PI*2-Math.PI/2;const n=net.neurons[net.nI+net.nH+i];n.x=cx+rO*Math.cos(a);n.y=cy+rO*Math.sin(a);n.radius=15;n.label=OL[i]} |
| | } |
| | |
| | function drawNetwork(){ |
| | const c=$('netCanvas'),ctx=c.getContext('2d'),W=c.width,H=c.height; |
| | ctx.clearRect(0,0,W,H); |
| | const bg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*.6);bg.addColorStop(0,'#0c1220');bg.addColorStop(1,'#06080e');ctx.fillStyle=bg;ctx.fillRect(0,0,W,H); |
| | ctx.strokeStyle='#0d1a2a';ctx.lineWidth=1; |
| | [.37,.21].forEach(r=>{ctx.beginPath();ctx.arc(W/2,H/2,Math.min(W,H)*r,0,Math.PI*2);ctx.stroke()}); |
| | ctx.font='8px JetBrains Mono';ctx.fillStyle='#1a2940';ctx.textAlign='center'; |
| | ctx.fillText('SENSORY',W/2,H/2-Math.min(W,H)*.37-5);ctx.fillText('HIDDEN',W/2,H/2-Math.min(W,H)*.21-5);ctx.fillText('MOTOR',W/2,H/2+Math.min(W,H)*.07+20); |
| | for(const s of net.synapses){if(s.silent)continue;const pr=net.neurons[s.preId],po=net.neurons[s.postId];const st=Math.abs(s.wf+s.ws),al=clamp(st*.4+.02,.02,.25);const w=s.wf+s.ws;ctx.beginPath();ctx.moveTo(pr.x,pr.y);const mx=(pr.x+po.x)/2+(pr.y-po.y)*.08,my=(pr.y+po.y)/2+(po.x-pr.x)*.08;ctx.quadraticCurveTo(mx,my,po.x,po.y);ctx.strokeStyle=w>0?`rgba(255,61,90,${al})`:w<0?`rgba(61,122,255,${al})`:`rgba(42,53,72,${al})`;ctx.lineWidth=clamp(st*3,.3,2.5);ctx.stroke()} |
| | for(const n of net.neurons){ctx.save();let fc;if(n.state===1){fc='#ff3d5a';ctx.shadowColor='rgba(255,61,90,.4)';ctx.shadowBlur=16}else if(n.state===-1){fc='#3d7aff';ctx.shadowColor='rgba(61,122,255,.4)';ctx.shadowBlur=16}else{fc='#1e2d40'}ctx.beginPath();ctx.arc(n.x,n.y,n.radius,0,Math.PI*2);ctx.fillStyle=fc;ctx.fill();ctx.shadowBlur=0;ctx.lineWidth=n.type==='output'?2.5:n.type==='input'?2:1;ctx.strokeStyle=n.type==='output'?'rgba(255,171,0,.6)':n.type==='input'?'rgba(0,229,255,.4)':'rgba(120,200,255,.12)';ctx.stroke();ctx.fillStyle=n.state!==0?'#fff':'#5a7a98';ctx.font=`${n.type==='output'?700:500} ${n.radius<11?7:9}px JetBrains Mono`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(n.label,n.x,n.y);ctx.restore()} |
| | } |
| | |
| | function drawPathCanvas(){ |
| | const c=$('pathCanvas'),ctx=c.getContext('2d'),W=c.width,H=c.height; |
| | ctx.clearRect(0,0,W,H); |
| | const bg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*.6);bg.addColorStop(0,'#0a0e18');bg.addColorStop(1,'#06080e');ctx.fillStyle=bg;ctx.fillRect(0,0,W,H); |
| | |
| | if(writerPlanned.length===0){ctx.fillStyle='#1e2d40';ctx.font='14px Syne,sans-serif';ctx.textAlign='center';ctx.fillText('Enter a word and press WRITE',W/2,H/2);return} |
| | |
| | |
| | let mnX=Infinity,mxX=-Infinity,mnY=Infinity,mxY=-Infinity; |
| | for(const p of writerPlanned){mnX=Math.min(mnX,p.x);mxX=Math.max(mxX,p.x);mnY=Math.min(mnY,p.y);mxY=Math.max(mxY,p.y)} |
| | const pw=mxX-mnX||1,ph=mxY-mnY||1; |
| | const margin=60; |
| | const scale=Math.min((W-2*margin)/pw,(H-2*margin)/ph); |
| | const offX=(W-pw*scale)/2-mnX*scale,offY=(H-ph*scale)/2-mnY*scale; |
| | const tx=x=>x*scale+offX,ty=y=>y*scale+offY; |
| | |
| | |
| | ctx.strokeStyle='#0d1a2a';ctx.lineWidth=.5; |
| | for(let x=Math.floor(mnX/10)*10;x<=mxX+10;x+=10){ctx.beginPath();ctx.moveTo(tx(x),margin-20);ctx.lineTo(tx(x),H-margin+20);ctx.stroke()} |
| | |
| | |
| | ctx.setLineDash([4,4]);ctx.lineWidth=1.5; |
| | for(let i=1;i<writerPlanned.length;i++){ |
| | if(!writerPlanned[i].penDown)continue; |
| | ctx.beginPath();ctx.moveTo(tx(writerPlanned[i-1].x),ty(writerPlanned[i-1].y)); |
| | ctx.lineTo(tx(writerPlanned[i].x),ty(writerPlanned[i].y)); |
| | ctx.strokeStyle=`hsla(${writerPlanned[i].hue},70%,55%,.15)`;ctx.stroke(); |
| | } |
| | ctx.setLineDash([]); |
| | |
| | |
| | if(trailHistory.length>1){ |
| | ctx.lineCap='round';ctx.lineJoin='round'; |
| | for(let i=1;i<trailHistory.length;i++){ |
| | const p=trailHistory[i-1],c2=trailHistory[i]; |
| | if(!c2.penDown)continue; |
| | ctx.lineWidth=5; |
| | ctx.beginPath();ctx.moveTo(tx(p.x),ty(p.y));ctx.lineTo(tx(c2.x),ty(c2.y)); |
| | ctx.strokeStyle=`hsla(${c2.hue},85%,60%,.9)`; |
| | ctx.shadowColor=`hsla(${c2.hue},85%,60%,.5)`;ctx.shadowBlur=10;ctx.stroke();ctx.shadowBlur=0; |
| | } |
| | } |
| | |
| | |
| | for(let i=0;i<writerPlanned.length;i++){ |
| | const p=writerPlanned[i]; |
| | ctx.beginPath();ctx.arc(tx(p.x),ty(p.y),2,0,Math.PI*2);ctx.fillStyle='#2a3548';ctx.fill(); |
| | } |
| | |
| | |
| | if(writerActive&&writerIdx<writerSegments.length){ |
| | const seg=writerSegments[writerIdx]; |
| | ctx.beginPath();ctx.arc(tx(seg.x),ty(seg.y),8,0,Math.PI*2);ctx.fillStyle='rgba(255,171,0,.3)';ctx.fill(); |
| | ctx.beginPath();ctx.arc(tx(seg.x),ty(seg.y),4,0,Math.PI*2);ctx.fillStyle='#ffab00';ctx.fill(); |
| | } |
| | |
| | |
| | for(let i=0;i<trailHistory.length;i++){ |
| | const t=trailHistory[i]; |
| | if(!t.penDown)continue; |
| | ctx.beginPath();ctx.arc(tx(t.x),ty(t.y),3,0,Math.PI*2);ctx.fillStyle='#00e676';ctx.fill(); |
| | } |
| | |
| | |
| | const word=$('wordInput').value.toUpperCase(); |
| | ctx.font='600 12px Syne,sans-serif';ctx.textAlign='center'; |
| | for(let i=0;i<word.length;i++){ |
| | const x=margin+(i+.5)*(W-2*margin)/Math.max(1,word.length); |
| | const curLetter=writerActive&&writerIdx<writerSegments.length?writerSegments[writerIdx].letterIdx:-1; |
| | ctx.fillStyle=i<(curLetter>=0?curLetter:writerDone?word.length:0)?'#00e676':i===curLetter?'#ffab00':'#2a3548'; |
| | ctx.fillText(word[i],x,30); |
| | } |
| | } |
| | |
| | |
| | |
| | |
| | |
| | function autoSensors(){const t=net.time;sensorValues=[Math.sin(t*.5)>.3?1:(Math.sin(t*.5)<-.3?-1:0),Math.cos(t*.7)>.3?1:(Math.cos(t*.7)<-.3?-1:0),Math.random()<.03?1:0,Math.sin(t*2.1)>.7?1:0,Math.sin(t*.8)>.2?1:-1,Math.cos(t*1.3)>.5?1:-1];updateSensorUI()} |
| | |
| | function updateSensorUI(){document.querySelectorAll('.sensor-btn').forEach(b=>{const i=parseInt(b.dataset.sensor),v=sensorValues[i];b.dataset.val=v;b.querySelector('b').textContent=v;b.classList.remove('active-exc','active-inh');if(v===1)b.classList.add('active-exc');else if(v===-1)b.classList.add('active-inh')})} |
| | |
| | function tick(){ |
| | const dt=20; |
| | if(mode==='auto')autoSensors(); |
| | |
| | net.neuromod.setBaselines(parseInt($('sDA').value)/100,parseInt($('s5HT').value)/100,parseInt($('sACh').value)/100,parseInt($('sNA').value)/100); |
| | net.simulateStep(sensorValues,dt); |
| | const e=net.neurons.filter(n=>n.state===1).length,i=net.neurons.filter(n=>n.state===-1).length; |
| | $('sExc').textContent=e;$('sNeu').textContent=net.N-e-i;$('sInh').textContent=i; |
| | $('oTime').textContent=net.time.toFixed(3);$('oStep').textContent=net.step;$('oEnergy').textContent=net.energy.toFixed(2);$('oActive').textContent=e+i;$('oTotal').textContent=net.N; |
| | if(activeTab==='brain')drawNetwork();else drawPathCanvas(); |
| | } |
| | |
| | function resizeCanvas(){ |
| | ['netCanvas','pathCanvas'].forEach(id=>{const c=$(id),p=c.parentElement;if(p.hasAttribute('data-hidden'))return;c.width=p.clientWidth;c.height=p.clientHeight}); |
| | layoutNeurons();if(activeTab==='brain')drawNetwork();else drawPathCanvas(); |
| | } |
| | window.addEventListener('resize',resizeCanvas); |
| | |
| | |
| | document.querySelectorAll('.sensor-btn').forEach(b=>b.addEventListener('click',()=>{if(mode!=='manual'||writerActive)return;const i=parseInt(b.dataset.sensor);let v=parseInt(b.dataset.val);v=v===0?1:v===1?-1:0;sensorValues[i]=v;updateSensorUI()})); |
| | |
| | $('modeManual').addEventListener('click',()=>{mode='manual';$('modeManual').classList.add('active');$('modeAuto').classList.remove('active')}); |
| | $('modeAuto').addEventListener('click',()=>{mode='auto';$('modeAuto').classList.add('active');$('modeManual').classList.remove('active')}); |
| | |
| | $('btnRun').addEventListener('click',()=>{if(running){clearInterval(simInterval);running=false;$('btnRun').textContent='▶ Run'}else{simInterval=setInterval(tick,20);running=true;$('btnRun').textContent='⏸ Pause';log('Simulation running')}}); |
| | $('btnStep').addEventListener('click',tick); |
| | $('btnReset').addEventListener('click',()=>{if(running){clearInterval(simInterval);running=false;$('btnRun').textContent='▶ Run'}abortWriting();Object.assign(net,new NeuraxonNetwork(6,12,3));sensorValues=[0,0,0,0,0,0];trailHistory=[];writerPlanned=[];writerSegments=[];updateSensorUI();layoutNeurons();drawNetwork();log('Reset')}); |
| | |
| | |
| | $('sSize').addEventListener('input',()=>{$('vSize').textContent=$('sSize').value;refreshPreview()}); |
| | $('sWSpeed').addEventListener('input',()=>$('vWSpeed').textContent=$('sWSpeed').value); |
| | $('sSegTime').addEventListener('input',()=>$('vSegTime').textContent=$('sSegTime').value); |
| | $('sDA').addEventListener('input',()=>$('vDA').textContent=(parseInt($('sDA').value)/100).toFixed(2)); |
| | $('s5HT').addEventListener('input',()=>$('v5HT').textContent=(parseInt($('s5HT').value)/100).toFixed(2)); |
| | $('sACh').addEventListener('input',()=>$('vACh').textContent=(parseInt($('sACh').value)/100).toFixed(2)); |
| | $('sNA').addEventListener('input',()=>$('vNA').textContent=(parseInt($('sNA').value)/100).toFixed(2)); |
| | |
| | function refreshPreview(){const w=$('wordInput').value.trim();if(w){writerPlanned=wordToPlannedPath(w,parseInt($('sSize').value));trailHistory=[];if(activeTab==='writer')drawPathCanvas()}} |
| | $('wordInput').addEventListener('input',refreshPreview); |
| | |
| | $('btnWrite').addEventListener('click',startWriting); |
| | $('btnAbort').addEventListener('click',abortWriting); |
| | |
| | |
| | document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));t.classList.add('active');activeTab=t.dataset.tab;if(activeTab==='brain'){$('panelBrain').removeAttribute('data-hidden');$('panelWriter').setAttribute('data-hidden','')}else{$('panelWriter').removeAttribute('data-hidden');$('panelBrain').setAttribute('data-hidden','')}requestAnimationFrame(resizeCanvas)})); |
| | |
| | |
| | $('btnConnect').addEventListener('click',async()=>{ |
| | try{$('btnConnect').disabled=true;sphero=new SpheroMiniBLE(); |
| | sphero.onDisconnect=()=>{bleConnected=false;$('badge').textContent='OFFLINE';$('badge').className='conn-badge off';$('btnConnect').disabled=false;$('btnDisconnect').disabled=true;log('Disconnected')}; |
| | await sphero.connect();await sphero.setMainLED(0,255,80);await sphero.resetYaw(); |
| | bleConnected=true;$('badge').textContent='CONNECTED';$('badge').className='conn-badge on';$('btnConnect').disabled=true;$('btnDisconnect').disabled=false; |
| | $('btnTestMotor').disabled=false;$('btnTestLED').disabled=false; |
| | log('Sphero connected & ready!')}catch(e){log('BLE: '+e.message);$('btnConnect').disabled=false} |
| | }); |
| | $('btnDisconnect').addEventListener('click',async()=>{if(sphero){try{await sphero.stop();await sphero.setMainLED(0,0,0)}catch(e){}await sphero.disconnect()}bleConnected=false;$('badge').textContent='OFFLINE';$('badge').className='conn-badge off';$('btnConnect').disabled=false;$('btnDisconnect').disabled=true;$('btnTestMotor').disabled=true;$('btnTestLED').disabled=true}); |
| | |
| | |
| | $('btnTestMotor').addEventListener('click',async()=>{ |
| | if(!sphero||!bleConnected)return; |
| | log('TEST: roll forward heading=0 speed=80 for 1s…'); |
| | try{ |
| | await sphero.setMainLED(255,100,0); |
| | await sphero.roll(80,0); |
| | await sleep(1000); |
| | await sphero.stop(); |
| | await sphero.setMainLED(0,255,80); |
| | log('TEST: motor OK — Sphero should have moved forward'); |
| | }catch(e){log('TEST FAIL: '+e.message)} |
| | }); |
| | $('btnTestLED').addEventListener('click',async()=>{ |
| | if(!sphero||!bleConnected)return; |
| | log('TEST: cycling LED colors…'); |
| | try{ |
| | await sphero.setMainLED(255,0,0);await sleep(400); |
| | await sphero.setMainLED(0,255,0);await sleep(400); |
| | await sphero.setMainLED(0,0,255);await sleep(400); |
| | await sphero.setMainLED(255,255,255);await sleep(400); |
| | await sphero.setMainLED(0,255,80); |
| | log('TEST: LED OK'); |
| | }catch(e){log('TEST FAIL: '+e.message)} |
| | }); |
| | |
| | |
| | requestAnimationFrame(()=>{ |
| | resizeCanvas();refreshPreview(); |
| | log(`Network: 6in → 12 hidden → 3out | ${net.synapses.length} synapses`); |
| | log('Watts-Strogatz small-world (k=4, β=0.3)'); |
| | log('Writer: timed segments, Neuraxon modulates speed ±30%'); |
| | log('Type a word → WRITE → Sphero traces it on the ground'); |
| | }); |
| | </script> |
| | </body> |
| | </html> |