Files
symclaw/skill/canvas/manipulate-gpu.html
T

471 lines
26 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 — GPU 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}
select{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:4px 6px;border-radius:4px;font-size:12px;font-family:inherit}
#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}
#stats{position:absolute;top:8px;right:10px;font-size:11px;color:#8b949e;background:rgba(13,17,23,0.85);padding:6px 10px;border-radius:4px;pointer-events:none;text-align:right;line-height:1.6}
#stats .gpu-label{color:#3fb950;font-weight:bold}
#stats .cpu-label{color:#d29922;font-weight:bold}
#fallback-msg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#d29922;font-size:14px;display:none;text-align:center;background:rgba(13,17,23,0.9);padding:12px 20px;border-radius:8px;border:1px solid #30363d}
</style>
</head>
<body>
<div id="toolbar">
<label>f(x) =</label>
<input id="expr-input" type="text" value="a*sin(b*x + c)" spellcheck="false">
<select id="res-select" title="Resolution">
<option value="1000">1K pts</option>
<option value="10000" selected>10K pts</option>
<option value="100000">100K pts</option>
<option value="1000000">1M pts</option>
</select>
<button class="btn active" id="btn-mode">GPU</button>
<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 id="stats"></div>
<div id="fallback-msg"></div>
</div>
<script>
// ===== Expression Parser (shared) =====
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};
const WGSL_FUNCS={sin:'sin',cos:'cos',tan:'tan',exp:'exp',ln:'log',log:'log',sqrt:'sqrt',abs:'abs',asin:'asin',acos:'acos',atan:'atan',ceil:'ceil',floor:'floor'};
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 parseTokens(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=parseTokens(tokenize(str));return{tree,params:[...findParams(tree)].sort(),err:null}}catch(e){return{tree:null,params:[],err:e.message}}}
// ===== AST to WGSL =====
function astToWgsl(node, paramNames){
if(node.t==='num'){const v=node.v;return Number.isInteger(v)?v.toFixed(1):String(v)}
if(node.t==='var'){if(node.name==='x')return'x';const idx=paramNames.indexOf(node.name);return idx>=0?`p[${idx}]`:'0.0'}
if(node.t==='bin'){const l=astToWgsl(node.l,paramNames),r=astToWgsl(node.r,paramNames);if(node.op==='^')return`pow(${l},${r})`;return`(${l} ${node.op} ${r})`}
if(node.t==='call'){const wn=WGSL_FUNCS[node.name];if(!wn)return'0.0';return`${wn}(${node.args.map(a=>astToWgsl(a,paramNames)).join(', ')})`}
return'0.0';
}
function makeComputeShader(tree, paramNames){
const body=astToWgsl(tree,paramNames);
return`
@group(0) @binding(0) var<storage, read> x_vals: array<f32>;
@group(0) @binding(1) var<storage, read_write> y_vals: array<f32>;
@group(0) @binding(2) var<uniform> p: array<vec4<f32>, 2>;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&x_vals)) { return; }
let x = x_vals[i];
y_vals[i] = ${body};
}`;
}
// ===== State =====
let viewX=0,viewY=0,scaleX=50,scaleY=50;
let showGrid=true,tree=null,params=[],paramVals={};
let dragging=false,dragStart={x:0,y:0},viewStart={x:0,y:0};
let gpuMode=true, numPoints=10000;
let gpuReady=false, gpuName='';
let device,context,canvasFormat,computePipeline,computeBGL;
let xBuffer,yBuffer,paramBuffer,stagingBuffer;
let lastExprStr='', lastShaderSrc='';
let fpsFrames=0,fpsTime=performance.now(),fps=0;
let evalTimeMs=0,renderTimeMs=0;
const canvas=document.getElementById('plot');
const input=document.getElementById('expr-input');
const slidersDiv=document.getElementById('sliders');
const exprDisplay=document.getElementById('expr-display');
const infoDiv=document.getElementById('info');
const statsDiv=document.getElementById('stats');
const fallbackMsg=document.getElementById('fallback-msg');
const resSelect=document.getElementById('res-select');
const modeBtn=document.getElementById('btn-mode');
// ===== CPU fallback =====
let ctx2d=null;
function initCPU(){ctx2d=canvas.getContext('2d')}
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,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 drawCPU(){
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
ctx2d.clearRect(0,0,W,H);ctx2d.fillStyle='#0d1117';ctx2d.fillRect(0,0,W,H);
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax]=toWorld(0,0),[,yMin]=toWorld(0,H);
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax-yMin);ctx2d.strokeStyle='#21262d';ctx2d.lineWidth=1;ctx2d.font=(11*dpr)+'px monospace';ctx2d.fillStyle='#484f58';ctx2d.textAlign='center';ctx2d.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);ctx2d.beginPath();ctx2d.moveTo(px,0);ctx2d.lineTo(px,H);ctx2d.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);ctx2d.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}ctx2d.textAlign='right';ctx2d.textBaseline='middle';for(let gy=Math.floor(yMin/sy)*sy;gy<=yMax;gy+=sy){const[,py]=toScreen(0,gy);ctx2d.beginPath();ctx2d.moveTo(0,py);ctx2d.lineTo(W,py);ctx2d.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);ctx2d.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
ctx2d.strokeStyle='#30363d';ctx2d.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){ctx2d.beginPath();ctx2d.moveTo(ax,0);ctx2d.lineTo(ax,H);ctx2d.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){ctx2d.beginPath();ctx2d.moveTo(0,ay);ctx2d.lineTo(W,ay);ctx2d.stroke()}
if(!tree)return;
const t0=performance.now();
const vars={...paramVals};ctx2d.strokeStyle='#58a6ff';ctx2d.lineWidth=2*dpr;ctx2d.beginPath();let first=true,prevOk=false;
const steps=Math.min(numPoints,100000);
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){ctx2d.moveTo(sx,sy);first=false}else ctx2d.lineTo(sx,sy);prevOk=true}
ctx2d.stroke();
evalTimeMs=performance.now()-t0;renderTimeMs=evalTimeMs;
}
// ===== WebGPU Init =====
async function initGPU(){
if(!navigator.gpu){showFallback('WebGPU not available — using CPU rendering');return false}
const adapter=await navigator.gpu.requestAdapter();
if(!adapter){showFallback('No GPU adapter — using CPU rendering');return false}
gpuName=(await adapter.requestAdapterInfo()).description||adapter.name||'GPU';
device=await adapter.requestDevice();
canvasFormat=navigator.gpu.getPreferredCanvasFormat();
context=canvas.getContext('webgpu');
context.configure({device,format:canvasFormat,alphaMode:'premultiplied'});
allocateBuffers();
return true;
}
function allocateBuffers(){
const size=numPoints*4;
xBuffer=device.createBuffer({size,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST});
yBuffer=device.createBuffer({size,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
paramBuffer=device.createBuffer({size:32,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
stagingBuffer=device.createBuffer({size,usage:GPUBufferUsage.MAP_READ|GPUBufferUsage.COPY_DST});
}
function rebuildComputePipeline(){
if(!tree||!device)return false;
const src=makeComputeShader(tree,params);
if(src===lastShaderSrc)return true;
lastShaderSrc=src;
try{
const module=device.createShaderModule({code:src});
computePipeline=device.createComputePipeline({layout:'auto',compute:{module,entryPoint:'main'}});
computeBGL=computePipeline.getBindGroupLayout(0);
return true;
}catch(e){console.error('Shader error:',e);return false}
}
function uploadParams(){
const data=new Float32Array(8);
params.forEach((p,i)=>{if(i<8)data[i]=paramVals[p]||0});
device.queue.writeBuffer(paramBuffer,0,data);
}
function uploadXValues(){
const W=canvas.width,H=canvas.height;
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0);
const data=new Float32Array(numPoints);
for(let i=0;i<numPoints;i++)data[i]=xMin+(xMax-xMin)*i/(numPoints-1);
device.queue.writeBuffer(xBuffer,0,data);
}
async function computeOnGPU(){
if(!computePipeline)return null;
uploadXValues();uploadParams();
const bg=device.createBindGroup({layout:computeBGL,entries:[{binding:0,resource:{buffer:xBuffer}},{binding:1,resource:{buffer:yBuffer}},{binding:2,resource:{buffer:paramBuffer}}]});
const enc=device.createCommandEncoder();
const pass=enc.beginComputePass();
pass.setPipeline(computePipeline);pass.setBindGroup(0,bg);
pass.dispatchWorkgroups(Math.ceil(numPoints/256));pass.end();
enc.copyBufferToBuffer(yBuffer,0,stagingBuffer,0,numPoints*4);
device.queue.submit([enc.finish()]);
await stagingBuffer.mapAsync(GPUMapMode.READ);
const result=new Float32Array(stagingBuffer.getMappedRange().slice(0));
stagingBuffer.unmap();
return result;
}
async function drawGPU(){
if(!tree||!device)return;
// We render with Canvas2D overlay for grid/axes, and GPU for compute
// Get a 2d context from an offscreen approach — actually, since we configured webgpu,
// we'll draw the plot data with the 2d fallback on a separate overlay or just read back and draw.
// Simplest correct approach: GPU compute + CPU Canvas2D render (still huge speedup for eval)
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax]=toWorld(0,0),[,yMin]=toWorld(0,H);
// GPU compute
const t0=performance.now();
if(!rebuildComputePipeline()){drawFallbackError();return}
const yVals=await computeOnGPU();
evalTimeMs=performance.now()-t0;
if(!yVals)return;
// Render to canvas via 2D (we need to get a 2d context)
// Since we used webgpu context, we draw to an offscreen then blit...
// Actually let's just use a second canvas for the 2D overlay
renderGPUResult(yVals,xMin,xMax);
}
// We'll use an offscreen canvas for 2D drawing since main canvas has webgpu context
let offCanvas,offCtx;
function ensureOffCanvas(){
if(!offCanvas){offCanvas=document.createElement('canvas');offCtx=offCanvas.getContext('2d')}
offCanvas.width=canvas.width;offCanvas.height=canvas.height;
}
function renderGPUResult(yVals,xMin,xMax){
const t0=performance.now();
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
const[,yMax2]=toWorld(0,0),[,yMin2]=toWorld(0,H);
// Draw to webgpu canvas by copying from offscreen
ensureOffCanvas();
const c=offCtx;
c.clearRect(0,0,W,H);c.fillStyle='#0d1117';c.fillRect(0,0,W,H);
// Grid
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax2-yMin2);c.strokeStyle='#21262d';c.lineWidth=1;c.font=(11*dpr)+'px monospace';c.fillStyle='#484f58';c.textAlign='center';c.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);c.beginPath();c.moveTo(px,0);c.lineTo(px,H);c.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);c.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}c.textAlign='right';c.textBaseline='middle';for(let gy=Math.floor(yMin2/sy)*sy;gy<=yMax2;gy+=sy){const[,py]=toScreen(0,gy);c.beginPath();c.moveTo(0,py);c.lineTo(W,py);c.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);c.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
// Axes
c.strokeStyle='#30363d';c.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){c.beginPath();c.moveTo(ax,0);c.lineTo(ax,H);c.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){c.beginPath();c.moveTo(0,ay);c.lineTo(W,ay);c.stroke()}
// Plot curve
c.strokeStyle='#58a6ff';c.lineWidth=2*dpr;c.beginPath();
let first=true,prevOk=false;
const step=Math.max(1,Math.floor(numPoints/W));// downsample for drawing if needed
for(let i=0;i<numPoints;i+=step){
const wx=xMin+(xMax-xMin)*i/(numPoints-1);
const wy=yVals[i];
if(!isFinite(wy)){prevOk=false;continue}
const[sx,sy]=toScreen(wx,wy);
if(first||!prevOk){c.moveTo(sx,sy);first=false}else c.lineTo(sx,sy);
prevOk=true;
}
c.stroke();
renderTimeMs=performance.now()-t0;
// Blit to webgpu canvas via copyExternalImageToTexture
const tex=context.getCurrentTexture();
device.queue.copyExternalImageToTexture({source:offCanvas},{texture:tex},[W,H]);
}
function drawFallbackError(){
ensureOffCanvas();offCtx.clearRect(0,0,canvas.width,canvas.height);
offCtx.fillStyle='#0d1117';offCtx.fillRect(0,0,canvas.width,canvas.height);
offCtx.fillStyle='#f85149';offCtx.font='14px monospace';offCtx.fillText('Shader compilation error',20,30);
const tex=context.getCurrentTexture();
device.queue.copyExternalImageToTexture({source:offCanvas},{texture:tex},[canvas.width,canvas.height]);
}
// ===== Draw dispatcher =====
let drawing=false;
async function draw(){
if(drawing)return;drawing=true;
try{
if(gpuMode&&gpuReady){await drawGPU()}
else{drawCPU()}
updateDisplay();updateFPS();
}finally{drawing=false}
}
function updateFPS(){
fpsFrames++;const now=performance.now();
if(now-fpsTime>=1000){fps=Math.round(fpsFrames*1000/(now-fpsTime));fpsFrames=0;fpsTime=now}
}
function updateDisplay(){
if(!tree){exprDisplay.textContent='';statsDiv.innerHTML='';return}
let s=input.value;params.forEach(p=>{s=s.replace(new RegExp('\\b'+p+'\\b','g'),Number(paramVals[p]||0).toFixed(2))});
exprDisplay.textContent='f(x) = '+s;
const mode=gpuMode&&gpuReady;
statsDiv.innerHTML=`<span class="${mode?'gpu':'cpu'}-label">${mode?'⚡ GPU':'🖥 CPU'}</span><br>${numPoints.toLocaleString()} points<br>Eval: ${evalTimeMs.toFixed(1)}ms<br>Render: ${renderTimeMs.toFixed(1)}ms<br>FPS: ${fps}${mode?'<br><span style="color:#484f58">'+gpuName+'</span>':''}`;
infoDiv.textContent=`Scale: ${scaleX.toFixed(1)} px/unit | View: (${viewX.toFixed(2)}, ${viewY.toFixed(2)})`;
}
function showFallback(msg){fallbackMsg.textContent=msg;fallbackMsg.style.display='block';setTimeout(()=>fallbackMsg.style.display='none',4000)}
// ===== Sliders =====
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);lastShaderSrc='';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}
lastShaderSrc='';buildSliders();draw();
}
// ===== 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'});
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),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=[]});
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()};
modeBtn.onclick=function(){
gpuMode=!gpuMode;
this.textContent=gpuMode&&gpuReady?'GPU':'CPU';
this.classList.toggle('active',gpuMode&&gpuReady);
// Switch canvas context
reinitCanvas();
draw();
};
resSelect.onchange=function(){
numPoints=parseInt(this.value);
if(gpuReady&&gpuMode){allocateBuffers();lastShaderSrc=''}
draw();
};
function reinitCanvas(){
// Recreate canvas to switch context type
const wrap=canvas.parentElement;
const newCanvas=document.createElement('canvas');
newCanvas.id='plot';newCanvas.style.cssText=canvas.style.cssText;
// Copy event listeners by replacing
wrap.replaceChild(newCanvas,canvas);
// Update ref — but we need to re-bind events... simpler: just reload
// Actually let's just use the offscreen approach for GPU and always keep 2d
// Hmm, this is getting complicated. Let me simplify the architecture.
}
// ===== Simplified Architecture =====
// GPU compute + always Canvas2D render. This avoids context switching issues.
// The GPU acceleration is for the COMPUTE (expression evaluation), not rendering.
// This is where the real perf win is anyway (1M point eval on GPU vs CPU).
async function init(){
// Always use 2D context for rendering
ctx2d=canvas.getContext('2d');
// Try to init WebGPU for compute
if(navigator.gpu){
try{
const adapter=await navigator.gpu.requestAdapter();
if(adapter){
gpuName=(await adapter.requestAdapterInfo()).description||'WebGPU';
device=await adapter.requestDevice();
allocateBuffers();
gpuReady=true;
}else{showFallback('No GPU adapter — using CPU');gpuMode=false}
}catch(e){showFallback('WebGPU init failed — using CPU');gpuMode=false}
}else{showFallback('WebGPU not available — using CPU');gpuMode=false}
if(!gpuReady){modeBtn.textContent='CPU';modeBtn.classList.remove('active')}
canvas.style.cursor='crosshair';
input.addEventListener('input',updateExpr);
window.addEventListener('resize',resize);
resize();updateExpr();
}
// Override drawGPU to use 2D context directly
drawGPU=async function(){
if(!tree||!device)return;
const W=canvas.width,H=canvas.height,dpr=devicePixelRatio;
const[xMin]=toWorld(0,0),[xMax]=toWorld(W,0),[,yMax2]=toWorld(0,0),[,yMin2]=toWorld(0,H);
ctx2d.clearRect(0,0,W,H);ctx2d.fillStyle='#0d1117';ctx2d.fillRect(0,0,W,H);
// Grid + axes (same as CPU)
if(showGrid){const sx=niceStep(xMax-xMin),sy=niceStep(yMax2-yMin2);ctx2d.strokeStyle='#21262d';ctx2d.lineWidth=1;ctx2d.font=(11*dpr)+'px monospace';ctx2d.fillStyle='#484f58';ctx2d.textAlign='center';ctx2d.textBaseline='top';for(let gx=Math.floor(xMin/sx)*sx;gx<=xMax;gx+=sx){const[px]=toScreen(gx,0);ctx2d.beginPath();ctx2d.moveTo(px,0);ctx2d.lineTo(px,H);ctx2d.stroke();if(Math.abs(gx)>sx*0.1){const[,py]=toScreen(0,0);ctx2d.fillText(+gx.toFixed(6),px,Math.min(Math.max(py+4*dpr,2*dpr),H-14*dpr))}}ctx2d.textAlign='right';ctx2d.textBaseline='middle';for(let gy=Math.floor(yMin2/sy)*sy;gy<=yMax2;gy+=sy){const[,py]=toScreen(0,gy);ctx2d.beginPath();ctx2d.moveTo(0,py);ctx2d.lineTo(W,py);ctx2d.stroke();if(Math.abs(gy)>sy*0.1){const[px]=toScreen(0,0);ctx2d.fillText(+gy.toFixed(6),Math.min(Math.max(px-4*dpr,40*dpr),W-2*dpr),py)}}}
ctx2d.strokeStyle='#30363d';ctx2d.lineWidth=1.5*dpr;const[ax]=toScreen(0,0);if(ax>=0&&ax<=W){ctx2d.beginPath();ctx2d.moveTo(ax,0);ctx2d.lineTo(ax,H);ctx2d.stroke()}const[,ay]=toScreen(0,0);if(ay>=0&&ay<=H){ctx2d.beginPath();ctx2d.moveTo(0,ay);ctx2d.lineTo(W,ay);ctx2d.stroke()}
// GPU compute
const t0=performance.now();
if(!rebuildComputePipeline()){exprDisplay.textContent='Shader error';return}
const yVals=await computeOnGPU();
evalTimeMs=performance.now()-t0;
if(!yVals)return;
// Draw curve
const tr=performance.now();
ctx2d.strokeStyle='#58a6ff';ctx2d.lineWidth=2*dpr;ctx2d.beginPath();
let first=true,prevOk=false;
// Downsample to ~2x canvas width for drawing efficiency
const maxDraw=Math.min(numPoints,W*2);
const step=Math.max(1,Math.floor(numPoints/maxDraw));
for(let i=0;i<numPoints;i+=step){
const wx=xMin+(xMax-xMin)*i/(numPoints-1);
const wy=yVals[i];
if(!isFinite(wy)||Math.abs(wy)>1e10){prevOk=false;continue}
const[sx,sy]=toScreen(wx,wy);
if(first||!prevOk){ctx2d.moveTo(sx,sy);first=false}else ctx2d.lineTo(sx,sy);
prevOk=true;
}
ctx2d.stroke();
renderTimeMs=performance.now()-tr;
};
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();
}
// Remove the reinitCanvas complexity
reinitCanvas=function(){};
modeBtn.onclick=function(){
if(!gpuReady){showFallback('WebGPU not available');return}
gpuMode=!gpuMode;
this.textContent=gpuMode?'GPU':'CPU';
this.classList.toggle('active',gpuMode);
lastShaderSrc='';draw();
};
init();
</script>
</body>
</html>