/**
* @module Canvas
*/
import { Operations, OperationType } from "./operations.js";
import { Color, Paint } from "./paint.js";
import { Size, Point, Matrix } from "./geo.js";
const KAPPA = 4 * ((Math.sqrt(2) - 1) / 3);
/**
* A canvas for drawing paths
* @class
* @example
* // Create a new canvas, with a stroke of red
* const canvas = new Canvas(new Paint(Color.red));
*/
class Canvas {
/**
* The pathing operations
* @type {import("./operations.js").Operation[]}
* @private
* @memberof Canvas
*/
operations = [];
/**
* Whether or not the path is closed
* @type {boolean}
* @private
* @memberof Canvas
*/
_closed = false;
/**
* The current point
* @type {Point}
* @memberof Canvas
* @readonly
*/
get currentPoint() {
let op = this.operations[this.operations.length - 1];
if (!op || op.points.length < 1) return this.startPoint;
return op.points[op.points.length - 1];
}
/**
* Whether or not the path is closed
* @type {boolean}
* @readonly
* @memberof Canvas
*/
get isClosed() {
return this._closed;
}
/**
* The size of the canvas (The size of the terminal)
* @type {Size}
* @readonly
*/
get size() {
return new Size(process.stdout.columns, process.stdout.rows);
}
/**
* Creates a new `Canvas`
* @param {Paint} paint The paint to use for rendering
* @param {Point} [startPoint=Point.zero()] The starting point of the path
*/
constructor(paint, startPoint = Point.zero()) {
/**
* The paint to use for rendering
* @type {Paint}
*/
this.paint = paint;
/**
* The starting point of the path
* @type {Point}
* @readonly
*/
this.startPoint = startPoint;
/**
* The current point of the path's rendering operations
* @type {Point}
* @private
*/
this._renderLocation = startPoint;
}
/**
* Draws the current canvas to the terminal
* @param {boolean} [clear=true] Whether or not to clear the screen before rendering
*/
draw(clear = true) {
this._render(Paint.Mode.Paint, !clear);
}
/**
* Without resetting the operations, clears the canvas
*/
clear() {
this.clearRect(Point.zero(), this.size);
}
/**
* Renders the canvas
* @param {Paint.Mode} mode The mode to render in
* @param {boolean} doNotClear Whether or not to clear the screen before rendering
* @param {boolean} print Whether or not to print the output to the terminal, or return it as a string
* @returns {(string|undefined)}
* @private
*/
_render(mode = Paint.Mode.Paint, doNotClear = false, print = true) {
// Move to the top left corner
let toWrite = `\x1b[0;0H`;
// Clear the screen
if (!doNotClear) {
toWrite += "\x1b[2J";
}
if (mode === Paint.Mode.Clear) {
toWrite += "\x1b[0m";
}
// Apply the render operations
for (let i = 0; i < this.operations.length; ++i) {
let operation = this.operations[i];
toWrite += Operations[operation.type].run(this, operation.points, operation, mode);
}
// Finalize the rendering by filling the path IF it is closed
if (this.isClosed && this.paint.fillColor !== Color.None) {
let char = "";
switch (mode) {
case Paint.Mode.Paint:
toWrite += "\x1b[" + this.paint.fillColor.toEscapeCode();
char = "█";
break;
case Paint.Mode.Clear:
toWrite += "\x1b[0m";
char = " ";
break;
case Paint.Mode.Gradient:
// TODO: Implement gradient rendering for the background
break;
}
if (char.length > 0) {
for (let i = 0; i < process.stdout.rows; ++i) {
for (let j = 0; j < process.stdout.columns; ++j) {
let p = new Point(j, i);
if (this.isPointInPath(p) && !this.isPointInStroke(p)) {
toWrite += `\x1b[${i};${j}H${char}`
}
}
}
}
}
// Move to the bottom right corner
if (!print) return toWrite;
process.stdout.write(toWrite + `\x1b[${process.stdout.rows};${process.stdout.columns}H`);
}
/**
* Applies an operation to the canvas
* @param {import("./operations.js").Operation} operation
* @private
*/
_applyOperation(operation) {
if (this.isClosed) throw new Error("Cannot apply operation to a closed path");
if (!Operations[operation.type].render) {
Operations[operation.type].run(this, operation.points, operation);
} else this.operations.push(operation);
}
// arc (Do we need this?)
/**
* Draws an arc
* @param {Point} center The center-point of the arc
* @param {number} radius The radius of the arc
* @param {number} startAngle The angle to start at
* @param {number} endAngle The angle to end at
* @param {boolean} [anticlockwise=false] Whether or not to draw the arc in an anticlockwise direction
*/
arc(center, radius, startAngle, endAngle, anticlockwise = false) {
let arcPath = this.subpathAt(new Point(center.x + radius * Math.cos(startAngle), center.y + radius * Math.sin(startAngle)));
let angleIncrement = Math.PI / 180;
if (anticlockwise) angleIncrement *= -1;
let angle = startAngle + angleIncrement;
let lastPoint = new Point(-1, -1);
while (angle < endAngle) {
angle += angleIncrement;
let currentPoint = new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle));
if (!lastPoint.equals(currentPoint)) {
arcPath.lineTo(currentPoint);
lastPoint = currentPoint;
}
}
arcPath.close();
}
// arcTo
/**
* Draws an arc through two controls points
* @param {Point} controlPoint1 The first control point
* @param {Point} controlPoint2 The second control point
* @param {number} radius The radius of the arc
*/
arcTo(controlPoint1, controlPoint2, radius) {
let angle = Math.atan2(controlPoint1.y - controlPoint2.y, controlPoint1.x - controlPoint2.x);
let controlPoint1Offset = new Point(
radius * KAPPA * Math.cos(angle + Math.PI / 2),
radius * KAPPA * Math.sin(angle + Math.PI / 2)
);
let controlPoint2Offset = new Point(
radius * KAPPA * Math.cos(angle + Math.PI / 2 + Math.PI),
radius * KAPPA * Math.sin(angle + Math.PI / 2 + Math.PI)
);
this.bezierCurveTo(new Point(
controlPoint1.x + controlPoint1Offset.x,
controlPoint1.y + controlPoint1Offset.y
), new Point(
controlPoint2.x + controlPoint2Offset.x,
controlPoint2.y + controlPoint2Offset.y
), controlPoint2);
}
/**
* Gets the bounds of the path, hoping that the path is closed
* @returns {Point[]} The points that make up the path bounds
*/
getBounds() {
// If the path is open, then the bounds will work "mostly" correctly (TODO: Open path bounds?)
// But, when determining if a point is inside the path, the bounds will only function on mostly CLOSED polygons within the open path
let bounds = [];
let currentPoint = this.operations[0].points.length > 0 ? this.operations[0].points[0] : this.startPoint;
let currentSlope = null;
for (let i = 0; i < this.operations.length; ++i) {
let operation = this.operations[i];
switch (operation.type) {
case OperationType.MoveToPoint:
currentPoint = operation.points[0];
currentSlope = null;
break;
case OperationType.AddLineToPoint:
let newSlope = (operation.points[0].y - currentPoint.y) / (operation.points[0].x - currentPoint.x);
if (newSlope === currentSlope) {
// Merge the lines
bounds[bounds.length - 1] = operation.points[0];
} else {
bounds.push(operation.points[0]);
}
currentPoint = operation.points[0];
currentSlope = newSlope;
break;
}
}
return bounds;
}
// beginPath - Opens a new subpath
/**
* Creates a new subpath at the current point
* @param {Paint} [paint=this.paint] The paint to use for the subpath
* @returns {Canvas} The subpath
*/
beginPath(paint = this.paint) {
let startPoint;
if (this.operations.length > 0) {
for (let i = this.operations.length - 1; i >= 0; i--) {
if (this.operations[i].points.length > 0) {
startPoint = this.operations[i].points[this.operations[i].points.length - 1];
break;
}
}
} else startPoint = this._renderLocation;
let subpath = new Canvas(paint, startPoint);
this._applyOperation({
type: OperationType.Subpath,
paint: paint,
points: [],
path: subpath
});
return subpath;
}
/**
* Draws a cubic bezier curve from the current point to the specified point
* @param {Point} controlPoint1 The first control point
* @param {Point} controlPoint2 The second control point
* @param {Point} endPoint The end point
*/
bezierCurveTo(controlPoint1, controlPoint2, endPoint) {
this._applyOperation({
type: OperationType.AddCurveToPoint,
points: [controlPoint1, controlPoint2, endPoint]
});
}
// clearRect
/**
* Clears a rectangle at the specified point in the screen
* @param {Point} start The top left corner of the rectangle
* @param {Size} size The size of the rectangle
*/
clearRect(start, size) {
let subpath = this.subpathAt(start);
subpath.lineTo(new Point(start.x, start.y + size.height));
subpath.lineTo(new Point(start.x + size.width, start.y + size.height));
subpath.lineTo(new Point(start.x + size.width, start.y));
subpath.close();
this._applyOperation({
type: OperationType.MaskSubpath,
points: [],
path: subpath
});
}
// clip
/**
* Clears a path out of the current path (like a mask)
* @param {Canvas} path The path to clip to
*/
clip(path) {
this._applyOperation({
type: OperationType.MaskSubpath,
points: [],
path: path
});
}
/**
* Closes the last opened subpath, or closes the path if no subpaths are open
*/
close() {
if (this.closed) throw new Error("Cannot close a path that is already closed");
// Close the path
if (this.currentPoint.x !== this.startPoint.x || this.currentPoint.y !== this.startPoint.y) {
this._applyOperation({
type: OperationType.AddLineToPoint,
points: [this.startPoint]
});
}
this._closed = true;
}
// createConicGradient (See ConicGradient)
// createImageData (TODO - Image Library)
// createLinearGradient (See LinearGradient)
// createPattern (TODO - Image Library)
// createRadialGradient (See RadialGradient)
// drawFocusIfNeeded (Not needed)
// drawImage (TODO - Image Library)
// ellipse
/**
* Draws an ellipse
* @param {Point} center The center of the ellipse
* @param {number} radiusX The radius of the ellipse on the x axis
* @param {number} radiusY The radius of the ellipse on the y axis
* @param {number} rotation The rotation of the ellipse (in radians)
* @param {number} startAngle The start angle of the ellipse (in radians)
* @param {number} endAngle The end angle of the ellipse (in radians)
*/
ellipse(center, radiusX, radiusY, rotation, startAngle, endAngle) {
// Same as a arc, but with a radiusX and radiusY, and a rotation
// So, we need to reimpliment the arc function with our own math
let angle = startAngle;
let angleStep = Math.PI / 180;
let x = center.x + radiusX * Math.cos(angle);
let y = center.y + radiusY * Math.sin(angle);
let xDiff = x - center.x;
let yDiff = y - center.y;
let x2 = xDiff * Math.cos(rotation) - yDiff * Math.sin(rotation);
let y2 = xDiff * Math.sin(rotation) + yDiff * Math.cos(rotation);
let lastPoint = new Point(
x2 + center.x,
y2 + center.y
);
let subpath = this.subpathAt(lastPoint)
while (angle < endAngle) {
angle += angleStep;
let x = center.x + radiusX * Math.cos(angle);
let y = center.y + radiusY * Math.sin(angle);
let xDiff = x - center.x;
let yDiff = y - center.y;
let x2 = xDiff * Math.cos(rotation) - yDiff * Math.sin(rotation);
let y2 = xDiff * Math.sin(rotation) + yDiff * Math.cos(rotation);
let point = new Point(x2 + center.x, y2 + center.y);
if (point.x !== lastPoint.x || point.y !== lastPoint.y) {
subpath.lineTo(point);
lastPoint = point;
}
}
subpath.close();
}
// fill (Not needed)
// fillRect (Not needed)
// fillText (TODO)
// getContextAttributes (Not needed)
// getImageData (Not needed)
// getLineDash (Not needed)
// getTransform (Not needed)
// isContextLost (Experimental)
// isPointInPath
/**
* Checks if the specified point is in the path
* @param {Point} point The point to check
* @returns {boolean} Whether or not the point is in the path
*/
isPointInPath(point) {
if (!this.bounds) this.bounds = this.getBounds();
if (point.x > this.bounds.width || point.x < 0 || point.y > this.bounds.height || point.y < 0) return false;
let windingNumber = 0;
let prev = this.bounds[this.bounds.length - 1];
for (let i = 0; i < this.bounds.length; ++i) {
let curr = this.bounds[i];
if ((curr.y > point.y) !== (prev.y > point.y)) {
const isLeft = (prev.x - curr.x) * (point.y - curr.y) - (point.x - curr.x) * (prev.y - curr.y);
if ((curr.y > point.y) ? isLeft > 0 : isLeft < 0) {
windingNumber += (curr.y > prev.y) ? 1 : -1;
}
}
prev = curr;
}
return windingNumber !== 0;
}
// isPointInStroke
/**
* Determines if the specified point is in the stroke
* @param {Point} point The point to check
* @param {Point} start The start point of the line
* @param {Point} end The end point of the line
* @returns {boolean} Whether or not the point is in the stroke
*/
isPointInStroke(point, start, end) {
if (start && end) {
// Is the point between the start and end points? (Use Bresenham's line algorithm)
let dx = Math.abs(end.x - start.x);
let sx = start.x < end.x ? 1 : -1;
let dy = Math.abs(end.y - start.y);
let sy = start.y < end.y ? 1 : -1;
let err = (dx > dy ? dx : -dy) / 2;
let x = start.x;
let y = start.y;
while (true) {
if (x === point.x && y === point.y) return true;
if (x === end.x && y === end.y) break;
let e2 = err;
if (e2 > -dx) { err -= dy; x += sx; }
if (e2 < dy) { err += dx; y += sy; }
}
return false;
} else {
// Check if the point is on ANY of the lines in the path
let currentPoint = this.startPoint;
for (let i = 0; i < this.operations.length; ++i) {
let operation = this.operations[i];
switch (operation.type) {
case OperationType.AddLineToPoint:
if (this.isPointInStroke(point, currentPoint, operation.points[0])) return true;
currentPoint = operation.points[0];
break;
case OperationType.Subpath:
if (operation.path.isPointInStroke(point)) return true;
currentPoint = operation.path.currentPoint;
break;
}
}
return false;
}
}
/**
* Draws a line from the current point to the specified point
* @param {Point} point The point to draw a line to
*/
lineTo(point) {
this._applyOperation({
type: OperationType.AddLineToPoint,
points: [point]
});
}
// measureText
// moveTo => subpathAt
/**
* Moves the current point to the specified point
* @param {Point} point The point to move to
* @returns {Canvas}
*/
subpathAt(point) {
let sp = this.beginPath();
sp.startPoint = point;
sp._renderLocation = point;
return sp;
}
// putImageData
/**
* Draws a quadratic bezier curve from the current point to the specified point
* @param {Point} controlPoint The control point
* @param {Point} endPoint The end point
*/
quadraticCurveTo(controlPoint, endPoint) {
this._applyOperation({
type: OperationType.AddQuadCurveToPoint,
points: [controlPoint, endPoint]
});
}
/**
* Draws a rectangle
* @param {Point} startingPoint The starting point of the rectangle (top left)
* @param {Size} size The size of the rectangle
*/
rect(startingPoint, size) {
// TODO: We don't need two subpaths, we can just use one
// We make a subpath with four lines (top, right, bottom, left)
// We then close the subpath
let subpath = this.subpathAt(startingPoint);
subpath.lineTo(new Point(startingPoint.x, startingPoint.y + size.height));
subpath.lineTo(new Point(startingPoint.x + size.width, startingPoint.y + size.height));
subpath.lineTo(new Point(startingPoint.x + size.width, startingPoint.y));
subpath.close();
}
// reset (Experimental)
/**
* Resets the path, including all the operations
* @param {Point} start The new starting point
*/
reset(start = this.startPoint) {
this.operations = [];
this._closed = false;
this.startPoint = start;
this._renderLocation = start;
this.bounds = null;
}
// resetTransform (Not needed)
// restore
// rotate
// roundRect
/**
* Makes a rounded rectangle
* @param {Point} startingPoint The starting point
* @param {Size} size The size of the rectangle
* @param {(number[]|number)} radii The radii of the corners. If it is a number, or number[1], it will be applied to all corners. If it is a number[2], the first element will apply to the top left and bottom right corners, and the second element will apply to the top right and bottom left corners. If it is a number[4], then the array will be applied to the top left, top right, bottom right, and bottom left corners, respectively.
*/
roundRect(startingPoint, size, radii) {
let topLeftRadius, topRightRadius, bottomLeftRadius, bottomRightRadius;
if (typeof radii === "number") {
topLeftRadius = topRightRadius = bottomLeftRadius = bottomRightRadius = radii;
} else if (Array.isArray(radii)) {
if (radii.length === 1) {
topLeftRadius = topRightRadius = bottomLeftRadius = bottomRightRadius = radii[0];
} else if (radii.length === 2) {
topLeftRadius = bottomRightRadius = radii[0];
topRightRadius = bottomLeftRadius = radii[1];
} else {
topLeftRadius = radii[0];
topRightRadius = radii[1];
bottomRightRadius = radii[2];
bottomLeftRadius = radii[3];
}
} else throw new Error("Invalid radii");
let subpath = this.subpathAt(startingPoint);
subpath.lineTo(new Point(startingPoint.x + size.width - topRightRadius, startingPoint.y));
subpath.quadraticCurveTo(new Point(startingPoint.x + size.width, startingPoint.y), new Point(startingPoint.x + size.width, startingPoint.y + topRightRadius));
subpath.lineTo(new Point(startingPoint.x + size.width, startingPoint.y + size.height - bottomRightRadius));
subpath.quadraticCurveTo(new Point(startingPoint.x + size.width, startingPoint.y + size.height), new Point(startingPoint.x + size.width - bottomRightRadius, startingPoint.y + size.height));
subpath.lineTo(new Point(startingPoint.x + bottomLeftRadius, startingPoint.y + size.height));
subpath.quadraticCurveTo(new Point(startingPoint.x, startingPoint.y + size.height), new Point(startingPoint.x, startingPoint.y + size.height - bottomLeftRadius));
subpath.lineTo(new Point(startingPoint.x, startingPoint.y + topLeftRadius));
subpath.quadraticCurveTo(new Point(startingPoint.x, startingPoint.y), new Point(startingPoint.x + topLeftRadius, startingPoint.y));
subpath.close();
}
// save
// scale (TODO)
// scrollPathIntoView (Experimental, not needed)
// setLineDash (TODO)
// setTransform (See transform)
// stroke (Not needed)
// strokeRect (Not needed)
// strokeText (Not needed)
// transform
/**
* Transforms all the points in the path by the specified matrix
* @param {Matrix} matrix The matrix to transform the path by
*/
transform(matrix) {
for (let i = 0; i < this.operations.length; ++i) {
let operation = this.operations[i];
if (operation.type === OperationType.Subpath || operation.type === OperationType.MaskSubpath) {
operation.path.transform(matrix);
continue;
}
for (let j = 0; j < operation.points.length; ++j) {
operation.points[j].transform(matrix);
}
}
}
// translate
/**
* Translates all the points in the path by the specified amount
* @param {number} x The x value to translate by
* @param {number} y The y value to translate by
*/
translate(x, y) {
for (let i = 0; i < this.operations.length; ++i) {
let operation = this.operations[i];
if (operation.type === OperationType.Subpath || operation.type === OperationType.MaskSubpath) {
operation.path.translate(x, y);
continue;
}
for (let j = 0; j < operation.points.length; ++j) {
operation.points[j].x += Math.round(x);
operation.points[j].y += Math.round(y);
if (!this.inBounds(operation.points[j]))
throw new Error(`Point ${operation.points[j].x}, ${operation.points[j].y} is out of bounds!`);
}
}
}
/**
* Adds a subpath to the path, and optionally applies a transform to it
* @param {Path} path The subpath to add
* @param {Matrix} [transform] The optional transform to apply to the subpath
*/
addPath(path, transform) {
// TODO: Make new points instead of modifying the old ones (Should be really easy)z
for (let i = 0; i < path.operations.length; ++i) {
if (transform instanceof Matrix) path.operations[i].points.forEach(point => point.transformByMatrix(transform));
}
this.operations.push({
type: OperationType.Subpath,
points: [],
path
});
}
}
export { Canvas };