I'm trying to get a react component working which uses the d3 sunburst chart. The problem I'm facing is I need a way to update the zoom level of the sunburst component, as a trigger from an external component. I'm sending the node to be zoomed to via the props to the sunburst component, and that changes each time there is an external input for a different component.
Here is the pseudocode I have so far but, each the the props changes.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
const SunburstSmooth = (props) => {
const prevProps = usePrevious(props);
useEffect(() => {
if (!isEqual(prevProps, props)) {
if (props.navigateTo) {
zoomToSunburst(props.navigateTo);
} else {
if (props.data) {
renderSunburstSmooth();
update();
}
}
}
}, [props])
// Global Variables
let root, node;
let gWidth, gHeight, radius, svg;
let color;
let x, y, arc, partition;
const svgRef = useRef();
const zoomToSunburst = (nodeToRender) => {
const gWidth = props.width;
const gHeight = props.height;
const radius = (Math.min(gWidth, gHeight) / 2) - 10
const svg = d3.select(svgRef.current)
const x = d3.scaleLinear().range([0, 2 * Math.PI])
const y = d3.scaleSqrt().range([0, radius])
const partition = d3.partition()
const arc = d3.arc()
// ....
root = d3.hierarchy(nodeToRender);
node = nodeToRender;
svg.selectAll("path")
.transition("update")
.duration(1000)
.attrTween("d", (d, i) =>
arcTweenPath(d, i, radius, x, y, arc));
}
const update = () => {
root.sum(d => d[props.value]);
let gSlices = svg.selectAll("g")
.data(partition(root).descendants())
.enter()
.append("g");
gSlices.exit().remove();
gSlices.append("path")
.style('fill', (d) => {
let hue;
const current = d;
if (current.depth === 0) {
return '#c6bebe';
}
return color((current.children ? current.x0 : current.parent.x0));
})
.attr('stroke', '#fff')
.attr('stroke-width', '1')
svg.selectAll("path")
.transition("update")
.duration(750)
.attrTween("d", (d, i) =>
arcTweenPath(d, i, radius, x, y, arc));
}
// Sets up the initial sunburst
const renderSunburstSmooth = () => {
// Structure
gWidth = props.width;
gHeight = props.height;
radius = (Math.min(gWidth, gHeight) / 2) - 10;
// Size our <svg> element, add a <g> element, and move translate 0,0 to the center of the element.
svg = d3.select(svgRef.current)
.append("g")
.attr("id", "bigG")
.attr("transform", `translate(${gWidth / 2},${gHeight / 2})`);
x = d3.scaleLinear().range([0, 2 * Math.PI]);
y = d3.scaleSqrt().range([0, radius]);
// Calculate the d path for each slice.
arc = d3.arc()
// .....
// Create our sunburst data structure
partition = d3.partition();
// Root Data
root = d3.hierarchy(props.data);
node = props.navigateTo || root;
}
return (
<div id={props.keyId}>
<svg ref={svgRef}/>
</div>
);
}
A lot of the code base code is from here:
http://bl.ocks.org/metmajer/5480307
Right now each time the prop is updated, the entire component is re-rendered. How do i make it so that it only updates the existing svg container, when the props.navigateTo is changed externally.
Right now, the rendering of your component depends on a change in any element in your props. In order to make it only depend on the change of the prop navigateTo, you would need two things:
1- navigateTo would need to be a state created with something like const [navigateTo, setNavigateTo] = UseState(""); in the parent component, and passed down as a prop. (I'm just putting this here to make sure you are doing this) So something like:
const parent = (props) => {
const [navigateTo, setNavigateTo] = UseState("");
<<any other code>>
return <SunburstSmooth
navigateTo={navigateTo}
data={data}
>
}
2- To make your code clearer, you can decompose the props to make the rendering depending on only a certain element of it:
const SunburstSmooth = (props) => {
const {navigateTo, data, ...rest} = props;
useEffect(() => {
if (data) {
renderSunburstSmooth();
update();
}
}, [navigateTo])
<<rest of your code>>
This ensures that the component is re-rendered only on changes of navigateTo, and not when something like data or any other prop is changed. In case you also want it to re-render every time data is changed, for example, you can just add it to the array at the end of the UseEffect hook
useEffect(() => {...}, [navigateTo, data])
Regarding the re-rendering of only the SVG element, any useEffect hook will cause everything in your return to be re-rendered, so the SVG would have to be the only thing your component returns in order to only re-render that. I can't see why you would mind re-rendering the enclosing div though
Related
I am creating a React application and have a component that creates D3 legends for every layer I have selected. When I have multiple legends showing and one gets deselected, the remaining legend has its color ramp replaced by the colors of the removed legend. I would expect my remaining legend to have its proper color ramp.
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import * as d3 from "d3";
function formatColors(colors, numberStops) {
let colorOffsets = [];
colors.forEach((colorHex, index) => {
const colorPct = ((index / numberStops) * 100.0).toFixed(2);
colorOffsets.push(
{offset: `${colorPct}%`, color: colorHex}
);
});
return colorOffsets;
}
const D3Legend = (props) => {
const d3Container = useRef(null);
useEffect(() => {
if(props.serviceType && d3Container.current){
const numberStops = props.legendStyle[props.serviceType].colors.length - 1;
const colors = props.legendStyle[props.serviceType].colors;
const colorOffsets = formatColors(colors, numberStops);
const svg = d3.select(d3Container.current);
const defUpdate = svg.selectAll("defs").data([1]);
const defEnter = defUpdate.enter().append("defs");
defEnter.append("linearGradient");
const linearGradient = defUpdate.merge(defEnter);
linearGradient
.select("linearGradient")
.attr("id", `linear-gradient-${props.serviceType}`)
.selectAll("stop")
.data(colorOffsets)
.enter().append("stop")
.attr("offset", function(d) { return d.offset; })
.attr("stop-color", function(d) { return d.color; });
defUpdate.exit().remove();
const update = svg.selectAll("g").data([1]);
const enter = update.enter().append("g");
enter.append("rect");
const bar = update.merge(enter);
bar
.select("rect")
.attr("width", 220)
.attr("height", 15)
.style("fill", `url(#linear-gradient-${props.serviceType})`);
update.exit().remove();
}
}
},
//React Hook useEffect has an unnecessary dependency: 'd3Container.current'.
//Either exclude it or remove the dependency array. Mutable values like
//'d3Container.current' aren't valid dependencies because mutating them
//doesn't re-render the component react-hooks/exhaustive-deps
//[props.serviceType, d3Container.current])
[props.serviceType])
return (
<>
<svg
className={"d3-legend " + props.legendStyle[props.serviceType].type}
ref={d3Container}
/>
<span className="d3-x-axis">Low</span>
<span className="d3-x-axis">Med</span>
<span className="d3-x-axis">High</span>
</>
);
}
D3Legend.propTypes = {
serviceType: PropTypes.string.isRequired,
legendStyle: PropTypes.object.isRequired,
}
export default D3Legend;
Below is the html elements when both legends are toggled (left) and when deselecting the top one (right).
The stop-colors from the deselected layer / legend end up replacing the ones for the still active layer / legend even though the correct legend name and ID is present.
I think it is related to how D3 binds the data and selects elements but I can not seem to work it out.
Update
Adding svg.selectAll("*").remove();
below const svg = d3.select(d3Container.current);
Has seemingly stopped this issue from happening. I don't quite understand why though...
I’m using this Rectangle class
class Rectangle {
constructor(x, y, width, height, color, hasCollision = false, kills = false) {
this.A = new Point(x, y)
this.B = new Point(x + width, y)
this.C = new Point(x + width, y + height)
this.D = new Point(x, y + height)
this.center = new Point(x + width / 2, y + height / 2)
this.width = width
this.height = height
this.color = color
this.hasCollision = hasCollision
this.kills = kills
}
get vertices() {
const { A, B, C, D } = this
return {
A,
B,
C,
D
}
}
get x() {
return this.A.x
}
get y() {
return this.A.y
}
static translate(rectangle, vector) {
for (let vertice of Object.values(rectangle.vertices)) {
vertice.translate(vector)
}
rectangle.center.translate(vector)
}
translate(vector) {
Rectangle.translate(this, vector)
}
hasUserFallenInTrap(user) {
if (circleIntersectsRectangle(user, this) && this.kills) {
return true
}
return false
}
display(ctx, useOwnColor = true) {
const { x, y, width, height } = this
if (useOwnColor) {
ctx.fillStyle = this.color
? this.color.hexString
: this.kills
? 'red'
: '#000000'
}
ctx.fillRect(x, y, width, height)
}
}
I need to store a bunch of Rectangles in an array so that I can display them in a canvas (wrapped in a React component). The component doesn’t update every frame, I’m using my own draw function on the canvas :
// Here is the component
class Game extends React.Component {
constructor(props) {
super(props)
const world = loadMap('world1')
this.state = {
currentWorld: world,
users: {},
user: new User(
world.spawn.center.x,
world.spawn.center.y,
12,
Color.random(),
'',
world.spawn
)
}
this.canvas = React.createRef()
this.ctx = null
}
componentDidMount() {
const { user } = this.state
server.userConnects(user)
openConnection()
this.ctx = this.canvas.current.getContext('2d')
setPointerLock(this.canvas.current, this.mouseMoved)
this.request = window.requestAnimationFrame(this.draw)
}
componentWillUnmount() {
closeConnection()
window.cancelAnimationFrame(this.request)
}
shouldComponentUpdate() {
return false
}
updateUserID = id => {
this.setState({ u })
}
mouseMoved = event => {
const { currentWorld, user } = this.state
const displacement = new Vector(
event.movementX / pixelRatio,
event.movementY / pixelRatio
)
user.translate(displacement)
resolveWorldBordersCircleCollision(user)
for (const w of currentWorld.walls) {
if (w.hasCollision) stepCollisionResolve(user, w)
}
server.updateUserPosition(user)
}
draw = () => {
const { currentWorld, users, user } = this.state
this.request = window.requestAnimationFrame(this.draw)
this.ctx.clearRect(0, 0, WIDTH, HEIGHT)
this.ctx.fillText(fpsCounter.fps, 1000, 20)
currentWorld.walls.forEach(w => {
if (w.hasCollision && resolveCollisionCircleRectangle(user, w)) {
server.updateUserPosition(user)
}
w.display(this.ctx)
})
currentWorld.movableWalls.forEach(w => {
w.walkPath()
if (w.hasCollision && resolveCollisionCircleRectangle(user, w)) {
server.updateUserPosition(user)
}
w.display(this.ctx)
})
currentWorld.traps.forEach(t => {
t.display(this.ctx)
if (t.hasUserFallenInTrap(user)) {
user.kill()
server.updateUserPosition(user)
}
})
user.display(this.ctx, false)
Object.values(users)
.filter(u => u.id !== user.id)
.forEach(u => {
u.display(this.ctx, true)
})
}
render() {
return (
<>
<canvas ref={this.canvas} id="canvas" width={WIDTH} height={HEIGHT} />
</>
)
}
}
I’m not sure how I can store and manage this array of rectangles.
I’m translating a rectangle using the class method translate.
const rect = new Rectangle(10, 10, 10, 10)
rect.translate({x: 10, y: 20})
But I can’t do that if the rectangle is in the state of a component.
calling rect.translate would mutate the state directly.
Creating a new object everytime I’m updating the state completely defeats the purpose of using this class .
using object destructuring would create a plain new object, and so I wouldn’t be able to call its display method anymore :
// changing a single rectangle for example
const { rectangles } = this.state
this.setState({ rectangles: [...rectangles.splice(0,index) , { ...rectangles[index], x: rectangles[index].x + 10, y: rectangles[index].y + 10 }, ...rectangles.splice(index + 1)]
Using an array outside of any react component appears like the only solution, but not really satisfying either for a react app.
I’m out of ideas to manage the state of my applications.
Are there better ways to store this array of Rectangle instances?
Or using this kind of object design is simply impossible in react?
The hard part is figuring out whether mutability is really needed (e.g. for performance reasons) or if the state can live inside React after giving it more thought (and a good night's sleep).
If mutability is needed, that part of the app can live outside of React as an Uncontrolled Component (the state can live in DOM or some imperative code that will update the DOM).
A parent component from React can access the out-of-react mutable state (from event handlers or lifecycle methods) by using a ref (see the article above). A simplified example, using a useRef hook:
const rndColor = () => `rgb(${Array.from(Array(3)).map(() => Math.random()*255).join(',')})`
const App = () => {
const canvasRef = React.useRef()
const handleClick = () => {
// --> rect.translate({x: 10, y: 20}) can be called here <--
canvasRef.current.style.background = rndColor()
}
return <canvas ref={canvasRef} onClick={handleClick} />
}
ReactDOM.render(<App />,document.getElementById('root'))
canvas {
width: 150px;
height: 150px;
background: orange;
}
<script src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div id="root"></div>
I'm trying to visualize a stock price chart, using reactJS and D3JS(v5).
Fetching data from Alphavantage API
Framework = ReactJS (which, honestly, introduces an unnecessary layer of complexity)
D3JS for viz
Roughly three parts to this:
global function parseData(). Parses data from the fetch into a
format that d3 can read. Verified working, but included for
completeness.
componentDidMount() call within class App. Promise chain: has fetch call, then parseData(), then setState(), and finally
drawChart()
drawChart() local function within class App: contains all D3 logic. Will be separated out into its own component later. Works if I pass it data from a local json (commented out; some sample rows provided below), but not when I try to pass it
data from fetch.
Code so far: this is all in App.js, no child components:
import React, { Component } from 'react';
import './App.css';
import * as d3 from "d3";
//import testTimeData from "./data/testTimeData"
function parseData(myInput) {
// processes alpha vantage data into a format for viz
// output an array of objects,
// where each object is {"Date":"yyyy-mm-dd", "a":<float>}
let newArray = []
for (var key in myInput) {
if (myInput.hasOwnProperty(key)) {
const newRow = Object.assign({"newDate": new Date(key)}, {"Date": key}, myInput[key])
newArray.push(newRow)
}
}
//console.log(newArray)
// 2. Generate plotData for d3js
let newArray2 = []
for (var i = 0; i < newArray.length; i++) {
let newRow = Object.assign({"Date": newArray[i]["Date"]}, {"a":parseFloat(newArray[i]["4. close"])})
newArray2.unshift(newRow)
}
return newArray2
}
class App extends Component {
constructor() {
super()
this.state = {
ticker:"",
plotData:[]
}
this.drawChart = this.drawChart.bind(this)
}
// setState() in componentDidMount()
// fetch monthly data
// hardcode ticker = VTSAX for the moment
// and call this.drawChart()
componentDidMount() {
const ticker = "VTSAX"
const api_key = "EEKL6B77HNZE6EB4"
fetch("https://www.alphavantage.co/query?function=TIME_SERIES_MONTHLY&symbol="+ticker+"&apikey="+api_key)
.then(console.log("fetching..."))
.then(response => response.json())
.then(data => parseData(data["Monthly Time Series"]))
.then(data => console.log(data))
.then(data =>{this.setState({plotData:data, ticker:ticker})})
.then(this.drawChart());
}
drawChart() {
const stockPlotData = this.state.plotData
console.log("stockPlotData.length=", stockPlotData.length)
var svg = d3.select("body").append("svg")
.attr("width", 960)
.attr("height", 300)
var margin = {left:50, right:30, top:30, bottom: 30}
var width = svg.attr("width") - margin.left - margin.right;
var height = svg.attr("height") - margin.bottom - margin.top;
var x = d3.scaleTime().rangeRound([0, width]);
var y = d3.scaleLinear().rangeRound([height, 0]);
var parseTime = d3.timeParse("%Y-%m-%d");
x.domain(d3.extent(stockPlotData, function(d) { return parseTime(d.date); }));
y.domain([0,
d3.max(stockPlotData, function(d) {
return d.a;
})]);
var multiline = function(category) {
var line = d3.line()
.x(function(d) { return x(parseTime(d.date)); })
.y(function(d) { return y(d[category]); });
return line;
}
var g = svg.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
var categories = ['a'];
for (let i in categories) {
var lineFunction = multiline(categories[i]);
g.append("path")
.datum(stockPlotData)
.attr("class", "line")
.style("stroke", "blue")
//.style("stroke", color(i))
.style("fill", "None")
.attr("d", lineFunction);
}
// append X Axis
g.append("g")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x).tickFormat(d3.timeFormat("%Y-%m-%d")));
// append Y Axis
g.append("g")
.call(d3.axisLeft(y));
}
render(){
return (<div>{this.state.ticker}</div>)
}
}
export default App;
Outputs from console.log() calls:
data => console.log(data) within the promise chain of componentDidMount() correctly displays the fetched data
But console.log("stockPlotData.length=", stockPlotData.length) call in the drawChart() function returns stockPlotData.length= 0. Did I call this.setState() wrongly?
The page renders "VTSAX" correctly in the render(){return(...)} call at the bottom, indicating that this.setState updated the ticker variable correctly.
Some test data rows:
[
{"Date":"2016-01-15", "a": 220 },
{"Date":"2016-01-16", "a": 250},
{"Date":"2016-01-17", "a": 130},
{"Date":"2016-01-18", "a": 180},
{"Date":"2016-01-19", "a": 200},
]
That's a lot of code to read, but based on a quick glance, you probably want to call this.drawChart() inside of a .then instead of after your promise chain, because it's gonna fire before your promises resolve.
My code looks something like this:
update = (props) => {
if (!this.rootEl) {
return;
}
const { viewportWidth, viewportHeight, cards } = props;
const simulation = d3.forceSimulation(cards)
.force('charge', d3.forceManyBody())
.force('center', d3.forceCenter(viewportWidth / 2, viewportHeight / 2))
.on('tick', this.tick);
};
tick = () => {
const { viewportWidth, viewportHeight, cards } = this.props;
const svg = d3.select(this.rootEl).attr('width', viewportWidth).attr('height', viewportHeight);
const circle = svg.selectAll('circle').data(cards);
const createCircle = chain => chain
.attr('cx', d => d.x)
.attr('cy', d => d.y)
.attr('r', d => 22);
createCircle(circle.enter().append('circle'));
createCircle(circle.transition(t));
circle.exit().remove();
}
I don't understand what kind of "magic" d3 is doing to add the x, y, vx, vy to the data inside the tick?
For example if d3 is used outside the tick function doesn't have those properties added to the data. simulation variable is never used and the const svg = d3.select... is never linked.
I can't think of anything except a messy global state. Is this some kind of global or how they are linked?
I'm new to d3 and I think there is too much magic :)
Ok, got it.
When you call d3.forceSimulation(cards);
The cards array of objects that I pass into the simulation are changed;
Then when I use the cards again those are changed;
As I'm using react, this is not the best, but there are workarounds.
What I want to do :
I would like to create a pie chart with the javascript library d3.js. I get data from my server with an AJAX call, and then create my piechart with it.
What I've done :
For this I created 2 react component : one which implements the ajax call and pass his state to the other component which create the pie chart with the data in props.
First component :
import React from 'react';
import Pie from './pie.jsx';
class RingChart extends React.Component {
loadPieChart() {
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: (data) => {
let d3data = Object.keys(data)
.filter(key => key!= 'pourcentage')
.map(key => { return {name: key, value: data[key]} });
this.setState({dataPie: d3data, percent: data['pourcentage']});
},
error: (xhr, status, err) => {
console.error(this.props.url, status, err.toString());
}
});
}
constructor(props) {
super(props);
this.state = {dataPie: [], percent: 0};
}
componentDidMount() {
this.loadPieChart();
setInterval(this.loadCommentsFromServer, this.props.pollInterval);
}
render() {
return (
<div className="chart">
<Pie size="400" data={this.state.dataPie} percent={this.state.percent}></Pie>
</div>
);
}
}
export default RingChart;
Here my second component which create my pie chart :
import React from 'react';
class Pie extends React.Component {
constructor(props) {
super(props);
}
componentDidUpdate() {
let size = this.props.size;
const data = this.props.data;
let percent = this.props.percent;
console.log(data);
var margin = {top: 20, right: 20, bottom: 20, left: 20};
let width = size - margin.left - margin.right;
let height = width - margin.top - margin.bottom;
var chart = d3.select(".ring")
.append('svg')
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + ((width/2)+margin.left) + "," + ((height/2)+margin.top) + ")");
var radius = Math.min(width, height) / 2;
var color = d3.scale.ordinal()
.range(["#313F46", "#4DD0E1"]);
var arc = d3.svg.arc()
.outerRadius(radius)
.innerRadius(radius - 20);
var pie = d3.layout.pie()
.sort(null)
.startAngle(1.1*Math.PI)
.endAngle(3.1*Math.PI)
.value(function(d) { return d.value; });
var g = chart.selectAll(".arc")
.data(pie(data))
.enter().append("g")
.attr("className", "arc");
function tweenPie(b) {
var i = d3.interpolate({startAngle: 1.1*Math.PI, endAngle: 1.1*Math.PI}, b);
return function(t) { return arc(i(t)); };
}
g.append("path")
.attr("fill", function(d, i) { return color(i); })
.transition()
.ease("exp")
.duration(600)
.attrTween("d", tweenPie);
g.append("text")
.attr("dy", ".35em")
.style("text-anchor", "middle")
.style("color", "#263238")
.style("font-size", "55px")
.attr("className", "text-pie")
.text(function(d) { return percent+'%'; });
}
render() {
return (<div className="ring" style={{textAlign:'center'}}></div>)
}
}
export default Pie;
My problem :
Nothing is created ... The console.log(data) in my second component return firstly an empty array and secondly my array with the correct values. It's weird that nothing is created, but even if it was created, my component Pie is call twice.
How can I call my component only once ?
Why my component is not created ?
How can I do for that my pie chart will be update automatically when new values appears on my server ?
thank's a lot, I'm discovering react.js, a lot of basic's notions is unknow for me.
EDIT : Ok now my component is created, I had forgotten the ".ring" in my line : var chart = d3.select("ring") -_-.
But as I said before, now two component are created (one empty, and one correct). Sometimes I have two components created correctly. It depends on the AJAX call ... How can I solve the problem of the async AJAX call ?
React basically takes the props and state of a component and when these changes it updates the component (it executes the render function again and then diffs this with your actual DOM).
You tap into the lifecycle with the ComponentDidUpdate method. This will be called every time React updates a component. Therefore, the first time it's called your array is empty (your ajax call hasn't returned). Then your call returns, which results in the array being filled. React updates your component and you get the updated data. The fact you see the log twice is simply because, according to React, your component should be updated: Once with an empty array, and once with the results in the filled array.
To see if you actually create two components, check out either the constructor or the ComponentDidMount methods, logging in there should only be called once per intended creation of the component.
To summarize: it's no problem that the ComponentDidUpdate call is called more than once.