Skip to content

Instantly share code, notes, and snippets.

@RichardPotthoff
Created March 5, 2026 19:12
Show Gist options
  • Select an option

  • Save RichardPotthoff/00e508867d5aae815025a4fcea13f738 to your computer and use it in GitHub Desktop.

Select an option

Save RichardPotthoff/00e508867d5aae815025a4fcea13f738 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Turtle App (Modular)</title>
</head>
<body>
<script type="text/javascript">(function(global){function setupStyles(){const sty=document.createElement('style');sty.textContent=`
body { margin: 0; font-family: Arial, sans-serif; }
.container { display: grid; grid-template-columns: 300px 1fr; height: 100vh; overflow: hidden; }
.controls { padding: 10px; overflow-y: auto; border-right: 1px solid #ccc; }
.canvas-container { position: relative; overflow: hidden; }
canvas { border: 1px solid black; display: block; width: 100%; height: 100%; }
.tabs { display: flex; border-bottom: 1px solid #ccc; }
.tab { padding: 10px; cursor: pointer; background-color: #f0f0f0; border-right: 1px solid #ccc; }
.tab.active { background-color: #fff; font-weight: bold; }
.tab-content { display: none; padding: 10px; }
.tab-content.active { display: block; }
.current-segment { margin: 10px 0; font-size: 14px; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
table, th, td { border: 1px solid black; }
th, td { padding: 3px; text-align: center; }
th:nth-child(1), td:nth-child(1) { width: 40px; }
th:nth-child(2), td:nth-child(2) { width: 120px; }
th:nth-child(3), td:nth-child(3) { width: 120px; }
td input[type="number"] { width: 90px; padding: 2px; }
.selected { background-color: #d3d3d3; }
.scrollable-table { max-height: 300px; overflow-y: auto; }
.control-buttons { margin: 10px 0; }
.control-buttons button { margin: 0 5px; padding: 5px 10px; }
.settings-option { margin: 10px 0; }
@media (max-width: 768px) {
.container { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.controls { border-right: none; border-bottom: 1px solid #ccc; }
.scrollable-table { max-height: 200px; }
th:nth-child(1), td:nth-child(1) { width: 30px; }
th:nth-child(2), td:nth-child(2) { width: 100px; }
th:nth-child(3), td:nth-child(3) { width: 100px; }
td input[type="number"] { width: 70px; }
}
`;document.head.appendChild(sty);}
if(!("modules" in global)){global["modules"]={}}
global.modules["style.js"]={setupStyles};})(window);(function(global){function toRad(deg){return deg*Math.PI/180;}
function toDeg(rad){return rad*180/Math.PI;}
function normAng(angle){return((angle+360+720)%720)-360;}
if(!("modules" in global)){global["modules"]={}}
global.modules["util.js"]={toRad,toDeg,normAng};})(window);(function(global){let{toRad,toDeg,normAng}=modules["util.js"];let cvs,ctx;let turtle={x:0,y:0,hdg:0};let segs=[];let isDrag=false,dragX=0,dragY=0,prvSeg=null,prvEndX=0,prvEndY=0;let editIdx=-1,origEditIdx=-1,editStartX=0,editStartY=0,editStartHdg=0,initOffX=0,initOffY=0;function setEditIdx(newIdx){editIdx=newIdx;}
function setupDrawing(){cvs=document.createElement('canvas');document.querySelector('.canvas-container').appendChild(cvs);ctx=cvs.getContext('2d');resizeCvs();window.addEventListener('resize',resizeCvs);cvs.addEventListener('mousedown',startDrag);cvs.addEventListener('mousemove',updateDrag);cvs.addEventListener('mouseup',stopDrag);cvs.addEventListener('touchstart',startDrag);cvs.addEventListener('touchmove',updateDrag);cvs.addEventListener('touchend',stopDrag);ctx.strokeStyle='black';ctx.lineWidth=2;redraw();}
function resizeCvs(){const cont=cvs.parentElement;cvs.width=cont.clientWidth;cvs.height=cont.clientHeight;turtle.x=cvs.width/2;turtle.y=cvs.height/2;redraw();}
function drawArcSeg(len,ang,isPrv=false,startX=turtle.x,startY=turtle.y,startHdg=turtle.hdg){if(len===0){if(!isPrv) turtle.hdg+=toRad(ang);return{endX:startX,endY:startY,hdg:startHdg+toRad(ang)};}
if(ang===0){const dx=len*Math.cos(startHdg);const dy=len*Math.sin(startHdg);ctx.beginPath();ctx.moveTo(startX,startY);const endX=startX+dx;const endY=startY+dy;if(!isPrv){turtle.x=endX;turtle.y=endY;}
ctx.lineTo(endX,endY);ctx.stroke();return{endX,endY,hdg:startHdg};}
const angRad=toRad(ang);const rad=Math.abs(len/angRad);const steps=Math.max(10,Math.floor(Math.abs(len)/5));const stepLen=len/steps;const stepAng=angRad/steps;ctx.beginPath();ctx.moveTo(startX,startY);let curX=startX,curY=startY,curHdg=startHdg;for(let i=0;i<steps;i++){const dx=stepLen*Math.cos(curHdg);const dy=stepLen*Math.sin(curHdg);curX+=dx;curY+=dy;curHdg+=stepAng;ctx.lineTo(curX,curY);}
ctx.stroke();if(!isPrv){turtle.x=curX;turtle.y=curY;turtle.hdg=curHdg;}
return{endX:curX,endY:curY,hdg:curHdg};}
function calcPosUpTo(idx){let tmpT={x:cvs.width/2,y:cvs.height/2,hdg:0};for(let i=0;i<idx&&i<segs.length;i++){const{endX,endY,hdg}=drawArcSeg(segs[i].len,segs[i].ang,true,tmpT.x,tmpT.y,tmpT.hdg);tmpT.x=endX;tmpT.y=endY;tmpT.hdg=hdg;}
return tmpT;}
function redraw(){ctx.clearRect(0,0,cvs.width,cvs.height);turtle.x=cvs.width/2;turtle.y=cvs.height/2;turtle.hdg=0;ctx.strokeStyle='black';ctx.lineWidth=2;let tmpT={x:turtle.x,y:turtle.y,hdg:turtle.hdg};for(let i=0;i<segs.length;i++){const{endX,endY,hdg}=drawArcSeg(segs[i].len,segs[i].ang,false,tmpT.x,tmpT.y,tmpT.hdg);tmpT.x=endX;tmpT.y=endY;tmpT.hdg=hdg;}
if(editIdx>=0&&editIdx<segs.length){const pos=calcPosUpTo(editIdx);editStartX=pos.x;editStartY=pos.y;editStartHdg=pos.hdg;const{endX,endY}=drawArcSeg(segs[editIdx].len,segs[editIdx].ang,true,editStartX,editStartY,editStartHdg);if(!isDrag){prvEndX=endX;prvEndY=endY;}} else if(editIdx===-1){const pos=calcPosUpTo(segs.length);editStartX=pos.x;editStartY=pos.y;editStartHdg=pos.hdg;prvEndX=editStartX;prvEndY=editStartY;} else if(editIdx===-2){editStartX=cvs.width/2;editStartY=cvs.height/2;editStartHdg=0;prvEndX=editStartX;prvEndY=editStartY;}
if(isDrag&&prvSeg){ctx.strokeStyle='gray';ctx.lineWidth=1;drawArcSeg(prvSeg.len,prvSeg.ang,true,editStartX,editStartY,editStartHdg);ctx.strokeStyle='black';ctx.lineWidth=2;}
ctx.beginPath();ctx.arc(editStartX,editStartY,5,0,2*Math.PI);ctx.fillStyle='red';ctx.fill();ctx.strokeStyle='black';ctx.stroke();ctx.beginPath();ctx.arc(prvEndX,prvEndY,5,0,2*Math.PI);ctx.fillStyle='green';ctx.fill();ctx.strokeStyle='black';ctx.stroke();updateSegTable();}
function getRelPos(e){const rect=cvs.getBoundingClientRect();let x,y;if(e.type.startsWith('touch')){const touch=e.touches[0]||e.changedTouches[0];x=touch.clientX-rect.left;y=touch.clientY-rect.top;} else{x=e.clientX-rect.left;y=e.clientY-rect.top;}
return{x,y};}
function startDrag(e){e.preventDefault();isDrag=true;const pos=getRelPos(e);dragX=pos.x;dragY=pos.y;origEditIdx=editIdx;if(editIdx===-1){segs.push({len:0,ang:0});editIdx=segs.length-1;}
if(editIdx>=0&&editIdx<segs.length){const pos=calcPosUpTo(editIdx);editStartX=pos.x;editStartY=pos.y;editStartHdg=pos.hdg;const{endX,endY}=drawArcSeg(segs[editIdx].len,segs[editIdx].ang,true,editStartX,editStartY,editStartHdg);prvEndX=endX;prvEndY=endY;prvSeg={len:segs[editIdx].len,ang:segs[editIdx].ang};initOffX=prvEndX-editStartX;initOffY=prvEndY-editStartY;} else if(editIdx===-2){prvSeg={len:0,ang:0};prvEndX=editStartX;prvEndY=editStartY;initOffX=0;initOffY=0;}
redraw();}
function updateDrag(e){if(!isDrag) return;e.preventDefault();const pos=getRelPos(e);const dx=pos.x-dragX;const dy=pos.y-dragY;prvEndX=editStartX+initOffX+dx;prvEndY=editStartY+initOffY+dy;const secDx=prvEndX-editStartX;const secDy=prvEndY-editStartY;const secLen=Math.sqrt(secDx*secDx+secDy*secDy);const secAng=Math.atan2(secDy,secDx);const angRad=secAng-editStartHdg;let angDeg=toDeg(angRad);let arcAngDeg=2*angDeg;arcAngDeg=normAng(arcAngDeg);const absAngRad=toRad(Math.abs(arcAngDeg));let arcLen=0;if(absAngRad>0.0001){const rad=secLen/(2*Math.sin(absAngRad/2));arcLen=rad*absAngRad;} else{arcLen=secLen;}
prvSeg={len:arcLen,ang:arcAngDeg};if(editIdx>=0&&editIdx<segs.length){segs[editIdx]={len:arcLen,ang:arcAngDeg};}
redraw();}
function stopDrag(e){if(!isDrag) return;e.preventDefault();isDrag=false;if(prvSeg){if(editIdx>=0){if(origEditIdx===-1){editIdx=-1;}} else if(editIdx===-2){segs.unshift(prvSeg);editIdx=0;}
prvSeg=null;}
initOffX=0;initOffY=0;redraw();}
function updateSegTable(){const tbody=document.querySelector('#segmentTableBody');if(!tbody) return;tbody.innerHTML='';const startRow=document.createElement('tr');if(editIdx===-2) startRow.classList.add('selected');startRow.innerHTML=`<td>Start</td><td>-</td><td>-</td>`;startRow.onclick=(e)=>{if(e.target.tagName !=='INPUT') editSeg(-2);};tbody.appendChild(startRow);segs.forEach((seg,idx)=>{const row=document.createElement('tr');if(idx===editIdx) row.classList.add('selected');row.innerHTML=`
<td>${idx + 1}</td>
<td><input type="number" value="${seg.len.toFixed(2)}" onchange="updateSeg(${idx}, 'len', this.value)"></td>
<td><input type="number" value="${seg.ang.toFixed(2)}" onchange="updateSeg(${idx}, 'ang', this.value)"></td>
`;row.onclick=(e)=>{if(e.target.tagName !=='INPUT') editSeg(idx);};tbody.appendChild(row);});const endRow=document.createElement('tr');if(editIdx===-1) endRow.classList.add('selected');endRow.innerHTML=`<td>End</td><td>-</td><td>-</td>`;endRow.onclick=(e)=>{if(e.target.tagName !=='INPUT') editSeg(-1);};tbody.appendChild(endRow);const insBtn=document.querySelector('#insertBtn');const delBtn=document.querySelector('#deleteBtn');if(insBtn) insBtn.disabled=false;if(delBtn) delBtn.disabled=editIdx<0||editIdx>=segs.length;}
function editSeg(idx){editIdx=idx;redraw();}
function updateSeg(idx,field,val){segs[idx][field]=parseFloat(val)||0;redraw();}
if(!("modules" in global)){global["modules"]={}}
global.modules["draw.js"]={turtle,segs,isDrag,editIdx,setEditIdx,setupDrawing,drawArcSeg,calcPosUpTo,redraw,updateSeg};})(window);(function(global){let{turtle,segs,editIdx,prvSeg,prvEndX,prvEndY,redraw,updateSeg,setEditIdx}=modules["draw.js"];function setupControls(){const cont=document.querySelector('.controls');cont.innerHTML=`
<div class="tabs">
<div class="tab active" data-tab="draw">Draw</div>
<div class="tab" data-tab="file">File</div>
<div class="tab" data-tab="settings">Settings</div>
</div>
<div id="draw" class="tab-content active">
<div class="current-segment">
Current Segment: <span id="currentLen">0</span> pixels, <span id="currentAng">0</span> degrees
</div>
<div class="control-buttons">
<button id="clear">Clear</button>
<button id="insertBtn">Insert Arc</button>
<button id="deleteBtn">Delete Arc</button>
</div>
<div class="scrollable-table">
<table id="segmentTable">
<thead>
<tr>
<th>#</th>
<th>Length (pixels)</th>
<th>Angle (degrees)</th>
</tr>
</thead>
<tbody id="segmentTableBody"></tbody>
</table>
</div>
</div>
<div id="file" class="tab-content">
<div class="control-buttons">
<button id="exportBtn">Export Path</button>
<label for="importFile">Import Path:</label>
<input type="file" id="importFile" accept=".txt">
</div>
</div>
<div id="settings" class="tab-content">
<div class="settings-option">
<label>Layout Side:</label>
<select id="layoutSide">
<option value="left">Controls on Left</option>
<option value="right">Controls on Right</option>
</select>
</div>
</div>
`;cont.querySelector('#clear').addEventListener('click',clear);cont.querySelector('#insertBtn').addEventListener('click',insertArc);cont.querySelector('#deleteBtn').addEventListener('click',deleteArc);cont.querySelector('#exportBtn').addEventListener('click',exportPath);cont.querySelector('#importFile').addEventListener('change',importPath);cont.querySelector('#layoutSide').addEventListener('change',(e)=>swapLayout(e.target.value));cont.querySelectorAll('.tab').forEach(tab=>{tab.addEventListener('click',()=>showTab(tab.dataset.tab));});redraw();}
function clear(){segs.length=0;setEditIdx(-1);redraw();}
function insertArc(){if(editIdx===-2){segs.unshift({len:0,ang:0});} else if(editIdx===-1||editIdx===segs.length){segs.push({len:0,ang:0});} else{segs.splice(editIdx+1,0,{len:0,ang:0});}
setEditIdx(editIdx===-2?0:editIdx+1);redraw();}
function deleteArc(){if(editIdx<0||editIdx>=segs.length) return;segs.splice(editIdx,1);if(editIdx>=segs.length) setEditIdx(-1);redraw();}
function exportPath(){if(segs.length===0){alert("No segments to export!");return;}
const data=segs.map(seg=>`${seg.len},${seg.ang}`).join(',\n')+'\n';const blob=new Blob([data],{type:'text/plain'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='turtle_path.txt';document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);}
function importPath(e){const file=e.target.files[0];if(!file) return;const reader=new FileReader();reader.onload=function(e){const text=e.target.result;try{const cleaned=text.replace(/[\[\]]/g,'');const lines=cleaned.trim().split('\n');segs.length=0;let lineNum=0;for(const line of lines){lineNum++;const parts=line.split(',').map(num=>num.trim());let n=parts.length;if(parts[n-1]==="") n-=1;if(n%2 !==0){throw new Error(`Invalid number of values in line ${lineNum}: ${line} (expected "length,angle")`);}
for(let i=0;i<n;i+=2){const len=parseFloat(parts[i]);const ang=parseFloat(parts[i+1]);if(isNaN(len)){throw new Error(`Invalid length in line ${lineNum}, pair ${i/2 + 1}: ${parts[i]} is not a number`);}
if(isNaN(ang)){throw new Error(`Invalid angle in line ${lineNum}, pair ${i/2 + 1}: ${parts[i+1]} is not a number`);}
segs.push({len,ang});}}
setEditIdx(-1);redraw();} catch(err){alert(`Error loading file: ${err.message}`);segs.length=0;setEditIdx(-1);redraw();}};reader.onerror=function(){alert("Error reading file!");};reader.readAsText(file);}
function showTab(tabId){document.querySelectorAll('.tab').forEach(tab=>tab.classList.remove('active'));document.querySelectorAll('.tab-content').forEach(content=>content.classList.remove('active'));document.getElementById(tabId).classList.add('active');document.querySelector(`.tab[data-tab="${tabId}"]`).classList.add('active');}
function swapLayout(side){const cont=document.querySelector('.container');if(side==='right'){cont.style.gridTemplateColumns='1fr 300px';cont.style.gridTemplateAreas='"canvas controls"';cont.children[0].style.order=1;cont.children[1].style.order=0;} else{cont.style.gridTemplateColumns='300px 1fr';cont.style.gridTemplateAreas='"controls canvas"';cont.children[0].style.order=0;cont.children[1].style.order=1;}}
if(!("modules" in global)){global["modules"]={}}
global.modules["ctrl.js"]={setupControls};})(window);(function(global){let{setupStyles}=modules["style.js"];let{setupDrawing}=modules["draw.js"];let{setupControls}=modules["ctrl.js"];function initApp(){const body=typeof element !=='undefined'?element:document.body;body.innerHTML='<div class="container"><div class="controls"></div><div class="canvas-container"></div></div>';setupStyles();setupDrawing();setupControls();}
if(typeof element !=='undefined'){initApp();} else{document.addEventListener('DOMContentLoaded',initApp);}})(window);(function(global){})(window);</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment