function nearestVertex(bi, bj, vertices, tolerance) {
let bestIndex = -1;
let bestDist = Infinity;
for (let k = 0; k < vertices.length; k++) {
const [vi, vj] = vertices[k];
const dist = Math.hypot(bi - vi, bj - vj);
if (dist < bestDist) {
bestDist = dist;
bestIndex = k;
}
}
if (bestDist < tolerance) return vertices[bestIndex];
return null;
}
class Triangle {
constructor(A, B, C, canvasObj) {
this.A = A;
this.B = B;
this.C = C;
this.canvasObj = canvasObj;
this.ctx = canvasObj.getContext();
this.CanvasA = canvasObj.toCanvas(A);
this.CanvasB = canvasObj.toCanvas(B);
this.CanvasC = canvasObj.toCanvas(C);
}
toCanvas(p) {
return this.canvasObj.toCanvas(p);
}
fromCanvas(p) {
return this.canvasObj.fromCanvas(p);
}
draw({ fillStyle = null, strokeStyle = "black", vertexColor = null } = {}) {
const { CanvasA, CanvasB, CanvasC, ctx } = this;
ctx.beginPath();
ctx.moveTo(...CanvasA);
ctx.lineTo(...CanvasB);
ctx.lineTo(...CanvasC);
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
if (strokeStyle) {
ctx.strokeStyle = strokeStyle;
ctx.stroke();
}
if (vertexColor) {
const points = [this.CanvasA, this.CanvasB, this.CanvasC];
for (const [x, y] of points) {
ctx.beginPath();
ctx.arc(x, y, 5, 0, 2 * Math.PI);
ctx.fillStyle = vertexColor;
ctx.fill();
}
}
}
drawGrid(spacing = 0.1, color = "rgba(200,200,200,0.5)") {
// Steps through integer grid indices (rather than repeatedly adding
// `spacing`) to avoid floating-point drift at the boundary.
const { ctx } = this;
const steps = Math.round(1 / spacing);
for (let ii = 0; ii <= steps; ii++) {
for (let jj = 0; jj <= steps - ii; jj++) {
const [x, y] = this.toCanvas(this.barycentricToCartesian(ii * spacing, jj * spacing));
const neighbors = [
[ii + 1, jj],
[ii, jj + 1],
[ii + 1, jj - 1]
];
for (const [ni, nj] of neighbors) {
if (ni >= 0 && nj >= 0 && ni + nj <= steps) {
const [x2, y2] = this.toCanvas(this.barycentricToCartesian(ni * spacing, nj * spacing));
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.stroke();
}
}
}
}
}
barycentricToCartesian(i, j) {
const k = 1 - i - j;
return [
i * this.A[0] + j * this.B[0] + k * this.C[0],
i * this.A[1] + j * this.B[1] + k * this.C[1]
];
}
cartesianToBarycentric(x, y) {
const [Ax, Ay] = this.A;
const [Bx, By] = this.B;
const [Cx, Cy] = this.C;
const v0 = [Bx - Ax, By - Ay];
const v1 = [Cx - Ax, Cy - Ay];
const v2 = [x - Ax, y - Ay];
const d00 = v0[0] * v0[0] + v0[1] * v0[1];
const d01 = v0[0] * v1[0] + v0[1] * v1[1];
const d11 = v1[0] * v1[0] + v1[1] * v1[1];
const d20 = v2[0] * v0[0] + v2[1] * v0[1];
const d21 = v2[0] * v1[0] + v2[1] * v1[1];
const denom = d00 * d11 - d01 * d01;
const j = (d11 * d20 - d01 * d21) / denom;
const k = (d00 * d21 - d01 * d20) / denom;
const i = 1 - j - k;
return [i, j];
}
}
class DraggableTriangle extends Triangle {
constructor(A, B, C, canvasObj) {
super(A, B, C, canvasObj);
this.draggingIndex = null;
}
hitTest([mx, my], radius = 10) {
const canvasPoints = [this.CanvasA, this.CanvasB, this.CanvasC];
for (let k = 0; k < canvasPoints.length; k++) {
const [x, y] = canvasPoints[k];
if (Math.hypot(mx - x, my - y) < radius) return k;
}
return -1;
}
startDrag([mx, my]) {
this.draggingIndex = this.hitTest([mx, my]);
return this.draggingIndex !== -1;
}
dragTo([mx, my]) {
if (this.draggingIndex === null) return;
const newCoord = this.fromCanvas([mx, my]);
if (this.draggingIndex === 0) this.A = newCoord;
else if (this.draggingIndex === 1) this.B = newCoord;
else if (this.draggingIndex === 2) this.C = newCoord;
this.CanvasA = this.toCanvas(this.A);
this.CanvasB = this.toCanvas(this.B);
this.CanvasC = this.toCanvas(this.C);
}
endDrag() {
this.draggingIndex = null;
}
}
class Canvas {
constructor(width, height, scale = 200, offset = [50, 250]) {
this.canvas = document.createElement("canvas");
this.canvas.width = width;
this.canvas.height = height;
this.ctx = this.canvas.getContext("2d");
this.scale = scale;
this.offset = offset;
}
getCanvas() {
return this.canvas;
}
getContext() {
return this.ctx;
}
toCanvas([x, y]) {
return [this.offset[0] + x * this.scale, this.offset[1] - y * this.scale];
}
fromCanvas([x, y]) {
return [(x - this.offset[0]) / this.scale, (this.offset[1] - y) / this.scale];
}
drawPoint([x, y], color = "black") {
const [canvasX, canvasY] = this.toCanvas([x, y]);
this.ctx.beginPath();
this.ctx.arc(canvasX, canvasY, 5, 0, 2 * Math.PI);
this.ctx.fillStyle = color;
this.ctx.fill();
}
newTriangle(A, B, C, draggable = false) {
return draggable
? new DraggableTriangle(A, B, C, this)
: new Triangle(A, B, C, this);
}
}
// === Observable viewof canvas ===
viewof canvas = {
const width = 800, height = 300;
const scale = 200, offset = [50, 250];
const canvasInstance = new Canvas(width, height, scale, offset);
const canvas = canvasInstance.getCanvas();
const ctx = canvasInstance.getContext();
const A = [0, 0], B = [1, 0], C = [0, 1];
const redTri = canvasInstance.newTriangle([1.5, 0], [3, 0], [2, 1], true);
let target = { i: 0.3, j: 0.3 };
let current = { i: 0.3, j: 0.3 };
function draw() {
ctx.clearRect(0, 0, width, height);
const blackTri = canvasInstance.newTriangle(A, B, C);
blackTri.drawGrid();
blackTri.draw({ fillStyle: "rgba(0,0,0,0.2)", strokeStyle: "black" });
redTri.drawGrid();
redTri.draw({ strokeStyle: "red", vertexColor: "red" });
const k = 1 - current.i - current.j;
const [x, y] = blackTri.barycentricToCartesian(current.i, current.j);
canvasInstance.drawPoint([x, y], "blue");
const [gx, gy] = redTri.barycentricToCartesian(current.i, current.j);
canvasInstance.drawPoint([gx, gy], "green");
ctx.fillStyle = "black";
ctx.font = "14px sans-serif";
ctx.fillText(`i = ${current.i.toFixed(2)}, j = ${current.j.toFixed(2)}, k = ${k.toFixed(2)}`, 75, height - 100);
ctx.fillText(`x = ${gx.toFixed(2)}, y = ${gy.toFixed(2)}`, 420, height - 100);
}
function animate() {
const alpha = 0.2;
current.i += alpha * (target.i - current.i);
current.j += alpha * (target.j - current.j);
draw();
requestAnimationFrame(animate);
}
canvas.addEventListener("mousedown", e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (redTri.startDrag([mx, my])) {
const move = e => {
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
redTri.dragTo([mx, my]);
draw();
};
const up = () => {
redTri.endDrag();
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
} else {
// Start tracking movement to update target continuously
const move = e => {
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const [x, y] = canvasInstance.fromCanvas([mx, my]);
const blackTri = canvasInstance.newTriangle(A, B, C);
const [bi, bj] = blackTri.cartesianToBarycentric(x, y);
const snapped = nearestVertex(bi, bj, [[1, 0], [0, 1], [0, 0]], 0.05);
if (snapped) {
[target.i, target.j] = snapped;
} else if (bi >= 0 && bj >= 0 && bi + bj <= 1) {
target.i = bi;
target.j = bj;
}
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
});
requestAnimationFrame(animate);
return canvas;
}Barycentric Coordinates
discrete math
triangles
barycentric coordinates
Barycentric coordinates are a coordinate system for describing points inside a triangle.
For a triangle with vertices \(A\), \(B\), and \(C\), any point \(P\) inside the triangle can be expressed as a weighted sum of the vertices: \[ P = iA + jB + kC \] where \(i\), \(j\), and \(k\) are the barycentric coordinates, satisfying \(i + j + k = 1\) and \(i, j, k \geq 0\). The barycentric coordinates can be interpreted as the relative areas of the sub-triangles formed with the point \(P\) and the vertices of the triangle.
Move the blue point around the triangle below and watch how the barycentric coordinates change. You can also move the vertices of the red triangle and observe how the coordinates adapt.
Consider the triangle with vertices \((0, 0)\), \((1, 0)\), and \((0, 1)\). A point \((x, y)\) lies inside this triangle exactly when: \[ 0 \le x, \quad 0 \le y, \quad \text{and} \quad x + y \le 1 \] In this case, we can write the point \((x, y)\) as: \[ \begin{bmatrix} x \\ y \end{bmatrix} = (1 - x - y) \begin{bmatrix} 0 \\ 0 \end{bmatrix} + x \begin{bmatrix} 1 \\ 0 \end{bmatrix} + y \begin{bmatrix} 0 \\ 1 \end{bmatrix} \] So the barycentric coordinates of \((x, y)\) are \((1 - x - y, x, y)\), all of which are non-negative and sum to 1.
Now all you need to do is map \((0, 0)\), \((1, 0)\), and \((0, 1)\) to the vertices \(A\), \(B\), and \(C\), respectively, via an affine transformation. Write \(T\) for the affine map sending \[ \begin{aligned} (0,0) &\mapsto A, \\ (1,0) &\mapsto B, \\ (0,1) &\mapsto C. \end{aligned} \] Being affine, \(T\) preserves weighted combinations whose coefficients sum to 1: \[ \begin{aligned} &T\big((1-x-y)(0,0) + x(1,0) + y(0,1)\big) \\ &= (1-x-y)\,T(0,0) + x\,T(1,0) + y\,T(0,1) \\ &= (1-x-y)A + xB + yC. \end{aligned} \] The left-hand side is just \(T(x, y)\), so the barycentric coordinates of \(T(x,y)\) with respect to \(A, B, C\) are exactly \((1-x-y, x, y)\) — the same triple as the barycentric coordinates of \((x,y)\) with respect to the standard triangle. This is why the formula transfers unchanged to any triangle.