UNPKG

@shlomil/turtlejs

Version:

A feature-rich JavaScript implementation of turtle graphics inspired by Python's turtle module

1,519 lines (1,282 loc) 144 kB
'use strict'; var tk_colors, hex_to_colors; var defaults = { width : 0.5, // Screen height : 0.75, canvwidth : 400, canvheight: 300, leftright: null, topbottom: null, mode: "standard", // TurtleScreen colormode: 1.0, colormode_keep_names: false, delay: 10, undobuffersize: 1000, // RawTurtle shape: "classic", pencolor : "black", fillcolor : "black", resizemode : "noresize", visible : true, x: 0, // Turtle defaults y: 0, angle: 0, isDown: true, drawSpeed: 1, penSize: 1, filling: false, fillRule: 'evenodd', tiltAngle: 0, turtleSize: 10, stretchFactors :{ width: 1, length: 1, outline: 1 }, fullcircle: 360.0, maxUndoSteps: 10, shapes:{ 'turtle': [[0,16], [-2,14], [-1,10], [-4,7], [-7,9], [-9,8], [-6,5], [-7,1], [-5,-3], [-8,-6], [-6,-8], [-4,-5], [0,-7], [4,-5], [6,-8], [8,-6], [5,-3], [7,1], [6,5], [9,8], [7,9], [4,7], [1,10], [2,14]], 'arrow': [[0,10], [-5,0], [5,0]], 'circle': [...Array(36)].map((_, i) => { const angle = i * 10 * Math.PI / 180; return [Math.sin(angle) * 10, Math.cos(angle) * 10]; }), 'square': [[10,10], [10,-10], [-10,-10], [-10,10]], 'triangle': [[0,10], [-10,-10], [10,-10]], 'classic': [[0,0], [-5,-9], [0,-7], [5,-9]] }, imageRendering: 'pixelated', imageSmoothing: false, imageSmoothingQuality: 'low' }; // Convert special key names to match JavaScript events const keyMap = { 'space': ' ', 'return': 'Enter', 'tab': 'Tab', 'backspace': 'Backspace', 'delete': 'Delete', 'escape': 'Escape', 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight' }; function isWebWorker() { return typeof self !== 'undefined' && typeof window === 'undefined' || (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope); } function isBrowser() {return typeof window !== 'undefined' && typeof document !== 'undefined';} const is_browser = isBrowser(); const is_webworker = isWebWorker(); let WINDOW = is_browser ? window : null; let DOCUMENT = is_browser ? document : null; class Turtle { #delay = 0; #canvas; #ctx; #x; #y; #angle; #isDown; #drawSpeed; #color; #penSize; #isVisible; #turtleSize; // Size of the turtle cursor #lastDrawnTurtleState = null; // Add this to class fields #shapes = { 'turtle': [[0,16], [-2,14], [-1,10], [-4,7], [-7,9], [-9,8], [-6,5], [-7,1], [-5,-3], [-8,-6], [-6,-8], [-4,-5], [0,-7], [4,-5], [6,-8], [8,-6], [5,-3], [7,1], [6,5], [9,8], [7,9], [4,7], [1,10], [2,14]], 'arrow': [[0,10], [-5,0], [5,0]], 'circle': [...Array(36)].map((_, i) => { const angle = i * 10 * Math.PI / 180; return [Math.sin(angle) * 10, Math.cos(angle) * 10]; }), 'square': [[10,10], [10,-10], [-10,-10], [-10,10]], 'triangle': [[0,10], [-10,-10], [10,-10]], 'classic': [[0,0], [-5,-9], [0,-7], [5,-9]] }; #currentShape; #filling; #fillPath = null; #fillcolor; #stampIds = 1; #stamps = new Map(); #tiltAngle; #mode; #undoBuffer = []; #maxUndoSteps; #resizeMode; #stretchFactors; #keyHandlers = new Map(); #currentAnimationPromise = Promise.resolve(); #fullcircle; // Add this property declaration #imageSmoothing = false; #imageSmoothingQuality = 'low'; #screen; // Add screen reference #undobuffersize; #pathBuffer = []; // Add this to store Path2D objects #needsUpdate = false; // Flag to indicate when screen needs redrawing #worldCoordinates; #polyPoints = null; #currentPoly = null; imageSmoothing(smoothing=null) { if (smoothing === null) return this.#imageSmoothing; this.#imageSmoothing = smoothing; } imageSmoothingQuality(quality=null) { if (quality === null) return this.#imageSmoothingQuality; if (['low', 'medium', 'high'].includes(quality)) { this.#imageSmoothingQuality = quality; } else { throw new Error("imageSmoothingQuality() expects 'low', 'medium', or 'high'"); } } get #colormode() { return this.#screen.colormode(); } #col_user(color) { return this.#screen._user_color(color); } #col_arg(color) { return this.#screen._color_arg_normalize(color); } constructor(canvasOrScreenOrTurtle, config = defaults) { // Handle either Screen instance or canvas element if (canvasOrScreenOrTurtle instanceof Screen) { this.#screen = canvasOrScreenOrTurtle; this.#canvas = canvasOrScreenOrTurtle.getcanvas(); } else if (canvasOrScreenOrTurtle instanceof Turtle) { this.#screen = canvasOrScreenOrTurtle.getscreen(); this.#canvas = this.#screen.getcanvas(); config = canvasOrScreenOrTurtle._getstate(); } else { // Create new Screen if canvas provided this.#screen = new Screen(canvasOrScreenOrTurtle); this.#canvas = canvasOrScreenOrTurtle; } // Add this turtle to screen's turtle collection this.#screen.addTurtle(this); this.#ctx = this.#canvas.getContext('2d', { willReadFrequently: true }); this.#ctx.imageSmoothingEnabled = this.#imageSmoothing; this.#ctx.imageSmoothingQuality = this.#imageSmoothingQuality; // Initialize other properties this._setstate(config); this.#worldCoordinates = this.#screen.getWorldCoordinates(); // Draw the initial turtle this.#drawTurtle(); } // Add this helper method to queue any operation async #queueOperation(operation) { this.#currentAnimationPromise = this.#currentAnimationPromise.then(() => { return new Promise(async resolve => { let res = await operation(); resolve(res); }); }); return this.#currentAnimationPromise; } // Convert world coordinates to screen coordinates #worldToScreen(x, y) { return this.#screen._worldToScreen(x, y); } // Convert screen coordinates to world coordinates #screenToWorld(x, y) { return this.#screen._screenToWorld(x, y); } // Screen methods async screensize(width=null, height=null, bgColor=null) { return this.#queueOperation(() => { if (width !== null && height !== null) { this.#canvas.width = width; this.#canvas.height = height; } if (bgColor !== null) { this.#canvas.style.backgroundColor = this.#col_arg(bgColor); } return [this.#canvas.width, this.#canvas.height]; }); } async begin_poly() { return this.#queueOperation(() => { // Start recording the vertices of a polygon this.#polyPoints = []; // Add current position as the first vertex this.#polyPoints.push([this.#x, this.#y]); return Promise.resolve(); }); } async end_poly() { return this.#queueOperation(() => { // Stop recording the vertices of a polygon if (!this.#polyPoints) { throw new Error("end_poly() without matching begin_poly()"); } // Add the current position as the last vertex this.#polyPoints.push([this.#x, this.#y]); // Save the completed polygon this.#currentPoly = [...this.#polyPoints]; // Reset the recording points array this.#polyPoints = null; return Promise.resolve(); }); } async get_poly() { return this.#queueOperation(() => { // Return the last recorded polygon if (!this.#currentPoly) { throw new Error("No polygon has been recorded yet. Use begin_poly() and end_poly() first."); } return [...this.#currentPoly]; }); } async resizemode(rmode=null) { if (!["noresize", "auto", "user"].includes(rmode)) { throw new Error("resizemode() expects 'noresize', 'auto', or 'user'"); } return this.#queueOperation(() => { if (rmode === null) { return this.#resizeMode; } if (this.#resizeMode === rmode) { this.#clearTurtle(); this.#resizeMode = rmode; this.#drawTurtle(); } this.#resizeMode = rmode; return this.#resizeMode; }); } async setundobuffer(size=null) { return this.#queueOperation(() => { if (size !== null) { this.#undobuffersize = size; } else { this.#undobuffersize = 0; } return this.#undobuffersize; }); } async setworldcoordinates(left, bottom, right, top) { return this.#queueOperation(() => { this.#screen.setworldcoordinates(left, bottom, right, top); }); } _setworldcoordinates(/*left, bottom, right, top*/) { const wasVisible = this.#isVisible; this.#isVisible = false; this.#clearTurtle(); if (!this.#worldcoordIsEqual(this.#screen.getWorldCoordinates(), this.#worldCoordinates)) { this._clear(false); this._pensize(this.#penSize); this.#needsUpdate = true; this.#worldCoordinates = this.#screen.getWorldCoordinates(); this._update(); } this.#isVisible = wasVisible; if (wasVisible) { this.#drawTurtle(); } } // Update existing methods to use the new coordinate system #clearTurtle() { if (this.#lastDrawnTurtleState) { this.#ctx.putImageData( this.#lastDrawnTurtleState.imageData, this.#lastDrawnTurtleState.x, this.#lastDrawnTurtleState.y ); } this.#lastDrawnTurtleState = null; } async forward(distance) { return this.#queueOperation(async () => { await this._forward(distance); }); } async _forward(distance) { this.#clearTurtle(); const state = this.#saveState('forward', [distance]); const startX = this.#x; const startY = this.#y; const startTime = performance.now(); const radians = (this.#angle * Math.PI) / 180; const duration = this.#drawSpeed === 0 ? 0 : (11 - this.#drawSpeed) * 50; // Initialize the path with the starting point if pen is down this.#addPointToState(state, startX, startY); const animate = (currentTime) => { this.#clearTurtle(); const elapsed = currentTime - startTime; const progress = Math.max(0, Math.min(elapsed / duration, 1)); const prevX = this.#x; const prevY = this.#y; // Calculate current position in world coordinates this.#x = startX + (distance * Math.cos(radians) * progress); this.#y = startY + (distance * Math.sin(radians) * progress); // Draw line if pen is down if (this.#isDown) { // Convert world coordinates to screen coordinates const [screenStartX, screenStartY] = this.#worldToScreen(prevX, prevY); const [screenEndX, screenEndY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.moveTo(screenStartX, screenStartY); this.#ctx.lineTo(screenEndX, screenEndY); this.#ctx.stroke(); } this.#drawTurtle(); }; if (this.#drawSpeed < 10) { // Start animation let that = this; return new Promise(resolve => { requestAnimationFrame(function animateWrapper(currentTime) { animate(currentTime); if (currentTime - startTime >= duration) { that.#addPointToState(state, that.#x, that.#y); that.#polyPoints && that.#polyPoints.push([that.#x, that.#y]); resolve(); } else { requestAnimationFrame(animateWrapper); } }); }); } else { // Instant movement for maximum speed this.#x = startX + (distance * Math.cos(radians)); this.#y = startY + (distance * Math.sin(radians)); if (this.#isDown) { const [screenStartX, screenStartY] = this.#worldToScreen(startX, startY); const [screenEndX, screenEndY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.moveTo(screenStartX, screenStartY); this.#ctx.lineTo(screenEndX, screenEndY); this.#ctx.stroke(); } this.#addPointToState(state, this.#x, this.#y); this.#polyPoints && this.#polyPoints.push([this.#x, this.#y]); this.#drawTurtle(); } } // Add a method to get the current world coordinates getworld() { return {...this.#worldCoordinates}; } async backward(distance) { return this.forward(-distance); } async right(angle) { return this.#queueOperation(async () => { await this._right(angle); }); } async _right(angle) { let that = this; this.#clearTurtle(); let state = this.#saveState('right', [angle]); const startAngle = this.#angle; const startTime = performance.now(); // Calculate duration based on speed const duration = this.#drawSpeed === 0 ? 0 : (11 - this.#drawSpeed) * 50; // Animation frame function const animate = (currentTime) => { // Clear turtle at start of each frame this.#clearTurtle(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Calculate current angle - negate the angle for right turns this.#angle = startAngle - (angle * progress); this.#drawTurtle(); }; if (this.#drawSpeed < 10) { // Start animation return new Promise(resolve => { requestAnimationFrame(function animateWrapper(currentTime) { animate(currentTime); if (currentTime - startTime >= duration) { that.#updateState(state); resolve(); } else { requestAnimationFrame(animateWrapper); } }); }); } else { // Instant rotation for maximum speed this.#angle = startAngle - angle; this.#updateState(state); this.#drawTurtle(); } } async left(angle) { return this.right(-angle); } // Modify penup to be async async penup() { return this.#queueOperation(() => { this.#isDown = false; this.#saveState('penup', []); }); } // Modify pendown to be async async pendown() { return this.#queueOperation(() => { this.#isDown = true; this.#saveState('pendown', []); }); } async color(...color) { return this.#queueOperation(() => { return this._color(...color); }); } _color(...color) { if (color.length == 0) { return [this.#color, this.#fillcolor].map(c => this.#col_user(c)); } this.#clearTurtle(); let pen_color, fill_color; if (color.length == 3) { fill_color = pen_color = this.#col_arg(color); } else if (color.length == 2) { pen_color = this.#col_arg(color[0]); fill_color = this.#col_arg(color[1]); } else if (color.length == 1) { pen_color = fill_color = this.#col_arg(color[0]); } this.#color = pen_color; this.#fillcolor = fill_color; this.#ctx.strokeStyle = pen_color; this.#saveState('color', color); this.#drawTurtle(); } async pensize(width=null) { return this.#queueOperation(() => { this._pensize(width); }); } async width(_width=null) {return await this.pensize(_width);} _pensize(width=null) { if (width == null) { return this.#penSize; } this.#penSize = width; if (this.#resizeMode === "noresize") { const scaleX = this.#canvas.width / (this.#worldCoordinates.right - this.#worldCoordinates.left); const scaleY = this.#canvas.height / (this.#worldCoordinates.top - this.#worldCoordinates.bottom); const scaleFactor = (Math.abs(scaleX) + Math.abs(scaleY))/2.0; this.#ctx.lineWidth = width / scaleFactor; } else { this.#ctx.lineWidth = width; } this.#saveState('pensize', [width]); } async clear() { return this.#queueOperation(() => { this._clear(); }); } _clear(saveState=true) { this.#clearTurtle(); this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); if (this.#isVisible) { this.#drawTurtle(); } if (saveState) { this.#saveState('clear', []); } } async home() { return this.#queueOperation(() => { this._home(); }); } _home() { this.#x = 0; this.#y = 0; this.#angle = 0; this.#saveState('home', []); } async goto(x, y=null) { return this.#queueOperation(async () => { await this._goto(x, y); }); } async teleport(x, y=null, fill_gap=false) { return this.#queueOperation(async () => { this._teleport(x, y, fill_gap); }); } async _teleport(x, y=null, fill_gap=false) { if (y === null) { if (Array.isArray(x)) { [x, y] = x; } else { throw new Error("If only one argument is provided, it must be a [x,y] array"); } } this.#clearTurtle(); this.#x = x; this.#y = y; this.#saveState('teleport', [x, y]); if (this.#isDown && fill_gap) { const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.arc(screenX, screenY, this.#penSize / 2, 0, Math.PI * 2); this.#ctx.fillStyle = this.#col_arg(this.#color); this.#ctx.fill(); } if (this.#isVisible) { this.#drawTurtle(); } } async _goto(x, y) { let that = this; this.#clearTurtle(); const state = this.#saveState('goto', [x, y]); const startX = this.#x; const startY = this.#y; this.#addPointToState(state, x, y); const startTime = performance.now(); const duration = this.#drawSpeed === 0 ? 0 : (11 - this.#drawSpeed) * 50; const animate = (currentTime) => { this.#clearTurtle(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Calculate current position this.#x = startX + (x - startX) * progress; this.#y = startY + (y - startY) * progress; // Draw line if pen is down if (this.#isDown) { const [screenStartX, screenStartY] = this.#worldToScreen(startX, startY); const [screenEndX, screenEndY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.moveTo(screenStartX, screenStartY); this.#ctx.lineTo(screenEndX, screenEndY); this.#ctx.stroke(); } this.#drawTurtle(); }; if (this.#drawSpeed < 10) { return new Promise(resolve => { requestAnimationFrame(function animateWrapper(currentTime) { animate(currentTime); if (currentTime - startTime >= duration) { that.#addPointToState(state, that.#x, that.#y); that.#polyPoints && that.#polyPoints.push([that.#x, that.#y]); resolve(); } else { requestAnimationFrame(animateWrapper); } }); }); } else { // Instant movement for maximum speed this.#x = x; this.#y = y; if (this.#isDown) { const [screenStartX, screenStartY] = this.#worldToScreen(startX, startY); const [screenEndX, screenEndY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.moveTo(screenStartX, screenStartY); this.#ctx.lineTo(screenEndX, screenEndY); this.#ctx.stroke(); } this.#addPointToState(state, this.#x, this.#y); this.#polyPoints && this.#polyPoints.push([this.#x, this.#y]); this.#drawTurtle(); } return Promise.resolve(); } async setheading(angle) { return this.#queueOperation(() => { this._setheading(angle); }); } // Aliases for goto async setpos(x, y=null) { return this.goto(x, y); } async setposition(x, y=null) { return this.goto(x, y); } // Aliases to match Python's turtle module async fd(distance) { return this.forward(distance); } async bk(distance) { return this.backward(distance); } async back(distance) { return this.backward(distance); } async rt(angle) { return this.right(angle); } async lt(angle) { return this.left(angle); } async seth(angle) { return this.setheading(angle); } async pu() { return this.penup(); } async pd() { return this.pendown(); } async down() { return this.pendown(); } async up() { return this.penup(); } // Position getters async position() { return this.#queueOperation(() => { return [this.#x, this.#y]; }); } async pos() { return this.position(); } async xcor() { return this.#queueOperation(() => { return this.#x; }); } async ycor() { return this.#queueOperation(() => { return this.#y; }); } // Heading getters async heading() { return this.#queueOperation(() => { return this.#angle; }); } async towards(x, y=null) { return this.#queueOperation(() => { let toX, toY; if (y === null) { if (Array.isArray(x)) { [toX, toY] = x; } else { throw new Error("If only one argument is provided, it must be a [x,y] array"); } } else { toX = x; toY = y; } const dx = toX - this.#x; const dy = toY - this.#y; return ((Math.atan2(dy, dx) * 180 / Math.PI) + 360) % 360; }); } #drawShape(shape, scale = 1, angle = 0) { const shapePoints = this.#screen.getShape(shape); if (!shapePoints) return; // Convert turtle position to screen coordinates const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); // Start a new path this.#ctx.beginPath(); // Transform shape points const transformedShape = shapePoints.map(([x, y]) => { // Scale the point const scaledX = x * scale * this.#stretchFactors.width; const scaledY = y * scale * this.#stretchFactors.length; // Rotate the point const rotationRad = (-this.#angle - 90) * Math.PI / 180; const rotatedX = scaledX * Math.cos(rotationRad) - scaledY * Math.sin(rotationRad); const rotatedY = scaledX * Math.sin(rotationRad) + scaledY * Math.cos(rotationRad); // Return screen coordinates return [ screenX + rotatedX, screenY + rotatedY ]; }); // Draw the transformed shape this.#ctx.moveTo(transformedShape[0][0], transformedShape[0][1]); for (const [x, y] of transformedShape.slice(1)) { this.#ctx.lineTo(x, y); } this.#ctx.closePath(); // Fill and stroke the shape this.#ctx.fillStyle = this.#color; this.#ctx.fill(); this.#ctx.strokeStyle = this.#color; this.#ctx.stroke(); } #drawTurtle(is_stamp=false) { if (!this.#isVisible && !is_stamp) return; this.#clearTurtle(); // Calculate screen coordinates for the turtle const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); // Calculate scaling factors const scaleX = this.#canvas.width / (this.#worldCoordinates.right - this.#worldCoordinates.left); const scaleY = this.#canvas.height / (this.#worldCoordinates.top - this.#worldCoordinates.bottom); // Increase the margin to ensure we capture the entire turtle // and scale it according to the coordinate system const margin = (this.#turtleSize + this.#penSize) * Math.max(scaleX, scaleY) * 2; const size = margin * 2; // Save the area where we'll draw the turtle if (!is_stamp) { this.#lastDrawnTurtleState = { imageData: this.#ctx.getImageData( screenX - margin, screenY - margin, size, size ), x: screenX - margin, y: screenY - margin }; } // Draw the turtle this.#drawShape(this.#currentShape, 1, this.#angle); } // Visibility methods async hideturtle() { return this.#queueOperation(() => { this.#clearTurtle(); this.#isVisible = false; }); } async showturtle() { return this.#queueOperation(() => { this.#isVisible = true; this.#drawTurtle(); }); } async isvisible() { return this.#queueOperation(() => { return this.#isVisible; }); } // Aliases async ht() { return this.hideturtle(); } async st() { return this.showturtle(); } #saveState(action, parameters = []) { // Create a state object without the full canvas image data const state = { x: this.#x, y: this.#y, angle: this.#angle, isDown: this.#isDown, color: this.#color, penSize: this.#penSize, isVisible: this.#isVisible, worldCoordinates: { ...this.#worldCoordinates }, resizeMode: this.#resizeMode, stretchFactors: { ...this.#stretchFactors }, path: null, // Will store Path2D for drawing operations action: action, // Store the function name parameters: parameters, // Store the parameters filling: this.#filling, // Track if we're in filling mode fillColor: this.#fillcolor, // Store the fill color fillPath: this.#filling && this.#fillPath ? new Path2D(this.#fillPath) : null, // Create a COPY of the fill path points: [] }; this.#undoBuffer.push(state); // Limit undo buffer size if (this.#undoBuffer.length > this.#undobuffersize) { // If we're exceeding buffer size, create a snapshot of current drawing const firstItem = this.#undoBuffer.shift(); // If the first item had a path that we're removing, we need to // incorporate it into our base drawing if (firstItem.path || firstItem.fillPath) { // Create a snapshot if we don't have one yet if (this.#pathBuffer.length === 0) { this.#pathBuffer.push({ type: 'snapshot', imageData: this.#ctx.getImageData(0, 0, this.#canvas.width, this.#canvas.height) }); } } } return state; // Return the state for operations to add path data } #restoreState(state) { this.#lastDrawnTurtleState = null; this.#x = state.x; this.#y = state.y; this.#angle = state.angle; this.#isDown = state.isDown; this.#color = state.color; this.#penSize = state.penSize; this.#isVisible = state.isVisible; this.#worldCoordinates = { ...state.worldCoordinates }; this.#resizeMode = state.resizeMode; this.#stretchFactors = { ...state.stretchFactors }; // Restore canvas state if (state.canvasState) { this.#ctx.putImageData(state.canvasState, 0, 0); } // Update visual properties this.#ctx.strokeStyle = this.#color; this._pensize(this.#penSize); // Redraw turtle if visible if (this.#isVisible) { this.#drawTurtle(); } } async setx(x) { return this.#queueOperation(() => { this.goto(x, this.#y); }); } async sety(y) { return this.#queueOperation(() => { this.goto(this.#x, y); }); } async distance(x, y=null) { return this.#queueOperation(() => { let toX, toY; if (y === null) { if (Array.isArray(x)) { [toX, toY] = x; } else { throw new Error("If only one argument is provided, it must be a [x,y] array"); } } else { toX = x; toY = y; } return Math.sqrt((this.#x - toX)**2 + (this.#y - toY)**2); }); } onclick(fun, btn=1, add=null) { let that = this; if (!fun) { // Remove all click handlers if fun is null/undefined this.#canvas.onclick = null; this.#canvas.onauxclick = null; return; } const handler = (event) => { // Get click coordinates relative to canvas const rect = that.#canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; // Convert screen coordinates to world coordinates const [worldX, worldY] = that.#screenToWorld(x, y); // Call the user function with world coordinates fun(worldX, worldY); }; if (btn === 1) { // Left click if (add) { // Add new handler while preserving existing ones const oldHandler = this.#canvas.onclick; this.#canvas.onclick = (e) => { if (oldHandler) oldHandler(e); handler(e); }; } else { // Replace existing handler this.#canvas.onclick = handler; } } else if (btn === 2) { // Middle click if (add) { const oldHandler = this.#canvas.onauxclick; this.#canvas.onauxclick = (e) => { if (e.button === 1) { // middle button is 1 in auxclick if (oldHandler) oldHandler(e); handler(e); } }; } else { this.#canvas.onauxclick = (e) => { if (e.button === 1) handler(e); // middle button is 1 in auxclick }; } } else if (btn === 3) { // Right click if (add) { const oldHandler = this.#canvas.onauxclick; this.#canvas.onauxclick = (e) => { if (e.button === 2) { // right button is 2 in auxclick if (oldHandler) oldHandler(e); handler(e); } }; } else { this.#canvas.onauxclick = (e) => { if (e.button === 2) handler(e); // right button is 2 in auxclick }; } } } // Add alias for onclick onscreenclick(fun, btn=1, add=null) { return this.onclick(fun, btn, add); } onkey(fun, key) { // If fun is null/undefined, remove the key binding if (fun === null) { if (key) { document.removeEventListener('keyup', this.#keyHandlers?.get(key)); this.#keyHandlers?.delete(key); } else { for (const [key, handler] of this.#keyHandlers) { document.removeEventListener('keyup', handler); } this.#keyHandlers.clear(); } return; } // Normalize the key const normalizedKey = keyMap[key.toLowerCase()] || key.toLowerCase(); // Create handler function const handler = (event) => { if (event.key.toLowerCase() === normalizedKey.toLowerCase()) { fun(); } }; // Store handler for potential later removal this.#keyHandlers.set(key, handler); // Add event listener document.addEventListener('keyup', handler); } // Alias for onkey onkeyrelease(fun, key) { return this.onkey(fun, key); } onkeypress(fun, key=null) { // If fun is null/undefined, remove the key binding if (fun === null) { this.onkey(null, key); return; } // Normalize the key const normalizedKey = keyMap[key.toLowerCase()] || key.toLowerCase(); // Create handler function const handler = (event) => { if (event.key.toLowerCase() === normalizedKey.toLowerCase()) { fun(); } }; // Store handler for potential later removal this.#keyHandlers.set(key, handler); // Add event listener document.addEventListener('keydown', handler); } world_width() { return this.#worldCoordinates.right - this.#worldCoordinates.left; } world_height() { return this.#worldCoordinates.top - this.#worldCoordinates.bottom; } window_width() { return this.#canvas.width; } window_height() { return this.#canvas.height; } async delay(ms=null) { return this.#queueOperation(() => { if (ms === null) return this.#delay; this.#delay = ms; }); } async bgcolor(color) { return this.#queueOperation(() => { this.#canvas.style.backgroundColor = this.#col_arg(color); }); } async shape(name=null) { return this.#queueOperation(() => { if (name === null) return this.#currentShape; if (this.#screen.getShape(name)) { this.#currentShape = name; this.#drawTurtle(); } else { throw new Error(`Shape ${name} is not available`); } }); } async shapesize(stretch_wid=null, stretch_len=null, outline=null) { return this.#queueOperation(() => { if (stretch_wid !== null && stretch_len !== null) { this.#stretchFactors.width = stretch_wid; this.#stretchFactors.length = stretch_len; } if (outline !== null) { this.#stretchFactors.outline = outline; } }); } async turtlesize(stretch_wid=null, stretch_len=null, outline=null) { return await this.shapesize(stretch_wid, stretch_len, outline); } async circle(radius, extent=360, steps=null) { return this.#queueOperation(async () => { await this._circle(radius, extent, steps); }); } async _circle(radius, extent=360, steps=null) { this.#clearTurtle(); const state = this.#saveState('circle', [radius, extent, steps]); const segments_per_circle = 100; // Calculate number of steps if not provided if (steps === null) { steps = Math.max(4, Math.floor(Math.abs(radius) / 5)); } // Calculate circle center based on current position and heading const radians = (this.#angle - 90) * Math.PI / 180; const centerX = this.#x - Math.abs(radius) * Math.cos(radians); const centerY = this.#y - Math.abs(radius) * Math.sin(radians); const startAngle = radians; // Start from current direction const counterClockwise = radius < 0; // Create a Path2D for this circle if pen is down if (this.#isDown) { // Number of segments for smooth curve: const segments = Math.max(2, Math.ceil(segments_per_circle * (Math.abs(extent) % 360) / 360)); const points = []; // Add starting point points.push([this.#x, this.#y]); // Generate points along the circle for (let i = 1; i <= segments; i++) { const segmentAngle = startAngle + (extent * (i / segments) * Math.PI / 180); const x = centerX + Math.abs(radius) * Math.cos(segmentAngle); const y = centerY + Math.abs(radius) * Math.sin(segmentAngle); points.push([x, y]); this.#addPointToState(state, x, y, false, false); this.#polyPoints && this.#polyPoints.push([x, y]); } // Create path from points state.path = this.#createPathFromPoints(points); state.points = points; } const startTime = performance.now(); const duration = this.#drawSpeed === 0 ? 0 : (11 - this.#drawSpeed) * 200; // Animation frame function const animate = (currentTime) => { this.#clearTurtle(); const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Calculate current extent and end angle const currentExtent = extent * progress; const currentEndAngle = startAngle + (currentExtent * Math.PI / 180); if (this.#isDown) { // Draw the circle segment using small line segments const segments = 100; // Number of segments for smooth curve const progressSegments = Math.floor(segments * progress); for (let i = 0; i <= progressSegments; i++) { const segmentAngle = startAngle + (extent * (i / segments) * Math.PI / 180); const x = centerX + Math.abs(radius) * Math.cos(segmentAngle); const y = centerY + Math.abs(radius) * Math.sin(segmentAngle); const [screenX, screenY] = this.#worldToScreen(x, y); if (i === 0) { this.#ctx.beginPath(); this.#ctx.moveTo(screenX, screenY); } else { this.#ctx.lineTo(screenX, screenY); this.#ctx.stroke(); this.#ctx.beginPath(); this.#ctx.moveTo(screenX, screenY); } } } // Update turtle position this.#x = centerX + Math.abs(radius) * Math.cos(currentEndAngle); this.#y = centerY + Math.abs(radius) * Math.sin(currentEndAngle); // Update turtle angle to be tangent to the circle const tangentAngle = (currentEndAngle * 180 / Math.PI) + (counterClockwise ? -90 : 90); this.#angle = tangentAngle; this.#drawTurtle(); }; if (this.#drawSpeed < 10) { // Start animation return new Promise(resolve => { requestAnimationFrame(function animateWrapper(currentTime) { animate(currentTime); if (currentTime - startTime >= duration) { resolve(); } else { requestAnimationFrame(animateWrapper); } }); }); } else { // Instant drawing for maximum speed const endAngle = startAngle + (extent * Math.PI / 180); if (this.#isDown) { // Draw the complete circle/arc using line segments const segments = 100; for (let i = 0; i <= segments; i++) { const segmentAngle = startAngle + (extent * (i / segments) * Math.PI / 180); const x = centerX + Math.abs(radius) * Math.cos(segmentAngle); const y = centerY + Math.abs(radius) * Math.sin(segmentAngle); if (i === 0) { this.#ctx.beginPath(); this.#ctx.moveTo(x, y); } else { this.#ctx.lineTo(x, y); this.#ctx.stroke(); this.#ctx.beginPath(); this.#ctx.moveTo(x, y); } } } // Update final turtle position this.#x = centerX + Math.abs(radius) * Math.cos(endAngle); this.#y = centerY + Math.abs(radius) * Math.sin(endAngle); // Update final turtle angle const tangentAngle = (endAngle * 180 / Math.PI) + (counterClockwise ? -90 : 90); this.#angle = tangentAngle; this.#updateState(state); this.#drawTurtle(); } return Promise.resolve(); } async dot(size=null, color=null) { return this.#queueOperation(() => { this.#clearTurtle(); this.#saveState(); const oldColor = this.#color; const oldWidth = this.#penSize; if (color) this.#color = this.#col_arg(color); if (size === null) size = Math.max(this.#penSize + 4, this.#penSize * 2); const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.beginPath(); this.#ctx.arc(screenX, screenY, size/2, 0, 2 * Math.PI); this.#ctx.fillStyle = this.#color; this.#ctx.fill(); this.#color = this.#col_arg(oldColor); this._pensize(oldWidth); this.#drawTurtle(); }); } async begin_fill() { return this.#queueOperation(() => { // Start a new fill path this.#filling = true; this.#fillPath = new Path2D(); // Add the current position to the fill path const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); this.#fillPath.moveTo(screenX, screenY); this.#saveState('begin_fill', []); }); } async end_fill(fill_rule=defaults.fillRule) { return this.#queueOperation(() => { let state = this.#saveState('end_fill', [fill_rule]); this.#clearTurtle(); if (this.#filling && this.#fillPath) { // Close the path this.#fillPath.closePath(); // Fill the path this.#ctx.fillStyle = this.#fillcolor; this.#ctx.fill(this.#fillPath, fill_rule); // Reset fill state state.filling = this.#filling = false; state.do_fill_path = new Path2D(this.#fillPath); state.fillPath = this.#fillPath = null; } this.#drawTurtle(); }); } async filling() { return this.#queueOperation(() => { return this.#filling; }); } // three call variants // pencolor(color) // pencolor() // pencolor(r, g, b) async pencolor(...color) { return this.#queueOperation(() => { if (color.length === 0) return this.#col_user(this.#color); this.#clearTurtle(); if (color.length === 3) this.#color = this.#col_arg(color); else if (color.length === 1) this.#color = this.#col_arg(color[0]); this.#ctx.strokeStyle = this.#color; this.#saveState('pencolor', color); this.#drawTurtle(); }); } async fillcolor(...color) { return this.#queueOperation(() => { if (color.length === 0) return this.#col_user(this.#fillcolor); this.#clearTurtle(); if (color.length === 3) this.#fillcolor = this.#col_arg(color); else if (color.length === 1) this.#fillcolor = this.#col_arg(color[0]); this.#ctx.fillStyle = this.#fillcolor; this.#saveState('fillcolor', color); this.#drawTurtle(); }); } // Reset and clear async reset() { return this.#queueOperation(async () => { this._reset(); }); } _reset() { this._clear(); // Reset turtle properties this.#x = 0; this.#y = 0; this.#angle = 0; this.#isDown = true; this.#drawSpeed = 1; this.#color = 'black'; this.#fillcolor = 'black'; this.#penSize = 1; this.#isVisible = true; this.#currentShape = defaults.shape; // Reset context properties this.#ctx.strokeStyle = 'black'; this.#ctx.fillStyle = 'black'; this.#ctx.lineWidth = 1; // Reset world coordinates const naturalCoords = this.#screen.naturalWorldCoordinates(); this._setworldcoordinates( naturalCoords.left, naturalCoords.bottom, naturalCoords.right, naturalCoords.top ); // Clear undo buffer this.#undoBuffer = []; this.#pathBuffer = []; // Draw the turtle in its initial state this.#drawTurtle(); } // Write text async write(text, move=false, align="left", font=["Arial", 8, "normal"]) { return this.#queueOperation(() => { this._write(text, move, align, font); }); } _write(text, move=false, align="left", font=["Arial", 8, "normal"]) { this.#clearTurtle(); // Convert world coordinates to screen coordinates const [screenX, screenY] = this.#worldToScreen(this.#x, this.#y); this.#ctx.fillStyle = this.#color; this.#ctx.textAlign = align; this.#ctx.font = `${font[2]} ${font[1]}px ${font[0]}`; // Calculate text rotation based on turtle angle const radians = (-this.#angle * Math.PI) / 180; // Save context state for text rotation this.#ctx.save(); this.#ctx.translate(screenX, screenY); this.#ctx.rotate(radians); this.#ctx.fillText(text, 0, 0); this.#ctx.restore(); if (move) { // Move to the end of the text in world coordinates const metrics = this.#ctx.measureText(text); const distance = metrics.width; const dx = distance * Math.cos(radians); // Convert screen distance back to world coordinates const scaleX = this.#canvas.width / (this.#worldCoordinates.right - this.#worldCoordinates.left); this.#x += dx / scaleX; } this.#saveState('write', [text, move, align, font]); this.#drawTurtle(); } // Stamp methods async stamp() { return this.#queueOperation(() => { const stampId = this.#stampIds++; this.#stamps.set(stampId, { x: this.#x, y: this.#y, angle: this.#angle, shape: this.#currentShape }); this.#drawTurtle(true); return stampId; }); } async clearstamp(stampId) { return this.#queueOperation(() => { this.#stamps.delete(stampId); this.#redrawStamps(); }); } async clearstamps(n=null) { return this.#queueOperation(() => {