I'm trying to scale a ReactJS component such that it expands to fit its parent div container. The component returns an
<svg/>
element with several children. The API of the react component only lets me set the svg element's CSS style. I don't have a way to give the svg element a viewbox attribute.
Is there a way for me to get the SVG to scale to fill its parent container through CSS only?
The react component for reference: link
Well, quite simple. All you need to do is combine ResizeObserver and transform: scale(). This solution was applied to stretch the svg world map into the area of dashboard widget on the prod build of app in the company where I work.
There is a gist which published with a link to JSFiddle demo.
(function() {
const parent = document.querySelector('.parent');
const item = document.querySelector('.item');
let scaleAdjust = 1.0;
const process = (container, ref) => {
const {
height: originHeight,
width: originWidth
} = ref.getBoundingClientRect();
const {
height: containerHeight,
width: containerWidth,
} = container.getBoundingClientRect();
const [height, width] = [originHeight * scaleAdjust, originWidth * scaleAdjust];
const [from, to] = height > width ? [width, containerWidth] : [height, containerHeight];
const scale = to / from;
ref.style.transform = `scale(${scale})`;
scaleAdjust = 1 / scale;
};
new ResizeObserver(() => process(parent, item)).observe(document.body);
})();
Related
I have a widget container where if I go to the right side, a dragger selector appears (mouseover).
I need to that dragger follow the mouse position in axis Y inside the widget container limits.
I have tried a lot of things but I'm not able to get the right position for the dragger.
rh.addEventListener('mouseover', (event) => {
const draggerElement = event.target.parentNode.querySelector('.widget-dragger')
if (draggerElement.style.opacity !== 1) {
draggerElement.style.top = `${event.pageY - (event.target.offsetHeight / 2)}px`
draggerElement.style.opacity = 1
}
})
jsfiddle: https://jsfiddle.net/rt0s8p7h/1/
Captures:
This is the rh element:
It has 100% height of the widget container.
Dragger has absolute position inside the widget container.
Widget Container has relative position, so the dragger is limited inside the container.
Solution here: https://jsfiddle.net/rt0s8p7h/3/
rh.addEventListener('mouseover', (event) => {
const draggerElement = event.target.parentNode.querySelector(".widget-dragger")
if (draggerElement.style.opacity !== 1) {
const rhRect = event.target.getBoundingClientRect()
draggerElement.style.top = `${event.clientY - rhRect.top - draggerElement.offsetHeight/2}px`
draggerElement.style.opacity = 1
}
})
Also works for mousemove to follow the mouse cursor.
What I want to achieve is smoothly scaled div container while scrolling (using mouse wheel to be strict) so user can zoom in and out.
However, my styles are "applied" by the browser only either when I scroll really slow or scroll normally and then wait about 0.2 seconds (after that time the changes are "bunched up"). I would like for the changes to be visible even during "fast" scrolling, not at the end.
The element with listener:
<div onWheel={(event) => {
console.log("wheeling"); // this console log fires frequently,
// and I want to update styles at the same rate
changeZoom(event);
}}
>
<div ref={scaledItem}> // content div that will be scaled according to event.deltaY
... // contents
</div>
</div>
My React code:
const changeZoom = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!scaledItem.current) return;
const newZoom = parseFloat(scaledItem.current.style.scale) + event.deltaY * 0.001;
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.scale = currentZoom.toString();
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.scale = "1";
}, [])
What I have tried first was to omit all the React states, and edit scaledItem.current.style.scale directly from useCallback, but the changes took place in a bunch, after the wheeling events stopped coming. Then I moved zoom amount to currentZoom useState hook, but rerenders don't help either.
Edit:
I have also tried adding EventListener inside useEffect directly to the DOM Node:
useEffect(() => {
if (!scaledItemWrapper.current) return; // ref for wrapper of my scaled content
const container = scaledItemWrapper.current;
container.addEventListener("wheel", changeZoom);
return () => {
container.removeEventListener("wheel", changeZoom);
};
}, [changeZoom]);
Instead of setting up multiple states and observing can you try using a single state below is a working example. Try this if this works
https://codesandbox.io/s/wonderful-cerf-69doe?file=/src/App.js:0-727
export default () => {
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });
const changeZoom = (e) => {
e.preventDefault();
const delta = e.deltaY * -0.01;
const newScale = pos.scale + delta;
const ratio = 1 - newScale / pos.scale;
setPos({
scale: newScale,
x: pos.x + (e.clientX - pos.x) * ratio,
y: pos.y + (e.clientY - pos.y) * ratio
});
};
return (
<div onWheelCapture={changeZoom}>
<img
src="https://source.unsplash.com/random/300x300?sky"
style={{
transformOrigin: "0 0",
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`
}}
/>
</div>
);
};
Use a CSS transition
What I want to achieve is smoothly scaled div container while scrolling
The question's JavaScript
I didn't have to make changes to the posted JavaScript in my answer's code snippets, besides replacing scale with transform: scale() because it currently has incomplete browser support. Perhaps this can be written better but it does the job here, and is not the cause of the choppy behavior you observe.
Creating fluid motion from choppy input
Scroll events are by nature "bunched" because they arrive as the wheel is being turned "a notch". While that's not as true for all scrollable devices, it is for most people's mouse, so we have to deal with it for the foreseeable future. The browser also does additional bunching, in case of fast motion, but even without that the problem is already there.
So it's best to write code in a way that choppy input still results in a fluid motion, regardless of the step size. Once you have that, it automatically accounts for additional bunching by the browser.
You can add a CSS transition on the transform property to smooth out the scaling movement. It seems to work well with a value of 0.2 seconds, which I assume makes sense as it spreads the motion over the 0.2 seconds the browser is bunching up the changes in.
transition: transform 0.2s ease-out;
Performance implications
As a bonus, your app can keep rendering just 5 times a second.
Conversely, a solution that causes React to capture the maximum amount of fine grained scroll events will likely cause performance issues. A CSS transform is a lot cheaper then achieving the same effect through repeated renders.
Demonstration
You can observe the difference in the following 2 snippets.
It only runs properly if you open it full page. Otherwise it works but it will scroll the whole page too. I didn't want to make the code overly complex just to prevent that on SO.
Without transition (choppy)
const {useCallback, useEffect, useState, useRef} = React;
const minZoom = .01;
function App() {
const [currentZoom, setCurrentZoom] = useState("1");
const scaledItem = useRef();
const changeZoom = useCallback((event) => {
if (!scaledItem.current) return;
const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.transform = "scale(1)";
}, [])
return <div onWheel={(event) => {
console.log("wheeling");
changeZoom(event);
}}
>
<div class="scaled" ref={scaledItem}>
<p>Scale me up and down! (Use "Full page" link of snippet)</p>
</div>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
border: 2px solid lightgreen;
transform: scale(1);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>
<div id="root2"></div>
With transition (smooth)
const {useCallback, useEffect, useState, useRef} = React;
const minZoom = .01;
function App() {
const [currentZoom, setCurrentZoom] = useState("1");
const scaledItem = useRef();
const changeZoom = useCallback((event) => {
if (!scaledItem.current) return;
const scaleNumber = scaledItem.current.style.transform.replace('scale(','').replace(')','');
const newZoom = Math.max(minZoom, parseFloat(scaleNumber) + event.deltaY * 0.001);
console.log(newZoom); // logs as frequently as "wheeling" above
setCurrentZoom(newZoom);
}, []);
useEffect(() => {
if (!scaledItem.current) return;
scaledItem.current.style.transform = `scale(${currentZoom.toString()})`;
}, [currentZoom]);
useEffect(() => { // this is just for reproduction, needs to set initial scale to 1
if (!scaledItem.current) return;
scaledItem.current.style.transform = "scale(1)";
}, [])
return <div onWheel={(event) => {
console.log("wheeling");
changeZoom(event);
}}
>
<div class="scaled" ref={scaledItem}>
<p>Scale me up and down! (Use "Full page" link of snippet)</p>
</div>
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'));
.scaled {
border: 2px solid lightgreen;
transform: scale(1);
transition: transform .2s ease-out;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>
I have an application which fetches content from an api and display it in a flex container. With a slider i can navigate through this content.
The shown content can have different height and i want to use a smooth animation of the height of my container if my content changes. Because the content i want to show is dynamic i cant use a fixed height of my container and instead i use height 100%.
Is there any way to react for changes in height and apply the transistion of e.g. transistion: height 1s
Im also open for some javascript solutions but please dont mention any jquery.
Please keep in mind im pretty new to css and especially for animations/transistions.
This is not an easy task, since transition animates property transition, while if you change the height of an element dynamically by setting it as auto ( it's auto by default ), it can't compute it as an height change, hence there is no transition. As long as I know your best option would be to calculate priorly the height that you need and change it imperatively, but if that can't be done, I've come up to this quite hacky vanilla solution:
const container = document.querySelector('.container');
const data = [
{
lines: 10,
},
{
lines: 25,
},
{
lines: 5,
},
{
lines: 35,
},
{
lines: 15,
},
{
lines: 45,
},
];
const offset = 40;
const asyncData = data.map(
(d, i) => new Promise((res) => setTimeout(() => res(d), 2000 * i))
);
const asyncCycle = async () => {
for (const d of asyncData) {
const res = await d;
const content = Array(res.lines).fill('Hello world').join('');
console.log('BEFORE MUTATION', container.clientHeight);
container.style.height = 'auto';
container.innerHTML = content;
console.log('AFTER MUTATION 1', container.clientHeight);
container.style.height = container.clientHeight - offset + 'px';
}
};
const targetNode = container;
const observerOptions = {
childList: true,
subtree: true,
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, observerOptions);
function callback(mutationList, observer) {
mutationList.forEach((mutation, i) => {
console.log('AFTER MUTATION 2', i, container.clientHeight);
container.style.height = container.clientHeight + 'px';
/* One or more children have been added to and/or removed
from the tree.
(See mutation.addedNodes and mutation.removedNodes.) */
});
}
// START CYCLE
asyncCycle();
body {
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: min(70vw, 300px);
border-radius: 10px;
background: orange;
transition: all 0.2s ease-out;
padding: 10px;
}
<div class="container"></div>
It makes use of MutationObserver to read dynamically the new height of the container and set it as a style attribute ( that can be transitioned ). It's a bit flawy though and just an idea of how to possibly implement the feature.
Just use a keyframes statement like
#keyframes display {
to {
display: 100%;
}
}
and then tell the element to execute the animation like so
#yourElement {
animation-name: display;
animation-duration: 1s;
animation-timing-function: linear;
}
You can replace the animation-timing-function: linear with whatever way you want the animation to look, see Mozilla docs.
I'm new to react, here i have svg element which is not in my code as you can see code i have provided, but it is in console, i want to change those 'height: 569px;width: 800px;' to height: 100%;width: 100%; my problem is, that does not have className and neither and id, so how can i change those values ? the other one which is above it i have changed that using that id.
that svg element have just 'name'
const _zoomConfig = () => {
const z = d3Select(`#graph-id-graph-wrapper`)
.call(d3Zoom() as any)
.on('wheel.zoom', null)
.on('dblclick.zoom', null)
.on('mousedown.zoom', null)
.on('touchstart.zoom', null)
.on('touchmove.zoom', null)
.on('touchend.zoom', null);
};
useEffect(() => {
_zoomConfig();
});
#graph-id-graph-wrapper {
/* margin-left: -100%; */
width: 100%;
height: 100%;
}
console:
i dont know if this is reacts way, i have tried this:
useEffect(() => {
const k = Array.from(document.getElementsByTagName('svg')).map(
(elem) => elem,
);
k.map((elem) => {
if (elem.getAttribute('name') === 'svg-container-graph-id') {
elem.setAttribute('height', '100%');
elem.setAttribute('width', '100%');
}
});
});
it doesnt affect it:
English is not my mother language so there could be mistakes.
You could change the inline style properties instead:
elem.style.width = '100%';
elem.style.height = '100%';
But I guess the graphs dimensions i.e. aspect ratio is also needed for a proper display. So adding a viewBox attribute might be a good idea:
elem.setAttribute('viewBox', '0 0 569 800');
Ideally you'll set these properties on creation/initialization.
See also: A simple way to make D3.js charts responsive
I'm using React Three Fiber to animate a 3d model looking at the mouse. This is creating a canvas element in the background of my page. I have a few divs and h1s layered on top of it, and whenever my mouse hovers over the divs/h1s, the canvas freezes, until I mouse out of it. This results in a choppy looking animation. How do I rectify this?
This is my React Component:
function Model() {
const { camera, gl, mouse, intersect, viewport } = useThree();
const { nodes } = useLoader(GLTFLoader, '/scene.glb');
const canvasRef = useRef(null);
const group = useRef();
useFrame(({ mouse }) => {
const x = (mouse.x * viewport.width) / 1200;
const y = (mouse.y * viewport.height) / 2;
group.current.rotation.y = x + 3;
});
return (
<mesh
receiveShadow
castShadow
ref={group}
rotation={[1.8, 40, 0.1]}
geometry={nodes.HEAD.geometry}
material={nodes.HEAD.material}
dispose={null}></mesh>
);
}
You'll need to assign a special CSS rule to your <h1> and <div>s so that they don't capture pointer events:
h1 {pointer-events: none;}
This means the element is never the target of pointer events, so the mousemove event sort of "passes through" to the element below it. See the MDN docs for further description.