Public
Edited
Mar 15, 2024
Fork of Force Hulls
Importers
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = ForceHulls(
graph,
{
width,
groupBy: (d) => d.group,
chargeStrength: -100,
disjoint: disjoint,
animate: animate,
this: this, // provide this and invalidation to support better animations
invalidation
}
)
Insert cell
fadeInJoin({element: "circle", o: {animate: true}})
Insert cell
function ForceHulls(
graph, // { nodes, links, clusters /* Map of Arrays for hulls */ }
options = {}
) {
let o = {
groupBy: (d) => d.group,
nodeSize: 50,
height: 1000,
width: 800,
hullPadding: 5,
showGroupingNodes: true,
hullColor: d3.scaleOrdinal(d3.schemeCategory10),
fontSize: 6,
clipLabels: true,
minDegree: 0,
useImages: true,
sizeBy: (d) => d.degree,
id: (d) => d.id,
labelBy: (d, i) => d.id,
lineWidth: 1.3,
title: "",
invalidation: null,
this: null,
chargeStrength: null, // If provided sets the fixed charge strength for all nodes
maxCharge: -200, // If chargeStrength is null, this value will be use to assign the charge strength proportionally to the radius and with this maxCharge strength value
photoBy: (d) => d.photo,
photoMargin: 2,
centerStrength: 0.1,
linkStrength: 0.15,
linkDistance: 5,
disjoint: false,
tipcontent: null, // Provide a function that returns html for the tooltip
warmUpTicks: 100,
drawHulls: true,
transitionDuration: 500,
animate: false,
...options
};

let visibleNodes = new Map(graph.nodes.map((d) => [o.id(d), d]));

let clusters = [];
if (o.drawHulls) {
//if (graph.clusters) {
//clusters = graph.clusters;
//} else {
clusters = d3.group(graph.nodes, (d) => ("" + o.groupBy(d)).trim());
//}
}

const filteredClusters = filterClustersFromNodes({
clusters,
visibleNodes,
id: o.id
});
const filteredLinks = filterLinksFromNodes({
links: graph.links,
visibleNodes,
id: o.id
});
console.log( "filtered Links", filteredLinks);

const chargeScale = d3
.scalePow()
.exponent(1.5)
.domain([1, 30])
.range([-0.1, o.maxCharge]);

o.chargeStrength = o.chargeStrength || ((d) => chargeScale(d.r));

const svg = o.this ? d3.select(o.this).select("svg") : d3.create("svg");

svg.append("defs").append("style").text(`
circle.highlight { stroke: red; stroke-width: 2px; }
`);

const target = htl.html`<div>
<h2>${o.title}</h2>
${svg.node()}
</div>`;

svg
.attr("width", o.width)
.attr("height", o.height)
.attr("viewBox", [0, 0, o.width, o.height])
.attr(
"style",
// "max-width: 100%; height: auto; height: intrinsic; overflow: visible",
//"overflow: visible"
);

const r = d3
.scalePow()
.domain([0, d3.max(graph.nodes, o.sizeBy)])
.range([o.nodeSize / 2, o.nodeSize * 2]);

graph.nodes.map((d) => (d.r = o.sizeBy(d) ? r(o.sizeBy(d)) : r.range()[0]));

const collision = d3.forceCollide((d) => d.r + 2).iterations(4);
const simulation = d3
.forceSimulation(graph.nodes)
.force(
"link",
d3
.forceLink(filteredLinks)
.id((n) => o.id(n))
.distance(o.linkDistance)
.strength(o.linkStrength)
)
.force("charge", d3.forceManyBody().strength(o.chargeStrength));

// Accelerate the simulation the first time
if (!o.this) {
simulation.alpha(3);
} else {
simulation.alpha(1);
}

if (o.disjoint) {
simulation
.force("x", d3.forceX(o.width / 2).strength(o.centerStrength))
.force(
"y",
d3
.forceY(o.height / 2)
.strength((o.centerStrength * 0.7 * width) / o.height)
);
} else {
simulation.force("center", d3.forceCenter(width / 2, o.height / 2));
}

// -------- HULLS ---------
const { hull, getHull } = drawHull({ o, svg, graph, filteredClusters });

// -------- LINKS ---------
const link = drawLinks({ graph, o, svg, filteredLinks });

// -------- NODES ---------
const node = drawNodes({ svg, graph, simulation, o, filteredClusters });

// -------- LABELS ---------
const label = createLabels({
svg,
_data: graph.nodes.filter(
(d) =>
// d.type !== "Connection" ? d.degree >= o.minDegree : 0
d.type !== "Connection"
),
className: "nodeLabel",
o
});

// ---------- IMAGES ---------------
const images = drawImages({ svg, graph, simulation, o });

// --------- Group Labels ------------
const labelDepts = createLabels({
svg,
_data: graph.nodes.filter((d) => d.type === "Connection"),
className: "connectionLabel",
o
});

// --------- Tooltips ---------------
const hoverOptions = { raise: false, r: r.range()[1], svg };
if (o.tipcontent) hoverOptions.tipcontent = o.tipcontent;
const hoverObj = hover(node, invalidation, hoverOptions);


function ticked() {
link
// .transition()
// .duration(o.transitionDuration)
// .attr("display", simulation.alpha() <= 0.1 ? "block" : "none")
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);

label
.classed("shadow", simulation.alpha() <= 0.1)
// .transition()
// .duration(o.transitionDuration)
.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
labelDepts
.classed("shadow", simulation.alpha() <= 0.1)
// .transition()
// .duration(o.transitionDuration)
.attr("transform", (d) => `translate(${d.x}, ${d.y})`);

node
.classed("shadow", simulation.alpha() <= 0.1)
// .transition(t)
// .duration(o.transitionDuration)
.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
// .attr("cx", (d) => d.x)
// .attr("cy", (d) => d.y)

o.useImages &&
images
// .transition()
// .duration(o.transitionDuration)
// .attr("transform", (d) => `translate(${d.x}, ${d.y})`)
.attr("x", (d) => d.x)
.attr("y", (d) => d.y);

hull.attr("d", getHull);

// Update the binary tree for the tooltip;
hoverObj.reset();

// collide only at the end of the simulation
simulation.force("collide", simulation.alpha() <= 0.5 ? collision : null);
}

simulation.stop();
simulation.on("tick", ticked);
for (let i = 0; i < o.warmUpTicks; i++) {
simulation.tick();
}
simulation.restart();

// For when no invalidation is passed
const _invalidation = o.invalidation || invalidation;
_invalidation.then(() => {
simulation.stop();
});
ticked();

target.clusters = filteredClusters;

return target;
}
Insert cell
// ----------- Drag ----------------
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}

function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}

function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}

return d3
.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
// ----------- /Drag ----------------
Insert cell
// To be used with selection.join to generate a fade in effect
fadeInJoin = ({ element, o, opacity = 1 } = {}) => o.animate ? [
(enter) => {
const ele = enter.append(element).style("opacity", 0);
ele
.transition()
.delay((_, i, all) => (i * o.transitionDuration) / all.length)
.duration(1000)
.style("opacity", opacity);
return ele;
},
(update) => update,
(exit) =>
exit
.transition()
.duration(o.transitionDuration)
.style("opacity", 0)
.remove()
] : [element]
Insert cell
function drawNodes({ svg, graph, simulation, o, filteredClusters }) {
const nodeG = svg
.selectAll("g#gNodes")
.data([0])
.join("g")
.attr("id", "gNodes");
const node = nodeG
.selectAll("circle")
.data(
graph.nodes.filter(
(d) => o.showGroupingNodes || d.type !== "Connection" // hide grey nodes
),
(n) => o.id(n)
)
.join(...fadeInJoin({ element: "circle", o }))
.attr("fill", (d) =>
d.type === "Connection"
? "#999"
: o.hullColor(
getFirstMatchInMap({ map: filteredClusters, query: d, id: o.id })
)
)
// .attr("stroke", "black")
.attr("stroke-opacity", 1)
.attr("stroke-width", 1)
.attr("r", (d) => d.r)
.call(drag(simulation));

return node;
}
Insert cell
Insert cell
function drawHull({ o, svg, graph, filteredClusters }) {
const area = d3.line().curve(d3.curveCatmullRomClosed);
const hp = o.hullPadding;
const getHull = (d) => {
const points = d[1]
.filter((d) => o.showGroupingNodes || d.type !== "Connection")
.map((n) => [
[n.x - (n.r + hp), n.y],
[n.x + n.r + hp, n.y],
[n.x, n.y + n.r + hp],
[n.x, n.y - (n.r + hp)]
])
.flat();
const hull = d3.polygonHull(points);

return hull && area(hull);
};

const hullG = o.this
? svg.select("g#gHulls")
: svg.append("g").attr("id", "gHulls");
const hull = hullG
.selectAll("path.hull")
.data(filteredClusters, (d) => d[0])
.join(...fadeInJoin({ element: "path", opacity: 0.3, o }))
.attr("class", "hull")
.attr("fill", (d) => o.hullColor(d[0]))
.attr("opacity", 0.3)
.attr("pointer-events", "none");

return { hull, getHull };
}
Insert cell
function createLabels({ svg, _data, className = "nodeLabel", o }) {
const gLabel = svg
.selectAll(`g#gLabel_${className}`)
.data([0])
.join("g")
.attr("id", `gLabel_${className}`);

const getNameArray = (d, i, all) => {
const label = d.type === "Connection" ? d._id : o.labelBy(d, i, all);
return d.nameArray
? d.nameArray
: label && typeof label.split === "function"
? label.split(" ")
: [label];
};

// 1.8 for each line, but then move back for centering on y
const fontSizeFn = (d) => o.fontSize * (d.type !== "Connection" ? 1 : 1.8);

return (
gLabel
.selectAll(`text.${className}`)
.data(_data, o.id)
.join(...fadeInJoin({ element: "text", o }))
.attr("class", className)
// .attr("text-align", "center")
.attr("text-anchor", "middle")
// // .attr("x", (d) => d.r)
.attr(
"y",
(d, i, all) =>
-(getNameArray(d, i, all).length / 2) * fontSizeFn(d) + "px"
)
.attr("font-size", (d) => `${fontSizeFn(d)}pt`)
.attr("font-weight", (d) =>
d.type !== "Connection" ? "light" : "bolder"
)
.attr("font-family", "Oswald")
.attr("pointer-events", "none")
// .style("clip-path", (d) =>
// o.clipLabels && d.type !== "Connection" ? `circle(closest-side)` : ""
// )
.style("clip-path", (d) =>
o.clipLabels && d.type !== "Connection"
? `circle(${d.r - o.photoMargin}px at 50% 50%)`
: ""
)
// .text(id);
.each(function (d) {
return (
d3
.select(this)
.selectAll("tspan")
.data(getNameArray)
.join("tspan")
.attr("x", 0)
.attr("dy", fontSizeFn(d) + "px")
// .attr("dy", "2.1ch")
.text((n) => n)
);
})
);
}
Insert cell
function drawImages({svg, graph, simulation, o}) {
const gImages = svg
.selectAll("g#gImages")
.data([0])
.join("g")
.attr("id", "gImages");
return gImages
.selectAll("image")
.data(
o.useImages
? graph.nodes.filter(
(d) =>
d.type !== "Connection" &&
o.photoBy(d)
// && d.photo.toUpperCase().indexOf("JPG") !== -1
)
: [],
o.id
)
.join(...fadeInJoin({element: "image", o}))
.attr("href", o.photoBy)
.attr("width", (d) => d.r * 2 - o.photoMargin)
.attr("height", (d) => d.r * 2 - o.photoMargin)
// .attr("stroke", "black")
.attr("preserveAspectRatio", "xMidYMid slice")
.attr("transform", (d) => `translate(-${d.r - o.photoMargin/2}, -${d.r - o.photoMargin/2})`)
.style("clip-path", (d) => `circle(${d.r - o.photoMargin/2}px)`)
.style("pointer-events", "none")
.call(drag(simulation));
}
Insert cell
// Searches in a Map of Arrays for the first occurance of a value using an id function
function getFirstMatchInMap({ map, query, id }) {
for (let [eleKey, eleNodes] of map) {
for (let n of eleNodes) {
if (id(n) === id(query)) return eleKey;
}
}

return null;
}
Insert cell
function filterClustersFromNodes({clusters, visibleNodes, id}) {
let filteredMap = new Map();
for (let [eleKey, eleNodes] of clusters) {
const filteredNodes = eleNodes.filter(n => visibleNodes.has(id(n)));
if (filteredNodes.length>0) {
filteredMap.set(eleKey, filteredNodes);
}
}

return filteredMap;
}
Insert cell
function filterLinksFromNodes({ links, visibleNodes, id }) {
const idIfObject = (d) => (typeof d === "string" ? d : id(d));

return links.filter(
({ source, target }) =>
visibleNodes.has(idIfObject(source)) &&
visibleNodes.has(idIfObject(target))
);
}
Insert cell
network
Insert cell
import {network} from "bfb0f4aada2da7ae"
Insert cell
graph = FileAttachment("miserables.json").json()
Insert cell
import {hover, link} with {tippytheme} from "@john-guerra/hello-tippy"
Insert cell
link
Insert cell
tippytheme = "light"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more