D3v4 force directed graph - localStorage disconnects links and nodes

Viewed 64

I am trying to use localStorage with my force-directed graph. I am saving the graph variable every each simulation.on("end", function()) and this works correctly. However, when I reload the page, use localStorage.getItem and drag node, then links are not connected with nodes anymore.

Could you help me with fixing this strange behavior?

Please check the screenshot and code below: localStorage-update-screenshot

'use strict';

var test = [];
var datatable;
var index = [];
var graph;

getLocal();
//  load and save data
function getLocal() {
        if (localStorage.getItem("graph") === null) {
        graph = {
            "nodes": [{'id': 1, 'lable': 1, 'group': 'search'}, {'id': 2, 'lable': 2, 'group': '1'}, {'id': 3, 'lable': 3, 'group': '2'}],
            "links": [{'source': 1, 'target': 2, 'value': "1-2"},{'source': 2, 'target': 1, 'value': "2-1"},{'source': 1, 'target': 3, 'value': "1-3"},{'source': 2, 'target': 3, 'value': "2-3"}]
        }} else {
            graph = JSON.parse(localStorage.getItem("graph"));
        };
    };

// Graph variables
var firstLinks = true;

var w = 1680; //window.innerWidth;
var h = 850; //window.innerHeight;

var svg = d3.select("#svgData"),
    scheme = ['#e41a1c','#377eb8','#4daf4a','#984ea3','#ff7f00','#ffff33','#a65628','#f781bf','#999999'],
    width = +svg.attr(w),
    height = +svg.attr(h),
    color = d3.scaleOrdinal(d3.schemeCategory10);
    //color = d3.scaleOrdinal(scheme);

// elements for data join
var link = svg.append("g").attr("class", "link").selectAll(".link"),
    value = svg.append("g").selectAll(".link"),
    node = svg.append("g").attr("class", "node").selectAll(".node"),
    lable = svg.append("g").selectAll(".node"),
    image = svg.append("g").selectAll(".node");

//  simulation initialization
var simulation = d3.forceSimulation()
        .force("charge", d3.forceManyBody().strength(-30).distanceMax(300))
        //.force('collision', d3.forceCollide().radius(30))
        //.force("center", d3.forceCenter(w / 2, h / 2))
        .force("link", d3.forceLink().id(function(d) { return d.id; }).distance(function (d) { if (d.length) {return d.length; } else { return 300;}}))
        .force("x", d3.forceX().x(function(d) { if (d.group == "search") {return w / 2; } else { return " ";}}))
        .force("y", d3.forceY().y(function(d) { if (d.group == "search") {return h / 2; } else { return h / 3;}}));
    //.force("center", d3.forceCenter(w / 2, h / 2));

var marker = d3.select("#svgData").append('defs')
    .append('marker')
    .attr("id", "Triangle")
    .attr('viewBox', '-0 -5 10 10')
    .attr("refX", 25)
    .attr("refY", 0)
    .attr("markerUnits", 'userSpaceOnUse')
    .attr("orient", 'auto')
    .attr("markerWidth", 13)
    .attr("markerHeight", 13)
    .attr('xoverflow', 'visible')
    .append('path')
    .attr("d", 'M 0,-5 L 10 ,0 L 0,5');

// Add search result to graph data
function QuickSearch(value) {
    var new_node = {};
    new_node = {'id': value, 'lable': value, 'group': 'search'};
    graph.nodes.findIndex(x => x.id == new_node.id) == -1 ? graph.nodes.push(new_node) : console.log("object already exists")
    update();
};

update();

function update() {
    // DATA JOIN
    link = link.data(graph.links, d => d.id);

    // EXIT
    // Remove old links
    link.exit().remove();

    // ENTER
    // Create new links as needed.  
    link = link.enter().append("path")
        .attr("id", function(_, i) {
            return "path" + i
        })
        .attr("marker-end", "url(#Triangle)")
        .merge(link);

    // DATA JOIN
    value = value.data(graph.links, d => d.id);

    // EXIT
    value.exit().remove();

    // ENTER
    value = value.enter().append("text")
        .attr("dy", -4)
        .append("textPath")
        .attr("xlink:href", function(_, i) {
            return "#path" + i
        })
        .attr("startOffset", "50%")
        .text(function(d) {
            return d.value;
        })
        .merge(value);
    
    // DATA JOIN
    node = node.data(graph.nodes, d => d.id);

    // EXIT
    node.exit().remove();

    // ENTER

    node = node.enter().append("circle")
        .attr('stroke-width', 3)
        .attr('stroke', function(d) { if (d.group == "addr") {return '#1f77b4'; } else {return color(d.group)}})
        .call(d3.drag()
                .on("start", dragstarted)
                .on("drag", dragged)
                .on("end", dragended))
        .merge(node);

    // DATA JOIN
    lable = lable.data(graph.nodes, d => d.id);

    // EXIT
    lable.exit().remove();

    // ENTER

    lable = lable.enter().append("text")
        .text(function (d) {
            if(d.id.length > 10)
                return d.id.substring(0,10)+'...';
            else
                return d.id;                       
        })
        .merge(lable);
    

    //  Set nodes, links, and alpha target for simulation
    simulation.nodes(graph.nodes).on("tick", ticked);
    simulation.force("link").links(graph.links).distance(function (d) { if (d.length) {return d.length; } else { return 200;}});
    simulation.alphaTarget(1).restart();
    simulation.on("end", function() {
        node.each(function(d) {
            d.fx = d.x;
            d.fy = d.y;
        });
        localStorage.setItem("graph", JSON.stringify(graph));
        console.log("saving");
    });
    

    function ticked() {
        node
            .attr("cx", function(d) { return d.x; })
            .attr("cy", function(d) { return d.y; });

        lable
            .attr("x", function(d) { return d.x; })
            .attr("y", function(d) { return d.y - 30; });

        link
            .attr("d", function(d) {
                return "M" + d.source.x + "," + d.source.y
                    + "L" + d.target.x + "," + d.target.y;
            })
            .attr("stroke-dasharray", function() { 
                return this.getTotalLength() - 25;
            });

        value
            .attr("x", function(d) { return (d.source.x + d.target.x)/2; })
            .attr("y", function(d) { return ((d.source.y + d.target.y)/2) - 10; });

    }

    // Zoom
    var zoom = d3.zoom()
    .scaleExtent([0, 10])
    .on("zoom", zoomed);

    d3.select("#svgData").call(zoom);

    function zoomed() {
    const currentTransform = d3.event.transform;
    svg.selectAll("g").attr("transform", currentTransform);
    }

    function slided(d) {
        zoom.scaleTo(svg, d3.select(this).property("value"));
    }

    // drag nodes
    function dragstarted(d){
        if (!d3.event.active) simulation.alphaTarget(0.3).restart();
        d.fx = d.x;
        d.fy = d.y;
    }
    
    function dragged(d){
        d.fx = d3.event.x;
        d.fy = d3.event.y;
        //fix_nodes(d);
    }
    
    function dragended(d){
        if (!d3.event.active) simulation.alphaTarget(0);
        d.fx = d.x;
        d.fy = d.y;
    }
  
  // Preventing other nodes from moving while dragging one node
    function fix_nodes(this_node) {
        node.each(function(d) {
            if (this_node != d) {
            d.fx = d.x;
            d.fy = d.y;
            }
        });
    }
};

var deleteLeafs = function() {
    var source = [];
    var target = [];
    link.each(function (d) {
        source.push(d.source.index);
        target.push(d.target.index);
    });
    node.each(function (d, i) {
        if (i in source && i in target ) {
            index.push(d);
        }
    })
    
};
1 Answers

D3-force replaces the source and target properties in your links from some identifier for a given node to the node's object reference itself. Consequently, all links consist of source and target object references, the node object references themselves.

Using JSON.parse and JSON.stringify to store/re-create the data doesn't result in an object where links and nodes share object references: node and link contain object references to different objects (even if they look identical):

Below sees if a link source is the same as a node, which it is after the force is initialized, but isn't after parsed and stringified.

var graph = {
  nodes: [
    {id: 1},
    {id: 2}
  ],
  links : [
    {source: 1, target: 2}
  ]
}

var force = d3.forceSimulation()
  .nodes(graph.nodes)
  .force("link", d3.forceLink().id(d=>d.id).links(graph.links));
  
// force only:
console.log(graph.nodes[0] == graph.links[0].source);

// parsed:
graph = JSON.parse(JSON.stringify(graph))
console.log(graph.nodes[0] == graph.links[0].source);

// For comparison:
console.log("graph.nodes[0]:")
console.log(graph.nodes[0])
console.log("graph.links[0].source:")
console.log(graph.links[0].source)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

If you are only creating/removing nodes, you could reload the links from the starting links array each time, storing only the nodes. Then, by re-initializing node and link, you should get a new force layout with the node positions (and consequently links) in their last positions.

If you are adding/removing links then you could use an alternative method of parsing the object, such as Flatted, which appears to work on a force layout:

var graph = {
  nodes: [
    {id: 1},
    {id: 2}
  ],
  links : [
    {source: 1, target: 2}
  ]
}

var force = d3.forceSimulation()
  .nodes(graph.nodes)
  .force("link", d3.forceLink().id(d=>d.id).links(graph.links));
  
// force only:
console.log(graph.nodes[0] == graph.links[0].source);

// parsed:
graph = Flatted.parse(Flatted.stringify(graph))
console.log(graph.nodes[0] == graph.links[0].source);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatted@3.1.1/min.js"></script>

Related