// @ts-check
/**
* @import {ExpressRequestAuthorized, ExpressResponse} from './../../types.js'
*/
import { query, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import pako from 'pako';
import { Buffer } from 'buffer';
import { getConfig } from '../../utils/compileTemplates.js';
import { errorLoggerRead } from '../../utils/requestLogger.js';
/**
* Builds a Graphviz-compatible, quoted label.
* @param {string} value
* @returns {string}
*/
const quoteGraphviz = (value) => {
const safe = String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n');
return `"${safe}"`;
};
/**
* Wraps text into short lines to keep Graphviz labels compact.
* @param {string} value
* @param {number} maxLineLength
* @returns {string}
*/
const wrapLabel = (value, maxLineLength = 16) => {
const words = String(value ?? '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '';
const lines = [];
let current = words[0];
for (const word of words.slice(1)) {
if (`${current} ${word}`.length <= maxLineLength) {
current = `${current} ${word}`;
} else {
lines.push(current);
current = word;
}
}
lines.push(current);
return lines.join('\n');
};
/**
* Builds a compact node label for tree rendering.
* @param {any} node
* @param {string} fallback
* @returns {string}
*/
const buildCompactNodeLabel = (node, fallback) => {
if (!node) return fallback;
const title = String(node.Title || '').trim();
const display = String(node.Display || '').trim();
const memberCount = Number.parseInt(String(node.memberCount ?? 0), 10) || 0;
if (title && display && title !== display) {
return `${wrapLabel(title, 16)}\n${wrapLabel(display, 16)}\n#${memberCount}`;
}
const name = title || display || fallback;
return `${wrapLabel(name, 16)}\n#${memberCount}`;
};
/**
* Validates a Graphviz color value.
* @param {any} color
* @returns {string | null}
*/
const sanitizeGraphvizColor = (color) => {
if (typeof color !== 'string') return null;
const value = color.trim();
if (!value) return null;
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)) return value;
if (/^[a-zA-Z][a-zA-Z0-9_-]{1,30}$/.test(value)) return value;
return null;
};
/**
* Resolves hierarchy colors from merged config.
* @param {any} config
* @returns {any[]|Object|null}
*/
const getHierarchyColorConfig = (config) => {
return config?.HierarchiColor
|| config?.HierarchieColor
|| config?.HierarchyColor
|| config?.UIconfig?.HierarchiColor
|| config?.UIconfig?.HierarchieColor
|| config?.UIconfig?.HierarchyColor
|| null;
};
/**
* Resolves stage colors from merged config.
* @param {any} config
* @returns {any[]|Object|null}
*/
const getStageColorConfig = (config) => {
return config?.StageColor
|| config?.UIStageColor
|| config?.UIconfig?.StageColor
|| config?.UIconfig?.UIStageColor
|| null;
};
const defaultHierarchyPalette = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
];
/**
* Returns border color for a group hierarchy from config/palette.
* @param {number|string|null|undefined} hierarchie
* @param {any[]|Object|null} hierarchyColors
* @returns {string}
*/
const getHierarchyBorderColor = (hierarchie, hierarchyColors) => {
const h = Number(hierarchie);
const fallback = defaultHierarchyPalette[Math.abs(Number.isNaN(h) ? 0 : h) % defaultHierarchyPalette.length];
if (hierarchyColors == null) return fallback;
if (Array.isArray(hierarchyColors)) {
const idx = Number.isNaN(h) ? -1 : h;
const color = hierarchyColors[idx];
if (typeof color === 'object' && color !== null) {
const objectColor = color.color || color.Color || color.borderColor || color.BorderColor || color.frameColor || color.FrameColor;
return sanitizeGraphvizColor(objectColor) || fallback;
}
return sanitizeGraphvizColor(color) || fallback;
}
if (typeof hierarchyColors === 'object') {
const hc = /** @type {any} */ (hierarchyColors);
const color = hc[String(hierarchie)] ?? hc[h];
return sanitizeGraphvizColor(color) || fallback;
}
return fallback;
};
/**
* @param {number|string|null|undefined} stage
* @param {any[]|Object|null} stageColors
* @returns {string}
*/
const getStageDotColor = (stage, stageColors) => {
const fallback = '#808080';
const s = Number(stage);
if (stageColors == null) return fallback;
if (Array.isArray(stageColors)) {
const idx = Number.isNaN(s) ? -1 : s;
const color = stageColors[idx];
if (typeof color === 'object' && color !== null) {
const objectColor = color.color || color.Color || color.dotColor || color.DotColor;
return sanitizeGraphvizColor(objectColor) || fallback;
}
return sanitizeGraphvizColor(color) || fallback;
}
if (typeof stageColors === 'object') {
const sc = /** @type {any} */ (stageColors);
const color = sc[String(stage)] ?? sc[s];
return sanitizeGraphvizColor(color) || fallback;
}
return fallback;
};
/** @param {string} value */
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
/**
* Encodes Graphviz text for Kroki using deflate + base64url.
* @param {string} diagramSource
* @returns {string}
*/
const encodeKrokiGraph = (diagramSource) => {
const data = Buffer.from(diagramSource, 'utf8');
const compressed = pako.deflate(data, { level: 9 });
return Buffer.from(compressed)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
};
const GROUP_LINKS_QUERY = `SELECT Links.UID, Links.UIDTarget, Links.Type
FROM Links
INNER JOIN ObjectBase AS Source ON (Source.UID=Links.UID AND Source.Type IN ('group','ggroup'))
INNER JOIN ObjectBase AS Target ON (Target.UID=Links.UIDTarget AND Target.Type IN ('group','ggroup'))
INNER JOIN Visible AS SourceVisible ON (SourceVisible.UID=Links.UID AND SourceVisible.UIDUser=?)
INNER JOIN Visible AS TargetVisible ON (TargetVisible.UID=Links.UIDTarget AND TargetVisible.UIDUser=?)
WHERE Links.Type IN ('memberA','memberS')
AND Links.UIDTarget IN (?)`;
/**
* Loads one traversal layer for the current frontier.
* @param {Buffer} userUID
* @param {Buffer[]} frontier
*/
const queryGroupLinksForFrontier = async (userUID, frontier) => {
if (frontier.length === 0) {
// Nothing to expand in this recursion step.
return [];
}
// Batch all nodes of the current level in one SQL call via IN (?).
return query(GROUP_LINKS_QUERY, [userUID, userUID, frontier]);
};
/**
* Applies one traversal layer and computes the next frontier.
* @param {{
* links: any[],
* frontierHexSet: Set<string>,
* seenNodes: Set<string>,
* edges: Map<string, any>,
* }} params
* @returns {Buffer[]}
*/
const processTraversalLayer = ({ links, frontierHexSet, seenNodes, edges }) => {
// Map key (hex UID) avoids duplicate entries and preserves Buffer value.
const nextFrontierMap = new Map();
for (const link of links) {
const childHex = link.UID.toString('hex');
const parentHex = link.UIDTarget.toString('hex');
const edgeKey = `${childHex}:${link.Type}:${parentHex}`;
if (!edges.has(edgeKey)) {
// Keep only unique edges across all recursion steps.
edges.set(edgeKey, link);
}
if (link.Type === 'memberA') {
// memberA traversal follows UIDTarget -> UID (down from current frontier).
if (!seenNodes.has(childHex)) {
seenNodes.add(childHex);
nextFrontierMap.set(childHex, link.UID);
}
continue;
}
// memberS traversal also follows UIDTarget -> UID from the current frontier.
if (!seenNodes.has(childHex)) {
seenNodes.add(childHex);
}
if (!seenNodes.has(parentHex)) {
seenNodes.add(parentHex);
}
if (frontierHexSet.has(parentHex)) {
// Keep expansion strictly anchored to the current frontier level.
nextFrontierMap.set(childHex, link.UID);
}
}
return Array.from(nextFrontierMap.values());
};
/**
* Recursively traverses group links level-by-level.
* Keeps route logic readable while preserving the existing batched SQL behavior.
* @param {{
* userUID: Buffer,
* frontier: Buffer[],
* seenNodes: Set<string>,
* edges: Map<string, any>,
* depth?: number,
* maxDepth?: number,
* }} params
*/
const traverseGroupGraphRecursive = async ({
userUID,
frontier,
seenNodes,
edges,
depth = 0,
maxDepth = 500,
}) => {
if (frontier.length === 0 || depth >= maxDepth) {
// Stop when no work is left or safety depth is reached.
return;
}
const frontierHexSet = new Set(frontier.map(uid => uid.toString('hex')));
const links = await queryGroupLinksForFrontier(userUID, frontier);
const nextFrontier = processTraversalLayer({ links, frontierHexSet, seenNodes, edges });
await traverseGroupGraphRecursive({
userUID,
frontier: nextFrontier,
seenNodes,
edges,
depth: depth + 1,
maxDepth,
});
};
/**
* Builds adjacency maps for fast traversal.
* - memberA is directed: parent -> child
* - memberS is undirected: sibling <-> sibling
* @param {{childHex: string, parentHex: string, type: string}[]} orientedEdges
*/
const buildNodeAdjacency = (orientedEdges) => {
const memberSNeighbors = new Map();
const childrenByMemberA = new Map();
/** @param {Map<string, string[]>} map @param {string} fromHex @param {string} toHex */
const addNeighbor = (map, fromHex, toHex) => {
if (!map.has(fromHex)) {
map.set(fromHex, []);
}
const list = map.get(fromHex);
if (list) {
list.push(toHex);
}
};
for (const edge of orientedEdges) {
const parentHex = edge.parentHex;
const childHex = edge.childHex;
if (edge.type === 'memberS') {
// Sibling relationship is undirected in traversal terms.
addNeighbor(memberSNeighbors, parentHex, childHex);
addNeighbor(memberSNeighbors, childHex, parentHex);
continue;
}
// memberA is directed parent -> child.
addNeighbor(childrenByMemberA, parentHex, childHex);
}
return { memberSNeighbors, childrenByMemberA };
};
/**
* Creates a hierarchy lookup function for nodes.
* @param {Map<string, any>} nodeMap
* @returns {(uidHex: string) => number | null}
*/
const createNodeHierarchyLookup = (nodeMap) => {
/** @param {string} uidHex */
return (uidHex) => {
const node = nodeMap.get(uidHex);
if (!node) return null;
const value = Number.parseInt(String(node.hierarchie ?? ''), 10);
return Number.isFinite(value) ? value : null;
};
};
/**
* Detects nodes that violate the expected memberA hierarchy step (+1).
* @param {{childHex: string, parentHex: string, type: string}[]} orientedEdges
* @param {(uidHex: string) => number } getNodeHierarchy
*/
const detectOutOfHierarchyNodes = (orientedEdges, getNodeHierarchy) => {
/** @type {Map<string, {hasValidParentStep: boolean, hasInvalidParentStep: boolean}>} */
const memberAStateByChild = new Map();
for (const edge of orientedEdges) {
if (edge.type !== 'memberA') {
continue;
}
const parentHierarchy = getNodeHierarchy(edge.parentHex);
const childHierarchy = getNodeHierarchy(edge.childHex);
const delta = childHierarchy - parentHierarchy;
if (!memberAStateByChild.has(edge.childHex)) {
memberAStateByChild.set(edge.childHex, {
hasValidParentStep: false,
hasInvalidParentStep: false,
});
}
const childState = memberAStateByChild.get(edge.childHex);
if (!childState) {
continue;
}
if (delta === 1) {
childState.hasValidParentStep = true;
} else {
childState.hasInvalidParentStep = true;
}
}
const outOfHierarchyNodeSet = new Set();
for (const [childHex, childState] of memberAStateByChild.entries()) {
if (childState.hasInvalidParentStep && !childState.hasValidParentStep) {
// Mark as out-of-hierarchy only when no valid +1 parent exists.
outOfHierarchyNodeSet.add(childHex);
}
}
return outOfHierarchyNodeSet;
};
/**
* Returns memberA rank increment from parent to child.
* Invalid hierarchy transitions return null and are omitted.
* @param {string} parentHex
* @param {string} childHex
* @param {(uidHex: string) => number} getNodeHierarchy
* @returns {number | null}
*/
const getMemberAStep = (parentHex, childHex, getNodeHierarchy) => {
const parentHierarchy = getNodeHierarchy(parentHex);
const childHierarchy = getNodeHierarchy(childHex);
const delta = childHierarchy - parentHierarchy;
if (delta <= 0) {
return null;
}
return delta;
};
/**
* Expands one memberS-connected band and computes local ranks from roots.
* @param {Map<string, number>} roots
* @param {(uidHex: string) => string[]} getNeighbors
*/
const expandMemberSBand = (roots, getNeighbors) => {
/** @type {Map<string, number>} */
const bandRanks = new Map();
let frontier = new Map(roots);
while (frontier.size > 0) {
/** @type {Map<string, number>} */
const nextFrontier = new Map();
for (const [uidHex, rank] of Array.from(frontier.entries())) {
const known = bandRanks.get(uidHex);
if (known !== undefined && known <= rank) {
// Already reached with a shorter or equal path.
continue;
}
bandRanks.set(uidHex, rank);
const neighbors = getNeighbors(uidHex);
for (const siblingHex of neighbors) {
const nextRank = rank + 1;
const existingNext = nextFrontier.get(siblingHex);
if (existingNext === undefined || nextRank < existingNext) {
// Track shortest local distance inside the sibling band.
nextFrontier.set(siblingHex, nextRank);
}
}
}
frontier = nextFrontier;
}
return bandRanks;
};
/**
* Computes rank levels for nodes by traversing memberS bands and memberA jumps.
* @param {{
* topAncestorHex: string,
* rootHierarchy: number,
* childrenByMemberA: Map<string, string[]>,
* memberSNeighbors: Map<string, string[]>,
* getNodeHierarchy: (uidHex: string) => number,
* displayLevelRows: number,
* }} params
*/
const computeNodeLevels = ({
topAncestorHex,
rootHierarchy,
childrenByMemberA,
memberSNeighbors,
getNodeHierarchy,
displayLevelRows,
}) => {
/** @type {Map<string, number>} */
const levels = new Map();
/** @param {string} uidHex */
const getNeighbors = (uidHex) => memberSNeighbors.get(uidHex) || [];
const rootsByHierarchy = new Map();
// Normal mode: process one hierarchy bucket after another.
rootsByHierarchy.set(rootHierarchy, new Map([[topAncestorHex, 0]]));
const processedHierarchies = new Set();
let offset = 0;
while (true) {
if (offset >= displayLevelRows) {
// All following hierarchy buckets would start at or below hidden levels.
break;
}
const pending = Array.from(rootsByHierarchy.keys())
.filter(h => !processedHierarchies.has(h))
.sort((a, b) => a - b);
if (pending.length === 0) {
break;
}
const currentHierarchy = pending[0];
const roots = rootsByHierarchy.get(currentHierarchy) || new Map();
processedHierarchies.add(currentHierarchy);
if (roots.size === 0) {
continue;
}
// Keep memberS expansion inside the active hierarchy bucket.
const bandRanks = expandMemberSBand(
roots,
(uidHex) => getNeighbors(uidHex)
.filter((neighborHex) => getNodeHierarchy(neighborHex) === currentHierarchy),
);
if (bandRanks.size === 0) {
continue;
}
let maxLocalRank = 0;
for (const [uidHex, localRank] of Array.from(bandRanks.entries())) {
const globalRank = offset + localRank;
const knownGlobal = levels.get(uidHex);
if (knownGlobal === undefined || globalRank < knownGlobal) {
levels.set(uidHex, globalRank);
}
if (localRank > maxLocalRank) {
maxLocalRank = localRank;
}
}
for (const [fatherHex, fatherLocalRank] of Array.from(bandRanks.entries())) {
const childrenA = childrenByMemberA.get(fatherHex) || [];
for (const childHex of childrenA) {
if (levels.has(childHex)) {
continue;
}
const memberAStep = getMemberAStep(fatherHex, childHex, getNodeHierarchy);
if (memberAStep === null) {
continue;
}
const childHierarchy = getNodeHierarchy(childHex);
if (childHierarchy <= currentHierarchy) {
continue;
}
if (!rootsByHierarchy.has(childHierarchy)) {
rootsByHierarchy.set(childHierarchy, new Map());
}
const nextRoots = rootsByHierarchy.get(childHierarchy);
if (nextRoots) {
const existingLocal = nextRoots.get(childHex);
if (existingLocal === undefined || fatherLocalRank < existingLocal) {
// Delay child processing to its hierarchy bucket.
nextRoots.set(childHex, fatherLocalRank);
}
}
}
}
offset += maxLocalRank + 1;
}
return levels;
};
/**
* Assigns extra levels for out-of-hierarchy nodes when explicitly requested.
* @param {{
* levels: Map<string, number>,
* outOfHierarchyNodeSet: Set<string>,
* seenNodes: Set<string>,
* displayLevelRows: number,
* getNodeHierarchy: (uidHex: string) => number,
* }} params
*/
const appendOutOfHierarchyLevels = ({
levels,
outOfHierarchyNodeSet,
seenNodes,
displayLevelRows,
getNodeHierarchy,
}) => {
// Only place extra nodes that are not already shown in main visible levels.
const extraOutOfHierarchyNodes = Array.from(outOfHierarchyNodeSet)
.filter((uidHex) => {
if (!seenNodes.has(uidHex)) return false;
const currentLevel = levels.get(uidHex);
return currentLevel === undefined || currentLevel >= displayLevelRows;
});
const hierarchies = extraOutOfHierarchyNodes
.map(uidHex => getNodeHierarchy(uidHex))
.filter((h) => Number.isFinite(h));
/** @type {number[]} */
const numericHierarchies = /** @type {number[]} */ (hierarchies);
const minHierarchy = numericHierarchies.length > 0 ? Math.min(...numericHierarchies) : null;
// Keep out-of-hierarchy nodes below the configured display levels.
const baseOutOfHierarchyRank = displayLevelRows + 1;
let fallbackOffset = 0;
for (const uidHex of extraOutOfHierarchyNodes) {
const hierarchy = getNodeHierarchy(uidHex);
if (minHierarchy !== null && hierarchy !== null && Number.isFinite(hierarchy)) {
levels.set(uidHex, baseOutOfHierarchyRank + (hierarchy - minHierarchy));
} else {
levels.set(uidHex, baseOutOfHierarchyRank + fallbackOffset);
fallbackOffset += 1;
}
}
};
/**
* Detects visible nodes from computed levels and hierarchy flags.
* @param {{
* levels: Map<string, number>,
* outOfHierarchyNodeSet: Set<string>,
* memberSLinkedNodeSet: Set<string>,
* includeOutOfHierarchy: boolean,
* displayLevelRows: number,
* seenNodes: Set<string>,
* topAncestorHex: string,
* }} params
*/
const detectVisibleNodes = ({
levels,
outOfHierarchyNodeSet,
memberSLinkedNodeSet,
includeOutOfHierarchy,
displayLevelRows,
seenNodes,
topAncestorHex,
}) => {
/** @type {Set<string>} */
const visibleNodeSet = new Set();
// Base visibility: ranked nodes inside display range.
for (const [uidHex, level] of levels.entries()) {
const isOutOfHierarchy = outOfHierarchyNodeSet.has(uidHex);
const isMemberSLinked = memberSLinkedNodeSet.has(uidHex);
if (includeOutOfHierarchy && isOutOfHierarchy) {
visibleNodeSet.add(uidHex);
continue;
}
if (isOutOfHierarchy && !isMemberSLinked) {
continue;
}
if (level < displayLevelRows) {
visibleNodeSet.add(uidHex);
}
}
if (includeOutOfHierarchy) {
// Optional mode: force-show out-of-hierarchy nodes discovered during traversal.
for (const uidHex of Array.from(outOfHierarchyNodeSet)) {
if (seenNodes.has(uidHex)) {
visibleNodeSet.add(uidHex);
}
}
}
if (!visibleNodeSet.has(topAncestorHex)) {
// Root should always be visible for orientation.
visibleNodeSet.add(topAncestorHex);
}
return visibleNodeSet;
};
/**
* Filters edges to only those that should be rendered.
* @param {{
* orientedEdges: {childHex: string, parentHex: string, type: string}[],
* visibleNodeSet: Set<string>,
* includeOutOfHierarchy: boolean,
* outOfHierarchyNodeSet: Set<string>,
* getNodeHierarchy: (uidHex: string) => number,
* }} params
*/
const filterVisibleEdges = ({
orientedEdges,
visibleNodeSet,
includeOutOfHierarchy,
outOfHierarchyNodeSet,
getNodeHierarchy,
}) => {
return orientedEdges.filter((edge) => {
if (!visibleNodeSet.has(edge.childHex) || !visibleNodeSet.has(edge.parentHex)) {
return false;
}
if (edge.type === 'memberA') {
if (!includeOutOfHierarchy && outOfHierarchyNodeSet.has(edge.childHex)) {
return false;
}
// Keep only forward hierarchy transitions for memberA edges.
return getMemberAStep(edge.parentHex, edge.childHex, getNodeHierarchy) !== null;
}
return true;
});
};
export const __krokiTreeInternals = {
detectOutOfHierarchyNodes,
detectVisibleNodes,
filterVisibleEdges,
};
/**
* Gets a Kroki graph representation of groups connected via memberA/memberS links.
* @param {ExpressRequestAuthorized} req - Express request object
* @param {ExpressResponse} res - Express response object
*/
export const getGroupTreeGraph = async (req, res) => {
try {
const startUID = UUID2hex(req.params.UID);
const userUID = UUID2hex(req.session?.user);
if (!startUID) {
res.status(300).json({ success: false, message: 'invalid group UID' });
return;
}
if (!userUID) {
res.status(403).json({ success: false, message: 'missing user session' });
return;
}
const [startGroup] = await query(
`SELECT UID
FROM ObjectBase
WHERE UID=? AND Type IN ('group','ggroup')`,
[startUID],
);
if (!startGroup) {
res.status(300).json({ success: false, message: 'invalid group UID' });
return;
}
const requestedDisplayLevels = Number.parseInt(
String(req.query.displayLevels ?? req.query.levels ?? '6'),
10,
);
const displayLevelRows = Number.isFinite(requestedDisplayLevels) && requestedDisplayLevels > 0
? requestedDisplayLevels
: 6;
const includeOutOfHierarchyRaw = req.query.includeOutOfHierarchy ?? 'false';
const includeOutOfHierarchy = ['1', 'true', 'yes'].includes(
String(includeOutOfHierarchyRaw).toLowerCase(),
);
const config = await getConfig('db', req);
const hierarchyColors = getHierarchyColorConfig(config);
const stageColors = getStageColorConfig(config);
const seenNodes = new Set([startUID.toString('hex')]);
const edges = new Map();
// Traversal populates seenNodes + edges starting from the requested root.
await traverseGroupGraphRecursive({
userUID,
frontier: [startUID],
seenNodes,
edges,
maxDepth: 500,
});
const nodeUIDs = Array.from(seenNodes).map(hex => Buffer.from(hex, 'hex'));
// Load all discovered visible group objects in a single query.
const nodes = await query(
`SELECT ObjectBase.UID, ObjectBase.Title, Member.Display, ObjectBase.dindex, ObjectBase.hierarchie, ObjectBase.stage
FROM ObjectBase
INNER JOIN Member ON (Member.UID=ObjectBase.UID)
INNER JOIN Visible ON (Visible.UID=ObjectBase.UID AND Visible.UIDUser=?)
WHERE ObjectBase.UID IN (?) AND ObjectBase.Type IN ('group','ggroup')`,
[userUID, nodeUIDs],
);
const memberCounts = nodeUIDs.length > 0
? await query(
// Count person members per discovered group.
`SELECT Links.UIDTarget AS UID, COUNT(Links.UID) AS memberCount
FROM Links
INNER JOIN ObjectBase AS PObjects ON (PObjects.UID=Links.UID AND PObjects.Type='person')
WHERE Links.Type IN ('member','memberA') AND Links.UIDTarget IN (?)
GROUP BY Links.UIDTarget`,
[nodeUIDs],
)
: [];
const memberCountByUid = new Map(
memberCounts.map(row => [
row.UID.toString('hex'),
Number.parseInt(String(row.memberCount ?? 0), 10) || 0,
]),
);
const nodesWithCounts = nodes.map(node => ({
...node,
memberCount: memberCountByUid.get(node.UID.toString('hex')) || 0,
}));
const nodeMap = new Map(nodesWithCounts.map(n => [n.UID.toString('hex'), n]));
const rootHex = startUID.toString('hex');
/** @param {string} uidHex */
const nodeId = (uidHex) => `N_${uidHex}`;
const orientedEdges = Array.from(edges.values()).map((edge) => ({
childHex: edge.UID.toString('hex'),
parentHex: edge.UIDTarget.toString('hex'),
type: edge.Type,
}));
const {
memberSNeighbors,
childrenByMemberA,
} = buildNodeAdjacency(orientedEdges);
const topAncestorHex = rootHex;
const getNodeHierarchyNullable = createNodeHierarchyLookup(nodeMap);
/** @param {string} uidHex */
const getNodeHierarchy = (uidHex) => {
const hierarchy = getNodeHierarchyNullable(uidHex);
if (hierarchy === null) {
throw new Error(`missing hierarchy for group ${uidHex}`);
}
return hierarchy;
};
for (const uidHex of Array.from(nodeMap.keys())) {
getNodeHierarchy(uidHex);
}
const rootHierarchy = getNodeHierarchy(topAncestorHex);
const outOfHierarchyNodeSet = detectOutOfHierarchyNodes(orientedEdges, getNodeHierarchy);
const memberSLinkedNodeSet = new Set(
orientedEdges
.filter(edge => edge.type === 'memberS')
.flatMap(edge => [edge.parentHex, edge.childHex]),
);
const levels = computeNodeLevels({
topAncestorHex,
rootHierarchy,
childrenByMemberA,
memberSNeighbors,
getNodeHierarchy,
displayLevelRows,
});
if (includeOutOfHierarchy) {
appendOutOfHierarchyLevels({
levels,
outOfHierarchyNodeSet,
seenNodes,
displayLevelRows,
getNodeHierarchy,
});
}
const visibleNodeSet = detectVisibleNodes({
levels,
outOfHierarchyNodeSet,
memberSLinkedNodeSet,
includeOutOfHierarchy,
displayLevelRows,
seenNodes,
topAncestorHex,
});
const visibleEdges = filterVisibleEdges({
orientedEdges,
visibleNodeSet,
includeOutOfHierarchy,
outOfHierarchyNodeSet,
getNodeHierarchy,
});
const visibleRanks = Array.from(visibleNodeSet).map(uidHex => levels.get(uidHex) ?? 0);
const maxVisibleRank = Math.max(
0,
displayLevelRows - 1,
visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0,
);
const maxEntriesPerRankLine = 8;
const rankGroups = new Map();
for (const [uidHex, level] of Array.from(levels.entries())) {
if (!visibleNodeSet.has(uidHex)) continue;
if (!rankGroups.has(level)) {
rankGroups.set(level, []);
}
// Group nodes by absolute level for Graphviz rank=same blocks.
rankGroups.get(level).push(nodeId(uidHex));
}
/** @type {{ anchorId: string, nodeIds: string[], slotIds: string[], padNodeIds: string[] }[][]} */
const rankRowsByLevel = [];
for (let level = 0; level <= maxVisibleRank; level += 1) {
const nodeIds = rankGroups.get(level) || [];
if (nodeIds.length === 0) {
rankRowsByLevel.push([{ anchorId: `RANK_${level}`, nodeIds: [], slotIds: [], padNodeIds: [] }]);
continue;
}
const chunks = [];
for (let index = 0; index < nodeIds.length; index += maxEntriesPerRankLine) {
chunks.push(nodeIds.slice(index, index + maxEntriesPerRankLine));
}
const levelRows = chunks.map((chunk, chunkIndex) => {
const anchorId = chunkIndex === 0
? `RANK_${level}`
: `RANK_${level}_I_${chunkIndex}`;
const padCount = Math.max(0, maxEntriesPerRankLine - chunk.length);
const padNodeIds = Array.from({ length: padCount }, (_, padIdx) => `${anchorId}_PAD_${padIdx}`);
const slotIds = [...chunk, ...padNodeIds];
return {
anchorId,
nodeIds: chunk,
slotIds,
padNodeIds,
};
});
rankRowsByLevel.push(levelRows);
}
const rankRows = rankRowsByLevel.flat();
const rankAnchorIds = rankRows.map(row => row.anchorId);
const rankAnchorNodes = rankAnchorIds
.map(anchorId => `${anchorId} [shape=point, width=0, height=0, label="", style=invis]`)
.join('\n');
const rankPadNodes = rankRows
.flatMap(row => row.padNodeIds)
.map(padNodeId => `${padNodeId} [shape=point, width=0, height=0, label="", style=invis]`)
.join('\n');
/** @type {string[]} */
const rankAnchorEdgeList = [];
for (const levelRows of rankRowsByLevel) {
for (let idx = 0; idx < levelRows.length - 1; idx += 1) {
const from = levelRows[idx].anchorId;
const to = levelRows[idx + 1].anchorId;
// Force wrapped rows to actually stack on separate ranks.
rankAnchorEdgeList.push(`${from} -> ${to} [style=invis, weight=80, minlen=1, constraint=true]`);
}
}
for (let level = 0; level < rankRowsByLevel.length - 1; level += 1) {
const currentLevelRows = rankRowsByLevel[level];
const nextLevelRows = rankRowsByLevel[level + 1];
const from = currentLevelRows[currentLevelRows.length - 1].anchorId;
const to = nextLevelRows[0].anchorId;
// Keep larger spacing only between logical levels.
rankAnchorEdgeList.push(`${from} -> ${to} [style=invis, weight=120, minlen=2, constraint=true]`);
}
const rankAnchorEdges = rankAnchorEdgeList.join('\n');
/** @type {string[]} */
const rankAlignmentEdgeList = [];
for (const levelRows of rankRowsByLevel) {
for (const row of levelRows) {
for (let idx = 0; idx < row.slotIds.length - 1; idx += 1) {
const from = row.slotIds[idx];
const to = row.slotIds[idx + 1];
// Keep slot order left-to-right inside each wrapped row.
rankAlignmentEdgeList.push(`${from} -> ${to} [style=invis, weight=120, minlen=1, constraint=true]`);
}
}
for (let rowIndex = 0; rowIndex < levelRows.length - 1; rowIndex += 1) {
const currentRow = levelRows[rowIndex];
const nextRow = levelRows[rowIndex + 1];
const sharedSlots = Math.min(currentRow.slotIds.length, nextRow.slotIds.length);
for (let slotIndex = 0; slotIndex < sharedSlots; slotIndex += 1) {
const from = currentRow.slotIds[slotIndex];
const to = nextRow.slotIds[slotIndex];
// Align wrapped rows into a consistent column grid to avoid large indent.
rankAlignmentEdgeList.push(`${from} -> ${to} [style=invis, weight=140, minlen=1, constraint=true]`);
}
}
}
const rankAlignmentEdges = rankAlignmentEdgeList.join('\n');
const rankBlocks = rankRows
.map((row) => {
const rankItems = [row.anchorId, ...(row.slotIds.length > 0 ? row.slotIds : row.nodeIds)];
return ` { rank=same; ${rankItems.join('; ')}; }`;
})
.join('\n');
const graphNodes = Array.from(visibleNodeSet)
.map((uidHex) => {
const node = nodeMap.get(uidHex);
const label = buildCompactNodeLabel(node, uidHex);
const borderColor = getHierarchyBorderColor(node?.hierarchie, hierarchyColors);
const stageDotColor = getStageDotColor(node?.stage, stageColors);
const htmlLines = label
.split('\n')
.map(line => escapeHtml(line))
.join('<BR ALIGN="LEFT"/>');
const groupUUID = String(node?.UID ? HEX2uuid(node.UID) : (HEX2uuid(Buffer.from(uidHex, 'hex')) || ''));
const nodeHref = `group:///${groupUUID}`;
const baseAttrs = [
'shape=box',
`label=<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0"><TR><TD ALIGN="LEFT"><FONT COLOR="${stageDotColor}">●</FONT></TD><TD ALIGN="LEFT">${htmlLines}</TD></TR></TABLE>>`,
'style="rounded,filled"',
`color="${borderColor}"`,
'fillcolor="white"',
'penwidth=2',
`href=${quoteGraphviz(nodeHref)}`,
`URL=${quoteGraphviz(nodeHref)}`,
`tooltip=${quoteGraphviz(groupUUID)}`,
'target="_top"',
];
if (uidHex === rootHex) {
baseAttrs[2] = 'style="rounded,filled,bold"';
baseAttrs.push('fillcolor="lightyellow"');
}
return `${nodeId(uidHex)} [${baseAttrs.join(',')}]`;
})
.join('\n');
const graphEdges = visibleEdges
.map((edge) => {
const childNodeId = nodeId(edge.childHex);
const parentNodeId = nodeId(edge.parentHex);
if (edge.type === 'memberS') {
// Visual convention: memberS as dashed blue.
return `${parentNodeId} -> ${childNodeId} [color="dodgerblue3", style="dashed", minlen=1, weight=3, constraint=true]`;
}
// Visual convention: memberA as solid dark edge.
return `${parentNodeId} -> ${childNodeId} [color="gray20", style="solid", minlen=2, weight=8, constraint=true]`;
})
.join('\n');
const diagramSource = `digraph GroupTree {
rankdir=TB;
graph [fontsize=10, ranksep="0.7 equally"];
node [fontsize=10];
edge [fontsize=9];
${rankAnchorNodes}
${rankPadNodes}
${rankAnchorEdges}
${rankAlignmentEdges}
{ rank=min; RANK_0; ${nodeId(topAncestorHex)}; }
${rankBlocks}
${graphNodes}
${graphEdges}
}`;
const encoded = encodeKrokiGraph(diagramSource);
const type = ['svg', 'png', 'pdf'].includes(String(req.query.type))
? String(req.query.type)
: 'svg';
const publicKrokiBase = process.env.KrokiPublicUrl || process.env.KrokiUrl || 'https://kroki.io';
const internalKrokiBase = process.env.KrokiInternalUrl || process.env.KrokiProxyUrl || 'http://10.1.1.15:8000';
const krokiUrl = `${publicKrokiBase}/graphviz/${type}/${encoded}`;
const internalKrokiUrl = `${internalKrokiBase}/graphviz/${type}/${encoded}`;
/**
* Fetches rendered content from the internal Kroki server.
* @returns {Promise<{payload: Buffer, contentType: string} | null>}
*/
const fetchKrokiPayload = async () => {
try {
const krokiResponse = await fetch(internalKrokiUrl);
if (!krokiResponse.ok) {
return null;
}
const contentType = krokiResponse.headers.get('content-type')
|| (type === 'png' ? 'image/png' : type === 'pdf' ? 'application/pdf' : 'image/svg+xml');
const payload = Buffer.from(await krokiResponse.arrayBuffer());
return { payload, contentType };
} catch (proxyError) {
errorLoggerRead(proxyError);
return null;
}
};
const redirectValue = String(req.query.redirect ?? 'false').toLowerCase();
const shouldRedirect = ['1', 'true', 'yes'].includes(redirectValue);
const krokiResult = await fetchKrokiPayload();
if (shouldRedirect) {
if (!krokiResult) {
res.status(502).json({ success: false, message: 'kroki proxy unavailable' });
return;
}
res.set('Content-Type', krokiResult.contentType);
res.set('Cache-Control', 'private, max-age=60');
res.status(200).send(krokiResult.payload);
return;
}
const renderedContent = krokiResult
? (type === 'svg' ? krokiResult.payload.toString('utf8') : krokiResult.payload.toString('base64'))
: null;
const debugEnabled = ['1', 'true', 'yes'].includes(String(req.query.debug ?? '').toLowerCase());
const debugLevels = debugEnabled
? Array.from(visibleNodeSet).map((uidHex) => {
const node = nodeMap.get(uidHex);
return {
uid: String(node?.UID ? HEX2uuid(node.UID) : (HEX2uuid(Buffer.from(uidHex, 'hex')) || '')),
level: levels.get(uidHex) ?? 0,
hierarchy: getNodeHierarchy(uidHex),
};
})
: undefined;
res.json({
success: true,
result: {
krokiUrl,
encoded,
type,
nodeCount: visibleNodeSet.size,
edgeCount: visibleEdges.length,
displayLevels: displayLevelRows,
includeOutOfHierarchy,
renderedContent,
contentType: krokiResult?.contentType ?? null,
debugLevels,
},
});
} catch (e) {
errorLoggerRead(e);
}
};