Search code examples
javascripthtml5-canvasbezier

How to generate bezier curve control points based on a pattern in javascript?


As requested in the comments, I have made a simpler example that focuses on the question. That being how to correctly calculate the two control points for a bezier curve that follows the pattern pictured below.

enter image description here

The solution I'm looking for needs calculate the variables control2 and control3 so that the curve draw has the same general form for any random pair of start/end points.

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    let canvasWidth = canvas.width;
    let canvasHeight = canvas.height;
    let zoneWidth = canvas.width*0.2;
    let zoneHeight = canvas.height*0.2;
    let zoneTopLeftX = (canvas.width/2) - (zoneWidth/2);
    let zoneTopLeftY = (canvas.height/2) - (zoneHeight/2);

    let startPoint = {x:zoneTopLeftX+30, y:zoneTopLeftY+(zoneHeight/2)};
    let endPoint = {x:zoneTopLeftX+zoneWidth-30, y:zoneTopLeftY+(zoneHeight/2)};

    // The sign of lineRun and lineRise should tell direction, but 
    // it's not the correct direction because canvas points are 
    // numbered differently from standard graph.  At least it is for y.
    let lineRun = startPoint.x - endPoint.x; // delta along the X-axis
    let lineRise = startPoint.y - endPoint.y; // delta along the Y-axis

    // This math works only in the example case. I need better math,
    // math that will work for any random start/end pair. 
    let cpX = (endPoint.x - lineRun) + (lineRise);
    let cpY = (endPoint.y - lineRise) + (lineRun);
    let control2 = {x:cpX, y:cpY};
    //console.log(cpX, cpY, control2);

    // Again, this math works only in the example case. I need better math,
    // math that will work for any random start/end pair. 
    cpX = startPoint.x;
    cpY = startPoint.y + lineRun + lineRun;
    let control3 = {x:cpX, y:cpY};
    //console.log(cpX, cpY, control3);

    ctx.fillStyle = "#eee"; // lite grey
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);

    ctx.fillStyle = "#aaa"; // grey
    ctx.fillRect(zoneTopLeftX, zoneTopLeftY, zoneWidth, zoneHeight);

    ctx.beginPath();
    // lable everything for demo
    ctx.font = "bold 12px Courier";
    ctx.strokeText("canvas width: "+canvasWidth, 15, 15);
    ctx.strokeText("canvas height: "+canvasHeight, 15, 30);

    ctx.beginPath();
    ctx.arc(startPoint.x, startPoint.y, 5, 0, 2 * Math.PI); // point 1
    ctx.fillStyle = "red";
    ctx.fill();
    ctx.strokeText("1 (" + (startPoint.x + "").substring(0,5) + "," + (startPoint.y + "").substring(0,5) + ")", startPoint.x+5, startPoint.y+15);

    ctx.beginPath();
    ctx.arc(control2.x, control2.y, 5, 0, 2 * Math.PI);     // point 2
    ctx.fillStyle = "#b1e22b"; // yellow-green
    ctx.fill();
    ctx.strokeText("2 (" + (control2.x + "").substring(0,5) + "," + (control2.y + "").substring(0,5) + ")", control2.x+5, control2.y+15);

    ctx.beginPath();
    ctx.arc(control3.x, control3.y, 5, 0, 2 * Math.PI);     // point 3
    ctx.fillStyle = "#8a2be2"; // purple-ish
    ctx.fill();
    ctx.strokeText("3 (" + (control3.x + "").substring(0,5) + "," + (control3.y + "").substring(0,5) + ")", control3.x+5, control3.y+15);

    ctx.beginPath();
    ctx.arc(endPoint.x, endPoint.y, 5, 0, 2 * Math.PI);     // point 4
    ctx.fillStyle = "blue";
    ctx.fill();
    ctx.strokeText("4 (" + (endPoint.x + "").substring(0,5) + "," + (endPoint.y + "").substring(0,5) + ")", endPoint.x+5, endPoint.y+15);

    ctx.moveTo(startPoint.x, startPoint.y);
    ctx.bezierCurveTo(control2.x, control2.y, control3.x, control3.y, endPoint.x, endPoint.y);
    ctx.stroke();
#canvas {
  position: absolute;
}
<canvas id="canvas" width="800" height="800"></canvas>
<div id="results"></div>

Any help is appreciated!


Solution

  • It looks like you need to translate the two additional control points based on the given two end points. For that you can use basic vector manipulation, so I have defined a few of these as separate functions (like vectorAdd). There is also a vectorTurn in two versions which returns a vector that is perpendicular to the given vector. Depending on the direction of the Y axis you can choose which of the two versions is the one you need.

    The snippet below will use a mouse drag as input, and will draw the points and curve as you drag the mouse. The start point is where you start the drag, and the end point is the current mouse position while you drag:

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    let mouseDownPos = { x: 0, y: 0};
    
    const getMousePos = (e) => ({ x: e.clientX - e.target.offsetLeft, y: e.clientY - e.target.offsetTop });
    
    canvas.addEventListener("mousedown", function (e) {
        mouseDownPos = getMousePos(e);
        refresh(mouseDownPos, mouseDownPos);
    });
    
    canvas.addEventListener("mousemove", function (e) {
        if (e.buttons === 1) refresh(mouseDownPos, getMousePos(e));
    });
    
    function setCanvasSize() {
        canvas.setAttribute("width", document.body.clientWidth);
        canvas.setAttribute("height", document.body.clientHeight - 4);
    }
    
    window.addEventListener("resize", setCanvasSize);
    document.addEventListener("DOMContentLoaded", setCanvasSize);
    
    // Vector helper functions
    const sum = (arr, prop) => arr.reduce((sum, obj) => sum + obj[prop], 0);
    const vectorAdd = (...vectors) => ({ x: sum(vectors, "x"), y: sum(vectors, "y")});
    const vectorSub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
    const vectorTurn = v => ({ x: -v.y, y: v.x });
    const vectorTurn2 = v => ({ x: v.y, y: -v.x });
    const vectorCoords = (vectors) => vectors.flatMap(v => [v.x, v.y]);
    
    function refresh(startPoint, endPoint) {
        const para = vectorSub(endPoint, startPoint);
        const ortho = vectorTurn2(para); // or vectorTurn (if upward Y axis)
        const points = [
            startPoint,
            vectorAdd(endPoint, para, ortho),
            vectorAdd(startPoint, ortho, ortho),
            endPoint
        ];
        draw(points);
    }
    
    function draw(points) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        for (const [i, color] of ["red", "#b1e22b", "#8a2be2", "blue"].entries()) {
            plotPoint(points[i], color, i + 1);
        }
        plotBezier(points);
    }
    
    function plotPoint({x, y}, color, label) {
        ctx.beginPath();
        ctx.arc(x, y, 5, 0, 2 * Math.PI);
        ctx.fillStyle = color;
        ctx.fill();
        ctx.strokeText(`${label} (${x.toFixed()},${y.toFixed()})`, x+5, y+15);
    }
    
    function plotBezier(points) {
        const [x, y, ...rest] = vectorCoords(points);
        ctx.moveTo(x, y);
        ctx.bezierCurveTo(...rest);
        ctx.stroke();
    }
    canvas { background: #eee }
    body { height: 100vh; margin: 0; }
    div { position: absolute; left: 0; top: 0; };
    <canvas id="canvas" width="600" height="600"></canvas>
    <div>Mouse down to set start point and drag to update end point:</div>

    Alternative calculation

    As a transformation can be described by a transformation matrix, we can do the same as the above, but with matrix-vector multiplication, where the vector has 4 scalars (the coordinates of the start point and the coordinates of the end point), and the product also has 4 scalars: for the coordinates of the second control point and the third control point.

    That matrix and product is like this (for an Y-axis that points downward):

    ( 𝑥₂ )   ( -1 -1  2  1 )   ( 𝑥₁ )
    ( 𝑦₂ )   (  1 -1 -1  2 )   ( 𝑦₁ )
    ( 𝑥₃ ) = (  1 -2  0  2 ) × ( 𝑥₄ )
    ( 𝑦₃ )   (  2  1 -2  0 )   ( 𝑦₄ )
    

    In this implementation points have their coordinates in an array instead of an x/y object:

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    let mouseDownPos = [0, 0];
    
    const getMousePos = (e) => [e.clientX - e.target.offsetLeft, e.clientY - e.target.offsetTop];
    
    canvas.addEventListener("mousedown", function (e) {
        mouseDownPos = getMousePos(e);
        refresh(mouseDownPos, mouseDownPos);
    });
    
    canvas.addEventListener("mousemove", function (e) {
        if (e.buttons === 1) refresh(mouseDownPos, getMousePos(e));
    });
    
    function setCanvasSize() {
        canvas.setAttribute("width", document.body.clientWidth);
        canvas.setAttribute("height", document.body.clientHeight - 4);
    }
    
    window.addEventListener("resize", setCanvasSize);
    document.addEventListener("DOMContentLoaded", setCanvasSize);
    
    // Matrix helper function
    function multiply(matrix, vector) {
        return matrix.map(row =>
            vector.reduce((sum, scalar, k) => sum + row[k] * scalar, 0)
        );
    }
    
    const MATRIX = [        // when Y-axis is up:
        [-1, -1,  2,  1],   //  [-1,  1,  2, -1], 
        [ 1, -1, -1,  2],   //  [-1, -1,  1,  2],
        [ 1, -2,  0,  2],   //  [ 1,  2,  0, -2],
        [ 2,  1, -2,  0],   //  [-2,  1,  2,  0],
    ];
    
    function refresh(startPoint, endPoint) {
        const product = multiply(MATRIX, [...startPoint, ...endPoint]);
        const points = [
            startPoint,
            product.splice(0, 2),
            product,
            endPoint
        ];
        draw(points);
    }
    
    function draw(points) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        for (const [i, color] of ["red", "#b1e22b", "#8a2be2", "blue"].entries()) {
            plotPoint(points[i], color, i + 1);
        }
        plotBezier(points);
    }
    
    function plotPoint([x, y], color, label) {
        ctx.beginPath();
        ctx.arc(x, y, 5, 0, 2 * Math.PI);
        ctx.fillStyle = color;
        ctx.fill();
        ctx.strokeText(`${label} (${x.toFixed()},${y.toFixed()})`, x+5, y+15);
    }
    
    function plotBezier(points) {
        ctx.moveTo(...points[0]);
        ctx.bezierCurveTo(...points.slice(1).flat());
        ctx.stroke();
    }
    canvas { background: #eee }
    body { height: 100vh; margin: 0; }
    div { position: absolute; left: 0; top: 0; };
    <canvas id="canvas" width="600" height="600"></canvas>
    <div>Mouse down to set start point and drag to update end point:</div>