From ea7afcb5aac486f7ed67918d728a705719f4dc48 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 14 Mar 2026 05:18:20 +0100 Subject: [PATCH 1/5] update asset tree to be interactive Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/templates/_macros.html | 328 ++++++++++++------------- 1 file changed, 154 insertions(+), 174 deletions(-) diff --git a/flexmeasures/ui/templates/_macros.html b/flexmeasures/ui/templates/_macros.html index 09dccecf3d..1076219c70 100644 --- a/flexmeasures/ui/templates/_macros.html +++ b/flexmeasures/ui/templates/_macros.html @@ -1,185 +1,165 @@ {% macro show_tree(assets, current_asset_name) %} -
-
+
+
+ + {% endmacro %} @@ -201,5 +181,5 @@ {% endif %} {% endfor %} - + {% endmacro %} From da5983d321f21438d1776dcb189afc604d5f4260 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 11 Jun 2026 00:54:03 +0200 Subject: [PATCH 2/5] fix: load the library Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/templates/_macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/ui/templates/_macros.html b/flexmeasures/ui/templates/_macros.html index 67b9823565..3205e3aa6b 100644 --- a/flexmeasures/ui/templates/_macros.html +++ b/flexmeasures/ui/templates/_macros.html @@ -3,7 +3,7 @@
- + @@ -34,32 +36,86 @@ } }); + var scrollDom = document.getElementById('view-scroll'); var chartDom = document.getElementById('view'); var myChart = echarts.init(chartDom); - // Count the maximum number of nodes at any single depth level. - // This is what drives vertical spacing: if 6 siblings share the height, - // each box can only be ~1/6 of the available height before they overlap. - function getMaxNodesAtAnyLevel(roots) { - var maxAtLevel = 0; - function traverse(nodeList) { - if (!nodeList || nodeList.length === 0) return; - if (nodeList.length > maxAtLevel) maxAtLevel = nodeList.length; - nodeList.forEach(function(n) { traverse(n.children || []); }); - } - traverse(roots); - return Math.max(maxAtLevel, 1); + // Count the leaf nodes. In a horizontal tree, every leaf occupies its own + // vertical slot, so the leaf count is what determines how tightly nodes + // are packed vertically - and therefore how large each box can be. + function countLeaves(roots) { + var n = 0; + (function walk(list) { + list.forEach(function(x) { + if (!x.children || x.children.length === 0) { n++; } + else { walk(x.children); } + }); + })(roots); + return Math.max(n, 1); } function updateChart() { - // Derive box size from available vertical space so nodes never overlap. - var maxNodes = getMaxNodesAtAnyLevel(rootNodes); - var available = chartDom.clientHeight * 0.72; // usable height (72 % of container) - var spacePerNode = available / maxNodes; - // Clamp: 52 px minimum (readable), 110 px maximum (not too large) - var BOX_SIZE = Math.max(52, Math.min(110, Math.floor(spacePerNode * 0.78))); - var ICON_SIZE = Math.floor(BOX_SIZE * 0.36); // ~36 % of box - var FONT_SIZE = Math.max(9, Math.floor(BOX_SIZE * 0.14)); // ~14 % of box + var leaves = countLeaves(rootNodes); + var viewportH = scrollDom.clientHeight; + + // Size each box to FIT the viewport: divide the usable height among the + // leaves, reserving a vertical gap per node. Clamp to a legible range so + // few assets get large boxes and many assets get small (but readable) ones. + var GAP_RATIO = 0.28; // vertical gap as a fraction of box + var fitBox = (viewportH * 0.92 / leaves) / (1 + GAP_RATIO); + var BOX_SIZE = Math.round(Math.max(50, Math.min(104, fitBox))); + var ICON_SIZE = Math.floor(BOX_SIZE * 0.26); + var FONT_SIZE = Math.max(8, Math.min(13, Math.round(BOX_SIZE * 0.13))); + var LINE_H = FONT_SIZE + 2; + + // Each node needs a vertical slot of box + gap. Boxes only stop shrinking + // at the 50px floor; past that the column is taller than the viewport, so + // we grow the canvas and let the container scroll instead of overlapping. + var SLOT = Math.round(BOX_SIZE * (1 + GAP_RATIO)); + var canvasH = Math.max(viewportH, leaves * SLOT); + chartDom.style.height = canvasH + 'px'; + myChart.resize(); + + // How many text lines fit under the icon, inside the box? + var textBudget = BOX_SIZE - ICON_SIZE - 10; + var MAX_LINES = Math.min(3, Math.max(1, Math.floor(textBudget / LINE_H))); + // Approx chars per line at this font (~0.56em average glyph width). + var charsPerLine = Math.max(4, Math.floor((BOX_SIZE - 12) / (FONT_SIZE * 0.56))); + + // Deterministically wrap a name into at most MAX_LINES lines, breaking on + // spaces where possible and ellipsising the overflow. We do this ourselves + // rather than rely on ECharts' rich-text width wrapping, which proved + // unreliable for long names (they would overflow the box horizontally). + function wrapName(name) { + var words = String(name).split(/\s+/); + var lines = []; + var cur = ''; + function pushHardWrapped(word) { + while (word.length > charsPerLine) { + lines.push(word.slice(0, charsPerLine)); + word = word.slice(charsPerLine); + } + return word; + } + for (var i = 0; i < words.length; i++) { + var w = words[i]; + var candidate = cur ? (cur + ' ' + w) : w; + if (candidate.length <= charsPerLine) { + cur = candidate; + } else { + if (cur) { lines.push(cur); cur = ''; } + if (w.length > charsPerLine) { w = pushHardWrapped(w); } + cur = w; + } + } + if (cur) lines.push(cur); + if (lines.length > MAX_LINES) { + lines = lines.slice(0, MAX_LINES); + var last = lines[MAX_LINES - 1]; + lines[MAX_LINES - 1] = last.slice(0, Math.max(1, charsPerLine - 1)).trimEnd() + '…'; + } + return lines.join('\n'); + } // Enhance nodes with styles function enrichNodeData(node) { @@ -72,13 +128,14 @@ : [BOX_SIZE, BOX_SIZE]; // Build rich-text dict so icon + text fit inside the symbol. + var displayName = wrapName(node.name); var rich = { text: { - width: BOX_SIZE - 10, + width: BOX_SIZE - 6, overflow: 'truncate', align: 'center', fontSize: FONT_SIZE, - lineHeight: FONT_SIZE + 2, + lineHeight: LINE_H, padding: [2, 0, 0, 0] } }; @@ -98,15 +155,15 @@ fontSize: FONT_SIZE, fontWeight: isCurrent ? 'bold' : '600', fontFamily: 'system-ui, -apple-system, sans-serif', - // Keep a reference to the node-id in a closure so the formatter - // can emit the correct rich-text key without ambiguity. - formatter: (function(n) { + // Capture the node and its pre-wrapped name in a closure so the + // formatter emits the correct rich-text key and text per node. + formatter: (function(n, dn) { return function() { return n.icon - ? ('{icon_' + n.id + '|}\n{text|' + n.name + '}') - : ('{text|' + n.name + '}'); + ? ('{icon_' + n.id + '|}\n{text|' + dn + '}') + : ('{text|' + dn + '}'); }; - })(node), + })(node, displayName), rich: rich }; @@ -144,14 +201,17 @@ { type: 'tree', data: rootNodes, - top: '10%', - left: '10%', - bottom: '15%', - right: '10%', + top: '6%', + left: '12%', + bottom: '6%', + right: '14%', + symbol: 'roundRect', expandAndCollapse: true, animationDuration: 550, animationDurationUpdate: 750, - roam: true, + // Pan only (drag). Vertical density is handled by container scroll, + // so wheel scrolls the list rather than fighting a zoom gesture. + roam: 'move', initialTreeDepth: -1, lineStyle: { color: '#94a3b8', From c41bb98876a444f5f44ed3f7658c35cef90eba6d Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Fri, 26 Jun 2026 23:15:48 +0200 Subject: [PATCH 5/5] feat: add zoom in/out and reset buttons Signed-off-by: Ahmad-Wahid --- flexmeasures/ui/templates/_macros.html | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/flexmeasures/ui/templates/_macros.html b/flexmeasures/ui/templates/_macros.html index 76027630a3..0ccad38215 100644 --- a/flexmeasures/ui/templates/_macros.html +++ b/flexmeasures/ui/templates/_macros.html @@ -1,7 +1,16 @@ {% macro show_tree(assets, current_asset_name) %} -
+
-
+
+
+
+
+ +
+ + +
@@ -38,7 +47,8 @@ var scrollDom = document.getElementById('view-scroll'); var chartDom = document.getElementById('view'); - var myChart = echarts.init(chartDom); + // SVG renderer so the tree stays crisp when zoomed in via CSS transform. + var myChart = echarts.init(chartDom, null, { renderer: 'svg' }); // Count the leaf nodes. In a horizontal tree, every leaf occupies its own // vertical slot, so the leaf count is what determines how tightly nodes @@ -237,6 +247,22 @@ window.location.href = params.data.link; } }); + + // ===== Zoom controls ===== + // Zoom scales the WHOLE tree (nodes + spacing) with a CSS transform, kept + // crisp by the SVG renderer. The gray container stays a fixed size; zooming + // out shrinks the whole tree centered within it, zooming in overflows it so + // the scroll container can pan around. + var zoomLayer = document.getElementById('view-zoom'); + var zoomLevel = 1; + var ZOOM_MIN = 0.3, ZOOM_MAX = 2.5, ZOOM_STEP = 0.2; + function setZoom(z) { + zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(z * 100) / 100)); + zoomLayer.style.transform = 'scale(' + zoomLevel + ')'; + } + document.getElementById('treeZoomIn').addEventListener('click', function() { setZoom(zoomLevel + ZOOM_STEP); }); + document.getElementById('treeZoomOut').addEventListener('click', function() { setZoom(zoomLevel - ZOOM_STEP); }); + document.getElementById('treeZoomReset').addEventListener('click', function() { setZoom(1); }); }; {% endmacro %}