D3 – Dynamically change text of node in tree when pressing a button

Tags: , , ,



I’m trying to update the text nodes of my D3 tree every time I press a HTML button, adding one every time, but I don’t know how to do it properly without having to create the tree again. Can anyone help me with it?

The following snippet exemplarizes the state that I’m now

const DX = 120;
const DY = 90;

const RECT_WIDTH = 40;
const RECT_HEIGHT = 15;

// Zoom constants
const MIN_ZOOM = 0.15;
const MAX_ZOOM = Infinity;

// Radius for the border of the rectangle of nodes
const RECTANGLE_RADIUS = 3;

// Time that takes to complete a change of state
const TRANSITION_DURATION = 700;

let data = {
    "name": "Nodo 1",
    "children": [
        {
            "name": "Nodo 2",
            "children": [
                {
                    "name": "Nodo 3"
                },
                {
                    "name": "Nodo 4"
                }
            ]
        }
    ]
};

// Get size of the screen available
let width = $(document).width();
let height = $(document).height();

// New tree layout with default settings. Set the size of the nodes to DX in the
// horizontal axis and to DY in the vertical axis
let tree = d3.tree().nodeSize([DX, DY]);

// Visualize links in a tree diagram rooted at the top
let diagonal = d3.linkVertical()
    .x(d => d.x + RECT_WIDTH / 2)
    .y(d => d.y + RECT_HEIGHT / 2);

/* 
    Function that creates the first tree with the default layout
*/
function createRoot(data) {
    // Data is already in JSON format. We only need to create the hierarchy
    // based on it.
    let root = d3.hierarchy(data);
    root.x0 = DX / 2;
    root.y0 = 0;
    root.descendants().forEach((d, i) => {
        d.id = i;
        // Auxiliar variable to hide and show nodes when user clicks
        d._children = d.children;
        // Only the root is displayed at first sight
        if (d.depth >= 0) d.children = null;
    });
    return root;
}

/* 
    Function that updates all the nodes and links in a tree according to the 
    click event
*/
function update(source) {
    // Get all nodes and links that are being shown
    const nodes = root.descendants().reverse();
    const links = root.links();

    // Compute the new tree layout
    tree(root);

    const transition = svg.transition()
        .duration(TRANSITION_DURATION)
        .tween("resize",
            window.ResizeObserver ? null : () => () => svg.dispatch("toggle"));

    /*=============================NODE SECTION============================== */
    // Obtain all the nodes
    const node = gNode.selectAll("g")
        .data(nodes, d => d.id);

    // Enter any new nodes at the parent's previous position.
    const nodeEnter = node.enter().append("g")
        .attr("class", "node")
        .attr("transform", d => `translate(${source.x0},${source.y0})`)
        .on("click", function (event, d) {
            if (d.children) // Node expanded -> Collapse
                collapse(d);
            else // Node collapsed -> Expand
                d.children = d._children
            update(d);
            centerNode(d);
        })

    nodeEnter.append("rect")
        // Two different classes, one for the links that can be expanded and
        // another for the expanded ones or leafs.
        .attr("class", d =>
            d._children && !d.children ? "notExpanded" : "expanded")
        .attr("height", RECT_HEIGHT)
        .attr("width", RECT_WIDTH)
        .attr("fill", d => d.color)
        .attr("rx", RECTANGLE_RADIUS)
        .attr("ry", RECTANGLE_RADIUS);

    // Inside node text
    nodeEnter.append("text")
        .attr("class", "nodeText")
        // The position of the text is at the top
        .attr("x", RECT_WIDTH / 2)
        .attr("y", RECT_HEIGHT / 2)
        .text(d => d.data.name)
        .clone(true).lower();

    // Transition nodes to their new position (update)
    var nodeUpdate = node.merge(nodeEnter).transition(transition)
        .attr("transform", d => `translate(${d.x},${d.y})`)
        // Smooth transition from 0 to 1 and back to 0 when expanding/collapsing
        .attr("fill-opacity", 1)
        .attr("stroke-opacity", 1);

    // Update class of rectangles
    nodeUpdate.select('rect')
        .attr("class", d =>
            d._children && !d.children ? "notExpanded" : "expanded");

    nodeUpdate.select('text')
        .text(d => numberPressed == 0 ? d.data.name : `${d.data.name} - ${numberPressed}`);

    // Transition exiting nodes to the parent's new position
    node.exit().transition(transition).remove()
        .attr("transform", d => `translate(${source.x},${source.y})`)
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0);

    /*=============================LINK SECTION============================== */
    const link = gLink.selectAll("path")
        .data(links, d => d.target.id);

    // Enter any new links at the parent's previous position
    const linkEnter = link.enter().append("path")
        .attr("class", "link")
        .attr("x", RECT_WIDTH / 2)
        .attr("y", RECT_HEIGHT / 2)
        // d attribute defines a path to be drawn
        .attr("d", d => {
            const o = {
                x: source.x0,
                y: source.y0
            };
            // Diagonal actually paints a curve line between the source and the
            // target
            return diagonal({
                source: o,
                target: o
            });
        });

    // Transition links to their new position
    link.merge(linkEnter).transition(transition)
        // In this case the link will be changed in order to have the arrows in
        // the correct position 
        .attr("d", d => diagonal({
            source: d.source,
            target: {
                x: d.target.x,
                y: d.target.y
            }
        }));

    // Transition exiting nodes to the parent's new position
    link.exit().transition(transition).remove()
        .attr("d", d => {
            const o = {
                x: source.x,
                y: source.y
            };
            return diagonal({
                source: o,
                target: o
            });
        });

    // Stash the old positions for transition
    root.eachBefore(d => {
        d.x0 = d.x;
        d.y0 = d.y;
    });
}

/*
    Function that centers a given node in the screen. It will be used so that
    the node that has been clicked doesn't go out of sight
*/
function centerNode(source) {
    let scale = d3.zoomTransform(d3.select("svg").node()).k;
    let x = -source.x0 * scale + width / 2 - RECT_WIDTH / 2 * scale;
    let y = -source.y0 * scale + height / 2 - RECT_HEIGHT / 2 * scale;
    // Define the transition
    const transition = svg.transition()
        .duration(TRANSITION_DURATION)
        .tween("resize",
            window.ResizeObserver ? null : () => () => svg.dispatch("toggle"));
    // Move all the nodes based on the previous parameters
    svg.transition(transition)
        .call(zoomBehaviours.transform,
            d3.zoomIdentity.translate(x, y).scale(scale));
}

/*
    Function that collapses all the subtree of a given node.
*/
function collapse(node) {
    if (node.children) { // Expanded
        node.children = null;
        node._children.forEach(collapse)
    }
}

const root = createRoot(data);
let numberPressed = 0;

/*
    Function that will create the variable menu and add it to the window
*/
function createVariableMenu() {
    d3.select("body").append("button")
        .attr("class", "addButton")
        .text("Add")
        .on("click", function() {
            numberPressed++;
            update(root);
        });
}

createVariableMenu();

// SVG variable that will contain all the configuration for the images.
// We need to append it to the body
const svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("xmlns", "http://www.w3.org/2000/svg");

// HTML tooltip
const div = d3.select("body").append("div")
    .attr("class", "tooltip")
    .style("opacity", 0);

// HTML tooltip for vars
const divVars = d3.select("body").append("div")
    .attr("class", "tooltipVar")
    .style("opacity", 0);

// SVG group that will contain two groups declared below
const g = svg.append("g");

// Two groups: One of links (and link labels) and another of nodes
const gLink = g.append("g");
const gNode = g.append("g");

// Zoom configuration
const zoomBehaviours = d3.zoom()
    .scaleExtent([MIN_ZOOM, MAX_ZOOM])
    .on('zoom', (event) => {
        g.attr('transform', event.transform);
    });

// Add the zoom so that svg knows that it is available
svg.call(zoomBehaviours);

// We need to update the root first to generate the first tree, and center it
update(root);
centerNode(root);
.node {
    cursor: pointer;
    fill: lightblue;
}

.node .notExpanded {
    stroke: black;
    stroke-width: 1.2;
}

.node .nodeText {
    fill: black;
    font: 10px sans-serif;
    text-anchor: middle;
    text-align: center;
    dominant-baseline: central;
}

.addButton {
    position: absolute;
    cursor: pointer;
    font-size: 20px;
}

.link {
    fill: none;
    stroke: black;
    stroke-width: 1.5;
    stroke-opacity: 0.5;
}

body {
    overflow: hidden;
    cursor: grab;
    margin: 2px;
}

body:active {
    cursor: grabbing;
}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title></title>
        <link rel="stylesheet" href="style.css">
    </head>
    <body></body>
    <script src="http://code.jquery.com/jquery-1.10.2.min.js"></script>
    <script src="https://d3js.org/d3.v6.min.js"></script>
    <script src="./src/main.js"></script>
</html>

As you can see, whenever there is a click event over the button, the text is shown below the node, and the previous text remains. I’m not sure of what’s happening, but it seems that the selection of the text is not working properly, because I’m only appending the text once, but it’s showing up twice. How should I change the code in order to have it working?

Any help is appreciated. Thanks 🙂

Answer

It seems that I was not selecting correctly the text. I changed it to select the class that I chose for the text inside the nodes (.nodeText) and it’s currently working as expected. In the following snippet you can see the changes

const DX = 120;
const DY = 90;

const RECT_WIDTH = 60;
const RECT_HEIGHT = 20;

// Zoom constants
const MIN_ZOOM = 0.15;
const MAX_ZOOM = Infinity;

// Radius for the border of the rectangle of nodes
const RECTANGLE_RADIUS = 3;

// Time that takes to complete a change of state
const TRANSITION_DURATION = 700;

let data = {
    "name": "Nodo 1",
    "children": [
        {
            "name": "Nodo 2",
            "children": [
                {
                    "name": "Nodo 3"
                },
                {
                    "name": "Nodo 4"
                }
            ]
        }
    ]
};

// Get size of the screen available
let width = $(document).width();
let height = $(document).height();

// New tree layout with default settings. Set the size of the nodes to DX in the
// horizontal axis and to DY in the vertical axis
let tree = d3.tree().nodeSize([DX, DY]);

// Visualize links in a tree diagram rooted at the top
let diagonal = d3.linkVertical()
    .x(d => d.x + RECT_WIDTH / 2)
    .y(d => d.y + RECT_HEIGHT / 2);

/* 
    Function that creates the first tree with the default layout
*/
function createRoot(data) {
    // Data is already in JSON format. We only need to create the hierarchy
    // based on it.
    let root = d3.hierarchy(data);
    root.x0 = DX / 2;
    root.y0 = 0;
    root.descendants().forEach((d, i) => {
        d.id = i;
        // Auxiliar variable to hide and show nodes when user clicks
        d._children = d.children;
        // Only the root is displayed at first sight
        if (d.depth >= 0) d.children = null;
    });
    return root;
}

/* 
    Function that updates all the nodes and links in a tree according to the 
    click event
*/
function update(source) {
    // Get all nodes and links that are being shown
    const nodes = root.descendants().reverse();
    const links = root.links();

    // Compute the new tree layout
    tree(root);

    const transition = svg.transition()
        .duration(TRANSITION_DURATION)
        .tween("resize",
            window.ResizeObserver ? null : () => () => svg.dispatch("toggle"));

    /*=============================NODE SECTION============================== */
    // Obtain all the nodes
    const node = gNode.selectAll("g")
        .data(nodes, d => d.id);

    // Enter any new nodes at the parent's previous position.
    const nodeEnter = node.enter().append("g")
        .attr("class", "node")
        .attr("transform", d => `translate(${source.x0},${source.y0})`)
        .on("click", function (event, d) {
            if (d.children) // Node expanded -> Collapse
                collapse(d);
            else // Node collapsed -> Expand
                d.children = d._children
            update(d);
            centerNode(d);
        })

    nodeEnter.append("rect")
        // Two different classes, one for the links that can be expanded and
        // another for the expanded ones or leafs.
        .attr("class", d =>
            d._children && !d.children ? "notExpanded" : "expanded")
        .attr("height", RECT_HEIGHT)
        .attr("width", RECT_WIDTH)
        .attr("fill", d => d.color)
        .attr("rx", RECTANGLE_RADIUS)
        .attr("ry", RECTANGLE_RADIUS);

    // Inside node text
    nodeEnter.append("text")
        .attr("class", "nodeText")
        // The position of the text is at the top
        .attr("x", RECT_WIDTH / 2)
        .attr("y", RECT_HEIGHT / 2)
        .text(d => d.data.name);

    // Transition nodes to their new position (update)
    var nodeUpdate = node.merge(nodeEnter).transition(transition)
        .attr("transform", d => `translate(${d.x},${d.y})`)
        // Smooth transition from 0 to 1 and back to 0 when expanding/collapsing
        .attr("fill-opacity", 1)
        .attr("stroke-opacity", 1);

    // Update class of rectangles
    nodeUpdate.select('rect')
        .attr("class", d =>
            d._children && !d.children ? "notExpanded" : "expanded");

    nodeUpdate.select('.nodeText')
        .text(d => numberPressed == 0 ? d.data.name : `${d.data.name} - ${numberPressed}`);

    // Transition exiting nodes to the parent's new position
    node.exit().transition(transition).remove()
        .attr("transform", d => `translate(${source.x},${source.y})`)
        .attr("fill-opacity", 0)
        .attr("stroke-opacity", 0);

    /*=============================LINK SECTION============================== */
    const link = gLink.selectAll("path")
        .data(links, d => d.target.id);

    // Enter any new links at the parent's previous position
    const linkEnter = link.enter().append("path")
        .attr("class", "link")
        .attr("x", RECT_WIDTH / 2)
        .attr("y", RECT_HEIGHT / 2)
        // d attribute defines a path to be drawn
        .attr("d", d => {
            const o = {
                x: source.x0,
                y: source.y0
            };
            // Diagonal actually paints a curve line between the source and the
            // target
            return diagonal({
                source: o,
                target: o
            });
        });

    // Transition links to their new position
    link.merge(linkEnter).transition(transition)
        // In this case the link will be changed in order to have the arrows in
        // the correct position 
        .attr("d", d => diagonal({
            source: d.source,
            target: {
                x: d.target.x,
                y: d.target.y
            }
        }));

    // Transition exiting nodes to the parent's new position
    link.exit().transition(transition).remove()
        .attr("d", d => {
            const o = {
                x: source.x,
                y: source.y
            };
            return diagonal({
                source: o,
                target: o
            });
        });

    // Stash the old positions for transition
    root.eachBefore(d => {
        d.x0 = d.x;
        d.y0 = d.y;
    });
}

/*
    Function that centers a given node in the screen. It will be used so that
    the node that has been clicked doesn't go out of sight
*/
function centerNode(source) {
    let scale = d3.zoomTransform(d3.select("svg").node()).k;
    let x = -source.x0 * scale + width / 2 - RECT_WIDTH / 2 * scale;
    let y = -source.y0 * scale + height / 2 - RECT_HEIGHT / 2 * scale;
    // Define the transition
    const transition = svg.transition()
        .duration(TRANSITION_DURATION)
        .tween("resize",
            window.ResizeObserver ? null : () => () => svg.dispatch("toggle"));
    // Move all the nodes based on the previous parameters
    svg.transition(transition)
        .call(zoomBehaviours.transform,
            d3.zoomIdentity.translate(x, y).scale(scale));
}

/*
    Function that collapses all the subtree of a given node.
*/
function collapse(node) {
    if (node.children) { // Expanded
        node.children = null;
        node._children.forEach(collapse)
    }
}

const root = createRoot(data);
let numberPressed = 0;

/*
    Function that will create the variable menu and add it to the window
*/
function createVariableMenu() {
    d3.select("body").append("button")
        .attr("class", "addButton")
        .text("Add")
        .on("click", function() {
            numberPressed++;
            update(root);
        });
}

createVariableMenu();

// SVG variable that will contain all the configuration for the images.
// We need to append it to the body
const svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("xmlns", "http://www.w3.org/2000/svg");

// HTML tooltip
const div = d3.select("body").append("div")
    .attr("class", "tooltip")
    .style("opacity", 0);

// HTML tooltip for vars
const divVars = d3.select("body").append("div")
    .attr("class", "tooltipVar")
    .style("opacity", 0);

// SVG group that will contain two groups declared below
const g = svg.append("g");

// Two groups: One of links (and link labels) and another of nodes
const gLink = g.append("g");
const gNode = g.append("g");

// Zoom configuration
const zoomBehaviours = d3.zoom()
    .scaleExtent([MIN_ZOOM, MAX_ZOOM])
    .on('zoom', (event) => {
        g.attr('transform', event.transform);
    });

// Add the zoom so that svg knows that it is available
svg.call(zoomBehaviours);

// We need to update the root first to generate the first tree, and center it
update(root);
centerNode(root);
.node {
    cursor: pointer;
    fill: lightblue;
}

.node .notExpanded {
    stroke: black;
    stroke-width: 1.2;
}

.node .nodeText {
    fill: black;
    font: 10px sans-serif;
    text-anchor: middle;
    text-align: center;
    dominant-baseline: central;
}

.addButton {
    position: absolute;
    cursor: pointer;
    font-size: 20px;
}

.link {
    fill: none;
    stroke: black;
    stroke-width: 1.5;
    stroke-opacity: 0.5;
}

body {
    overflow: hidden;
    cursor: grab;
    margin: 2px;
}

body:active {
    cursor: grabbing;
}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title></title>
        <link rel="stylesheet" href="style.css">
    </head>
    <body></body>
    <script src="http://code.jquery.com/jquery-1.10.2.min.js"></script>
    <script src="https://d3js.org/d3.v6.min.js"></script>
    <script src="./src/main.js"></script>
</html>


Source: stackoverflow