Published
Edited
May 1, 2021
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
class Simulation {
constructor({ width, height, ctx, balls, bars }) {
this.width = width || 800;
this.height = height || 800;
this.ctx = ctx || null;
this.balls = balls || [];
this.bars = bars || [];
this.maxRadius = d3.max(balls, (a) => a.radius);
this.center = [this.width / 2, this.height / 2];
this.quadtree = null;
}

clearCanvas() {
this.ctx.fillStyle = "#ffffff";
this.ctx.clearRect(0, 0, width, height);
}

tick() {
this.clearCanvas();

this.quadtree = d3
.quadtree()
.x((d) => d.x)
.y((d) => d.y)
.extent([-1, -1], [this.width + 1, this.height + 1])
.addAll(this.balls);

this.balls.forEach((ball) => {
this.detectBallBarCollision(ball);
this.detectBallWallCollision(ball);
this.fastDetectBallCollision(ball);

ball.tick();
ball.drawCircle();
});

this.bars = this.bars.filter((bar) => bar.remove === false);

this.bars.forEach((bar) => {
this.detectBarWallCollision(bar);
this.detectBarToBarCollision(bar);

if (bar.active) {
bar.tick();
}

bar.drawRect();
});
}

fastDetectBallCollision(ball) {
const r = ball.radius + this.maxRadius;
const nx1 = ball.x - r;
const nx2 = ball.x + r;
const ny1 = ball.y - r;
const ny2 = ball.y + r;

this.quadtree.visit((visited, x1, y1, x2, y2) => {
if (visited.data && visited.data.id !== ball.id) {
// Collision
if (
geometric.lineLength([ball.pos, visited.data.pos]) <
ball.radius + visited.data.radius
) {
const keep = geometric.lineLength([
geometric.pointTranslate(ball.pos, ball.angle, ball.speed),
geometric.pointTranslate(
visited.data.pos,
visited.data.angle,
visited.data.speed
)
]);

const swap = geometric.lineLength([
geometric.pointTranslate(
ball.pos,
visited.data.angle,
visited.data.speed
),
geometric.pointTranslate(visited.data.pos, ball.angle, ball.speed)
]);

if (keep < swap) {
elasticCollision(ball, visited.data, false);
}
}
}

return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
});
}

detectBallWallCollision(ball) {
// Detect sides
const wallVertical =
ball.x <= ball.radius || ball.x >= this.width - ball.radius;
const wallHorizontal =
ball.y <= ball.radius || ball.y >= this.height - ball.radius;

if (wallVertical || wallHorizontal) {
const t0 = geometric.pointTranslate(ball.pos, ball.angle, ball.speed);
const l0 = geometric.lineLength([this.center, t0]);

const reflected = geometric.angleReflect(
ball.angle,
wallVertical ? 90 : 0
);
const t1 = geometric.pointTranslate(ball.pos, reflected, ball.speed);
const l1 = geometric.lineLength([this.center, t1]);

if (l1 < l0) {
ball.angle = reflected;
}
}
}

detectBarWallCollision(bar) {
// Determine wall collision
const wallDistance =
bar.direction === "up"
? bar.originY + bar.height
: bar.direction === "down"
? height - (bar.originY + bar.height)
: bar.direction === "left"
? bar.originX + bar.width
: this.width - (bar.originX + bar.width);

if (wallDistance <= 0) {
bar.active = false;
bar.color = lightGray;
bar.drawRect();
bar.multiplier = 0;
}
}

detectBallBarCollision(ball) {
const ballLeft = ball.x - ball.radius;
const ballRight = ball.x + ball.radius;
const ballTop = ball.y + ball.radius;
const ballBottom = ball.y - ball.radius;

this.bars.forEach((bar) => {
const verticalCollision =
((ballRight >= bar.right && ballLeft <= bar.right) ||
(ballLeft <= bar.left && ballRight >= bar.left)) &&
Math.round(ball.y) <= bar.bottom && Math.round(ball.y) >= bar.top;

const horizontalCollision =
((ballTop >= bar.top && ballBottom <= bar.top) ||
(ballBottom <= bar.bottom && ballTop >= bar.bottom)) &&
Math.round(ball.x) <= bar.right && Math.round(ball.x) >= bar.left;

if (verticalCollision || horizontalCollision) {
if (bar.active) {
bar.remove = true;
} else {
const reflected = geometric.angleReflect(
ball.angle,
verticalCollision ? 90 : 0
);

const t0 = geometric.pointTranslate(ball.pos, ball.angle, ball.speed);
const t1 = geometric.pointTranslate(ball.pos, reflected, ball.speed);

const barTargetCoordinates = verticalCollision
? [(bar.left + bar.right) / 2, ball.y]
: [ball.x, (bar.top + bar.bottom) / 2];

const l0 = geometric.lineLength([barTargetCoordinates, t0]);
const l1 = geometric.lineLength([barTargetCoordinates, t1]);

if (l1 > l0) {
ball.angle = reflected;
}
}
}
});
}

detectBarToBarCollision(bar) {
const possibleBars = this.bars.filter((candidateBar) => {
if (
candidateBar.originX === bar.originX &&
candidateBar.originY === bar.originY
) {
return false;
}

if (bar.direction === "left") {
return candidateBar.originX < bar.originX;
} else if (bar.direction === "right") {
return candidateBar.originX > bar.originX;
} else if (bar.direction === "up") {
return candidateBar.originY < bar.originY;
} else {
return candidateBar.originY > bar.originY;
}
});

possibleBars.forEach((collisionBar) => {
if (
(bar.direction === "left" &&
bar.left <= collisionBar.right &&
bar.top <= collisionBar.bottom &&
bar.bottom >= collisionBar.top) ||
(bar.direction === "right" &&
bar.right >= collisionBar.left - 1 &&
bar.top <= collisionBar.bottom &&
bar.bottom >= collisionBar.top) ||
(bar.direction === "up" &&
bar.top <= collisionBar.bottom &&
bar.left <= collisionBar.right &&
bar.right >= collisionBar.left) ||
(bar.direction === "down" &&
bar.bottom >= collisionBar.top - 1 &&
bar.left <= collisionBar.right &&
bar.right >= collisionBar.left)
) {
bar.active = false;
bar.color = lightGray;
}
});
}
}
Insert cell
Insert cell
class Bar {
constructor({ id, originX, originY, direction, ctx }) {
this.id = id;

this.direction = direction;
this.color = direction === "up" || direction === "left" ? "red" : "blue";
this.orientation = (direction === "up" || direction === "down") ? "vertical" : "horizontal";
this.multiplier = (this.direction === "up" || this.direction === "left") ? -1 : 1;
this.originX = direction === "left" ? roundToGrid(originX) - 1 : roundToGrid(originX);
this.originY = direction === "up" ? roundToGrid(originY) - 1 : roundToGrid(originY);
this.height = this.orientation == "vertical" ? 0 : (gridSize - 1);
this.width = this.orientation === "horizontal" ? 0 : (gridSize - 1);
this.ctx = ctx || null;
this.growthSpeed = 2;
this.active = true;
this.remove = false;
this.left = Math.min((this.originX + this.width), this.originX);
this.right = Math.max((this.originX + this.width), this.originX);
this.top = Math.min((this.originY + this.height), this.originY);
this.bottom = Math.max((this.originY + this.height), this.originY);
}

drawRect() {
this.ctx.beginPath();
this.ctx.rect(this.originX, this.originY, this.width, this.height);
this.ctx.fillStyle = this.color;
this.ctx.strokeStyle = this.color;
this.ctx.fill();
this.ctx.stroke();
}

tick() {
if (this.orientation === "vertical") {
this.height += this.multiplier*this.growthSpeed;
}
else {
this.width += this.multiplier*this.growthSpeed;
}
this.left = Math.min((this.originX + this.width), this.originX);
this.right = Math.max((this.originX + this.width), this.originX);
this.top = Math.min((this.originY + this.height), this.originY);
this.bottom = Math.max((this.originY + this.height), this.originY);
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more