unipept-visualizations
Version:
The Unipept visualisation library
397 lines (326 loc) • 14.9 kB
text/typescript
import * as d3 from "d3";
import TreeviewSettings from "./TreeviewSettings";
import TreeviewNode from "./TreeviewNode";
import MaxCountHeap from "./heap/MaxCountHeap";
import TreeviewPreprocessor from "./TreeviewPreprocessor";
import TooltipUtilities from "./../../utilities/TooltipUtilities";
import DataNode, { DataNodeLike } from "./../../DataNode";
type HPN<T> = d3.HierarchyPointNode<T>;
type HPL<T> = d3.HierarchyPointLink<T>;
export default class Treeview {
private readonly settings: TreeviewSettings;
private readonly data: HPN<TreeviewNode>[];
private root: HPN<TreeviewNode>;
private nodeId: number = 0;
private widthScale: d3.ScaleLinear<number, number>;
private treeLayout: d3.TreeLayout<TreeviewNode>;
private visElement: d3.Selection<SVGGElement, any, d3.BaseType, unknown>;
private tooltip!: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>;
private zoomListener: d3.ZoomBehavior<any, any>;
private tooltipTimer!: number;
private zoomScale: number = 1;
private svg: any;
constructor(
private readonly element: HTMLElement,
data: DataNodeLike,
options: TreeviewSettings = new TreeviewSettings()
) {
this.settings = this.fillOptions(options);
if (this.settings.enableTooltips) {
this.tooltip = TooltipUtilities.initTooltip();
}
const dataProcessor = new TreeviewPreprocessor();
const processedData = dataProcessor.preprocessData(data);
const rootNode = d3.hierarchy<TreeviewNode>(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: TreeviewNode) => d.children.length > 0 ? 0 : d.count);
this.widthScale = d3.scaleLinear()
.range([this.settings.minNodeSize, this.settings.maxNodeSize]);
this.treeLayout = d3.tree<TreeviewNode>()
.nodeSize([2, 10])
.separation((a: HPN<TreeviewNode>, b: HPN<TreeviewNode>) => {
if (a.data.isCollapsed() || b.data.isCollapsed()) {
return 0;
}
const width = (this.computeNodeSize(a) + this.computeNodeSize(b));
const distance = width / 2 + 4;
return (a.parent === b.parent) ? distance : distance + 4;
});
this.data = this.treeLayout(rootNode).descendants();
this.root = this.data[0];
this.element.innerHTML = "";
this.svg = 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)
.style("font-family", "'Helvetica Neue', Helvetica, Arial, sans-serif");
this.zoomListener = d3.zoom()
.extent([[0, 0], [this.settings.width, this.settings.height]])
.scaleExtent([0.1, 3])
.on("zoom", (event: d3.D3ZoomEvent<any, any>) => {
this.zoomScale = event.transform.k;
this.visElement.attr("transform", event.transform.toString())
})
this.visElement = this.svg.call(this.zoomListener).append("g");
this.render(this.root);
}
public reset() {
this.render(this.data[0]);
}
private fillOptions(options: any = undefined): TreeviewSettings {
const output = new TreeviewSettings();
return Object.assign(output, options);
}
private render(root: HPN<TreeviewNode>) {
this.widthScale.domain([0, root.data.count]);
this.root = root;
this.root.x = this.settings.height / 2;
this.root.y = 0;
this.root.data.setSelected(true);
const updateColor = (d: HPN<TreeviewNode>, level: number) => {
d.data.setColor(this.settings.colorProvider(d.data, level - 1));
if (level < this.settings.colorProviderLevels && d.children) {
for (const child of d.children) {
updateColor(child, level + 1);
}
}
}
this.root.children?.forEach((d: HPN<TreeviewNode>, i: number) => {
updateColor(d, 1);
});
if (this.settings.enableExpandOnClick) {
this.root.data.collapseAll();
this.initialExpand(this.root);
} else {
this.root.data.expandAll();
}
this.update(root);
this.centerRoot(root);
}
private centerRoot(source: HPN<TreeviewNode>): void {
let [x, y] = [-source.y, -source.x];
x = x * this.zoomScale + this.settings.width / 4;
y = y * this.zoomScale + this.settings.height / 2;
this.visElement
.transition()
.duration(this.settings.animationDuration)
.attr("transform", `translate(${x},${y})scale(${this.zoomScale})`)
.on("end", () => this.zoomListener.transform(this.svg, d3.zoomIdentity.translate(x, y).scale(this.zoomScale)));
}
private initialExpand(root: HPN<TreeviewNode>): void {
if (!this.settings.enableAutoExpand) {
root.data.expand(this.settings.levelsToExpand);
return;
}
root.data.expand(1);
let allowedCount = root.data.count * (this.settings.enableAutoExpand ? this.settings.autoExpandValue : 0.8);
const pq = new MaxCountHeap<HPN<TreeviewNode>>([...(root.children || [])], (a: HPN<TreeviewNode>, b: HPN<TreeviewNode>) => b.data.count - a.data.count);
while (allowedCount > 0 && pq.size() > 0) {
const toExpand = pq.remove();
allowedCount -= toExpand.data.count;
toExpand.data.expand(1);
toExpand.children?.forEach((d: HPN<TreeviewNode>, i: number) => {
pq.add(d);
});
}
}
private update(source: HPN<TreeviewNode>): void {
// Compute the new tree layout
const layout = this.treeLayout(this.root);
const nodes: HPN<TreeviewNode>[] = layout.descendants().reverse().filter((d: HPN<TreeviewNode>) => !d.data.isCollapsed());
const links: HPL<TreeviewNode>[] = layout.links().filter((d: HPL<TreeviewNode>) => !d.target.data.isCollapsed() && !d.source.data.isCollapsed());
// Normalize for fixed depth. The depth of a node determines it's horizontal position from the root.
nodes.forEach(d => d.y = d.depth * this.settings.nodeDistance);
// Update the nodes...
const node = this.visElement.selectAll<d3.BaseType, HPN<TreeviewNode>>("g.node")
.data(nodes, (d: HPN<TreeviewNode>) => d.data.id || (d.data.id = ++this.nodeId));
let nodeEnter = node.enter()
.append("g")
.attr("class", "node")
.style("cursor", "pointer")
// Every node is originally situated on the clicked node's (the source) position. Animations afterwards
// reposition the node to it's final location.
.attr("transform", `translate(${source.y || 0},${source.data.previousPosition.x || 0})`)
.on("click", (event: MouseEvent, d: HPN<TreeviewNode>) => this.click(event, d))
.on("mouseover", (event: MouseEvent, d: HPN<TreeviewNode>) => this.tooltipIn(event, d))
.on("mouseout", (event: MouseEvent, d: HPN<TreeviewNode>) => this.tooltipOut(event, d))
.on("contextmenu", (event: MouseEvent, d: HPN<TreeviewNode>) => this.rightClick(event, d))
// @ts-ignore
.merge(node);
nodeEnter.append("circle")
.attr("r", 1e-6)
.style("stroke-width", "1.5px")
.style("stroke", (d: HPN<TreeviewNode>) => this.settings.nodeStrokeColor(d.data))
.style("fill", (d: HPN<TreeviewNode>) => this.settings.nodeFillColor(d.data));
const arcScale = d3.scaleLinear().range([0, 2 * Math.PI]);
const innerArc = d3.arc()
.innerRadius(0)
// @ts-ignore
.outerRadius((d: HPN<TreeviewNode>) => {
return this.computeNodeSize(d);
})
.startAngle(0)
.endAngle(d => {
// @ts-ignore
return arcScale(d.data.selfCount / d.data.count) || 0;
});
if (this.settings.enableInnerArcs) {
// @ts-ignore
nodeEnter.append("path")
.attr("class", "innerArc")
// @ts-ignore
.attr("d", innerArc)
.style("fill", (d: HPN<TreeviewNode>) => this.settings.nodeStrokeColor(d.data))
.style("fill-opacity", 0);
}
if (this.settings.enableLabels) {
nodeEnter.append("text")
.attr("x", (d: HPN<TreeviewNode>) => d.children ? -10 : 10)
.attr("dy", ".35em")
.attr("text-anchor", (d: HPN<TreeviewNode>) => d.children ? "end" : "start")
.text((d: HPN<TreeviewNode>) => this.settings.getLabel(d.data))
.style("font", "10px sans-serif")
.style("fill-opacity", 1e-6);
}
// Transition nodes to their new position. (From the source's location to the final location)
const nodeUpdate = nodeEnter.transition()
.duration(this.settings.animationDuration)
.attr("transform", (d: HPN<TreeviewNode>) => `translate(${d.y}, ${d.x})`);
// Animate the fill and stroke of each circle (these circles make up the nodes that are rendered).
nodeUpdate.select("circle")
.attr("r", (d: HPN<TreeviewNode>) => this.computeNodeSize(d))
.style("fill-opacity", (d: HPN<TreeviewNode>) => d.children && d.children[0].data.isCollapsed() ? 1 : 0)
.style("stroke", (d: HPN<TreeviewNode>) => this.settings.nodeStrokeColor(d.data))
.style("fill", (d: HPN<TreeviewNode>) => this.settings.nodeFillColor(d.data));
if (this.settings.enableInnerArcs) {
nodeUpdate.select(".innerArc")
.style("fill-opacity", 1);
}
if (this.settings.enableLabels) {
nodeUpdate.select("text")
.style("fill-opacity", 1);
}
// Animate the movement of every node that should be removed to the source node location.
const nodeExit = node.exit().transition()
.duration(this.settings.animationDuration)
.attr("transform", d => `translate(${source.y},${source.x})`)
.remove();
nodeExit.select("circle")
.attr("r", 1e-6);
nodeExit.select("path")
.style("fill-opacity", 1e-6);
nodeExit.select("text")
.style("fill-opacity", 1e-6);
// Update the links between the different nodes.
// @ts-ignore
let link = this.visElement.selectAll("path.link")
.data(links, (d: HPL<TreeviewNode>) => d.target.data.id);
const linkGenerator = d3.linkHorizontal<any, HPL<TreeviewNode>, HPN<TreeviewNode>>().x(d => d.y).y(d => d.x);
// Enter any new links at the parent's previous position.
// @ts-ignore
link.enter()
.insert("path", "g")
.attr("class", "link")
.style("fill", "none")
.style("stroke-opacity", "0.5")
.style("stroke-linecap", "round")
.style("stroke", (d: HPL<TreeviewNode>) => this.settings.linkStrokeColor(d))
.style("stroke-width", 1e-6)
// @ts-ignore
.attr("d", (d: HPL<TreeviewNode>) => {
const o = {
x: source.data.previousPosition.x,
y: source.data.previousPosition.y
}
// @ts-ignore
return linkGenerator({
source: o,
target: o
});
})
// @ts-ignore
.merge(link)
.transition()
.duration(this.settings.animationDuration)
.attr("d", linkGenerator)
.style("stroke", this.settings.linkStrokeColor)
.style("stroke-width", (d: HPL<TreeviewNode>) => {
if (d.source.data.isSelected()) {
return this.widthScale(d.target.data.count) + "px";
} else {
return "4px";
}
});
// Transition exiting links to parent's new position.
link.exit().transition()
.duration(this.settings.animationDuration)
.style("stroke-width", 1e-6)
// @ts-ignore
.attr("d", (d: HPL<TreeviewNode>) => {
const o = {
x: source.x,
y: source.y
};
// @ts-ignore
return linkGenerator({
source: o,
target: o
});
})
.remove();
// Keep track of the old positions for the transitions
nodes.forEach((d: HPN<TreeviewNode>) => {
d.data.previousPosition = {
x: d.x,
y: d.y
};
});
}
private computeNodeSize(d: HPN<TreeviewNode>): number {
if (d.data.isSelected()) {
return this.widthScale(d.data.count) / 2;
} else {
return 2;
}
}
private click(event: MouseEvent, d: HPN<TreeviewNode>): void {
if (!this.settings.enableExpandOnClick) {
return;
}
if (event.defaultPrevented) {
return;
}
if (event.shiftKey) {
d.data.expandAll();
} else if (d.children && d.children.some(n => !n.data.isCollapsed())) {
d.data.collapseAll();
} else {
d.data.expand(this.settings.levelsToExpand);
}
this.update(d);
this.centerRoot(d);
}
private tooltipIn(event: MouseEvent, d: HPN<TreeviewNode>) {
if (this.settings.enableTooltips && this.tooltip) {
this.tooltip.html(this.settings.getTooltip(d.data))
.style("top", (event.pageY + 10) + "px")
.style("left", (event.pageX + 10) + "px");
this.tooltipTimer = window.setTimeout(() => this.tooltip.style("visibility", "visible"), 1000);
}
}
private tooltipOut(event: MouseEvent, d: HPN<TreeviewNode>) {
if (this.settings.enableTooltips && this.tooltip) {
clearTimeout(this.tooltipTimer);
this.tooltip.style("visibility", "hidden");
}
}
private rightClick(event: MouseEvent, d: HPN<TreeviewNode>) {
if (this.settings.enableRightClick) {
this.render(d);
}
}
}