Published
Edited
Oct 5, 2022
Importers
5 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
import { ForceGraph } from "@d3/force-directed-graph"
Insert cell
ForceGraph({
nodes: items,
links: cooccurrences
}, {
nodeId: d => d.name, // we want to uniquely identify each node
nodeTitle: d => `${d.name}`, // we can provide a title for tooltip
linkSource: d => d.item_a, // we need to tell the ForceGraph how to associate the link source with a node
linkTarget: d => d.item_b, // we need to tell the ForceGraph how to associate the link target with a node
linkStrokeWidth: l => Math.sqrt(l.count / 5), // we can encode the count in the stroke width of the link
width,
invalidation // a promise to stop the simulation when the cell is re-run
})
Insert cell
Insert cell
viewof searchedItems = Inputs.search(items, { query: "campus" }) // let's add a default query so you can see the effect right away
Insert cell
Insert cell
Insert cell
Insert cell
relatedItems = items.filter(d => searchedLinks.find(l => d.name === l.item_a || d.name === l.item_b))
Insert cell
ForceGraph({
nodes: relatedItems,
links: searchedLinks
}, {
nodeId: d => d.name,
nodeGroup: d => !!searchedItems.find(s => s.name === d.name), // we can color the nodes by whether or not they are in the searched items
nodeTitle: d => `${d.name}`,
linkSource: d => d.item_a,
linkTarget: d => d.item_b,
linkStrokeWidth: l => Math.sqrt(l.count),
width,
height: 600,
invalidation // a promise to stop the simulation when the cell is re-run
})
Insert cell
Insert cell
CustomForceGraph({
nodes: relatedItems,
links: searchedLinks
}, {
nodeId: d => d.name,
nodeGroup: d => !!searchedItems.find(s => s.name === d.name),
nodeTitle: d => `${d.name}`,
linkSource: d => d.item_a,
linkTarget: d => d.item_b,
linkStrokeWidth: l => Math.sqrt(l.count),
width,
invalidation // a promise to stop the simulation when the cell is re-run
})
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/force-directed-graph
function CustomForceGraph({
nodes, // an iterable of node objects (typically [{id}, …])
links // an iterable of link objects (typically [{source, target}, …])
}, {
nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)
nodeGroup, // given d in nodes, returns an (ordinal) value for color
nodeGroups, // an array of ordinal values representing the node groups
nodeTitle, // given d in nodes, a title string
nodeFill = "currentColor", // node stroke fill (if not using a group color encoding)
nodeStroke = "#fff", // node stroke color
nodeStrokeWidth = 1.5, // node stroke width, in pixels
nodeStrokeOpacity = 1, // node stroke opacity
nodeRadius = 5, // node radius, in pixels
nodeStrength,
linkSource = ({source}) => source, // given d in links, returns a node identifier string
linkTarget = ({target}) => target, // given d in links, returns a node identifier string
linkStroke = "#999", // link stroke color
linkStrokeOpacity = 0.6, // link stroke opacity
linkStrokeWidth = 1.5, // given d in links, returns a stroke width in pixels
linkStrokeLinecap = "round", // link stroke linecap
linkStrength,
colors = d3.schemeTableau10, // an array of color strings, for the node groups
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
invalidation // when this promise resolves, stop the simulation
} = {}) {
// Compute values.
const N = d3.map(nodes, nodeId).map(intern);
const LS = d3.map(links, linkSource).map(intern);
const LT = d3.map(links, linkTarget).map(intern);
if (nodeTitle === undefined) nodeTitle = (_, i) => N[i];
const T = nodeTitle == null ? null : d3.map(nodes, nodeTitle);
const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern);
const W = typeof linkStrokeWidth !== "function" ? null : d3.map(links, linkStrokeWidth);
const L = typeof linkStroke !== "function" ? null : d3.map(links, linkStroke);

// Replace the input nodes and links with mutable objects for the simulation.
nodes = d3.map(nodes, (_, i) => ({id: N[i]}));
links = d3.map(links, (_, i) => ({source: LS[i], target: LT[i]}));

// Compute default domains.
if (G && nodeGroups === undefined) nodeGroups = d3.sort(G);

// Construct the scales.
const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors);

// Construct the forces.
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);
if (nodeStrength !== undefined) forceNode.strength(nodeStrength);
if (linkStrength !== undefined) forceLink.strength(linkStrength);

const simulation = d3.forceSimulation(nodes)
.force("link", forceLink)
.force("charge", forceNode)
.force("center", d3.forceCenter())
.on("tick", ticked);

const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");

const link = svg.append("g")
.attr("stroke", typeof linkStroke !== "function" ? linkStroke : null)
.attr("stroke-opacity", linkStrokeOpacity)
.attr("stroke-width", typeof linkStrokeWidth !== "function" ? linkStrokeWidth : null)
.attr("stroke-linecap", linkStrokeLinecap)
.selectAll("line")
.data(links)
.join("line");

/*
MODIFICATION
Instead of directly adding circle elements, we append a g (group) element
This will allow us to add a circle and a text element and position them together
*/
const node = svg.append("g")
.attr("fill", nodeFill)
.attr("stroke", nodeStroke)
.attr("stroke-opacity", nodeStrokeOpacity)
.attr("stroke-width", nodeStrokeWidth)
.selectAll("g")
.data(nodes)
.join("g")
.call(drag(simulation))
/*
ADDITION
Here we add event handlers to show and hide the text elements
*/
.on("mouseover", function(d) {
d3.select(this).raise().select("text").style("visibility", "")
})
.on("mouseout", function(d) {
d3.select(this).select("text").style("visibility", "hidden")
})

/*
MODIFICATION
We now have a reference to the circle as well as the node (g element)
*/
const circle = node
.append("circle")
.attr("r", nodeRadius)

if (W) link.attr("stroke-width", ({index: i}) => W[i]);
if (L) link.attr("stroke", ({index: i}) => L[i]);
if (G) circle.attr("fill", ({index: i}) => color(G[i]));
if (T) circle.append("title").text(({index: i}) => T[i]);
/*
ADDITION
Here we add a text element to each node if the user has supplied the title accessor
*/
if (T) node.append("text").text(({index: i}) => T[i])
.attr("stroke", "white")
.attr("fill", "black")
.attr("stroke-width", 3)
.attr("paint-order", "stroke")
.style("pointer-events", "none")
.style("visibility", "hidden")
.attr("dx", 10)
.attr("dy", 5)
if (invalidation != null) invalidation.then(() => simulation.stop());

function intern(value) {
return value !== null && typeof value === "object" ? value.valueOf() : value;
}

function ticked() {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
/*
MODIFICATION
Instead of modifying the positions of the circles directly
we translate the g elements so the circles and text move together
*/
node
.attr("transform", d => `translate(${d.x}, ${d.y})`)
// .attr("cx", d => d.x)
// .attr("cy", d => d.y);
}

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);
}

return Object.assign(svg.node(), {scales: {color}});
}
Insert cell
Insert cell
Insert cell
Insert cell
import { navigation, previews, notebooks, collection} from "@observablehq/which-items-are-purchased-together"
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more