Search code examples
javascripthtmlgeometry

How to append an element to another rotated and positioned element while maintaining its position on the screen?


I have the following HTML structure:

codepen

<body>
<button onclick={appendBlueToGreen()}>Append To Green</button>
  <div id="red" style="transform: rotate(20deg); width: 100px; height: 100px; position: absolute; top: 50px; left: 50px; background-color: red;">
    <div id="green" style="transform: rotate(20deg); width: 80px; height: 80px; position: absolute; top: 13px; left: 11px; background-color: green;">
    </div>
  </div>
  <div id="blue" style="transform: rotate(45deg); width: 50px; height: 50px; position: absolute; top: 29px; left: 313px; background-color: blue;"></div>
</body>

I want to append the #blue element to the #green element, but the #blue element should maintain its exact position on the screen. In other words, the #blue element should be a child of the #green element, but its visual position and rotation should remain unchanged. So whether the #blue element is attached to #green, <body>, or any other element, it should remain in the same position as in the image below.

enter image description here

To achieve this, I need to:

  • Calculate the absolute position and rotation of the #green and #red elements relative to the document body.
  • Adjust the position and rotation of the #blue element based on the calculated position and rotation of the #red and #green element, so that it appears at the same position on the screen but within the #green element.
  • Counter-rotate the #blue element to negate the effect of the rotations applied to the #green and #red elements.

I'm struggling to figure out the correct mathematical calculations to achieve this. I would greatly appreciate if someone could provide a JavaScript solution to achive this.

Here is the code that I have used with the help of AI to achieve this, but it was not successful: codepen


Solution

  • Insofar the rotation is involved, the formula is quite simple:

    newBlueRotation = originalBlueRotation - redRotation - greenRotation;
    

    However, for the new positions of the element, it is unexpectedly complicated. That is because each rotation is performed around a centre that is different from the reference point used for positioning. The centre is by default the centre of the div, but it can be altered using transform-origin css property.

    Thus, for finding the actual centre of rotation of an element one may use:

    window.getComputedStyle(element).transformOrigin
    

    The difference between the centre of rotation and the reference point means that in order to compute the positions of elements included down the line, for each included element one has to translate from the reference point to the centre, apply the rotation and then translate back to the reference point.

    For instance, the reference point of only one rotated rectangle, the red one is found by this operation (I hope this pseudo-code notation is self-explanatory):

    redReferencePoint = redOffset + redCenter - redCenter * rotate(-red)
    

    Offsets are positions of the elements with respect to their parents (all assumed absolutely positioned for the calculation to work).

    Apply these calculations sequentially for the red->green->new blue elements, setting the condition that the position of the new blue element is the same as the position of the original blue one, one gets an equation that can be solved for the new blue offset, which is huge:

    newBlueOffset = greenCenter - blueCenter +
    ( blueOffset + blueCenter - redOffset - redCenter - 
        (greenOffset - redCenter + greenCenter) * rotate(-red)
    ) * rotate(red+green)
    

    The complicated computations have also the effect of the result being slightly imprecise, probably due to round-off errors - there's sometimes a minute "subpixel" repositioning of the blue element,

    Maybe the formulae can be better seen in code; some of the code in this snippet comes from the codepen in the question.

    const appendBlueToGreen = () => {
       // Get the elements
       const blueElement = document.getElementById('blue');
       const greenElement = document.getElementById('green');
       const redElement = document.getElementById('red');
    
       // Function to convert degree to radian
       const radToDeg = (deg) => (deg * 180) / Math.PI;
    
       // get the rotation center
       const getCenter = element => {
          try{
             const [x, y] = window.getComputedStyle(element).transformOrigin.split(' ').map(parseFloat);
             return {x, y};
          }
          catch(err){
             // default is element center
             return {x: element.offsetWidth / 2, y: element.offsetHeight / 2};
          }
       }
    
       // Function to get the rotation angle of an element (in radians)
       const getRotation = (element) => {
          const transform = window.getComputedStyle(element).transform;
          const matrix = transform.match(/^matrix\((.+)\)$/);
          if (matrix) {
             const values = matrix[1].split(', ');
             const a = values[0];
             const b = values[1];
             return Math.atan2(b, a);
          } else {
             return 0;
          }
       };
    
       const getOffset = (element) => {
          //return {x: parseFloat(window.getComputedStyle(element).left),
          //   y: parseFloat(window.getComputedStyle(element).top)};
          return {x: element.offsetLeft, y: element.offsetTop};
       }
    
       const rotateVec = ({x, y}, theta) => { // rotate vector {x, y} by theta clockwise
          const cosTheta = Math.cos(theta),
             sinTheta = Math.sin(theta);
          return {x: x * cosTheta + y * sinTheta, y: -x * sinTheta + y * cosTheta};
       };
    
       const addVec = ({x: x1, y: y1}, {x: x2, y: y2}, ...rest) => {
          const sum12 = {x: x1 + x2, y: y1 + y2};
          return rest.length === 0 ? sum12 : addVec(sum12, ...rest);
       }
    
       const negVec = ({x, y}) => ({x: -x, y: -y});
    
       const roundToFixed = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, '');
    
       // Calculate the rotations
       const redRotation = getRotation(redElement);
       const greenRotation = getRotation(greenElement);
       const blueRotation = getRotation(blueElement);
    
       // calculate the offset positions of elements wrt their parents
       const redOffset = getOffset(redElement);
       const greenOffset = getOffset(greenElement);
       const blueOffset = getOffset(blueElement);
    
       // calculate the centers
       const redCenter = getCenter(redElement);
       const greenCenter = getCenter(greenElement);
       const blueCenter = getCenter(blueElement);
    
       let newOffset = addVec(negVec(greenOffset), negVec(greenCenter), redCenter);
       newOffset = rotateVec(newOffset, -redRotation);
       newOffset = addVec(newOffset, blueOffset, blueCenter, negVec(redOffset), negVec(redCenter));
       newOffset = rotateVec(newOffset, greenRotation + redRotation);
       newOffset = addVec(newOffset, greenCenter, negVec(blueCenter));
    
       // Set the new position and rotation for the blue element
       blueElement.style.position = 'absolute';
       blueElement.style.left = `${roundToFixed(newOffset.x, 2)}px`;
       blueElement.style.top = `${roundToFixed(newOffset.y, 2)}px`;
    
    
       const newBlueRotation = roundToFixed(radToDeg(blueRotation - redRotation - greenRotation), 2);
       blueElement.style.transform = `rotate(${newBlueRotation}deg)`;
    
       // Append the blue element to the green element
       greenElement.appendChild(blueElement);
    
       console.log(blueElement.style.left, blueElement.style.top, blueElement.style.transform)
    
       this.event.target.setAttribute("disabled","d");
    };
    
    window.appendBlueToGreen = appendBlueToGreen;
    <button onclick={appendBlueToGreen()}>Append To Green</button>
    <div id="red" style="transform: rotate(20deg);width: 100px;height: 100px;position: absolute;top: 50px;left: 50px;background-color: red;">
        <div id="green" style="transform: rotate(20deg);width: 80px;height: 80px;position: absolute;top: 13px;left: 11px;background-color: green;">
        </div>
    </div>
    <div id="blue" style="transform: rotate(45deg);width: 50px;height: 50px;position: absolute;top: 29px;left: 313px;background-color: blue;"></div>