Created
March 5, 2026 19:12
-
-
Save RichardPotthoff/00e508867d5aae815025a4fcea13f738 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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