Public
Edited
Apr 9
1 fork
12 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
viewof timescale = {
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, width, height + margins.bottom])
.style("font", font);

const g = svg.append("g");

// Make this into a view, so that the currently hovered sequence is available to the breadcrumb
const element = svg.node();
let focus = root;
element.value = { selected: names[0], sequence: [] };
element.dispatchEvent(new CustomEvent("input"));

let hideSmallTicks = true;

const cellGroup = g.append("g").attr("id", "cells");

const cell = cellGroup
.selectAll("g")
.data(root.descendants())
.join("g")
.attr("transform", (d) => `translate(${d.x0},${d.y0})`);

const rect = cell
.append("rect")
.attr("width", (d) => d.x1 - d.x0)
.attr("height", (d) => d.y1 - d.y0)
.attr("fill", (d) => d.data.color)
.attr("stroke", "white")
.attr("stroke-width", 0.5)
.attr("cursor", "pointer")
.on("pointerenter", (event, d) => {
// Get the ancestors of the current segment
const sequence = d.ancestors().reverse();
// Highlight the ancestors
cell.attr("fill-opacity", (d) => {
return sequence.includes(d) ? 1.0 : 0.5;
});
// Update the value of this view with the currently hovered sequence
element.value = { ...element.value, sequence };
element.dispatchEvent(new CustomEvent("input"));
})
.on("click", clicked);

svg.on("pointerleave", () => {
cell.attr("fill-opacity", 1);
// Update the value of this view
element.value = { ...element.value, sequence: [] };
element.dispatchEvent(new CustomEvent("input"));
});

cell.append("title").text((d) => {
const sequence = d
.ancestors()
.map((d) => d.data.name)
.reverse();

return `${sequence.join(" > ")}`;
});

const text = cell
.append("text")
.style("user-select", "none")
.attr("pointer-events", "none")
.attr("x", (d) => {
const textX = (d.x1 - d.x0) / 2;
return Number.isNaN(textX) ? -1000 : textX; // not sure checking NaN is necessary
})
.attr("y", (d) => (d.y1 - d.y0) / 2)
.attr("fill", (d) => d.data.textColor ?? "black")
.attr("fill-opacity", labelVisible)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.text((d) => {
const rectWidth = d.x1 - d.x0;
const labelWidth = getTextWidth(d.data.name, font);
const abbrev = d.data.abr || d.data.name.charAt(0);

return rectWidth - 10 < labelWidth ? abbrev : d.data.name;
});

// Append ages ticks scale bar
const ticksGroup = g
.append("g")
.attr("id", "ticks")
.attr("transform", `translate(0,${height - margins.bottom})`); // Move tick group down

ticksGroup.call((g) => ticks(g, makeTicksData(root), hideSmallTicks));

svg.call(
d3
.zoom()
.extent([
[0, 0],
[width, height]
])
.scaleExtent([1, 8])
.on("zoom", zoomed)
.on("end", () => {
rect.attr("cursor", "pointer");
})
);

if (selectedInterval) {
const matchNode = root.find((d) => d.data.name === selectedInterval);
clicked(null, matchNode);
}

function clicked(event, p) {
focus = p === focus ? p.parent : p;
hideSmallTicks = [0, 1].includes(focus.depth);

// sync input to clicked node
if (event) {
set(viewof selectInterval[0], [focus.data.name]);
}

const focusAncestors = focus.ancestors().slice(1); // Ignore clicked node itself

element.value = { ...element.value, selected: focus.data.name };
element.dispatchEvent(new CustomEvent("input"));

const t = event
? d3.transition().duration(450).ease(d3.easeCubicInOut)
: null; // Can't transition when using input, bit of a hack

// Show a bit of the neighbouring cells on focus of an interval
const leftNeighbor =
focus.data.start === root.data.start ? 0 : neighborWidth;
const rightNeighbor = focus.data.end === root.data.end ? 0 : neighborWidth;

const focusWidth = focus.x1 - focus.x0; // partition width of focused node

root.each((d) => {
const widthMinusNeighbors = width - rightNeighbor - leftNeighbor;

const target = {
x0:
leftNeighbor + ((d.x0 - focus.x0) / focusWidth) * widthMinusNeighbors,
x1:
leftNeighbor + ((d.x1 - focus.x0) / focusWidth) * widthMinusNeighbors,
y0: d.y0,
y1: d.y1
};

d.target = target;
});

// Reset drag
g.transition(t).attr("transform", "translate(0,0)");

cell
.transition(t)
.attr("transform", (d) => `translate(${d.target.x0},${d.target.y0})`);

rect
.transition(t)
.attr("width", (d) => d.target.x1 - d.target.x0)
.attr("stroke", "white")
.attr("stroke-width", 1);

if (event) {
d3.select(this)
.transition(t)
.attr("stroke", "black")
.attr("stroke-width", 1.5);

d3.select(this.parentNode).raise();
}

text
.transition(t)
.attr("fill-opacity", (d) =>
focusAncestors.includes(d) ? 1 : labelVisible(d.target)
)
.attr("x", (d) => {
// Position all the ancestors labels in the middle
if (focusAncestors.includes(d)) {
return -d.target.x0 + width / 2;
}
const rectWidth = d.target.x1 - d.target.x0;
const textX = rectWidth / 2;

return Number.isNaN(textX) ? width / 2 : textX;
})
.text((d) => {
const rectWidth = d.target.x1 - d.target.x0;
const labelWidth = getTextWidth(d.data.name, font);
const abbrev = d.data.abr || d.data.name.charAt(0);

return rectWidth - 8 < labelWidth ? abbrev : d.data.name;
});

ticksGroup.call((g) => ticks(g, makeTicksData(root), hideSmallTicks, t));
}

function zoomed(e) {
if (!root.target) return;

const translateX = e.transform.x;

// Do not allow scrolling beyond left- and rightmost node
// These conditions are not completely correct🚨
if (
translateX + root.target.x0 > 0 ||
root.x1 - translateX > root.target.x1
)
return;

rect.attr("cursor", "grabbing");
g.attr("transform", `translate(${translateX},0)`);
}

return element;
}
Insert cell
Insert cell
selectedInterval = selectInterval.length === 1 ? selectInterval[0] : null
Insert cell
Insert cell
makeTicksData = (root) => {
const uniqueStartAges = new Set(
root.descendants().map((node) => node.data.start)
);

const ticksData = Array.from(uniqueStartAges)
.map((start) =>
root.descendants().find((node) => node.data.start === start)
)
.map((d) => ({
x: d.x0,
depth: d.depth,
targetX: d?.target?.x0 || 0,
text: d.data.start
}));

const now = {
x: root.x1,
depth: 0,
targetX: root?.target?.x1 || width,
text: 0
};

ticksData.push(now);

return ticksData;
}
Insert cell
labelVisible = (d) => +(d.x1 - d.x0 > 14)
Insert cell
// Via https://stackoverflow.com/questions/1636842/svg-get-text-element-width
function getTextWidth(text, font) {
// re-use canvas object for better performance
var canvas =
getTextWidth.canvas ||
(getTextWidth.canvas = document.createElement("canvas"));
var context = canvas.getContext("2d");
context.font = font;
var metrics = context.measureText(text);
return metrics.width;
}
Insert cell
Insert cell
// Create a new d3 partition layout
partition = (data) =>
d3
.partition()
.size([width, height - margins.bottom])
.padding(0)(data)
Insert cell
// https://observablehq.com/@observablehq/synchronized-inputs
function set(input, value) {
console.log(input, value);
input.value = value;
input.dispatchEvent(new Event("input"));
// input.dispatchEvent(new CustomEvent("input"));
}
Insert cell
Insert cell
intervals = FileAttachment("intervals@3.json").json()
Insert cell
names = intervals.map((d) => d.name)
Insert cell
hierarchicalData = d3
.stratify()(intervals)
.sum((d) => (d.leaf ? d.start - d.end : 0))
.sort((a, b) => b.start - a.start)
Insert cell
root = partition(hierarchicalData)
Insert cell
Insert cell
hiddenLevels = [4, 5]
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
import { freelanceBanner } from "@julesblm/freelance-banner"
Insert cell
Insert cell
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