382 lines
23 KiB
HTML
382 lines
23 KiB
HTML
<!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>
|