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

    2. Calculate the ellipses' intersections

    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!

    3. Combine to new motion path

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