I'm trying to position a custom path underneath images that are being loaded, the images will have the same width but the height will vary.
I'm using react-measure to get width, height of my image in svg to later on use that height to position the next element, but due to presumably aspect ratio, I get wrong values.
<svg viewBox="0 0 1000 2000">
<svg
x="150"
y="200"
>
<Measure
bounds
onResize={ (contentRef) => {
this.setState(prevState => ({ imgDimensions: contentRef.bounds }));
} }
>
{ ({ measureRef }) => (
<svg x="0">
<image className={ classes.img } ref={ measureRef } width="200" xlinkHref={ image } />
</svg>
) }
</Measure>
<rect x="5" y={ this.state.imgDimensions.height } height="2" width={ width } />
</svg>
</svg>
Example values:
imgDimensions:
bottom: 504.6000061035156
height: 176.39999389648438
left: 595
right: 835
top: 328.20001220703125
width: 240
and how it's positioned:
https://imgur.com/a/o4bbFG2
That black line should be exactly where the image ends.
But if I resize the website and refresh it - this is what I get: https://imgur.com/a/n9sxTic
Related
I have created this codesandbox to illustrate the problem.
I have this ResponsiveSVG component:
export function ResponsiveSVG({
height,
width,
children,
origin = { x: 0, y: 0 },
preserveAspectRatio = "xMidYMid meet",
innerRef,
className,
...props
}: ResponsiveSVGProps): JSX.Element {
const aspect = height === 0 ? 1 : width / height;
const adjustedHeight = Math.ceil(width / aspect);
return (
<div
data-testid="cutting-svg-container"
style={{
position: 'relative',
overflow: 'visible',
height: '1px',
}}
>
<svg
style={{ overflow: 'visible' }}
className={className}
preserveAspectRatio={preserveAspectRatio}
width={width}
height={adjustedHeight}
viewBox={`${origin.x} ${origin.y} ${width} ${height}`}
ref={innerRef}
{...props}
>
{children}
</svg>
</div>
);
}
I would like my ResponsiveSVG component to know how to fit its content to the full width of the container while keeping the aspect ratio the same.
In the example I have a circle as a child of the svg document:
<ResponsiveSVG width={width} height={height}>
<circle cx={width / 2} cy={height / 2} r={radius} />
</ResponsiveSVG>
On desktop, it looks like this:
But in mobile view, it looks like this:
My calculations make the viewBox width and height the same as the actual width and height I am passing in, so no change happens.
The viewBox coordinates are exactly the same as the viewport.
Passing the width and height of the containing element to the viewBox is actually the wrong direction. viewBox defines the canvas on which the svg content elements are drawn: if you draw a circle with r="50", the viewBox width value must be at least 100, otherwise, it will never fit. If the center is at cx=50, the viewBox x value must not be greater than 0, otherwise the left side is cut off.
Responsiveness in SVG is achieved because there needs not to be any relation between the viewBox values and the width and height of the <svg> element. The canvas the child elements are drawn on is always fitted to the parent element dimensions.
In short, if all you want to achieve is to fit a circle in a <svg> element, choose a arbitrary radius r, set cx=cy=r and viewBox="0 0 2r 2r", and it will work. There is no need to know anything explicit about the parent size.
If you want your <svg> element to be the width of the container, set it so: width: 100%. The default height is auto, so no need to write that or preserveAspectRatio. It will size itself such that the canvas defined by viewBox fits itself.
<div
style="
position: relative;
overflow: visible;
height: 1px;
"
>
<svg
style="
width: 100%;
overflow: visible;
"
viewBox="0 0 100 100"
>
<circle cx="50" cy="50" r="50" />
</svg>
</div>
I'm trying to use React to work with SVG's in a procedural manner.
Something I'd like to do is use a parent element to set the color of different circles in my svg:
const MySVG = (): JSX.Element => {
return (
//all circles in this SVG should have a yellow fill
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
style={{ width: "fit-content", height: "100%" }}
viewBox="0 0 971 191"
>
<SVGProcessor>
<circle cx="730.9" cy="109.6" r="52.9" />
<g>
<path d="M587.8,39.6c10.7,8,25.9,5.9,33.9-4.8L641.9,91c-13-3.1-26.1,4.9-29.2,17.9L587.8,39.6z" />
</g>
<circle cx="816.2" cy="53.9" r="33.4" />
<g>
<circle cx="547.5" cy="93.1" r="67" />
</g>
<circle cx="915.5" cy="95.5" r="55" />
</SVGProcessor>
</svg>
);
};
//should apply a yellow fill to all circles in the svg
const SVGProcessor = ({
children,
}: {
children: JSX.Element[];
}): JSX.Element => {
useEffect(() => {
children.forEach((child) => {
if (child.type.displayName === "circle") {
const c = child as React.SVGProps<SVGCircleElement>;
c.fill = "yellow"
}
});
}, []);
return <>{children}</>;
};
But this doesn't work with nested svg elements, as they are not direct children of the wrapper component.
Is there a good way to compose SVG's procedurally?
I'm trying to draw a dashed path, or at least give that illusion, using Framer Motion. Think animating a foot path on a treasure map. Animating the path length seems to be a common method, and so I've implemented it like below.
<motion.span
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
d="...a list of coordinates"
stroke="#000"
strokeWidth="5"
strokeDasharray="8"
/>
But it appears animating the path length doesn't work well with strokeDasharray. When I add the strokeDasharray value using the attribute, the path length animates but the strokeDasharray value, when inspected, reads 2000px instead of 8px. And when I add the strokeDasharray using CSS or inline styling, the path is dashed correctly, but the animation doesn't work.
From what I've read, strokeDasharray uses the path length when doing it's computations, so I'm guessing the initial "0" value is throwing things off. Might be way off. I don't know.
Is there a simple fix here? Or should I reassess how I go about the animation? Thank you!
Not a solution using Framer Motion, but found this pen by Ruskinz that does the job using some css animation. The HTML looks like this:
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="340" height="333" viewBox="0 0 340 333">
<defs>
<path id="path1" d="M66.039,133.545c0,0-21-57,18-67s49-4,65,8s30,41,53,27s66,4,58,32s-5,44,18,57s22,46,0,45s-54-40-68-16s-40,88-83,48s11-61-11-80s-79-7-70-41 C46.039,146.545,53.039,128.545,66.039,133.545z" />
<mask id="mask1"><use class="mask" xlink:href="#path1"></mask>
</defs>
<use class="paths" xlink:href="#path1" mask="url(#mask1)" />
</svg>
And the CSS looks like this:
.paths {
fill: none;
stroke: grey;
stroke-dasharray: 5;
stroke-width: 5;
stroke-linejoin: round;
}
.mask {
fill: none;
stroke: white;
stroke-width: 10;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: dash 5s linear alternate infinite;
}
/* does not work in IE, need JS to animate there */
#keyframes dash {
from {
stroke-dashoffset: 1000;
}
to {
stroke-dashoffset: 0;
}
}
See the full pen at https://codepen.io/elliz/pen/prYqwx
I had the same issue because i wanted to animate it with framer-motion and not in css. What i just did is i just put an exact copy of the path with the dashedArray line below the path which i'm going to animate.
It will act as an overlay. I just gave it the stroke color of the background and tweaked the stroke-width. I don't know how it would be with a linearGradient background. But in my case with a static background color it worked.
import { motion } from 'framer-motion';
export default function Path({ pathColor, bg }) {
return (
<svg
width="245.24878"
height="233.49042"
viewBox="0 0 64.888737 61.777671"
version="1.1"
id="svg1033">
<defs
id="defs1030" />
<g
id="layer1"
transform="translate(-20.472293,-22.027827)">
<g
id="g484"
transform="translate(11.886667,6.306109)"
>
<motion.path
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
pathLength: { delay: 0.4, type: "tween", duration: 3, bounce: 0 }
}}
stroke={pathColor}
strokeDasharray='3.846, 1.282'
strokeDashoffset='0'
strokeWidth='0.641'
style={{ fill: 'none', fillRule: 'evenodd', strokeLinejoin: 'round' }}
d="m 70.258127,15.782623 c 0,0 -1.867161,10.194243 -5.854843,12.473363 -9.471023,5.413069 -22.204956,-6.41444 -32.583479,-3.054701 -9.553598,3.092694 -21.015474,9.948708 -22.6557013,19.855557 -1.7758628,10.726077 5.8258513,25.311914 16.2917403,28.255989 11.258271,3.166974 19.313188,-18.990719 30.80157,-16.800859 5.208004,0.992724 10.182339,12.218805 10.182339,12.218805"
id="path1154"
/>
<path
stroke={bg}
strokeDasharray='3.846, 2.282'
strokeDashoffset='0'
strokeWidth='1.641'
style={{ fill: 'none', fillRule: 'evenodd', strokeLinejoin: 'round' }}
d="m 70.258127,15.782623 c 0,0 -1.867161,10.194243 -5.854843,12.473363 -9.471023,5.413069 -22.204956,-6.41444 -32.583479,-3.054701 -9.553598,3.092694 -21.015474,9.948708 -22.6557013,19.855557 -1.7758628,10.726077 5.8258513,25.311914 16.2917403,28.255989 11.258271,3.166974 19.313188,-18.990719 30.80157,-16.800859 5.208004,0.992724 10.182339,12.218805 10.182339,12.218805"
id="path1155"
/>
</g>
</g>
</svg>
);
}
I'm trying to create an effect where if a user mouses over a line chart the parts of the svg:path elements that are to the right of the mouse are faded out while the parts of the svg:path element to the left remain at full opacity.
I've tried a few options to no avail - see below.
My first try was to use a path with mask which does change the opacity, but the rest of the lines are hidden because they are not under the mask.
<defs>
<mask
id='mask-for-line'
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
>
<rect style={{opacity: .5, stroke: 'none', fill: 'white'}}
x={x}
y={y}
width={width}
height={height}
/>
</mask>
</defs>
<path mask='url(#mask-for-line)' ... />
My second try was to put an svg:rect over the faded-out section, but that doesn't work either.
<rect x={x} y={0} width={width} height={height}
style={{opacity: .1, stroke: 'none', fill: 'lightgray'}}/>
Thanks to the inspiration from michael-rovinsky I was able to solve the problem. Within the mask, I have one <rect/> at full opacity covering the left-side of the chart and a second <rect/> at 25% opacity covering the right-side of the chart.
<defs>
<mask
id='mask-for-line'
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
>
<rect style={{fillOpacity: .25, fill: 'white'}}
x={x}
y={y}
width={width - x}
height={height}
/>
<rect style={{fillOpacity: 1, fill: 'white'}}
width={x}
height={height}
/>
</mask>
</defs>
You can try linear gradient with variable stop offsets:
const svg = d3.select('svg');
const width = parseInt(svg.attr('width'));
const height = parseInt(svg.attr('height'));
console.log(width, height);
const colors = ['red', 'green', 'blue', 'orange', 'purple', 'brown'];
const defs = svg.append('defs');
colors.forEach(color => {
const grad = defs.append('linearGradient').attr('id', `${color}-opacity-mask`);
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 1);
grad.append('stop').attr('stop-color', color).attr('stop-opacity', 1).classed('mid-stop', true);
grad.append('stop').attr('stop-color', color).attr('stop-opacity', 0.25).classed('mid-stop', true);
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.25);
})
const step = 100;
const paths = colors.map(color => {
let path = '';
for (let index = 0; index <= width / step; index++)
if (!index)
path = `M 0,${Math.random() * height}`;
else
path += `L ${index * step},${Math.random() * height}`;
return {color, path};
});
paths.forEach(({path, color}) => svg.append('path').attr('d', path).style('stroke', `url(#${color}-opacity-mask)`).style('fill', 'none'));
const line = svg.append('line')
.attr('y1', 0)
.attr('y2', height)
.style('stroke', 'black')
.style('stroke-dasharray', '3 3')
.style('visibility', 'hidden');
svg.on('mousemove', e => {
const pct = Math.round(100 * e.layerX / width);
svg.selectAll('.mid-stop').attr('offset', `${pct}%`);
line.attr('x1', e.layerX).attr('x2', e.layerX).style('visibility', 'visible');
});
svg.on('mouseleave', e => line.style('visibility', 'hidden'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<svg width="500" height="200">
</svg>
I have this code that you can run below in the snippet. My issue is that in my production environment I have 2 SVGs, one on top of the other, each containing all sorts of content. When the user clicks on either side of the header, as seen in the snippet, the SVG that is on top should grow or shrink to either show more or less of the SVG underneath (I know this isn't perhaps performant with regards to the SVG potentially being drawn beneath - but my question likely involves this, as the answer probably has something to do with a lack of understanding of SVG viewbox on my part).
The issue is that because I want the images contents to remain the same size as the SVG on top grows or shrinks I decided to use Greensock in order to animate the grow/shrink of both the width AND the viewbox. This though causes very nasty glitching - in the snippet, run the code and click the Black box (pay attention especially to the bottom left corner of the image, but also to the jumps in the leftmost circle)!
Am I mistaken in changing both? Is there a way to only alter the width? Would this still cause the glitch as seen below?
Appreciate your help!
class Header extends React.Component {
constructor(props) {
super(props);
this.leftPanel = null;
this.leftPanelTween = null;
this.rightPanel = null;
this.rightPanelTween = null;
}
slideLeftHandler = () => {
this.leftPanelTween = TweenMax.to(this.leftPanel, 2, {width: '100%', attr: {viewBox: "0 0 500 150"}});
};
slideRightHandler = () => {
this.rightPanelTween = TweenMax.to(this.leftPanel, 2, {width: 0, attr: {viewBox: "0 0 0 150"}});
};
render() {
return (
<div class="Header">
<svg width="100%" viewBox="0 0 500 150" ref={el => this.rightPanel = el} onClick={this.slideRightHandler}>
<rect id = "middle" width="100%" height="100%" fill="black">
</rect>
<circle cx="400" cy="75" r="25" fill="red">
</circle>
<circle cx="100" cy="75" r="25" fill="red">
</circle>
</svg>
<svg width="50%" viewBox="0 0 250 150" ref={el => this.leftPanel = el} onClick={this.slideLeftHandler}>
<rect id = "middle" width="100%" height="100%" fill="red">
</rect>
<circle cx="100" cy="75" r="25" fill="black">
</circle>
<circle cx="400" cy="75" r="25" fill="black">
</circle>
</svg>
</div>
);
}
}
ReactDOM.render(
<Header />,
document.getElementById('app')
);
.Header {
margin: 0;
cursor: pointer;
}
.Header svg {
position: absolute;
}
.Header .leftPanel {
top: 0;
left: 0;
}
.Header .rightPanel {
top: 0;
right: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.2/TweenMax.min.js"></script>
<div id="app"></div>
Animating the viewBox is not performant. I recommend making the SVG as large as it needs to be at the end all of the time and just animate the width of the rectangle within the SVG that was clicked.