<div class="gallery">
  <svg class="gallery__svg-line" data-line-svg>
    <path class="gallery__path-line" data-line-path />
  </svg>
  
  <div class="gallery__item">
    <div class="gallery__item-point" style="margin-left: 0"
         data-anchor="outAngle: 35, outLen: 40%">
    </div>
  </div>

  <div class="gallery__item">
    <div class="gallery__item-point" style="margin-right: 0"
         data-anchor="inAngle: 60, inLen: 50%, outAngle: 55, outLen: 100">
    </div>
  </div>
  
  <div class="gallery__item">
    <div class="gallery__item-point"
         data-anchor="inAngle: -55, inLen: 100, outAngle: -55, outLen: 150">
    </div>
  </div>

  <div class="gallery__item">
    <div class="gallery__item-point"
         data-anchor="inAngle: 60, inLen: 100">
    </div>
  </div>
</div>
body {
  display: flex;
  flex-direction: column;
  margin: 0;
  background-color: #d8ffda;
}

.gallery {
  position: relative;
  width: 100%;
  max-width: 576px;
  margin: 0 auto;
  z-index: 0;
}

.gallery__svg-line {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
}

.gallery__item {
  margin: 50px;
  height: 150px;
}

.gallery__item-point {
  margin-left: auto;
  margin-right: auto;
  width: 32px;
  height: 32px;
  border: 6px solid #4caf50;
  background-color: #d8ffda;
  border-radius: 50%;
  box-shadow:
    inset 0px 4px 4px rgb(0 0 0 / 25%),
    0px 4px 4px rgb(0 0 0 / 25%);
}

.gallery__path-line {
  fill: none;
  stroke: #4caf50;
  stroke-width: 5px;
  stroke-dasharray: 8px;
  stroke-opacity: 0.65;
}
function normalizeVec(vec) {
  const l = Math.hypot(vec.x, vec.y);
  if (l === 0) return;

  vec.x /= l;
  vec.y /= l;
}

function rotateVec(vec, angle) {
  angle = angle * (Math.PI / 180);
  const sin = Math.sin(angle);
  const cos = Math.cos(angle);
  const x = cos * vec.x - sin * vec.y;
  const y = sin * vec.x + cos * vec.y;
  vec.x = x;
  vec.y = y;
}

function convertToUnitValue(s) {
  const n = parseFloat(s);

  if (Number.isFinite(n)) {
    const units = (s.match(/[a-zA-Z%]+$/) ?? [])[0] ?? "";
    return {
      type: "number",
      value: n,
      units
    };
  }

  return {
    type: "string",
    value: str,
    units: ""
  };
}

function parseDataOption(str) {
  const opts = str.split(",").map((s) =>
    s.split(":").map((o, i) => {
      o = o.trim();
      return i === 0 ? o : convertToUnitValue(o);
    })
  );

  return Object.fromEntries(opts);
}

class JoinLine {
  constructor(container) {
    this.container =
      typeof container === "string"
        ? document.querySelector(container)
        : container instanceof HTMLElement
        ? container
        : null;
    
    if (!this.container) {
      throw new Error("Container element not found");
    }
    
    this.svg = this.container.querySelector("[data-line-svg]");
    this.path = this.svg.querySelector("[data-line-path]");

    this.containerWidth = this.container.offsetWidth;
    this.containerHeight = this.container.offsetHeight;

    const anchorsEls = this.container.querySelectorAll("[data-anchor]");

    const zeroPixel = { type: "number", value: 0, units: "px" };
    const zeroAngle = { type: "number", value: 0, units: "deg" };

    const defaultOptions = {
      inAngle: { ...zeroAngle },
      inLen: { ...zeroPixel },
      outAngle: { ...zeroAngle },
      outLen: { ...zeroPixel }
    };

    this.anchors = Array.from(anchorsEls, (el) => {
      const options = el.dataset.anchor
        ? parseDataOption(el.dataset.anchor)
        : null;

      return {
        el,
        ...defaultOptions,
        ...options,
        bbox: { x: 0, y: 0, w: 0, h: 0 }
      };
    });

    this.updateAnchorsRects();
    this.draw();

    window.addEventListener("resize", this.onResizeHandler.bind(this));
  }

  updateAnchorsRects() {
    this.anchors.forEach(({ el, bbox }) => {
      bbox.x = el.offsetLeft;
      bbox.y = el.offsetTop;
      bbox.w = el.offsetWidth;
      bbox.h = el.offsetHeight;
    });
  }

  draw() {
    const mx = this.anchors[0].bbox.x + this.anchors[0].bbox.w / 2;
    const my = this.anchors[0].bbox.y + this.anchors[0].bbox.h / 2;
    const pathData = [`M ${mx} ${my}`];

    for (let i = 0; i < this.anchors.length - 1; i++) {
      const currAnchor = this.anchors[i];
      const nextAnchor = this.anchors[i + 1];

      const dist = Math.hypot(
        currAnchor.bbox.x - nextAnchor.bbox.x,
        currAnchor.bbox.y - nextAnchor.bbox.y
      );

      const currPos = {
        x: currAnchor.bbox.x + currAnchor.bbox.w / 2,
        y: currAnchor.bbox.y + currAnchor.bbox.h / 2
      };

      const nextPos = {
        x: nextAnchor.bbox.x + nextAnchor.bbox.w / 2,
        y: nextAnchor.bbox.y + nextAnchor.bbox.h / 2
      };

      const cp0 = {
        x: nextPos.x - currPos.x,
        y: nextPos.y - currPos.y
      };
      normalizeVec(cp0);

      const cp1 = { x: -cp0.x, y: -cp0.y };

      const outLen =
        currAnchor.outLen.units === "%"
          ? (currAnchor.outLen.value * dist) / 100
          : currAnchor.outLen.value;

      rotateVec(cp0, currAnchor.outAngle.value);
      cp0.x *= outLen;
      cp0.y *= outLen;

      const inLen =
        nextAnchor.inLen.units === "%"
          ? (nextAnchor.inLen.value * dist) / 100
          : nextAnchor.inLen.value;

      rotateVec(cp1, nextAnchor.inAngle.value);
      cp1.x *= inLen;
      cp1.y *= inLen;

      const h0x = currPos.x + cp0.x;
      const h0y = currPos.y + cp0.y;

      const h1x = nextPos.x + cp1.x;
      const h1y = nextPos.y + cp1.y;

      pathData.push(`C ${h0x} ${h0y} ${h1x} ${h1y} ${nextPos.x} ${nextPos.y}`);
    }

    this.path.setAttribute("d", pathData.join(" "));
  }

  onResizeHandler() {
    const newWidth = this.container.offsetWidth;
    const newHeight = this.container.offsetHeight;

    if (
      newWidth === this.containerWidth &&
      newHeight === this.containerHeight
    ) {
      return;
    }

    this.containerWidth = newWidth;
    this.containerHeight = newHeight;

    this.updateAnchorsRects();
    this.draw();
  }
}

new JoinLine(".gallery");

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.