UNPKG

unipept-visualizations

Version:
397 lines (326 loc) 14.9 kB
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); } } }