296 lines
12 KiB
HTML
296 lines
12 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 — 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>
|