Ross Drew
Ross Drew

Reputation: 8246

How to draw connecting lines between web elements on a page

I want to find the simplest barebones (that is, no libraries if possible; this is a learning exercise) way to draw a simple line between components. The elements are divs representing cards always stacked vertically potentially forever. Cards can be different heights. The line will exit the left hand side of any given element (card a), turn 90 degrees and go up, turning 90 degrees back into another (card b).

Example

I've tried a few things. I haven't got any fully working yet and they're looking like they all need some serious time dedicated to figuring them out. What I want to know is what's the right/preferred way to do this so that I spend time on the right thing and it's future proof with the view:

  1. I can add as many connecting lines as I need between any two boxes, not just consecutive ones
  2. These lines obey resizing and scrolling down and up the cards
  3. Some cards may not have an end point and will instead terminate top left of page, waiting for their card to scroll into view or be created.

Attempts

My first thought was a <canvas> in a full column component on the left but aligning canvas' and the drawings in them to my divs was a pain, as well as having an infinite scrolling canvas. Couldn't make it work.

Next I tried <div>s. Like McBrackets has done here. Colouring the top, bottom and outer edge of the div and aligning it with the two cards in question but while I can position it relative to card a, I can't figure out how to then stop it at card b.

Lastly I tried <SVG>s. Just .getElementById() then add an SVG path that follows the instructions above. i.e.

    const connectingPath =
        "M " + aRect.left + " " + aRect.top + " " +
        "H " + (aRect.left - 50) +
        "V " + (bRect.top) +
        "H " + (bRect.left);

Nothing seems to line up, it's proving pretty difficult to debug and it's looking like a much more complex solution as I need to take into account resizing and whatnot.

Upvotes: 5

Views: 2364

Answers (1)

GenericUser
GenericUser

Reputation: 3230

You might be able to apply something like this by taking a few measurements from the boxes you want to connect; offsetTop and clientHeight.


Update Added some logic for undrawn cards requirement.

While this doesn't fully simulate dynamic populating of cards, I made an update to show how to handle a scenario where only one card is drawn.

  1. Click connect using the default values (1 and 5). This will show an open connector starting from box 1.
  2. Click "Add box 5". This will add the missing box and update the connector.

The remaining work here is to create an event listener on scroll to check the list of connectors. From there you can check if both boxes appear or not in the DOM (see checkConnectors function). If they appear, then pass values to addConnector which will connect them fully.

class Container {
  constructor(element) {
    this.connectors = new Map();
    this.element = element;
  }

  addConnector(topBox, bottomBox, displayHalf = false) {
    if (!topBox && !bottomBox) throw new Error("Invalid params");
    const connector = new Connector(topBox, bottomBox, displayHalf);
    const connectorId = `${topBox.id}:${bottomBox.id}`;
    this.element.appendChild(connector.element);
    if (this.connectors.has(connectorId)) {
      connector.element.style.borderColor = this.connectors.get(connectorId).element.style.borderColor;
    } else {
      connector.element.style.borderColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
    }
    this.connectors.set(connectorId, connector);
  }

  checkConnectors() {
    this.connectors.forEach((connector) => {
      if (connector.displayHalf) {
        connector.firstBox.updateElement();
        connector.secondBox.updateElement();

        if (connector.firstBox.element && connector.secondBox.element) {
          this.addConnector(connector.firstBox, connector.secondBox);
        }
      }
    });
  }
}

class Box {
  constructor(id) {
    this.id = id;
    this.updateElement();
  }

  getMidpoint() {
    return this.element.offsetTop + this.element.clientHeight / 2;
  }

  updateElement() {
    this.element ??= document.getElementById(`box${this.id}`);
  }

  static sortTopDown(firstBox, secondBox) {
    return [firstBox, secondBox].sort((a,b) => a.element.offsetTop - b.element.offsetTop);
  }
}

class Connector {
  constructor(firstBox, secondBox, displayHalf) {
    this.firstBox = firstBox;
    this.secondBox = secondBox;
    this.displayHalf = displayHalf;
    const firstBoxHeight = this.firstBox.getMidpoint();
    this.element = document.createElement("div");
    this.element.classList.add("connector");
    this.element.style.top = firstBoxHeight + "px";
    let secondBoxHeight;
    if (this.displayHalf) {
      secondBoxHeight = this.firstBox.element.parentElement.clientHeight;
      this.element.style.borderBottom = "unset";
    } else {
      secondBoxHeight = this.secondBox.getMidpoint();
    }
    this.element.style.height = Math.abs(secondBoxHeight - firstBoxHeight) + "px";
  }
}

const connectButton = document.getElementById("connect");
const error = document.getElementById("error");
const addBoxButton = document.getElementById("addBox");
const container = new Container(document.getElementById("container"));

connectButton.addEventListener("click", () => {
  const firstBoxId = document.getElementById("selectFirstBox").value;
  const secondBoxId = document.getElementById("selectSecondBox").value;
  if (firstBoxId === "" || secondBoxId === "") return;
  error.style.display = firstBoxId === secondBoxId ? "block" : "none";
  const firstBox = new Box(firstBoxId);
  const secondBox = new Box(secondBoxId);
  // Check for undrawn cards  
  if (!!firstBox.element ^ !!secondBox.element) {
    return container.addConnector(firstBox, secondBox, true);
  }
  const [topBox, bottomBox] = Box.sortTopDown(firstBox, secondBox);  
  container.addConnector(topBox, bottomBox);
});

window.addEventListener("resize", () => container.checkConnectors());

addBoxButton.addEventListener("click", () => {
  const box = document.createElement("div");
  box.innerText = 5;
  box.id = "box5";
  box.classList.add("box");
  container.element.appendChild(box);
  addBoxButton.style.display = 'none';  
  container.checkConnectors();
});
.box {
  border: solid 1px;
  width: 60px;
  margin-left: 30px;
  margin-bottom: 5px;
  text-align: center;
}

#inputs {
  margin-top: 20px;
}

#inputs input {
  width: 150px;
}

.connector {
  position: absolute;
  border-top: solid 1px;
  border-left: solid 1px;
  border-bottom: solid 1px;
  width: 29px;
}

#error {
  display: none;
  color: red;
}
<div id="container">
  <div id="box1" class="box">1</div>
  <div id="box2" class="box">2</div>
  <div id="box3" class="box">3</div>
  <div id="box4" class="box">4</div>
</div>
<div id="inputs">
  <input id="selectFirstBox" type="number" placeholder="Provide first box id" min="1" value="1" max="5" />
  <input id="selectSecondBox" type="number" placeholder="Provide second box id" min="1" value="5" max="5" />
  <div id="error">Please select different boxes to connect.</div>
</div>
<button id="connect">Connect</button>
<button id="addBox">Add box 5</button>

Upvotes: 4

Related Questions