heatmap-cluster
Version:
The Unipept visualisation library
1,047 lines (856 loc) • 43.8 kB
text/typescript
import * as d3 from "d3";
import HeatmapSettings from "./HeatmapSettings";
import UPGMAClusterer from "./cluster/UPGMAClusterer";
import EuclidianDistanceMetric from "./metric/EuclidianDistanceMetric";
import ClusterElement from "./cluster/ClusterElement";
import TreeNode from "./cluster/TreeNode";
import Reorderer from "./reorder/Reorderer";
import MoloReorderer from "./reorder/MoloReorderer";
import HeatmapFeature from "./HeatmapFeature";
import HeatmapValue from "./HeatmapValue";
import Preprocessor from "./Preprocessor";
import "core-js/stable";
import "regenerator-runtime/runtime";
import CanvasRenderHelper from "./../../render/CanvasRenderHelper";
import RenderHelper from "./../../render/RenderHelper";
type ViewPort = {
xTop: number,
yTop: number,
xBottom: number,
yBottom: number
};
export default class Heatmap {
private element: HTMLElement;
private settings: HeatmapSettings;
private rows: HeatmapFeature[];
private columns: HeatmapFeature[];
private values: HeatmapValue[][];
private valuesPerColor: Map<string, [number, number][]>;
// private tooltip: d3.Selection<HTMLDivElement, any, HTMLElement, any> | null = null;
private originalViewPort: ViewPort;
private currentViewPort: ViewPort;
private visElement: d3.Selection<HTMLCanvasElement, unknown, HTMLElement, any>;
private context: CanvasRenderingContext2D;
// Which portion of the visualisation is currently reserved for the text?
private textWidth: number;
private textHeight: number;
private tooltip: d3.Selection<HTMLDivElement, unknown, HTMLElement, any> | null = null;
private highlightedRow: number = -1;
private highlightedColumn: number = -1;
private pixelRatio: number;
private rowClusterRoot!: TreeNode;
private colClusterRoot!: TreeNode;
private horizontalNodesPerDepth!: TreeNode[][];
private verticalNodesPerDepth!: TreeNode[][];
private animatingRows: boolean = false;
private animatingCols: boolean = false;
private clusteredHorizontal: boolean = false;
private clusteredVertical: boolean = false;
private lastZoomStatus: { k: number, x: number, y: number } = {
k: 1,
x: 0,
y: 0
};
constructor(
elementIdentifier: HTMLElement,
values: number[][],
rowLabels: string[],
columnLabels: string[],
options: Partial<HeatmapSettings> = new HeatmapSettings()
) {
this.settings = this.fillOptions(options);
this.element = elementIdentifier;
const preprocessor = new Preprocessor();
this.rows = preprocessor.preprocessFeatures(rowLabels);
this.columns = preprocessor.preprocessFeatures(columnLabels);
this.values = preprocessor.transformValues(
values,
this.settings.colors,
this.settings.colorBuckets,
this.settings.valueRange
);
this.valuesPerColor = preprocessor.orderPerColor(this.values);
if (this.settings.enableTooltips) {
this.tooltip = this.initTooltip();
}
this.pixelRatio = window.devicePixelRatio || 1;
// Initialize the viewport with the default width and height of the visualization
this.originalViewPort = {
xTop: 0,
yTop: 0,
xBottom: this.settings.width,
yBottom: this.settings.height
}
this.currentViewPort = this.originalViewPort;
this.textWidth = this.settings.initialTextWidth;
this.textHeight = this.settings.initialTextHeight;
// Add a canvas to the desired element and set it's required properties
this.element.innerHTML = "";
// @ts-ignore
this.visElement = d3.select(this.element)
.append("canvas")
.attr("width", this.pixelRatio * this.settings.width)
.attr("height", this.pixelRatio * this.settings.height)
.attr("style", `width: ${this.settings.width}px; height: ${this.settings.height}px`)
.on("mouseover", (event: MouseEvent) => this.tooltipMove(event))
.on("mousemove", (event: MouseEvent) => this.tooltipMove(event))
.on("mouseout", (event: MouseEvent) => this.tooltipMove(event))
.on("click", (event: MouseEvent) => this.click(event));
this.context = this.visElement.node()!.getContext("2d")!;
this.context.scale(this.pixelRatio, this.pixelRatio);
const zoom = d3.zoom()
.extent([[0, 0], [this.settings.width, this.settings.height]])
.scaleExtent([0.25, 12])
.on("zoom", (event: d3.D3ZoomEvent<any, any>) => {
this.zoomed(event.transform);
});
// @ts-ignore
this.visElement.call(zoom);
this.computeClusterRoots();
this.redraw();
}
private fillOptions(options: any = undefined): HeatmapSettings {
let output = new HeatmapSettings();
return Object.assign(output, options);
}
/**
* Reset the complete view to it's initial state with the options and data passed in the constructor.
*/
public reset() {
this.redraw();
}
/**
* Cluster the data found in the Heatmap according to the default clustering algorithm.
* @param toCluster One of "all", "columns" or "rows". "All" denotes that clustering on both the rows and columns
* should be performed. "Columns" denotes that clustering should only be clustered on the columns only. "Rows"
* denotes that the clustering is performed on the rows only.
*/
public async cluster(toCluster: "all" | "columns" | "rows" | "none" = "all") {
const animationDuration = this.settings.animationsEnabled ? this.settings.animationDuration / 2 : 0;
// Function that animates the movement of the rows and columns
const createAnimator = (rowOrder: number[], columnOrder: number[]) => {
return new Promise<void>((resolve) => {
let animationStart: number;
const animateRows = (timestamp: number) => {
if (animationStart === undefined) {
animationStart = timestamp;
}
const elapsed = timestamp - animationStart;
const animationStep = this.settings.transition(elapsed / animationDuration);
this.redraw(rowOrder, columnOrder, animationStep);
if (elapsed < animationDuration) {
requestAnimationFrame(animateRows);
} else {
resolve();
}
};
requestAnimationFrame(animateRows);
});
}
const preprocessor = new Preprocessor();
let rowOrder: number[] = Array.from(Array(this.rows.length).keys())
let inverseRowOrder: number[] = new Array(rowOrder.length);
if ((toCluster === "all" || toCluster === "rows") && !this.clusteredVertical) {
this.clusteredVertical = true;
// Now we perform a depth first search on the result in order to find the order of the values
rowOrder = this.determineOrder(this.rowClusterRoot);
for (const [idx, row] of Object.entries(rowOrder)) {
inverseRowOrder[row] = Number.parseInt(idx);
}
// First animate rows
const columnIdentity = Array.from(Array(this.columns.length).keys());
this.animatingRows = true;
await createAnimator(inverseRowOrder, columnIdentity);
this.animatingRows = false;
let newValues = [];
// Swap rows into the correct position
for (const row of rowOrder) {
newValues.push(this.values[row]);
}
// Swap row titles
const newRowTitles = [];
for (const row of rowOrder) {
newRowTitles.push(this.rows[row]);
}
this.rows = newRowTitles;
this.values = newValues;
this.valuesPerColor = preprocessor.orderPerColor(this.values);
}
let columnOrder: number[] = Array.from(Array(this.columns.length).keys())
let inverseColumnOrder: number[] = new Array(columnOrder.length);
if ((toCluster === "all" || toCluster === "columns") && !this.clusteredHorizontal) {
this.clusteredHorizontal = true;
columnOrder = this.determineOrder(this.colClusterRoot);
for (const [idx, col] of Object.entries(columnOrder)) {
inverseColumnOrder[col] = Number.parseInt(idx);
}
// Then animate columns
const rowIdentity = Array.from(Array(this.rows.length).keys());
this.animatingCols = true;
await createAnimator(rowIdentity, inverseColumnOrder);
this.animatingCols = false;
let newValues = [];
// Swap columns
for (const row of rowIdentity) {
let newRow: HeatmapValue[] = [];
for (const column of columnOrder) {
newRow.push(this.values[row][column]);
}
newValues.push(newRow);
}
// Swap column titles
const newColumnTitles = [];
for (const col of columnOrder) {
newColumnTitles.push(this.columns[col]);
}
this.columns = newColumnTitles;
this.values = newValues;
this.valuesPerColor = preprocessor.orderPerColor(this.values);
}
this.redraw();
}
private computeClusterRoots() {
let clusterer = this.settings.clusteringAlgorithm;
let molo: Reorderer = this.settings.reorderer;
// Create a new ClusterElement for every row that exists. This ClusterElement keeps track of an array of
// numbers that correspond to a row's values.
let rowElements: ClusterElement[] = this.rows.map((el, idx) => new ClusterElement(
this.values[idx].filter(val => val.rowId == el.idx).map(x => x.value), el.idx!)
);
this.rowClusterRoot = molo.reorder(clusterer.cluster(rowElements));
this.verticalNodesPerDepth = this.bfsNodesPerDepth(this.rowClusterRoot);
// Create a new ClusterElement for every column that exists.
let columnElements: ClusterElement[] = this.columns.map(
(el, idx) => new ClusterElement(
this.values.map(col => col[idx].value),
el.idx!
)
);
this.colClusterRoot = molo.reorder(clusterer.cluster(columnElements));
this.horizontalNodesPerDepth = this.bfsNodesPerDepth(this.colClusterRoot);
}
public resize(newWidth: number, newHeight: number) {
this.settings.width = newWidth;
this.settings.height = newHeight;
this.visElement.attr("height", this.pixelRatio * newHeight);
this.visElement.attr("width", this.pixelRatio * newWidth);
this.visElement.attr("style", `width: ${this.settings.width}px; height: ${this.settings.height}px`);
this.context.scale(this.pixelRatio, this.pixelRatio);
this.originalViewPort = {
xTop: 0,
yTop: 0,
xBottom: newWidth,
yBottom: newHeight
}
this.zoomed(this.lastZoomStatus);
}
/**
* Convert the heatmap to an SVG-string that can easily be downloaded as a valid SVG-file. Note that the current
* positioning and zooming level of the heatmap will not be taken into account (but clustering will!).
*
* Note that this function can take a while to compute for larger heatmaps. It is recommended to start this
* function in a dedicated worker in order not to block the main JS thread.
*
* @param fontSize Font size that should be used for the labels in the produced SVG file.
* @param squareDimension width and height (in pixels) of one square in the produced heatmap.
* @param squarePadding Amount of space between squares in both the horizontal and vertical direction (in pixels).
* @param visualizationTextPadding Amount of space between the heatmap itself and the labels on both axes.
* @return A string that represents the content of a valid SVG file.
*/
public toSVG(
fontSize: number = 14,
squareDimension: number = 20,
squarePadding: number = 2,
visualizationTextPadding: number = 4
): string {
const dimension = squareDimension;
let svgContents = "";
// First produce SVG-contents for all squares in the heatmap
for (const [color, values] of this.valuesPerColor) {
for (const [row, col] of values) {
const xTop = col * (dimension + squarePadding);
const yTop = row * (dimension + squarePadding);
svgContents += `
<rect width="${dimension}" height="${dimension}" fill="${color}" x="${xTop}" y="${yTop}"></rect>
`
}
}
const offscreenCanvas = new OffscreenCanvas(1, 1);
const ctx = offscreenCanvas.getContext("2d");
ctx!.font = `${fontSize}px 'Helvetica Neue', Helvetica, Arial, sans-serif`;
// Then add the row and colum titles to the heatmap
const x = dimension * this.columns.length + squarePadding * (this.columns.length - 1) + visualizationTextPadding;
const textCenter = Math.max((dimension - fontSize) / 2, 0);
let maximumWidth: number = x;
for (let row = 0; row < this.rows.length; row++) {
const y = (dimension + squarePadding) * row + textCenter;
svgContents += `
<text
x="${x}"
y="${y}"
font-size="${fontSize}"
dominant-baseline="hanging"
fill="black"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
>
${this.rows[row].name}
</text>
`;
// Compute the length of the label in pixels
const computedWidth: number = ctx!.measureText(this.rows[row].name).width + x;
if (computedWidth > maximumWidth) {
maximumWidth = computedWidth;
}
}
const y = dimension * this.rows.length + squarePadding * (this.rows.length - 1) + visualizationTextPadding;
let maximumHeight: number = y;
for (let col = 0; col < this.columns.length; col++) {
const x = (dimension + squarePadding) * col + textCenter;
svgContents += `
<text
x="${x}"
y="${y}"
font-size="${fontSize}"
text-anchor="start"
fill="black"
transform="rotate(90, ${x}, ${y})"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
>
${this.columns[col].name}
</text>
`;
const computedWidth: number = ctx!.measureText(this.columns[col].name).width + y;
if (computedWidth > maximumHeight) {
maximumHeight = computedWidth;
}
}
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(maximumWidth)}" height="${Math.ceil(maximumHeight)}">
${svgContents}
</svg>
`;
}
/**
* Extracts a linear order from a dendrogram by following all branches up to leaves in a depth-first ordering.
*
* @param treeNode Root of a dendrogram for which a linear leaf ordering needs to be extracted.
*/
private determineOrder(treeNode: TreeNode): number[] {
return treeNode.values.map(item => item.id);
}
/**
* Determines the dimensions of one square based upon the current width and height-settings and the amount of rows
* and columns currently set to be visualized.
*/
private determineSquareWidth(
viewPort = this.currentViewPort,
textWidth: number = this.textWidth,
textHeight: number = this.textHeight
) {
const dendrogramWidth = this.determineDendrogramWidth();
const visualizationWidth = viewPort.xBottom -
viewPort.xTop -
dendrogramWidth -
this.columns.length * this.settings.squarePadding -
textWidth;
const visualizationHeight = viewPort.yBottom -
viewPort.yTop -
dendrogramWidth -
this.rows.length * this.settings.squarePadding -
textHeight;
// Squares should at least be one pixel in height
let squareWidth = Math.max(1, visualizationWidth / this.columns.length);
let squareHeight = Math.max(1, visualizationHeight / this.rows.length);
return Math.min(squareWidth, squareHeight);
}
private determineDendrogramWidth(): number {
if (this.settings.dendrogramEnabled) {
return this.settings.dendrogramWidth * this.lastZoomStatus.k;
} else {
return 0;
}
}
private computeTextStartX(
viewPort = this.currentViewPort,
textWidth: number = this.textWidth,
textHeight: number = this.textHeight
): number {
return viewPort.xTop +
this.determineDendrogramWidth() +
this.determineSquareWidth(viewPort, textWidth, textHeight) * this.columns.length +
this.settings.squarePadding * (this.columns.length - 1) +
this.settings.visualizationTextPadding;
}
private computeTextStartY(
viewPort = this.currentViewPort,
textWidth: number = this.textWidth,
textHeight: number = this.textHeight
): number {
return viewPort.yTop +
this.determineDendrogramWidth() +
this.determineSquareWidth(viewPort, textWidth, textHeight) * this.rows.length +
this.settings.squarePadding * (this.rows.length - 1) +
this.settings.visualizationTextPadding;
}
private zoomed({ k, x, y }: { k: number, x: number, y: number }) {
this.lastZoomStatus = { k, x, y };
const newTextStartX = x + this.computeTextStartX(
this.originalViewPort,
this.settings.initialTextWidth,
this.settings.initialTextHeight
) * k;
const newTextStartY = y + this.computeTextStartY(
this.originalViewPort,
this.settings.initialTextWidth,
this.settings.initialTextHeight
) * k;
const comparator: (x: number, y: number) => number = (x, y) => {
if (x > y) {
return y;
} else if (k >= 1) {
return Math.min(x, y);
} else {
return Math.max(x, y);
}
};
// Recalculate the current viewport
this.currentViewPort = {
xTop: x + this.originalViewPort.xTop * k,
yTop: y + this.originalViewPort.yTop * k,
xBottom: comparator(x + this.originalViewPort.xBottom * k, this.originalViewPort.xBottom),
yBottom: comparator(y + this.originalViewPort.yBottom * k, this.originalViewPort.yBottom)
}
this.textWidth = this.currentViewPort.xBottom - newTextStartX;
this.textHeight = this.currentViewPort.yBottom - newTextStartY;
this.redraw();
}
/**
* Redraw the complete Heatmap and clear the view first. This function accepts three optional arguments that
* determine the current animation state (if requested).
*
* @param newRowPositions Current position of the rows. Row[i] = j denotes that the i'th row in the original grid
* should move to position j.
* @param newColumnPositions New positions of the columns. Column[i] = j denotes that i'th column in the original
* grid should move to position j.
* @param animationStep A decimal number (in [0, 1]) that denotes the current animation progress. If 0.7 is passed
* as a value, 70% of the animation has already passed.
*/
private redraw(
newRowPositions: number[] = Array.from(Array(this.rows.length).keys()),
newColumnPositions: number[] = Array.from(Array(this.columns.length).keys()),
animationStep: number = -1
) {
this.redrawGrid(newRowPositions, newColumnPositions, animationStep);
this.redrawRowTitles(newRowPositions, animationStep);
this.redrawColumnTitles(newColumnPositions, animationStep);
this.redrawDendrogram(animationStep);
}
private redrawGrid(
newRowPositions: number[],
newColumnPositions: number[],
animationStep: number
) {
if (animationStep === -1) {
animationStep = 0;
}
let squareWidth = this.determineSquareWidth();
const dendrogramWidth: number = this.determineDendrogramWidth();
this.context.clearRect(0, 0, this.settings.width, this.settings.height);
for (const [color, values] of this.valuesPerColor) {
this.context.beginPath();
this.context.fillStyle = color;
for (const [row, col] of values) {
// First compute the positions at the start of the animation
const xTopStart = this.currentViewPort.xTop + dendrogramWidth + col * (squareWidth + this.settings.squarePadding);
const yTopStart = this.currentViewPort.yTop + dendrogramWidth + row * (squareWidth + this.settings.squarePadding);
// Then compute the positions at the end of the animation
const xTopEnd = this.currentViewPort.xTop + dendrogramWidth + newColumnPositions[col] * (squareWidth + this.settings.squarePadding);
const yTopEnd = this.currentViewPort.yTop + dendrogramWidth + newRowPositions[row] * (squareWidth + this.settings.squarePadding);
const xDifference = xTopEnd - xTopStart;
const yDifference = yTopEnd - yTopStart;
let xTopCurrent = xTopStart + xDifference * animationStep;
let yTopCurrent = yTopStart + yDifference * animationStep;
let xBottomCurrent = xTopCurrent + (squareWidth + this.settings.squarePadding);
let yBottomCurrent = yTopCurrent + (squareWidth + this.settings.squarePadding);
// We do not need to draw the current square
if (xBottomCurrent < 0 || xTopCurrent > this.settings.width) {
continue;
}
if (yBottomCurrent < 0 || yTopCurrent > this.settings.height) {
continue;
}
if (this.settings.highlightSelection && row == this.highlightedRow && col == this.highlightedColumn) {
// Add a highlight border around the currently selected square
this.context.save();
this.context.fillStyle = this.settings.maxColor;
this.context.fillRect(
xTopCurrent - this.settings.squarePadding,
yTopCurrent - this.settings.squarePadding,
squareWidth + 2 * this.settings.squarePadding,
squareWidth + 2 * this.settings.squarePadding
);
this.context.restore();
}
this.context.fillRect(
xTopCurrent,
yTopCurrent,
squareWidth,
squareWidth
);
}
this.context.closePath();
}
}
/**
* Add ellipsis characters to the string, if it does not fit onto the screen.
*
* @param input The string to which an ellipsis should be added, if required.
* @param width The maximum width that the string should occupy.
* @return A string to which an ellipsis has been added, if it was required.
*/
private ellipsizeString(input: string, width: number): string {
const computedWidth = this.context.measureText(input);
if (computedWidth.width > width) {
let i = input.length;
let output = input.substr(0, i) + "...";
while (this.context.measureText(output).width > width && i > 0) {
i--;
output = input.substr(0, i) + "...";
}
if (i === 0) {
return "";
}
return output;
} else {
return input;
}
}
private redrawRowTitles(
newRowPositions: number[],
animationStep: number
) {
if (animationStep === -1) {
animationStep = 0;
}
const squareWidth = this.determineSquareWidth();
const dendrogramWidth = this.determineDendrogramWidth();
// Per how many items should we display a text item? (padding is 8)
const stepSize: number = Math.max(Math.floor((this.settings.fontSize + 12) / (squareWidth + this.settings.squarePadding)), 1);
const textStart = this.computeTextStartX();
let textCenter = Math.max((squareWidth - this.settings.fontSize) / 2, 0);
this.context.save();
this.context.fillStyle = this.settings.labelColor;
this.context.textBaseline = "top";
this.context.textAlign = "start"
this.context.font = `${this.settings.fontSize}px Arial, sans-serif`;
for (let i = 0; i < this.rows.length; i += stepSize) {
const row = this.rows[i];
if (this.settings.highlightSelection && i == this.highlightedRow) {
this.context.save();
this.context.fillStyle = this.settings.highlightFontColor;
this.context.font = `${this.settings.highlightFontSize}px 'Helvetica Neue', Helvetica, Arial, sans-serif`;
textCenter = Math.max((squareWidth - this.settings.highlightFontSize) / 2, 0);
}
const originalY = this.currentViewPort.yTop + dendrogramWidth + (squareWidth + this.settings.squarePadding) * i + textCenter;
const endY = this.currentViewPort.yTop + dendrogramWidth + (squareWidth + this.settings.squarePadding) * newRowPositions[i] + textCenter;
const difference = endY - originalY;
const currentY = originalY + difference * animationStep;
this.context.fillText(
this.ellipsizeString(row.name, this.textWidth),
textStart,
currentY
);
if (this.settings.highlightSelection && i == this.highlightedRow) {
this.context.restore();
}
}
this.context.restore();
}
private redrawColumnTitles(
newColumnPositions: number[],
animationStep: number
) {
if (animationStep === -1) {
animationStep = 0;
}
let squareWidth = this.determineSquareWidth();
const dendrogramWidth = this.determineDendrogramWidth();
// Per how many items should we display a text item? (padding is 8)
let stepSize: number = Math.max(Math.floor((this.settings.fontSize + 12) / (squareWidth + this.settings.squarePadding)), 1);
let textStart = this.computeTextStartY();
let textCenter = Math.max((squareWidth - this.settings.fontSize) / 2, 0);
this.context.save();
this.context.rotate((90 * Math.PI) / 180);
this.context.fillStyle = this.settings.labelColor;
this.context.textBaseline = "bottom";
this.context.textAlign = "start";
this.context.font = `${this.settings.fontSize}px Arial, sans-serif`;
for (let i = 0; i < this.columns.length; i += stepSize) {
const col = this.columns[i];
if (this.settings.highlightSelection && i == this.highlightedColumn) {
this.context.save();
this.context.fillStyle = this.settings.highlightFontColor;
this.context.font = `${this.settings.highlightFontSize}px 'Helvetica Neue', Helvetica, Arial, sans-serif`;
textCenter = Math.max((squareWidth - this.settings.highlightFontSize) / 2, 0);
}
const originalX = -(this.currentViewPort.xTop + dendrogramWidth + (squareWidth + this.settings.squarePadding) * i + textCenter);
const endX = -(this.currentViewPort.xTop + dendrogramWidth + (squareWidth + this.settings.squarePadding) * newColumnPositions[i] + textCenter);
const difference = endX - originalX;
const currentX = originalX + difference * animationStep;
// The axis of the canvas also rotate 90 degrees clockwise
this.context.fillText(
this.ellipsizeString(col.name, this.textHeight),
textStart,
currentX
);
if (this.settings.highlightSelection && i == this.highlightedColumn) {
this.context.restore();
}
}
this.context.restore();
}
/**
* Perform a BFS search on the given tree and order all encountered nodes per depth level. The resulting output
* of this function is a 2D array of the format depth => TreeNode[] (thus it keeps track of all nodes that are
* situated at a specific level). Note that the ordering of these nodes per level is not arbitrary, but that nodes
* in pairs share the parent (that is, node at index 0 and index 1 share the same parent, etc).
*
* @param root The root of the tree for which we should order all the children per depth level.
* @return A 2D array containing one array per depth level of the given tree.
*/
private bfsNodesPerDepth(root: TreeNode) {
const nodesPerDepth: TreeNode[][] = [];
const queue: [TreeNode, number][] = [];
// Push current node and depth of the node
queue.push([root, 0]);
while (queue.length > 0) {
const [node, depth]: [TreeNode, number] = queue.shift()!;
if (nodesPerDepth.length <= depth) {
nodesPerDepth.push([]);
}
nodesPerDepth[depth].push(node);
if (node.leftChild) {
queue.push([node.leftChild, depth + 1]);
}
if (node.rightChild) {
queue.push([node.rightChild, depth + 1]);
}
}
return nodesPerDepth;
}
private redrawDendrogram(animationStep: number) {
if (this.settings.dendrogramEnabled) {
this.redrawHorizontalDendrogram(animationStep);
this.redrawVerticalDendrogram(animationStep);
}
}
private computeDendrogramColor(clustered: boolean, shouldAnimate: boolean, animationStep: number) {
if (animationStep === -1 || !shouldAnimate) {
return clustered ? this.settings.dendrogramColor : "#d3d3d3";
}
const scale = d3.interpolateLab(d3.lab("#d3d3d3"), d3.lab(this.settings.dendrogramColor));
return scale(animationStep);
}
private redrawVerticalDendrogram(animationStep: number) {
this.context.save();
const clusterColor: string = this.computeDendrogramColor(this.clusteredVertical, this.animatingRows, animationStep);
// Calculate size of all the different items
const squareWidth: number = this.determineSquareWidth();
const dendrogramWidth: number = this.settings.dendrogramWidth * this.lastZoomStatus.k;
const renderHelper: RenderHelper = new CanvasRenderHelper(this.context);
const verticalLineOffset: number = this.currentViewPort.yTop + dendrogramWidth + squareWidth / 2;
// Maps node with id i to it's corresponding starting position ([x, y]);
const nodePositions: Map<number, [number, number]> = new Map<number, [number, number]>();
const newRowPositions = this.determineOrder(this.rowClusterRoot!);
for (let i = 0; i < newRowPositions.length; i++) {
nodePositions.set(
newRowPositions[i],
[
this.currentViewPort.xTop + dendrogramWidth,
i * (squareWidth + this.settings.squarePadding) + verticalLineOffset
]
);
}
// Calculate the amount of pixels that can be used for each merge
const pixelsPerMerge: number = dendrogramWidth / this.rows.length;
let currentMergeStep: number = this.currentViewPort.xTop + dendrogramWidth - pixelsPerMerge;
for (let currentDepth = this.verticalNodesPerDepth.length - 1; currentDepth > 0; currentDepth--) {
// We need to iterate over the different nodes in increments of 2 (since these nodes define a merge per 2)
for (let i = 0; i < this.verticalNodesPerDepth[currentDepth].length; i += 2) {
const leftChild = this.verticalNodesPerDepth[currentDepth][i];
const rightChild = this.verticalNodesPerDepth[currentDepth][i + 1];
const parent = leftChild.parent;
const [leftX, leftY] = nodePositions.get(leftChild.id)!;
const [rightX, rightY] = nodePositions.get(rightChild.id)!;
this.context.beginPath();
// Line for the left child
renderHelper.renderLine(leftX, leftY, currentMergeStep, leftY, this.settings.dendrogramLineWidth, clusterColor);
// Line for right child
renderHelper.renderLine(rightX, rightY, currentMergeStep, rightY, this.settings.dendrogramLineWidth, clusterColor);
// Draw vertical line that connects both items
renderHelper.renderLine(currentMergeStep, leftY, currentMergeStep, rightY, this.settings.dendrogramLineWidth, clusterColor);
this.context.closePath();
// Update the starting position of the parent node.
if (parent) {
const mergePoint: number = Math.min(leftY, rightY) + Math.abs(leftY - rightY) / 2;
nodePositions.set(parent.id, [currentMergeStep, mergePoint]);
}
currentMergeStep -= pixelsPerMerge;
}
}
if (!this.clusteredVertical) {
this.context.rotate(-(90 * Math.PI) / 180);
this.context.fillStyle = this.settings.labelColor;
const fontSize = 24 * this.lastZoomStatus.k;
this.context.font = `${fontSize}px 'Helvetica Neue', Helvetica, Arial, sans-serif`;
const textWidth = this.context.measureText("Click to cluster").width;
this.context.fillText(
"Click to cluster",
-(this.currentViewPort.yTop + dendrogramWidth + (this.rows.length * (squareWidth + this.settings.squarePadding)) / 2) - textWidth / 2,
this.currentViewPort.xTop + dendrogramWidth / 2 + fontSize / 2,
);
}
this.context.restore();
}
private redrawHorizontalDendrogram(animationStep: number) {
this.context.save();
const clusterColor: string = this.computeDendrogramColor(this.clusteredHorizontal, this.animatingCols, animationStep);
// Calculate size of all the different items
const squareWidth: number = this.determineSquareWidth();
const dendrogramWidth: number = this.settings.dendrogramWidth * this.lastZoomStatus.k;
const renderHelper: RenderHelper = new CanvasRenderHelper(this.context);
const horizontalLineOffset: number = this.currentViewPort.xTop + squareWidth / 2 + dendrogramWidth;
// Maps node with id i to it's corresponding starting position ([x, y]);
const nodePositions: Map<number, [number, number]> = new Map<number, [number, number]>();
const newColPositions = this.determineOrder(this.colClusterRoot);
for (let i = 0; i < newColPositions.length; i++) {
nodePositions.set(
newColPositions[i],
[
i * (squareWidth + this.settings.squarePadding) + horizontalLineOffset,
this.currentViewPort.yTop + dendrogramWidth
]
);
}
// Calculate the amount of pixels that can be used for each merge
const pixelsPerMerge: number = dendrogramWidth / this.columns.length;
let currentMergeStep: number = this.currentViewPort.yTop + dendrogramWidth - pixelsPerMerge;
for (let currentDepth = this.horizontalNodesPerDepth.length - 1; currentDepth > 0; currentDepth--) {
// We need to iterate over the different nodes in increments of 2 (since these nodes define a merge per 2)
for (let i = 0; i < this.horizontalNodesPerDepth[currentDepth].length; i += 2) {
const leftChild = this.horizontalNodesPerDepth[currentDepth][i];
const rightChild = this.horizontalNodesPerDepth[currentDepth][i + 1];
const parent = leftChild.parent;
const [leftX, leftY] = nodePositions.get(leftChild.id)!;
const [rightX, rightY] = nodePositions.get(rightChild.id)!;
this.context.beginPath();
// Line for the left child
renderHelper.renderLine(leftX, leftY, leftX, currentMergeStep, this.settings.dendrogramLineWidth, clusterColor);
// Line for right child
renderHelper.renderLine(rightX, rightY, rightX, currentMergeStep, this.settings.dendrogramLineWidth, clusterColor);
// Draw horizontal line that connects both items
renderHelper.renderLine(leftX, currentMergeStep, rightX, currentMergeStep, this.settings.dendrogramLineWidth, clusterColor);
this.context.closePath();
// Update the starting position of the parent node.
if (parent) {
const mergePoint: number = Math.min(leftX, rightX) + Math.abs(leftX - rightX) / 2;
nodePositions.set(parent.id, [mergePoint, currentMergeStep]);
}
currentMergeStep -= pixelsPerMerge;
}
}
if (!this.clusteredHorizontal) {
this.context.fillStyle = this.settings.labelColor;
const fontSize = 24 * this.lastZoomStatus.k;
this.context.font = `${fontSize}px 'Helvetica Neue', Helvetica, Arial, sans-serif`;
const textWidth = this.context.measureText("Click to cluster").width;
this.context.fillText(
"Click to cluster",
this.currentViewPort.xTop + dendrogramWidth + (this.columns.length * (squareWidth + this.settings.squarePadding)) / 2 - textWidth / 2,
this.currentViewPort.yTop + dendrogramWidth / 2 + fontSize / 2,
);
}
this.context.restore();
}
private initTooltip() {
return d3.select("body")
.append("div")
.attr("class", "tip")
.style("position", "absolute")
.style("z-index", "10")
.style("visibility", "hidden");
}
private findRowAndColForPosition(x: number, y: number): [number, number] {
const dendrogramWidth = this.determineDendrogramWidth();
const currentX = x - this.currentViewPort.xTop - dendrogramWidth;
const currentY = y - this.currentViewPort.yTop - dendrogramWidth;
const squareWidth = this.determineSquareWidth();
const row = Math.floor(currentY / (squareWidth + this.settings.squarePadding));
const col = Math.floor(currentX / (squareWidth + this.settings.squarePadding));
return [row, col];
}
private tooltipMove(event: MouseEvent) {
// Find out which element is situated under the current mouse position.
// @ts-ignore
const rect = event.target.getBoundingClientRect();
const [row, col] = this.findRowAndColForPosition(event.clientX - rect.left, event.clientY - rect.top);
if (row < 0 || row >= this.rows.length || col < 0 || col >= this.columns.length) {
if (this.settings.enableTooltips && this.tooltip) {
this.tooltip.style("visibility", "hidden");
}
this.highlightedRow = -1;
this.highlightedColumn = -1;
if (this.settings.highlightSelection) {
this.redraw();
}
return;
}
this.highlightedRow = row;
this.highlightedColumn = col;
if (this.settings.highlightSelection) {
this.redraw();
}
if (this.settings.enableTooltips && this.tooltip) {
this.tooltip.html(this.settings.getTooltip(this.values[row][col], this.rows[row], this.columns[col]))
.style("top", (event.pageY + 10) + "px")
.style("left", (event.pageX + 10) + "px")
.style("visibility", "visible");
}
}
/**
* Determines if a click occurred on one of the dendrograms and if clustering should be applied to the heatmap.
*
* @param event
* @private
*/
private click(event: MouseEvent) {
if (!this.settings.dendrogramEnabled) {
return;
}
const dendroWidth = this.determineDendrogramWidth();
const squareWidth = this.determineSquareWidth();
// @ts-ignore
const rect = event.target.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (
x >= this.currentViewPort.xTop &&
x <= this.currentViewPort.xTop + dendroWidth &&
y >= this.currentViewPort.yTop + dendroWidth &&
y <= this.currentViewPort.yTop + dendroWidth + this.rows.length * (squareWidth + this.settings.squarePadding)
) {
// Clicked on the vertical dendrogram (and thus cluster vertically)
this.cluster("rows");
return;
}
if (
x >= this.currentViewPort.xTop + dendroWidth &&
x <= this.currentViewPort.xTop + dendroWidth + this.columns.length * (squareWidth + this.settings.squarePadding) &&
y >= this.currentViewPort.yTop &&
y <= this.currentViewPort.yTop + dendroWidth
) {
this.cluster("columns");
return;
}
}
}