UNPKG

jsroot

Version:
388 lines (325 loc) 15.3 kB
import { ObjectPainter } from '../base/ObjectPainter.mjs'; import { ensureTCanvas } from '../gpad/TCanvasPainter.mjs'; import { RTreeMapTooltip } from './RTreeMapTooltip.mjs'; function computeFnv(str) { const FNV_offset = 14695981039346656037n, FNV_prime = 1099511628211n; let h = FNV_offset; for (let i = 0; i < str.length; ++i) { const octet = BigInt(str.charCodeAt(i) & 0xFF); h ^= octet; h *= FNV_prime; } return h; } class RTreeMapPainter extends ObjectPainter { static CONSTANTS = { STROKE_WIDTH: 0.15, STROKE_COLOR: 'black', COLOR_HOVER_BOOST: 10, DATA_UNITS: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'], MAIN_TREEMAP: { LEFT: 0.025, BOTTOM: 0.05, RIGHT: 0.825, TOP: 0.9 }, LEGEND: { START_Y: 0.835, ITEM_HEIGHT: 0.05, BOX_WIDTH: 0.05, TEXT_OFFSET_X: 0.01, TEXT_OFFSET_Y: 0.01, TEXT_LINE_SPACING: 0.015, MAX_ITEMS: 10 }, TEXT: { SIZE_VW: 0.6, MIN_RECT_WIDTH: 0.025, MIN_RECT_HEIGHT: 0.05, PADDING: 10, LEAF_OFFSET_Y: 0.015 }, INDENT: 0.005, LEGEND_INDENT_MULTIPLIER: 4 }; constructor(dom, obj, opt) { super(dom, obj, opt); this.tooltip = new RTreeMapTooltip(this); this.rootIndex = 0; this.parentIndices = []; } cleanup() { if (this._frame_hidden) { delete this._frame_hidden; this.getPadPainter()?.getFrameSvg().style('display', null); } this.tooltip.cleanup(); super.cleanup(); } appendRect(begin, end, color, strokeColor = RTreeMapPainter.CONSTANTS.STROKE_COLOR, strokeWidth = RTreeMapPainter.CONSTANTS.STROKE_WIDTH, node = null) { const rect = this.getG() .append('rect') .attr('x', this.axisToSvg('x', begin.x, this.isndc)) .attr('y', this.axisToSvg('y', begin.y, this.isndc)) .attr('width', `${Math.abs(end.x - begin.x) * 100}%`) .attr('height', `${Math.abs(end.y - begin.y) * 100}%`) .attr('fill', color) .attr('stroke', strokeColor) .attr('stroke-width', strokeWidth) .attr('pointer-events', 'fill'); if (node) { rect.datum(node); this.attachPointerEventsTreeMap(rect, node); } return rect; } appendText(content, pos, size, color, anchor = 'start') { return this.getG() .append('text') .attr('x', this.axisToSvg('x', pos.x, this.isndc)) .attr('y', this.axisToSvg('y', pos.y, this.isndc)) .attr('font-size', `${size}vw`) .attr('fill', color) .attr('text-anchor', anchor) .attr('pointer-events', 'none') .text(content); } getRgbList(rgbStr) { return rgbStr.slice(4, -1).split(',').map((x) => parseInt(x)); } toRgbStr(rgbList) { return `rgb(${rgbList.join()})`; } attachPointerEventsTreeMap(element, node) { const original_color = element.attr('fill'), hovered_color = this.toRgbStr(this.getRgbList(original_color) .map((color) => Math.min(color + RTreeMapPainter.CONSTANTS.COLOR_HOVER_BOOST, 255))), mouseenter = () => { element.attr('fill', hovered_color); this.tooltip.content = this.tooltip.generateTooltipContent(node); this.tooltip.x = 0; this.tooltip.y = 0; }, mouseleave = () => { element.attr('fill', original_color); this.tooltip.hideTooltip(); }, mousemove = (event) => { this.tooltip.x = event.pageX; this.tooltip.y = event.pageY; this.tooltip.showTooltip(); }, click = () => { const obj = this.getObject(), nodeIndex = obj.fNodes.findIndex((elem) => elem === node); if (nodeIndex === this.rootIndex) this.rootIndex = this.parentIndices[nodeIndex]; else { let parentIndex = nodeIndex; while (this.parentIndices[parentIndex] !== this.rootIndex) parentIndex = this.parentIndices[parentIndex]; this.rootIndex = parentIndex; if (obj.fNodes[parentIndex].fNChildren === 0) this.rootIndex = this.parentIndices[nodeIndex]; } this.redraw(); }; this.attachPointerEvents(element, { mouseenter, mouseleave, mousemove, click }); } attachPointerEventsLegend(element, type) { const rects = this.getG().selectAll('rect'), mouseenter = () => { rects.filter((node) => node !== undefined && node.fType !== type).attr('opacity', '0.5'); }, mouseleave = () => { rects.attr('opacity', '1'); }; this.attachPointerEvents(element, { mouseenter, mouseleave, mousemove: () => {}, click: () => {} }); } attachPointerEvents(element, events) { for (const [key, value] of Object.entries(events)) element.on(key, value); } computeColor(n) { const hash = Number(computeFnv(String(n)) & 0xFFFFFFFFn), r = (hash >> 16) & 0xFF, g = (hash >> 8) & 0xFF, b = hash & 0xFF; return this.toRgbStr([r, g, b]); } getDataStr(bytes) { const units = RTreeMapPainter.CONSTANTS.DATA_UNITS, order = Math.floor(Math.log10(bytes) / 3), finalSize = bytes / Math.pow(1000, order); return `${finalSize.toFixed(2)}${units[order]}`; } computeWorstRatio(row, width, height, totalSize, horizontalRows) { if (row.length === 0) return 0; const sumRow = row.reduce((sum, child) => sum + child.fSize, 0); if (sumRow === 0) return 0; let worstRatio = 0; for (const child of row) { const ratio = horizontalRows ? (child.fSize * width * totalSize) / (sumRow * sumRow * height) : (child.fSize * height * totalSize) / (sumRow * sumRow * width), aspectRatio = Math.max(ratio, 1 / ratio); if (aspectRatio > worstRatio) worstRatio = aspectRatio; } return worstRatio; } squarifyChildren(children, rect, horizontalRows, totalSize) { const width = rect.topRight.x - rect.bottomLeft.x, height = rect.topRight.y - rect.bottomLeft.y, remaining = [...children].sort((a, b) => b.fSize - a.fSize), result = [], remainingBegin = { ...rect.bottomLeft }; while (remaining.length > 0) { const row = []; let currentWorstRatio = Infinity; const remainingWidth = rect.topRight.x - remainingBegin.x, remainingHeight = rect.topRight.y - remainingBegin.y; if (remainingWidth <= 0 || remainingHeight <= 0) break; while (remaining.length > 0) { row.push(remaining.shift()); const newWorstRatio = this.computeWorstRatio(row, remainingWidth, remainingHeight, totalSize, horizontalRows); if (newWorstRatio > currentWorstRatio) { remaining.unshift(row.pop()); break; } currentWorstRatio = newWorstRatio; } const sumRow = row.reduce((sum, child) => sum + child.fSize, 0); if (sumRow === 0) continue; const dimension = horizontalRows ? (sumRow / totalSize * height) : (sumRow / totalSize * width); let position = 0; for (const child of row) { const childDimension = child.fSize / sumRow * (horizontalRows ? width : height), childBegin = horizontalRows ? { x: remainingBegin.x + position, y: remainingBegin.y } : { x: remainingBegin.x, y: remainingBegin.y + position }, childEnd = horizontalRows ? { x: remainingBegin.x + position + childDimension, y: remainingBegin.y + dimension } : { x: remainingBegin.x + dimension, y: remainingBegin.y + position + childDimension }; result.push({ node: child, rect: { bottomLeft: childBegin, topRight: childEnd } }); position += childDimension; } if (horizontalRows) remainingBegin.y += dimension; else remainingBegin.x += dimension; } return result; } drawLegend() { const obj = this.getObject(), diskMap = {}; let stack = [this.rootIndex]; while (stack.length > 0) { const node = obj.fNodes[stack.pop()]; if (node.fNChildren === 0) diskMap[node.fType] = (diskMap[node.fType] || 0) + node.fSize; stack = stack.concat(Array.from({ length: node.fNChildren }, (_, a) => a + node.fChildrenIdx)); } const diskEntries = Object.entries(diskMap) .sort((a, b) => b[1] - a[1]) .slice(0, RTreeMapPainter.CONSTANTS.LEGEND.MAX_ITEMS) .filter(([, size]) => size > 0), legend = RTreeMapPainter.CONSTANTS.LEGEND; diskEntries.forEach(([typeName, size], index) => { const posY = legend.START_Y - index * legend.ITEM_HEIGHT, posX = legend.START_Y + legend.ITEM_HEIGHT + legend.TEXT_OFFSET_X, textSize = RTreeMapPainter.CONSTANTS.TEXT.SIZE_VW, rect = this.appendRect({ x: legend.START_Y, y: posY }, { x: legend.START_Y + legend.ITEM_HEIGHT, y: posY - legend.ITEM_HEIGHT }, this.computeColor(typeName)); this.attachPointerEventsLegend(rect, typeName); const diskOccupPercent = `${(size / obj.fNodes[this.rootIndex].fSize * 100).toFixed(2)}%`, diskOccup = `(${this.getDataStr(size)} / ${this.getDataStr(obj.fNodes[this.rootIndex].fSize)})`; [typeName, diskOccup, diskOccupPercent].forEach( (content, i) => this.appendText(content, { x: posX, y: posY - legend.TEXT_OFFSET_Y - legend.TEXT_LINE_SPACING * (i) }, textSize, 'black')); }); } trimText(textElement, rect) { const nodeElem = textElement.node(); let textContent = nodeElem.textContent; const availablePx = Math.abs(this.axisToSvg('x', rect.topRight.x, this.isndc) - this.axisToSvg('x', rect.bottomLeft.x, this.isndc)) - RTreeMapPainter.CONSTANTS.TEXT.PADDING; while (nodeElem.getComputedTextLength && nodeElem.getComputedTextLength() > availablePx && textContent.length > 0) { textContent = textContent.slice(0, -1); nodeElem.textContent = textContent + '…'; } return textContent; } drawTreeMap(node, rect, depth = 0) { const isLeaf = node.fNChildren === 0, color = isLeaf ? this.computeColor(node.fType) : 'rgb(100,100,100)'; this.appendRect({ x: rect.bottomLeft.x, y: rect.topRight.y }, { x: rect.topRight.x, y: rect.bottomLeft.y }, color, RTreeMapPainter.CONSTANTS.STROKE_COLOR, RTreeMapPainter.CONSTANTS.STROKE_WIDTH, node); const rectWidth = rect.topRight.x - rect.bottomLeft.x, rectHeight = rect.topRight.y - rect.bottomLeft.y, labelBase = `${node.fName} (${this.getDataStr(node.fSize)})`, textConstants = RTreeMapPainter.CONSTANTS.TEXT, textSize = (rectWidth <= textConstants.MIN_RECT_WIDTH || rectHeight <= textConstants.MIN_RECT_HEIGHT) ? 0 : textConstants.SIZE_VW; if (textSize > 0) { const textElement = this.appendText(labelBase, { x: rect.bottomLeft.x + (isLeaf ? rectWidth / 2 : RTreeMapPainter.CONSTANTS.INDENT), y: isLeaf ? (rect.bottomLeft.y + rect.topRight.y) / 2 : (rect.topRight.y - textConstants.LEAF_OFFSET_Y) }, textSize, 'white', isLeaf ? 'middle' : 'start'); textElement.textContent = this.trimText(textElement, rect); } if (!isLeaf && node.fNChildren > 0) { const obj = this.getObject(), children = obj.fNodes.slice(node.fChildrenIdx, node.fChildrenIdx + node.fNChildren), totalSize = children.reduce((sum, child) => sum + child.fSize, 0); if (totalSize > 0) { const indent = RTreeMapPainter.CONSTANTS.INDENT, innerRect = { bottomLeft: { x: rect.bottomLeft.x + indent, y: rect.bottomLeft.y + indent }, topRight: { x: rect.topRight.x - indent, y: rect.topRight.y - indent * RTreeMapPainter.CONSTANTS.LEGEND_INDENT_MULTIPLIER } }, width = innerRect.topRight.x - innerRect.bottomLeft.x, height = innerRect.topRight.y - innerRect.bottomLeft.y, horizontalRows = width > height, rects = this.squarifyChildren(children, innerRect, horizontalRows, totalSize); rects.forEach( ({ node: childNode, rect: childRect }) => { this.drawTreeMap(childNode, childRect, depth + 1); }); } } } createParentIndices() { const obj = this.getObject(); this.parentIndices = new Array(obj.fNodes.length).fill(0); obj.fNodes.forEach((node, index) => { for (let i = node.fChildrenIdx; i < node.fChildrenIdx + node.fNChildren; i++) this.parentIndices[i] = index; }); } getDirectory() { const obj = this.getObject(); let result = '', currentIndex = this.rootIndex; while (currentIndex !== 0) { result = obj.fNodes[currentIndex].fName + '/' + result; currentIndex = this.parentIndices[currentIndex]; } return result; } redraw() { const svg = this.getPadPainter().getFrameSvg(); if (!svg.empty()) { svg.style('display', 'none'); this._frame_hidden = true; } const obj = this.getObject(); this.createG(); this.isndc = true; if (obj.fNodes && obj.fNodes.length > 0) { this.createParentIndices(); const mainArea = RTreeMapPainter.CONSTANTS.MAIN_TREEMAP; this.drawTreeMap( obj.fNodes[this.rootIndex], { bottomLeft: { x: mainArea.LEFT, y: mainArea.BOTTOM }, topRight: { x: mainArea.RIGHT, y: mainArea.TOP } }); this.drawLegend(); this.appendText(this.getDirectory(), { x: RTreeMapPainter.CONSTANTS.MAIN_TREEMAP.LEFT, y: RTreeMapPainter.CONSTANTS.MAIN_TREEMAP.TOP + 0.01 }, RTreeMapPainter.CONSTANTS.TEXT.SIZE_VW, 'black'); } return this; } static async draw(dom, obj, opt) { const painter = new RTreeMapPainter(dom, obj, opt); return ensureTCanvas(painter, false).then(() => painter.redraw()); } } // class RTreeMapPainter export { RTreeMapPainter };