animationmathsvgpathellipse

How to animate a dot on a path along two intertwined ellipses


I'm trying to create a small animation where a dot would smoothly go along two intertwined ellipses, like in this drawing

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 :)


Solution

  • 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.

    1. reverse the ellipses to their parameters

    1. center point – identical for both ellipses
    2. radii values rx and ry or in geometry usually referred to as a and b.

    Heads up: rx/ry values in A commands

    SVG arc commands use a end-point-parametrization concept for rendering. Simplified: the first 2 parameters provided by the arc command don't necessarily describe the exact rx and ry – if these values are to small a auto-adjustment kicks in. In this case

    // (type) rx, ry, xAxisRotation, largeArc, sweep, final on-path x and y
    A 52.9167, 26.4583, 0, 0, 1, 63.049, 93.4094 
    

    We can retrieve the ellipse radii rx = 53 and ry = 26.5 – so a a/b ratio of 0.5 (albeit I've slightly rounded these values).

    2. Calculate the ellipses' intersections

    As mentioned before we can apply a simple calculation like so:

    function symmetricEllipseIntersections(cx, cy, rx, ry) {
      
        // ellipses are congruent - return empty array
        if (rx === ry) {
            return [];
        }
        
        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!

    3. Combine to new motion path

    Now we have all required data to create the new motion path:

    // starting point
    M ${A.x} ${A.y} 
    
    // first ellipse
    A ${rx0} ${ry0} 0  0 1 ${E.x} ${E.y}
    A ${rx0} ${ry0} 0  0 1 ${A.x} ${A.y}
    
    // second ellipse
    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) {
    
      // ellipses are congruent - return empty array
      if (rx === ry) {
        return [];
      }
    
      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).

    Using a GUI graphic editor

    Advanced graphic editors like Inkscape (free) or commercial solutions like Adobe Illustrator provide path operations like divide paths to split intersecting paths into separate segments like so:

    divided path
    This is especially helpful for complex shapes where we can't apply simple math algorithms.
    As illustrated above you could split the paths and then join them as needed (using the desired order etc).
    Unfortunately, GUI editors can be very inconvenient when it comes to changing the starting points or drawing directions – which is crucial for motion path ore line animations.
    Nonetheless – graphic editors can quite often solve the problem way easier than trying to code a shape.