unipept-visualizations
Version:
The Unipept visualisation library
513 lines (438 loc) • 20.6 kB
text/typescript
import * as d3 from "d3";
import SunburstSettings from "./SunburstSettings";
import SunburstPreprocessor from "./SunburstPreprocessor";
import DataNode, { DataNodeLike } from "./../../DataNode";
import TooltipUtilities from "./../../utilities/TooltipUtilities";
import StringUtils from "./../../utilities/StringUtils";
import NodeUtils from "./../../utilities/NodeUtils";
import ColorUtils from "./../../color/ColorUtils";
import "core-js/stable";
import "regenerator-runtime/runtime";
type HRN<T> = d3.HierarchyRectangularNode<T>;
export default class Sunburst {
private readonly settings: SunburstSettings;
private readonly data: HRN<DataNode>[];
private tooltip!: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>;
private breadCrumbs: d3.Selection<HTMLUListElement, unknown, HTMLElement, any>;
private colorCounter: number = -1;
private currentMaxLevel: number = 4;
private xScale: d3.ScaleLinear<number, number>;
private yScale: d3.ScaleLinear<number, number>;
private path!: d3.Selection<SVGPathElement, HRN<DataNode>, SVGGElement, unknown>;
private text!: d3.Selection<any, HRN<DataNode>, SVGGElement, unknown>;
private arc!: d3.Arc<any, HRN<DataNode>>;
private visGElement: d3.Selection<SVGGElement, unknown, HTMLElement, unknown>;
private arcData: HRN<DataNode>[] = [];
private textData: HRN<DataNode>[] = [];
private previousRoot: HRN<DataNode> | null = null;
private previousMaxLevel: number = this.currentMaxLevel;
constructor(
private readonly element: HTMLElement,
data: DataNodeLike,
options: SunburstSettings = new SunburstSettings()
) {
this.settings = this.fillOptions(options);
const preprocessor = new SunburstPreprocessor();
const processedData = preprocessor.preprocessData(data);
if (this.settings.enableTooltips) {
this.tooltip = TooltipUtilities.initTooltip();
}
this.currentMaxLevel = this.settings.levels;
this.xScale = d3.scaleLinear().range([0, 2 * Math.PI]); // use full circle
this.yScale = d3.scaleLinear().domain([0, 1]).range([0, this.settings.radius]);
const rootNode = d3.hierarchy<DataNode>(processedData);
// We don't want D3 to compute the sum itself. That's why we need to return 0 if the current node has no
// children.
rootNode.sum((d: DataNode) => d.children.length > 0 ? 0 : d.selfCount);
const partition = d3.partition<DataNode>();
this.data = partition(rootNode).descendants();
this.arc = d3.arc<HRN<DataNode>>()
.startAngle((d: HRN<DataNode>) => Math.max(0, Math.min(Math.PI * 2, this.xScale(d.x0))))
.endAngle((d: HRN<DataNode>) => Math.max(0, Math.min(Math.PI * 2, this.xScale(d.x1))))
.innerRadius((d: HRN<DataNode>) => Math.max(0, d.y0 ? this.yScale(d.y0) : d.y0))
.outerRadius((d: HRN<DataNode>) => Math.max(0, this.yScale(d.y1) + 1));
this.initCss();
// Prepare element and create SVG container
this.element.innerHTML = "";
// @ts-ignore
this.breadCrumbs = d3.select(this.element)
.append("div")
.attr("id", Math.floor(Math.random() * 2**16) + "-breadcrumbs")
.attr("class", "sunburst-breadcrumbs")
.append("ul");
const visElement = d3.select(this.element)
.append("svg")
.attr("version", "1.1")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("viewBox", `0 0 ${this.settings.width} ${this.settings.height}`)
.attr("width", this.settings.width)
.attr("height", this.settings.height)
.attr("overflow", "hidden")
.style("font-family", "'Helvetica Neue', Helvetica, Arial, sans-serif");
visElement.append("style")
.attr("type", "text/css")
.html(".hidden{ visibility: hidden;}");
// @ts-ignore
this.visGElement = visElement.append("g")
// set origin to radius center
.attr("transform", "translate(" + this.settings.radius + "," + this.settings.radius + ")");
// Fake click on the center node
this.reset();
}
/**
* Reset the current view of the visualization. The visualization will completely be reset to it's initial state.
*/
public reset() {
this.click(this.data[0]);
}
/**
* Change the root of the visualization to the node with a given ID. Note that the reroot will only be executed if
* a node with the given ID exists. If no node was found, nothing happens.
*
* @param nodeId ID of the node that should now become the new root of the tree.
* @param triggerCallback Should the `rerootCallback` be triggered for this node?
*/
public reroot(nodeId: number, triggerCallback: boolean = true) {
const newRoot = this.data.find((obj: HRN<DataNode>) => obj.data.id === nodeId);
if (newRoot) {
this.click(newRoot, triggerCallback);
}
}
private fillOptions(options: any = undefined): SunburstSettings {
const output = new SunburstSettings();
return Object.assign(output, options);
}
private maxY(d: HRN<DataNode>): number {
return d.children ? Math.max(...d.children!.map((i) => this.maxY(i))) : d.y1;
}
/**
* Calculates the color of an arc based on the color of his children.
*
* @param d The node for which we want the color.
* @return string The calculated color in HTML color representation.
*/
private color(d: DataNode) {
if (d.name === "empty") {
return "white";
}
if (this.settings.useFixedColors) {
return this.settings.fixedColorPalette[
Math.abs(this.settings.fixedColorHash(d)) % this.settings.fixedColorPalette.length
];
} else {
if (d.children.length > 0) {
const colours: string[] = d.children.map(c => this.color(c));
const a = d3.hsl(colours[0]);
const b = d3.hsl(colours[1]);
const singleChild = d.children.length === 1 || d.children[1].name === "empty";
// if we only have one child, return a slightly darker variant of the child color
if (singleChild) {
return d3.hsl(a.h, a.s, a.l * 0.98);
}
// if we have 2 children or more, take the average of the first two children
return d3.hsl((a.h + b.h) / 2, (a.s + b.s) / 2, (a.l + b.l) / 2);
}
// if we don't have children, pick a new color
if (!d.extra.color) {
d.extra.color = this.getColor();
}
return d.extra.color;
}
}
/**
* Color generation function that iterates over a fixed list of colors.
*
* @return string HTML-representation of the generated color
*/
private getColor(): string {
this.colorCounter = (this.colorCounter + 1) % this.settings.colorPalette.length;
return this.settings.colorPalette[this.colorCounter];
}
private initCss() {
let elementClass = this.settings.className;
this.element.className += " " + elementClass;
const styleElement = this.element.ownerDocument.createElement("style");
styleElement.appendChild(this.element.ownerDocument.createTextNode(`
.${elementClass} {
font-family: Roboto,'Helvetica Neue',Helvetica,Arial,sans-serif;
width: ${this.settings.width + this.settings.breadcrumbWidth}px;
}
.${elementClass} .sunburst-breadcrumbs {
width: 176px;
float: right;
margin-right: 15px;
margin-top: 10px;
padding-left: 5px;
}
.${elementClass} .sunburst-breadcrumbs ul {
padding-left: 0;
list-style: none;
}
.${elementClass} .sunburst-breadcrumbs .crumb {
margin-bottom: 5px;
cursor: pointer;
}
.${elementClass} .sunburst-breadcrumbs .crumb svg {
float: left;
margin-right: 3px;
}
.${elementClass} .sunburst-breadcrumbs .crumb p {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
margin: 0;
font-size: 14px;
}
.${elementClass} .sunburst-breadcrumbs .crumb .percentage {
font-size: 11px;
}`))
this.element.ownerDocument.head.appendChild(styleElement);
}
/**
* Interpolate the scales! Defines new scales based on the clicked item.
*
* @param d The clicked item
* @return new scales
*/
private arcTween(d: HRN<DataNode>, that: any): any {
let my = Math.min(this.maxY(d), d.y0 + that.settings.levels * (d.y1 - d.y0)),
xd = d3.interpolate(that.xScale.domain(), [d.x0, d.x1]),
yd = d3.interpolate(that.yScale.domain(), [d.y0, my]),
yr = d3.interpolate(that.yScale.range(), [d.y0 ? 20 : 0, that.settings.radius]);
return (d: HRN<DataNode>) => {
// Return a function that takes in a timing (between 0 and 1) and returns the current arc that corresponds
// with this timing.
return (t: number) => {
that.xScale.domain(xd(t));
that.yScale.domain(yd(t)).range(yr(t));
return that.arc(d);
}
}
}
private tooltipIn(event: MouseEvent, d: HRN<DataNode>) {
if (this.settings.enableTooltips && this.tooltip) {
if (d.depth < this.currentMaxLevel && d.data.name !== "empty") {
this.tooltip.html(this.settings.getTooltip(d.data))
.style("top", (event.pageY + 10) + "px")
.style("left", (event.pageX + 10) + "px")
.style("visibility", "visible");
}
}
}
private tooltipMove(event: MouseEvent, d: HRN<DataNode>) {
if (this.settings.enableTooltips && this.tooltip) {
this.tooltip
.style("top", (event.pageY + 10) + "px")
.style("left", (event.pageX + 10) + "px");
}
}
private tooltipOut(event: MouseEvent, d: HRN<DataNode>) {
if (this.settings.enableTooltips && this.tooltip) {
this.tooltip.style("visibility", "hidden");
}
}
/**
* Compute the amount of vertical space that's available for text (i.e. the maximum text height) for a specific node
* in the sunburst visualization.
*
* @param d The node in the sunburst visualization for which the vertical space should be computed.
* @return The available vertical space in pixels.
*/
private computeAvailableSpace(d: HRN<DataNode>): number {
const circumference = 2 * Math.max(0, this.yScale(d.y1) + 1) * Math.PI;
// Difference in radians between the start and end of the angle.
const difference = Math.max(
0,
Math.min(Math.PI * 2, this.xScale(d.x1)) -
Math.max(0, Math.min(Math.PI * 2, this.xScale(d.x0)))
);
// Since an angle of 360 degrees corresponds to 2 * Pi radians, we can convert this angle difference to
// pixels if we compute (difference / (2 * Pi)) * circumference_in_pixels
return circumference * (difference / (2 * Math.PI));
}
/**
* Defines what happens after a node is clicked.
*
* @param d The data object of the clicked arc
* @param triggerCallback Should the rerootCallback function be triggered for this click?
*/
private click(d: HRN<DataNode>, triggerCallback: boolean = true) {
if (d.data.name === "empty" || (this.previousRoot && this.previousRoot.data.id === d.data.id)) {
return;
}
this.previousRoot = d;
if (this.settings.enableBreadcrumbs) {
this.setBreadcrumbs(d);
}
if (this.settings.rerootCallback && triggerCallback) {
this.settings.rerootCallback(d.data);
}
// perform animation
this.currentMaxLevel = d.depth + this.settings.levels;
this.renderArcs(d);
this.renderText(d);
}
private async renderArcs(parentNode: HRN<DataNode>) {
// The previously rendered nodes should be kept until the animation is over. We should also compute which items
// need to be added to the selection.
const filteredData = this.data.filter((e: HRN<DataNode>) => {
return NodeUtils.isParentOf(parentNode, e, this.currentMaxLevel + 2);
});
if (parentNode.parent) {
filteredData.push(parentNode.parent);
}
const newData = filteredData.filter((x: HRN<DataNode>) => !this.arcData.includes(x));
const data = this.arcData.concat(...newData);
this.visGElement.selectAll("path").data([]).exit().remove();
this.path = this.visGElement.selectAll("path")
.data(data)
.enter()
.insert("path")
.attr("class", "arc")
.attr("id", (d: HRN<DataNode>, i: number) => "path-" + i) // id based on index
.attr("d", this.arc) // path data
.attr("fill-rule", "evenodd") // fill rule
.style("fill", (d: HRN<DataNode>) => this.color(d.data)) // call function for colour
.attr("fill-opacity", d => d.depth >= this.previousMaxLevel ? 0.2 : 1)
.on("click", (event: MouseEvent, d: HRN<DataNode>) => {
if (d.depth < this.currentMaxLevel) {
this.click(d);
}
})
.on("mouseover", (event: MouseEvent, d: HRN<DataNode>) => this.tooltipIn(event, d))
.on("mousemove", (event: MouseEvent, d: HRN<DataNode>) => this.tooltipMove(event, d as HRN<DataNode>))
.on("mouseout", (event: MouseEvent, d: HRN<DataNode>) => this.tooltipOut(event, d as HRN<DataNode>));
// Wait for the animations to be completed...
await new Promise<void>((resolve) => {
this.path.transition()
.duration(this.settings.animationDuration)
.attrTween("d", this.arcTween(parentNode, this))
.attr("class", (d: HRN<DataNode>) => d.depth >= this.currentMaxLevel ? "arc toHide" : "arc")
.attr("fill-opacity", d => d.depth >= this.currentMaxLevel ? 0.2 : 1)
.on("end", () => {
resolve();
});
});
this.previousMaxLevel = this.currentMaxLevel;
this.arcData = filteredData;
}
private async renderText(parentNode: HRN<DataNode>) {
const filteredData = this.data.filter((e: HRN<DataNode>) => {
return NodeUtils.isParentOf(parentNode, e, this.currentMaxLevel);
});
const newData = filteredData.filter((x: HRN<DataNode>) => !this.textData.includes(x));
const data = this.textData.concat(...newData);
if (parentNode.parent) {
data.splice(data.indexOf(parentNode.parent), 1);
}
// hack for the getComputedTextLength
const that = this;
const offscreenCanvasSupported = typeof OffscreenCanvas !== "undefined";
// eslint-disable-next-line no-undef
let ctx: OffscreenCanvasRenderingContext2D;
if (offscreenCanvasSupported) {
const offscreenCanvas = new OffscreenCanvas(1, 1);
ctx = offscreenCanvas.getContext("2d")!;
ctx.font = ctx!.font = "16px 'Helvetica Neue', Helvetica, Arial, sans-serif"
}
// Remove old text nodes
this.visGElement.selectAll("text").data([]).exit().remove();
// Add new text nodes
this.text = this.visGElement.selectAll("text").data(data).enter().append("text")
.style("fill", (d: HRN<DataNode>) => ColorUtils.getReadableColorFor(this.color(d.data)))
.style("fill-opacity", 0)
.style("font-family", "font-family: Helvetica, 'Super Sans', sans-serif")
.style("pointer-events", "none") // don't invoke mouse events
.attr("dy", ".2em")
.text((d: HRN<DataNode>) => this.settings.getLabel(d.data))
.style("font-size", function(this: SVGTextContentElement, d: HRN<DataNode>) {
const txtLength = offscreenCanvasSupported ? ctx.measureText(this.textContent!).width : this.getComputedTextLength();
return Math.floor(Math.min(((that.settings.radius / that.settings.levels) / txtLength * 10) + 1, 12)) + "px";
});
// Somewhat of a hack as we rely on arcTween updating the scales.
await new Promise<void>((resolve) => {
this.text
.transition().duration(this.settings.animationDuration)
.attrTween("text-anchor", (d: HRN<DataNode>) => {
return (t: number) => this.xScale(d.x0 + (d.x1 - d.x0) / 2) > Math.PI ? "end" : "start";
})
.attrTween("dx", (d: HRN<DataNode>) => {
return (t: number) => this.xScale(d.x0 + (d.x1 - d.x0) / 2) > Math.PI ? "-4px" : "4px";
})
.attrTween("transform", (d: HRN<DataNode>) => {
return (t: number) => {
let angle = this.xScale(d.x0 + (d.x1 - d.x0) / 2) * 180 / Math.PI - 90;
return `rotate(${angle})translate(${this.yScale(d.y0)})rotate(${angle > 90 ? -180 : 0})`;
}
})
.styleTween("fill-opacity", function(this: SVGTextContentElement, e: HRN<DataNode>) {
const selectedFontSize = Number.parseInt(d3.select(this).style("font-size").replace("px", ""))
return (t: number) => {
const availableSpace = that.computeAvailableSpace(e);
if (availableSpace > selectedFontSize) {
return t.toString();
} else {
return "0";
}
}
})
.on("end", function(this: SVGTextContentElement, e: HRN<DataNode>) {
const availableSpace = that.computeAvailableSpace(e);
const node = d3.select(this);
node.style(
"visibility",
availableSpace > Number.parseInt(node.style("font-size").replace("px", "")) && NodeUtils.isParentOf(parentNode, e, that.currentMaxLevel) ? "visible" : "hidden"
);
resolve();
});
});
this.textData = filteredData;
}
private setBreadcrumbs(d: HRN<DataNode>) {
// First find out which nodes we encounter on the path from the root node to the clicked node.
let crumbs: HRN<DataNode>[] = [];
let temp: (HRN<DataNode> | null) = d;
while (temp) {
crumbs.push(temp);
temp = temp.parent;
}
crumbs.reverse().shift();
// Small arc that's drawn for each of the breadcrumbs
const breadArc: any = d3.arc()
.innerRadius(0)
.outerRadius(15)
.startAngle(0)
.endAngle((d: any) => {
return 2 * Math.PI * d.data.count / d.parent!.data.count
});
this.breadCrumbs.selectAll(".crumb")
.data(crumbs)
.enter()
.append("li")
.on("click", (event: MouseEvent, d: HRN<DataNode>) => {
this.click(d.parent!);
})
.attr("class", "crumb")
.style("opacity", "0")
.attr("title", (d: HRN<DataNode>) => this.settings.getTitleText(d.data))
.html((d: HRN<DataNode>) => `
<p class='name'>${d.data.name}</p>
<p class='percentage'>${Math.round(100 * d.data.count / d.parent!.data.count)}% of ${d.parent?.data.name}</p>`)
.insert("svg", ":first-child").attr("width", 30)
.attr("height", 30)
.append("path")
.attr("d", breadArc)
.attr("transform", "translate(15, 15)")
.attr("fill", (d: HRN<DataNode>) => this.color(d.data));
this.breadCrumbs.selectAll(".crumb")
.transition()
.duration(this.settings.animationDuration)
.style("opacity", "1");
this.breadCrumbs.selectAll(".crumb")
.data(crumbs)
.exit().transition()
.duration(this.settings.animationDuration)
.style("opacity", "0")
.remove();
}
}