diff --git a/demo/examples.json b/demo/examples.json index 200a8ac59..0c13faf11 100644 --- a/demo/examples.json +++ b/demo/examples.json @@ -403,7 +403,8 @@ { "name": "legend", "url": "nobrowser&file=$$$graph_twopad.root&item=[c1/pad1/[0],c1/pad2/[0],$legend]&opt=[alp,ly+;yaxis_red]", "title": "Display legend for the drawn objects" }, { "name": "scatter", "file": "scatter.root", "item": "c2", "title": "Canvas with new TScatter class, tutorials/graphs/scatter.C" }, { "name": "ann3d", "file": "tutorials_graphs.root", "item": "annotation3d_new", "title": "Usage of new TAnnotation class, tutorials/graphs/annotation3d.C" }, - { "name": "marker", "file": "marker_types.root", "item": "c1", "title": "Canvases from TAttMarker documentaion with different types and lines width" } + { "name": "marker", "file": "marker_types.root", "item": "c1", "title": "Canvases from TAttMarker documentaion with different types and lines width" }, + { "name": "treemap", "file": "treemap.root", "item": "RTreeMapPainter", "title": "Display of RNTuple structure in RBrowser" } ], "User" : [ { "name": "div", "url": "nobrowser&inject=../demo/custom/divhist.mjs&file=demo/custom/divhist.root&item=DivHist", "title": "Draw division of two histograms from user class, see demo/custom/ dir for details" }, diff --git a/modules/draw.mjs b/modules/draw.mjs index 04edf24a7..ccb8dfbd5 100644 --- a/modules/draw.mjs +++ b/modules/draw.mjs @@ -162,7 +162,8 @@ drawFuncs = { lst: [ { name: nsREX+'RPaveText', icon: 'img_pavetext', class: () => import_v7('pave').then(h => h.RPaveTextPainter), opt: '' }, { name: nsREX+'RFrame', icon: 'img_frame', draw: () => import_v7().then(h => h.drawRFrame), opt: '' }, { name: nsREX+'RFont', icon: 'img_text', draw: () => import_v7().then(h => h.drawRFont), opt: '', direct: 'v7', csstype: 'font' }, - { name: nsREX+'RAxisDrawable', icon: 'img_frame', draw: () => import_v7().then(h => h.drawRAxis), opt: '' } + { name: nsREX+'RAxisDrawable', icon: 'img_frame', draw: () => import_v7().then(h => h.drawRAxis), opt: '' }, + { name: nsREX+'RTreeMapPainter', class: () => import('./draw/RTreeMapPainter.mjs').then(h => h.RTreeMapPainter), opt: '' } ], cache: {} }; diff --git a/modules/draw/RTreeMapPainter.mjs b/modules/draw/RTreeMapPainter.mjs new file mode 100644 index 000000000..a6f72f144 --- /dev/null +++ b/modules/draw/RTreeMapPainter.mjs @@ -0,0 +1,406 @@ +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': mouseEnter, + 'mouseleave': mouseLeave, + 'mousemove': 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': mouseEnter, 'mouseleave': 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()); + } + +} +export { RTreeMapPainter }; diff --git a/modules/draw/RTreeMapTooltip.mjs b/modules/draw/RTreeMapTooltip.mjs new file mode 100644 index 000000000..ad47e3267 --- /dev/null +++ b/modules/draw/RTreeMapTooltip.mjs @@ -0,0 +1,86 @@ +class RTreeMapTooltip { + + static CONSTANTS = { DELAY: 0, OFFSET_X: 10, OFFSET_Y: -10, PADDING: 8, BORDER_RADIUS: 4 }; + + constructor(painter) + { + this.painter = painter; + this.tooltip = null; + this.content = ''; + this.x = 0; + this.y = 0; + } + + cleanup() { + if (this.tooltip !== null) document.body.removeChild(this.tooltip); + } + + createTooltip() + { + if (this.tooltip) + return; + + this.tooltip = document.createElement('div'); + this.tooltip.style.cssText = ` + position: absolute; + background: rgba(0, 0, 0, 0.9); + color: white; + padding: ${RTreeMapTooltip.CONSTANTS.PADDING}px; + border-radius: ${RTreeMapTooltip.CONSTANTS.BORDER_RADIUS}px; + font-size: 12px; + pointer-events: none; + z-index: 10000; + opacity: 0; + transition: opacity 0.2s; + max-width: 200px; + word-wrap: break-word; + `; + document.body.appendChild(this.tooltip); + } + + showTooltip() + { + if (!this.tooltip) + this.createTooltip(); + + this.tooltip.innerHTML = this.content; + this.tooltip.style.left = (this.x + RTreeMapTooltip.CONSTANTS.OFFSET_X) + 'px'; + this.tooltip.style.top = (this.y + RTreeMapTooltip.CONSTANTS.OFFSET_Y) + 'px'; + this.tooltip.style.opacity = '1'; + } + + hideTooltip() + { + if (this.tooltip) + this.tooltip.style.opacity = '0'; + } + + generateTooltipContent(node) + { + const isLeaf = node.fNChildren === 0; + let content = (node.fName.length > 0) ? `${node.fName}
` : ''; + + content += `${(isLeaf ? 'Column' : 'Field')}
`; + content += `Size: ${this.painter.getDataStr(node.fSize)}
`; + + if (isLeaf && node.fType !== undefined) + content += `Type: ${node.fType}
`; + + + if (!isLeaf) + content += `Children: ${node.fNChildren}
`; + + + const obj = this.painter.getObject(); + if (obj.fNodes && obj.fNodes.length > 0) { + const totalSize = obj.fNodes[0].fSize, + percentage = ((node.fSize / totalSize) * 100).toFixed(2); + content += `Disk Usage: ${percentage}%`; + } + + return content; + } + +} + +export { RTreeMapTooltip }; \ No newline at end of file diff --git a/modules/main.mjs b/modules/main.mjs index d8a547aab..03c63346e 100644 --- a/modules/main.mjs +++ b/modules/main.mjs @@ -23,6 +23,8 @@ export * from './hist/TH3Painter.mjs'; export * from './hist/TGraphPainter.mjs'; +export * from './draw/RTreeMapPainter.mjs'; + export { geoCfg } from './geom/geobase.mjs'; export { createGeoPainter, TGeoPainter } from './geom/TGeoPainter.mjs';