Public
Edited
Apr 27
Insert cell
Insert cell
Insert cell
data = FileAttachment("labelled@1.json").json()
Insert cell
mutable selectedPaths = new Set();
Insert cell
mutable selectedNodes = new Set();
Insert cell
mutable nodeWeights = new Map();
Insert cell
mutable maxWeight = 0;
Insert cell
// we need to create a map so we can "bundle" nodes in the same thingy
function radialGraph(data, signatures) {
const width = 1000;
const radius = Math.min(width, width) / 2 - 30;

const svg = createSVG(width, radius);
const { nodeMap, links, signatureGroups } = buildGraph(data, signatures);
const nodes = prepareNodes(nodeMap, links, signatures, signatureGroups);
positionNodes(nodes, links, radius);

drawLinks(svg, links, nodeMap, signatures);
drawNodes(data, svg, nodes, nodeMap, signatures);
return svg.node();
}
Insert cell
function formatNodes(svg) {
svg.selectAll("circle")
.style("r", n => {
const weight = nodeWeights.get(n.id);
return Math.log2(weight) || 0;
});
}
Insert cell
function setNodeSize(data, nodes, svg) {
nodeWeights.clear();
nodes.forEach(n => {
// TODO: normalize weights before putting them there!
const rows = data.filter(row => row.label.startsWith(n));
const weight = new Set(rows.map(r => r.id)).size
nodeWeights.set(n, weight);
});
if (maxWeight === 0) {
mutable maxWeight = Math.max(...nodeWeights.values());
}
formatNodes(svg);
}
Insert cell
function createSVG(width, radius) {
return d3.create("svg")
.attr("viewBox", [-radius, -radius, width, width])
.attr("style", "width: 100%; height: auto; font: 10px sans-serif;");
}
Insert cell
function buildGraph(data, signatures) {
const nodeMap = new Map();
const links = [];
const signatureGroups = new Map();
const signatureSet = new Set(signatures);

data.forEach(d => {
const parts = d.label.split(':');
let parentKey = null;
let currentSignature = null;

for (let i = 0; i < parts.length; i++) {
const name = parts[i];
const nodeKey = getNodeKey(name, parentKey, signatureSet);

if (!nodeMap.has(nodeKey)) {
nodeMap.set(nodeKey, { id: nodeKey, name, depth: i, sources: new Set() });
}

if (parentKey !== null) {
links.push({ source: parentKey, target: nodeKey });
nodeMap.get(nodeKey).sources.add(parentKey);
}

if (signatureSet.has(name)) {
currentSignature = name;
if (!signatureGroups.has(name)) {
signatureGroups.set(name, new Set());
}
}

if (currentSignature) {
signatureGroups.get(currentSignature).add(nodeKey);
}

parentKey = nodeKey;
}
});

return { nodeMap, links, signatureGroups };
}
Insert cell
function prepareNodes(nodeMap, links, signatures, signatureGroups) {
const connectedKeys = new Set();
links.forEach(link => {
connectedKeys.add(link.source);
connectedKeys.add(link.target);
});

const nodes = [];
signatures.forEach(sig => {
const group = signatureGroups.get(sig);
if (group) {
group.forEach(nodeKey => {
const node = nodeMap.get(nodeKey);
if (node && connectedKeys.has(node.id)) {
nodes.push(node);
}
});
}
});

return nodes;
}
Insert cell
function positionNodes(nodes, links, outerRadius) {
const idToNode = new Map(nodes.map(d => [d.id, d]));

// Compute depth properly if needed
nodes.forEach(node => {
node.depth = getDepth(node, idToNode);
});

const maxDepth = Math.max(...nodes.map(d => d.depth));

// Assign target radius based on depth
nodes.forEach(node => {
node.targetRadius = (node.depth / (maxDepth)) * outerRadius;
});

// Force simulation
const simulation = d3.forceSimulation(nodes)
// make it radial
.force("radial", d3.forceRadial(d => d.targetRadius, 0, 0).strength(1))
// pull linked together
.force("link", d3.forceLink(links)
.id(d => d.id)
.distance(70)
.strength(0.6)
)
// separate nodes a bit
.force("collide", d3.forceCollide(d => 20).strength(1))
// separate trees as far as possible
.force("charge", d3.forceManyBody().strength(-500))
.stop();

// Run manually
simulation.tick(100);

// Save x, y, radius, angle
nodes.forEach(node => {
node.radius = Math.sqrt(node.x * node.x + node.y * node.y);
node.angle = Math.atan2(node.y, node.x) + Math.PI / 2;
if (node.angle < 0) node.angle += 2 * Math.PI;
});

function getDepth(node, idToNode, visited = new Set()) {
if (visited.has(node.id)) return 0; // prevent cycles
visited.add(node.id);
if (!node.sources || node.sources.size === 0) return 0;
let minParentDepth = Infinity;
node.sources.forEach(parentId => {
const parent = idToNode.get(parentId);
if (parent) {
minParentDepth = Math.min(minParentDepth, getDepth(parent, idToNode, new Set(visited)));
}
});
return 1 + (isFinite(minParentDepth) ? minParentDepth : 0);
}
}
Insert cell
Insert cell
function walkNode(id, nodeMap){
let paths = [];
let nodes = [];
const walk = (nid, path = []) => {
const node = nodeMap.get(nid);
path.unshift(node.name);
nodes.push(node);

if (node.sources.size == 0){
paths.push(path.join(":"));
};
node.sources.forEach(s => {
walk(s, path);
});
};
walk(id);
return [paths, nodes];
}
Insert cell
function findNodeMatches(label, nodeMap) {
const parts = label.split(':');
const n = parts.length;
const matches = [];

for (let i = 0; i < n; i++) {
for (let j = n; j > i; j--) {
const candidate = parts.slice(i, j).join(':');
if (nodeMap.has(candidate)) {
matches.push(candidate);
i = j - 1; // Skip matched parts
break;
}
}
}

return matches;
}
Insert cell
function findNodesFromData(data, nodeMap, nodes = []) {
const labels = [... new Set(data.map(r => r.label))]
labels.forEach(l => {
findNodeMatches(l, nodeMap).forEach(n => nodes.push(n));
});
return new Set(nodes);
}
Insert cell
function nodeLabel(node){
const s = nodeWeights.get(node.id);
if (!s || s === undefined) {
return node.name;
}
return `${node.name} (${s})`
}
Insert cell
function handleNodeClick(svg, node, nodeMap, data) {
let [paths, nodes] = walkNode(node.id, nodeMap);
// Add them to the record
paths.forEach(path => mutable selectedPaths.add(path));
nodes.forEach(n => mutable selectedNodes.add(n.id));
// Get the collected results
let results = data.filter(row => {
return Array.from(mutable selectedPaths).some(path => row.label.startsWith(path));
});
const activeNodes = findNodesFromData(results, nodeMap, Array.from(selectedNodes));
// Update circles
svg.selectAll("circle")
.style("opacity", n => activeNodes.has(n.id) ? 1 : 0.1);

// Update links
svg.selectAll("path")
.style("opacity", link => {
const source = link.source;
const target = link.target;
if (activeNodes.has(source.id) && activeNodes.has(target.id)) {
return 1;
}
return 0.1;
});

// Update text
svg.selectAll("text")
.text(nodeLabel)
.style("opacity", n => activeNodes.has(n.id) ? 1 : 0.1);

setNodeSize(results, activeNodes, svg);
}
Insert cell
function drawNodes(data, svg, nodes, nodeMap, signatures) {
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
const specialColor = new Map(signatures.map((d, i) => [d, colorScale(i)]));
const signatureSet = new Set(signatures);

const node = svg.append("g")
.style("opacity", .8)
.selectAll("g")
.data(nodes)
.join("g")
.attr("transform", d => `translate(${d.x},${d.y})`)
.style("cursor", "pointer")
.on("click", (event, node) => {
handleNodeClick(svg, node, nodeMap, data)
});

node.append("circle")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.attr("fill", d => {
const ancestor = findAncestor(d, nodeMap, signatureSet);
return specialColor.get(ancestor) || "#888";
})
.attr("fill-opacity", 0.8);

setNodeSize(data, nodes.map(n => n.id), svg);

node.append("text")
.text(nodeLabel)
.attr("dy", "0.31em")
.attr("x", 6)
.attr("text-anchor", "start")
.attr("paint-order", "stroke")
.attr("stroke", "white")
.attr("fill", "black");

svg.on("click", (event) => {
if (event.target.tagName === "svg") {
selectedNodes.clear();
selectedPaths.clear();

svg.selectAll("circle").style("opacity", .8);
svg.selectAll("path").style("opacity", .8);
svg.selectAll("text").style("opacity", 1).text(nodeLabel);
setNodeSize(data, nodes.map(n => n.id), svg);
}
});
}
Insert cell
function findAncestor(node, nodeMap, signatureSet) {
if (signatureSet.has(node.name)) return node.name;

const visited = new Set();
let queue = [node.id];
while (queue.length) {
const next = [];
for (const key of queue) {
const n = nodeMap.get(key);
if (!n) continue;
if (signatureSet.has(n.name)) return n.name;
n.sources.forEach(parent => {
if (!visited.has(parent)) {
visited.add(parent);
next.push(parent);
}
});
}
queue = next;
}
return null;
}
Insert cell
function getNodeKey(name, parentKey, signatureSet) {
if (signatureSet.has(name)) return name;
if (parentKey) return parentKey + ":" + name;
return name;
}
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