<div class="demo">
<svg class="demo-svg" viewBox="-40 -30 580 230"></svg>
<a target="_blank" href="https://muffinman.io/blog/draw-svg-rope-using-javascript/">Read the blog post »</a>
<div class="demo-controls">
<div class="demo-controls-column">
<div class="demo-title">Options</div>
<label class="demo-label">
<input class="demo-checkbox-animate" type="checkbox" checked />
Animate
</label>
<div class="demo-control">
<label for="demo-step">Segment width</label>
<span class="demo-value"></span>
<input data-key="step" type="range" min="3" max="50" step="1" value="20" id="demo-step" />
</div>
<div class="demo-control">
<label for="demo-thickness">Rope thickness</label>
<span class="demo-value"></span>
<input data-key="thickness" type="range" min="3" max="50" step="1" value="40" id="demo-thickness" />
</div>
<div class="demo-control">
<label for="demo-angle">Skew angle</label>
<span class="demo-value"></span>
<input data-key="angle" type="range" min="0" max="90" step="1" value="45" id="demo-angle" />
</div>
</div>
<div class="demo-controls-column">
<div class="demo-title">Render</div>
<label class="demo-label">
<input data-key="path" class="demo-checkbox" type="checkbox" />
Path
</label>
<label class="demo-label">
<input data-key="points" class="demo-checkbox" type="checkbox" />
Points
</label>
<label class="demo-label">
<input data-key="normals" class="demo-checkbox" type="checkbox" />
Normals
</label>
<label class="demo-label">
<input data-key="polygons" class="demo-checkbox" type="checkbox" />
Square-ish segments
</label>
<label class="demo-label">
<input data-key="polygonsRounded" class="demo-checkbox" type="checkbox" />
Rounded square-ish segments
</label>
<label class="demo-label">
<input data-key="segments" class="demo-checkbox" type="checkbox" />
Segments
</label>
<label class="demo-label">
<input data-key="rope" class="demo-checkbox" type="checkbox" checked />Rounded segments (finished rope)
</label>
</div>
<div class="demo-controls-column">
<div class="demo-title">Colors</div>
<label class="demo-label">
<input class="demo-radio" type="radio" name="demo-colors" value="transparent" />
Transparent
</label>
<label class="demo-label">
<input class="demo-radio" type="radio" name="demo-colors" value="white" />
White
</label>
<label class="demo-label">
<input class="demo-radio" type="radio" checked name="demo-colors" value="natural" />
Natural
</label>
<label class="demo-label">
<input class="demo-radio" type="radio" name="demo-colors" value="rainbow" />
Rainbow
</label>
</div>
</div>
</div>
$accent: #4394e5;
* {
margin: 0;
padding: 0;
}
body {
padding: 50px 20px;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
}
input {
accent-color: $accent;
}
a,
a:visited{
display: inline-block;
font-size: 20px;
line-height: 1;
margin: 20px 0;
color: black;
border-bottom: 1px solid #ccc;
outline-offset: 5px;
transition: all 250ms;
text-decoration: none;
&:hover {
border-bottom-color: $accent;
}
&:active {
transform: translateY(1px);
}
}
.demo {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
.demo-svg {
overflow: visible;
}
path {
stroke-width: 1.5;
}
.path {
stroke: $accent;
}
.normals path {
stroke: $accent;
}
.points {
stroke: none;
fill: $accent;
}
.segment path,
.polygons-rounded path,
.polygons path {
stroke: $accent;
}
.demo-controls {
display: flex;
flex-wrap: wrap;
max-width: 750px;
}
.demo-controls-column {
flex: auto 0 0;
margin-right: 40px;
}
.demo-title {
font-weight: bold;
color: $accent;
margin: 20px 0;
font-size: 20px;
}
.demo-control {
display: flex;
margin-bottom: 10px;
max-width: 200px;
flex-wrap: wrap;
input {
display: block;
flex: 100%;
}
}
.demo-label {
display: flex;
margin-bottom: 10px;
input {
margin-right: 10px;
}
}
.demo-control label,
.demo-label{
font-weight: bold;
}
.demo-value {
margin-left: 6px;
}
View Compiled
// ----- VECTORS ----- //
function multiplyVector(v, scalar) {
return {
x: v.x * scalar,
y: v.y * scalar
};
}
function getVector(a, b) {
return {
x: b.x - a.x,
y: b.y - a.y
};
}
function addVectors(a, b) {
return {
x: a.x + b.x,
y: a.y + b.y
};
}
// ----- MATH ----- //
function getPointOnLine(start, end, ratio) {
const vector = getVector(start, end);
const v = multiplyVector(vector, ratio);
return {
x: start.x + v.x,
y: start.y + v.y
};
}
function getAngleBetweenThreePoints(a, b, c) {
const vectorBA = getVector(a, b);
const vectorBC = getVector(c, b);
const angle =
Math.atan2(vectorBC.y, vectorBC.x) - Math.atan2(vectorBA.y, vectorBA.x);
return angle;
}
// ----- CHAIKIN ----- //
function cut(start, end, ratio) {
const r1 = {
x: start.x * (1 - ratio) + end.x * ratio,
y: start.y * (1 - ratio) + end.y * ratio
};
const r2 = {
x: start.x * ratio + end.x * (1 - ratio),
y: start.y * ratio + end.y * (1 - ratio)
};
return [r1, r2];
}
function chaikin(curve, iterations = 1, closed = false, ratio = 0.25) {
if (ratio > 0.5) {
ratio = 1 - ratio;
}
for (let i = 0; i < iterations; i++) {
let refined = [];
refined.push(curve[0]);
for (let j = 1; j < curve.length; j++) {
let points = cut(curve[j - 1], curve[j], ratio);
refined = refined.concat(points);
}
if (closed) {
refined.shift();
refined = refined.concat(cut(curve[curve.length - 1], curve[0], ratio));
} else {
refined.push(curve[curve.length - 1]);
}
curve = refined;
}
return curve;
}
// ----- ROPE ----- //
function getPathPoints(d, step = 10) {
// For potential NodeJS version
// https://www.npmjs.com/package/svg-path-properties
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
const length = path.getTotalLength();
const count = length / step;
const points = [];
for (let i = 0; i < count + 1; i++) {
const n = i * step;
points.push(path.getPointAtLength(n));
}
const vectorStart = getVector(points[1], points[0]);
const vectorEnd = getVector(
points[points.length - 2],
points[points.length - 1]
);
return [
// Add helper points at the start
addVectors(points[0], vectorStart),
...points,
// and end
addVectors(points[points.length - 1], vectorEnd)
];
}
// Takes three points and returns two points.
// Points are located at the end of a vector which is bisector of the angle between these three points.
// The distance is "thickness" param
/*
• outerPoint[0]
/
/
v1 •------• v2
/ \
/ • v3
outerPoint[1] •
*/
function getOuterPoints(v1, v2, v3, thickness, angleOffset = 0) {
/*
v1 •------• v2
angle1 / \
/ • v3
*/
let angle1 = getAngleBetweenThreePoints(v1, v2, v3) / 2;
const offset = angle1 > 0 ? -1 : 1;
// Angle between (v1, v2) vector and x axis
/*
v2 •--------• (v2.x + offset, v2.y)
/ angle2
/
v1 •
*/
const angle2 = getAngleBetweenThreePoints(v1, v2, {
x: v2.x + offset, // Moving point on x axis
y: v2.y
});
// Angle between the x axis and the bisector angle
const angle = angle2 - angle1 + angleOffset;
const r = thickness / 2;
const point1 = {
x: v2.x + Math.cos(angle) * r,
y: v2.y - Math.sin(angle) * r
};
const point2 = {
x: v2.x + Math.cos(angle + Math.PI) * r,
y: v2.y - Math.sin(angle + Math.PI) * r
};
return [point1, point2];
}
function getLines(points, thickness, angleOffset = 0) {
const normals = [];
for (let i = 1; i < points.length - 1; i++) {
const v1 = points[i - 1];
const v2 = points[i];
const v3 = points[i + 1];
const line = getOuterPoints(v1, v2, v3, thickness, angleOffset);
normals.push(line);
}
// Adding an extra line for the last segment
normals.push(normals[normals.length - 1]);
return normals;
}
function getSegments(normals, fixGaps = false) {
const segments = [];
for (let i = 0; i < normals.length - 2; i++) {
const l1 = normals[i];
const l2 = normals[i + 1];
const l3 = normals[i + 2];
const path = [l1[0], l1[1], l2[1], l2[0]];
const prevSegment = segments[i - 1];
const A = l1[0];
const B = l1[1];
const C = l2[0];
const D = l2[1];
const E = l3[0];
/*
F---------E
| |
D---------C
| |
B---------A
*/
const ratio1 = 0.3; // Parametrize
const ratio2 = 1 - ratio1;
const BD033 = getPointOnLine(B, D, 0.33);
const DC_p1 = getPointOnLine(D, C, ratio1);
let corner1 = getPointOnLine(BD033, DC_p1, 0.5);
// Move the point closer to the corner
corner1 = addVectors(corner1, multiplyVector(getVector(corner1, D), 0.25));
const DC_p2 = getPointOnLine(D, C, ratio2);
const CE066 = getPointOnLine(C, E, 0.66);
let corner2 = getPointOnLine(DC_p2, CE066, 0.5);
// Move the point closer to the corner
corner2 = addVectors(corner2, multiplyVector(getVector(corner2, C), 0.25));
const AC066 = getPointOnLine(A, C, 0.66);
const AB_p1 = getPointOnLine(A, B, ratio1);
const AB_p2 = getPointOnLine(A, B, ratio2);
const line1 = [
prevSegment ? prevSegment.line1[2] : B,
BD033,
corner1,
fixGaps ? corner1 : null,
fixGaps ? corner1 : null,
DC_p1,
DC_p2,
corner2
].filter((p) => p);
const line2 = [
corner2,
AC066,
prevSegment ? prevSegment.line1[fixGaps ? 7 : 5] : null,
prevSegment && fixGaps ? prevSegment.line1[7] : null,
prevSegment && fixGaps ? prevSegment.line1[7] : null,
AB_p1,
prevSegment ? AB_p2 : null,
prevSegment ? prevSegment.line1[2] : B
].filter((p) => p);
const roundedLine1 = chaikin(line1, 2, false, 0.25);
const roundedLine2 = chaikin(line2, 2, false, 0.25);
roundedLine1.pop();
roundedLine2.pop();
const points = [...roundedLine1, ...roundedLine2];
segments.push({
line1,
line2,
path,
points
});
}
return segments;
}
function renderRope(
path,
svg,
options = {
step: 10,
thickness: 20,
angle: Math.PI * 0.25,
colors: []
},
render = {
path: 0,
points: 0,
normals: 0,
polygons: 0,
polygonsRounded: 0,
segments: 0,
rope: 1
}
) {
const points = getPathPoints(path, options.step);
const normals = getLines(points, options.thickness, options.angle);
const segments = getSegments(normals, options.fixGaps);
const paths = `
<g stroke-linecap="round" stroke-linejoin="round" fill="none" stroke="black">
<g opacity="${render.rope}" class="rope">
${segments
.map(
(segment, i) =>
`<path d="M ${segment.points
.map((p) => `${p.x} ${p.y}`)
.join(" L ")} Z" style="fill: ${
options.colors[i % options.colors.length] || "none"
}" />`
)
.join("\n")}
</g>
<g opacity="${render.normals}" class="normals">
${normals.map(
(line) =>
`<path d="M ${line[0].x} ${line[0].y} L ${line[1].x} ${line[1].y}" />`
)}
</g>
<g opacity="${render.polygons}" class="polygons">
${segments
.map(
(segment) =>
`<path d="M ${segment.path
.map((p) => `${p.x} ${p.y}`)
.join(" L ")} Z"/>`
)
.join("\n")}
</g>
<g opacity="${render.polygonsRounded}" class="polygons-rounded">
${segments
.map(
(segment) =>
`<path d="M ${chaikin(segment.path, 3, true, 0.15)
.map((p) => `${p.x} ${p.y}`)
.join(" L ")} Z"/>`
)
.join("\n")}
</g>
<g opacity="${render.segments}" class="segments">
${segments
.map(
(segment) =>
`<g class="segment">
<path d="M ${segment.line1
.map((p) => `${p.x} ${p.y}`)
.join(" L ")}"/>
<path d="M ${segment.line2
.map((p) => `${p.x} ${p.y}`)
.join(" L ")}"/>
</g>`
)
.join("\n")}
</g>
<path class="path" d="${path}" opacity="${render.path}" />
<g opacity="${render.points}" class="points">
${points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="3" />`).join("")}
</g>
</g>
`;
svg.innerHTML = paths;
}
// ----- INTERACTIVE DEMO ----- //
let demoT = 0;
let demoMovement = 0.005;
let demoLastUpdate = Date.now();
let demoRaf;
const FRAME_DURATION = 1000 / 60;
// easeInOutBack
function easing(x) {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
: (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
}
function getDemoPath() {
const t = easing(demoT);
const y = t * 100 + 60;
const y2 = 50 - t * 50;
const y3 = 130 + t * 50;
const x = t * 225;
const x2 = 275 + (1 - t) * 225;
return `M ${x} 100
C 50 100, 50 0, 100 0
C 150 0, 150 ${y}, 200 ${y}
C 250 ${y}, 250 ${y2}, 300 ${y2}
C 350 ${y2}, 350 ${y3}, 400 ${y3}
C 450 ${y3}, 450 100, ${x2} 100`;
}
const demoSvg = document.querySelector(".demo-svg");
const demoRenderCheckboxes = document.querySelectorAll(".demo-checkbox");
const demoRenderRadios = document.querySelectorAll(".demo-radio");
const demoAnimateCheckbox = document.querySelector(".demo-checkbox-animate");
const demoControlElements = document.querySelectorAll(".demo-control");
const colors = {
transparent: [],
white: ["#fff"],
natural: ["#e4cdad", "#dcbf99", "#d6b88e", "#dcbf99"],
rainbow: ["#2ecc71", "#3498db", "#9b59b6", "#e74c3c", "#e67e22", "#f1c40f"]
};
function updateDemo() {
const options = {
colors: colors[document.querySelector(".demo-radio:checked").value]
};
const render = {};
const animate = demoAnimateCheckbox.checked;
demoControlElements.forEach((element) => {
const input = element.querySelector("input");
const valueElement = element.querySelector(".demo-value");
valueElement.innerHTML = `(${input.value})`;
options[input.getAttribute("data-key")] = parseFloat(input.value);
});
options.angle *= Math.PI / 180;
demoRenderCheckboxes.forEach((checkbox) => {
render[checkbox.getAttribute("data-key")] = checkbox.checked ? 1 : 0;
});
demoLastUpdate = Date.now();
renderRope(getDemoPath(), demoSvg, options, render);
cancelAnimationFrame(demoRaf);
if (animate) {
demoRaf = requestAnimationFrame(() => {
const now = Date.now();
const delta = (now - demoLastUpdate) / FRAME_DURATION;
demoT = demoT + demoMovement * delta;
if (demoT < 0) {
demoT = 0;
demoMovement = -demoMovement;
} else if (demoT > 1) {
demoT = 1;
demoMovement = -demoMovement;
}
updateDemo();
});
}
}
updateDemo();
demoControlElements.forEach((element) => {
element.querySelector("input").addEventListener("change", updateDemo);
});
demoRenderCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", updateDemo);
});
demoRenderRadios.forEach((radio) => {
radio.addEventListener("change", updateDemo);
});
demoAnimateCheckbox.addEventListener("change", updateDemo);
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.