hulet
Version:
The canvas as the Cartesian plane
499 lines (370 loc) • 13.4 kB
JavaScript
// Core logic and functions for Hulet
// Most of the code depends on other modules
// Dependencies
const { form } = require('./math')
// Camera
class Camera {
// Create a new camera linked to a specific canvas
// Manages perspective, zoom, translation, and other 2D transformations
constructor(Canvas) {
// Initialize the camera
this.Canvas = Canvas
this.center = [0, 0]
this.zoom = Canvas.width / Canvas.cwidth
}
transform(vec) {
// Transform a coordinate pair given by the vector `vec` from the
// Cartesian plane into the canvas coordinate system
// Transformations
let nVec = form.translate(vec, form.negate(this.center))
nVec = form.scale(nVec, 1 / this.zoom)
// Set origin to the top-left
nVec = [nVec[0], -nVec[1]]
nVec = form.translate(
nVec,
[
this.Canvas.cwidth / 2,
this.Canvas.cheight / 2
]
)
return nVec
}
invTransform(vec) {
// Transform a coordinate pair given by the vector `vec` from the
// canvas coordinate system into the Cartesian plane
// *Inverse of `transform`*
// Set origin to the top-left
let nVec = form.translate(
vec,
[
-this.Canvas.cwidth / 2,
-this.Canvas.cheight / 2
]
)
nVec = [nVec[0], -nVec[1]]
// Transformations
nVec = form.scale(nVec, this.zoom)
nVec = form.translate(nVec, this.center)
return nVec
}
getEndpoints() {
// Get the endpoints of the camera's viewport (as Cartesian coordinates)
// In the order of the quadrants I-IV
let w = this.Canvas.cwidth
let h = this.Canvas.cheight
let endpoints = [
[w, 0],
[0, 0],
[0, h],
[w, h],
]
return endpoints.map(endpoint => this.invTransform(endpoint))
}
}
// Create the Cartesian plane
class Cartesian {
// Initialize a Cartesian plane over an existing canvas instance
// The standard drawing context in Hulet
constructor(ctx, w, h) {
// Initialize a Cartesian plane over the given context `ctx` with
// dimensions `w` (width) and `h` (height), which set the initial
// `Camera` perspective.
// Set intrinsic canvas properties
this.ctx = ctx
this.cwidth = ctx.canvas.width
this.cheight = ctx.canvas.height
// Set internal canvas dimensions
this.width = w
this.height = h
// Initialize the Cartesian plane
this.init()
// Control variables
this.pointStyle = '#c70000'
this.pointSize = 2.5
this.strokeStyle = 'black'
this.lineWidth = 2.5
this.fillStyle = 'rgba(200, 0, 0, 0.5)'
this.stroke = true
this.fill = true
}
init() {
// Initialize the Cartesian plane as the standard 2D rendering context
// Performs basic setup routines on the canvas
// Set the canvas to be white
this.ctx.save()
this.ctx.fillStyle = 'white'
this.ctx.fillRect(0, 0, this.cwidth, this.cheight)
this.ctx.restore()
// Initialize the camera
this.Camera = new Camera(this)
}
// Controls
clear() {
// Clear the canvas
this.ctx.clearRect(0, 0, this.cwidth, this.cheight)
}
// Geometry
point(vec) {
// Draw a point at the given vector `vec`
// Get coordinates
let [x, y] = this.Camera.transform(vec)
// Draw the point
this.ctx.beginPath()
this.ctx.arc(x, y, this.pointSize, 0, 2 * Math.PI)
this.ctx.fillStyle = this.pointStyle
this.ctx.fill()
}
segment(u, v) {
// Draw a line segment from the vector `u` to the vector `v`
// Get coordinates
let [x1, y1] = this.Camera.transform(u)
let [x2, y2] = this.Camera.transform(v)
// Draw the segment
this.ctx.beginPath()
this.ctx.moveTo(x1, y1)
this.ctx.lineTo(x2, y2)
this.ctx.strokeStyle = this.strokeStyle
this.ctx.lineWidth = this.lineWidth
this.ctx.stroke()
}
ray(u, v) {
// Draw a ray from the vector `u` extending past the vector `v`
// Decompose coordinates
let [x1, y1] = u
let [x2, y2] = v
// Calculate ray equation
if (x1 === x2) {
// Vertical ray
// Determine direction
let dir = y2 > y1 ? 1 : -1
// Calculate endpoint
let end
let endY
let endpoints = this.Camera.getEndpoints()
dir === 1 ? endY = endpoints[0][1] : endY = endpoints[2][1]
end = [x1, endY]
// Draw the ray
this.segment(u, end)
}
let m = (y2 - y1) / (x2 - x1)
const rayEq = (x) => y1 + m * (x - x1)
// Determine direction
let dir = x2 > x1 ? 1 : -1
// Calculate endpoint
let end
let endX
let endpoints = this.Camera.getEndpoints()
dir === 1 ? endX = endpoints[0][0] : endX = endpoints[1][0]
end = [endX, rayEq(endX)]
// Draw the ray
this.segment(u, end)
}
line(u, v) {
// Draw a line extending through two vectors `u` and `v`
this.ray(u, v)
this.ray(v, u)
}
polygon(vertices) {
// Draw a polygon with the given vertices `vertices`
// Get coordinates
let coords = vertices.map(v => this.Camera.transform(v))
// Draw the polygon
this.ctx.beginPath()
this.ctx.moveTo(coords[0][0], coords[0][1])
for (let i = 1; i < coords.length; i++) {
this.ctx.lineTo(coords[i][0], coords[i][1])
}
this.ctx.closePath()
// Render polygon
if (this.fill) {
this.ctx.fillStyle = this.fillStyle
this.ctx.fill()
}
if (this.stroke) {
this.ctx.strokeStyle = this.strokeStyle
this.ctx.lineWidth = this.lineWidth
this.ctx.stroke()
}
}
circle(c, r) {
// Draw a circle with the given center `c` and radius `r`
// Get coordinates
let [x, y] = this.Camera.transform(c)
// Draw the circle
this.ctx.beginPath()
this.ctx.arc(x, y, r / this.Camera.zoom, 0, 2 * Math.PI)
if (this.fill) {
this.ctx.fillStyle = this.fillStyle
this.ctx.fill()
}
if (this.stroke) {
this.ctx.strokeStyle = this.strokeStyle
this.ctx.lineWidth = this.lineWidth
this.ctx.stroke()
}
}
// Algebra
axes(x=true, y=true, style='black') {
// Draw the Cartesian axes with `style='black'`
// Use `x` and `y` to determine which axes to draw (default to `true`)
let oldStyle = this.strokeStyle
this.strokeStyle = style
let endpoints = this.Camera.getEndpoints()
if (x) this.segment([endpoints[1][0], 0], [endpoints[0][0], 0])
if (y) this.segment([0, endpoints[2][1]], [0, endpoints[0][1]])
this.strokeStyle = oldStyle
}
grid(delta, x=true, y=true, style='rgba(0,0,0,0.25)') {
// Draw the Cartesian grid with the given spacing `delta` and
// `style='rgba(0,0,0,0.25)'`; use `x` and `y` to determine
// which axes to draw (default to `true`)
if (arguments.length === 2) {
style = arguments[1]
x = true
y = true
}
let oldStyle = this.strokeStyle
this.strokeStyle = style
let endpoints = this.Camera.getEndpoints()
let x1 = Math.round(endpoints[1][0] / delta) * delta
let y1 = Math.round(endpoints[2][1] / delta) * delta
let x2 = Math.round(endpoints[0][0] / delta) * delta
let y2 = Math.round(endpoints[0][1] / delta) * delta
if (x) {
if (x1 <= 0 && x2 >= 0) {
for (let x = 0; x < x2; x += delta) {
this.segment([x, y1], [x, y2])
}
for (let x = 0; x > x1; x -= delta) {
this.segment([x, y1], [x, y2])
}
} else {
for (let x = x1 + delta; x < x2; x += delta) {
this.segment([x, y1], [x, y2])
}
}
}
if (y) {
if (y1 <= 0 && y2 >= 0) {
for (let y = 0; y < y2; y += delta) {
this.segment([x1, y], [x2, y])
}
for (let y = 0; y > y1; y -= delta) {
this.segment([x1, y], [x2, y])
}
} else {
for (let y = y1 + delta; y < y2; y += delta) {
this.segment([x1, y], [x2, y])
}
}
}
this.strokeStyle = oldStyle
}
label(delta, X=true, Y=true, style='black', font='16px times', offset=5) {
// Label axes with the given spacing `delta` and `style='black'`,
// `font='times'`; use `X` and `Y` to determine which axes to label
// (default to `true`)
if (arguments.length == 2) {
style = arguments[1]
X = true
Y = true
} else if (arguments.length == 3) {
if (typeof arguments[1] === 'string') {
style = arguments[1]
font = arguments[2]
X = true
Y = true
}
}
this.ctx.save()
this.ctx.font = font
this.ctx.fillStyle = style
let endpoints = this.Camera.getEndpoints()
let x1 = Math.round(endpoints[1][0] / delta) * delta
let y1 = Math.round(endpoints[2][1] / delta) * delta
let x2 = Math.round(endpoints[0][0] / delta) * delta
let y2 = Math.round(endpoints[0][1] / delta) * delta
if (X) {
for (let x = x1; x < x2; x += delta) {
this.ctx.textAlign = 'center'
this.ctx.textBaseline = 'top'
if (x === 0) {
// Don't intersect origin label with axes
this.ctx.textAlign = 'right'
this.ctx.textBaseline = 'top'
let pos = this.Camera.transform([0, 0])
let dist = delta / this.Camera.zoom
this.ctx.fillText(x, pos[0] - offset, pos[1] + offset, dist)
continue
}
let pos = this.Camera.transform([x, 0])
let dist = delta / this.Camera.zoom
this.ctx.fillText(x, pos[0], pos[1] + offset, dist)
}
}
if (Y) {
for (let y = y1; y < y2; y += delta) {
// Don't duplicate the origin label
if (y == 0 && X) continue
let pos = this.Camera.transform([0, y])
this.ctx.textAlign = 'right'
this.ctx.textBaseline = 'middle'
this.ctx.fillText(y, pos[0] - offset, pos[1])
}
}
this.ctx.restore()
}
parametric(f, T, k=256) {
// Plot the parametric curve `f(t)` over the interval [`T[0]`, `T[1]`]
// with `k=256` linear approximations
let start = T[0]
let span = T[1] - T[0]
for (let i = 0; i < k; i++) {
// Calculate the current parameter
let t = start + i * span / k
let [x, y] = f(t)
// Calculate with the next parameter value
let tPrime = start + (i + 1) * span / k
let [xPrime, yPrime] = f(tPrime)
// Plot a linear approximation
this.segment([x, y], [xPrime, yPrime])
}
}
graph(f, X, Y, k=256) {
/*
Plot the graph of `f(x, y)` over the domains:
x ∈ [`X[0]`, `X[1]`] and y ∈ [`Y[0]`, `Y[1]`]
with `k=256` linear approximations.
If `X`, `Y` domains are not specified,
graph endpoints are used instead.
*/
if (X === undefined || Y === undefined) {
// Get endpoints
let endpoints = this.Camera.getEndpoints()
// Set domains
X === undefined ? X = [endpoints[1][0], endpoints[0][0]] : X = X
Y === undefined ? Y = [endpoints[2][1], endpoints[0][1]] : Y = Y
}
let start = X[0]
let span = X[1] - X[0]
for (let i = 0; i <= k; i++) {
// Calculate the current parameter
let x = start + i * span / k
if (x < X[0] || x > X[1]) continue
let y = f(x)
if (y < Y[0] || y > Y[1]) continue
// Calculate with the next parameter value
let xPrime = start + (i + 1) * span / k
let yPrime = f(xPrime)
// Plot a linear approximation
this.segment([x, y], [xPrime, yPrime])
}
}
}
// Initialize Hulet and export relevant functions
module.exports = {
// Base
Cartesian,
// Camera
Camera,
}