I created this simple example using data join in D3. It works: when user clicks on +1 a green is added to the list, when clicks on -1 the last color in the list is removed.
Here the code:
const colors = ["white", "yellow", "red", "brown", "orange"];
const root = d3.select("#root");
const addColor = d3.select("#add-color");
addColor.on("click", (d) => {
colors.push("green");
update();
});
const removeColor = d3.select("#remove-color");
removeColor.on("click", (d) => {
colors.pop();
update();
});
const ul = root.append("ul");
ul.selectAll("li")
.data(colors)
.join(
(enter) => enter.append("li").text((d) => d),
(update) => update,
(exit) => exit.remove()
);
function update(){
ul.selectAll("li")
.data(colors)
.join(
(enter) => enter.append("li").text((d) => d),
(update) => update,
(exit) => exit.remove()
);
}
#buttons-container {
display: flex;
margin-bottom: 30px;
}
#buttons-container div {
min-width: 30px;
text-align: center;
cursor: pointer;
border: 1px solid black;
margin-right: 50px;
}
<!DOCTYPE html>
<meta charset="utf-8" />
<html>
<body>
<div id="root">
<div id="buttons-container">
<div id="add-color">+1</div>
<div id="remove-color">-1</div>
</div>
</root>
<script
type="text/javascript"
src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.5.0/d3.min.js"
></script>
<script type="text/javascript" src="./index.js"></script>
<script type="text/css" src="./styles.css"></script>
</body>
</html>
Then I commented the exit action:
function update() {
ul.selectAll("li")
.data(flavors, (d) => d)
.join(
(enter) =>
enter
.append("li")
.text((d) => d)
.style("color", "green"),
(update) => update.style("color", "steelblue"),
//(exit) => exit.style("color", "red").remove()
);
}
And it works in the same way. What am I missing? Is exit necessary?
By default, .join calls selection.remove() for the exiting selection, without having to explicitly pass a function. The code is equivalent.
The join method is a recent addition to d3 that manages the full update pattern (enter, update, exit).
It can be used in the most simple way, without specifying any part of the pattern, eg,
ul.selectAll("li")
.data(flavors, (d) => d)
.join("li")
.text((d) => d)
.style("color", "green");
In short, this code above will deal with the append part, will update existing elements and will remove any not needed. Its equivalent to:
var list = ul.selectAll("li")
.data(flavors, (d) => d);
list.enter()
.append("li")
.merge("list")
.text((d) => d)
.style("color", "green");
list.exit().remove();
if we want to add specific actions for a part of the pattern, than we can specify each one.
In your case, if you only want to differentiate the colors of the enter and update, then you don't need to specify the exit part.
Just to add to the already existing answers, your question's title is a bit misleading. Let's see your title and some variants:
"Is [the] exit selection necessary in D3?"
No, it's not. You can perfectly have a D3 code without an exit selection. Actually, you can even have an exit selection without calling selection.remove(), that is, you can do another stuff with the exit selection other than just removing the elements (for instance, setting a different colour). That brings us to another question:
"Is [the] exit selection synonymous with 'elements to be removed'?"
No, it's not.
"Is [the] exit selection necessary in a dynamic data visualisation?"
Generally yes, but again that's not necessary. So the adequate answer is no.
"Is [the] exit selection necessary in selection.join()?"
I believe that this is the question you're asking. Well, the exit selection is already there, as the other answers pointed. Here's a brief explanation:
If you look at the source code, which is quite small, you'll see:
export default function(onenter, onupdate, onexit) {
var enter = this.enter(), update = this, exit = this.exit();
enter = typeof onenter === "function" ? onenter(enter) : enter.append(onenter + "");
if (onupdate != null) update = onupdate(update);
if (onexit == null) exit.remove(); else onexit(exit);
return enter && update ? enter.merge(update).order() : update;
}
Thus, as you can see in this line...
if (onexit == null) exit.remove(); else onexit(exit);
...if you explicitly set an exit function (here the parameter named onexit) the code will call it, otherwise it will simply do exit.remove(), which is actually this.exit.remove(), where this is the selection.
Finally it's interesting to see that, as a convenient method, selection.join() assumes that the users want to remove the elements in the exit selection. However, as I already explained, that's not necessary.
Related
I create directed graphs like the following from wikidata with the help of networkx and nxv. The result is an svg file which might be embedded in some html page.
Now I want that every node and every edge is "clickable", such that a user can add their comments to specific elements of the graph. I think this could be done with a modal dialog popping up. This dialog should know from which element it was triggered and it should send the content of the textarea to some url via a post request.
What would be the best way to achieve this?
Wrapped in a W3C standard Web Component (supported in all Modern Browsers) you can make it generic for any src="filename.svg"
Simple example: How to get SVG document data to be inserted into the DOM?
More complex example:
<graphviz-svg-annotator src="https://graphviz.org/Gallery/directed/fsm.svg">
</graphviz-svg-annotator>
The SVG is loaded with an async fetch
Nodes and Edges are clickable in this SO Snippet
add your own, better modal, window and saving to database
Try the SVGs from: https://graphviz.org/Gallery/directed/Genetic_Programming.html
<graphviz-svg-annotator src="fsm.svg"></graphviz-svg-annotator>
<graphviz-svg-annotator src="Linux_kernel_diagram.svg"></graphviz-svg-annotator>
<style>
svg .annotate { cursor:pointer }
</style>
<script>
customElements.define('graphviz-svg-annotator', class extends HTMLElement {
constructor() {
let loadSVG = async ( src , container = this.shadowRoot ) => {
container.innerHTML = `<style>:host { display:inline-block }
::slotted(svg) { width:100%;height:200px }
</style>
<slot name="svgonly">Loading ${src}</slot>`;
this.innerHTML = await(await fetch(src)).text(); // load full XML in lightDOM
let svg = this.querySelector("svg");
svg.slot = "svgonly"; // show only SVG part in shadowDOM slot
svg.querySelectorAll('g[id*="node"],g[id*="edge"]').forEach(g => {
let label = g.querySelector("text")?.innerHTML || "No label";
let shapes = g.querySelectorAll("*:not(title):not(text)");
let fill = (color = "none") => shapes.forEach(x => x.style.fill = color);
let prompt = "Please annotate: ID: " + g.id + " label: " + label;
g.classList.add("annotate");
g.onmouseenter = evt => fill("lightgreen");
g.onmouseleave = evt => fill();
g.onclick = evt => g.setAttribute("annotation", window.prompt(prompt));
})
}
super().attachShadow({ mode: 'open' });
loadSVG("//graphviz.org/Gallery/directed/"+this.getAttribute("src"));
}});
</script>
Detailed:
this.innerHTML = ... injects the full XML in the component ligthDOM
(because the element has shadowDOM, the lightDOM is not visible in the Browser)
But you only want the SVG part (graphviz XML has too much data)... and you don't want a screen flash; that is why I put the XML .. invisible.. in lightDOM
A shadowDOM <slot> is used to only reflect the <svg>
with this method the <svg> can still be styled from global CSS (see cursor:pointer)
With multiple SVGs on screen <g> ID values could conflict.
The complete SVG can be moved to shadowDOM with:
let svg = container.appendChild( this.querySelector("svg") );
But then you can't style the SVG with global CSS any more, because global CSS can't style shadowDOM
As far as I know, nxv generates a g element with class "node" for each node, all nested inside a graph g. So basically you could loop over all gs elements inside the main group and attach a click event listener on each one. (actually, depending of the desired behavior, you might want to attach the event listener to the shape inside the g, as done below. For the inside of the shape to be clickable, it has to be filled)
On click, it would update a form, to do several things: update its style to show it as a modal (when submitted, the form should go back to hiding), and update an hidden input with the text content of the clicked g.
Basically it would be something like that:
<svg>Your nxv output goes here</svg>
<form style="display: none;">
<input type="hidden" id="node_title">
<textarea></textarea>
<input type="submit" value="Send!">
</form>
<script>
const graph = document.querySelector("svg g");
const form = document.querySelector("form");
[...graph.querySelectorAll("g")].map(g => { //loop over each g element inside graph
if (g.getAttribute("class") == "node") { //filter for nodes
let target = "polygon";
if (g.querySelector("polygon") === null) {
target = "ellipse";
}
g.querySelector(target).addEventListener("click",() => {
const node_title = g.querySelector("text").innerHTML;
form.querySelector("#node_title").setAttribute("value", node_title);
form.setAttribute("style","display: block;");
});
}
});
const submitForm = async (e) => { //function for handling form submission
const endpoint = "path to your POST endpoint";
const body = {
source_node: form.querySelector("#node_title").value,
textarea: form.querySelector("textarea").value
}
e.preventDefault(); //prevent the default form submission behavior
let response = await fetch(endpoint, { method: "POST", body: JSON.stringify(body) });
// you might wanna do something with the server response
// if everything went ok, let's hide this form again & reset it
form.querySelector("#node_title").value = "";
form.querySelector("textarea").value = "";
form.setAttribute("style","display: none;");
}
form.addEventListener("submit",submitForm);
</script>
I'm having trouble removing the CSS 'active_bg' class of the wheel's core circle when it's removed from all segments.
The full code is on Github and Codepen.
Codepen: https://codepen.io/Rburrage/pen/xmqJoO
Github: https://github.com/RBurrage/wheel
In my click event, I tried saying that if the class exists on a segment, add it to the core circle, too -- ELSE -- remove it from the core circle. My code is within a forEach method that loops through all groups in the SVG.
The part in question is in the last event listener below (the 'click' event).
var secondGroups = document.querySelectorAll('.sols-and-mods');
secondGroups.forEach(function (secondGroup) {
let solution = secondGroup.childNodes[1];
let module = secondGroup.childNodes[3];
let core = document.querySelector('.core_background');
secondGroup.addEventListener('mouseover', () => {
solution.classList.add('hovered_bg');
module.classList.add('hovered_bg');
})
secondGroup.addEventListener('mouseout', () => {
solution.classList.remove('hovered_bg');
module.classList.remove('hovered_bg');
})
secondGroup.addEventListener('click', () => {
solution.classList.toggle('active_bg');
module.classList.toggle('active_bg');
if (solution.classList.contains('active_bg')) {
core.classList.add('active_bg');
solution.classList.remove('hovered_bg');
module.classList.remove('hovered_bg');
}else{
core.classList.remove('active_bg');
}
})
})
When the user clicks on a segment of the wheel, the CSS 'active_bg' class gets added to both the clicked segment and the wheel's core circle.
I want to remove the 'active_bg' class from the wheel's core circle but only when it is removed from ALL segments.
Currently, as soon as I remove the class from any ONE segment, it gets removed from the core circle.
Can someone please tell me what I'm doing wrong?
Thank you!
Explained
Okay, so to keep this as short and simply as possible, I changed your logic only ever so slightly, I've just included a check to see if there's at least one option selected, if not, then the core circle has the default class, otherwise, it will continue to have the class name of active_bg.
Here's the JSFiddle that I've made.
If there's any further issues with this solution, don't hesitate to ask.
Edit
I just thought I'd go ahead an include the JavaScript that I was playing around with.
window.onload = function() {
TweenMax.staggerFrom('.solution', .5, {
opacity: 0,
delay: 0.25
}, 0.1);
TweenMax.staggerFrom('.module', .5, {
opacity: 0,
delay: 0.5
}, 0.1);
}
var secondGroups = document.querySelectorAll('.sols-and-mods');
secondGroups.forEach(function(secondGroup) {
let solution = secondGroup.childNodes[1];
let module = secondGroup.childNodes[3];
let core = document.querySelector('.core_background');
secondGroup.addEventListener('mouseover', () => {
solution.classList.add('hovered_bg');
module.classList.add('hovered_bg');
})
secondGroup.addEventListener('mouseout', () => {
solution.classList.remove('hovered_bg');
module.classList.remove('hovered_bg');
})
secondGroup.addEventListener('click', () => {
solution.classList.toggle('active_bg');
module.classList.toggle('active_bg');
if (solution.classList.contains('active_bg')) {
core.classList.add('active_bg');
solution.classList.remove('hovered_bg');
module.classList.remove('hovered_bg');
}
// Added this line.
if (document.querySelector(".sols-and-mods .active_bg") == null) {
core.classList.remove('active_bg');
}
})
})
I have a map with d3 circles showing the site locations, as well as a linechart showing the time trend for each of the site. I am trying to make a particular line highlight when a corresponding circle is clicked. Here is the code. I can't seem to connect the siteIDs with the following function:
function highlightLine(id) {
lineGroup.classed("g-highlight", function(d) {
return d.siteID == id.siteID;
});
};
Insert a console.log as shown below, and it should become clearer:
function highlightLine(id) {
lineGroup.classed("g-highlight", function(d) {
console.log(d);
return d.siteID == id.siteID;
});
};
Because you're binding to data that you've run through d3.nest, the id of d that you're interested in is actually d.key not d.siteID, which does not exist on that level. So the boolean inside classed should be
return d.key == id.siteID
That will cause the appropriate trendline's <g> to have a "g-highlight" class, however it still will not visibly color the line. I believe that's because your css rule .g-highlight { stroke:... } applies the stroke to the containing <g> instead of the <path> inside it. You can change that css rule to be .g-highlight path { ... } and that will color the path as you'd like.
To bind the click event in d3 you should select the object with that class and bind the click:
d3.selectAll(".g-highlight").on("click", function(d) {
return d.siteID == id.siteID;
});
I am in the process of creating a bubble chart with D3.js. Now I want it to display a comment in the <h3 id="comment"></h3> tag when I click on each bubble.
Here is my data.csv file:
name,count,group,comment
apple,5,red,"This is the best apple ever."
grape,10,purple,"Grapes are huge."
banana,8,yellow,"Are these bananas even ripe?"
pineapple,1,yellow,"Great for making juice."
...
And in my viz.coffee, I have:
idValue = (d) -> d.name
textValue = (d) -> d.name
groupValue = (d) -> d.group
commentValue= (d) -> d.comment
Originally, I use the following code to display the name of the bubble when clicked:
updateActive = (id) ->
node.classed("bubble-selected", (d) -> id == idValue(d))
if id.length > 0
d3.select("#comment").html("<h3>#{id} is selected.</h3>")
else
d3.select("#comment").html("<h3>Nothing is selected</h3>")
How should I change it, so that when you click on the bubble, the comment displays instead?
I tried:
updateActive = (id) ->
node.classed("bubble-selected", (d) -> id == idValue(d))
if id.length > 0
d3.select("#comment").html(d.comment)
else
d3.select("#comment").html("<h3>Click on a bubble to read its comment.</h3>")
But it doesn't seem to work, because d is undefined, which I can see why, but I'm not sure what I should do. Please help.
Even though I'm not completely sure of your code I think this should work:
updateActive = (id) ->
node.classed("bubble-selected", (d) -> id == idValue(d))
if id
for x in data when idValue(x) is id
d3.select("#comment").html(commentValue(x))
else
d3.select("#comment").html("<h3>Click on a bubble to read its comment.</h3>")
Here data is what you supply to d3 through .data().
I am building a small UI where the user has to select a point on each of the two SVGs shown.
These points coordinates are then shown under the SVGs. I would like to achieve this using D3's data-binding with the enter() and exit() methods. However it seems that D3 doesn't always update the part where I display the points coordinates, even if I call the enter() method on the bound elements. When removing data, the exit() methods works however.
Here is the main code :
function showPoints() {
var coordinatesElements = d3.select('#coordinates').selectAll('.point').data(points);
coordinatesElements.enter().append('div').classed('point', true)
.text(function (d) {
var textParts = [];
if (d.firstSvg) { textParts.push('first : '+JSON.stringify(d.firstSvg)); }
if (d.secondSvg) { textParts.push('second : '+JSON.stringify(d.secondSvg)); }
return textParts.join(' - ');
})
.append("span")
.classed('removeCalibrationPoint', true)
.html(" X")
.on('click', function(d, i) {
points.splice(i, 1);
showPoints();
});
coordinatesElements.exit().remove();
}
I have created a JSBin fiddle that demonstrates the problem.
The first problem is that you have an empty div of class point in your HTML. This will be selected by the .selectAll('.point') and cause the first element in your data not to show up.
The second problem is that you're not handling the update selection -- in some cases, you're not adding new data, but modifying existing data. The following code updates the text for the data in the update selection.
coordinatesElements.text(function (d) {
var textParts = [];
if (d.firstSvg) { textParts.push('first : '+JSON.stringify(d.firstSvg)); }
if (d.secondSvg) { textParts.push('second : '+JSON.stringify(d.secondSvg)); }
return textParts.join(' - ');
});
Complete demo here. Notice I've simplified the code slightly by setting the text only on the update selection -- elements added from the enter selection merge into the update selection, so there's no need to do it twice.