Edit svgs instead of recreating - javascript

I have an apparently simple misunderstanding of d3, and can't find a matching explanation, although lots of stuff I look for seems close.
Basically, I have an array of data, and I want to render a in an for each item. I want each to have its own children elements (a and a text />) that depends on the datum bound to each .
My problem is that when the data updates, additional and elements are added to each , rather than replacing it.
A minimal reproduction codepen is attached. Any assistance would be greatly appreciated!
https://codepen.io/kamry-bowman/pen/GaLvKJ
html:
<body>
<svg />
<button>Increment</button>
</body>
js:
let state = [];
for (let i = 0; i < 5; i++) {
state.push({ id: i, value: (i + 1) * 2 });
}
function render() {
console.log('running', state)
const svg = d3
.select("svg")
.attr("width", "1000")
.attr("height", "200");
const gGroup = svg
.selectAll("g")
.data(state, d => d.id)
.join("g")
.attr("transform", (d, i) => `translate(${100 * i})`);
gGroup
.append("circle")
.attr("cx", "50")
.attr("cy", "50")
.attr("r", "50")
.attr("fill", "none")
.attr("stroke", "black");
gGroup
.append("text")
.text(d => d.value)
.attr("stroke", "black")
.attr("x", "40")
.attr("y", "55")
.attr("style", "font: bold 30px sans-serif");
}
function clickHandler() {
state = state.map(obj => ({ ...obj, value: obj.value + 1 }))
render()
}
document.querySelector('button').addEventListener('click', clickHandler)
render()
I have found a hacky fix of calling gGroup.html(''), but that seems like I'm missing something with how the library is supposed to work.

Add this line directly after you create your svg in the render method:
svg.selectAll("*").remove();
This will remove all children of the root svg. Otherwise you'll just keep drawing on top of the same svgs.
Try it here
And the docs:
d3.selectAll()
selection.remove()
EDIT
To update just the text values themselves, call this update() function in the click handler instead:
function update() {
const svg = d3.select("svg")
d3.selectAll("svg text").each(function(d, i) {
d3.select(this).text(state[i].value);
});
}
The codepen has been updated to use this method
EDIT AGAIN
If you want to bind the values to their id, just access it from d in .each() method instead of i (which is the index in the collection):
function update() {
const svg = d3.select("svg")
d3.selectAll("svg text").each(function(d, i) {
d3.select(this).text(
state.find((e) => e.id === d.id).value
);
});
}
Since the entries can be in any order, we'll have to use Array.find() to find the element with the matching id. Here's another codepen for this.

Related

Transition.each in d3 doesn't interpolate attributes

I'm missing something in my understanding here rather than a bug I think...
In d3, I'm trying to update multiple attribute values within a transition. Using multiple transition.attr declarations works fine. But I'd like to use .each as in the real world I have a big calculation I don't want to do twice or more... I tried using transition.each but the attribute values don't interpolate.
Is it the d3.select within the each? I tried d3.active but that didn't do anything.
(There is a https://github.com/d3/d3-selection-multi library that supplied .attrs that worked nicely up to v5, but not with the new v6...)
See https://jsfiddle.net/f679a4yj/3/ - what I can do to get the animation but through the .each, what am I not getting?
The problem here is a misconception regarding transition.each: you cannot use it to wrap several transition.attr or transition.style methods. In fact, it is used only to invoke arbitrary code to each element in that transition, just like selection.each.
Therefore, when you do...
.each (function (d, i) {
// complicated stuff with d I don't want to do twice
d3.select(this)
.attr("x", ((zz+i)%4) *20)
.attr("y", ((zz+i)%4) *40)
})
... you are simply changing the attribute in the DOM element, and the element will immediately jump to the new x and y position, as expected. In other words, that d3.select(this).attr(... in your code is a selection.attr, not a transition.attr.
You have several alternatives (for avoiding redundant complex calculations). If you want to stick with transition.each, you can use it to create a new local variable, or maybe a new property. For that to work, we need to have objects as the data elements in the array, and then we just use transition.attr as usual:
.each(function(d, i) {
d.complexValue = ((zz + i) % 4) * 20;
})
.attr("x", function(d) {
return d.complexValue;
})
//etc...
Here is a demo:
const sel = d3.select("svg")
.selectAll(null)
.data([{
color: "red"
}, {
color: "blue"
}, {
color: "green"
}, {
color: "yellow"
}])
.enter()
.append("rect")
.attr("width", 30)
.attr("height", 30)
.attr("x", function(d, i) {
return i * 20;
})
.attr("y", function(d, i) {
return i * 40;
})
.style("fill", function(d) {
return d.color;
});
let zz = 0;
function each() {
zz++;
d3.selectAll("rect")
.transition()
.duration(1000)
.each(function(d, i) {
d.complexValue = ((zz + i) % 4) * 20;
})
.attr("x", function(d) {
return d.complexValue;
})
.attr("y", function(d) {
return d.complexValue;
})
};
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="demo"><svg width="360px"></svg></div>
<button onclick="each()">In an Each</button>

Text label hides in circle d3

i've stuck for this code. Im trying to add text behind the circle
and sample code like this
for the text:
g.selectAll(".my-text")
.data(marks)
.enter().append("text")
.attr("class", "text-desc")
.attr("x", function(d, i) {
return projection([d.long, d.lat])[0];
})
.attr("y", function(d, i) {
return projection([d.long, d.lat])[1];
})
.attr('dy', ".3em")
.text(function() { return location})
.attr("text-anchor", "middle")
.attr('color', 'white')
.attr('font-size', 15)
and for circle like this
g.selectAll(".circle")
.data(marktests)
.enter().append("circle")
.attr("class", "bubble")
.attr("cx", function(d, i) {
return projection([d.long, d.lat])[0];
})
.attr("cy", function(d, i) {
return projection([d.long, d.lat])[1];
})
.attr("r", function() {
return myRadius(locationGroup + 20);
})
.on('mouseover', tipBranch.show)
.on('mouseout', tipBranch.hide)
.on('click', function(d){
window.open('http://localhost:8000/detail/'+d.branch);
});
}
but i got result just like this
and the elements if using inspect element
Thank you if you can help to help me and explain how to solve the problem code
First of all I noticed the following issue:
g.selectAll(".my-text")
.data(marks)
.enter().append("text")
.attr("class", "text-desc")
Also the following line: .text(function() { return location}) is faulty because you are missing the data object that you iterate with. This might be changed to: .text(function(d) { return d.location})
you are selecting all elements with class .my-text but then you are attaching text-desc as class to the text elements. The correct change for this would be:
g.selectAll(".text-desc")
.data(marks)
.enter().append("text")
.attr("class", "text-desc")
considering that you want to use text-desc as a class. the same problem is with the circle as well: Either do: g.selectAll("circle") to select the circle tag elements or g.selectAll(".bubble") to select the bubbles.
You are also using different iterating objects for text and circles - usually you should iterate over a single collection.
Another issue with the sample is that location and locationGroup are not part of the collection items. I would expect that the values to be taken from the data object as such .text( d => d.location) and .attr("r", d => myRadius(d.locationGroup)). Before proceeding make sure that you populate iterating items with this properties.
Another approach would be to do the following:
const group =
g.selectAll('.mark')
.data(marks)
.enter()
.append('g')
.attr('class', 'mark')
.attr('transform', d => {
const proj = projection([d.long, d.lat])
return `translate(${proj[0]}, ${proj[1]})`;
})
group.append('text').text(d => return d.location) //apply other props to text
group.append('circle').text(d => return d.location) //apply other props to circle
Using this approach will allow you to iterate the collection with a group element and use translation property in order to move the group to the location (small improvement, projection will be executed once) and use the group to populate with other elements: text, circle.
Hope it helps.

Pass the current parameter to function in a loop

I'm using D3 to print multiple rect out. Now I'hope the rect could allow user click on it and the active some function.
For example, there are 3 rectangles, "Tom", "Mary" and "Ben". When user click on different rect, it will pass the current value. Like when I click on "Tom" it will pass "Tom" to call the function.
However, I found that after finish print out the rect, no matter I click on which rect, they both return the least value of the dataset.
In my example, even I click on "Tom" or "Mary", both return "Ben".
for (var i = 0; i < ward_set.length; i++) {
var ward_id = ward_set[i];
legend.append("rect")
.attr("x", legend_x + 180 + 100 * n)
.attr("y", legend_y)
.attr("width", 18)
.attr("height", 18)
.attr("fill", colors[count])
.attr("class", "legend" + ward_set[i])
.on("click", function() {
console.log(ward_id);
});
}
Your question perfectly illustrates a very important principle, a rule of thumb if you like, when writing a D3 code: never use a loop to append elements. Use the enter/update/exit pattern instead.
What happens when you use a loop (be it for, while, forEach etc...) is that not only there is no data binding, but also you experience this strange outcome you described (getting always the last value), which is explained here: JavaScript closure inside loops – simple practical example
Therefore, the solution using a D3 idiomatic enter selection would be:
const data = ["Tom", "Mary", "Ben"];
const svg = d3.select("svg");
const rects = svg.selectAll(null)
.data(data)
.enter()
.append("rect")
//etc...
Then, in the click listener, you get the first argument, which is the datum:
.on("click", function(d) {
console.log(d)
})
Here is a demo:
const data = ["Tom", "Mary", "Ben"];
const svg = d3.select("svg");
const rects = svg.selectAll(null)
.data(data)
.enter()
.append("rect")
.attr("width", 50)
.attr("height", 150)
.attr("x", (_, i) => i * 60)
.on("click", function(d) {
console.log(d)
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg></svg>

Change style on points, d3

I have this d3 polygons based on a dataset.
let svg = d3.select($element[0]).append('svg')
.attr("width", 800)
.attr("height", 800);
let myGroups = svg.selectAll('g').data(rooms);
let myGroupsEnter = myGroups.enter().append("g");
myGroupsEnter.append("polygon")
.style("fill", "none")
.style("stroke", "black")
.style("strokeWidth", "10px")
.attr("points", function (d) {
let q = d.footprint.coordinates.map(function (point, idx) {
let _d = [];
_d.push(scale(point[0]));
_d.push(scale(point[1]));
return _d;
});
return q.join(" ");
});
});
I want to change the style fill attribute on the items that meets a requirment when user selects a value from a radiobutton.
Can I somehow update this attribute? Or do I need to redraw everything?
I think this is what you're looking for.
.style('fill', function(d) {
/* match item (d) with some condition */
})
On value change as well, you can just restyle/refill the polygons:
svg.selectAll('polygon').style('fill', function(d) {
/* match item (d) with value selected */
});

Get width of d3.js SVG text element after it's created

I'm trying to get the widths of a bunch of text elements I have created with d3.js
This is how I'm creating them:
var nodesText = svg.selectAll("text")
.data(dataset)
.enter()
.append("text")
.text(function(d) {
return d.name;
})
.attr("x", function(d, i) {
return i * (w / dataset.length);
})
.attr("y", function(d) {
return 45;
});
I'm then using the width to create rectangles the same size as the text's boxes
var nodes = svg.selectAll("rect")
.data(dataset)
.enter()
.append("rect")
.attr("x", function(d, i) {
return i * (w / dataset.length);
})
.attr("y", function(d) {
return 25;
})
.attr("width", function(d, i) {
//To Do: find width of each text element, after it has been generated
var textWidth = svg.selectAll("text")
.each(function () {
return d3.select(this.getComputedTextLength());
});
console.log(textWidth);
return textWidth;
})
.attr("height", function(d) {
return 30;
})
I tried using the Bbox method from here but I don't really understand it. I think selecting the actual element is where I'm going wrong really.
I would make the length part of the original data:
var nodesText = svg.selectAll("text")
.data(dataset)
.enter()
.append("text")
.text(function(d) {
return d.name;
})
.attr("x", function(d, i) {
return i * (w / dataset.length);
})
.attr("y", function(d) {
return 45;
})
.each(function(d) {
d.width = this.getBBox().width;
});
and then later
var nodes = svg.selectAll("rect")
.data(dataset)
.enter()
.append("rect")
.attr("width", function(d) { return d.width; });
You can use getBoundingClientRect()
Example:
.style('top', function (d) {
var currElemHeight = this.getBoundingClientRect().height;
}
edit: seems like its more appropriate for HTML elements. for SVG elements you can use getBBbox() instead.
d3.selectAll returns a selection. You can get each of the elements by navigating through the array in the _groups property. When you are determining the width of a rectangle, you can use its index to get the corresponding text element:
.attr('width', function (d, i) {
var textSelection = d3.selectAll('text');
return textSelection._groups[0][i].getComputedTextLength();
});
The _groups property of d3's selection has a list of nodes at [0]. This list contains all of the selected elements, which you can access by index. It's important that you get the SVG element so that you can use the getComputedTextLength method.
You may also want to consider creating the rect elements first, then the text elements, and then going back to the rectangles to edit the width attribute, so that the text elements are on top of the rectangles (in case you want to fill the rectangles with color).
Update:
It's typically preferred that you don't access _groups, though, so a safer way to get the matching text element's width would be:
.attr('width', function (d, i) {
return d3.selectAll('text').filter(function (d, j) { return i === j; })
.node().getComputedTextLength();
});
Using node safely retrieves the element, and filter will find the text element which matches index.

Categories