Public
Edited
Nov 30, 2023
Fork of SpaceTree
1 star
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
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
})
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] : `🐧`) }
)
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
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);

const margin = { top: 10, right: 120, bottom: 10, left: 40 };
const dx = thumbSize + 2;
const dy = width / (root.height + padding);
const layout = tree().nodeSize([dx, dy]);
layout(root);

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

// 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, dx])
.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) => nodeIds.includes(d.id) )
.transition()
.duration(transitionDuration / 5)
.attr("opacity", 1);
}

// ******* Internal Click Event ***************
async function _onClick(event, d, triggerEvent = true) {
// 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();

// 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 = 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, left.x - dx, width, 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) {
// console.log("set", v);
value = v;
_onClick(null, value, false);
}
});

return svg.node();
}
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 () => {
console.log("Animate", animate, stopAnimation, newI, currentChildrenI);
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(() => {
console.log("clear Interval");
clearInterval(interval);
});
}
Insert cell
flare = FileAttachment("string-to-json-online(1).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

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