<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");
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.