Public
Edited
Nov 7
Paused
6 forks
Importers
22 stars
Insert cell
Insert cell
Insert cell
Insert cell
viewof selection = SpaceTree(flare, {
label: (d) => d.data.name,
width,
onClick: (d) => {
mutable stopAnimation = true;
}
})
Insert cell
selection
Insert cell
Insert cell
viewof knowledgeChiSelected = SpaceTree(knowledgeChi, {
label: d => d.data.content,
width,
thumbSize: 50
})
Insert cell
Insert cell
viewof orgSelected = SpaceTree(orgchart, {
label: d => d.data.content,
width,
thumbSize: 30
})
Insert cell
viewof animalSelected = SpaceTree(animalTaxonomy, {
label: d => d.data.content,
width,
thumbSize: 30,
height: 600
})
Insert cell
Insert cell
// Using it with d3.group
SpaceTree(
d3.group(
penguins,
(d) => d.species,
(d) => d.island
),
{ label: (d, i) => (Array.isArray(d.data) ? d.data[0] : `🐧`), height: 400 }
)

Insert cell
howto("SpaceTree")
Insert cell
function SpaceTree(
data,
{
// data is either tabular (array of objects) or hierarchy (nested objects)
path, // as an alternative to id and parentId, returns an array identifier, imputing internal nodes
id = Array.isArray(data) ? (d) => d.id : null, // if tabular data, given a d in data, returns a unique identifier (string)
parentId = Array.isArray(data) ? (d) => d.parentId : null, // if tabular data, given a node d, returns its parent’s identifier
children, // if hierarchical data, given a d in data, returns its children
tree = d3.tree, // layout algorithm (typically d3.tree or d3.cluster)
sort, // how to sort nodes prior to layout (e.g., (a, b) => d3.descending(a.height, b.height))
label, // given a node d, returns the display name
title, // given a node d, returns its hover text
link, // given a node d, its link (if any)
linkTarget = "_blank", // the target attribute for links (if any)
width = 640, // outer width, in pixels
height, // if provided will fit the chart to this height
margin = { top: 10, right: 150, bottom: 10, left: 20 },
r = 3, // radius of nodes
padding = 1, // horizontal padding for first and last column
fill = "#999", // fill for nodes
fillOpacity, // fill opacity for nodes
stroke = "#555", // stroke for links
strokeWidth = 1.5, // stroke width for links
strokeOpacity = 0.4, // stroke opacity for links
strokeLinejoin, // stroke line join for links
strokeLinecap, // stroke line cap for links
halo = "#fff", // color of label halo
haloWidth = 3, // padding around the labels
curve = d3.curveBumpX, // curve for the link
thumbSize = 25,
transitionDuration = 1000,
highlight = "#ca2c92",
value = null, // currently selected value
onClick = null // Function to call when node is clicked e.g. (d) => console.log("clicked ", d.data.name)
} = {}
) {
// If id and parentId options are specified, or the path option, use d3.stratify
// to convert tabular data to a hierarchy; otherwise we assume that the data is
// specified as an object {children} with nested objects (a.k.a. the “flare.json”
// format), and use d3.hierarchy.
const root =
path != null
? d3.stratify().path(path)(data)
: id != null || parentId != null
? d3.stratify().id(id).parentId(parentId)(data)
: d3.hierarchy(data, children);

value = value || root;

// Sort the nodes.
if (sort != null) root.sort(sort);

// Compute labels and titles.
const descendants = root.descendants();

const diagonal = d3
.link(curve)
.x((d) => d.y)
.y((d) => d.x);

// Initially collapse everything beyond depth 1
descendants.forEach((d, i) => {
d.id = i;
d._children = d.children;
if (d.depth) d.children = null;
});

let dx, //vertical
dy; //horizontal

let heightFit = false;

const layout = tree();
// horizontal
dy = (width - margin.left - margin.right) / (root.height + padding);

if (!height) {
heightFit = false;
height = thumbSize * root.leaves().length + margin.top + margin.bottom;
// vertical
dx = thumbSize;
layout.nodeSize([dx, dy]);
} else {
heightFit = true;
// vertical
dx = (height - margin.top - margin.bottom) / root.leaves().length;
// height = thumbSize * root.leaves().length + 1;
layout.nodeSize([dx, dy]);
// layout.size([
// height - margin.top - margin.bottom,
// width - margin.left - margin.right
// ]);
}

layout(root);

let maxX, minX;
if (height) {
// Center the tree if we have fixed height
// Tree boundaries
minX = Infinity;
maxX = -minX; // y coordinates go down on svg, so max is negative (up)
root.each((d) => {
if (d.x > maxX) maxX = d.x;
if (d.x < minX) minX = d.x;
});
}

// Default positions to come back to when collapsing
root.x0 = dy / 2;
root.y0 = 0;

// Use the required curve
if (typeof curve !== "function") throw new Error(`Unsupported curve`);

const svg = d3
.create("svg")
.attr("viewBox", [
-margin.left,
-margin.top,
width - margin.left - margin.right,
height - margin.top - margin.bottom
])
.style("font", "10px sans-serif")
.style("user-select", "none");

svg.append("defs").html(`
<style>
.highlight circle { fill:${highlight} }
.highlight circle { fill:${highlight} }
.highlight text { fill:${highlight} }
.leaf circle { fill:${highlight} }
.leaf circle { fill:${highlight} }
.leaf text { fill:${highlight} }
path.highlight { stroke:${highlight} }
<style>`);

const gLink = svg
.append("g")
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-width", strokeWidth);

const gNode = svg
.append("g")
.attr("cursor", "pointer")
.attr("pointer-events", "all");

function removeThumb(source) {
return svg
.selectAll("image.thumb")
.filter((d) => d === source)
.transition()
.duration(transitionDuration / 5)
.attr("opacity", 0);
}

function addThumb(nodeIds) {
// console.log("add Thumbs", nodeIds);
return svg
.selectAll("image.thumb")
.filter(
(d) => typeof nodeIds?.includes === "function" && nodeIds.includes(d.id)
)
.transition()
.duration(transitionDuration / 5)
.attr("opacity", 1);
}

// ******* Internal Click Event ***************
async function _onClick(event, d, triggerEvent = true) {
// ***** EVENT Triggering
value = d;
// Trigger an input event change with the current subtree
if (triggerEvent)
svg.node().dispatchEvent(new Event("input", { bubbles: true }));

// Call the onClick user function
if (triggerEvent && onClick && typeof onClick === "function") onClick(d);

try {
// Start the animation

// Then trim
const trimmingNodes = trim(d);
// Wait for trimming to finish
await update(d, trimmingNodes).end();

// Add the thumb for the trimming nodes;
await addThumb(trimmingNodes).end();

// Remove the thumb of the source
await removeThumb(d).end();

// Then expand
expand(d);
await update(d, trimmingNodes).end();

// Finally add the thumb for the collapsed node
addThumb([d.id]);
} catch (e) {
console.log("Error running animation", e);
}
}

function update(source, trimmingNodes = []) {
value = source;
const duration =
d3.event && d3.event.altKey
? transitionDuration * 10
: (transitionDuration * 2) / 5;
const nodes = root.descendants().reverse();
const links = root.links();

if (heightFit) {
dx = (height - margin.top - margin.bottom) / root.leaves().length;
layout.nodeSize([dx, dy]);
}

// Compute the new tree layout.
layout(root);

let left = root;
let right = root;
root.eachBefore((node) => {
if (node.x < left.x) left = node;
if (node.x > right.x) right = node;
});

let _height = heightFit ? height : right.x - left.x + dx * 2;
// let _height = height || right.x - left.x + dx * 2;

let transition = svg
.transition()
.duration(duration)
// .attr("viewBox", [-margin.left, left.x - margin.top, width, _height])
.attr("viewBox", [
(-dy * padding) / 2,
heightFit ? -_height / 2 : left.x - dx,
width - margin.left - margin.right,
_height
])
.attr("width", width)
.attr("height", _height)
// .attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.tween(
"resize",
window.ResizeObserver ? null : () => () => svg.dispatch("toggle")
);

const context = {
gNode,
transition,
nodes,
source,
update,
r,
stroke,
fill,
label,
halo,
haloWidth,
links,
diagonal,
gLink,
thumbSize,
curve,
root,
_onClick,
trimmingNodes
};

const node = updateNodes(context);
const link = updateLinks(context);
updatePath({ node, link, root, source });

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

return transition;
}

update(root);
// Set initial value
svg.node().value = root;

// Update the display whenever the value changes
Object.defineProperty(svg.node(), "value", {
get() {
return value;
},
set(v) {
_onClick(null, v, false);
}
});

return svg.node();
}
Insert cell
// For import {chart} with {data} from "@john-guerra/spacetree"
chart = SpaceTree(data, { label: (d) => d.data.name })
Insert cell
data = flare
Insert cell
knowledgeChi = FileAttachment("knowledgeChiBrowser.json").json()
Insert cell
function updateNodes({
gNode,
transition,
nodes,
source,
target,
update,
r,
stroke,
fill,
label,
halo,
haloWidth,
thumbSize,
curve,
_onClick,
trimmingNodes
}) {
// Update the nodes…
const node = gNode
.selectAll("g.nodes")
.data(nodes, (d) => d.id)
.join(nodeEnterFn, nodeUpdateFn, nodeExitFn);

function getThumbHrefIfCollapsed(d) {
return d._children && d.children === null
? TreeThumb({
treeData: d,
width: thumbSize,
height: thumbSize,
curve,
stroke
})
: "";
}

function nodeEnterFn(node) {
// Enter any new nodes at the parent's previous position.
const nodeEnter = node
.append("g")
.attr("class", "nodes")
.attr("transform", (d) => `translate(${source.y0},${source.x0})`)
.attr("fill-opacity", 0)
.attr("stroke-opacity", 0)
.on("click", _onClick);

nodeEnter
.append("circle")
.attr("r", r)
.attr("fill", (d) => (d._children ? stroke : fill))
.attr("stroke-width", 1);

//thumbnails
nodeEnter
.filter((d) => d._children)
.append("image")
.attr("class", "thumb")
.style("transform", (d, i, all) => {
const rpx = (typeof r === "function" ? r(d, i, all) : r) + 2;
return `translate(${rpx}px, ${-thumbSize / 2 + rpx / 2}px)`;
})
// .style("text-align", "center")
.attr("href", getThumbHrefIfCollapsed)
.attr("width", thumbSize)
.attr("height", thumbSize)
.attr("opacity", 1);

if (label) {
nodeEnter
.append("text")
.attr("dy", "0.31em")
.attr("x", (d) => (d._children ? -6 : 6))
.attr("text-anchor", (d) => (d._children ? "end" : "start"))

.text(label);
}

// Equivalent to merge
nodeUpdateFn(nodeEnter);

return nodeEnter;
}

function nodeUpdateFn(node) {
const nodeUpdate = node
.attr("paint-order", "stroke")
.attr("stroke", halo)
.attr("stroke-width", haloWidth);

nodeUpdate
.transition(transition) // Transition nodes to their new position.
.attr("transform", (d) => `translate(${d.y},${d.x})`)
.attr("fill-opacity", 1)
.attr("stroke-opacity", 1);

// debugger;

const nodeImage = node
.select("image.thumb")
.attr("href", getThumbHrefIfCollapsed);

// Hide the thumb for the trimmingNodes
nodeImage
.filter((d) => {
return d.id === source.id || trimmingNodes.includes(d.id);
})
.attr("opacity", 0);

// Show the thumb for everyone else
nodeImage
.filter((d) => {
return d.id !== source.id && !trimmingNodes.includes(d.id);
})
.transition(transition)
.attr("opacity", (d) => (d._children && d.children === null ? 1 : 0));

return nodeUpdate;
}

function nodeExitFn(node) {
// Transition exiting nodes to the parent's new position.
const nodeExit = node
.transition(transition)
.attr("transform", (d) => `translate(${d.parent.y},${d.parent.x})`)
.attr("opacity", 0)
.remove();

return nodeExit;
}

return node;
}
Insert cell
Insert cell
function updatePath({node, link, root, source}) {
// https://observablehq.com/@fil/d3-tidy-tree-mouseover
//Highlight Path
let d = source;
const path = [];

do {
path.push(d.data);
} while ((d = d.parent));

node.classed("highlight", (d) => path.indexOf(d.data) > -1);
node.classed("leaf", (d) => path.indexOf(d.data) === 0);
link.classed("highlight", (d) => path.indexOf(d.target.data) > -1);
}
Insert cell
// Tree modification functions
function trim(d) {
const trimmingNodes = []
if (!d.parent) return;
for (let sibling of d.parent.children) {
if (sibling.id != d.id && sibling.children) {
sibling.children = null;
trimmingNodes.push(sibling.id);
}
}
return trimmingNodes;
}
Insert cell
function expand(d) {
d.children = d.children ? null : d._children;
}
Insert cell
// ChatGPT helped me write this one
function TreeThumb({ treeData, width, height, curve, stroke }) {
// Create a canvas element
const canvas = document.createElement("canvas");
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
const context = canvas.getContext("2d");
context.scale(dpr, dpr);

// Create a d3 hierarchy layout
const root = d3.hierarchy(treeData, (d) => d._children);

const treeLayout = d3.tree().size([width, height]);

// Assign positions to nodes using the layout
treeLayout(root);

context.save();
// Draw links between nodes
context.beginPath();
context.lineWidth = 0.1;
context.strokeStyle = stroke;
const curveContext = curve(context);
context.beginPath();
for (const link of root.links()) {
curveContext.lineStart();
curveContext.point(link.source.y, link.source.x);
curveContext.point(link.target.y, link.target.x);
curveContext.lineEnd();
}
context.stroke();

context.restore();

// Return the canvas element as an image
// const image = new Image();
return canvas.toDataURL();
// return image;
}
Insert cell
mutable stopAnimation = false
Insert cell
{
// Animation demo code
const root = viewof selection.value;
let currentChildrenI = 0,
newI = 0;

const interval = setInterval(async () => {
if (mutable stopAnimation || !animate) {
return;
}
while (animate && newI === currentChildrenI) {
newI = Math.floor(Math.random() * root.children.length);
}

currentChildrenI = newI;
viewof selection.value = root.children[currentChildrenI];
viewof selection.dispatchEvent(new Event("input"));
}, 2500);

invalidation.then(() => {
clearInterval(interval);
});
}
Insert cell
flare = FileAttachment("flare.json").json()
Insert cell
animalTaxonomy = FileAttachment("animal-taxonomy.json").json()
Insert cell
orgchart = FileAttachment("orgchart.json").json()
Insert cell
import {howto} from "@d3/example-components"
Insert cell
import {viewof groupBy, tree } from "@john-guerra/budget-timelines"
Insert cell
viewof groupBy
Insert cell
SpaceTree(tree, {
label: (d) => (Array.isArray(d.data) ? d.data[0] : d.data["Account Name"]),
height: 600,
width,
// thumbSize: 30
})
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