Public
Edited
Dec 4, 2024
Insert cell
Insert cell
data = await FileAttachment("links@5.csv").csv()
Insert cell
// one extra data cleaning step; haven't resolved relative backpaths yet
records = data.filter(r => r.link.indexOf('..') === -1)
Insert cell
function createTree() {
let root = {name: '/', children: [], links: []}
records.forEach(record => {
// add nodes for parent page link
let path = record.parent
let parentNode = root
path.split('/').map((pathSegment, i) => {
let nextNode
if (pathSegment === '') {
pathSegment = '/'
nextNode = root
}
else {
nextNode = parentNode.children.filter(child => child.name === pathSegment)[0]
}
if (!nextNode) {
nextNode = {name: pathSegment, children: [], links: []}
parentNode.children.push(nextNode)
}
if (path.split('/').length === i + 2) {
nextNode.links.push(record.link)
}
parentNode = nextNode
})
// add nodes for link
let anotherPath = record.link
parentNode = root
anotherPath.split('/').map((pathSegment, i) => {
let nextNode
if (pathSegment === '') {
pathSegment = '/'
nextNode = root
}
else {
nextNode = parentNode.children.filter(child => child.name === pathSegment)[0]
}
if (!nextNode) {
nextNode = {name: pathSegment, children: [], links: []}
parentNode.children.push(nextNode)
}
parentNode = nextNode
})
})
return root
}
Insert cell
createCanvasChart = { //function(d3, customBilink, createTree, reconstructPath) {
const width = 1000 * window.devicePixelRatio;
const radius = width / 2;
const halo = "#fff"; // color of label halo
const haloWidth = 3; // padding around the labels
const colorin = "#00f";
const colorout = "#f00";
const colornone = "#ccc";

const tree = d3.cluster()
.size([2 * Math.PI, radius - 100]);

const root = tree(customBilink(d3.hierarchy(createTree()))
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name))
);

// Create a canvas element
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = width;
canvas.style.maxWidth = '100%'
canvas.style.height = 'auto'
const context = canvas.getContext('2d');

const line = d3.lineRadial()
.curve(d3.curveBundle.beta(0.85))
.radius(d => d.y)
.angle(d => d.x)
.context(context);

// Function to draw links
function drawLinks() {
context.globalAlpha = 0.25; // poor man's mix-blend-mode: multiply
context.strokeStyle = '#ccc';
context.lineWidth = 1;
root.descendants().flatMap(d => d.outgoing).forEach(([i, o]) =>
drawOneLink(i,o)
)
}

function drawOneLink(startNode, endNode) {
context.save();
context.translate(radius, radius);
context.beginPath();
line(startNode.path(endNode));
context.stroke();
context.restore();
}

// Function to draw nodes
function drawNodes() {
context.font = '12px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';

root.descendants()
.filter(d => d.incoming.length > 0 || d.outgoing.length > 0)
.forEach(d => {
context.save();
context.translate(radius, radius);
context.rotate(d.x - Math.PI/2);
context.translate(d.y, 0);
context.rotate(d.x < Math.PI ? 0 : Math.PI); // rotate half the labels that seem upside down
context.textAlign = d.x < Math.PI ? 'start' : 'end' // align edges of labels with circles' circumference
// .attr("x", d => d.x < Math.PI ? 6 : -6)
context.beginPath();
context.fillStyle = 'black';
context.strokeStyle = halo;
context.lineWidth = haloWidth;
context.strokeText(d.data.name, 0, 0);
context.fillText(d.data.name, 0, 0);
context.restore();
});
}

drawLinks();
let defaultLinksDrawing = context.getImageData(0,0,width,width)

function redrawLinks() {
context.putImageData(defaultLinksDrawing, 0, 0)
}
// drawNodes();

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

const node = svg.append("g")
.selectAll()
.data(root.descendants()
.filter(d => d.incoming.length > 0 || d.outgoing.length > 0))
.join("g")
.attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI ? 6 : -6)
.attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
.attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
.attr("paint-order", "stroke")
.attr("stroke", halo)
.attr("stroke-width", haloWidth)
.text(d => d.data.name)
.each(function(d) { d.text = this; })
.on("mouseover", overed)
.on("mouseout", outed)
// .call(text => text.append("title").text(d => `a/b/c/sample/`));
.call(text => text.append("title").text(d => reconstructPath(d))); // tooltip text

function overed(event, d) {
// link.style("mix-blend-mode", null); // maybe 1/2 opacity?
d3.select(this).raise()
d3.select(this).attr("font-weight", "bold");
d3.select(this).style("cursor", "pointer");

context.globalAlpha = 0.5
// context.lineWidth = 2
context.strokeStyle = '#f542a7'
; d.outgoing.forEach(([i,o]) => drawOneLink(i,o))
context.strokeStyle = 'blue';
d.incoming.forEach(([i,o]) => drawOneLink(i,o))

// d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", colorin).attr("font-weight", "bold");

//
// d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", colorout).attr("font-weight", "bold"); // this is a slow operation, to change attrs on hundreds of svg nodes
}

function outed(event, d) {
// link.style("mix-blend-mode", "multiply");
d3.select(this).attr("font-weight", null);

// d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", null).attr("font-weight", null);
//
// d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", null).attr("font-weight", null); // this is a slow operation, to change attrs on hundreds of svg nodes
redrawLinks()
}


// return svg.node()
// return canvas

let container = document.createElement('div')
container.style.position = 'relative'
canvas.style.position = 'absolute'
canvas.style.left = '0'
canvas.style.top = '0'
canvas.style.zIndex = '-1' // make sure canvas is behind svg so svg can receive mouse events
let _svg = svg.node()
_svg.style.left = '0'
_svg.style.top = '0'
container.appendChild(_svg)
container.appendChild(canvas)
return container
}
Insert cell
createTree()
Insert cell
import {Tree} from "@d3/tree-component"
Insert cell
basictreechart = Tree(createTree(), {
label: d => d.name,
title: (d, n) => `${n.ancestors().reverse().map(d => d.data.name).join("/").substring(1)}`, // hover text
width: 1152
})
Insert cell
Insert cell
basiccirclechart = {
const width = 2000
const radius = width / 2
const halo = "#fff" // color of label halo
const haloWidth = 3 // padding around the labels

const tree = d3.cluster()
.size([2 * Math.PI, radius - 100]);
const root = tree(d3.hierarchy(createTree())
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name)));

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

const link = svg.append("g")
.attr("stroke", '#ccc')
.attr("fill", "black")
.selectAll()
.data(root.leaves())
.join("path")
.style("mix-blend-mode", "multiply")
// .attr("d", (d) => d3.line(d.path(root))) // calculate traversal of hierarchy from start node to dest node
.attr("d", (d) => d3.line(root.path(d)))
.each(function(d) { d.path = this; });

const node = svg.append("g")
.selectAll()
.data(root.descendants())
.join("g")
.attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI ? 6 : -6)
.attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
.attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
.attr("paint-order", "stroke")
.attr("stroke", halo)
.attr("stroke-width", haloWidth)
.text(d => d.data.name)
.each(function(d) { d.text = this; })
.call(text => text.append("title").text(d => ``));
return svg.node()
}
Insert cell
function reconstructPath(node) {
if (node.data.name === '/') {return '/'}
let result = ''
while (node) {
result = node.data.name + '/' + result
node = node.parent
}
return result.substring(2)
}
Insert cell
function createId(node) {
return reconstructPath(node).substring(1)
}
Insert cell
new Map( d3.hierarchy(createTree()).descendants().map(node => [reconstructPath(node), node]))
Insert cell
Insert cell
customBilink(d3.hierarchy(createTree()))
.descendants()
.map(d => d.outgoing.map(pair => pair[1]))
.flatMap(x => x)
.indexOf(undefined)
Insert cell
customBilink(d3.hierarchy(createTree()))
.descendants()
.map(d => d.outgoing)
Insert cell
Insert cell
Insert cell
Insert cell
// TODO:
// reduce label clutter. maybe only render labels for top N / top N% children of parent node?
// make colors for edges indicating incoming/outgoing
//
// note: pure svg is very laggy with many thousands of nodes
gchartwithedges = function() {
const width = 1000;
const radius = width / 2;
const halo = "#fff" // color of label halo
const haloWidth = 3 // padding around the labels
const colorin = "#00f"
const colorout = "#f00"
const colornone = "#ccc";

const tree = d3.cluster()
.size([2 * Math.PI, radius - 100]);
const root = tree(customBilink(d3.hierarchy(createTree()))
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name))
)
const svg = d3.create("svg")
.attr("width", width)
.attr("height", width)
.attr("viewBox", [-width / 2, -width / 2, width, width])
.attr("style", "max-width: 100%; height: auto; font: 12px sans-serif;");

var colors = [ 'rgb(255,0,0)', 'rgb(0,0,0)' ];

var grad = svg.append('defs')
.append('linearGradient')
.attr('id', 'edgeGradient')
.attr('x1', '0%')
.attr('x2', 'q00%')
.attr('y1', '0%')
.attr('y2', '100%');

grad.selectAll('stop')
.data(colors)
.enter()
.append('stop')
.style('stop-color', d => d)
.attr('offset', function(d,i){
return 100 * (i / (colors.length - 1)) + '%';
})

const line = d3.lineRadial()
.curve(d3.curveBundle.beta(0.85))
.radius(d => d.y)
.angle(d => d.x);

// usage:
// line([[x0,0y],[x1,y1],[x2,y2],...])
const link = svg.append("g")
.attr("stroke", '#ccc')
// .style('stroke', 'url(#dgeeGradient)')
.attr("fill", "none")
.selectAll()
.data(root.descendants().flatMap(d => d.outgoing)) // [[srcNode,destNode], [srcNode,destNode], ...]
.join("path")
.style("mix-blend-mode", "multiply") // aka stacking opacities to indicate dense areas
.attr("d", ([i, o]) => line(i.path(o))) // calculate traversal of hierarchy from incoming node to outgoing node
.each(function(d) { d.path = this; });

const node = svg.append("g")
.selectAll()
.data(root.descendants())
.join("g")
.attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI ? 6 : -6)
.attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
.attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
.attr("paint-order", "stroke")
.attr("stroke", halo)
.attr("stroke-width", haloWidth)
.text(d => d.data.name)
.each(function(d) { d.text = this; })
.on("mouseover", overed)
.on("mouseout", outed)
// .call(text => text.append("title").text(d => `a/b/c/sample/`));
.call(text => text.append("title").text(d => reconstructPath(d))); // tooltip text

function overed(event, d) {
link.style("mix-blend-mode", null);
d3.select(this).attr("font-weight", "bold");
d3.select(this).style("cursor", "pointer");

d3.selectAll(d.incoming.map(d => d.path)).attr("stroke", colorin).raise();
d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", colorin).attr("font-weight", "bold");
d3.selectAll(d.outgoing.map(d => d.path)).attr("stroke", colorout).arise();
d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", colorout).attr("font-weight", "bold");
}

function outed(event, d) {
link.style("mix-blend-mode", "multiply");
d3.select(this).attr("font-weight", null);
d3.selectAll(d.incoming.map(d => d.path)).attr("stroke", null);
d3.selectAll(d.incoming.map(([d]) => d.text)).attr("fill", null).attr("font-weight", null);
d3.selectAll(d.outgoing.map(d => d.path)).attr("stroke", null);
d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr("fill", null).attr("font-weight", null);
}
return svg.node()
}
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