const addButton = document.getElementById('add'); const removeButton = document.getElementById('remove'); const c0Button = document.getElementById('c0'); const c1Button = document.getElementById('c1'); const canvas = document.querySelector('canvas'); const context = canvas.getContext('2d'); let adding = false; /** @type {number|null} */ let selected = null; let mouseDown = false; /** @type {number|null} */ let movePointIndex = null; /** @type {number[][][]} */ const curves = []; addButton.addEventListener('click', () => { adding = true; curves.push([]); selected = curves.length - 1; }); removeButton.addEventListener('click', () => { if (selected === null) return; curves.splice(selected, 1); selected = null; draw(); }); c0Button.addEventListener('click', () => { if (curves.length <= 1 || selected === null) return; let p = getClosestEndPoint(curves[selected][0][0], curves[selected][0][1]); if (p === null) return; let [curveIndex, pointIndex] = p; curves[selected][0] = curves[curveIndex][pointIndex]; p = getClosestEndPoint(curves[selected][3][0], curves[selected][3][1]); if (p === null) return; [curveIndex, pointIndex] = p; curves[selected][3] = curves[curveIndex][pointIndex]; draw(); }); c1Button.addEventListener('click', () => { if (curves.length <= 1 || selected === null) return; let [curveIndex, pointIndex] = getClosestEndPoint( curves[selected][0][0], curves[selected][0][1], ); // Move first point of selected curve to closest found point curves[selected][0] = curves[curveIndex][pointIndex]; // Get control point of found point const h1 = curves[curveIndex][pointIndex === 0 ? 1 : 2]; // Set control point to location mirrored over the first point curves[selected][1] = [ 2 * curves[selected][0][0] - h1[0], 2 * curves[selected][0][1] - h1[1], ]; [curveIndex, pointIndex] = getClosestEndPoint( curves[selected][3][0], curves[selected][3][1], ); curves[selected][3] = curves[curveIndex][pointIndex]; const h2 = curves[curveIndex][pointIndex === 0 ? 1 : 2]; curves[selected][2] = [ 2 * curves[selected][3][0] - h2[0], 2 * curves[selected][3][1] - h2[1], ]; draw(); }); /** * @param {number} x * @param {number} y */ function addPoint(x, y) { const curve = curves[curves.length - 1]; curve.push([x, y]); if (curve.length === 4) { adding = false; } draw(); } /** * @param {number} t * @param {number} a * @param {number} b * @returns {number} */ function lerp(t, a, b) { return (1 - t) * a + t * b; } /** * interpolates the points * @param {number} t * @param {number[]} p array of points */ function lerpCurve(t, p) { if (p.length === 1) return p[0]; return lerp(t, lerpCurve(t, p.slice(0, -1)), lerpCurve(t, p.slice(1))); } function drawCircles() { if (selected === null) return; for (const [x, y] of curves[selected]) { context.beginPath(); context.ellipse(x, y, 10, 10, 0, 0, Math.PI * 2); context.stroke(); } } function drawLines() { for (let i = 0; i < curves.length; i++) { const curve = curves[i]; for (let j = 0; j < curve.length - 3; j += 3) { const p1 = curve[j]; const p2 = curve[j + 1]; const p3 = curve[j + 2]; const p4 = curve[j + 3]; context.beginPath(); if (selected === i) { context.moveTo(p1[0], p1[1]); context.lineTo(p2[0], p2[1]); context.moveTo(p3[0], p3[1]); context.lineTo(p4[0], p4[1]); } context.moveTo(p1[0], p1[1]); for (let t = 0; t <= 100; t++) { const tt = t / 100; const x = lerpCurve(tt, [p1[0], p2[0], p3[0], p4[0]]); const y = lerpCurve(tt, [p1[1], p2[1], p3[1], p4[1]]); context.lineTo(x, y); } context.stroke(); } } } function draw() { context.clearRect(0, 0, canvas.width, canvas.height); drawCircles(); drawLines(); } function resizeCanvas() { canvas.height = window.innerHeight; canvas.width = window.innerWidth; draw(); } /** * Gets the index of the closest curve to the point * @param {number} x * @param {number} y * @returns {number|null} */ function getClosestCurveIndex(x, y) { /** @type {number|null} */ let minDist = null; /** @type {number|null} */ let minIndex = null; for (let i = 0; i < curves.length; i++) { for (const point of curves[i]) { const dist = Math.abs( Math.sqrt((x - point[0]) ** 2 + (y - point[1]) ** 2), ); if (!minDist || dist < minDist) { minDist = dist; minIndex = i; } } } return minIndex; } /** * Gets the selected curve point under the mouse * @param {number} x * @param {number} y * @returns {number|null} */ function getSelectedPoint(x, y) { if (selected === null) return null; for (let i = 0; i < curves[selected].length; i++) { const point = curves[selected][i]; const dist = Math.abs( Math.sqrt((x - point[0]) ** 2 + (y - point[1]) ** 2), ); if (dist <= 10) { return i; } } return null; } /** * Gets the closest end point * @param {number} x * @param {number} y * @returns {number[]|null} */ function getClosestEndPoint(x, y) { /** @type {number|null} */ let minDist = null, minCurveIndex = null, minPointIndex = null; for (let i = 0; i < curves.length; i++) { if (i === selected) continue; let dist = Math.abs( Math.sqrt((x - curves[i][0][0]) ** 2 + (y - curves[i][0][1]) ** 2), ); if (minDist === null || dist < minDist) { minDist = dist; minCurveIndex = i; minPointIndex = 0; } dist = Math.abs( Math.sqrt((x - curves[i][3][0]) ** 2 + (y - curves[i][3][1]) ** 2), ); if (minDist === null || dist < minDist) { minDist = dist; minCurveIndex = i; minPointIndex = 3; } } return [minCurveIndex, minPointIndex]; } canvas.addEventListener('mousedown', (e) => { mouseDown = true; if (adding) { addPoint(e.pageX, e.pageY); return; } const point = getSelectedPoint(e.pageX, e.pageY); if (point !== null) { movePointIndex = point; return; } const i = getClosestCurveIndex(e.pageX, e.pageY); if (i === null) return; selected = i; draw(); }); canvas.addEventListener('mousemove', (e) => { if (!mouseDown) return; if (movePointIndex === null) return; curves[selected][movePointIndex] = [e.pageX, e.pageY]; draw(); }); canvas.addEventListener('mouseup', () => { mouseDown = false; movePointIndex = null; }); window.addEventListener('resize', resizeCanvas); resizeCanvas();