Goal: I have a ball in a triangle. The ball has an initial position and velocity. I'm trying to figure out which side of the triangle the ball will hit.
What I've Tried: I derived a formula that outputs which side the ball will hit, by parametrizing the ball's path and the triangle's sides, and finding the minimum time that satisfies the parametric equations. But when I implement this formula into my program, it produces the wrong results! I've tried many things, to no avail. Any help is greatly appreciated. The MWE is here: CodePen
let angle = 0;
let sides = [];
let vertices = [];
const len = 100;
function setup() {
createCanvas(windowWidth, windowHeight);
angleMode(DEGREES);
angleOne = createSlider(0, 89, 60);
angleOne.position(10, 10);
angleOne.style("width", "80px");
angleTwo = createSlider(0, 89, 60);
angleTwo.position(10, 30);
angleTwo.style("width", "80px");
// Initial vertice & side setup (these don't change)
let v1 = createVector(width / 2 - len / 2, height * 0.7);
let v2 = createVector(width / 2 + len / 2, height * 0.7);
sides[0] = new Side(v1.x, v1.y, v2.x, v2.y, "green");
vertices[0] = new Vertex(v1.x, v1.y);
vertices[1] = new Vertex(v2.x, v2.y);
}
function draw() {
background(255);
let angOne = angleOne.value();
let angTwo = angleTwo.value();
fill(0);
strokeWeight(0);
textSize(15);
text(angOne, 100, 25);
text(angTwo, 100, 45);
let v2Offset = createVector(len * cos(-angOne), len * sin(-angOne));
let v3Offset = createVector(-len * cos(angTwo), -len * sin(angTwo));
vertices[2] = new Vertex(
vertices[0].a.x + v2Offset.x,
vertices[0].a.y + v2Offset.y
);
vertices[3] = new Vertex(
vertices[1].a.x + v3Offset.x,
vertices[1].a.y + v3Offset.y
);
// Update the sides
sides[1] = new Side(
vertices[0].a.x,
vertices[0].a.y,
vertices[2].a.x,
vertices[2].a.y
);
sides[3] = new Side(
vertices[1].a.x,
vertices[1].a.y,
vertices[3].a.x,
vertices[3].a.y
);
const m1 =
(vertices[2].a.y - vertices[0].a.y) / (vertices[2].a.x - vertices[0].a.x);
const m2 =
(vertices[3].a.y - vertices[1].a.y) / (vertices[3].a.x - vertices[1].a.x);
// Calculate the y-offset relative to vertices[0]
const b2 = (vertices[1].a.x - vertices[0].a.x) * -m2;
const xInt = b2 / (m1 - m2);
const yInt = xInt * m1;
// Note xInt and yInt are relative to vertices[0]
// draw all the things
// sides.forEach((s) => s.show());
// stroke(0, 255, 0);
// strokeWeight(20);
point(vertices[0].a.x + xInt, vertices[0].a.y + yInt);
vertices[4] = new Vertex(vertices[0].a.x + xInt, vertices[0].a.y + yInt);
sides[4] = new Side(
vertices[1].a.x,
vertices[1].a.y,
vertices[0].a.x + xInt,
vertices[0].a.y + yInt,
"blue"
);
sides[5] = new Side(
vertices[0].a.x,
vertices[0].a.y,
vertices[0].a.x + xInt,
vertices[0].a.y + yInt,
"purple"
);
scale(2); // so I can make the triangle actually *visible*
translate(-width / 3, -height / 4);
sides[0].show();
sides[4].show();
sides[5].show();
vertices[0].show();
vertices[1].show();
vertices[4].show();
strokeWeight(1);
stroke(255, 0, 0);
noFill();
arc(vertices[0].a.x, vertices[0].a.y, 40, 40, -1 * angleOne.value(), 0, PIE);
arc(
vertices[1].a.x,
vertices[1].a.y,
40,
40, -180, -(180 - angleTwo.value()),
PIE
);
let P1x = vertices[0].a.x;
let P1y = vertices[0].a.y;
let P2x = vertices[1].a.x;
let P2y = vertices[1].a.y;
let P3x = vertices[4].a.x;
let P3y = vertices[4].a.y;
stroke(255, 255, 0);
stroke("purple"); // Change the color
strokeWeight(5); // Make the points 10 pixels in size
let P0 = createVector(P1x + 60, P1y - 40);
let V0 = createVector(0, -15);
point(P0.x, P0.y);
stroke(255, 0, 0);
point(P0.x + V0.x, P0.y + V0.y);
strokeWeight(2);
stroke("purple");
line(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
// console.log(P1x,P1y,P2x,P2y,P3x,P3y);
let A1 = P3y - P1y;
let B1 = -(P3x - P1x);
let C1 = A1 * P1x + B1 * P1y;
let A2 = -(P3y - P2y);
let B2 = P3x - P2x;
let C2 = A2 * P2x + B2 * P2y;
let A3 = -(P2y - P1y);
let B3 = P2x - P1x;
let C3 = A3 * P2x + B3 * P2y;
let t1 = (C1 - A1 * P0.x - B1 * P0.y) / (A1 * V0.x + B1 * P0.y);
let t2 = (C2 - A2 * P0.x - B2 * P0.y) / (A2 * V0.x + B2 * P0.y);
let t3 = (C3 - A3 * P0.x - B3 * P0.y) / (A3 * V0.x + B3 * P0.y);
let times = [t1, t2, t3];
let posTimes = [];
for (let i = 0; i < times.length; i++) {
times[i] = round(times[i], 2);
}
// console.log("After rounding:", times);
for (let i = 0; i < times.length; i++) {
if (times[i] > 0) {
posTimes.push(times[i]);
}
}
// console.log("posTimes:", posTimes);
trueTime = min(posTimes);
if (trueTime == round(t1, 2)) {
fill("Blue");
text("Hit Blue", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
} else if (trueTime == round(t2, 2)) {
fill("Green");
text("Hit Green", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
} else {
fill("Purple");
text("Hit Purple", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
}
}
class Side {
constructor(x1, y1, x2, y2, col = "black") {
this.a = createVector(x1, y1);
this.b = createVector(x2, y2);
this.color = col;
}
show() {
stroke(this.color);
strokeWeight(4);
line(this.a.x, this.a.y, this.b.x, this.b.y);
}
}
class Vertex {
constructor(x1, y1) {
this.a = createVector(x1, y1);
}
show() {
stroke(255, 0, 0);
strokeWeight(10);
point(this.a.x, this.a.y);
}
}
html, body { margin: 0; padding: 0; overflow: hidden }
<script src="https://cdn.jsdelivr.net/npm/p5#1.4.1/lib/p5.min.js"></script>
I couldn't figure out your math. I think you should try annotating this kind of code with explanatory comments. Often that will help you spot your own mistake:
let A1 = P3y - P1y;
let B1 = -(P3x - P1x);
let C1 = A1 * P1x + B1 * P1y;
let A2 = -(P3y - P2y);
let B2 = P3x - P2x;
let C2 = A2 * P2x + B2 * P2y;
let A3 = -(P2y - P1y);
let B3 = P2x - P1x;
let C3 = A3 * P2x + B3 * P2y;
let t1 = (C1 - A1 * P0.x - B1 * P0.y) / (A1 * V0.x + B1 * P0.y);
let t2 = (C2 - A2 * P0.x - B2 * P0.y) / (A2 * V0.x + B2 * P0.y);
let t3 = (C3 - A3 * P0.x - B3 * P0.y) / (A3 * V0.x + B3 * P0.y);
Here's some working math. It may not be the most dense/elegant, but I tried to explain it step by step from basic algebra:
// Find which of
//
// P1 to P2 = green
// P1 to P3 = purple
// P2 to P3 = blue
//
// PO to PO + V0 intersects with
// Find the intersection point between lines A and B
function intersection(Ax1, Ay1, Ax2, Ay2, Bx1, By1, Bx2, By2) {
// Calculate the slope of line A
let Am = (Ay2 - Ay1) / (Ax2 - Ax1);
// Calculate the y-intercept of line A
let Ab = Ay1 - Ax1 * Am;
// slope of line B
let Bm = (By2 - By1) / (Bx2 - Bx1);
// y-intercept of line B
let Bb = By1 - Bx1 * Bm;
if (Am === Bm) {
// Parallel lines
return;
}
if (!Number.isFinite(Am)) {
// Line A is vertical
if (!Number.isFinite(Bm)) {
// Line B is also vertical (Am may not equal Bm though because Infinity != NegativeInfinity)
return;
} else {
// Since line A is vertical, intersection point will lie along the same x position as Ax1 and Ax2
const xInt = Ax1;
// Simply use the equation for line segment B to find the corresponding Y value
const yInt = Bm * xInt + Bb;
return createVector(xInt, yInt);
}
} else if (!Number.isFinite(Bm)) {
// Line B is vertical
const xInt = Bx1;
const yInt = Am * xInt + Ab;
return createVector(xInt, yInt);
} else {
// Derived from Am * x + Ab = Bm * x + Bb
const xInt = (Bb - Ab) / (Am - Bm);
const yInt = Am * xInt + Ab;
return createVector(xInt, yInt);
}
}
let P1toP2int =
intersection(P1.x, P1.y, P2.x, P2.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
let P1toP3int =
intersection(P1.x, P1.y, P3.x, P3.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
let P2toP3int =
intersection(P2.x, P2.y, P3.x, P3.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
// These intersection points assume that all lines point infinitely in both
// directions, so we still have some more work to do.
// Check if each of these points is within the target segment
function isWithin(lineX1, lineY1, lineX2, lineY2, xInt, yInt) {
if (abs((lineY2 - lineY1) / (lineX2 - lineX1)) > 1) {
// If the line segment is more vertical, check the Y position
return yInt >= min(lineY1, lineY2) && yInt <= max(lineY1, lineY2);
} else {
return xInt >= min(lineX1, lineX2) && xInt <= max(lineX1, lineX2);
}
}
if (P1toP2int && !isWithin(P1.x, P1.y, P2.x, P2.y, P1toP2int.x, P1toP2int.y)) {
P1toP2int = undefined;
}
if (P1toP3int && !isWithin(P1.x, P1.y, P3.x, P3.y, P1toP3int.x, P1toP3int.y)) {
P1toP3int = undefined;
}
if (P2toP3int && !isWithin(P2.x, P2.y, P3.x, P3.y, P2toP3int.x, P2toP3int.y)) {
P2toP3int = undefined;
}
// Check if each intersection point is in the direction our ray is pointing
function isOnRay(rayX0, rayY0, rayX1, rayY1, xInt, yInt) {
// If the ray is more vertical, check the y coordinates
if (abs((rayY1 - rayY0) / (rayX1 - rayX0)) > 1) {
// If the ray is pointing in the positive Y direction
// (rayY1 > rayY0) then the yInt must be on the positive
// side of rayY0; and vice versa
return (rayY1 > rayY0) === (yInt > rayY0);
} else {
return (rayX1 > rayX0) === (xInt > rayX0);
}
}
if (P1toP2int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P1toP2int.x, P1toP2int.y)) {
P1toP2int = undefined;
}
if (P1toP3int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P1toP3int.x, P1toP3int.y)) {
P1toP3int = undefined;
}
if (P2toP3int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P2toP3int.x, P2toP3int.y)) {
P2toP3int = undefined;
}
let angle = 0;
let sides = [];
let vertices = [];
const len = 100;
let angleOne;
let angleTwo;
let angleThree;
function setup() {
createCanvas(windowWidth, windowHeight);
angleMode(DEGREES);
angleOne = createSlider(0, 89, 60);
angleOne.position(10, 10);
angleOne.style("width", "80px");
angleTwo = createSlider(0, 89, 60);
angleTwo.position(10, 30);
angleTwo.style("width", "80px");
angleThree = createSlider(0, 360, 0);
angleThree.position(10, 50);
angleThree.style("width", "80px");
// Initial vertice & side setup (these don't change)
let v1 = createVector(width / 2 - len / 2, height * 0.7);
let v2 = createVector(width / 2 + len / 2, height * 0.7);
sides[0] = new Side(v1.x, v1.y, v2.x, v2.y, "green");
vertices[0] = new Vertex(v1.x, v1.y);
vertices[1] = new Vertex(v2.x, v2.y);
}
function draw() {
background(255);
let angOne = angleOne.value();
let angTwo = angleTwo.value();
let rayAngle = angleThree.value();
fill(0);
strokeWeight(0);
textSize(15);
text(angOne, 100, 25);
text(angTwo, 100, 45);
text(rayAngle, 100, 65);
let v2Offset = createVector(len * cos(-angOne), len * sin(-angOne));
let v3Offset = createVector(-len * cos(angTwo), -len * sin(angTwo));
vertices[2] = new Vertex(
vertices[0].a.x + v2Offset.x,
vertices[0].a.y + v2Offset.y
);
vertices[3] = new Vertex(
vertices[1].a.x + v3Offset.x,
vertices[1].a.y + v3Offset.y
);
// Update the sides
sides[1] = new Side(
vertices[0].a.x,
vertices[0].a.y,
vertices[2].a.x,
vertices[2].a.y
);
sides[3] = new Side(
vertices[1].a.x,
vertices[1].a.y,
vertices[3].a.x,
vertices[3].a.y
);
const m1 =
(vertices[2].a.y - vertices[0].a.y) / (vertices[2].a.x - vertices[0].a.x);
const m2 =
(vertices[3].a.y - vertices[1].a.y) / (vertices[3].a.x - vertices[1].a.x);
// Calculate the y-offset relative to vertices[0]
const b2 = (vertices[1].a.x - vertices[0].a.x) * -m2;
const xInt = b2 / (m1 - m2);
const yInt = xInt * m1;
// Note xInt and yInt are relative to vertices[0]
// draw all the things
// sides.forEach((s) => s.show());
// stroke(0, 255, 0);
// strokeWeight(20);
point(vertices[0].a.x + xInt, vertices[0].a.y + yInt);
vertices[4] = new Vertex(vertices[0].a.x + xInt, vertices[0].a.y + yInt);
sides[4] = new Side(
vertices[1].a.x,
vertices[1].a.y,
vertices[4].a.x,
vertices[4].a.y,
"blue"
);
sides[5] = new Side(
vertices[0].a.x,
vertices[0].a.y,
vertices[4].a.x,
vertices[4].a.y,
"purple"
);
scale(2); // so I can make the triangle actually *visible*
translate(-width / 3, -height / 4);
sides[0].show();
sides[4].show();
sides[5].show();
vertices[0].show();
vertices[1].show();
vertices[4].show();
strokeWeight(1);
stroke(255, 0, 0);
noFill();
arc(vertices[0].a.x, vertices[0].a.y, 40, 40, -1 * angleOne.value(), 0, PIE);
arc(
vertices[1].a.x,
vertices[1].a.y,
40,
40, -180, -(180 - angleTwo.value()),
PIE
);
let P1 = vertices[0].a;
let P2 = vertices[1].a;
let P3 = vertices[4].a;
stroke(255, 255, 0);
stroke("purple"); // Change the color
strokeWeight(5); // Make the points 10 pixels in size
let P0 = createVector(P1.x + 60, P1.y - 40);
let V0 = createVector(15 * cos(rayAngle), 15 * sin(rayAngle));
point(P0.x, P0.y);
stroke(255, 0, 0);
point(P0.x + V0.x, P0.y + V0.y);
strokeWeight(2);
stroke("purple");
line(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
// console.log(P1x,P1y,P2x,P2y,P3x,P3y);
// Find which of
//
// P1 to P2 = green
// P1 to P3 = purple
// P2 to P3 = blue
//
// PO to PO + V0 intersects with
// Find the intersection point between lines A and B
function intersection(Ax1, Ay1, Ax2, Ay2, Bx1, By1, Bx2, By2) {
// Calculate the slope of line A
let Am = (Ay2 - Ay1) / (Ax2 - Ax1);
// Calculate the y-intercept of line A
let Ab = Ay1 - Ax1 * Am;
// slope of line B
let Bm = (By2 - By1) / (Bx2 - Bx1);
// y-intercept of line B
let Bb = By1 - Bx1 * Bm;
if (Am === Bm) {
// Parallel lines
return;
}
if (!Number.isFinite(Am)) {
// Line A is vertical
if (!Number.isFinite(Bm)) {
// Line B is also vertical (Am may not equal Bm though because Infinity != NegativeInfinity)
return;
} else {
// Since line A is vertical, intersection point will lie along the same x position as Ax1 and Ax2
const xInt = Ax1;
// Simply use the equation for line segment B to find the corresponding Y value
const yInt = Bm * xInt + Bb;
return createVector(xInt, yInt);
}
} else if (!Number.isFinite(Bm)) {
// Line B is vertical
const xInt = Bx1;
const yInt = Am * xInt + Ab;
return createVector(xInt, yInt);
} else {
// Derived from Am * x + Ab = Bm * x + Bb
const xInt = (Bb - Ab) / (Am - Bm);
const yInt = Am * xInt + Ab;
return createVector(xInt, yInt);
}
}
let P1toP2int =
intersection(P1.x, P1.y, P2.x, P2.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
let P1toP3int =
intersection(P1.x, P1.y, P3.x, P3.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
let P2toP3int =
intersection(P2.x, P2.y, P3.x, P3.y, P0.x, P0.y, P0.x + V0.x, P0.y + V0.y);
// These intersection points assume that all lines point infinitely in both
// directions, so we still have some more work to do.
// Check if each of these points is within the target segment
function isWithin(lineX1, lineY1, lineX2, lineY2, xInt, yInt) {
if (abs((lineY2 - lineY1) / (lineX2 - lineX1)) > 1) {
// If the line segment is more vertical, check the Y position
return yInt >= min(lineY1, lineY2) && yInt <= max(lineY1, lineY2);
} else {
return xInt >= min(lineX1, lineX2) && xInt <= max(lineX1, lineX2);
}
}
if (P1toP2int && !isWithin(P1.x, P1.y, P2.x, P2.y, P1toP2int.x, P1toP2int.y)) {
P1toP2int = undefined;
}
if (P1toP3int && !isWithin(P1.x, P1.y, P3.x, P3.y, P1toP3int.x, P1toP3int.y)) {
P1toP3int = undefined;
}
if (P2toP3int && !isWithin(P2.x, P2.y, P3.x, P3.y, P2toP3int.x, P2toP3int.y)) {
P2toP3int = undefined;
}
// Check if each intersection point is in the direction our ray is pointing
function isOnRay(rayX0, rayY0, rayX1, rayY1, xInt, yInt) {
// If the ray is more vertical, check the y coordinates
if (abs((rayY1 - rayY0) / (rayX1 - rayX0)) > 1) {
// If the ray is pointing in the positive Y direction
// (rayY1 > rayY0) then the yInt must be on the positive
// side of rayY0; and vice versa
return (rayY1 > rayY0) === (yInt > rayY0);
} else {
return (rayX1 > rayX0) === (xInt > rayX0);
}
}
if (P1toP2int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P1toP2int.x, P1toP2int.y)) {
P1toP2int = undefined;
}
if (P1toP3int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P1toP3int.x, P1toP3int.y)) {
P1toP3int = undefined;
}
if (P2toP3int && !isOnRay(P0.x, P0.y, P0.x + V0.x, P0.y + V0.y, P2toP3int.x, P2toP3int.y)) {
P2toP3int = undefined;
}
// Only one of these should be true, except perhaps if the ray passes precisely through a corner
if (P1toP2int) {
stroke("Red");
strokeWeight(8);
point(P1toP2int.x, P1toP2int.y);
fill("Green");
noStroke();
text("Hit Green", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
}
if (P1toP3int) {
stroke("Red");
strokeWeight(8);
point(P1toP3int.x, P1toP3int.y);
fill("Purple");
noStroke();
text("Hit Purple", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
}
if (P2toP3int) {
stroke("Red");
strokeWeight(8);
point(P2toP3int.x, P2toP3int.y);
fill("Blue");
noStroke();
text("Hit Blue", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
}
/* I don't understand this math at all
let A1 = P3y - P1y;
let B1 = -(P3x - P1x);
let C1 = A1 * P1x + B1 * P1y;
let A2 = -(P3y - P2y);
let B2 = P3x - P2x;
let C2 = A2 * P2x + B2 * P2y;
let A3 = -(P2y - P1y);
let B3 = P2x - P1x;
let C3 = A3 * P2x + B3 * P2y;
let t1 = (C1 - A1 * P0.x - B1 * P0.y) / (A1 * V0.x + B1 * P0.y);
let t2 = (C2 - A2 * P0.x - B2 * P0.y) / (A2 * V0.x + B2 * P0.y);
let t3 = (C3 - A3 * P0.x - B3 * P0.y) / (A3 * V0.x + B3 * P0.y);
let times = [t1, t2, t3];
let posTimes = [];
for (let i = 0; i < times.length; i++) {
times[i] = round(times[i], 2);
}
// console.log("After rounding:", times);
for (let i = 0; i < times.length; i++) {
if (times[i] > 0) {
posTimes.push(times[i]);
}
}
// console.log("posTimes:", posTimes);
trueTime = min(posTimes);
if (trueTime == round(t1, 2)) {
fill("Blue");
text("Hit Blue", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
} else if (trueTime == round(t2, 2)) {
fill("Green");
text("Hit Green", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
} else {
fill("Purple");
text("Hit Purple", vertices[1].a.x + 50, max(50, vertices[1].a.y - 50));
}
*/
}
class Side {
constructor(x1, y1, x2, y2, col = "black") {
this.a = createVector(x1, y1);
this.b = createVector(x2, y2);
this.color = col;
}
show() {
stroke(this.color);
strokeWeight(4);
line(this.a.x, this.a.y, this.b.x, this.b.y);
}
}
class Vertex {
constructor(x1, y1) {
this.a = createVector(x1, y1);
}
show() {
stroke(255, 0, 0);
strokeWeight(10);
point(this.a.x, this.a.y);
}
}
html, body { margin: 0; padding: 0; overflow: hidden }
<script src="https://cdn.jsdelivr.net/npm/p5#1.4.1/lib/p5.min.js"></script>
The intersection of a ray (R1, R2) and a line (L1, L2) can be calculated using the Dot Product:
N = (L1.y - L2.y, L2.x - L1.x)
t = dot(R1-L1, N) / dot(L2-L1, N)
X = R1 + (R2-R1) * t (if 0 <= t <= 1 and dot(R2-R1, X-R1) > 0)
In p5.js this can be implemented using p5.Vector:
function intersect_ray_line_segment(r1, r2, l1, l2) {
let R = p5.Vector.sub(r2, r1);
let L = p5.Vector.sub(l2, l1);
let N = p.createVector(-R.y, R.x);
if (L.dot(N) == 0) {
return undefined; // parallel
}
let t = p5.Vector.sub(r1, l1).dot(N) / L.dot(N);
if (t < 0 || t > 1) {
return undefined; // intersection is not on line segment
}
let X = L.mult(t).add(l1);
if (R.dot(p5.Vector.sub(X, r1)) < 0) {
return undefined; // wrong direction
}
return X;
}
Example:
var sketch = function( p ) {
p.setup = function() {
let sketchCanvas = p.createCanvas(p.windowWidth, p.windowHeight);
}
p.windowResized = function() {
p.resizeCanvas(p.windowWidth, p.windowHeight);
}
function intersect_ray_line_segment(r1, r2, l1, l2) {
let R = p5.Vector.sub(r2, r1);
let L = p5.Vector.sub(l2, l1);
let N = p.createVector(-R.y, R.x);
if (L.dot(N) == 0) {
return undefined; // parallel
}
let t = p5.Vector.sub(r1, l1).dot(N) / L.dot(N);
if (t < 0 || t > 1) {
return undefined; // intersection is not on line segment
}
let X = L.mult(t).add(l1);
if (R.dot(p5.Vector.sub(X, r1)) < 0) {
return undefined; // wrong direction
}
return X;
}
function closest_hit_point(r1, r2, points, lines) {
let hit_p = undefined;
let dist = undefined;
let N = undefined;
for (let i = 0; i < lines.length; ++i) {
let l1 = points[lines[i][0]];
let l2 = points[lines[i][1]];
let new_hit_p = intersect_ray_line_segment(r1, r2, points[lines[i][0]], points[lines[i][1]]);
if (new_hit_p) {
let new_d = p5.Vector.dist(r1, new_hit_p);
if (new_d > 0.1 && (!dist || new_d < dist)) {
dist = new_d;
hit_p = new_hit_p;
N = p.createVector(l1.y - l2.y, l2.x - l1.x);
}
}
}
return hit_p ? [hit_p, N] : undefined;
}
p.draw = function() {
let sx = p.width / 2;
let sy = p.height / 2;
let points = [
p.createVector(-sx*0.7, -sy*0.2), p.createVector(-sx*0.7, +sy*0.2),
p.createVector(-sx*0.3, -sy*0.5), p.createVector(sx*0.4, -sy*0.5),
p.createVector(sx*0.6, -sy*0.3), p.createVector(sx*0.6, sy*0.5), p.createVector(-sx*0.3, sy*0.5)];
let lines = [[0, 1], [2, 3], [3, 4], [4, 5], [5, 6]]
let center = new p5.Vector(0, 0);
let mouse_p = p.createVector(p.mouseX - sx, p.mouseY - sy);
let direction = p.createVector(mouse_p.x - center.x, mouse_p.y - center.y);
let hit_points = [center]
if (center.x != mouse_p.x || center.y != mouse_p.y) {
let start = center;
result = closest_hit_point(center, mouse_p, points, lines);
let count = 0
while (result && count < 100) {
hit_points.push(result[0]);
direction = direction.reflect(result[1]);
result = closest_hit_point(result[0], p5.Vector.add(result[0], direction), points, lines);
count ++;
}
}
direction.normalize();
hit_points.push(direction.mult( new p5.Vector(p.width, p.height).mag()).add(hit_points[hit_points.length-1]));
p.translate(p.width/2, p.height/2);
p.background(192);
p.strokeWeight(3);
p.stroke(64, 64, 255);
p.fill(128, 128, 255);
for (let i = 0; i < lines.length; ++i) {
let p0 = points[lines[i][0]];
let p1 = points[lines[i][1]];
p.line(p0.x, p0.y, p1.x, p1.y);
}
for (let i = 0; i < points.length; ++i) {
p.ellipse(points[i].x, points[i].y, 10, 10);
}
p.stroke(0, 0, 0);
p.fill(128, 128, 128);
p.ellipse(center.x, center.y, 10, 10);
for (let i = 1; i < hit_points.length; ++i) {
p.line(hit_points[i-1].x, hit_points[i-1].y, hit_points[i].x, hit_points[i].y);
}
for (let i = 0; i < hit_points.length; ++i) {
p.ellipse(hit_points[i].x, hit_points[i].y, 10, 10);
}
}
};
var circle_3_pts = new p5(sketch);
<script src="https://cdn.jsdelivr.net/npm/p5#1.4.1/lib/p5.min.js"></script>
Demo
I am trying to draw intensity profile for an image with x axis as the length of the line on the image and the y-axis with intensity values along the length of the line. How can i do this on html 5 canvas? I tried the below code but I am not getting the right intensity values. Not sure where i am going wrong.
private getLineIntensityVals = function (lineObj, img) {
const slope = this.calculateSlopeOfLine(lineObj.upPos, lineObj.downPos);
const intercept = this.calculateIntercept(lineObj.downPos, slope);
const ctx = img.getContext('2d');
const coordinates = [];
const intensities = [];
for (let x = lineObj.downPos.x; x <= lineObj.upPos.x; x++) {
const y = slope * x + intercept;
const pixelData = ctx.getImageData(x, y, 1, 1).data;
pixelData[0] = 255 - pixelData[0];
pixelData[1] = 255 - pixelData[1];
pixelData[2] = 255 - pixelData[2];
const intensity = ((0.299 * pixelData[0]) + (0.587 * pixelData[1]) + (0.114 * pixelData[2]));
intensities.push(intensity);
}
return intensities;
};
private calculateSlopeOfLine = function (upPos, downPos) {
if (upPos.x === downPos.x || upPos.y === downPos.y) {
return null;
}
return (downPos.y - upPos.y) / (downPos.x - upPos.x);
};
private calculateIntercept = function (startPoint, slope) {
if (slope === null) {
return startPoint.x;
}
return startPoint.y - slope * startPoint.x;
};
private calculateLineLength(line) {
const dim = {width: Math.abs(line.downPos.x -line.upPos.x),height:Math.abs(line.downPos.y- line.upPos.y)};
length = Math.sqrt(Math.pow(dim.width, 2) + Math.pow(dim.height, 2));
return length;
};
Image data
Don't get the image data one pixel at a time. Gaining access to pixel data is expensive (CPU cycles), and memory is cheap. Get all the pixels once and reuse that data.
Sampling the data
Most lines will not fit into pixels evenly. To solve divide the line into the number of samples you want (You can use the line length)
Then step to each sample in turn getting the 4 neighboring pixels values and interpolating the color at the sample point.
As we are interpolating we need to ensure that we do not use the wrong color model. In this case we use sRGB.
We thus get the function
// imgData is the pixel date
// x1,y1 and x2,y2 are the line end points
// sampleRate is number of samples per pixel
// Return array 3 values for each sample.
function getProfile(imgData, x1, y1, x2, y2, sampleRate) {
// convert line to vector
const dx = x2 - x1;
const dy = y2 - y1;
// get length and calculate number of samples for sample rate
const samples = (dx * dx + dy * dy) ** 0.5 * Math.abs(sampleRate) + 1 | 0;
// Divide line vector by samples to get x, and y step per sample
const nx = dx / samples;
const ny = dy / samples;
const w = imgData.width;
const h = imgData.height;
const pixels = imgData.data;
const values = [];
// Offset line to center of pixel
var x = x1 + 0.5;
var y = y1 + 0.5;
var i = samples;
while (i--) { // for each sample
// make sure we are in the image
if (x >= 0 && x < w - 1 && y >= 0 && y < h - 1) {
// get 4 closest pixel indexes
const idxA = ((x | 0) + (y | 0) * w) * 4;
const idxB = ((x + 1 | 0) + (y | 0) * w) * 4;
const idxC = ((x + 1 | 0) + (y + 1 | 0) * w) * 4;
const idxD = ((x | 0) + (y + 1 | 0) * w) * 4;
// Get channel data using sRGB approximation
const r1 = pixels[idxA] ** 2.2;
const r2 = pixels[idxB] ** 2.2;
const r3 = pixels[idxC] ** 2.2;
const r4 = pixels[idxD] ** 2.2;
const g1 = pixels[idxA + 1] ** 2.2;
const g2 = pixels[idxB + 1] ** 2.2;
const g3 = pixels[idxC + 1] ** 2.2;
const g4 = pixels[idxD + 1] ** 2.2;
const b1 = pixels[idxA + 2] ** 2.2;
const b2 = pixels[idxB + 2] ** 2.2;
const b3 = pixels[idxC + 2] ** 2.2;
const b4 = pixels[idxD + 2] ** 2.2;
// find value at location via linear interpolation
const xf = x % 1;
const yf = y % 1;
const rr = (r2 - r1) * xf + r1;
const gg = (g2 - g1) * xf + g1;
const bb = (b2 - b1) * xf + b1;
/// store channels as uncompressed sRGB
values.push((((r3 - r4) * xf + r4) - rr) * yf + rr);
values.push((((g3 - g4) * xf + g4) - gg) * yf + gg);
values.push((((b3 - b4) * xf + b4) - bb) * yf + bb);
} else {
// outside image
values.push(0,0,0);
}
// step to next sample
x += nx;
y += ny;
}
return values;
}
Conversion to values
The array hold raw sample data. There are a variety of ways to convert to a value. That is why we separate the sampling from the conversion to values.
The next function takes the raw sample array and converts it to values. It returns an array of values. While it is doing the conversion it also get the max value so that the data can be plotted to fit a graph.
function convertToMean(values) {
var i = 0, v;
const results = [];
results._max = 0;
while (i < values.length) {
results.push(v = (values[i++] * 0.299 + values[i++] * 0.587 + values[i++] * 0.114) ** (1/2.2));
results._max = Math.max(v, results._max);
}
return results;
}
Now you can plot the data how you like.
Example
Click drag line on image (when loaded)
Results are plotted real time.
Move mouse over plot to see values.
Use full page to see all.
const ctx = canvas.getContext("2d");
const ctx1 = canvas1.getContext("2d");
const SCALE_IMAGE = 0.5;
const PLOT_WIDTH = 500;
const PLOT_HEIGHT = 150;
canvas1.width = PLOT_WIDTH;
canvas1.height = PLOT_HEIGHT;
const line = {x1: 0, y1: 0, x2: 0, y2:0, canUse: false, haveData: false, data: undefined};
var bounds, bounds1, imgData;
// ix iy image coords, px, py plot coords
const mouse = {ix: 0, iy: 0, overImage: false, px: 0, py:0, overPlot: false, button : false, dragging: 0};
["down","up","move"].forEach(name => document.addEventListener("mouse" + name, mouseEvents));
const img = new Image;
img.crossOrigin = "Anonymous";
img.src = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Black_and_yellow_garden_spider%2C_Washington_DC.jpg/800px-Black_and_yellow_garden_spider%2C_Washington_DC.jpg";
img.addEventListener("load",() => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img,0,0);
imgData = ctx.getImageData(0,0,ctx.canvas.width, ctx.canvas.height);
canvas.width = img.width * SCALE_IMAGE;
canvas.height = img.height * SCALE_IMAGE;
bounds = canvas.getBoundingClientRect();
bounds1 = canvas1.getBoundingClientRect();
requestAnimationFrame(update);
},{once: true});
function getProfile(imgData, x1, y1, x2, y2, sampleRate) {
x1 *= 1 / SCALE_IMAGE;
y1 *= 1 / SCALE_IMAGE;
x2 *= 1 / SCALE_IMAGE;
y2 *= 1 / SCALE_IMAGE;
const dx = x2 - x1;
const dy = y2 - y1;
const samples = (dx * dx + dy * dy) ** 0.5 * Math.abs(sampleRate) + 1 | 0;
const nx = dx / samples;
const ny = dy / samples;
const w = imgData.width;
const h = imgData.height;
const pixels = imgData.data;
const values = [];
var x = x1 + 0.5;
var y = y1 + 0.5;
var i = samples;
while (i--) {
if (x >= 0 && x < w - 1 && y >= 0 && y < h - 1) {
// get 4 closest pixel indexs
const idxA = ((x | 0) + (y | 0) * w) * 4;
const idxB = ((x + 1 | 0) + (y | 0) * w) * 4;
const idxC = ((x + 1 | 0) + (y + 1 | 0) * w) * 4;
const idxD = ((x | 0) + (y + 1 | 0) * w) * 4;
// Get channel data using sRGB approximation
const r1 = pixels[idxA] ** 2.2;
const r2 = pixels[idxB] ** 2.2;
const r3 = pixels[idxC] ** 2.2;
const r4 = pixels[idxD] ** 2.2;
const g1 = pixels[idxA + 1] ** 2.2;
const g2 = pixels[idxB + 1] ** 2.2;
const g3 = pixels[idxC + 1] ** 2.2;
const g4 = pixels[idxD + 1] ** 2.2;
const b1 = pixels[idxA + 2] ** 2.2;
const b2 = pixels[idxB + 2] ** 2.2;
const b3 = pixels[idxC + 2] ** 2.2;
const b4 = pixels[idxD + 2] ** 2.2;
// find value at location via linear interpolation
const xf = x % 1;
const yf = y % 1;
const rr = (r2 - r1) * xf + r1;
const gg = (g2 - g1) * xf + g1;
const bb = (b2 - b1) * xf + b1;
/// store channels as uncompressed sRGB
values.push((((r3 - r4) * xf + r4) - rr) * yf + rr);
values.push((((g3 - g4) * xf + g4) - gg) * yf + gg);
values.push((((b3 - b4) * xf + b4) - bb) * yf + bb);
} else {
// outside image
values.push(0,0,0);
}
x += nx;
y += ny;
}
values._nx = nx;
values._ny = ny;
values._x = x1;
values._y = y1;
return values;
}
function convertToMean(values) {
var i = 0, max = 0, v;
const results = [];
while (i < values.length) {
results.push(v = (values[i++] * 0.299 + values[i++] * 0.587 + values[i++] * 0.114) ** (1/2.2));
max = Math.max(v, max);
}
results._max = max;
results._nx = values._nx;
results._ny = values._ny;
results._x = values._x;
results._y = values._y;
return results;
}
function plotValues(ctx, values) {
const count = values.length;
const scaleX = ctx.canvas.width / count;
// not using max in example
// const scaleY = (ctx.canvas.height-3) / values._max;
const scaleY = (ctx.canvas.height-3) / 255;
ctx1.clearRect(0,0, ctx.canvas.width, ctx.canvas.height);
var i = 0;
ctx.beginPath();
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
while (i < count) {
const y = ctx.canvas.height - values[i] * scaleY + 1;
ctx.lineTo(i++ * scaleX, y);
}
ctx.stroke();
if (!mouse.button && mouse.overPlot) {
ctx.fillStyle = "#f008";
ctx.fillRect(mouse.px, 0, 1, ctx.canvas.height);
const val = values[mouse.px / scaleX | 0];
info.textContent = "Value: " + (val !== undefined ? val.toFixed(2) : "");
}
}
function update() {
ctx.clearRect(0,0,ctx.canvas.width, ctx.canvas.height);
ctx.drawImage(img, 0, 0, img.width * SCALE_IMAGE, img.height * SCALE_IMAGE);
var atSample = 0;
if (!mouse.button) {
if (line.canUse) {
if (line.haveData && mouse.overPlot) {
const count = line.data.length;
const scaleX = ctx1.canvas.width / count
atSample = mouse.px / scaleX;
}
}
}
if (mouse.button) {
if (mouse.dragging === 1) { // dragging line
line.x2 = mouse.ix;
line.y2 = mouse.iy;
line.canUse = true;
line.haveData = false;
} else if(mouse.overImage) {
mouse.dragging = 1;
line.x1 = mouse.ix;
line.y1 = mouse.iy;
line.canUse = false;
line.haveData = false;
canvas.style.cursor = "none";
}
} else {
mouse.dragging = 0;
canvas.style.cursor = "crosshair";
}
if (line.canUse) {
ctx.strokeStyle = "#F00";
ctx.strokeWidth = 2;
ctx.beginPath();
ctx.lineTo(line.x1, line.y1);
ctx.lineTo(line.x2, line.y2);
ctx.stroke();
if (atSample) {
ctx.fillStyle = "#FF0";
ctx.beginPath();
ctx.arc(
(line.data._x + line.data._nx * atSample) * SCALE_IMAGE,
(line.data._y + line.data._ny * atSample) * SCALE_IMAGE,
line.data[atSample | 0] / 32,
0, Math.PI * 2
);
ctx.fill();
}
if (!line.haveData) {
const vals = getProfile(imgData, line.x1, line.y1, line.x2, line.y2, 1);
line.data = convertToMean(vals);
line.haveData = true;
plotValues(ctx1, line.data);
} else {
plotValues(ctx1, line.data);
}
}
requestAnimationFrame(update);
}
function mouseEvents(e){
if (bounds) {
mouse.ix = e.pageX - bounds.left;
mouse.iy = e.pageY - bounds.top;
mouse.overImage = mouse.ix >= 0 && mouse.ix < bounds.width && mouse.iy >= 0 && mouse.iy < bounds.height;
mouse.px = e.pageX - bounds1.left;
mouse.py = e.pageY - bounds1.top;
mouse.overPlot = mouse.px >= 0 && mouse.px < bounds1.width && mouse.py >= 0 && mouse.py < bounds1.height;
}
mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
}
canvas {
border: 2px solid black;
}
<canvas id="canvas"></canvas>
<div id="info">Click drag line over image</div>
<canvas id="canvas1"></canvas>
Image source: https://commons.wikimedia.org/w/index.php?curid=93680693 By BethGuay - Own work, CC BY-SA 4.0,
I am new to d3.js. I want to add sub-value indicator and text along with tooltip as shown in the attached image. The purpose of the sub-value indicator is to show some cut-off value. How can I add text at the end of the needle?
I have shared the code below. Please guide me to achieve this.
JAVASCRIPT:
(function () {
var Needle, arc, arcEndRad, arcStartRad, barWidth, chart, chartInset, degToRad, el, endPadRad, height, i, margin, needle, numSections, padRad, percToDeg, percToRad, percent, radius, ref, sectionIndx, sectionPerc, startPadRad, svg, totalPercent, width;
percent = .65;
barWidth = 60;
numSections = 3;
// / 2 for HALF circle
sectionPerc = [0.1, 0.25, 0.15];
padRad = 0;
chartInset = 10;
// start at 270deg
totalPercent = .75;
el = d3.select('.chart-gauge');
margin = {
top: 20,
right: 20,
bottom: 30,
left: 20 };
width = el[0][0].offsetWidth - margin.left - margin.right;
height = width;
radius = Math.min(width, height) / 2;
percToDeg = function (perc) {
return perc * 360;
};
percToRad = function (perc) {
return degToRad(percToDeg(perc));
};
degToRad = function (deg) {
return deg * Math.PI / 180;
};
svg = el.append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom);
chart = svg.append('g').attr('transform', `translate(${(width + margin.left) / 2}, ${(height + margin.top) / 2})`);
// build gauge bg
for (sectionIndx = i = 1, ref = numSections; 1 <= ref ? i <= ref : i >= ref; sectionIndx = 1 <= ref ? ++i : --i) {
arcStartRad = percToRad(totalPercent);
arcEndRad = arcStartRad + percToRad(sectionPerc[sectionIndx-1]);
totalPercent += sectionPerc[sectionIndx-1];
startPadRad = 0;
endPadRad = 0;
arc = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth).startAngle(arcStartRad + startPadRad).endAngle(arcEndRad - endPadRad);
chart.append('path').attr('class', `arc chart-color${sectionIndx}`).attr('d', arc);
}
Needle = class Needle {
constructor(len, radius1) {
this.len = len;
this.radius = radius1;
}
drawOn(el, perc) {
el.append('circle').attr('class', 'needle-center').attr('cx', 0).attr('cy', 0).attr('r', this.radius);
return el.append('path').attr('class', 'needle').attr('d', this.mkCmd(perc));
}
animateOn(el, perc) {
var self;
self = this;
return el.transition().delay(500).ease('elastic').duration(3000).selectAll('.needle').tween('progress', function () {
return function (percentOfPercent) {
var progress;
progress = percentOfPercent * perc;
return d3.select(this).attr('d', self.mkCmd(progress));
};
});
}
mkCmd(perc) {
var centerX, centerY, leftX, leftY, rightX, rightY, thetaRad, topX, topY;
thetaRad = percToRad(perc / 2); // half circle
centerX = 0;
centerY = 0;
topX = centerX - this.len * Math.cos(thetaRad);
topY = centerY - this.len * Math.sin(thetaRad);
leftX = centerX - this.radius * Math.cos(thetaRad - Math.PI / 2);
leftY = centerY - this.radius * Math.sin(thetaRad - Math.PI / 2);
rightX = centerX - this.radius * Math.cos(thetaRad + Math.PI / 2);
rightY = centerY - this.radius * Math.sin(thetaRad + Math.PI / 2);
return `M ${leftX} ${leftY} L ${topX} ${topY} L ${rightX} ${rightY}`;
}};
needle = new Needle(140, 15);
needle.drawOn(chart, 0);
needle.animateOn(chart, percent);
}).call(this);
//# sourceURL=coffeescript
CSS:
#import compass
.chart-gauge
width: 400px
margin: 10px auto
.chart-color1
fill: #D82724
.chart-color2
fill: #FCBF02
.chart-color3
fill: #92D14F
.needle,
.needle-center
fill: #464A4F
.prose
text-align: center
font-family: sans-serif
color: #ababab
HTML:
<div class="chart-gauge"></div>
Thanks
For creating this sub value indicator, you just need to set an arc generator who accepts the percentage value you want to set (here named subIndicator), with a padding in the outer and inner radiuses...
arc2 = d3.svg.arc()
.outerRadius(radius - chartInset + 10)
.innerRadius(radius - chartInset - barWidth - 10)
.startAngle(percToRad(subIndicator))
.endAngle(percToRad(subIndicator));
... and use it to append the new path:
chart.append('path')
.attr('d', arc2)
.style("stroke", "black")
.style("stroke-width", "2px");
Pay attention to the fact that, given the way the author of the original code set their functions, the zero in the gauge is equivalent to 75%. Therefore, you have to set the percentage value accordingly. For instance, positioning the sub value indicator at 55%:
subIndicator = totalPercent + (55 / 200);
Here is a demo (using 55% for the sub value indicator):
(function () {
var Needle, arc, arcEndRad, arcStartRad, barWidth, chart, chartInset, degToRad, el, endPadRad, height, i, margin, needle, numSections, padRad, percToDeg, percToRad, percent, radius, ref, sectionIndx, sectionPerc, startPadRad, svg, totalPercent, width, subIndicator;
percent = .65;
barWidth = 60;
numSections = 3;
// / 2 for HALF circle
sectionPerc = [0.1, 0.25, 0.15];
padRad = 0;
chartInset = 10;
// start at 270deg
totalPercent = .75;
subIndicator = totalPercent + (55/200)
el = d3.select('.chart-gauge');
margin = {
top: 20,
right: 20,
bottom: 30,
left: 20 };
width = el[0][0].offsetWidth - margin.left - margin.right;
height = width;
radius = Math.min(width, height) / 2;
percToDeg = function (perc) {
return perc * 360;
};
percToRad = function (perc) {
return degToRad(percToDeg(perc));
};
degToRad = function (deg) {
return deg * Math.PI / 180;
};
svg = el.append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom);
chart = svg.append('g').attr('transform', `translate(${(width + margin.left) / 2}, ${(height + margin.top) / 2})`);
// build gauge bg
for (sectionIndx = i = 1, ref = numSections; 1 <= ref ? i <= ref : i >= ref; sectionIndx = 1 <= ref ? ++i : --i) {
arcStartRad = percToRad(totalPercent);
arcEndRad = arcStartRad + percToRad(sectionPerc[sectionIndx-1]);
totalPercent += sectionPerc[sectionIndx-1];
startPadRad = 0;
endPadRad = 0;
arc = d3.svg.arc().outerRadius(radius - chartInset).innerRadius(radius - chartInset - barWidth).startAngle(arcStartRad + startPadRad).endAngle(arcEndRad - endPadRad);
chart.append('path').attr('class', `arc chart-color${sectionIndx}`).attr('d', arc);
}
arc2 = d3.svg.arc().outerRadius(radius - chartInset + 10).innerRadius(radius - chartInset - barWidth - 10).startAngle(percToRad(subIndicator)).endAngle(percToRad(subIndicator));
chart.append('path').attr('d', arc2).style("stroke", "black").style("stroke-width", "2px");
Needle = class Needle {
constructor(len, radius1) {
this.len = len;
this.radius = radius1;
}
drawOn(el, perc) {
el.append('circle').attr('class', 'needle-center').attr('cx', 0).attr('cy', 0).attr('r', this.radius);
return el.append('path').attr('class', 'needle').attr('d', this.mkCmd(perc));
}
animateOn(el, perc) {
var self;
self = this;
return el.transition().delay(500).ease('elastic').duration(3000).selectAll('.needle').tween('progress', function () {
return function (percentOfPercent) {
var progress;
progress = percentOfPercent * perc;
return d3.select(this).attr('d', self.mkCmd(progress));
};
});
}
mkCmd(perc) {
var centerX, centerY, leftX, leftY, rightX, rightY, thetaRad, topX, topY;
thetaRad = percToRad(perc / 2); // half circle
centerX = 0;
centerY = 0;
topX = centerX - this.len * Math.cos(thetaRad);
topY = centerY - this.len * Math.sin(thetaRad);
leftX = centerX - this.radius * Math.cos(thetaRad - Math.PI / 2);
leftY = centerY - this.radius * Math.sin(thetaRad - Math.PI / 2);
rightX = centerX - this.radius * Math.cos(thetaRad + Math.PI / 2);
rightY = centerY - this.radius * Math.sin(thetaRad + Math.PI / 2);
return `M ${leftX} ${leftY} L ${topX} ${topY} L ${rightX} ${rightY}`;
}};
needle = new Needle(140, 15);
needle.drawOn(chart, 0);
needle.animateOn(chart, percent);
}).call(this);
//# sourceURL=coffeescript
.chart-gauge {
width: 400px;
margin: 10px auto;
}
.chart-color1 {
fill: #D82724;
}
.chart-color2 {
fill: #FCBF02;
}
.chart-color3 {
fill: #92D14F;
}
.needle,
.needle-center {
fill: #464A4F;
}
.prose {
text-align: center;
font-family: sans-serif;
color: #ababab;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<div class="chart-gauge"></div>
Use the same approach for appending the sub value indicator text.
I'm trying to convert svg path to canvas in javascript, however it's really hard to map svg path elliptical arcs to canvas path. One of the ways is to approximate using multiple bezier curves.
I have successfully implemented the approximation of elliptical arcs with bezier curves however the approximation isn't very accurate.
My code:
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
canvas.width = document.body.clientWidth;
canvas.height = document.body.clientHeight;
ctx.strokeWidth = 2;
ctx.strokeStyle = "#000000";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max)
}
function svgAngle(ux, uy, vx, vy ) {
var dot = ux*vx + uy*vy;
var len = Math.sqrt(ux*ux + uy*uy) * Math.sqrt(vx*vx + vy*vy);
var ang = Math.acos( clamp(dot / len,-1,1) );
if ( (ux*vy - uy*vx) < 0)
ang = -ang;
return ang;
}
function generateBezierPoints(rx, ry, phi, flagA, flagS, x1, y1, x2, y2) {
var rX = Math.abs(rx);
var rY = Math.abs(ry);
var dx2 = (x1 - x2)/2;
var dy2 = (y1 - y2)/2;
var x1p = Math.cos(phi)*dx2 + Math.sin(phi)*dy2;
var y1p = -Math.sin(phi)*dx2 + Math.cos(phi)*dy2;
var rxs = rX * rX;
var rys = rY * rY;
var x1ps = x1p * x1p;
var y1ps = y1p * y1p;
var cr = x1ps/rxs + y1ps/rys;
if (cr > 1) {
var s = Math.sqrt(cr);
rX = s * rX;
rY = s * rY;
rxs = rX * rX;
rys = rY * rY;
}
var dq = (rxs * y1ps + rys * x1ps);
var pq = (rxs*rys - dq) / dq;
var q = Math.sqrt( Math.max(0,pq) );
if (flagA === flagS)
q = -q;
var cxp = q * rX * y1p / rY;
var cyp = - q * rY * x1p / rX;
var cx = Math.cos(phi)*cxp - Math.sin(phi)*cyp + (x1 + x2)/2;
var cy = Math.sin(phi)*cxp + Math.cos(phi)*cyp + (y1 + y2)/2;
var theta = svgAngle( 1,0, (x1p-cxp) / rX, (y1p - cyp)/rY );
var delta = svgAngle(
(x1p - cxp)/rX, (y1p - cyp)/rY,
(-x1p - cxp)/rX, (-y1p-cyp)/rY);
delta = delta - Math.PI * 2 * Math.floor(delta / (Math.PI * 2));
if (!flagS)
delta -= 2 * Math.PI;
var n1 = theta, n2 = delta;
// E(n)
// cx +acosθcosη−bsinθsinη
// cy +asinθcosη+bcosθsinη
function E(n) {
var enx = cx + rx * Math.cos(phi) * Math.cos(n) - ry * Math.sin(phi) * Math.sin(n);
var eny = cy + rx * Math.sin(phi) * Math.cos(n) + ry * Math.cos(phi) * Math.sin(n);
return {x: enx,y: eny};
}
// E'(n)
// −acosθsinη−bsinθcosη
// −asinθsinη+bcosθcosη
function Ed(n) {
var ednx = -1 * rx * Math.cos(phi) * Math.sin(n) - ry * Math.sin(phi) * Math.cos(n);
var edny = -1 * rx * Math.sin(phi) * Math.sin(n) + ry * Math.cos(phi) * Math.cos(n);
return {x: ednx, y: edny};
}
var n = [];
n.push(n1);
var interval = Math.PI/4;
while(n[n.length - 1] + interval < n2)
n.push(n[n.length - 1] + interval)
n.push(n2);
function getCP(n1, n2) {
var en1 = E(n1);
var en2 = E(n2);
var edn1 = Ed(n1);
var edn2 = Ed(n2);
var alpha = Math.sin(n2 - n1) * (Math.sqrt(4 + 3 * Math.pow(Math.tan((n2 - n1)/2), 2)) - 1)/3;
console.log(en1, en2);
return {
cpx1: en1.x + alpha*edn1.x,
cpy1: en1.y + alpha*edn1.y,
cpx2: en2.x - alpha*edn2.x,
cpy2: en2.y - alpha*edn2.y,
en1: en1,
en2: en2
};
}
var cps = []
for(var i = 0; i < n.length - 1; i++) {
cps.push(getCP(n[i],n[i+1]));
}
return cps;
}
// M100,200
ctx.moveTo(100,200)
// a25,100 -30 0,1 50,-25
var rx = 25, ry=100 ,phi = -30 * Math.PI / 180, fa = 0, fs = 1, x = 100, y = 200, x1 = x + 50, y1 = y - 25;
var cps = generateBezierPoints(rx, ry, phi, fa, fs, x, y, x1, y1);
var limit = 4;
for(var i = 0; i < limit && i < cps.length; i++) {
ctx.bezierCurveTo(cps[i].cpx1, cps[i].cpy1,
cps[i].cpx2, cps[i].cpy2,
i < limit - 1 ? cps[i].en2.x : x1, i < limit - 1 ? cps[i].en2.y : y1);
}
ctx.stroke()
With the result:
The red line represents the svg path elliptical arc and the black line represents the approximation
How can I accurately draw any possible elliptical arc on canvas?
Update:
Forgot to mention the original source of the algorithm: https://mortoray.com/2017/02/16/rendering-an-svg-elliptical-arc-as-bezier-curves/
So both bugs are simply:
n2 should be declare n2 = theta + delta;
The E and Ed functions should use rX rY rather than rx ry.
And that fixes everything. Though the original should have obviously opted to divide up the arcs into equal sized portions rather than pi/4 sized elements and then appending the remainder. Just find out how many parts it will need, then divide the range into that many parts of equal size, seems like a much more elegant solution, and because error goes up with length it would also be more accurate.
See: https://jsfiddle.net/Tatarize/4ro0Lm4u/ for working version.
It's not just off in that one respect it doesn't work most anywhere. You can see that depending on phi, it does a lot of variously bad things. It's actually shockingly good there. But, broken everywhere else too.
https://jsfiddle.net/Tatarize/dm7yqypb/
The reason is that the declaration of n2 is wrong and should read:
n2 = theta + delta;
https://jsfiddle.net/Tatarize/ba903pss/
But, fixing the bug in the indexing, it clearly does not scale up there like it should. It might be that arcs within the svg standard are scaled up so that there can certainly be a solution whereas in the relevant code they seem like they are clamped.
https://www.w3.org/TR/SVG/implnote.html#ArcOutOfRangeParameters
"If rx, ry and φ are such that there is no solution (basically, the
ellipse is not big enough to reach from (x1, y1) to (x2, y2)) then the
ellipse is scaled up uniformly until there is exactly one solution
(until the ellipse is just big enough)."
Testing this, since it does properly have code that should scale it up, I changed it green when that code got called. And it turns green when it screws up. So yeah, it's failure to scale for some reason:
https://jsfiddle.net/Tatarize/tptroxho/
Which means something is using rx rather than the scaled rX and it's the E and Ed functions:
var enx = cx + rx * Math.cos(phi) * Math.cos(n) - ry * Math.sin(phi) * Math.sin(n);
These rx references must read rX and rY for ry.
var enx = cx + rX * Math.cos(phi) * Math.cos(n) - rY * Math.sin(phi) * Math.sin(n);
Which finally fixes the last bug, QED.
https://jsfiddle.net/Tatarize/4ro0Lm4u/
I got rid of the canvas, moved everything to svg and animated it.
var svgNS = "http://www.w3.org/2000/svg";
var svg = document.getElementById("svg");
var arcgroup = document.getElementById("arcgroup");
var curvegroup = document.getElementById("curvegroup");
function doArc() {
while (arcgroup.firstChild) {
arcgroup.removeChild(arcgroup.firstChild);
} //clear old svg data. -->
var d = document.createElementNS(svgNS, "path");
//var path = "M100,200 a25,100 -30 0,1 50,-25"
var path = "M" + x + "," + y + "a" + rx + " " + ry + " " + phi + " " + fa + " " + fs + " " + " " + x1 + " " + y1;
d.setAttributeNS(null, "d", path);
arcgroup.appendChild(d);
}
function doCurve() {
var cps = generateBezierPoints(rx, ry, phi * Math.PI / 180, fa, fs, x, y, x + x1, y + y1);
while (curvegroup.firstChild) {
curvegroup.removeChild(curvegroup.firstChild);
} //clear old svg data. -->
var d = document.createElementNS(svgNS, "path");
var limit = 4;
var path = "M" + x + "," + y;
for (var i = 0; i < limit && i < cps.length; i++) {
if (i < limit - 1) {
path += "C" + cps[i].cpx1 + " " + cps[i].cpy1 + " " + cps[i].cpx2 + " " + cps[i].cpy2 + " " + cps[i].en2.x + " " + cps[i].en2.y;
} else {
path += "C" + cps[i].cpx1 + " " + cps[i].cpy1 + " " + cps[i].cpx2 + " " + cps[i].cpy2 + " " + (x + x1) + " " + (y + y1);
}
}
d.setAttributeNS(null, "d", path);
d.setAttributeNS(null, "stroke", "#000");
curvegroup.appendChild(d);
}
setInterval(phiClock, 50);
function phiClock() {
phi += 1;
doCurve();
doArc();
}
doCurve();
doArc();