graph.js

/**
 * Handle to the force simulation.
 */
let simulation;

/**
 * SVG group for links.
 */
let links;

/**
 * SVG group for nodes.
 */
let nodes;

/**
 * SVG group for bundle edges.
 */
let paths;

/**
 * Loaded data.
 */
let loaded_data;

/**
 * Default strong attraction strength. Between 0 and 1.
 * @type {number}
 */
let attraction_strength = 0.8;

/**
 * Default weak attraction strength for edges. Between 0 and 1.
 * @type {number}
 */
let attraction_strength_weak = 0.1;

/**
 * Default strong repulsion strength between two nodes.
 * @type {number}
 */
let repulsion_strength = -300;

/**
 * Default weak repulsion strength between two nodes.
 * @type {number}
 */
let repulsion_strength_weak = -30;

/**
 * Default size of the circle representing a node.
 * @type {number}
 */
let node_radius = 8;

/**
 * Default link opacity.
 * @type {number}
 */
let link_opacity = 0.4;

/**
 * Contains the node that currently is selected for additional information.
 * @type {null}
 */
let active_clicked = null;

/**
 * True if edges are bundled.
 * @type {boolean}
 */
let paths_loaded = false;

/**
 * Resizes the graph according to the window size.
 */
function resize_graph() {
    let svg_graph = d3.select("#graph");
    let width = window.innerWidth;
    let height = window.innerHeight;

    svg_graph.attr("width", width);
    svg_graph.attr("height", height);
}

/**
 * Reads the settings for the dataset.
 * @param settings for the dataset
 */
function configure_graph(settings) {
    attraction_strength = settings.attraction_strength;
    attraction_strength_weak = settings.attraction_strength_weak;
    repulsion_strength = settings.repulsion_strength;
    repulsion_strength_weak = settings.repulsion_strength_weak;
    link_opacity = settings.link_opacity;
    node_radius = settings.node_radius;
}

/**
 * Deselects nodes therefore removing the text and returning opacity to normal.
 */
function deselect_nodes() {
    $('#deselect_button').removeClass("fadeInDown").addClass("fadeOutUp");
    active_clicked = null;

    d3.selectAll("text")
        .remove();
    nodes.selectAll("rect")
        .remove();

    d3.selectAll("circle")
        .style("opacity", 1.0);


    if (paths_loaded) {
        paths.selectAll("path").style("stroke-opacity", link_opacity * 0.4);
    }
    else
    {
        links.style("opacity", link_opacity);
    }
}

/**
 * Creates the initial FDG and all necessary SVGs and forces.
 * @param data used to create the graph.
 */
function create_graph(data) {

    simulation = d3.forceSimulation();
    let width = window.innerWidth;
    let height = window.innerHeight;

    configure_graph(data.settings);

    let svg_graph = d3.select("#graph");

    svg_graph.attr("width", width);
    svg_graph.attr("height", height);

    let g = svg_graph.append("g")
        .attr("class", "everything");

    //add zoom capabilities
    let zoom_handler = d3.zoom()
        .on("zoom", function () {
            g.attr("transform", d3.event.transform);
        }).wheelDelta(function () {
            return (d3.event.deltaMode !== 1) ? -d3.event.deltaY * 0.0004 : -d3.event.deltaY * 0.02;
        });

    zoom_handler(svg_graph);

    links = g.append("g")
        .attr("class", "links")
        .selectAll("links")
        .data(data.links)
        .enter().append("line")
        .attr("stroke-width", 1)
        .attr("stroke", "#222222")
        .style("opacity", link_opacity);

    paths = g.append("g")
        .attr("class", "paths");

    nodes = g.append("g")
        .attr("class", "nodes")
        .selectAll("g")
        .data(data.nodes)
        .enter().append("g");

    nodes.append("circle")
        .attr("r", node_radius)
        .attr("fill", function (d) {
            return color(d.group);
        })
        .on("click", function (d) {
            if (active_clicked === d.id) {
                deselect_button.removeClass('fadeInDown').addClass('fadeOutUp');
                active_clicked = null;
                d3.selectAll("text")
                    .remove();
                d3.selectAll("circle")
                    .style("opacity", 1.0);
                links
                    .style("opacity", (paths_loaded) ? 0.0 : link_opacity);
                
                if (paths_loaded) {
                    paths.selectAll("path").style("stroke-opacity", link_opacity * 0.4);
                }
                return;
            }

            brushLinks(d.id);
        });

    simulation
        .force("link", d3.forceLink().id(function (d) {
            return d.id;
        }))
        .force("center", d3.forceCenter(width / 2, height / 2));

    let group_map = new Map();

    for (let i = 0; i < data.nodes.length; i++) {
        if (group_map.get(data.nodes[i].group) === undefined) {
            group_map.set(data.nodes[i].group, 1);
        } else {
            group_map.set(data.nodes[i].group, group_map.get(data.nodes[i].group) + 1);
        }

        for (let j = i + 1; j < data.nodes.length; j++) {
            simulation.force(data.nodes[i].id.concat(data.nodes[j].id), isolate(d3.forceManyBody().strength(-30), data.nodes[i], data.nodes[j]));
        }
    }

    group_map[Symbol.iterator] = function* () {
        yield* [...this.entries()].sort((a, b) => a[1] - b[1]);
    }

    for (let [key, value] of group_map) {
        addGroupLabel(key, value);
    }

    simulation.on("end",
        function () {
            btn_bundle_edges.removeClass("disabled");
        })

    simulation.force("link").strength(attraction_strength_weak).distance(function (d) {
        return d.value;
    });

    simulation
        .nodes(data.nodes)
        .on("tick", animation);

    simulation.force("link")
        .links(data.links);
}

/**
 * Updates the link opacities.
 * @param d id of the selected node
 */
function brushLinks(d) {
    d3.selectAll("text")
        .remove();
    nodes.selectAll("rect")
        .remove();

    d3.selectAll("circle")
        .style("opacity", 1.0);

    links
        .style("opacity", (paths_loaded) ? 0.0 : link_opacity);

    if (paths_loaded) {
        paths.selectAll("path").style("stroke-opacity", link_opacity * 0.4);
    }

    let adj_nodes = [];
    adj_nodes.push(d);

    if (d === null) {
        return;
    }

    deselect_button.removeClass('fadeOutUp').addClass('fadeInDown');
    deselect_button.css("visibility", "visible");

    active_clicked = d;

    if (paths_loaded) {
        paths.selectAll("path").style("stroke-opacity", function () {
            if (d3.select(this).attr("source") === d || d3.select(this).attr("target") === d) {
                adj_nodes.push(d3.select(this).attr("source"));
                adj_nodes.push(d3.select(this).attr("target"));
                return 1.0;
            }
            return link_opacity * 0.4;
        });
    } else {
        links
            .style("opacity", function (l) {
                if (l.target.id === d || l.source.id === d) {
                    adj_nodes.push(l.target.id);
                    adj_nodes.push(l.source.id);
                    return 0.8;
                }
                return (paths_loaded) ? 0.0 : link_opacity * 0.5;
            });
    }

    d3.selectAll("circle")
        .style("opacity", function (n) {
            if (adj_nodes.find(function (element) {
                return element === n.id;
            })) {
                return 1.0;
            }
            return 0.3;
        });

    let connected_nodes = nodes.filter(
        function (t) {
            return adj_nodes.find(function (element) {
                return t.id === element;
            });
        });

    connected_nodes
        .append("rect")
        .style("fill", "#FFFFFF")
        .style("opacity", 0.0)
        .style("rx", 3)
        .style("ry", 3);

    connected_nodes
        .append("text")
        .text(function (n) {
            return n.id;
        })
        .attr("x", 6)
        .attr("y", 3)
        .style("background-color", "#FFFFFF")
        .style("font-size", "8px")
        .style("font-weight", "bold")
        .style("position", "absolute")
        .style("z-index", 2)
        .style("opacity", 0.4)
        .on("mouseover", function () {
            d3.select(this.parentNode).each(function () {
                this.parentNode.appendChild(this);
            });
            d3.select(this).style("opacity", 1.0);



            let bbox = d3.select(this).node().getBBox();
            console.log(d3.select(this).attr("width"));
            console.log(d3.select(this).attr("height"));

            d3.select(this.parentNode).select("rect").style("opacity", 1.0)
                .attr("width", bbox.width + 4)
                .attr("x", d3.select(this).attr("x") - 2)
                .attr("y", -d3.select(this).attr("y") - 2)
                .attr("height", bbox.height + 0.5);
        })
        .on("mouseout", function () {
            d3.select(this).style("opacity", 0.4);
            d3.select(this.parentNode).select("rect").style("opacity", 0.0);
        });
}

/**
 * Computes the edge bundling for the current graph layout.
 */
function bundle_Edges() {
    disablePaths();

    let bundling = d3.ForceEdgeBundling()
        .step_size(0.1)
        .compatibility_threshold(0.4)
        .nodes(simulation.nodes())
        .edges(loaded_data.links);
    let results = bundling();

    let d3line = d3.line()
        .x(function (d) {
            return d.x;
        })
        .y(function (d) {
            return d.y;
        });

    results.forEach(function (sub_points) {
        // for each of the arrays in the results
        // draw a line between the sub-divisions points for that edge
        paths
            .append("path")
            .attr("d", d3line(sub_points))
            .attr("source", sub_points[0].id)
            .attr("target", sub_points[sub_points.length - 1].id)
            .style("stroke-width", 1)
            .style("stroke", "#222222")
            .style("fill", "none")
            .style('stroke-opacity', link_opacity * 0.4); //use opacity as blending
    });

    paths_loaded = true;
    links.style("opacity", 0.0);

    if (active_clicked != null) {
        brushLinks(active_clicked);
    }
}

/**
 * Creates an isolated repulsion force for the nodes A and B.
 * @param force force that is isolated for A and B
 * @param nodeA first node
 * @param nodeB second node
 * @returns {*} the repulsion force
 */
function isolate(force, nodeA, nodeB) {
    let initialize = force.initialize;
    force.initialize = function () {
        initialize.call(force, [nodeA, nodeB]);
    };
    return force;
}

/**
 * Adds a Group label for the given name with the number of elements of this group.
 * @param name of the group
 * @param number of elements in the group
 */
function addGroupLabel(name, number) {
    let btn = $("<button></button>").text(name)
        .addClass("btn")
        .addClass("btn-sm")
        .addClass("disabled")
        .addClass("font-weight-bold")
        .css("color", "#FFFFFF")
        .css("opacity", 1.0)
        .css("background-color", color(name));

    let span = $("<span></span>").text(number)
        .addClass("badge")
        .attr("style", "color: #220 !important")
        .css("background-color", "#FFFFFF")
        .addClass("font-weight-bold")
        .addClass("ml-2")
        .addClass("float-right")
        .css("font-size", "x-small");

    btn.append(span);
    group_div.append(btn);
}

/**
 * Updates the position of nodes and links on each tick.
 */
function animation() {
    updateNodesAndLinks()
}

/**
 * Lets the simulation tick 500 times and then updates nodes and link positions.
 */
function noAnimation() {
    for (let i = 0; i < 500; i++) {
        simulation.tick();
    }
    updateNodesAndLinks();
    simulation.stop();
}

/**
 * Updates the position of nodes and links.
 */
function updateNodesAndLinks() {
    if (paths_loaded) {
        paths_loaded = false;
        d3.selectAll("path").remove();
    }

    links
        .attr("x1", function (d) {
            return d.source.x;
        })
        .attr("y1", function (d) {
            return d.source.y;
        })
        .attr("x2", function (d) {
            return d.target.x;
        })
        .attr("y2", function (d) {
            return d.target.y;
        });

    nodes
        .attr("transform", function (d) {
            return "translate(" + d.x + "," + d.y + ")";
        });
}

/**
 * Disables or Enables the animation of the graph optimisation process.
 * @param enable true to show the animation
 * */
function changeAnimate(enable) {
    if (enable) {
        simulation
            .on("tick", animation);
    } else {
        simulation
            .on("tick", noAnimation);
    }
}

/**
 * Updates the size of the nodes in the graph.
 * @param value either 'fixed' or 'dynamic'
 * */
function updateNodeSize(value) {
    // if the node size changes to fixed => take the configured radius for the circles
    if (value === "fixed") {
        nodes.selectAll("circle")
            .attr("r", node_radius)
            .attr("fill", function (d) {
                return color(d.group);
            });
    } else { // otherwise compute it by the number of incident edges
        nodes.selectAll("circle")
            .attr("r", function (d) {
                return Math.sqrt(loaded_data.links.filter(function (l) {
                    return l.source.id === d.id || l.target.id === d.id;
                }).length);
            })
            .attr("fill", function (d) {
                return color(d.group);
            });
    }
}

/**
 * Function that updates which bars
 * are considered for additional attraction.
 * @param value Value of the slider
 * */
function update_attraction(value) {
    disablePaths();
    brushLinks(active_clicked);

    // we update all links
    simulation.force("link").strength(function (link) {
        for (let i = 0; i < bars.length; i++) {
            // if the bar is longer than the slider value it should not be considered
            if (bars[i].death > value) {
                continue;
            }

            // if the link(edge) is that of the bar we give it a strong attraction
            if (link === bars[i].edge) {
                return attraction_strength;
            }
        }

        // otherwise a weak force is applied to this link
        return attraction_strength_weak;
    });

    // simulation needs to be restarted for anything to work
    simulation.alpha(1).alphaDecay(0.01).restart();
}

/**
 * Function that updates the repulsion based on the selected bars.
 * */
function update_repulsion() {
    disablePaths();
    brushLinks(active_clicked);

    // maybe it is necessary to set all forces again. this could definitely
    // be optimised so that two nested for loops are not necessary, but it works for now
    for (let i = 0; i < loaded_data.nodes.length - 1; i++) {
        for (let j = i + 1; j < loaded_data.nodes.length; j++) {
            simulation.force(loaded_data.nodes[i].id.concat(loaded_data.nodes[j].id)).strength(repulsion_strength_weak);
        }
    }

    // go through all possible forces and check if that bar is selected
    // if so add strong repulsion between these two nodes
    for (let i = 0; i < loaded_data.nodes.length - 1; i++) {
        for (let j = i + 1; j < loaded_data.nodes.length; j++) {
            for (let k = 0; k < bars.length; k++) {
                if (!bars[k].selected) {
                    continue;
                }

                if (bars[k].componentA.contains(loaded_data.nodes[i].id) && bars[k].componentB.contains(loaded_data.nodes[j].id)) {
                    simulation.force(loaded_data.nodes[i].id.concat(loaded_data.nodes[j].id)).strength(repulsion_strength);
                }
            }
        }
    }
    simulation.alpha(1).alphaDecay(0.01).restart();
}

/**
 * Removes the bundled edges.
 */
function disablePaths() {
    d3.selectAll("path").remove();
    paths_loaded = false;
    btn_bundle_edges.addClass("disabled");
}