javascriptcsssvg

SVG Bezier path connecting two divs


I have two divs, and these two divs have an icon on the border. I want to connect the icons using an SVG path line.

I have tried some solutions, but for some reason, I cannot seem to manage to make it look nice. The line starts away from the actual div. I believe I have some big problems during the calculations.

My project is in Vue, so I cannot give a proper link to show the reproduction, however, here's the function I am using:

function drawConnector() {
  if (divA.value && divB.value && svg.value) {
    const paths = svg.value

    let oldPaths = paths.children
    for (let a = oldPaths.length - 1; a >= 0; a--) {
      paths.removeChild(oldPaths[a])
    }

    let x1, y1, x4, y4, dx, x2, x3, path, boxA, boxB
    
    const bezierWeight = 0.8

    for (let a = 0; a < 1; a++) {
      // divA and divB are the icons
      boxA = divA.value
      boxB = divB.value
      
      x1 = getOffset(boxA).left + boxA.getBoundingClientRect().width / 2
      y1 = getOffset(boxA).top + (boxA.getBoundingClientRect().height - 100) / 2 // EDIT -100
      x4 = getOffset(boxB).left + boxB.getBoundingClientRect().width / 2
      y4 = getOffset(boxB).top + (boxB.getBoundingClientRect().height - 100)/ 2 // EDIT -100

      // Apply padding

      dx = Math.abs(x4 - x1) * bezierWeight

      if (x4 < x1) {
        x2 = x1 - dx
        x3 = x4 + dx
      } else {
        x2 = x1 + dx
        x3 = x4 - dx
      }

      const data = `M${x1} ${y1} C ${x2} ${y1} ${x3} ${y4} ${x4} ${y4}`
      path = document.createElementNS(SVG_URL, 'path')
      path.setAttribute('d', data)
      path.setAttribute('class', 'path')
      paths.appendChild(path)
    }
  }
}

function getOffset(element: HTMLDivElement) {
  if (!element.getClientRects().length) {
    return { top: 0, left: 0 }
  }

  let rect = element.getBoundingClientRect()
  let win = element.ownerDocument.defaultView
  return {
    top: rect.top + (win ? win.scrollY : 0),
    left: rect.left + (win ? win.scrollX : 0)
  }
}

The end result looks like this:

enter image description here

The divs themselves are draggable, and the line is re-drawn accordingly but it maintains that distance from the ports from where it is supposed to start. How do I make sure that the connection starts from the icon infront of "An ouput Port" and ends properly on the other output port.

Also, what kind of bezier curve do I use so that the path tries to go around the div and not from under it?

Any guidance will be appreciated.

EDIT: I have been tampering around with it, and I added a -100 when calculating the Y-axis. I dont know why it works, I tried different values but 100 is what works the best.

Output after the -100

I still would like help regarding the working of this because the -100 feels like a fluke and not a proper solution. Also, my goal would be to get the link to not go under the div but rather around it, if you have any advice regarding that too, it would be of course appreciated, otherwise I can always try to solve it later.

EDIT (ANSWER): So based on chrwahl's help, I went into detail into how am I calculating my offset.

I ended up using the following code to solve my issue. I just had to end up using the offset relative to the parent

// calculate position of source port
        const sourcePosition = {
          x: getOffsetRelativeToParent(divA).left,
          y: getOffsetRelativeToParent(divA).top + divA.offsetHeight / 2
        }

        // calculate position of target port
        const targetPosition = {
          x: getOffsetRelativeToParent(divB).left + 2,
          y: getOffsetRelativeToParent(divB).top + divB.offsetHeight / 2
        }

        // create svg path based on co-ordinates
        const path = document.createElementNS(SVG_URL, 'path')
        path.setAttribute(
          'd',
          `M ${sourcePosition.x}, ${sourcePosition.y} C ${sourcePosition.x + 100}, ${
            sourcePosition.y
          } ${targetPosition.x - 200}, ${targetPosition.y} ${targetPosition.x}, ${targetPosition.y}`

The function getOffsetRelativeToParent is as followed:

function getOffsetRelativeToParent(child: HTMLDivElement | HTMLElement) {
  const parent = mainDiv.value

  if (!parent) {
    return { top: 0, left: 0 }
  }

  const childRect = child.getBoundingClientRect()
  const parentRect = parent.getBoundingClientRect()

  const top = childRect.top - parentRect.top
  const left = childRect.left - parentRect.left

  return { top, left }
}

And now it looks like: enter image description here


Solution

  • It is difficult to guess that the issue is from the images, but somehow you are not taking into account the offset or the position of the SVG in relation to the area (here I call it the board).

    I hope that you can learn something from this example:

    const board = document.getElementById('board');
    const svg = board.querySelector('svg');
    const cardProps = {'width': 120, 'height': 80, 'offset': 65};
    
    updatePaths();
    
    function updatePaths() {
      let paths = svg.querySelectorAll('path');
      [...paths].forEach(path => {
        let from = board.querySelectorAll(`div.card`)[path.dataset.from];
        let to = board.querySelectorAll(`div.card`)[path.dataset.to];
        let fromPoint = {
          'left': from.offsetLeft + cardProps.width,
          'top': from.offsetTop + cardProps.offset
        };
        let toPoint = {
          'left': to.offsetLeft,
          'top': to.offsetTop + cardProps.offset
        };
    
        path.setAttribute('d', `M ${fromPoint.left} ${fromPoint.top}
        C ${fromPoint.left + 50} ${fromPoint.top} ${toPoint.left - 50} ${toPoint.top} ${toPoint.left} ${toPoint.top}`);
      });
    }
    
    board.addEventListener('mousedown', e => {
      switch (e.target.className) {
        case 'card':
          e.target.dataset.moving = "on";
          e.target.mouse = {
            left: e.layerX,
            top: e.layerY
          };
          break;
      }
    });
    
    board.addEventListener('mousemove', e => {
      let card = board.querySelector('div[data-moving="on"]');
      if (card) {
        card.style.left = `${e.clientX - card.mouse.left - board.offsetLeft}px`;
        card.style.top = `${e.clientY - card.mouse.top - board.offsetTop}px`;
        updatePaths();
      }
    });
    
    document.addEventListener('mouseup', e => {
      let cards = board.querySelectorAll('div[data-moving]');
      [...cards].forEach(card => card.dataset.moving = null);
    });
    #board {
      width: 100%;
      height: 400px;
      border: solid thin navy;
      position: relative;
    }
    
    .card {
      border: solid thin black;
      width: 120px;
      height: 80px;
      position: absolute;
      background-color: WhiteSmoke;
    }
    
    .card::before {
      content: "";
      width: 10px;
      height: 10px;
      border-radius: 5px;
      background-color: gray;
      position: absolute;
      left: -5px;
      top: 60px;
    }
    
    .card::after {
      content: "";
      width: 10px;
      height: 10px;
      border-radius: 5px;
      background-color: gray;
      position: absolute;
      right: -5px;
      top: 60px;
    }
    
    svg path {
      stroke-width: 2px;
      fill: none;
    }
    <div id="board">
      <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
        <path d="M 0 0 L 20 20" stroke="black" data-from="0" data-to="1" />
    </svg>
      <div class="card" style="left: 10px;top: 10px"></div>
      <div class="card" style="left: 200px;top: 50px"></div>
    </div>