/** * ============================================================ * PYRAMID ANALYTICS – ORGANISATION CHART SCORECARD (CV API 2.0) * ============================================================ * * Renders a top-down, expandable / collapsible organisation chart * where every node displays a scorecard card with: * • Department / member name * • Primary measure value (large headline number) * • Delta value Δ $ (absolute change vs secondary measure) * • Delta % Δ (%) (relative change) * • A mini sparkline drawn from the series/size drop-zone values * * Straight elbow connectors link parent nodes to their children. * Nodes are arranged left-to-right within each depth row. * * DROP ZONE CONFIGURATION: * ┌──────────────────┬──────────────────┬──────────┬──────────────┐ * │ Drop Zone │ Caption │ Required │ Chip Type │ * ├──────────────────┼──────────────────┼──────────┼──────────────┤ * │ Rows │ Hierarchy │ Yes │ Hierarchy │ * │ Values │ Current Value │ Yes │ Measure │ * │ Size (optional) │ Previous Value │ No │ Measure │ * │ Series (optional)│ Sparkline Points │ No │ Measure(s) │ * └──────────────────┴──────────────────┴──────────┴──────────────┘ * * HOW IT WORKS: * • The hierarchy in Rows drives the org-chart depth and branching. * • Values → headline figure shown in the card. * • Size → previous-period value used to compute Δ $ and Δ (%). * • Series → one or more measures whose values are plotted as a * sparkline across the bottom of the card. * • Click the +/− toggle on any card to expand or collapse its * direct children. * • Root node and its direct children are expanded by default. * ============================================================ */ /* $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ * * USER CONFIGURATION — EDIT THESE VALUES TO CUSTOMISE * * $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ */ var CONFIG = { /* ---------------------------------------------------------- * FONT * ---------------------------------------------------------- */ FONT_FAMILY: 'Segoe UI, Arial, sans-serif', FONT_SIZE_PX: 12, /* ---------------------------------------------------------- * COLORS * ---------------------------------------------------------- */ /** Background of the entire canvas */ COLOR_BACKGROUND: '#f7f8fa', /** Card background */ COLOR_CARD_BG: '#ffffff', /** Card border color */ COLOR_CARD_BORDER: '#e0e3ea', /** Card shadow (CSS box-shadow) */ COLOR_CARD_SHADOW: '0 2px 6px rgba(0,0,0,0.10)', /** Member name label color */ COLOR_LABEL: '#333333', /** Headline value color */ COLOR_VALUE: '#111111', /** Positive delta color */ COLOR_DELTA_POS: '#2e7d32', /** Negative delta color */ COLOR_DELTA_NEG: '#c62828', /** Sparkline stroke color */ COLOR_SPARKLINE: '#888888', /** Sparkline fill (area under curve) */ COLOR_SPARKLINE_FILL: 'rgba(150,160,180,0.15)', /** Connector line color */ COLOR_CONNECTOR: '#c0c8d8', /** * Card accent colors per hierarchy depth. * Index 0 = root (Grand Total), 1 = first level, 2 = second level, … * These are used as a left-border stripe on each card. */ COLORS_DEPTH: [ '#f5c400', /* depth 0 – amber / gold (root / grand total) */ '#f5c400', /* depth 1 – amber (division level) */ '#4a90d9', /* depth 2 – blue (department level) */ '#e91e8c', /* depth 3 – pink */ '#4ecdc4', /* depth 4 – teal */ '#7b68ee', /* depth 5 – slate blue */ '#2ecc71', /* depth 6 – green */ '#e74c3c' /* depth 7 – red */ ], /* ---------------------------------------------------------- * LAYOUT * ---------------------------------------------------------- */ /** Card width in pixels */ CARD_W: 190, /** Card height in pixels */ CARD_H: 100, /** Horizontal gap between sibling cards at the same depth */ H_GAP: 20, /** Vertical gap between depth rows */ V_GAP: 60, /** Horizontal padding inside the card */ CARD_PAD_X: 10, /** Corner radius of each card */ CARD_RADIUS: 6, /** Thickness of the left accent stripe */ ACCENT_W: 5, /** Height of the sparkline inside each card (pixels) */ SPARKLINE_H: 28, /* ---------------------------------------------------------- * EXPAND BEHAVIOUR * 'children' – toggle only direct children. * 'descendants' – toggle the full subtree. * ---------------------------------------------------------- */ EXPAND_MODE: 'children' }; /* $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ * END OF USER CONFIGURATION — DO NOT EDIT BELOW THIS LINE * $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$ */ /* ── CROSS-RENDER STATE ─────────────────────────────────── */ var _expandedIds = {}; var _initialized = false; var _zoomLevel = 1.0; /* zoom: 1.0 = 100 %, range 0.25 – 2.0 */ /* ── ENTRY POINT ─────────────────────────────────────────── */ function main() { cvApi2.canvas.addEventListener(cvApi2.enums.Events.Render, render); } /* ── RENDER ──────────────────────────────────────────────── */ function render() { var trellisData = cvApi2.resultSet.data.getCurrentTrellisData(); if (!trellisData || !trellisData.datapoints || trellisData.datapoints.length === 0) return; var datapoints = trellisData.datapoints; var element = cvApi2.canvas.getHTMLElement(); var width = cvApi2.canvas.width; var height = cvApi2.canvas.height; element.innerHTML = ''; /* ── 1. Build tree ── */ var root = buildTree(datapoints); /* ── 2. Compute layout positions ── */ computeLayout(root, 0, 0); /* ── 3. Determine canvas extents ── */ var bounds = { minX: Infinity, maxX: -Infinity, maxDepth: 0 }; measureBounds(root, bounds, 0); var treeW = bounds.maxX - bounds.minX + CONFIG.CARD_W + 40; var treeH = (bounds.maxDepth + 1) * (CONFIG.CARD_H + CONFIG.V_GAP) + 40; var svgW = Math.max(width, treeW); var svgH = Math.max(height, treeH); /* ── 4. Wire up outer SVG → foreignObject → scrollable div → inner SVG ── */ var outerSvg = makeSvgEl('svg', { width: width, height: height }); outerSvg.style.display = 'block'; var fo = makeSvgEl('foreignObject', { x: 0, y: 0, width: width, height: height }); var wrapper = document.createElement('div'); wrapper.style.cssText = 'width:' + width + 'px;height:' + height + 'px;' + 'display:flex;flex-direction:column;box-sizing:border-box;' + 'background:' + CONFIG.COLOR_BACKGROUND + ';' + 'font-family:' + CONFIG.FONT_FAMILY + ';overflow:hidden;'; /* ── Control bar ── */ var BAR_H = 36; var ctrlBar = document.createElement('div'); ctrlBar.style.cssText = 'width:100%;height:' + BAR_H + 'px;min-height:' + BAR_H + 'px;' + 'display:flex;align-items:center;gap:8px;padding:0 12px;' + 'background:#f4f5f7;border-bottom:1px solid #dde0e6;box-sizing:border-box;'; function makeBtn(label, title) { var btn = document.createElement('button'); btn.textContent = label; btn.title = title; btn.style.cssText = 'padding:4px 14px;font-size:12px;font-family:' + CONFIG.FONT_FAMILY + ';' + 'background:#ffffff;color:#333333;border:1px solid #c0c4cc;border-radius:4px;' + 'cursor:pointer;line-height:1.4;'; btn.onmouseover = function () { btn.style.background = '#e8edf5'; }; btn.onmouseout = function () { btn.style.background = '#ffffff'; }; return btn; } var btnExp = makeBtn('+ Expand All', 'Expand every node'); btnExp.addEventListener('click', function () { setExpandedRecursive(root, true); render(); }); var btnCol = makeBtn('− Collapse All', 'Collapse every node'); btnCol.addEventListener('click', function () { for (var ci = 0; ci < root.children.length; ci++) { setExpandedRecursive(root.children[ci], false); } render(); }); ctrlBar.appendChild(btnExp); ctrlBar.appendChild(btnCol); /* ── Zoom slider ── */ var zoomWrap = document.createElement('div'); zoomWrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin-left:16px;' + 'font-size:12px;color:#555;font-family:' + CONFIG.FONT_FAMILY + ';'; var zoomIcon = document.createElement('span'); zoomIcon.textContent = '🔍'; zoomIcon.style.fontSize = '13px'; var zoomSlider = document.createElement('input'); zoomSlider.type = 'range'; zoomSlider.min = '25'; zoomSlider.max = '200'; zoomSlider.step = '5'; zoomSlider.value = String(Math.round(_zoomLevel * 100)); zoomSlider.style.cssText = 'width:110px;cursor:pointer;vertical-align:middle;accent-color:#4a90d9;'; var zoomVal = document.createElement('span'); zoomVal.textContent = zoomSlider.value + '%'; zoomVal.style.cssText = 'min-width:38px;font-size:12px;color:#555;'; var btnZoomReset = makeBtn('Reset', 'Reset zoom to 100%'); btnZoomReset.style.padding = '3px 10px'; btnZoomReset.addEventListener('click', function () { _zoomLevel = 1.0; zoomSlider.value = '100'; zoomVal.textContent = '100%'; render(); }); zoomSlider.addEventListener('input', function () { _zoomLevel = parseInt(zoomSlider.value, 10) / 100; zoomVal.textContent = zoomSlider.value + '%'; render(); }); zoomWrap.appendChild(zoomIcon); zoomWrap.appendChild(zoomSlider); zoomWrap.appendChild(zoomVal); zoomWrap.appendChild(btnZoomReset); ctrlBar.appendChild(zoomWrap); wrapper.appendChild(ctrlBar); /* ── Scrollable canvas ── */ var scrollerH = height - BAR_H; var scroller = document.createElement('div'); scroller.style.cssText = 'width:' + width + 'px;height:' + scrollerH + 'px;' + 'overflow:auto;box-sizing:border-box;' + 'background:' + CONFIG.COLOR_BACKGROUND + ';'; /* ── Inner SVG — natural (unscaled) size; zoom applied via CSS transform ── */ var svg = makeSvgEl('svg', { width: svgW, height: svgH }); svg.style.display = 'block'; svg.style.transformOrigin = '0 0'; svg.style.transform = 'scale(' + _zoomLevel + ')'; /* Expand the scrollable area to match the scaled size so scrollbars appear correctly */ svg.style.marginBottom = Math.round(svgH * (_zoomLevel - 1)) + 'px'; svg.style.marginRight = Math.round(svgW * (_zoomLevel - 1)) + 'px'; /* Drop-shadow filter for cards */ var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.innerHTML = '' + '' + ''; svg.appendChild(defs); /* Offset so cards aren't clipped at the left edge */ var offsetX = 20 - bounds.minX; var offsetY = 20; /* ── 5. Draw connectors (behind cards) ── */ drawConnectors(svg, root, 0, offsetX, offsetY); /* ── 6. Draw cards ── */ drawCards(svg, root, 0, offsetX, offsetY, element); scroller.appendChild(svg); wrapper.appendChild(scroller); fo.appendChild(wrapper); outerSvg.appendChild(fo); element.appendChild(outerSvg); } /* ── LAYOUT ──────────────────────────────────────────────── */ /** * computeLayout * ------------- * Assigns .layoutX and .layoutY to each node using a simple * Reingold-Tilford-style bottom-up / top-down pass: * • Leaf nodes are placed at sequential X positions. * • Parent X = midpoint of its leftmost and rightmost child X. * * visibleLeafIdx is shared state (passed by reference as an object). */ function computeLayout(node, depth, leafIdx) { node.layoutY = depth * (CONFIG.CARD_H + CONFIG.V_GAP); if (!node.expanded || node.children.length === 0) { /* Leaf in the current view — assign the next slot */ node.layoutX = leafIdx * (CONFIG.CARD_W + CONFIG.H_GAP); return leafIdx + 1; } var firstX = null, lastX = null; for (var i = 0; i < node.children.length; i++) { leafIdx = computeLayout(node.children[i], depth + 1, leafIdx); var childX = node.children[i].layoutX; if (firstX === null || childX < firstX) firstX = childX; if (lastX === null || childX > lastX) lastX = childX; } /* Centre parent over its children */ node.layoutX = (firstX + lastX) / 2; return leafIdx; } function measureBounds(node, bounds, depth) { if (node.layoutX < bounds.minX) bounds.minX = node.layoutX; if (node.layoutX > bounds.maxX) bounds.maxX = node.layoutX; if (depth > bounds.maxDepth) bounds.maxDepth = depth; if (node.expanded) { for (var i = 0; i < node.children.length; i++) { measureBounds(node.children[i], bounds, depth + 1); } } } /* ── DRAWING ─────────────────────────────────────────────── */ function cardCentreX(node, offsetX) { return node.layoutX + offsetX + CONFIG.CARD_W / 2; } function cardTop(node, offsetY) { return node.layoutY + offsetY; } function cardBottom(node, offsetY) { return cardTop(node, offsetY) + CONFIG.CARD_H; } /** * drawConnectors * -------------- * Draws elbow (right-angle) connector lines between a parent card * and each of its visible children. * * Parent bottom-centre → midpoint horizontal line → child top-centre */ function drawConnectors(svg, node, depth, offsetX, offsetY) { if (!node.expanded || node.children.length === 0) return; var pCX = cardCentreX(node, offsetX); var pBY = cardBottom(node, offsetY); var midY = pBY + CONFIG.V_GAP / 2; /* Vertical line from parent bottom to mid-row */ svg.appendChild(makeSvgEl('line', { x1: pCX, y1: pBY, x2: pCX, y2: midY, stroke: CONFIG.COLOR_CONNECTOR, 'stroke-width': '1.5' })); for (var i = 0; i < node.children.length; i++) { var child = node.children[i]; var cCX = cardCentreX(child, offsetX); var cTY = cardTop(child, offsetY); /* Horizontal segment at mid-row */ svg.appendChild(makeSvgEl('line', { x1: pCX, y1: midY, x2: cCX, y2: midY, stroke: CONFIG.COLOR_CONNECTOR, 'stroke-width': '1.5' })); /* Vertical drop from mid-row to child top */ svg.appendChild(makeSvgEl('line', { x1: cCX, y1: midY, x2: cCX, y2: cTY, stroke: CONFIG.COLOR_CONNECTOR, 'stroke-width': '1.5' })); drawConnectors(svg, child, depth + 1, offsetX, offsetY); } } /** * drawCards * --------- * Recursively draws a scorecard for every visible node. */ function drawCards(svg, node, depth, offsetX, offsetY, rootElement) { drawSingleCard(svg, node, depth, offsetX, offsetY, rootElement); if (node.expanded) { for (var i = 0; i < node.children.length; i++) { drawCards(svg, node.children[i], depth + 1, offsetX, offsetY, rootElement); } } } function drawSingleCard(svg, node, depth, offsetX, offsetY, rootElement) { var x = node.layoutX + offsetX; var y = node.layoutY + offsetY; var W = CONFIG.CARD_W; var H = CONFIG.CARD_H; var R = CONFIG.CARD_RADIUS; var AW = CONFIG.ACCENT_W; var PAD = CONFIG.CARD_PAD_X; var accent = CONFIG.COLORS_DEPTH[depth % CONFIG.COLORS_DEPTH.length]; /* ── Card background + border ── */ svg.appendChild(makeSvgEl('rect', { x: x, y: y, width: W, height: H, rx: R, ry: R, fill: CONFIG.COLOR_CARD_BG, stroke: CONFIG.COLOR_CARD_BORDER, 'stroke-width': '1', filter: 'url(#cardShadow)' })); /* ── Left accent stripe (clipped to card corners) ── */ svg.appendChild(makeSvgEl('rect', { x: x, y: y, width: AW, height: H, rx: R, ry: R, fill: accent })); /* Square off the right side of the accent stripe */ svg.appendChild(makeSvgEl('rect', { x: x + AW / 2, y: y, width: AW / 2, height: H, fill: accent })); /* ── Text content ── */ var tx = x + AW + PAD; var textW = W - AW - PAD * 2; /* Department name */ var nameEl = makeSvgEl('text', { x: tx, y: y + 16, 'font-size': '11px', 'font-family': CONFIG.FONT_FAMILY, 'font-weight': '600', fill: CONFIG.COLOR_LABEL, 'clip-path': 'url(#none)' }); nameEl.textContent = truncate(node.label, textW, '11px'); svg.appendChild(nameEl); /* Primary value (headline) */ var valEl = makeSvgEl('text', { x: x + W - PAD, y: y + 32, 'text-anchor': 'end', 'font-size': '14px', 'font-family': CONFIG.FONT_FAMILY, 'font-weight': '700', fill: CONFIG.COLOR_VALUE }); valEl.textContent = node.priFmt; svg.appendChild(valEl); /* Delta lines */ if (node.secValue !== 0 || node.hasPrev) { var deltaAbs = node.priValue - node.secValue; var deltaPct = node.secValue !== 0 ? ((deltaAbs / Math.abs(node.secValue)) * 100) : 0; var dColor = deltaAbs >= 0 ? CONFIG.COLOR_DELTA_POS : CONFIG.COLOR_DELTA_NEG; var sign = deltaAbs >= 0 ? '+' : ''; var dAbsEl = makeSvgEl('text', { x: x + W - PAD, y: y + 47, 'text-anchor': 'end', 'font-size': '10px', 'font-family': CONFIG.FONT_FAMILY, fill: dColor }); dAbsEl.textContent = 'Δ $ ' + sign + formatValue(deltaAbs); svg.appendChild(dAbsEl); var dPctEl = makeSvgEl('text', { x: x + W - PAD, y: y + 59, 'text-anchor': 'end', 'font-size': '10px', 'font-family': CONFIG.FONT_FAMILY, fill: dColor }); dPctEl.textContent = 'Δ (%) ' + sign + deltaPct.toFixed(2) + ' %'; svg.appendChild(dPctEl); } /* ── Sparkline ── */ var sparklinePoints = node.sparklineValues; if (sparklinePoints && sparklinePoints.length >= 2) { drawSparkline(svg, sparklinePoints, x + AW + PAD, y + H - CONFIG.SPARKLINE_H - 4, W - AW - PAD * 2, CONFIG.SPARKLINE_H); } /* ── Expand / Collapse button ── */ if (node.children.length > 0) { var btnX = x + W / 2; var btnY = y + H; var btnR = 8; var btnCircle = makeSvgEl('circle', { cx: btnX, cy: btnY, r: btnR, fill: node.expanded ? '#aaaaaa' : '#4a90d9', stroke: '#ffffff', 'stroke-width': '1.5', style: 'cursor:pointer' }); svg.appendChild(btnCircle); var btnText = makeSvgEl('text', { x: btnX, y: btnY, 'text-anchor': 'middle', 'dominant-baseline': 'middle', fill: '#ffffff', 'font-size': '13px', 'font-weight': 'bold', 'font-family': CONFIG.FONT_FAMILY, 'pointer-events': 'none' }); btnText.textContent = node.expanded ? '\u2212' : '+'; svg.appendChild(btnText); (function (targetNode) { btnCircle.addEventListener('click', function () { if (CONFIG.EXPAND_MODE === 'descendants') { setExpandedRecursive(targetNode, !targetNode.expanded); } else { targetNode.expanded = !targetNode.expanded; } _expandedIds[targetNode.id] = targetNode.expanded; render(); }); }(node)); } } /** * drawSparkline * ------------- * Draws a simple area sparkline inside the card using SVG path. */ function drawSparkline(svg, values, x, y, w, h) { var min = values[0], max = values[0]; for (var i = 1; i < values.length; i++) { if (values[i] < min) min = values[i]; if (values[i] > max) max = values[i]; } var range = max - min || 1; var step = w / (values.length - 1); var pts = []; for (var j = 0; j < values.length; j++) { var px = x + j * step; var py = y + h - ((values[j] - min) / range) * h; pts.push([px, py]); } /* Area fill */ var areaD = 'M' + pts[0][0] + ',' + (y + h); for (var k = 0; k < pts.length; k++) { areaD += ' L' + pts[k][0] + ',' + pts[k][1]; } areaD += ' L' + pts[pts.length - 1][0] + ',' + (y + h) + ' Z'; svg.appendChild(makeSvgEl('path', { d: areaD, fill: CONFIG.COLOR_SPARKLINE_FILL, stroke: 'none' })); /* Line */ var lineD = 'M' + pts[0][0] + ',' + pts[0][1]; for (var m = 1; m < pts.length; m++) { lineD += ' L' + pts[m][0] + ',' + pts[m][1]; } svg.appendChild(makeSvgEl('path', { d: lineD, fill: 'none', stroke: CONFIG.COLOR_SPARKLINE, 'stroke-width': '1.2', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })); } /* ── TREE CONSTRUCTION ───────────────────────────────────── */ /** * buildTree * --------- * Converts the flat Pyramid datapoints array into an in-memory tree. * * dp.coordinates[] * .caption – member label * .parentChip.index – drop-zone chip index (ensures level order) * * dp.numerics.value – primary (current) measure * .rawValue / .formattedValue * * dp.numerics.size[0] – secondary (previous) measure * .rawValue / .formattedValue * * dp.numerics.series[] – sparkline measure(s) * [].rawValue */ function buildTree(datapoints) { var root = makeNode('__root__', 'Grand Total', -1); root.expanded = true; for (var i = 0; i < datapoints.length; i++) { var dp = datapoints[i]; var coords = dp.coordinates || []; var priRaw = 0, priFmt = '—', secRaw = 0, secFmt = ''; var sparkRaw = []; if (dp.numerics && dp.numerics.value) { var pv = dp.numerics.value; if (typeof pv.rawValue === 'number') priRaw = pv.rawValue; if (typeof pv.formattedValue === 'string') priFmt = pv.formattedValue; } if (dp.numerics && dp.numerics.size && dp.numerics.size[0]) { var sv = dp.numerics.size[0]; if (typeof sv.rawValue === 'number') secRaw = sv.rawValue; if (typeof sv.formattedValue === 'string') secFmt = sv.formattedValue; } if (dp.numerics && dp.numerics.series) { for (var si = 0; si < dp.numerics.series.length; si++) { var sr = dp.numerics.series[si]; if (sr && typeof sr.rawValue === 'number') { sparkRaw.push(sr.rawValue); } } } /* Sort coordinates by chip index so hierarchy levels are in user order */ var sortedCoords = coords.slice().sort(function (a, b) { var ai = (a && a.parentChip && typeof a.parentChip.index === 'number') ? a.parentChip.index : 0; var bi = (b && b.parentChip && typeof b.parentChip.index === 'number') ? b.parentChip.index : 0; return ai - bi; }); var current = root; for (var d = 0; d < sortedCoords.length; d++) { var coord = sortedCoords[d]; var caption = (coord && typeof coord.caption === 'string' && coord.caption.trim() !== '') ? coord.caption.trim() : '(blank)'; var nodeId = current.id + '|' + caption; var child = findChild(current, nodeId); if (!child) { child = makeNode(nodeId, caption, d); child.expanded = (d === 0); current.children.push(child); } current = child; } /* Accumulate at leaf */ current.priValue += priRaw; current.secValue += secRaw; current.priFmt = priFmt; current.secFmt = secFmt; current.hasPrev = (secRaw !== 0); /* Append sparkline point */ if (sparkRaw.length > 0) { if (!current.sparklineValues) current.sparklineValues = []; for (var sp = 0; sp < sparkRaw.length; sp++) { current.sparklineValues.push(sparkRaw[sp]); } } } propagateValues(root); /* ── Strip redundant single-child wrapper nodes ───────────────────── * When a measure is placed in the Size (or Values) drop zone, Pyramid * injects the measure name as an extra coordinate level, producing one * or more intermediate nodes that each have exactly one child and carry * 100 % of the total (e.g. root → "Sales" → actual children). * We loop until the root genuinely branches (> 1 child) or until the * sole child is a true leaf (no grandchildren), promoting grandchildren * up to root each iteration and discarding the redundant wrapper. */ var safetyLimit = 10; while (root.children.length === 1 && root.children[0].children.length > 0 && safetyLimit-- > 0) { var wrapper = root.children[0]; root.children = wrapper.children; for (var pi = 0; pi < root.children.length; pi++) { root.children[pi].level = 0; } } applyExpandState(root); _initialized = true; return root; } function makeNode(id, label, level) { return { id: id, label: label, level: level, priValue: 0, secValue: 0, priFmt: '—', secFmt: '', hasPrev: false, sparklineValues: null, children: [], expanded: false, layoutX: 0, layoutY: 0 }; } function findChild(parent, id) { for (var i = 0; i < parent.children.length; i++) { if (parent.children[i].id === id) return parent.children[i]; } return null; } function propagateValues(node) { if (node.children.length === 0) return; for (var i = 0; i < node.children.length; i++) { propagateValues(node.children[i]); } var sumPri = 0, sumSec = 0; var sparkAgg = null; for (var j = 0; j < node.children.length; j++) { sumPri += node.children[j].priValue; sumSec += node.children[j].secValue; /* Aggregate sparkline: sum corresponding positions */ if (node.children[j].sparklineValues) { if (!sparkAgg) { sparkAgg = node.children[j].sparklineValues.slice(); } else { for (var k = 0; k < sparkAgg.length; k++) { sparkAgg[k] += (node.children[j].sparklineValues[k] || 0); } } } } node.priValue = sumPri; node.secValue = sumSec; node.priFmt = formatValue(sumPri); node.secFmt = sumSec > 0 ? formatValue(sumSec) : ''; node.hasPrev = sumSec !== 0; node.sparklineValues = sparkAgg; } function applyExpandState(node) { if (_initialized && _expandedIds.hasOwnProperty(node.id)) { node.expanded = _expandedIds[node.id]; } else { _expandedIds[node.id] = node.expanded; } for (var i = 0; i < node.children.length; i++) { applyExpandState(node.children[i]); } } function setExpandedRecursive(node, state) { node.expanded = state; _expandedIds[node.id] = state; for (var i = 0; i < node.children.length; i++) { setExpandedRecursive(node.children[i], state); } } /* ── VALUE FORMATTING ────────────────────────────────────── */ function formatValue(v) { if (v == null || isNaN(v)) return '—'; var abs = Math.abs(v); var sign = v < 0 ? '-' : ''; if (abs >= 1e9) return sign + (abs / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B'; if (abs >= 1e6) return sign + (abs / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M'; if (abs >= 1e3) return sign + (abs / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K'; return v.toLocaleString(); } /* ── TEXT TRUNCATION ─────────────────────────────────────── */ /** * Naively truncates text to approximate pixel width. * Average character width ≈ 0.55 × font-size-px for sans-serif. */ function truncate(text, maxW, fontSize) { var px = parseInt(fontSize, 10) || 11; var avg = px * 0.58; var max = Math.floor(maxW / avg); if (text.length <= max) return text; return text.substring(0, max - 1) + '…'; } /* ── SVG UTILITY ─────────────────────────────────────────── */ function makeSvgEl(tag, attrs) { var el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (var k in attrs) { if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]); } return el; }