heyho123
heyho123

Reputation: 129

Merge shapes that share the same border in CSS or JS

I am looking for a simple way to combine shapes via CSS or JS to achieve something like below:

What I am trying to achieve

At first I was trying to just overlay the basic shapes using before and after. But by doing so I can not create a border around the whole merged shape. So next I tried a solution using html canvas like:

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

ctx.lineWidth = 10;
ctx.strokeRect(30,20,150,250);
ctx.strokeRect(120,160,120,50);

ctx.globalCompositeOperation='destination-out';

//Filling the rects only keeps the border
ctx.fillStyle = "#FF0000";
ctx.fillRect(30,20,150,250);
ctx.fillRect(120,160,120,50); 
body{ background-color: green; }
<canvas id="canvas" width=300 height=300></canvas>

But by doing so I wasn't able to keep the custom white fill of the combined shape. So I am now asking for a way to either optimize my current approach to be able to set a custom background for my shape or a completely new approach.

As I need to get the onMouseEnter event I didn't found using a svg-file suitable for my situation.

If there is any framework that would make this process easier, I am also happy to adapt.

Upvotes: 0

Views: 574

Answers (2)

Kaiido
Kaiido

Reputation: 136755

I'm not sure this is the most suitable solution for your situation but note that your canvas code can be modified to fill the merged area and detect mouse events just fine.

You don't have to call strokeRect() or fillRect() that do only create a temporary rectangle only for their call. You can create a way more complex path made of several rectangles and other commands. This complex path will then be filled a single entity, doing the "merging" for you. By stroking first and filling after, we can hide the junctions between each shapes.
And to detect if you're over the filled area, you can use ctx.isPointInPath():

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

// beware, we fill over the inner half of the stroke
// so only half of this value will be visible
ctx.lineWidth = 20;

let mouse = { x: -1, y: -1 };

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  ctx.rect(30,20,150,250);
  ctx.rect(120,160,120,50);

  ctx.rect(350,130,50,130);
  // contrarily to rect(),
  // arc() doesn't call automatically moveTo()
  // so to avoid having a link between the previous point
  // and the beginning of the arc, we hav to call it ourselves
  // this would also be true with any other command, except roundRect()
  ctx.moveTo(375+70, 80);
  ctx.arc(375, 80, 70, 0, Math.PI*2);

  // check if the mouse is over either the stroke or the fill area
  const hovering = ctx.isPointInStroke(mouse.x, mouse.y) ||
                     ctx.isPointInPath(mouse.x, mouse.y)
  // change the color based on hover state
  ctx.fillStyle = hovering ? "#FF0000" : "#FFFF00";

  // It's all a single path
  ctx.stroke(); // stroking would show all the various sections
  ctx.fill(); // but since we fill over it, we only keep the common outline
}
draw();
// update the hovering state
addEventListener("mousemove", (evt) => {
  const rect = canvas.getBoundingClientRect();
  mouse.x = evt.clientX - rect.left;
  mouse.y = evt.clientY - rect.top;
  draw();
});
body{ background-color: green; }
<canvas id="canvas" width=600 height=300></canvas>

And if you need you could separate each shape in their own Path2D object:

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

ctx.lineWidth = 20;

let mouse = { x: -1, y: -1 };

const p1 = new Path2D();
p1.rect(30,20,150,250);
p1.rect(120,160,120,50);

const p2 = new Path2D();
p2.rect(350,130,50,130);
p2.moveTo(375+70, 80);
p2.arc(375, 80, 70, 0, Math.PI*2);

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();

  const overP1 = ctx.isPointInStroke(p1, mouse.x, mouse.y) ||
                   ctx.isPointInPath(p1, mouse.x, mouse.y)
  ctx.fillStyle = overP1 ? "#FF0000" : "#FFFF00";
  ctx.stroke(p1);
  ctx.fill(p1);
  
  const overP2 = ctx.isPointInStroke(p2, mouse.x, mouse.y) ||
                   ctx.isPointInPath(p2, mouse.x, mouse.y)
  ctx.fillStyle = overP2 ? "#FF0000" : "#FFFF00";
  ctx.stroke(p2);
  ctx.fill(p2);
}
draw();

addEventListener("mousemove", (evt) => {
  const rect = canvas.getBoundingClientRect();
  mouse.x = evt.clientX - rect.left;
  mouse.y = evt.clientY - rect.top;
  draw();
});
body{ background-color: green; }
<canvas id="canvas" width=600 height=300></canvas>

Upvotes: 3

IT goldman
IT goldman

Reputation: 19485

Let's reduce the problem. Build the shapes from atomic shapes such as rectangles and circles. Then you can extrapolate for many shapes. At first I thought about canvas using this function to detect mouse over:

const pointInRect = ({x1, y1, x2, y2}, {x, y}) => (
  (x > x1 && x < x2) && (y > y1 && y < y2)
)

But then again, since we reduced the problem we might as well use HTML. Since we are dealing with multiple shapes, it will be easier using some 2D map.

var shape = [
  " 1 1111 ".split(""),
  "11 1  1 ".split(""),
  " 111 111".split(""),
  "  1  1 1".split(""),
  "  1111 1 ".split(""),
]
const square_size = 40;
var container = document.querySelector(".container");

function draw_square(x, y) {
  var div = document.createElement("div");
  div.classList.add("square")
  div.style.left = x + "px"
  div.style.top = y + "px"
  container.appendChild(div)
  return div;
}

function draw_shape() {
  for (var i = 0; i < shape.length; i++) {
    for (var j = 0; j < shape[i].length; j++) {
      var pixel = shape[i][j];
      if (pixel == 1) {
        var div = draw_square(j * square_size, i * square_size);

        if (i > 0 && shape[i - 1][j] == 1) {
          div.style.borderTopColor = "transparent";
        }
        if (i < shape.length - 1 && shape[i + 1][j] == 1) {
          div.style.borderBottomColor = "transparent";
        }
        if (j > 0 && shape[i][j - 1] == 1) {
          div.style.borderLeftColor = "transparent";
        }
        if (j < shape[i].length - 1 && shape[i][j + 1] == 1) {
          div.style.borderRightColor = "transparent";
        }

      }
    }
  }
}

draw_shape(shape)
attach_mouse_events()

function attach_mouse_events() {
  var squares = document.querySelectorAll(".square");
  squares.forEach(function (square) {
    square.addEventListener('mouseenter', function(ev) {
      squares.forEach(function (square) {
        square.classList.add("active");
      })
    })
    square.addEventListener('mouseleave', function(ev) {
      squares.forEach(function (square) {
        square.classList.remove("active");
      })
    })    
  })

}
body {
  background: lightgray;
}

.square {
  width: 40px;
  height: 40px;
  background: green;
  border: 2px solid black;
  position: absolute;
}

.square.active {
   background: white;
}

.container {
  margin: 20px;
  background-size:cover;
  height: 300px;
  position: relative;
}
<div class="container">
</div>

As for the circles - it's the same idea. I would recommend painting the circles first then the squares, for a perfect borders. Good luck.

Upvotes: 1

Related Questions