Public
Edited
Dec 2
1 star
Insert cell
Insert cell
{
// -- SVG Setup -- //
const height = width / 2;
const svg = d3.create('svg')
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("style", "font: 12px sans-serif; user-select: none;");
const gRoot = svg.append("g").attr("aria-label", "org-chart")
.attr("transform","translate(0, 0) scale(1)");
const gLinks = gRoot.append("g").attr("aria-label", "links");
const gNodes = gRoot.append("g").attr("aria-label", "nodes");

// We want a tree-like layout.
const dx = 100;
const dy = 360;
const nodeWidth = dy - 100;
const nodeHeight = dx - 40;
const margin = {top: 20, right: 20, bottom: 20, left: 20}

const tree = d3.tree()
.nodeSize([dx, dy])
.separation((a, b) => a.parent == b.parent ? 1 : 1.5);

// Create our zoom behaviour.
let zoom = d3.zoom()
.scaleExtent([0.5, 2])
.on('zoom', handleZoom);
function handleZoom(e) {
console.log({transform: e.transform})
d3.select('svg g')
.attr('transform', e.transform);
}

const drawLink = d3.linkHorizontal()
.source(d => (
{
...d.source,
// Adjust to the middle right of the node.
y: d.source.y + d.source.offsetY + nodeWidth,
x: d.source.x + d.source.offsetX + nodeHeight / 2
}
))
.target(d => (
{
// Adjust to the middle left of the node.
...d.target,
y: d.target.y + d.target.offsetY,
x: d.target.x + d.target.offsetX + nodeHeight / 2
}
))
.x(d => d.y)
.y(d => d.x)


function dragstarted(event, d) {
const node = d3.select(this)
.raise()
node.select("rect")
.attr("stroke", "black");
console.log("dragStart", d)
}

function dragged(event, d) {
d.offsetX += event.dy;
d.offsetY += event.dx;
d3.select(this)
.attr("transform", `translate(${d.y + d.offsetY}, ${d.x + d.offsetX})`);
// Redraw links
gLinks.selectAll("path")
.filter(p => p.target.id == d.id || p.source.id == d.id)
.attr("d", drawLink);
}

function dragended(event, d) {
const node = d3
.select(this)
node.select("rect")
.attr("stroke", "#eee");
console.log("dragEnd", d)
}

const drag = d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
function update(event, source) {
// Update the chart in response to clicking on a node (source)
// Applying our tree function to our hierarchy gives laid-out data.
tree(hierarchy)
const nodes = hierarchy.descendants().reverse()
const edges = hierarchy.links();

// Calculate the bounds of our tree.
// const yBounds = d3.extent(hierarchy, d => d.y); // SWAP X AND Y FOR HORIZONTAL TREE
// const xBounds = d3.extent(hierarchy, d => d.x); // SWAP X AND Y FOR HORIZONTAL TREE
// const w = yBounds[1] - yBounds[0];
// const h = xBounds[1] - xBounds[0];
// Update zoom bounds
// zoom.translateExtent([[-w, -h],[w*2, h*2]])

// Create a transition
const transition = svg.transition()
.duration(500)
.tween("resize", window.ResizeObserver ? null : () => () => svg.dispatch("toggle"));

// -- Draw Nodes -- //
const node = gNodes.selectAll("g")
.data(nodes, d => d.id);

const nodeEnter = node.enter()
.append("g")
.attr("id", d => `node-${d.id}`)
.attr("transform", d => `translate(${source.y0 + source.offsetY}, ${source.x0 + source.offsetX})`)
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1)
.on("dblclick", (event, d) => {
d.children = d.children ? null : d._children;
update(event, d);
})
.call(drag)
nodeEnter
.append("rect")
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.attr("fill", "white")
.attr("stroke", "#eee")
.attr("rx", 10)
.style("filter","drop-shadow(2px 4px 4px rgba(0, 0, 0, 0.1))")

// Clip path
nodeEnter
.append("defs")
.append("clipPath")
.attr("id", d => `clip-${d.id}`)
.append("rect")
.attr("width", nodeWidth)
.attr("height", nodeHeight)
.attr("rx", 10)

nodeEnter
.append("image")
.attr("clip-path", d => `url(#clip-${d.id})`)
.attr("rx", 10)
// .attr("r", nodeHeight / 2)
.attr("width", nodeHeight)
.attr("height", nodeHeight)
.attr("href",d => `https://i.pravatar.cc/300?img=${d.id}`)
nodeEnter
.append("text")
.attr("id", "nodeName")
.attr("fill", "black")
.attr("font-weight", "bold")
.attr("dy", 20)
.attr("dx", nodeHeight + 10)
nodeEnter
.append("text")
.attr("id", "nodeTitle")
.attr("fill", "black")
.attr("dy", 40)
.attr("dx", nodeHeight + 10)
.text(d => d.data.title)

// Add expand icon
nodeEnter.append("text")
.attr("id", "nodeExpand")
.attr("dy", nodeHeight/2)
.attr("dx", nodeWidth - 20)
.attr("fill", "black")
.attr("fill-opacity", 0)
.attr("alignment-baseline", "central")
.text("⛶")

const nodeUpdate = node.merge(nodeEnter);
nodeUpdate.transition(transition)
.attr("transform", d => `translate(${d.y + d.offsetY}, ${d.x + d.offsetX})`)
nodeUpdate.select("#nodeName")
.text(d => d.data.name)
nodeUpdate.select("#nodeTitle")
.text(d => d.data.title)
nodeUpdate.select("#nodeExpand")
.transition(transition)
.attr("fill-opacity", d => d._children ? 1 : 0)

const nodeExit = node.exit()
.transition(transition)
.attr("transform", d => `translate(${source.y + source.offsetY},${source.x + source.offsetX})`)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0)
.remove()


// -- Draw Links -- //
const link = gLinks.selectAll("path").data(edges, d => d.target.id);
const linkEnter = link.enter()
.append('path')
.attr("id", d => `edge-${d.target.id}`)
.attr("stroke", "#ccc")
.attr("fill", "none")
.attr("d", d => {
const o = {
x: source.x0,
offsetX: source.offsetX,
y: source.y0,
offsetY: source.offsetY
};
return drawLink({source: o, target: o});
})
const linkUpdate = link.merge(linkEnter)
.transition(transition)
.attr("d", drawLink)
const linkExit = link.exit()
.remove()

// Stash the old positions for transition.
hierarchy.eachBefore(d => {
d.x0 = d.x;
d.y0 = d.y;
});
}

// Init zoom
svg.call(zoom)
// Disable double click zooming, since we're using that to expand nodes.
.on("dblclick.zoom", null);
// We can set our zoom to an initial state
svg.call(zoom.transform, d3.zoomIdentity.translate(10, 300))
// But this wont set the attr because the node isn't real yet
gRoot.attr("transform",`translate(10, 300) scale(1)`)

// Duplicate children for toggling on and off
hierarchy.descendants().forEach((d,i) => {
d._children = d.children;
d.offsetX = 0;
d.offsetY = 0;
// d.id = `${d.depth}_${d.data[0]}`
if (d.depth > 0) d.children = null
})

update(null, hierarchy)
return svg.node()
}
Insert cell
data = [
{id: "1", name: "Jane Bloggs", title: "CEO", reportsTo: null},
{id: "2", name: "Alice Smith", title: "Chief Marketing Officer", reportsTo: "1"},
{id: "3", name: "Ian Hunt", title: "Director of Marketing EMEA", reportsTo: "2"},
{id: "4", name: "Roy Castle", title: "Director of Marketing APAC", reportsTo: "2"},
{id: "5", name: "Jim Jones", title: "CFO", reportsTo: "1"},
{id: "6", name: "Tilly Cramer", title: "Financial Controller", reportsTo: "5"},
{id: "7", name: "Rex Bandana", title: "Financial Controller", reportsTo: "5"},
{id: "8", name: "Andy Johnson", title: "CIO", reportsTo: "1"},
{id: "9", name: "Emma Culliford", title: "Head of Security", reportsTo: "8"},
{id: "10", name: "Lisa Smith", title: "Head of Analytics", reportsTo: "8"}
]
Insert cell
hierarchy = {
const h = d3.stratify()
.id(d => d.id)
.parentId(d => d.reportsTo)
(data)

h.eachBefore(d => {
d.x0 = 0
d.y0 = 0
})
return h;
}
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