Files
symclaw/skill/canvas/collab.html
T

382 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SymClaw Collab</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:'SF Mono',Consolas,'Courier New',monospace;height:100vh;overflow:hidden;display:flex;flex-direction:column}
#header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#161b22;border-bottom:1px solid #30363d;font-size:13px;flex-shrink:0}
#header .title{font-weight:bold;color:#58a6ff}
#status-dot{width:8px;height:8px;border-radius:50%;background:#f85149;display:inline-block}
#status-dot.connected{background:#3fb950}
#status-dot.connecting{background:#d29922}
#session-label{color:#8b949e;margin-left:auto;font-size:11px}
#main{display:flex;flex:1;overflow:hidden}
#sidebar{width:240px;min-width:180px;background:#161b22;border-right:1px solid #30363d;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
.sidebar-section{padding:8px 10px;border-bottom:1px solid #30363d}
.sidebar-section h3{font-size:11px;color:#8b949e;text-transform:uppercase;margin-bottom:6px;letter-spacing:0.5px}
#expr-list{flex:1;overflow-y:auto;padding:4px 0}
.expr-card{padding:6px 10px;border-bottom:1px solid #21262d;cursor:pointer;position:relative;font-size:12px}
.expr-card:hover{background:#21262d}
.expr-card.selected{background:#1c2333;border-left:2px solid #58a6ff}
.expr-card .author{font-size:10px;font-weight:bold;margin-bottom:2px}
.expr-card .input{color:#c9d1d9}
.expr-card .result{color:#8b949e;font-size:11px}
.expr-card .kind-badge{font-size:9px;background:#30363d;padding:1px 4px;border-radius:3px;color:#8b949e;margin-left:4px}
.expr-card .del-btn{position:absolute;right:6px;top:6px;background:none;border:none;color:#f8514950;cursor:pointer;font-size:14px;display:none}
.expr-card:hover .del-btn{display:block}
.expr-card .del-btn:hover{color:#f85149}
#participants{max-height:120px;overflow-y:auto}
.participant{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px}
.participant .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
#right-panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
#canvas-wrap{flex:1;position:relative;overflow:hidden}
canvas{display:block;width:100%;height:100%;cursor:crosshair}
#plot-info{position:absolute;top:8px;left:10px;font-size:12px;color:#e6edf3;background:rgba(13,17,23,0.85);padding:3px 8px;border-radius:4px;pointer-events:none}
#sliders-bar{padding:6px 14px;background:#161b22;border-top:1px solid #30363d;display:flex;gap:16px;flex-wrap:wrap}
#sliders-bar:empty{display:none}
.slider-group{display:flex;align-items:center;gap:6px;font-size:12px}
.slider-group label{color:#8b949e;min-width:14px}
.slider-group input[type=range]{width:100px;accent-color:#58a6ff}
.slider-group .val{color:#58a6ff;min-width:36px;text-align:right}
.slider-group .by{color:#484f58;font-size:10px;margin-left:2px}
#input-bar{display:flex;gap:6px;padding:8px 14px;background:#161b22;border-top:1px solid #30363d;align-items:center;flex-shrink:0;flex-wrap:wrap}
#expr-input{flex:1;min-width:160px;background:#0d1117;border:1px solid #30363d;color:#58a6ff;padding:6px 10px;border-radius:4px;font-family:inherit;font-size:13px}
#expr-input:focus{outline:none;border-color:#58a6ff}
select,.btn{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit}
select:focus,.btn:hover{border-color:#58a6ff}
.btn.primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
#var-select,#order-input{width:50px}
#order-input{display:none}
#toast-container{position:fixed;top:60px;right:16px;z-index:999;display:flex;flex-direction:column;gap:6px}
.toast{background:#21262d;border:1px solid #30363d;padding:6px 12px;border-radius:6px;font-size:12px;animation:fadeIn .3s ease,fadeOut .3s ease 2.7s;opacity:0;animation-fill-mode:forwards}
@keyframes fadeIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
@keyframes fadeOut{from{opacity:1}to{opacity:0}}
/* Join overlay */
#join-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:1000}
#join-overlay.hidden{display:none}
#join-box{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:28px 32px;width:320px;text-align:center}
#join-box h2{color:#58a6ff;margin-bottom:16px;font-size:18px}
#join-box input{width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:8px 10px;border-radius:4px;font-family:inherit;font-size:14px;margin-bottom:10px}
#join-box input:focus{outline:none;border-color:#58a6ff}
#join-box button{width:100%;padding:8px;margin-top:4px}
#join-box .hint{color:#8b949e;font-size:11px;margin-top:8px}
</style>
</head>
<body>
<div id="join-overlay">
<div id="join-box">
<h2>SymClaw Collab</h2>
<input id="join-name" placeholder="Your name" maxlength="20" autofocus>
<input id="join-session" placeholder="Session ID (leave blank for random)">
<input id="join-host" placeholder="Host (default: current host:9090)">
<button class="btn primary" id="join-btn">Join Session</button>
<div class="hint">Or press Enter</div>
</div>
</div>
<div id="header">
<span class="title">SymClaw Collab</span>
<span id="status-dot"></span>
<span id="status-text">Disconnected</span>
<span id="session-label"></span>
</div>
<div id="main">
<div id="sidebar">
<div id="expr-list"></div>
<div class="sidebar-section">
<h3>Participants</h3>
<div id="participants"></div>
</div>
</div>
<div id="right-panel">
<div id="canvas-wrap"><canvas id="plot"></canvas><div id="plot-info"></div></div>
<div id="sliders-bar"></div>
<div id="input-bar">
<input id="expr-input" placeholder="Type expression…" spellcheck="false">
<select id="mode-select"><option>Raw</option><option>Simplify</option><option>Differentiate</option><option>Integrate</option><option>Solve</option><option>Taylor</option></select>
<select id="var-select"><option>x</option><option>y</option><option>z</option><option>t</option></select>
<input id="order-input" type="number" min="1" max="20" value="5" placeholder="n">
<button class="btn primary" id="eval-btn">▶ Eval</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<script>
// ─── Parser (from manipulate.html) ───
const FUNCS={sin:Math.sin,cos:Math.cos,tan:Math.tan,exp:Math.exp,ln:Math.log,log:Math.log,sqrt:Math.sqrt,abs:Math.abs,asin:Math.asin,acos:Math.acos,atan:Math.atan,ceil:Math.ceil,floor:Math.floor};
const CONSTS={pi:Math.PI,e:Math.E};
function tokenize(s){const t=[];let i=0;while(i<s.length){const c=s[i];if(' \t'.includes(c)){i++;continue}if('0123456789.'.includes(c)){let n='';while(i<s.length&&'0123456789.'.includes(s[i]))n+=s[i++];t.push({t:'num',v:parseFloat(n)});continue}if(/[a-zA-Z_]/.test(c)){let n='';while(i<s.length&&/[a-zA-Z_0-9]/.test(s[i]))n+=s[i++];t.push({t:'id',v:n});continue}if('+-*/^(),'.includes(c)){t.push({t:'op',v:c});i++;continue}throw new Error('Unexpected: '+c)}return t}
function parseExpr(tokens){let pos=0;function peek(){return pos<tokens.length?tokens[pos]:null}function eat(v){const t=tokens[pos++];if(v&&t.v!==v)throw new Error('Expected '+v);return t}function expr(){return addSub()}function addSub(){let l=mulDiv();while(peek()&&(peek().v==='+'||peek().v==='-')){const op=eat().v;l={t:'bin',op,l,r:mulDiv()}}return l}function mulDiv(){let l=unary();while(peek()&&(peek().v==='*'||peek().v==='/')){const op=eat().v;l={t:'bin',op,l,r:unary()}}return l}function unary(){if(peek()&&peek().v==='-'){eat();return{t:'bin',op:'*',l:{t:'num',v:-1},r:power()}}if(peek()&&peek().v==='+'){eat();return power()}return power()}function power(){let l=atom();if(peek()&&peek().v==='^'){eat();l={t:'bin',op:'^',l,r:unary()}}return l}function atom(){const tk=peek();if(!tk)throw new Error('Unexpected end');if(tk.t==='num'){eat();return{t:'num',v:tk.v}}if(tk.t==='id'){const name=tk.v;eat();if(peek()&&peek().v==='('){eat('(');const args=[expr()];while(peek()&&peek().v===','){eat(',');args.push(expr())}eat(')');return{t:'call',name,args}}if(CONSTS[name]!==undefined)return{t:'num',v:CONSTS[name]};return{t:'var',name}}if(tk.v==='('){eat('(');const e=expr();eat(')');return e}throw new Error('Unexpected: '+tk.v)}const tree=expr();if(pos<tokens.length)throw new Error('Trailing: '+tokens[pos].v);return tree}
function evaluate(node,vars){if(node.t==='num')return node.v;if(node.t==='var')return vars[node.name]!==undefined?vars[node.name]:NaN;if(node.t==='bin'){const l=evaluate(node.l,vars),r=evaluate(node.r,vars);switch(node.op){case'+':return l+r;case'-':return l-r;case'*':return l*r;case'/':return l/r;case'^':return Math.pow(l,r)}}if(node.t==='call'){const fn=FUNCS[node.name];if(!fn)return NaN;return fn(...node.args.map(a=>evaluate(a,vars)))}return NaN}
function findParams(node,s=new Set()){if(node.t==='var'&&node.name!=='x')s.add(node.name);if(node.l)findParams(node.l,s);if(node.r)findParams(node.r,s);if(node.args)node.args.forEach(a=>findParams(a,s));return s}
function compileExpr(str){try{const tree=parseExpr(tokenize(str));return{tree,params:[...findParams(tree)].sort(),err:null}}catch(e){return{tree:null,params:[],err:e.message}}}
// ─── State ───
const COLORS=['#ff6b6b','#4ecdc4','#45b7d1','#f9ca24','#6c5ce7','#a29bfe','#fd79a8','#00cec9'];
const S={
ws:null, myId:null, myName:'', sessionId:'',
participants:new Map(), // id → {name,color,cursor,joined_at}
expressions:[], // [{id,input,result,latex,author,timestamp,kind}]
selectedExpr:null, // expression id to plot
paramVals:{}, sliderAuthors:{},
viewX:0, viewY:0, scaleX:50, scaleY:50, showGrid:true,
plotTree:null, plotParams:[],
dragging:false, dragStart:{x:0,y:0}, viewStart:{x:0,y:0},
reconnectDelay:1000, reconnectTimer:null
};
// ─── DOM refs ───
const $=id=>document.getElementById(id);
const canvas=$('plot'),ctx=canvas.getContext('2d');
const exprList=$('expr-list'),partList=$('participants'),slidersBar=$('sliders-bar');
const exprInput=$('expr-input'),modeSelect=$('mode-select'),varSelect=$('var-select'),orderInput=$('order-input');
// ─── Toast ───
function toast(msg,color){
const d=document.createElement('div');d.className='toast';d.textContent=msg;
if(color)d.style.borderLeftColor=color;d.style.borderLeftWidth='3px';
$('toast-container').append(d);setTimeout(()=>d.remove(),3000);
}
// ─── WebSocket ───
function connect(){
const host=$('join-host').value.trim()||location.hostname+':9090';
const url='ws://'+host+'/'+S.sessionId;
$('status-dot').className='connecting';$('status-text').textContent='Connecting…';
try{S.ws=new WebSocket(url)}catch(e){$('status-dot').className='';$('status-text').textContent='Failed';return}
S.ws.onopen=()=>{
$('status-dot').className='connected';$('status-text').textContent='Connected';
S.reconnectDelay=1000;
send({type:'join',name:S.myName});
};
S.ws.onmessage=e=>{try{handleMsg(JSON.parse(e.data))}catch(err){console.error(err)}};
S.ws.onclose=()=>{
$('status-dot').className='';$('status-text').textContent='Disconnected';
S.ws=null;scheduleReconnect();
};
S.ws.onerror=()=>{};
}
function send(obj){if(S.ws&&S.ws.readyState===1)S.ws.send(JSON.stringify(obj))}
function scheduleReconnect(){
if(S.reconnectTimer)return;
S.reconnectTimer=setTimeout(()=>{S.reconnectTimer=null;connect()},S.reconnectDelay);
S.reconnectDelay=Math.min(S.reconnectDelay*2,30000);
}
function handleMsg(m){
switch(m.type){
case'welcome':
S.myId=m.participant_id;
if(m.state){
if(m.state.expressions)S.expressions=m.state.expressions;
if(m.state.participants)m.state.participants.forEach(p=>S.participants.set(p.id,p));
if(m.state.sliders)Object.assign(S.paramVals,m.state.sliders);
}
renderExprs();renderParticipants();break;
case'result':
S.expressions.unshift(m.entry);renderExprs();
if(!S.selectedExpr)selectExpr(m.entry.id);break;
case'slider':
S.paramVals[m.param]=m.value;S.sliderAuthors[m.param]=m.from||'';
buildSliders();draw();break;
case'cursor':
if(m.participant_id!==S.myId){
const p=S.participants.get(m.participant_id);
if(p){p.cursor={x:m.x,y:m.y};p.name=m.name;p.color=m.color}
else S.participants.set(m.participant_id,{id:m.participant_id,name:m.name,color:m.color,cursor:{x:m.x,y:m.y}});
}break;
case'joined':
S.participants.set(m.participant.id,m.participant);
renderParticipants();toast(m.participant.name+' joined',m.participant.color);break;
case'left':
S.participants.delete(m.id);renderParticipants();toast(m.name+' left','#f85149');break;
case'view':
S.viewX=m.center_x??S.viewX;S.viewY=m.center_y??S.viewY;
if(m.zoom)S.scaleX=S.scaleY=m.zoom*50;
if(m.plot_expr)selectExpr(m.plot_expr);draw();break;
case'error':toast('Error: '+m.message,'#f85149');break;
}
}
// ─── Expression list ───
function renderExprs(){
exprList.innerHTML='';
S.expressions.forEach(ex=>{
const d=document.createElement('div');d.className='expr-card'+(S.selectedExpr===ex.id?' selected':'');
const color=findAuthorColor(ex.author);
const kindLabel=typeof ex.kind==='string'?ex.kind:Object.keys(ex.kind)[0];
d.innerHTML=`<div class="author" style="color:${color}">${esc(ex.author)}<span class="kind-badge">${esc(kindLabel)}</span></div><div class="input">${esc(ex.input)}</div><div class="result">= ${esc(ex.result||'…')}</div>`;
d.title=ex.latex||ex.result||'';
d.onclick=()=>selectExpr(ex.id);
if(ex.author===S.myName){const b=document.createElement('button');b.className='del-btn';b.textContent='×';b.onclick=ev=>{ev.stopPropagation();send({type:'remove_expr',id:ex.id});S.expressions=S.expressions.filter(e=>e.id!==ex.id);if(S.selectedExpr===ex.id){S.selectedExpr=null;S.plotTree=null}renderExprs();draw()};d.append(b)}
exprList.append(d);
});
}
function findAuthorColor(name){
for(const[,p]of S.participants)if(p.name===name)return p.color;
let h=0;for(let i=0;i<name.length;i++)h=name.charCodeAt(i)+((h<<5)-h);
return COLORS[Math.abs(h)%COLORS.length];
}
function esc(s){const d=document.createElement('span');d.textContent=s;return d.innerHTML}
function selectExpr(id){
S.selectedExpr=id;
const ex=S.expressions.find(e=>e.id===id);
if(ex){
const r=compileExpr(ex.result||ex.input);
S.plotTree=r.tree;S.plotParams=r.params;
}else{S.plotTree=null;S.plotParams=[]}
buildSliders();renderExprs();draw();
}
// ─── Participants ───
function renderParticipants(){
partList.innerHTML='';
const count=S.participants.size;
$('status-text').textContent=S.ws&&S.ws.readyState===1?`Connected (${count})`:'Disconnected';
for(const[,p]of S.participants){
const d=document.createElement('div');d.className='participant';
d.innerHTML=`<span class="dot" style="background:${p.color}"></span>${esc(p.name)}`;
partList.append(d);
}
}
// ─── Sliders ───
function buildSliders(){
slidersBar.innerHTML='';
S.plotParams.forEach(p=>{
if(S.paramVals[p]===undefined)S.paramVals[p]=1;
const g=document.createElement('div');g.className='slider-group';
const lbl=document.createElement('label');lbl.textContent=p;
const inp=document.createElement('input');inp.type='range';inp.min='-5';inp.max='5';inp.step='0.05';inp.value=S.paramVals[p];
const val=document.createElement('span');val.className='val';val.textContent=Number(S.paramVals[p]).toFixed(2);
const by=document.createElement('span');by.className='by';by.textContent=S.sliderAuthors[p]?'— '+S.sliderAuthors[p]:'';
let debounce=null;
inp.addEventListener('input',()=>{
S.paramVals[p]=parseFloat(inp.value);val.textContent=Number(inp.value).toFixed(2);
S.sliderAuthors[p]=S.myName;by.textContent='— '+S.myName;draw();
clearTimeout(debounce);debounce=setTimeout(()=>send({type:'slider',param:p,value:S.paramVals[p]}),50);
});
g.append(lbl,inp,val,by);slidersBar.append(g);
});
}
// ─── Plotter ───
function resize(){const r=canvas.parentElement.getBoundingClientRect();canvas.width=r.width*devicePixelRatio;canvas.height=r.height*devicePixelRatio;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';draw()}
function toScreen(wx,wy){const cx=canvas.width/2,cy=canvas.height/2;return[cx+(wx-S.viewX)*S.scaleX*devicePixelRatio,cy-(wy-S.viewY)*S.scaleY*devicePixelRatio]}
function toWorld(sx,sy){const cx=canvas.width/2,cy=canvas.height/2;return[(sx-cx)/(S.scaleX*devicePixelRatio)+S.viewX,-(sy-cy)/(S.scaleY*devicePixelRatio)+S.viewY]}
function niceStep(range){const rough=range/8,mag=Math.pow(10,Math.floor(Math.log10(rough))),norm=rough/mag;if(norm<1.5)return mag;if(norm<3.5)return 2*mag;if(norm<7.5)return 5*mag;return 10*mag}
function draw(){
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
ctx.clearRect(0,0,W,H);ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0);const[,yMax]=toWorld(0,0),[,yMin]=toWorld(0,H);
// Grid
if(S.showGrid){
const sx=niceStep(xMax-xMin),sy=niceStep(yMax-yMin);
ctx.strokeStyle='#21262d';ctx.lineWidth=1;ctx.font=(11*dpr)+'px SF Mono,Consolas,monospace';ctx.fillStyle='#484f58';
ctx.textAlign='center';ctx.textBaseline='top';
for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);ctx.beginPath();ctx.moveTo(px,0);ctx.lineTo(px,H);ctx.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);ctx.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}
ctx.textAlign='right';ctx.textBaseline='middle';
for(let gy=Math.floor(yMin/sy)*sy;gy<=yMax;gy+=sy){const[,py]=toScreen(0,gy);ctx.beginPath();ctx.moveTo(0,py);ctx.lineTo(W,py);ctx.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);ctx.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}
}
// Axes
ctx.strokeStyle='#30363d';ctx.lineWidth=1.5*dpr;
const[ax,ay]=toScreen(0,0);
if(ax>=0&&ax<=W){ctx.beginPath();ctx.moveTo(ax,0);ctx.lineTo(ax,H);ctx.stroke()}
if(ay>=0&&ay<=H){ctx.beginPath();ctx.moveTo(0,ay);ctx.lineTo(W,ay);ctx.stroke()}
// Plot
if(S.plotTree){
const vars={...S.paramVals};ctx.strokeStyle='#58a6ff';ctx.lineWidth=2*dpr;ctx.beginPath();
let first=true,prevOk=false;const steps=Math.max(W,600);
for(let i=0;i<=steps;i++){const wx=xMin+(xMax-xMin)*i/steps;vars.x=wx;const wy=evaluate(S.plotTree,vars);if(!isFinite(wy)){prevOk=false;continue}const[sx2,sy2]=toScreen(wx,wy);if(first||!prevOk){ctx.moveTo(sx2,sy2);first=false}else ctx.lineTo(sx2,sy2);prevOk=true}
ctx.stroke();
}
// Cursors
for(const[id,p]of S.participants){
if(id===S.myId||!p.cursor)continue;
const[cx,cy]=toScreen(p.cursor.x,p.cursor.y);
ctx.fillStyle=p.color||'#fff';ctx.beginPath();ctx.arc(cx,cy,5*dpr,0,Math.PI*2);ctx.fill();
ctx.font=(11*dpr)+'px SF Mono,Consolas,monospace';ctx.fillText(p.name,cx+8*dpr,cy-4*dpr);
}
// Info
const sel=S.expressions.find(e=>e.id===S.selectedExpr);
$('plot-info').textContent=sel?'f(x) = '+(sel.result||sel.input):'No expression selected';
}
// ─── Canvas interaction ───
function zoom(factor,cx,cy){if(!cx){cx=canvas.width/2;cy=canvas.height/2}const[wx,wy]=toWorld(cx,cy);S.scaleX*=factor;S.scaleY*=factor;S.scaleX=Math.max(1,Math.min(1e5,S.scaleX));S.scaleY=Math.max(1,Math.min(1e5,S.scaleY));const[nwx,nwy]=toWorld(cx,cy);S.viewX+=wx-nwx;S.viewY+=wy-nwy;draw()}
canvas.addEventListener('wheel',e=>{e.preventDefault();const r=canvas.getBoundingClientRect();zoom(e.deltaY<0?1.15:1/1.15,(e.clientX-r.left)*devicePixelRatio,(e.clientY-r.top)*devicePixelRatio)},{passive:false});
canvas.addEventListener('mousedown',e=>{S.dragging=true;S.dragStart={x:e.clientX,y:e.clientY};S.viewStart={x:S.viewX,y:S.viewY};canvas.style.cursor='grabbing'});
window.addEventListener('mousemove',e=>{if(S.dragging){S.viewX=S.viewStart.x-(e.clientX-S.dragStart.x)/S.scaleX;S.viewY=S.viewStart.y+(e.clientY-S.dragStart.y)/S.scaleY;draw()}});
window.addEventListener('mouseup',()=>{S.dragging=false;canvas.style.cursor='crosshair'});
// Cursor sharing
let lastCursorSend=0;
canvas.addEventListener('mousemove',e=>{
const now=performance.now();if(now-lastCursorSend<33)return;lastCursorSend=now;
const r=canvas.getBoundingClientRect();
const[wx,wy]=toWorld((e.clientX-r.left)*devicePixelRatio,(e.clientY-r.top)*devicePixelRatio);
send({type:'cursor',x:+wx.toFixed(4),y:+wy.toFixed(4)});
});
// Touch
canvas.addEventListener('touchstart',e=>{e.preventDefault();const t=e.touches[0];S.dragging=true;S.dragStart={x:t.clientX,y:t.clientY};S.viewStart={x:S.viewX,y:S.viewY}},{passive:false});
canvas.addEventListener('touchmove',e=>{e.preventDefault();const t=e.touches;if(t.length===1&&S.dragging){S.viewX=S.viewStart.x-(t[0].clientX-S.dragStart.x)/S.scaleX;S.viewY=S.viewStart.y+(t[0].clientY-S.dragStart.y)/S.scaleY;draw()}},{passive:false});
canvas.addEventListener('touchend',()=>{S.dragging=false});
// ─── Input ───
modeSelect.addEventListener('change',()=>{
const m=modeSelect.value;
varSelect.style.display=['Differentiate','Integrate','Solve'].includes(m)?'':'none';
orderInput.style.display=m==='Taylor'?'':'none';
});
function submitExpr(){
const input=exprInput.value.trim();if(!input)return;
let mode=modeSelect.value,kind;
// Smart detect d/dx
const dm=input.match(/^d\/d([a-z])\s+(.+)$/i);
if(dm){mode='Differentiate';modeSelect.value='Differentiate';varSelect.value=dm[1];exprInput.value=dm[2]}
const expr=dm?dm[2]:input;
switch(mode){
case'Raw':case'Simplify':kind=mode;break;
case'Differentiate':kind={Differentiate:{var:varSelect.value}};break;
case'Integrate':kind={Integrate:{var:varSelect.value}};break;
case'Solve':kind={Solve:{var:varSelect.value}};break;
case'Taylor':kind={Taylor:{var:varSelect.value,order:parseInt(orderInput.value)||5}};break;
}
send({type:'add_expr',input:expr,kind});
// Local fallback if no server
if(!S.ws||S.ws.readyState!==1){
const r=compileExpr(expr);
const entry={id:'local-'+Date.now(),input:expr,result:r.err||expr,latex:'',author:S.myName||'Local',timestamp:Date.now(),kind:mode};
S.expressions.unshift(entry);selectExpr(entry.id);renderExprs();
}
exprInput.value='';
}
$('eval-btn').onclick=submitExpr;
exprInput.addEventListener('keydown',e=>{if(e.key==='Enter')submitExpr()});
// ─── Render loop for cursors ───
function animLoop(){draw();requestAnimationFrame(animLoop)}
// ─── Join ───
function doJoin(){
S.myName=$('join-name').value.trim()||'User'+Math.floor(Math.random()*999);
S.sessionId=$('join-session').value.trim()||Math.random().toString(36).slice(2,8);
$('join-overlay').classList.add('hidden');
$('session-label').textContent='Session: '+S.sessionId;
connect();
resize();requestAnimationFrame(animLoop);
}
$('join-btn').onclick=doJoin;
$('join-name').addEventListener('keydown',e=>{if(e.key==='Enter')doJoin()});
$('join-session').addEventListener('keydown',e=>{if(e.key==='Enter')doJoin()});
$('join-host').addEventListener('keydown',e=>{if(e.key==='Enter')doJoin()});
window.addEventListener('resize',resize);
// Trigger initial varSelect visibility
modeSelect.dispatchEvent(new Event('change'));
</script>
</body>
</html>