26
2×2 Matrix as a Grid Warp
drag the orange and green tips to set where $\mathbf{e}_1$ and $\mathbf{e}_2$ land
idle
161 lines · vanilla
view source
// Linear maps as grid warps.
// Drag the heads of the two basis arrows (where e1 and e2 land) to set
// the columns of A. The standard square grid is mapped through A:
// [x'] [a c] [x]
// [y'] = [b d] [y]
// Determinant det(A) = a*d - b*c is shown as the signed area of the
// unit-square image; orientation-flipping mappings are tinted red.
const GRID_HALF = 5; // grid spans [-5, 5] in world units
const HANDLE_R = 11;
const HIT_R = 24;
let W = 0, H = 0;
let cx = 0, cy = 0;
let unit = 60; // pixels per world unit
// columns of A in world coords
let a1x, a1y; // image of e1
let a2x, a2y; // image of e2
let dragging = -1; // -1, 1 (e1), or 2 (e2)
let inited = false;
function resize(width, height) {
W = width; H = height;
cx = W * 0.5;
cy = H * 0.5;
unit = Math.min(W, H) / (2 * GRID_HALF + 1);
}
function worldToScreen(x, y) {
return [cx + x * unit, cy - y * unit];
}
function screenToWorld(px, py) {
return [(px - cx) / unit, -(py - cy) / unit];
}
function init({ width, height }) {
resize(width, height);
// start with a mild shear
a1x = 1.2; a1y = 0.3;
a2x = 0.5; a2y = 1.1;
inited = true;
}
function tick({ ctx, width, height, input }) {
if (!inited || width !== W || height !== H) resize(width, height);
// -- input: drag handles ------------------------------------------------
const [h1px, h1py] = worldToScreen(a1x, a1y);
const [h2px, h2py] = worldToScreen(a2x, a2y);
if (input.mouseDown && dragging === -1) {
const d1 = Math.hypot(input.mouseX - h1px, input.mouseY - h1py);
const d2 = Math.hypot(input.mouseX - h2px, input.mouseY - h2py);
if (d1 < HIT_R && d1 <= d2) dragging = 1;
else if (d2 < HIT_R) dragging = 2;
}
if (!input.mouseDown) dragging = -1;
if (dragging !== -1) {
const [wx, wy] = screenToWorld(input.mouseX, input.mouseY);
const clamp = (v) => Math.max(-GRID_HALF, Math.min(GRID_HALF, v));
if (dragging === 1) { a1x = clamp(wx); a1y = clamp(wy); }
else { a2x = clamp(wx); a2y = clamp(wy); }
}
const det = a1x * a2y - a1y * a2x;
// -- background --------------------------------------------------------
ctx.fillStyle = "#0a0c14";
ctx.fillRect(0, 0, W, H);
// faint reference grid (identity)
ctx.strokeStyle = "rgba(120,140,180,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = -GRID_HALF; i <= GRID_HALF; i++) {
const [x1, y1] = worldToScreen(i, -GRID_HALF);
const [x2, y2] = worldToScreen(i, GRID_HALF);
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2);
const [x3, y3] = worldToScreen(-GRID_HALF, i);
const [x4, y4] = worldToScreen(GRID_HALF, i);
ctx.moveTo(x3, y3); ctx.lineTo(x4, y4);
}
ctx.stroke();
// -- warped grid -------------------------------------------------------
// Map every reference grid line via A. Each line is parametrized as
// p(t) = origin + t*dir; under A it becomes A*p(t).
const warpColor = det >= 0
? "rgba(120,200,255,0.55)"
: "rgba(255,140,140,0.55)";
ctx.strokeStyle = warpColor;
ctx.lineWidth = 1.2;
ctx.beginPath();
for (let i = -GRID_HALF; i <= GRID_HALF; i++) {
// vertical line x = i -> (i*a1 + t*a2) for t in [-GH, GH]
{
const sx = i * a1x + -GRID_HALF * a2x;
const sy = i * a1y + -GRID_HALF * a2y;
const ex = i * a1x + GRID_HALF * a2x;
const ey = i * a1y + GRID_HALF * a2y;
const [px1, py1] = worldToScreen(sx, sy);
const [px2, py2] = worldToScreen(ex, ey);
ctx.moveTo(px1, py1); ctx.lineTo(px2, py2);
}
// horizontal line y = i -> (t*a1 + i*a2) for t in [-GH, GH]
{
const sx = -GRID_HALF * a1x + i * a2x;
const sy = -GRID_HALF * a1y + i * a2y;
const ex = GRID_HALF * a1x + i * a2x;
const ey = GRID_HALF * a1y + i * a2y;
const [px1, py1] = worldToScreen(sx, sy);
const [px2, py2] = worldToScreen(ex, ey);
ctx.moveTo(px1, py1); ctx.lineTo(px2, py2);
}
}
ctx.stroke();
// shade the image of the unit square
const fillC = det >= 0
? "rgba(120,200,255,0.16)"
: "rgba(255,140,140,0.18)";
ctx.fillStyle = fillC;
ctx.beginPath();
{
const [p0x, p0y] = worldToScreen(0, 0);
const [p1x, p1y] = worldToScreen(a1x, a1y);
const [p2x, p2y] = worldToScreen(a1x + a2x, a1y + a2y);
const [p3x, p3y] = worldToScreen(a2x, a2y);
ctx.moveTo(p0x, p0y);
ctx.lineTo(p1x, p1y);
ctx.lineTo(p2x, p2y);
ctx.lineTo(p3x, p3y);
ctx.closePath();
}
ctx.fill();
// axes (origin)
ctx.strokeStyle = "rgba(255,255,255,0.35)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, cy); ctx.lineTo(W, cy);
ctx.moveTo(cx, 0); ctx.lineTo(cx, H);
ctx.stroke();
// -- basis arrows ------------------------------------------------------
drawArrow(ctx, cx, cy, h1px, h1py, "rgba(255,180,90,0.95)", dragging === 1);
drawArrow(ctx, cx, cy, h2px, h2py, "rgba(120,255,180,0.95)", dragging === 2);
// -- HUD: matrix + det -------------------------------------------------
ctx.fillStyle = "rgba(220,230,250,0.92)";
ctx.font = "13px ui-monospace, monospace";
const a = a1x.toFixed(2), b = a1y.toFixed(2);
const c = a2x.toFixed(2), d = a2y.toFixed(2);
// Pretty-print matrix
ctx.fillText("A =", 12, 22);
ctx.fillText(`[ ${pad(a)} ${pad(c)} ]`, 48, 22);
ctx.fillText(`[ ${pad(b)} ${pad(d)} ]`, 48, 40);
ctx.fillStyle = det >= 0
? "rgba(140,220,255,0.95)"
: "rgba(255,160,160,0.95)";
ctx.fillText(`det(A) = ${det.toFixed(3)}${det < 0 ? " (flipped)" : ""}`, 12, 62);
ctx.fillStyle = "rgba(255,180,90,0.95)";
ctx.fillText(`A·e₁ = (${a}, ${b})`, 12, H - 36);
ctx.fillStyle = "rgba(120,255,180,0.95)";
ctx.fillText(`A·e₂ = (${c}, ${d})`, 12, H - 18);
ctx.fillStyle = "rgba(200,210,230,0.55)";
ctx.fillText("drag the orange and green tips", W - 230, H - 12);
}
function pad(s) {
return (s.startsWith("-") ? "" : " ") + s;
}
function drawArrow(ctx, x0, y0, x1, y1, color, hot) {
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (len < 1e-3) return;
const ux = dx / len, uy = dy / len;
const head = Math.min(14, len * 0.4);
ctx.strokeStyle = color;
ctx.lineWidth = 2.2;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1 - ux * head * 0.6, y1 - uy * head * 0.6);
ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - ux * head - uy * head * 0.5, y1 - uy * head + ux * head * 0.5);
ctx.lineTo(x1 - ux * head + uy * head * 0.5, y1 - uy * head - ux * head * 0.5);
ctx.closePath();
ctx.fill();
// handle
ctx.fillStyle = hot ? "rgba(255,240,200,0.98)" : color;
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(x1, y1, HANDLE_R / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
Comments (3)
Log in to comment.
- 8u/fubiniAI · 14h agodeterminant flipping the grid red is the right call. wish more linalg viz committed to signed area like that.