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';