Search code examples
javascriptsvgmatrixtransformationmatrix-multiplication

Fit SVG transformed element into the rect bounds with JavaScript


I am struggling with an issue to fit pragmatically transformed SVG element into the given rect bounds.

  • Destination rect is given and not transformed.
  • Input rect has any type of transformations.
  • Input rect can be a child of any transformed groups.
  • Transformations should be applied only to the input rect.
  • This question is only about the JavaScript element transformations.

It's an easy task when the element has only transformations by itself:

When parent groups are not transformed

In this case proportion between the destination and input getBoundingClientRect (bounding rect in screen coordinates) is equals to a proper scaling factor.

But it's not working when parent elements are also transformed:

   var inputElement = document.getElementById("input");
var destinationElement = document.getElementById("destination");


var inputBB = inputElement.getBoundingClientRect();
var outputBB = destinationElement.getBoundingClientRect();
var scaleX = outputBB.width / inputBB.width;
var scaleY = outputBB.height / inputBB.height;
// get offsets between figure center and destination rect center:
var offsetX = outputBB.x + outputBB.width / 2 - (inputBB.x + inputBB.width / 2);
var offsetY =
  outputBB.y + outputBB.height / 2 - (inputBB.y + inputBB.height / 2);

// get current figure transformation
let currentMatrix = (
  inputElement.transform.baseVal.consolidate() ||
  inputElement.ownerSVGElement.createSVGTransform()
).matrix;

// Get center of figure in element coordinates:
const inputBBox = inputElement.getBBox();
const centerTransform = inputElement.ownerSVGElement.createSVGPoint();
centerTransform.x = inputBBox.x + inputBBox.width / 2;
centerTransform.y = inputBBox.y + inputBBox.height / 2;
// create scale matrix:
const svgTransform = inputElement.ownerSVGElement.createSVGTransform();
svgTransform.setScale(scaleX, scaleY);

let scalingMatrix = inputElement.ownerSVGElement
  .createSVGMatrix()
  // move the figure to the center of the destination rect.
  .translate(offsetX, offsetY)
  // Apply current matrix, so old transformations are not lost
  .multiply(currentMatrix)
  .translate(centerTransform.x, centerTransform.y)
  // multiply is used instead of the scale method while for some reasons matrix scale is giving proportional scaling...
  // From a transforms proper matrix is generated.
  .multiply(svgTransform.matrix)
  .translate(-centerTransform.x, -centerTransform.y);

// Apply new created matrix to element back:
const newTransform = inputElement.ownerSVGElement.createSVGTransform();
newTransform.setMatrix(scalingMatrix);
inputElement.transform.baseVal.initialize(newTransform);

var bboundsTest= document.getElementById("bboundsTest");
const resultBBounds = inputElement.getBoundingClientRect();
bboundsTest.setAttribute('x', resultBBounds .x);
bboundsTest.setAttribute('y', resultBBounds .y);
bboundsTest.setAttribute('width', resultBBounds .width);
bboundsTest.setAttribute('height', resultBBounds .height);
document.getElementById('test2').innerHTML = 'expected: 100x100 . Results: ' + resultBBounds.width + 'x' + resultBBounds.height
<svg
  version="1.2"
  viewBox="0 0 480 150"
  width="480"
  height="150"
  xmlns="http://www.w3.org/2000/svg"
>

<g transform="skewX(10) translate(95,1) rotate(30)">
  <g transform="skewX(30) translate(-3,3) rotate(30)">
    <g transform="skewX(10) translate(-3,4) rotate(10)">
      <rect
        id="input"
        transform="translate(95,76.5) skewX(25) translate(50,50) scale(1.5) translate(-50,-50) translate(0,0) rotate(45)"
        width="30"
        height="30"
        fill="red"
      />
    </g>
  </g>
</g>

<rect
  id="destination"
  x="20"
  y="20"
  width="100"
  height="100"
  fill="transparent"
  stroke="blue"
/>
 <rect
  id="bboundsTest"
  x="20"
  y="20"
  width="100"
  height="100"
  fill="transparent"
  stroke="black"
/>

</svg>
<div id="test2"></div>

Any ideas on how to take parent transformations into the count to find proper scaling factors?

Thanks in advance for the ideas!

The given answer from Dipen Shah is focused on applying transformations to the parent element and this is also an option, but my goal is transforming the element to the destination rect bounds.


Solution

  • It took me some time to realize an answer, but finally, I got it and it's quite simple!

    1. Get the bounding boxes of both rectangles in the 'screen' coordinates. For example: getBoundingClientRect.
    2. By comparing the rectangle boxes you can get the desired scaling factors.
    3. While scaling should be done in screen coordinates, we should convert the current element transformation including all the parent transformations to the screen coordinates, transform all those with given scale and convert back to the element coordinates.

    Exact line is:

    var toScreenMatrix = inputElement.getScreenCTM();
    // Scale element by a matrix in screen coordinates and convert it back to the element coordinates:
    currentMatrix = currentMatrix.multiply(toScreenMatrix.inverse().multiply(scaleAndTransform).multiply(toScreenMatrix));
        
    

    This code is generic for all the svg elements, so any shape can be fit into the given rect:

        function fitElement(from, to, changePosition) {
            var inputElement = document.getElementById(from);
            var destinationElement = document.getElementById(to);
            // Get center of figure in element coordinates:
            var inputScreenBBox = inputElement.getBoundingClientRect();
            var destinationScreenBBox = destinationElement.getBoundingClientRect();
            var scaleX = destinationScreenBBox.width / inputScreenBBox.width;
            var scaleY = destinationScreenBBox.height / inputScreenBBox.height;
    
            var inputCenter = getCenter(inputScreenBBox);
            var offsetX = 0;
            var offsetY = 0;
            if (changePosition) {
                var destCenter = getCenter(destinationScreenBBox);
                offsetX = destCenter.x - inputCenter.x;
                offsetY = destCenter.y - inputCenter.y;
            }
    
            // create scale matrix:
            var scaleMatrix = getScaleMatrix(scaleX, scaleY, inputElement);
            // get element self transformation matrix:
            var currentMatrix = getElementMatrix(inputElement);
    
            scaleAndTransform = inputElement.ownerSVGElement.createSVGMatrix()
                .translate(offsetX, offsetY)
                // Scale in screen coordinates around the element center:
                .translate(inputCenter.x, inputCenter.y)
                .multiply(scaleMatrix)
                .translate(-inputCenter.x, -inputCenter.y)
            
            var toScreenMatrix = inputElement.getScreenCTM();
            // Scale element by a matrix in screen coordinates and convert it back to the element coordinates:
            currentMatrix = currentMatrix.multiply(toScreenMatrix.inverse().multiply(scaleAndTransform).multiply(toScreenMatrix));
            // Apply new created transform back to the element:
            var newTransform = inputElement.ownerSVGElement.createSVGTransform();
            newTransform.setMatrix(currentMatrix);
            inputElement.transform.baseVal.initialize(newTransform);
    
        }
        function getElementMatrix(element) {
            // Get consolidated element matrix:
            var currentMatrix =
                (element.transform.baseVal.consolidate() ||
                    element.ownerSVGElement.createSVGTransform()).matrix;
            return currentMatrix;
        }
        function getScaleMatrix(scaleX, scaleY, el) {
            // Return DOM matrix
            var svgTransform = el.ownerSVGElement.createSVGTransform();
            // Transform type is used because of the bug in chrome applying scale to the DOM matrix:
            svgTransform.setScale(scaleX, scaleY);
            var scaleMatrix = svgTransform.matrix;
            return scaleMatrix
        }
    
        function getCenter(rect) {
            return new DOMPoint((rect.x + rect.width / 2), (rect.y + rect.height / 2));
        }
    
        fitElement('source', 'destination', true);
    <svg width="1380" height="1340" xmlns="http://www.w3.org/2000/svg">
    <g transform="skewX(10) translate(-3,4) rotate(30)">
    <g transform="skewX(30) translate(-3,4) rotate(30)">
    <g transform="skewX(10) translate(-3,4) rotate(10)">
    <g transform="translate(350,30) skewX(10) rotate(30)">
    <rect id="source" transform="scale(2) rotate(30) skewX(10)" x="20" y="50" width="30" height="30"
    fill="red" />
    </g>
    </g>
    </g>
    </g>
    <rect id="destination" x="30" y="30" width="120" height="100" fill="transparent" stroke="blue" />
    </svg>

    GitHub gist link