Animate SVG Arc Path in React - javascript

I'm using React, and what I want to achieve is to have an animation on SVG arc path when they change. Basically, I've a gauge that show a certain value between 0 and 100, and the value can change (in the following example it changes every second).
I've created this codepen that simulate what I want (code below): https://codepen.io/Gesma94/pen/oJvjwe
As you can see in the example, I've a Gauge created with d3 inside an SVG, where the blue bar can take more or less space in time; as you can see, when the Gauge is re-rendered, the new blue bar is just rendered, without any animation between the "old point" and "new point".
What I would like to achieve is having a smooth movement between the point the bar was before, and the point where the bar is going to be (hope I've been clear).
class MyComponent extends React.Component {
render() {
console.log("Rendering");
const value = (this.props.value * Math.PI / 100) - Math.PI/2;
const currentValueFilledCircle = d3.arc()
.innerRadius(37.5)
.outerRadius(49.5)
.startAngle(-Math.PI/2)
.endAngle(value)(null);
const currentValueEmptyCircle = d3.arc()
.innerRadius(37.5)
.outerRadius(49.5)
.startAngle(value)
.endAngle(Math.PI/2)(null);
return (
<div style={{width: "300px", height: "300px"}}>
<svg height="100%" width="100%" viewBox="-50 -50 100 100">
<g>
<path d={currentValueFilledCircle} fill="blue" />
<path d={currentValueEmptyCircle} fill="gray" />
</g>
</svg>
</div>
);
};
}
class App extends React.Component {
constructor() {
super();
this.value = 77;
}
componentDidMount() {
this.interval = setInterval(() => {
const diff = Math.floor(Math.random() * 7) - 3;
let newCurrentValue = this.value + diff;
if (newCurrentValue > 100) newCurrentValue = 100;
else if (newCurrentValue < 0) newCurrentValue = 0;
this.value = newCurrentValue;
this.forceUpdate();
}, 500);
}
render() {
return (<MyComponent value={this.value} />)
}
}
ReactDOM.render(<App />, document.getElementById('app'));

So, I struggled for some times, but I found a solution using react-move/Animate: https://react-move.js.org/#/documentation/animate
Since I couldn't make it work on Codepen, I recreate the situation in a sandbox, there it is: https://codesandbox.io/embed/0qyrmyrw
The gist is the following part of code:
<Animate
start={{ value: this.props.value }}
update={{
value: [this.props.value], // Before the sqaure brackets!!
timing: { duration: 750 }
}}
>
{(state: { value: number }) => {
const scaledValue = (state.value * Math.PI) / 100 - Math.PI / 2;
const currentValueFilledCircle = arc()
.innerRadius(37.5)
.outerRadius(49.5)
.startAngle(-Math.PI / 2)
.endAngle(scaledValue)(null);
const currentValueEmptyCircle = arc()
.innerRadius(37.5)
.outerRadius(49.5)
.startAngle(scaledValue)
.endAngle(Math.PI / 2)(null);
return (
<React.Fragment>
<path d={currentValueFilledCircle} fill="blue" />
<path d={currentValueEmptyCircle} fill="gray" />
</React.Fragment>
);
}}
</Animate>
Basically, by writing update={{value: [this.props.value] ... }}, the Animate Component just run a set of render() method with different values, from the previous to the current, and so it gives a smooth-movement effect.

Related

Is it possible to avoid labels overlapping in Pie recharts js?

I used the Pie component from Recharts js and the problem is that I get labels overlapping for labels with the same value.
here is some of my code:
<PieChart>
<Pie dataKey="value"
data={data}
fill="#536A6D"
label nameKey="name"
>
<LabelList dataKey="name" position="insideStart" />
<Pie>
</PieChart>
Is it possible to arrange the labels so that they do not collide with each other?
Thank you in advance!
Yes, you will have to conditionally render the labelline and label attribute. In my case only the zero values overlap so when the value is zero I do not render the value. Other examples online will help with the custom label but there is nothing over the little label line left over, I had this problem and had to dig through the source code to come up with the custom code /:
<Pie
data={dataZ}
cx={150 + wid - pad / 2}
cy={150}
innerRadius={70 + scaler}
outerRadius={100 + scaler}
fill="#8884d8"
paddingAngle={1}
dataKey="value"
label={RenderLabel2}
labelLine={RenderCustomizedLabelLine}
>
let RenderCustomizedLabelLine = function (props: any) {
return (props.value != 0 ? <path stroke={props.stroke} d={`M${props.points[0].x},${props.points[0].y}L${props.points[1].x},${props.points[1].y}`} className="customized-label-line" /> : <polyline stroke={props.stroke} fill="none" />)
}
let RenderLabel2 = function (props: any) {
const RADIAN = Math.PI / 180;
const radius = 25 + props.innerRadius + (props.outerRadius - props.innerRadius);
const x = props.cx + radius * Math.cos(-props.midAngle * RADIAN);
const y = props.cy + radius * Math.sin(-props.midAngle * RADIAN);
return (props.value != 0 ? <text
className="recharts-text recharts-pie-label-text"
x={x}
y={y}
fontSize='16'
fontFamily='sans-serif'
dominantBaseline="central"
cy={props.cy}
cx={props.cx}
fill="#666"
textAnchor={props.x > props.cx ? 'start' : 'end'}
>{Number.isInteger(props.value) ? Number(props.value) : Number(props.value).toFixed(1)}%</text> : <g>
<text x={500} y={y} fill="#transparent" rotate="90"></text>
</g>)
}

How to cancel current animation and immediately start new one with mouse event and requestAnimationFrame()

I want the dot to follow mouse cursor, e.g. on click.
Code seems simple, but with every click the dot runs shorter distance and doesn't reach the target.
The question is why?
The code is here:
https://jsfiddle.net/thiefunny/ny0chx3q/3/
HTML
<circle r="10" cx="300" cy="300" />
JavaScript
const circle = document.querySelector("circle")
window.addEventListener("click", mouse => {
const animation = _ => {
let getCx = Number(circle.getAttribute('cx'))
let getCy = Number(circle.getAttribute('cy'))
circle.setAttribute("cx", `${getCx + (mouse.clientX - getCx)/10}`);
circle.setAttribute("cy", `${getCy + (mouse.clientY - getCy)/10}`);
requestAnimationFrame(animation)
}
requestAnimationFrame(animation)
});
EDIT: for this task I need requestAnimationFrame(), not CSS, because this is just the simplest example, but I want to add much more complexity later to the movement, including multiple dots, random parametres etc., like I did here: https://walanus.pl
I spent lots of time experimenting, but the only conclusion I have is that after click event I should somehow cancel the current animation and start new one to make a fresh start for next animation.
you don't need requestAnimationFrame for this:
const circle = document.querySelector("circle")
window.addEventListener("click", e => {
let targetCircleX = e.clientX;
let targetCircleY = e.clientY;
let getCx = Number(circle.getAttribute('cx'))
let getCy = Number(circle.getAttribute('cy'))
let cx = targetCircleX - getCx;
let cy = targetCircleY - getCy;
circle.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
});
circle {
transition-duration: .2s;
}
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500">
<circle r="10" cx="100" cy="100" />
</svg>
EDIT: CSS animations are an easy yet powerful method to animate things in web, but manual control over the animation, done properly, always require more work, i.e. performant loops, proper timings, etc. (by the way, the mentioned site doesn't bother with these). So, for fullness of answer, a variant with requestAnimationFrame is below
const circle = document.querySelector("circle");
const fps = 60;
const delay = 1000 / fps;
let rafId;
window.addEventListener("click", e => {
cancelAnimationFrame(rafId);
let [time, cx, cy, xf, yf] = [0];
let r = +circle.getAttribute('r');
let [X, x] = [e.clientX - r, +circle.getAttribute('cx')];
let [Y, y] = [e.clientY - r, +circle.getAttribute('cy')];
const decel = 10;
const anim = now => {
const delta = now - time;
if (delta > delay) {
time = now - (delta % delay);
[x, y] = [x + (X - x) / decel, y + (Y - y) / decel];
[xf, yf] = [x.toFixed(1), y.toFixed(1)];
if (cx === xf && cy === yf)
return;
circle.setAttribute("cx", cx = xf);
circle.setAttribute("cy", cy = yf);
}
rafId = requestAnimationFrame(anim);
}
anim(time);
});
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="500" style="background: black">
<circle r="10" cx="100" cy="100" fill="red"/>
</svg>
The question is why
Well, you seem to know why: you never stop your animation loop, so at every frame it will try to go to the mouse.clientN position of when that animation loop started. Then at the next click, a second animation loop will start, running in parallel of the first one, and both will fight against each other to go toward their own mouse.clientN position.
To avoid this situation, as you have identified, you can simply stop the previous loop by using cancelAnimationFrame. All it takes is a variable accessible both to the animation scope and to the click handler.
However, keeping your animation loop going on is just killing trees. So make your code check if it has reached the target position before calling again requestAnimationFrame from inside animation.
const circle = document.querySelector("circle")
{
let anim_id; // to be able to cancel the animation loop
window.addEventListener("click", mouse => {
const animation = _ => {
const getCx = Number(circle.getAttribute('cx'))
const getCy = Number(circle.getAttribute('cy'))
const setCx = getCx + (mouse.clientX - getCx)/10;
const setCy = getCy + (mouse.clientY - getCy)/10;
circle.setAttribute("cx", setCx);
circle.setAttribute("cy", setCy);
// only if we didn't reach the target
if(
Math.floor( setCx ) !== mouse.x &&
Math.floor( setCy ) !== mouse.y
) {
// continue this loop
anim_id = requestAnimationFrame(animation);
}
}
// clear any previous animation loop
cancelAnimationFrame( anim_id );
anim_id = requestAnimationFrame(animation)
});
}
svg { border: 1px solid }
<svg viewBox="0 0 500 500" width="500" height="500">
<circle r="10" cx="100" cy="100" />
</svg>
Also, beware that your animation will run twice faster on devices with a 120Hz monitor than the ones with a 60Hz monitor, and even faster on a 240Hz. To avoid that, use a delta time.

How to plot degree on spiral chart using d3.js

I have a running Angular 9 application where SVG has a spiral chart with min and max value for the degree.
I am using d3.js to plot given value of degree on the spiral chart.
I have written the following code :
// min -> min degree, -140 in this example
// max -> max degree, 440 in this example
// currentDegree -> degree value to be ploted, 0 in this example
// svg -> svg containing spiral chart
// circle -> circle to be moved to depict current Degree position in the svg
void setDegree(min,max,currentDegree, svg, circle) {
const pathNode = svg.select('path').node();
const totalPathLength = pathNode.getTotalLength();
const yDomain = d3.scale.linear().domain([min, max]).range(
[0, totalPathLength]);
const currentPathLength = yDomain(currentDegree); // current path length
const pathPoint = pathNode.getPointAtLength(totalPathLength - currentPathLength);
circle.transition()
.duration(300)
.attrTween('cx', () => (t) => pathPoint.x)
.attrTween('cy', () => (t) => pathPoint.y);
}
Above code produces this output :
In the above image, 0 degrees is slightly shifted to the right but it should have been at the center as shown in the image below :
function setDegree(min, max, currentDegree, svg, circle) {
const pathNode = svg.select("path").node();
const totalPathLength = pathNode.getTotalLength();
const yDomain = d3
.scaleLinear()
.domain([min, max])
.range([0, totalPathLength]);
const currentPathLength = yDomain(currentDegree); // current path length
const pathPoint = pathNode.getPointAtLength(
totalPathLength - currentPathLength
);
circle
.transition()
.duration(300)
.attrTween("cx", () => t => pathPoint.x)
.attrTween("cy", () => t => pathPoint.y);
}
const svg = d3.select("svg");
const circle = d3.select("#cur_pos");
setDegree(-140, 410, 0, svg, circle);
p {
font-family: Lato;
}
.cls-3 {
fill: none;
stroke-width: 10px;
stroke: #000;
}
.cls-3,
.cls-4,
.cls-5 {
stroke-miterlimit: 10;
}
.cls-4,
.cls-5 {
stroke-width: 0.25px;
}
.cls-5 {
font-size: 60px;
font-family: ArialMT, Arial;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1031.1 1010.3" preserveAspectRatio="xMinYMin meet">
<title>Spiral Chart</title>
<g>
<g id="Spiral_Chart">
<g id="Spiral_Path">
<path
class="cls-3 svg-range-line"
d="M881.5,154.9C679.6-47,352.3-47,150.4,154.9c-195.9,195.9-195.9,513.4,0,709.3,189.7,190,497.5,190.1,687.5.4l.4-.4c184.3-184.3,184.3-483,0-667.3h0C659.6,18.1,369.8,18.1,191.1,196.8H191C17.6,370.3,17.6,651.4,191,824.8"
/>
</g>
<circle
id="cur_pos"
class="cls-4 svg-range-indicator"
cx="514"
cy="64.3"
r="18.5"
/>
</g>
<text
id="Min"
class="cls-5 svg-text"
style="text-anchor:start;"
x="195"
y="880"
>
-140
</text>
<text
id="Max"
class="cls-5 svg-text"
style="text-anchor:start;"
x="885"
y="210"
>
410
</text>
</g>
</svg>
Because your spiral is smaller on the inside, if you calculate the length at 0 degrees (or 90, or -90), you'll overshoot it. That is because the total length of the path includes the outside part of the spiral, which is longer, because it's radius is greater. In other words, your logic is correct if the path would have been completely circular. But it's not so you're off by a little bit.
Note that if you change currentDegree to 360, it's almost perfectly placed. That is again because of this radius.
I've used this wonderful package kld-intersections, which can calculate the intersecting points of two SVG shapes.
I first take the midpoint of the circle, then calculate some very long line in the direction I want the circle to have. I calculate the intersections of the path with that line, and I get back an array of intersections.
Now, to know whether to use the closest or the furthest intersection, I sort them by distance to the centre, and check how many times 360 fits between the minimum angle and the desired angle.
Note that the centre point is not perfect, that is why if you change it to -140, the circle will not be at the exact end position. Maybe you can improve on this or - if the design is stable, calculate the point by hand.
const {
ShapeInfo,
Intersection
} = KldIntersections;
function getCentroid(node) {
const bbox = node.getBBox();
return {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
}
function setDegree(min, max, currentDegree, svg, circle) {
const pathNode = svg.select("path").node();
const centroid = getCentroid(pathNode);
const pathInfo = ShapeInfo.path(pathNode.getAttribute("d"));
// We need to draw a line from the centroid, at the angle we want the
// circle to have.
const currentRadian = (currentDegree / 180) * Math.PI - Math.PI / 2;
const lineEnd = {
// HACK: small offset so the line is never completely vertical
x: centroid.x + 1000 * Math.cos(currentRadian) + Math.random() * 0.01,
y: centroid.y + 1000 * Math.sin(currentRadian),
};
indicatorLine
.attr("x1", centroid.x)
.attr("y1", centroid.y)
.attr("x2", lineEnd.x)
.attr("y2", lineEnd.y);
const line = ShapeInfo.line([centroid.x, centroid.y], [lineEnd.x, lineEnd.y]);
const intersections = Intersection.intersect(pathInfo, line).points;
// Sort the points based on their distance to the centroid
intersections.forEach(
p => p.dist = Math.sqrt((p.x - centroid.x) ** 2 + (p.y - centroid.y) ** 2));
intersections.sort((a, b) => a.dist - b.dist);
// See which intersection we need.
// Iteratively go round the circle until we find the correct one
let i = 0;
while (min + 360 * (i + 1) <= currentDegree) {
i++;
}
const pathPoint = intersections[i];
circle
.attr("cx", pathPoint.x)
.attr("cy", pathPoint.y);
}
const svg = d3.select("svg");
const indicatorLine = svg.append("line").attr("stroke", "red");
const circle = d3.select("#cur_pos");
setDegree(-140, 410, 0, svg, circle);
d3.select("input").on("change", function() {
setDegree(-140, 410, +this.value, svg, circle);
});
p {
font-family: Lato;
}
.cls-3 {
fill: none;
stroke-width: 10px;
stroke: #000;
}
.cls-3,
.cls-4,
.cls-5 {
stroke-miterlimit: 10;
}
.cls-4,
.cls-5 {
stroke-width: 0.25px;
}
.cls-5 {
font-size: 60px;
font-family: ArialMT, Arial;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<script src="https://unpkg.com/kld-intersections"></script>
<label>Value</label> <input type="number" value="0" min="-140" max="410"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1031.1 1010.3" preserveAspectRatio="xMinYMin meet">
<g>
<g id="Spiral_Chart">
<g id="Spiral_Path">
<path
class="cls-3 svg-range-line"
d="M881.5,154.9C679.6-47,352.3-47,150.4,154.9c-195.9,195.9-195.9,513.4,0,709.3,189.7,190,497.5,190.1,687.5.4l.4-.4c184.3-184.3,184.3-483,0-667.3h0C659.6,18.1,369.8,18.1,191.1,196.8H191C17.6,370.3,17.6,651.4,191,824.8"
/>
</g>
<circle
id="cur_pos"
class="cls-4 svg-range-indicator"
cx="514"
cy="64.3"
r="18.5"
/>
</g>
<text
id="Min"
class="cls-5 svg-text"
style="text-anchor:start;"
x="195"
y="880"
>
-140
</text>
<text
id="Max"
class="cls-5 svg-text"
style="text-anchor:start;"
x="885"
y="210"
>
410
</text>
</g>
</svg>

SVG circular progress bar - display score at the end of the progress bar

I'm building a circular progress bar as a react component with SVG elements.
Desired output:
Instead, I get it like this
My question is, how to rotate the score/text in the yellow circle to match the first image?
React component
const Gauge = ({ width, height, thick, progress, score }) => {
let angle = (progress / 100 * 360);
let progressValue = angle / 2;
let ticks = [];
for (let i = 1; i <= 180; i++) {
let tickClass = (progressValue > i) ? styles.tickActive : styles.tick;
ticks.push(<use key={i} className={tickClass} href="#tick" transform={`rotate(${i * 2} 60 60)`}> </use>)
}
ticks.push(<use key={0} className={styles.tickActive } href="#tick" transform={`rotate(0 60 60)`}> </use>);
return (
<div className={ styles.svgContainer }>
<svg className={styles.progress} width={`${width}`} height={`${height}`} viewBox="-20 -20 160 160">
<defs>
<line id="tick" x1="-5" y1="60" x2={`${thick}`} y2="60"> </line>
</defs>
<g id="ticks">
{ticks}
</g>
<g transform={`rotate(${progressValue * 2} 60 60)`}>
<line className={styles.scoreCircle} x1="0" y1="60" x2="0" y2="60"> </line>
<text className={styles.score2} fontSize={8} x={-10} y={63}>
{score}K
</text>
</g>
</svg >
</div>
);
};

React-konva double connected objects with arrow

I'm trying to expand the Connected Objects demo by allowing two nodes (shapes of Circle class) to be double referenced (A connects to B with Arrow1 and B connects to A with Arrow2). I work with react-konva package.
I have implemented a demo on Code Sandbox with some basic functionality.
On line 5, 6 you'll find the Nodes info, on line 21 there exists a high-order component that creates the Arrow based on the start Node and end Node position.
In the default example, the arrows are working as expected. If you try to set the value of redNode.x to 300 the arrows overlap. The same happens when blueNode.x is equal to -100. This has something to do with the way I calculate the arrows (I suspect the equations on line 38).
Also note that as redNode.x moves to value 300, the two arrows approach each other (this happens on other values too), which is something I do not want to happen. I expect the arrows to have the same shape when the two nodes change position and not to overlap or approach each other. Unfortunately, my lack of mathematics does not help me solve the problem. I also tried to create a custom shape using quadraticCurveTo method without success.
Thanks in advance for the help. I appreciate all the solutions.
There are many ways to make curved lines. Here is my attempt to make it better:
import React from "react";
import ReactDOM from "react-dom";
import { Stage, Layer, Circle, Arrow, Text } from "react-konva";
const BLUE_DEFAULTS = {
x: 100,
y: 100,
fill: "blue",
width: 30,
height: 30,
draggable: true
};
const RED_DEFAULTS = {
x: 100,
y: 300,
fill: "red",
width: 30,
height: 30,
draggable: true
};
const Edge = ({ node1, node2 }) => {
const dx = node1.x - node2.x;
const dy = node1.y - node2.y;
let angle = Math.atan2(-dy, dx);
const radius = 20;
const curvePower = 30;
const arrowStart = {
x: node2.x + -radius * Math.cos(angle + Math.PI),
y: node2.y + radius * Math.sin(angle + Math.PI)
};
const arrowEnd = {
x: node1.x + -radius * Math.cos(angle),
y: node1.y + radius * Math.sin(angle)
};
const arrowCurve = {
x:
(arrowStart.x + arrowEnd.x) / 2 +
curvePower * Math.cos(angle + Math.PI / 2),
y:
(arrowStart.y + arrowEnd.y) / 2 +
curvePower * Math.sin(angle - Math.PI / 2)
};
return (
<Arrow
tension={0.2}
points={[
arrowStart.x,
arrowStart.y,
arrowCurve.x,
arrowCurve.y,
arrowEnd.x,
arrowEnd.y
]}
stroke="#000"
fill="#000"
strokeWidth={3}
pointerWidth={6}
/>
);
};
const App = () => {
const [blueNode, updateBlueNode] = React.useState(BLUE_DEFAULTS);
const [redNode, updateRedNode] = React.useState(RED_DEFAULTS);
return (
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
<Text text="Drag any node to see connections change" />
<Edge node1={blueNode} node2={redNode} />
<Edge node1={redNode} node2={blueNode} />
<Circle
{...blueNode}
onDragMove={e => {
updateBlueNode({ ...blueNode, ...e.target.position() });
}}
/>
<Circle
{...redNode}
onDragMove={e => {
updateRedNode({ ...redNode, ...e.target.position() });
}}
/>
</Layer>
</Stage>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Demo: https://codesandbox.io/s/react-konva-double-connected-objects-m5g22

Categories