@shlomil/turtlejs
Version:
A feature-rich JavaScript implementation of turtle graphics inspired by Python's turtle module
1,517 lines (1,282 loc) • 141 kB
JavaScript
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(() => {
if (n === n