Files
symclaw/skill/canvas/manipulate.html
T

296 lines
12 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 — Function Manipulator</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:'SF Mono',Consolas,'Courier New',monospace;display:flex;flex-direction:column;height:100vh;overflow:hidden}
#toolbar{display:flex;gap:8px;padding:10px 14px;background:#161b22;border-bottom:1px solid #30363d;align-items:center;flex-wrap:wrap}
#toolbar label{color:#8b949e;font-size:12px}
#expr-input{flex:1;min-width:200px;background:#0d1117;border:1px solid #30363d;color:#58a6ff;padding:6px 10px;border-radius:4px;font-family:inherit;font-size:14px}
#expr-input:focus{outline:none;border-color:#58a6ff}
.btn{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit}
.btn:hover{background:#30363d}
.btn.active{background:#1f6feb;border-color:#1f6feb;color:#fff}
#sliders{padding:6px 14px;background:#161b22;border-bottom:1px solid #30363d;display:flex;gap:16px;flex-wrap:wrap;min-height:0}
#sliders:empty{display:none}
.slider-group{display:flex;align-items:center;gap:6px}
.slider-group label{font-size:12px;color:#8b949e;min-width:14px}
.slider-group input[type=range]{width:120px;accent-color:#58a6ff}
.slider-group .val{font-size:12px;color:#58a6ff;min-width:40px;text-align:right}
#canvas-wrap{flex:1;position:relative;overflow:hidden}
canvas{display:block;width:100%;height:100%}
#info{position:absolute;bottom:8px;left:10px;font-size:11px;color:#484f58;pointer-events:none}
#expr-display{position:absolute;top:8px;left:10px;font-size:13px;color:#e6edf3;background:rgba(13,17,23,0.85);padding:4px 8px;border-radius:4px;pointer-events:none}
</style>
</head>
<body>
<div id="toolbar">
<label>f(x) =</label>
<input id="expr-input" type="text" value="a*sin(b*x + c)" spellcheck="false">
<button class="btn" id="btn-zin" title="Zoom In">+</button>
<button class="btn" id="btn-zout" title="Zoom Out"></button>
<button class="btn" id="btn-reset" title="Reset View"></button>
<button class="btn active" id="btn-grid" title="Toggle Grid">Grid</button>
</div>
<div id="sliders"></div>
<div id="canvas-wrap">
<canvas id="plot"></canvas>
<div id="expr-display"></div>
<div id="info"></div>
</div>
<script>
// --- Expression Parser ---
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 parse(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 left=mulDiv();
while(peek()&&(peek().v==='+'||peek().v==='-')){const op=eat().v;left={t:'bin',op,l:left,r:mulDiv()}}
return left;
}
function mulDiv(){
let left=unary();
while(peek()&&(peek().v==='*'||peek().v==='/')){const op=eat().v;left={t:'bin',op,l:left,r:unary()}}
return left;
}
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 left=atom();
if(peek()&&peek().v==='^'){eat();left={t:'bin',op:'^',l:left,r:unary()}}
return left;
}
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('Unexpected 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=parse(tokenize(str));return{tree,params:[...findParams(tree)].sort(),err:null}}
catch(e){return{tree:null,params:[],err:e.message}}
}
// --- State ---
let viewX=0,viewY=0,scaleX=50,scaleY=50; // pixels per unit
let showGrid=true,tree=null,params=[],paramVals={};
let dragging=false,dragStart={x:0,y:0},viewStart={x:0,y:0};
const canvas=document.getElementById('plot');
const ctx=canvas.getContext('2d');
const input=document.getElementById('expr-input');
const slidersDiv=document.getElementById('sliders');
const exprDisplay=document.getElementById('expr-display');
const infoDiv=document.getElementById('info');
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 buildSliders(){
slidersDiv.innerHTML='';
params.forEach(p=>{
if(paramVals[p]===undefined)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=paramVals[p];
const val=document.createElement('span');val.className='val';val.textContent=Number(paramVals[p]).toFixed(2);
inp.addEventListener('input',()=>{paramVals[p]=parseFloat(inp.value);val.textContent=Number(inp.value).toFixed(2);draw()});
g.append(lbl,inp,val);slidersDiv.append(g);
});
}
function updateExpr(){
const r=compileExpr(input.value);
tree=r.tree;params=r.params;
if(r.err){exprDisplay.textContent='Error: '+r.err;tree=null}
buildSliders();draw();
}
// --- Drawing ---
function toScreen(wx,wy){
const cx=canvas.width/2,cy=canvas.height/2;
return[cx+(wx-viewX)*scaleX*devicePixelRatio, cy-(wy-viewY)*scaleY*devicePixelRatio];
}
function toWorld(sx,sy){
const cx=canvas.width/2,cy=canvas.height/2;
return[(sx-cx)/(scaleX*devicePixelRatio)+viewX, -(sy-cy)/(scaleY*devicePixelRatio)+viewY];
}
function niceStep(range){
const rough=range/8;const mag=Math.pow(10,Math.floor(Math.log10(rough)));
const 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(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]=toScreen(0,0);if(ax>=0&&ax<=W){ctx.beginPath();ctx.moveTo(ax,0);ctx.lineTo(ax,H);ctx.stroke()}
const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){ctx.beginPath();ctx.moveTo(0,ay);ctx.lineTo(W,ay);ctx.stroke()}
// Plot
if(!tree){updateDisplay();return}
const vars={...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(tree,vars);
if(!isFinite(wy)){prevOk=false;continue}
const[sx,sy]=toScreen(wx,wy);
if(first||!prevOk){ctx.moveTo(sx,sy);first=false}else ctx.lineTo(sx,sy);
prevOk=true;
}
ctx.stroke();
updateDisplay();
}
function updateDisplay(){
if(!tree){return}
let s=input.value;
params.forEach(p=>{s=s.replace(new RegExp('\\b'+p+'\\b','g'),Number(paramVals[p]).toFixed(2))});
exprDisplay.textContent='f(x) = '+s;
infoDiv.textContent=`Scale: ${scaleX.toFixed(1)} px/unit | View: (${viewX.toFixed(2)}, ${viewY.toFixed(2)})`;
}
// --- Interaction ---
function zoom(factor,cx,cy){
if(!cx){cx=canvas.width/2;cy=canvas.height/2}
const[wx,wy]=toWorld(cx,cy);
scaleX*=factor;scaleY*=factor;
scaleX=Math.max(1,Math.min(1e5,scaleX));scaleY=Math.max(1,Math.min(1e5,scaleY));
const[nwx,nwy]=toWorld(cx,cy);
viewX+=wx-nwx;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=>{dragging=true;dragStart={x:e.clientX,y:e.clientY};viewStart={x:viewX,y:viewY};canvas.style.cursor='grabbing'});
window.addEventListener('mousemove',e=>{if(!dragging)return;
viewX=viewStart.x-(e.clientX-dragStart.x)/(scaleX);viewY=viewStart.y+(e.clientY-dragStart.y)/(scaleY);draw()});
window.addEventListener('mouseup',()=>{dragging=false;canvas.style.cursor='crosshair'});
// Touch
let touches=[];
canvas.addEventListener('touchstart',e=>{e.preventDefault();touches=[...e.touches];
if(touches.length===1){dragging=true;dragStart={x:touches[0].clientX,y:touches[0].clientY};viewStart={x:viewX,y:viewY}}
},{passive:false});
canvas.addEventListener('touchmove',e=>{e.preventDefault();const t=[...e.touches];
if(t.length===1&&dragging){viewX=viewStart.x-(t[0].clientX-dragStart.x)/scaleX;viewY=viewStart.y+(t[0].clientY-dragStart.y)/scaleY;draw()}
else if(t.length===2&&touches.length===2){
const d0=Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);
const d1=Math.hypot(t[0].clientX-t[1].clientX,t[0].clientY-t[1].clientY);
zoom(d1/d0,(t[0].clientX+t[1].clientX)/2*devicePixelRatio,(t[0].clientY+t[1].clientY)/2*devicePixelRatio);
touches=t;
}
},{passive:false});
canvas.addEventListener('touchend',()=>{dragging=false;touches=[]});
// Buttons
document.getElementById('btn-zin').onclick=()=>zoom(1.3);
document.getElementById('btn-zout').onclick=()=>zoom(1/1.3);
document.getElementById('btn-reset').onclick=()=>{viewX=0;viewY=0;scaleX=50;scaleY=50;draw()};
document.getElementById('btn-grid').onclick=function(){showGrid=!showGrid;this.classList.toggle('active');draw()};
input.addEventListener('input',updateExpr);
canvas.style.cursor='crosshair';
window.addEventListener('resize',resize);
resize();updateExpr();
</script>
</body>
</html>