I'm trying to create a small animation where a dot would smoothly go along two intertwined ellipses, like
I managed to create an almost-working path using an online tool to turn shapes into a single path :
.wrapper {
margin-top: 25dvh;
height: 50dvh;
width: 50dvh;
background-color: black;
margin: auto;
}
<div class="wrapper">
<svg viewBox="0 0 130 130" xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="lightgrey"
d="M 115.9656 66.951 A 52.9167 26.4583 0 0 1 63.049 93.4094 A 52.9167 26.4583 0 0 1 10.1323 66.951 A 52.9167 26.4583 0 0 1 63.049 40.4927 A 52.9167 26.4583 0 0 1 115.9656 66.951 Z M 62.9167 14.2985 A 26.3265 52.7849 0 0 1 89.2432 67.0833 A 26.3265 52.7849 0 0 1 62.9167 119.8682 A 26.3265 52.7849 0 0 1 36.5901 67.0833 A 26.3265 52.7849 0 0 1 62.9167 14.2985 Z" />
<circle r="5" fill="red">
<animateMotion
dur="10s"
repeatCount="indefinite"
path="M 115.9656 66.951 A 52.9167 26.4583 0 0 1 63.049 93.4094 A 52.9167 26.4583 0 0 1 10.1323 66.951 A 52.9167 26.4583 0 0 1 63.049 40.4927 A 52.9167 26.4583 0 0 1 115.9656 66.951 Z M 62.9167 14.2985 A 26.3265 52.7849 0 0 1 89.2432 67.0833 A 26.3265 52.7849 0 0 1 62.9167 119.8682 A 26.3265 52.7849 0 0 1 36.5901 67.0833 A 26.3265 52.7849 0 0 1 62.9167 14.2985 Z" />
</circle>
</svg>
</div>
But of course the starting point of each ellipse is different, while I would want them superposed - for instance in A (in my little drawing above). So my questions are:
Thanks for your help :)
Fortunately, both ellipses have a simple rotation-symmetry and are also concentric – so we can apply a rather simple calculation to retrieve the intersection points used for <path>
segment points.
rx
and ry
or in geometry usually referred to as a
and b
As mentioned before we can apply a simple calculation like so:
function symmetricEllipseIntersections(cx, cy, rx, ry) {
let points = [];
// ellipses are congruent - return empty array
if (rx === ry) {
return points;
}
let term = (rx * ry) / Math.sqrt(rx**2 + ry**2);
return [
{x: cx + term, y: cy + term},
{x: cx - term, y: cy + term},
{x: cx + term, y: cy - term},
{x: cx - term, y: cy - term},
];
}
This only works for concentric rotation-symmetric ellipses!
Now we have all required data to create the new motion path:
M ${A.x} ${A.y}
A ${rx0} ${ry0} 0 0 1 ${E.x} ${E.y}
A ${rx0} ${ry0} 0 0 1 ${A.x} ${A.y}
A ${rx1} ${ry1} 0 0 1 ${E.x} ${E.y}
A ${rx1} ${ry1} 0 0 1 ${A.x} ${A.y}
// radii
let rx0 = 26.5,
ry0 = 53;
let rx1 = ry0,
ry1 = rx0;
// center points
let cx = 65,
cy = 65;
/**
* calculate
* intersection points
*/
let pts = symmetricEllipseIntersections(cx, cy, rx0, ry0)
/**
* visualize
* calculated points
*/
/*
// E
renderPoint(svg, pts[0], 'orange')
// G
renderPoint(svg, pts[1], 'red')
// C
renderPoint(svg, pts[2], 'purple')
// A
renderPoint(svg, pts[3], 'cyan')
*/
// map to point identifiers
let [A, C, E, G] = [pts[3], pts[2], pts[0], pts[1]];
renderPoint(svg, A, 'cyan')
renderPoint(svg, C, 'purple')
renderPoint(svg, E, 'orange')
renderPoint(svg, G, 'red')
/**
* create arc pathdata
*/
let d1_1 =
`M ${A.x} ${A.y}
A ${rx0} ${ry0} 0 0 1 ${E.x} ${E.y}
`;
path1_1.setAttribute('d', d1_1);
let d1_2 =
`M ${E.x} ${E.y}
A ${rx0} ${ry0} 0 0 1 ${A.x} ${A.y}
`;
path1_2.setAttribute('d', d1_2)
let d2_1 =
`M ${A.x} ${A.y}
A ${rx1} ${ry1} 0 0 1 ${E.x} ${E.y}
`;
path2_1.setAttribute('d', d2_1)
let d2_2 =
`M ${E.x} ${E.y}
A ${rx1} ${ry1} 0 0 1 ${A.x} ${A.y}
`;
path2_2.setAttribute('d', d2_2)
/**
* create new motion path data
*/
let d_m =
`M ${A.x} ${A.y}
A ${rx0} ${ry0} 0 0 1 ${E.x} ${E.y}
A ${rx0} ${ry0} 0 0 1 ${A.x} ${A.y}
A ${rx1} ${ry1} 0 0 1 ${E.x} ${E.y}
A ${rx1} ${ry1} 0 0 1 ${A.x} ${A.y}
`
mPath.setAttribute('d', d_m);
pathdata.textContent = d_m;
function symmetricEllipseIntersections(cx, cy, rx, ry) {
let points = [];
// ellipses are congruent - return empty array
if (rx === ry) {
return points;
}
let term = (rx * ry) / Math.sqrt(rx ** 2 + ry ** 2);
return [{
x: cx + term,
y: cy + term
},
{
x: cx - term,
y: cy + term
},
{
x: cx + term,
y: cy - term
},
{
x: cx - term,
y: cy - term
},
];
}
/**
* render points:
* just for visualisation
*/
function renderPoint(svg, pt, fill = "red", r = "1%", opacity = "1", title = '', render = true, id = "", className = "") {
if (Array.isArray(pt)) {
pt = {
x: pt[0],
y: pt[1]
};
}
let marker =
`<circle class="${className}" opacity="${opacity}" id="${id}" cx="${pt.x}" cy="${pt.y}" r="${r}" fill="${fill}"/>`;
if (render) {
svg.insertAdjacentHTML("beforeend", marker);
} else {
return marker;
}
}
svg {
display: block;
outline: 1px solid #ccc;
overflow: visible;
}
path,
rect,
ellipse {
fill: none;
}
path {
stroke: red;
}
#path1_1 {
stroke: cyan;
}
#path1_2 {
stroke: orange;
}
#path2_1 {
stroke: purple;
}
#path2_2 {
stroke: red;
}
#mPath {
stroke: #ccc;
}
rect {
stroke: #ccc;
}
#svg2{
max-width:75vmin;
}
.grd {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
}
<div class="grd">
<div class="col">
<h3>1. Parametrize ellipse</h3>
<svg id="svg" viewBox="0 0 130 130">
<ellipse cx="65" cy="65" rx="26.5" ry="53" stroke='blue' stroke-opacity="0.5" />
<ellipse cx="65" cy="65" rx="53" ry="26.5" stroke='red' stroke-opacity="0.5" />
</svg>
</div>
<div class="col">
<h3>2. Ellipse data to arc segments</h3>
<svg id="svg1" viewBox="0 0 130 130">
<path id="path1_1" />
<path id="path1_2" />
<path id="path2_1" />
<path id="path2_2" />
</svg>
</div>
</div>
<h3>3. New motion path</h3>
<svg id="svg2" viewBox="0 0 130 130">
<path id="mPath" />
<circle r="2%" fill="red">
<animateMotion dur="10s" repeatCount="indefinite">
<mpath href="#mPath" />
</animateMotion>
</circle>
</svg>
<h4>Motion pathdata</h4>
<pre>
<code id="pathdata">
</code>
</pre>
You can also minify the final pathData to relative commands and apply some rounding for a more compact motion path:
M41.3 41.3a26.5 53 0 0147.4 47.4 26.5 53 0 01-47.4-47.4 53 26.5 0 0147.4 47.4 53 26.5 0 01-47.4-47.4
BTW. you can draw full ellipses with only 2 A
(arc) commands.
Technically, you may also get an almost complete circle/ellipse by slightly changing the final-on-path point, but this approach is prone to errors when you apply post-optimizations (e.g minifying it via SVGO).