Make Chart.js Radar labels clickable - javascript

Has anyone managed to make the labels around the Chart.js Radar perimeter clickable?
There doesn't seem to be an immediately obvious solution.

I came up with a solution for this for version 2.8.0 by copying the label position calculations from the RadialLinear scale into an event handler.
document.getElementById("myChart").onclick = function (e) {
var helpers = Chart.helpers;
var scale = myRadarChart.scale;
var opts = scale.options;
var tickOpts = opts.ticks;
// Position of click relative to canvas.
var mouseX = e.offsetX;
var mouseY = e.offsetY;
var labelPadding = 5; // number pixels to expand label bounding box by
// get the label render position
// calcs taken from drawPointLabels() in scale.radialLinear.js
var tickBackdropHeight = (tickOpts.display && opts.display) ?
helpers.valueOrDefault(tickOpts.fontSize, Chart.defaults.global.defaultFontSize)
+ 5: 0;
var outerDistance = scale.getDistanceFromCenterForValue(opts.ticks.reverse ? scale.min : scale.max);
for (var i = 0; i < scale.pointLabels.length; i++) {
// Extra spacing for top value due to axis labels
var extra = (i === 0 ? tickBackdropHeight / 2 : 0);
var pointLabelPosition = scale.getPointPosition(i, outerDistance + extra + 5);
// get label size info.
// TODO fix width=0 calc in Brave?
// https://github.com/brave/brave-browser/issues/1738
var plSize = scale._pointLabelSizes[i];
// get label textAlign info
var angleRadians = scale.getIndexAngle(i);
var angle = helpers.toDegrees(angleRadians);
var textAlign = 'right';
if (angle == 0 || angle == 180) {
textAlign = 'center';
} else if (angle < 180) {
textAlign = 'left';
}
// get label vertical offset info
// also from drawPointLabels() calcs
var verticalTextOffset = 0;
if (angle === 90 || angle === 270) {
verticalTextOffset = plSize.h / 2;
} else if (angle > 270 || angle < 90) {
verticalTextOffset = plSize.h;
}
// Calculate bounding box based on textAlign
var labelTop = pointLabelPosition.y - verticalTextOffset - labelPadding;
var labelHeight = 2*labelPadding + plSize.h;
var labelBottom = labelTop + labelHeight;
var labelWidth = plSize.w + 2*labelPadding;
var labelLeft;
switch (textAlign) {
case 'center':
var labelLeft = pointLabelPosition.x - labelWidth/2;
break;
case 'left':
var labelLeft = pointLabelPosition.x - labelPadding;
break;
case 'right':
var labelLeft = pointLabelPosition.x - labelWidth + labelPadding;
break;
default:
console.log('ERROR: unknown textAlign '+textAlign);
}
var labelRight = labelLeft + labelWidth;
// Render a rectangle for testing purposes
ctx.save();
ctx.strokeStyle = 'red';
ctx.lineWidth = 1;
ctx.strokeRect(labelLeft, labelTop, labelWidth, labelHeight);
ctx.restore();
// compare to the current click
if (mouseX >= labelLeft && mouseX <= labelRight && mouseY <= labelBottom && mouseY >= labelTop) {
alert(scale.pointLabels[i]+' clicked');
// Break loop to prevent multiple clicks, if they overlap we take the first one.
break;
}
}
};
JSFiddle here:
https://jsfiddle.net/simoncoggins/7r08uLk9/
The downside of this approach is it that it will break if the core labelling implementation changes in the future. It would be better if the library separated the calculation of label position from its rendering and started exposing the position info via the API. Then this solution could be greatly simplified and would be more robust to library changes.
I've opened a ticket offering to make that change here:
https://github.com/chartjs/Chart.js/issues/6549
Please comment on that issue if it would be useful to you.

Thanks Pogrindis. Answer here works for Chart.js v2.1: Chart.js click on labels, using bar chart
To make that work for Chart.js v5.0+, add the following function back into the Chart.js code.
LinearRadialScale = Chart.LinearScaleBase.extend({...})
getValueCount: function() {
return this.chart.data.labels.length;
}

Related

How to detect the side on which collision occured

This is my first post so I'm trying to make my problem as clear as possible. I'm making a game and I want to improve my collision detection. This is because I want to check what side is being hit and stop the player from moving past it without using something general like if(collision(player, enemy)) player.x = enemy.x - player.w(width) because if the player were to collide with the top it wouldn't keep the player on top.
In the code it checks if any one of the statements is true and then returns it but it doesn't tell me which statement was the one that was equal to true so I can stop the player from moving accordingly, if that makes sense. If you have a more efficient collision detection for me to use it would be greatly appreciated.
I've already tried to make a position variable to be equal to whatever side gets collided into and then stop the player from moving past it but it only works for the left side and won't let my player jump over the enemy or block.
function collision(object1, object2) {
return !(
object1.x > object2.x + object2.w ||
object1.x + object1.w < object2.x ||
object1.y > object2.y + object2.h ||
object1.y + object1.h < object2.y
)
}
//Only works for the left side
if(collision(player, enemy)) player.x = enemy.x - player.w
I expect it to be able to tell me what side is being collided into and then either stop the player from moving past/into it and for the player to be able to be on top of the block/enemy without just being pushed to the left.
You'll want to calculate the distance between the x's and y's and also use the minimum distance that they could be colliding along each axis to find the depth along both axes. Then you can pick the smaller depth and move along that one. Here's an example:
if(collision(player, enemy)){
// Most of this stuff would probably be good to keep stored inside the player
// along side their x and y position. That way it doesn't have to be recalculated
// every collision check
var playerHalfW = player.w/2
var playerHalfH = player.h/2
var enemyHalfW = enemy.w/2
var enemyHalfH = enemy.h/2
var playerCenterX = player.x + player.w/2
var playerCenterY = player.y + player.h/2
var enemyCenterX = enemy.x + enemy.w/2
var enemyCenterY = enemy.y + enemy.h/2
// Calculate the distance between centers
var diffX = playerCenterX - enemyCenterX
var diffY = playerCenterY - enemyCenterY
// Calculate the minimum distance to separate along X and Y
var minXDist = playerHalfW + enemyHalfW
var minYDist = playerHalfH + enemyHalfH
// Calculate the depth of collision for both the X and Y axis
var depthX = diffX > 0 ? minXDist - diffX : -minXDist - diffX
var depthY = diffY > 0 ? minYDist - diffY : -minYDist - diffY
// Now that you have the depth, you can pick the smaller depth and move
// along that axis.
if(depthX != 0 && depthY != 0){
if(Math.abs(depthX) < Math.abs(depthY)){
// Collision along the X axis. React accordingly
if(depthX > 0){
// Left side collision
}
else{
// Right side collision
}
}
else{
// Collision along the Y axis.
if(depthY > 0){
// Top side collision
}
else{
// Bottom side collision
}
}
}
}
Working example
Here's a working example that you can play around with. Use the arrow keys to move the player around.
player = {
x: 9,
y: 50,
w: 100,
h: 100
}
enemy = {
x: 100,
y: 100,
w: 100,
h: 100
}
output = document.getElementById("collisionType");
canvas = document.getElementById("canvas");
ctx = canvas.getContext("2d")
function collision(object1, object2) {
return !(
object1.x > object2.x + object2.w ||
object1.x + object1.w < object2.x ||
object1.y > object2.y + object2.h ||
object1.y + object1.h < object2.y
)
}
function draw() {
ctx.clearRect(0, 0, 400, 400)
ctx.lineWidth = "5"
ctx.beginPath();
ctx.strokeStyle = "red";
ctx.rect(player.x, player.y, player.w, player.h);
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = "blue";
ctx.rect(enemy.x, enemy.y, enemy.w, enemy.h);
ctx.stroke();
}
function handleCollision() {
if (collision(player, enemy)) {
var playerHalfW = player.w / 2
var playerHalfH = player.h / 2
var enemyHalfW = enemy.w / 2
var enemyHalfH = enemy.h / 2
var playerCenterX = player.x + player.w / 2
var playerCenterY = player.y + player.h / 2
var enemyCenterX = enemy.x + enemy.w / 2
var enemyCenterY = enemy.y + enemy.h / 2
// Calculate the distance between centers
var diffX = playerCenterX - enemyCenterX
var diffY = playerCenterY - enemyCenterY
// Calculate the minimum distance to separate along X and Y
var minXDist = playerHalfW + enemyHalfW
var minYDist = playerHalfH + enemyHalfH
// Calculate the depth of collision for both the X and Y axis
var depthX = diffX > 0 ? minXDist - diffX : -minXDist - diffX
var depthY = diffY > 0 ? minYDist - diffY : -minYDist - diffY
// Now that you have the depth, you can pick the smaller depth and move
// along that axis.
if (depthX != 0 && depthY != 0) {
if (Math.abs(depthX) < Math.abs(depthY)) {
// Collision along the X axis. React accordingly
if (depthX > 0) {
output.innerHTML = "left side collision"
} else {
output.innerHTML = "right side collision"
}
} else {
// Collision along the Y axis.
if (depthY > 0) {
output.innerHTML = "top side collision"
} else {
output.innerHTML = "bottom side collision"
}
}
}
} else {
output.innerHTML = "No collision"
}
}
keyStates = []
function handleKeys() {
if (keyStates[39]) {
player.x += 2 //Move right
} else if (keyStates[37]) {
player.x -= 2 //Move left
}
if (keyStates[38]) {
player.y -= 2 //Move up
}
if (keyStates[40]) {
player.y += 2 //Move down
}
}
function main() {
handleKeys();
draw();
handleCollision();
window.requestAnimationFrame(main);
}
window.onkeydown = function(e) {
keyStates[e.keyCode] = true
}
window.onkeyup = function(e) {
keyStates[e.keyCode] = false
}
main();
<h2 id="collisionType"></h2>
<canvas id="canvas" width='300' height='300'></canvas>
Reacting to the collision
Now that you know the side the collision happened on, it should be fairly trivial to decide how to react. It would be very similar to what you are currently doing for the left side just flip some signs around and change the axis.
Other Considerations
You may want to take into account your player's velocity (if it has one) otherwise the detection may fail.
If the player's velocity is too high, it might 'tunnel' through the enemy and no collision will be detected.
The player's movement can also look jittery if the velocity is not stopped upon collision
Can your objects rotate or have more than 4 sides? If so, you'll probably want to use another method as described below.
Here's a good answer to another post that talks in depth about collision engines
Other Methods
As for other collision detection methods, there's quite a few but one that comes to mind is Separating Axis Theorem which is a little more complex than what you have but will work with more complex convex shapes and rotation. It also tells you the direction and distance needed to move to resolve the collision. Here's a site that has interactive examples and goes in-depth on the subject. It doesn't appear to give a full implementation but those can be found other places.

JS Canvas - Filling a bean-shaped polygon

I have a problem with JS canvas ctx.fill() filling outside of my polygonal shape.
Here's how my code works :
ctx.beginPath()
// Here are for loops that draws a the closed shape using
ctx.stroke();
ctx.fill();
Here are the for loops:
var sx1, sy1, ex1, ey1, sx2, sy2, ex2, ey2;
for(var i = 0; i < n; i += Math.floor(n/steps)){
var radius = Math.exp(-2*i/n)*rmax+rmin;
radius += frequencyData[i]/255*(n-i + 200)/n*50;
var angle = -Math.PI/2 - i/n*2*Math.PI;
var x = radius*Math.cos(angle) + w/2+rmin/2;
var y = radius*Math.sin(angle) + (h-110)/2+rmin/2 + analyser_offset;
if (i == 0) {
gctx.moveTo(x,y);
sx1 = x;
sy1 = y;
}else if (i == n-1){
ex1 = x;
ey1 = y;
}else{
gctx.lineTo(x,y);
}
spd += frequencyData[i];
}
for(var i = 0; i < n; i += Math.floor(n/steps)){
var radius = Math.exp(-2*i/n)*rmax+rmin;
radius -= frequencyData[i]/255*(n-i + 200)/n*50;
var angle = -Math.PI/2 - i/n*2*Math.PI;
var x = radius*Math.cos(angle) + w/2+rmin/2;
var y = radius*Math.sin(angle) + (h-110)/2+rmin/2 + analyser_offset;
if (i == 0) {
gctx.moveTo(x,y);
}else if (i == 20){
sx2 = x;
sy2 = y;
}else if (i == n-1){
ex2 = x;
ey2 = y;
} else {
gctx.lineTo(x,y);
}
}
gctx.moveTo(sx1, sy1);
gctx.lineTo(sx2, sy2);
gctx.moveTo(ex1, ey1);
gctx.lineTo(ex2, ey2);
So the first for loop draws the outter side of the shape, the second for loop draws the inner side. And then the sx1, sy1, ex1, ey1, sx2, sy2, ex2, ey2 variables are here to ensure that in the last 4 lines, it closes the shape (by adding vertical line between the outter and inner lines). Maybe this problem happens because I draw the lines in an unusual order? (like drawing a rectangle by starting with 2 horizontal lines and then adding 2 vertical ones)
Here's what I get after the fill() :
And this is what I would like to have:
So could you guide me on how I'm supposed to achieve this?
Ok I fixed it by making the second loop go in the reverse order like this: for (var i = n-1; i >= 0; i -= Math.floor(n/steps)) so it draws the polygon in a more usual order and that works! I don't even need to close it using the last 4 lines which was what I wanted so that's great!

getting any overlaps even when rotated

I have been learning about konva and html canvas and with the help of the community here I have a canvas with draggable shapes which stay in the bounds of the stage even when rotated.
Also, my konva code is detecting intersections in dragboundfunc and setting strokeEnabled to true if a intersection is detected. I was using my own collision detection code/function until yesterday but because I wasn't getting correct detection results when shapes had been rotated I changed my code to put the shape being dragged in a tempLayer to enable getIntersection to work during dragBoundFunc hoping that would fix it but it didn't.
My problem is I cannot get correct collision detection even with getIntersection rather than my own collision detection code and have spent since last night trying. I can get it detecting collision fine if the shape rotation is 0 using the code below which is my shapes dragBoundFunc.
I know I can't be far off but I'm also at a loss. I also know the part of my code where I set collisionz is probably the wrong way of going about this even though it works for 0 degrees rotation but I have left it in to show what I have tried and that I am trying.
Does anyone know the answer to help me on my way with Konva please?
function theDragFunc(pos) {
var thisRect;
if(parseInt(this.getClientRect().width) != parseInt(this.width())){
if(userRotation == 90 || userRotation == 270)
thisRect = {x: this.x(), y: this.y(), width: this.getClientRect().height, height: this.getClientRect().width};
else
thisRect = {x: this.x(), y: this.y(), width: this.getClientRect().width, height: this.getClientRect().height};
console.log("must have changed userRotation = "+userRotation);
}
else{
thisRect = {x: this.x(), y: this.y(), width: this.width(), height: this.height()};
console.log("must not have changed userRotation = " +userRotation);
}
isCollision = false;
// copy the boundary rect into a testRect which defines the extent of the dragbounds without
// accounting for the width and height of dragging rectangle.
// This is changed below depending on rotation.
var testRect={
left: boundary.x,
top: boundary.y,
right: boundary.x + boundary.width,
bottom: boundary.y + boundary.height
};
// the userRotation value is calculated in the rotation button onclick
// to be one of 0, 90, 180, 270
switch (userRotation){
case 0: // for 0 degrees compute as per a normal bounds rect
testRect.right = testRect.right - thisRect.width;
testRect.bottom = testRect.bottom - thisRect.height;
break;
case 90: // for 90 degs we have to modify the test boundary left and bottom
testRect.left = testRect.left + thisRect.height;
testRect.bottom = testRect.bottom - thisRect.width;
break;
case 180: // for 180 degs we have to modify the test boundary left and top
testRect.left = testRect.left + thisRect.width;
testRect.top = testRect.top + thisRect.height;
break;
case 270: // for 270 degs we have to modify the test boundary right and top
testRect.right = testRect.right - thisRect.height;
testRect.top = testRect.top + thisRect.width;
break;
}
// get new pos as: if pos inside bounday ranges then use it, otherwise user boundary
var newX = (pos.x < testRect.left ? testRect.left : pos.x);
// looking at the far x pos we need to consider width of the dragging rect...
newX = (newX > testRect.right ? testRect.right : newX);
// get new pos as: if pos inside bounday ranges then use it, otherwise user boundary
var newY = (pos.y < testRect.top ? testRect.top : pos.y);
// looking at the far y pos we need to consider height of the dragging rect...
newY = (newY > testRect.bottom ? testRect.bottom : newY);
var collisionz = stage.getIntersection({x:newX+this.getClientRect().width,y:newY}, ".rect");
if(!collisionz)collisionz = stage.getIntersection({x:newX,y:newY}, ".rect");
else if(!collisionz)collisionz = stage.getIntersection({x:newX,y:newY+this.getClientRect().height}, ".rect");
else if(!collisionz)collisionz = stage.getIntersection({x:newX,y:newY+this.getClientRect().height/2}, ".rect");
else if(!collisionz)collisionz = stage.getIntersection({x:newX+this.getClientRect().width,y:newY+this.getClientRect().height/2}, ".rect");
else if(!collisionz)collisionz = stage.getIntersection({x:newX + this.getClientRect().height/2,y:newY + this. getClientRect().height}, ".rect");
if(!collisionz)collisionz = stage.getIntersection({x:newX + this.getClientRect().width,y:newY + this.getClientRect().height}, ".rect");
var search_term = 'funcRect';
if(collisionz && collisionz != this){
console.log("INTERSECTION detected"+collisionz.getName());
isCollision = true;
}
else
isCollision = false;
if(!isCollision && this.getStrokeEnabled())
{
this.setStrokeEnabled(false);
layer.draw();
}
else if(isCollision && !this.getStrokeEnabled())
{
this.setStrokeWidth(2);
this.setStroke('black');
this.setStrokeEnabled(true);
layer.draw();
}
return {
x: newX,
y: newY
}
}

Algorithm - locating enough space to draw a rectangle given the x and y axis of all other rectangles

Every rectangle has x and y coordinates, width and height.
The total width of the screen is maxWidth and total height is maxHeight.
I have an array containing all the already drawn rectangles.
I am working on an web App where users will be drawing rectangles on the screen using their mouse. For that I am using Javascript to draw on the canvas element.
The challenge is that the rectangles must not intersect at any given point.
I am trying to avoid this kind of case:
or this:
This is how the output I am aiming for should look like:
What I basically need is an Algorithm (preferably in JavaScript) that can help locating enough space to draw a rectangle knowing its axis, height and width.
BM67 Box packing.
This is a method I use to pack rectangles. I made it up myself to create sprite sheets.
How it works.
You maintain two arrays, one holds rectangles of available spaces (space array), and the other rectangles you have placed.
You start by adding to the space array a rectangle that covers the whole area to be filled. This rectangle represents available space.
When you add a rectangle to fit you search the available space rectangles for a rectangle that will fit the new rectangle. If you can not find a rectangle that is bigger or sane size as the one you want to add there is no room.
Once you have found a place to put the rectangle, check all the available space rectangles to see if any of them overlap the new added rectangle. If any overlap you slice it up along the top, bottom, left and right, resulting in up to 4 new space rectangles. There are some optimisation when you do this to keep the number of rectangles down but it will work without the optimisations.
It's not that complicated and reasonably efficient compared to some other methods. It is particularly good when the space starts to run low.
Example
Below is a demo of it filling the canvas with random rectangles. It's on a animation loop to show the process, so is very much slowed down.
Gray boxes are the ones to fit. Red show the current spacer boxes. Each box has a 2 pixel margin. See top of code for demo constants.
Click the canvas to restart.
const boxes = []; // added boxes
const spaceBoxes = []; // free space boxes
const space = 2; // space between boxes
const minW = 4; // min width and height of boxes
const minH = 4;
const maxS = 50; // max width and height
// Demo only
const addCount = 2; // number to add per render cycle
const ctx = canvas.getContext("2d");
canvas.width = canvas.height = 1024;
// create a random integer
const randI = (min, max = min + (min = 0)) => (Math.random() * (max - min) + min) | 0;
// itterates an array
const eachOf = (array, cb) => { var i = 0; const len = array.length; while (i < len && cb(array[i], i++, len) !== true ); };
// resets boxes
function start(){
boxes.length = 0;
spaceBoxes.length = 0;
spaceBoxes.push({
x : space, y : space,
w : canvas.width - space * 2,
h : canvas.height - space * 2,
});
}
// creates a random box without a position
function createBox(){
return { w : randI(minW,maxS), h : randI(minH,maxS) }
}
// cuts box to make space for cutter (cutter is a box)
function cutBox(box,cutter){
var b = [];
// cut left
if(cutter.x - box.x - space > minW){
b.push({
x : box.x, y : box.y,
w : cutter.x - box.x - space,
h : box.h,
})
}
// cut top
if(cutter.y - box.y - space > minH){
b.push({
x : box.x, y : box.y,
w : box.w,
h : cutter.y - box.y - space,
})
}
// cut right
if((box.x + box.w) - (cutter.x + cutter.w + space) > space + minW){
b.push({
x : cutter.x + cutter.w + space,
y : box.y,
w : (box.x + box.w) - (cutter.x + cutter.w + space),
h : box.h,
})
}
// cut bottom
if((box.y + box.h) - (cutter.y + cutter.h + space) > space + minH){
b.push({
x : box.x,
y : cutter.y + cutter.h + space,
w : box.w,
h : (box.y + box.h) - (cutter.y + cutter.h + space),
})
}
return b;
}
// get the index of the spacer box that is closest in size to box
function findBestFitBox(box){
var smallest = Infinity;
var boxFound;
eachOf(spaceBoxes,(sbox,index)=>{
if(sbox.w >= box.w && sbox.h >= box.h){
var area = sbox.w * sbox.h;
if(area < smallest){
smallest = area;
boxFound = index;
}
}
})
return boxFound;
}
// returns an array of boxes that are touching box
// removes the boxes from the spacer array
function getTouching(box){
var b = [];
for(var i = 0; i < spaceBoxes.length; i++){
var sbox = spaceBoxes[i];
if(!(sbox.x > box.x + box.w + space || sbox.x + sbox.w < box.x - space ||
sbox.y > box.y + box.h + space || sbox.y + sbox.h < box.y - space )){
b.push(spaceBoxes.splice(i--,1)[0])
}
}
return b;
}
// Adds a space box to the spacer array.
// Check if it is insid, too small, or can be joined to another befor adding.
// will not add if not needed.
function addSpacerBox(box){
var dontAdd = false;
// is to small?
if(box.w < minW || box.h < minH){ return }
// is same or inside another
eachOf(spaceBoxes,sbox=>{
if(box.x >= sbox.x && box.x + box.w <= sbox.x + sbox.w &&
box.y >= sbox.y && box.y + box.h <= sbox.y + sbox.h ){
dontAdd = true;
return true;
}
})
if(!dontAdd){
var join = false;
// check if it can be joinded with another
eachOf(spaceBoxes,sbox=>{
if(box.x === sbox.x && box.w === sbox.w &&
!(box.y > sbox.y + sbox.h || box.y + box.h < sbox.y)){
join = true;
var y = Math.min(sbox.y,box.y);
var h = Math.max(sbox.y + sbox.h,box.y + box.h);
sbox.y = y;
sbox.h = h-y;
return true;
}
if(box.y === sbox.y && box.h === sbox.h &&
!(box.x > sbox.x + sbox.w || box.x + box.w < sbox.x)){
join = true;
var x = Math.min(sbox.x,box.x);
var w = Math.max(sbox.x + sbox.w,box.x + box.w);
sbox.x = x;
sbox.w = w-x;
return true;
}
})
if(!join){ spaceBoxes.push(box) }// add to spacer array
}
}
// Adds a box by finding a space to fit.
function locateSpace(box){
if(boxes.length === 0){ // first box can go in top left
box.x = space;
box.y = space;
boxes.push(box);
var sb = spaceBoxes.pop();
spaceBoxes.push(...cutBox(sb,box));
}else{
var bf = findBestFitBox(box); // get the best fit space
if(bf !== undefined){
var sb = spaceBoxes.splice(bf,1)[0]; // remove the best fit spacer
box.x = sb.x; // use it to position the box
box.y = sb.y;
spaceBoxes.push(...cutBox(sb,box)); // slice the spacer box and add slices back to spacer array
boxes.push(box); // add the box
var tb = getTouching(box); // find all touching spacer boxes
while(tb.length > 0){ // and slice them if needed
eachOf(cutBox(tb.pop(),box),b => addSpacerBox(b));
}
}
}
}
// draws a box array
function drawBoxes(list,col,col1){
eachOf(list,box=>{
if(col1){
ctx.fillStyle = col1;
ctx.fillRect(box.x+ 1,box.y+1,box.w-2,box.h - 2);
}
ctx.fillStyle = col;
ctx.fillRect(box.x,box.y,box.w,1);
ctx.fillRect(box.x,box.y,1,box.h);
ctx.fillRect(box.x+box.w-1,box.y,1,box.h);
ctx.fillRect(box.x,box.y+ box.h-1,box.w,1);
})
}
// Show the process in action
ctx.clearRect(0,0,canvas.width,canvas.height);
var count = 0;
var handle = setTimeout(doIt,10);
start()
function doIt(){
ctx.clearRect(0,0,canvas.width,canvas.height);
for(var i = 0; i < addCount; i++){
var box = createBox();
locateSpace(box);
}
drawBoxes(boxes,"black","#CCC");
drawBoxes(spaceBoxes,"red");
if(count < 1214 && spaceBoxes.length > 0){
count += 1;
handle = setTimeout(doIt,10);
}
}
canvas.onclick = function(){
clearTimeout(handle);
start();
handle = setTimeout(doIt,10);
count = 0;
}
canvas { border : 2px solid black; }
<canvas id="canvas"></canvas>
Update
Improving on the above algorithm.
Turned algorithm into an object
Improved speed by finding better fitting spacer via weighting the fit on the aspect ratio
Added placeBox(box) function that adds a box without checking if it fits. It will be placed at its box.x, box.y coordinates
See code example below on usage.
Example
The example is the same as the above example but have added randomly place boxes before fitting boxes.
Demo displays the boxes and spacer boxes as it goes to show how it works. Click the canvas to restart. Hold [shift] key and click canvas to restart without displaying intermediate results.
Pre placed boxes are blue.
Fitted boxes are gray.
Spacing boxes are red and will overlap.
When holding shift the fitting process is stopped at the first box tat does not fit. The red boxes will show area that are available but unused.
When showing progress the function will keep adding boxes ignoring non fitting boxes until out of room.
const minW = 4; // min width and height of boxes
const minH = 4;
const maxS = 50; // max width and height
const space = 2;
const numberBoxesToPlace = 20; // number of boxes to place befor fitting
const fixedBoxColor = "blue";
// Demo only
const addCount = 2; // number to add per render cycle
const ctx = canvas.getContext("2d");
canvas.width = canvas.height = 1024;
// create a random integer randI(n) return random val 0-n randI(n,m) returns random int n-m, and iterator that can break
const randI = (min, max = min + (min = 0)) => (Math.random() * (max - min) + min) | 0;
const eachOf = (array, cb) => { var i = 0; const len = array.length; while (i < len && cb(array[i], i++, len) !== true ); };
// creates a random box. If place is true the box also gets a x,y position and is flaged as fixed
function createBox(place){
if(place){
const box = {
w : randI(minW*4,maxS*4),
h : randI(minH*4,maxS*4),
fixed : true,
}
box.x = randI(space, canvas.width - box.w - space * 2);
box.y = randI(space, canvas.height - box.h - space * 2);
return box;
}
return {
w : randI(minW,maxS),
h : randI(minH,maxS),
}
}
//======================================================================
// BoxArea object using BM67 box packing algorithum
// https://stackoverflow.com/questions/45681299/algorithm-locating-enough-space-to-draw-a-rectangle-given-the-x-and-y-axis-of
// Please leave this and the above two lines with any copies of this code.
//======================================================================
//
// usage
// var area = new BoxArea({
// x: ?, // x,y,width height of area
// y: ?,
// width: ?,
// height : ?.
// space : ?, // optional default = 1 sets the spacing between boxes
// minW : ?, // optional default = 0 sets the in width of expected box. Note this is for optimisation you can add smaller but it may fail
// minH : ?, // optional default = 0 sets the in height of expected box. Note this is for optimisation you can add smaller but it may fail
// });
//
// Add a box at a location. Not checked for fit or overlap
// area.placeBox({x : 100, y : 100, w ; 100, h :100});
//
// Tries to fit a box. If the box does not fit returns false
// if(area.fitBox({x : 100, y : 100, w ; 100, h :100})){ // box added
//
// Resets the BoxArea removing all boxes
// area.reset()
//
// To check if the area is full
// area.isFull(); // returns true if there is no room of any more boxes.
//
// You can check if a box can fit at a specific location with
// area.isBoxTouching({x : 100, y : 100, w ; 100, h :100}, area.boxes)){ // box is touching another box
//
// To get a list of spacer boxes. Note this is a copy of the array, changing it will not effect the functionality of BoxArea
// const spacerArray = area.getSpacers();
//
// Use it to get the max min box size that will fit
//
// const maxWidthThatFits = spacerArray.sort((a,b) => b.w - a.w)[0];
// const minHeightThatFits = spacerArray.sort((a,b) => a.h - b.h)[0];
// const minAreaThatFits = spacerArray.sort((a,b) => (a.w * a.h) - (b.w * b.h))[0];
//
// The following properties are available
// area.boxes // an array of boxes that have been added
// x,y,width,height // the area that boxes are fitted to
const BoxArea = (()=>{
const defaultSettings = {
minW : 0, // min expected size of a box
minH : 0,
space : 1, // spacing between boxes
};
const eachOf = (array, cb) => { var i = 0; const len = array.length; while (i < len && cb(array[i], i++, len) !== true ); };
function BoxArea(settings){
settings = Object.assign({},defaultSettings,settings);
this.width = settings.width;
this.height = settings.height;
this.x = settings.x;
this.y = settings.y;
const space = settings.space;
const minW = settings.minW;
const minH = settings.minH;
const boxes = []; // added boxes
const spaceBoxes = [];
this.boxes = boxes;
// cuts box to make space for cutter (cutter is a box)
function cutBox(box,cutter){
var b = [];
// cut left
if(cutter.x - box.x - space >= minW){
b.push({
x : box.x, y : box.y, h : box.h,
w : cutter.x - box.x - space,
});
}
// cut top
if(cutter.y - box.y - space >= minH){
b.push({
x : box.x, y : box.y, w : box.w,
h : cutter.y - box.y - space,
});
}
// cut right
if((box.x + box.w) - (cutter.x + cutter.w + space) >= space + minW){
b.push({
y : box.y, h : box.h,
x : cutter.x + cutter.w + space,
w : (box.x + box.w) - (cutter.x + cutter.w + space),
});
}
// cut bottom
if((box.y + box.h) - (cutter.y + cutter.h + space) >= space + minH){
b.push({
w : box.w, x : box.x,
y : cutter.y + cutter.h + space,
h : (box.y + box.h) - (cutter.y + cutter.h + space),
});
}
return b;
}
// get the index of the spacer box that is closest in size and aspect to box
function findBestFitBox(box, array = spaceBoxes){
var smallest = Infinity;
var boxFound;
var aspect = box.w / box.h;
eachOf(array, (sbox, index) => {
if(sbox.w >= box.w && sbox.h >= box.h){
var area = ( sbox.w * sbox.h) * (1 + Math.abs(aspect - (sbox.w / sbox.h)));
if(area < smallest){
smallest = area;
boxFound = index;
}
}
})
return boxFound;
}
// Exposed helper function
// returns true if box is touching any boxes in array
// else return false
this.isBoxTouching = function(box, array = []){
for(var i = 0; i < array.length; i++){
var sbox = array[i];
if(!(sbox.x > box.x + box.w + space || sbox.x + sbox.w < box.x - space ||
sbox.y > box.y + box.h + space || sbox.y + sbox.h < box.y - space )){
return true;
}
}
return false;
}
// returns an array of boxes that are touching box
// removes the boxes from the array
function getTouching(box, array = spaceBoxes){
var boxes = [];
for(var i = 0; i < array.length; i++){
var sbox = array[i];
if(!(sbox.x > box.x + box.w + space || sbox.x + sbox.w < box.x - space ||
sbox.y > box.y + box.h + space || sbox.y + sbox.h < box.y - space )){
boxes.push(array.splice(i--,1)[0])
}
}
return boxes;
}
// Adds a space box to the spacer array.
// Check if it is inside, too small, or can be joined to another befor adding.
// will not add if not needed.
function addSpacerBox(box, array = spaceBoxes){
var dontAdd = false;
// is box to0 small?
if(box.w < minW || box.h < minH){ return }
// is box same or inside another box
eachOf(array, sbox => {
if(box.x >= sbox.x && box.x + box.w <= sbox.x + sbox.w &&
box.y >= sbox.y && box.y + box.h <= sbox.y + sbox.h ){
dontAdd = true;
return true; // exits eachOf (like a break statement);
}
})
if(!dontAdd){
var join = false;
// check if it can be joined with another
eachOf(array, sbox => {
if(box.x === sbox.x && box.w === sbox.w &&
!(box.y > sbox.y + sbox.h || box.y + box.h < sbox.y)){
join = true;
var y = Math.min(sbox.y,box.y);
var h = Math.max(sbox.y + sbox.h,box.y + box.h);
sbox.y = y;
sbox.h = h-y;
return true; // exits eachOf (like a break statement);
}
if(box.y === sbox.y && box.h === sbox.h &&
!(box.x > sbox.x + sbox.w || box.x + box.w < sbox.x)){
join = true;
var x = Math.min(sbox.x,box.x);
var w = Math.max(sbox.x + sbox.w,box.x + box.w);
sbox.x = x;
sbox.w = w-x;
return true; // exits eachOf (like a break statement);
}
})
if(!join){ array.push(box) }// add to spacer array
}
}
// Adds a box by finding a space to fit.
// returns true if the box has been added
// returns false if there was no room.
this.fitBox = function(box){
if(boxes.length === 0){ // first box can go in top left
box.x = space;
box.y = space;
boxes.push(box);
var sb = spaceBoxes.pop();
spaceBoxes.push(...cutBox(sb,box));
}else{
var bf = findBestFitBox(box); // get the best fit space
if(bf !== undefined){
var sb = spaceBoxes.splice(bf,1)[0]; // remove the best fit spacer
box.x = sb.x; // use it to position the box
box.y = sb.y;
spaceBoxes.push(...cutBox(sb,box)); // slice the spacer box and add slices back to spacer array
boxes.push(box); // add the box
var tb = getTouching(box); // find all touching spacer boxes
while(tb.length > 0){ // and slice them if needed
eachOf(cutBox(tb.pop(),box),b => addSpacerBox(b));
}
} else {
return false;
}
}
return true;
}
// Adds a box at location box.x, box.y
// does not check if it can fit or for overlap.
this.placeBox = function(box){
boxes.push(box); // add the box
var tb = getTouching(box); // find all touching spacer boxes
while(tb.length > 0){ // and slice them if needed
eachOf(cutBox(tb.pop(),box),b => addSpacerBox(b));
}
}
// returns a copy of the spacer array
this.getSpacers = function(){
return [...spaceBoxes];
}
this.isFull = function(){
return spaceBoxes.length === 0;
}
// resets boxes
this.reset = function(){
boxes.length = 0;
spaceBoxes.length = 0;
spaceBoxes.push({
x : this.x + space, y : this.y + space,
w : this.width - space * 2,
h : this.height - space * 2,
});
}
this.reset();
}
return BoxArea;
})();
// draws a box array
function drawBoxes(list,col,col1){
eachOf(list,box=>{
if(col1){
ctx.fillStyle = box.fixed ? fixedBoxColor : col1;
ctx.fillRect(box.x+ 1,box.y+1,box.w-2,box.h - 2);
}
ctx.fillStyle = col;
ctx.fillRect(box.x,box.y,box.w,1);
ctx.fillRect(box.x,box.y,1,box.h);
ctx.fillRect(box.x+box.w-1,box.y,1,box.h);
ctx.fillRect(box.x,box.y+ box.h-1,box.w,1);
})
}
// Show the process in action
ctx.clearRect(0,0,canvas.width,canvas.height);
var count = 0;
var failedCount = 0;
var timeoutHandle;
var addQuick = false;
// create a new box area
const area = new BoxArea({x : 0, y : 0, width : canvas.width, height : canvas.height, space : space, minW : minW, minH : minH});
// fit boxes until a box cant fit or count over count limit
function doIt(){
ctx.clearRect(0,0,canvas.width,canvas.height);
if(addQuick){
while(area.fitBox(createBox()));
count = 2000;
}else{
for(var i = 0; i < addCount; i++){
if(!area.fitBox(createBox())){
failedCount += 1;
break;
}
}
}
drawBoxes(area.boxes,"black","#CCC");
drawBoxes(area.getSpacers(),"red");
if(count < 5214 && !area.isFull()){
count += 1;
timeoutHandle = setTimeout(doIt,10);
}
}
// resets the area places some fixed boxes and starts the fitting cycle.
function start(event){
clearTimeout(timeoutHandle);
area.reset();
failedCount = 0;
for(var i = 0; i < numberBoxesToPlace; i++){
var box = createBox(true); // create a fixed box
if(!area.isBoxTouching(box,area.boxes)){
area.placeBox(box);
}
}
if(event && event.shiftKey){
addQuick = true;
}else{
addQuick = false;
}
timeoutHandle = setTimeout(doIt,10);
count = 0;
}
canvas.onclick = start;
start();
body {font-family : arial;}
canvas { border : 2px solid black; }
.info {position: absolute; z-index : 200; top : 16px; left : 16px; background : rgba(255,255,255,0.75);}
<div class="info">Click canvas to reset. Shift click to add without showing progress.</div>
<canvas id="canvas"></canvas>
Try the following:
iterate through the existing rectangles from top to bottom, based on the top boundary of each existing rectangle
while proceeding in top-to-bottom order, maintain a list of "active rectangles":
adding each succeeding rectangle based on its top boundary as an active rectangle, and
removing active rectangles based on their bottom boundary
(you can do this efficiently by using a priority queue)
also keep track of the gaps between active rectangles:
adding an active rectangle will end all gaps that overlap it, and (assuming it doesn't overlap any existing rectangles) start a new gap on each side
removing an active rectangle will add a new gap (without ending any)
note that multiple active gaps may overlap each other -- you can't count on having exactly one gap between active rectangles!
Check your new rectangle (the one you want to place) against all gaps. Each gap is itself a rectangle; you can place your new rectangle if it fits entirely inside some gap.
This kind of method is called a sweep-line algorithm.
You may have to check whether your current point is inside the area of any of the current rectangles. You can use the following code to test that (stolen from here)
In the array you are having, store the rectangle details in the following way
var point = {x: 1, y: 2};
var rectangle = {x1: 0, x2: 10, y1: 1, y2: 7};
Following will be your function to test whether any given point is inside any given rectangle.
function isPointInsideRectangle(p, r) {
return
p.x > r.x1 &&
p.x < r.x2 &&
p.y > r.y1 &&
p.y < r.y2;
}
I am not sure how you are going to implement this -
On mouse down
Always during drawing (This may be too much of a work).
On mouse up (this will be my preference. You can cancel the drawing if the test did not pass, with possible explanation for the user somewhere in the canvas)
Hope this will get you starting.

Algorithm to find space for an object within a 2d area

I'm building a website which uses jQuery to allow users to add widgets to a page, drag them around and resize them (the page is fixed width and infinite height.) The issue that I'm having is that when adding a new widget to the page I have to find a free space for it (the widgets cannot overlap and I'd like to favour spaces at the top of the page.)
I've been looking at various packing algorithms and none of them seem to be suitable. The reason why is that they are designed for packing all of the objects in to the container, this means that all of the previous rectangles are laid out in a uniform way. They often line up an edge of the rectangle so that they form rows/columns, this simplifies working out what will fit where in the next row/column. When the user can move/resize widgets at will these algorithms don't work well.
I thought that I had a partial solution but after writing some pseudo code in here I’ve realized that it won’t work. A brute force based approach would work, but I'd prefer something more efficient if possible. Can anyone suggest a suitable algorithm? Is it a packing algorithm that I'm looking for or would something else work better?
Thanks
Ok, I've worked out a solution. I didn't like the idea of a brute force based approach because I thought it would be inefficient, what I realized though is if you can look at which existing widgets are in the way of placing the widget then you can skip large portions of the grid.
Here is an example: (the widget being placed is 20x20 and page width is 100px in this example.)
This diagram is 0.1 scale and got messed up so I've had to add an extra column
*123456789A*
1+---+ +--+1
2| | | |2
3| | +--+3
4| | 4
5+---+ 5
*123456789A*
We attempt to place a widget at 0x0 but it doesn't fit because there is a 50x50 widget at that coordinate.
So we then advance the current x coordinate being scanned to 51 and check again.
We then find a 40x30 widget at 0x61.
So we then advance the x coordinate to 90 but this doesn't leave enough room for the widget being placed so we increment the y coordinate and reset x back to 0.
We know from the previous attempts that the widgets on the previous line are at least 30px high so we increase the y coordinate to 31.
We encounter the same 50x50 widget at 0x31.
So we increase x to 51 and find that we can place a widget at 51x31
Here is the javascript:
function findSpace(width, height) {
var $ul = $('.snap-layout>ul');
var widthOfContainer = $ul.width();
var heightOfContainer = $ul.height();
var $lis = $ul.children('.setup-widget'); // The li is on the page and we dont want it to collide with itself
for (var y = 0; y < heightOfContainer - height + 1; y++) {
var heightOfShortestInRow = 1;
for (var x = 0; x < widthOfContainer - width + 1; x++) {
console.log(x + '/' + y);
var pos = { 'left': x, 'top': y };
var $collider = $(isOverlapping($lis, pos, width, height));
if ($collider.length == 0) {
// Found a space
return pos;
}
var colliderPos = $collider.position();
// We have collided with something, there is no point testing the points within this widget so lets skip them
var newX = colliderPos.left + $collider.width() - 1; // -1 to account for the ++ in the for loop
x = newX > x ? newX : x; // Make sure that we are not some how going backwards and looping forever
var colliderBottom = colliderPos.top + $collider.height();
if (heightOfShortestInRow == 1 || colliderBottom - y < heightOfShortestInRow) {
heightOfShortestInRow = colliderBottom - y; // This isn't actually the height its just the distance from y to the bottom of the widget, y is normally at the top of the widget tho
}
}
y += heightOfShortestInRow - 1;
}
//TODO: Add the widget to the bottom
}
Here is the longer and more less elegant version that also adjusts the height of the container (I've just hacked it together for now but will clean it up later and edit)
function findSpace(width, height,
yStart, avoidIds // These are used if the function calls itself - see bellow
) {
var $ul = $('.snap-layout>ul');
var widthOfContainer = $ul.width();
var heightOfContainer = $ul.height();
var $lis = $ul.children('.setup-widget'); // The li is on the page and we dont want it to collide with itself
var bottomOfShortestInRow;
var idOfShortestInRow;
for (var y = yStart ? yStart : 0; y <= heightOfContainer - height + 1; y++) {
var heightOfShortestInRow = 1;
for (var x = 0; x <= widthOfContainer - width + 1; x++) {
console.log(x + '/' + y);
var pos = { 'left': x, 'top': y };
var $collider = $(isOverlapping($lis, pos, width, height));
if ($collider.length == 0) {
// Found a space
return pos;
}
var colliderPos = $collider.position();
// We have collided with something, there is no point testing the points within this widget so lets skip them
var newX = colliderPos.left + $collider.width() - 1; // -1 to account for the ++ in the for loop
x = newX > x ? newX : x; // Make sure that we are not some how going backwards and looping forever
colliderBottom = colliderPos.top + $collider.height();
if (heightOfShortestInRow == 1 || colliderBottom - y < heightOfShortestInRow) {
heightOfShortestInRow = colliderBottom - y; // This isn't actually the height its just the distance from y to the bottom of the widget, y is normally at the top of the widget tho
var widgetId = $collider.attr('data-widget-id');
if (!avoidIds || !$.inArray(widgetId, avoidIds)) { // If this is true then we are calling ourselves and we used this as the shortest widget before and it didnt work
bottomOfShortestInRow = colliderBottom;
idOfShortestInRow = widgetId;
}
}
}
y += heightOfShortestInRow - 1;
}
if (!yStart) {
// No space was found so create some
var idsToAvoid = [];
for (var attempts = 0; attempts < widthOfContainer; attempts++) { // As a worse case scenario we have lots of 1px wide colliders
idsToAvoid.push(idOfShortestInRow);
heightOfContainer = $ul.height();
var maxAvailableRoom = heightOfContainer - bottomOfShortestInRow;
var extraHeightRequired = height - maxAvailableRoom;
if (extraHeightRequired < 0) { extraHeightRequired = 0;}
$ul.height(heightOfContainer + extraHeightRequired);
var result = findSpace(width, height, bottomOfShortestInRow, idsToAvoid);
if (result.top) {
// Found a space
return result;
}
// Got a different collider so lets try that next time
bottomOfShortestInRow = result.bottom;
idOfShortestInRow = result.id;
if (!bottomOfShortestInRow) {
// If this is undefined then its broken (because the widgets are bigger then their contianer which is hardcoded atm and resets on f5)
break;
}
}
debugger;
// Something has gone wrong so we just stick it on the bottom left
$ul.height($ul.height() + height);
return { 'left': 0, 'top': $ul.height() - height };
} else {
// The function is calling itself and we shouldnt recurse any further, just return the data required to continue searching
return { 'bottom': bottomOfShortestInRow, 'id': idOfShortestInRow };
}
}
function isOverlapping($obsticles, tAxis, width, height) {
var t_x, t_y;
if (typeof (width) == 'undefined') {
// Existing element passed in
var $target = $(tAxis);
tAxis = $target.position();
t_x = [tAxis.left, tAxis.left + $target.outerWidth()];
t_y = [tAxis.top, tAxis.top + $target.outerHeight()];
} else {
// Coordinates and dimensions passed in
t_x = [tAxis.left, tAxis.left + width];
t_y = [tAxis.top, tAxis.top + height];
}
var overlap = false;
$obsticles.each(function () {
var $this = $(this);
var thisPos = $this.position();
var i_x = [thisPos.left, thisPos.left + $this.outerWidth()]
var i_y = [thisPos.top, thisPos.top + $this.outerHeight()];
if (t_x[0] < i_x[1] && t_x[1] > i_x[0] &&
t_y[0] < i_y[1] && t_y[1] > i_y[0]) {
overlap = this;
return false;
}
});
return overlap;
}

Categories