I have an horizontally responsive SVG path that stretch without keeping its ratio, and it's working when using preserveAspectRatio="none"
on the tag and vector-effect="non-scaling-stroke"
on the path to keep it intact. But I also need to animate an element along that same path without losing its aspect ratio.
Here is an example and as you can see it's working if you resize horizontally the wrapper but not for the star that loses its aspect-ratio caused by the global preserveAspectRatio="none"
attribute. I can't find a workaround for that and have both the path horizontally responsive and keep the star intact and following the path...
.wrapper {
width: 100%;
min-width: 200px;
max-width: 100%;
height: 200px;
resize: horizontal;
overflow: hidden;
border: 1px solid blue;
}
svg {
width: 100%;
height: 100%;
}
.path {
fill: none;
stroke: black;
stroke-width: 2px;
stroke-dasharray: 6 6;
stroke-linecap:round;
}
.star {
fill: red;
}
<div class="wrapper">
<svg viewBox="0 0 400 200" preserveAspectRatio="none">
<defs>
<path id="star" d="M0,24.675L2.679,32.921L11.349,32.921L4.335,38.017L7.014,46.262L0,41.166L-7.014,46.262L-4.335,38.017L-11.349,32.921L-2.679,32.921L0,24.675Z"/>
<path id="path" d="M0,36.608C0,36.608 64.096,15.519 92.956,48.531C121.816,81.542 101.293,129.283 74.824,115.941C48.354,102.599 68.017,24.7 188.557,73.454C309.097,122.207 261.935,170.513 400,175.664" vector-effect="non-scaling-stroke"/>
</defs>
<use href="#path" class="path"/>
<use href="#star" class="star" x="0" y="-36">
<animateMotion dur="10s" repeatCount="indefinite" rotate="auto">
<mpath href="#path"/>
</animateMotion>
</use>
</svg>
</div>
Unfortunately we can't change the track of a motion path via transform
or prevent unproportional scaling for specific elements.
A workaround could be to update the motion path on resize:
ResizeObserver
scalePathData()
)We can't specify a fixed viewBox
– otherwise the animated element would be scaled/"squeezed" as well.
Basically we're updating scaleX and scaleY values on resize based on a comparison between the original motion path bounding box and the resized SVG aspect ratio.
Update: Since a motion path in a <defs>
section is not rendered we can't retrieve a bounding box. Therefore, we need to retrieve the bbox from the <use>
instance or alternatively from a temporarily appended cloned element (which would be removed afterwards).
let svg = document.querySelector('svg');
let motionPath = svg.querySelector('#path')
// parse path for transformations
let d = motionPath.getAttribute('d')
let pathData = motionPath.getPathData({
normalize: true
});
// original bounding box of motion path from rendered use element
let bbO = usePath.getBBox();
function scaleMotionPath(svg, selector) {
let bb = svg.getBoundingClientRect();
let scaleX = bb.width / (bbO.width + bbO.x * 2);
let scaleY = bb.height / (bbO.height + bbO.y * 2);
// clone path data to prevent overwriting
let pathDataNew = JSON.parse(JSON.stringify(pathData));
let pathDataScaled = scalePathData(pathDataNew, scaleX, scaleY);
// update path data
path.setPathData(pathDataScaled)
}
let resizeObserver = new ResizeObserver(entries => {
let svg = entries[0].target;
let aspect = scaleMotionPath(svg)
});
resizeObserver.observe(svg);
/**
* scale pathData
*/
function scalePathData(pathData, scaleX, scaleY) {
pathData.forEach((com, i) => {
let {
type,
values
} = com;
let typeRel = type.toLowerCase();
switch (typeRel) {
case "a":
com.values = [
values[0] * scaleX,
values[1] * scaleY,
values[2],
values[3],
values[4],
values[5] * scaleX,
values[6] * scaleY
];
break;
case "h":
com.values = [values[0] * scaleX];
break;
case "v":
com.values = [values[0] * scaleY];
break;
default:
if (values.length) {
for (let i = 0; i < values.length; i += 2) {
com.values[i] *= scaleX;
com.values[i + 1] *= scaleY;
}
}
}
});
return pathData;
}
.wrapper {
width: 100%;
max-width: 100%;
resize: both;
overflow: auto;
border: 1px solid blue;
}
svg {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
outline: 1px solid red;
overflow: visible;
}
.path {
fill: none;
stroke: black;
stroke-width: 2px;
stroke-dasharray: 6 6;
stroke-linecap: round;
}
.star {
fill: red;
}
<div class="wrapper">
<svg>
<defs>
<path id="star" d="M 0 -12 l 2.7 8.2 l 8.6 0 l -7 5.1 l 2.7 8.3 l -7 -5.1 l -7 5.1 l 2.7 -8.3 l -7 -5.1 l 8.6 0 l 2.7 -8.2 z" />
<path id="path" d="M0,36.608C0,36.608 64.096,15.519 92.956,48.531C121.816,81.542 101.293,129.283 74.824,115.941C48.354,102.599 68.017,24.7 188.557,73.454C309.097,122.207 261.935,170.513 400,175.664" vector-effect="non-scaling-stroke" />
</defs>
<use id="usePath" href="#path" class="path" />
<use href="#star" class="star" >
<animateMotion dur="10s" repeatCount="indefinite" rotate="auto">
<mpath href="#path" />
</animateMotion>
</use>
</svg>
</div>
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@1.0.6/path-data-polyfill.min.js"></script>
The above example is based on the SVGPathData interface draft structure parsed via Jarek Foksa's polyfill.
The path data manipulation requires a normalization:
a
(arc) due too their potential x-rotation
angles (we could but it would significantly bloat the recalculation for unproportional scaling) => we convert arcs to cubic approximations (perfectly fine!)The normalizing parameter does the trick for us
See also