interface Node { kind: string; name: string; title?: string; subtitle?: string; color?: string; tags?: string[]; } interface Cardinality { sourceCount?: number; targetCount?: number; sameKindCount?: number; } interface NodeProperties extends Node { targetCount?: number; sourceCount?: number; placeHolder?: boolean; cardinality?: Cardinality; selectedCardinality?: Cardinality; hasRelatedSourceOfOtherKinds?: boolean; hasRelatedSourceOfSameKind?: boolean; // used for indentation hasRelationsOfSameKinds?: boolean; } interface Magnitude { nodes: Node[]; value: number; // total decomposition: { [key: string]: number }; // the sum MUST not exceed the value } interface Analytics { traffic: number; environment?: string; errors?: number; } interface Relation { source: Node; target: Node; analytics?: Analytics; environment?: string; } enum IncludeKind { WITH_SAME_TARGET = "WITH_SAME_TARGET" } interface SankeyChartDataOptions { noTag?: string; noTagSuffixCharacter?: string; relationDefaultWidth?: number; defaultColor?: string; tagColorMap?: { [key: string]: string }; kinds: KindMeta[]; showRelatedKinds?: boolean; showSameKindsOnNonSelected?: boolean; selectAndFilter?: boolean; } interface Kind { name: string; title?: string; color?: string; } interface KindMeta extends Kind { includeAlternative?: IncludeKind; } class SankeyChartData { selectedNode?: NodeProperties; nodes: NodeProperties[]; dependencies: { relations: Relation[]; hasRelatedSourceOfOtherKinds: boolean; }; originalData: { name: string; color?: string; nodes: NodeProperties[]; relations: Relation[] }; allNodesLoaded: boolean; nodesByKinds: { [key: string]: NodeProperties[] }; title?: KindMeta; options: SankeyChartDataOptions; constructor( data: { name: string; color?: string; nodes?: Node[]; relations?: Relation[] }, // <-- nodes?: Node[] options: SankeyChartDataOptions, partialData: boolean = false ) { this.selectedNode = undefined; this.nodes = []; this.dependencies = { relations: [], hasRelatedSourceOfOtherKinds: false }; this.originalData = { name: data.name, color: data.color, nodes: (data.nodes || []).map(node => ({ ...node })), relations: data.relations || [] }; this.allNodesLoaded = !partialData; this.nodesByKinds = {}; this.title = undefined; this.options = { noTag: 'Others', noTagSuffixCharacter: '…', relationDefaultWidth: 15, defaultColor: "orange", tagColorMap: {}, kinds: [], showRelatedKinds: false, selectAndFilter: true }; this.setOptions(options); } initialize() { this.initializeSortRelations(); this.initializeRelationsInfo(); } resetColors() { if (this.options.tagColorMap) { const tags = Object.keys(this.options.tagColorMap); this.nodes.forEach(node => { const hasSome = tags.some(tag => node.tags?.includes(tag)); if (hasSome || node['color'] === this.options.defaultColor) { delete node['color']; } }); } else { this.nodes.forEach(node => delete node['color']); } } setOptions(options: SankeyChartDataOptions) { this.resetColors(); this.options = { ...this.options, ...options }; const previousNode = this.selectedNode; this.initialize(); this.selectedNode = undefined; this.selectNode(previousNode); } appendData(data: { nodes: Node[]; relations: Relation[] }, selectedNode?: Node) { this.selectedNode = undefined; this.mergeData(this.originalData, data); this.initialize(); this.selectNode(selectedNode); } getNodes(): NodeProperties[] { return this.nodes || []; } getNodesByKind(kind: string): NodeProperties[] { return this.nodesByKinds[kind] ?? []; } getRelations(): Relation[] { return this.dependencies.relations || []; } getKinds(): Kind[] { const filteredKinds = Object.keys(this.nodesByKinds); if (this.options?.kinds?.length > 0) { return this.options.kinds.filter(kind => filteredKinds.includes(kind.name)); } return filteredKinds.map(kind => ({ name: kind })); } getTitle(): KindMeta | undefined { return this.title; } setTitle(title?: KindMeta) { this.title = title ? { title: title.title, name: title.name, color: title.color } : undefined; } getSelectedNode(): NodeProperties | undefined { return this.selectedNode; } selectNode(node?: Node): NodeProperties | undefined { const groupByKind = (nodes: Node[]) => { const dataByKinds: { [key: string]: NodeProperties[] } = {}; nodes.forEach(node => { if (!dataByKinds[node.kind]) { dataByKinds[node.kind] = []; } dataByKinds[node.kind].push(node); }); return dataByKinds; }; if (!node) { this.nodes = this.originalData.nodes; this.dependencies.relations = this.originalData.relations || []; this.nodesByKinds = groupByKind(this.nodes); this.selectedNode = undefined; } else if (!node.kind || !node.name) { throw new Error('Node must have kind and name'); } else if (this.selectedNode && node.name === this.selectedNode.name && node.kind === this.selectedNode.kind) { return this.selectedNode; } else { this.selectedNode = this.originalData.nodes.find(item => item.name === node.name && item.kind === node.kind); if (this.selectedNode) { const selectedKind = this.options.kinds.find(kind => kind.name === this.selectedNode?.kind); if (selectedKind?.includeAlternative) { this.selectedNode['hasRelatedSourceOfOtherKinds'] = true; } else { delete this.selectedNode['hasRelatedSourceOfOtherKinds']; } this.selectedNode['hasRelatedSourceOfOtherKinds'] = selectedKind?.includeAlternative ? true : false; if (this.options.selectAndFilter) { if (this.options.showRelatedKinds) { this.dependencies = this.filterDependencies(this.selectedNode, selectedKind); } else { this.dependencies = this.filterDependencies(this.selectedNode); } this.nodes = this.filterNodes(this.dependencies.relations); this.nodes.forEach(node => { node.hasRelatedSourceOfSameKind = this.dependencies.relations.find(relation => relation.target.kind === node.kind && relation.target.name === node.name && relation.source.kind === node.kind) ? true : false; }); } } else { this.nodes = []; } this.nodesByKinds = groupByKind(this.nodes); } this.sortNodes(); return this.selectedNode; } private sortNodesAlpabetically(nodes: NodeProperties[]) { const undefinedTag = (this.options.noTag || '') + this.options.noTagSuffixCharacter; nodes.sort((a, b) => { if (a.name === undefinedTag && b.name !== undefinedTag) { return 1; } else if (a.name !== undefinedTag && b.name === undefinedTag) { return -1; } else { return 0; } }); } private sortNodes() { const selectedNode = this.getSelectedNode(); if (!selectedNode) { this.sortNodesAlpabetically(this.getNodes()); return } let previousKinds: NodeProperties[] = []; const startIndex = this.options.kinds.findIndex(k => k.name === selectedNode?.kind); for (let index = startIndex; index < this.options.kinds.length; index++) { const kind = this.options.kinds[index]; const currentKinds = this.nodesByKinds[kind.name]; if (currentKinds) { this.sortNodesOfKind(kind, currentKinds, previousKinds, selectedNode); previousKinds = currentKinds; } } const kind = this.options.kinds[startIndex]; previousKinds = this.nodesByKinds[kind.name]; for (let index = startIndex - 1; index >= 0; index--) { const kind = this.options.kinds[index]; const currentKinds = this.nodesByKinds[kind.name]; if (currentKinds) { this.sortNodesOfKind(kind, currentKinds, previousKinds, selectedNode); previousKinds = currentKinds; } } this.sortRelations(); } private sortNodesOfKind(kind: Kind, nodes: NodeProperties[], previousKinds: NodeProperties[], selectedNode: NodeProperties) { if (kind.name === selectedNode?.kind) { const relations = this.getRelations(); const relatedOfSameKindNodes = relations.filter(rel => rel.source.name === selectedNode.name && rel.source.kind === kind.name && rel.target.kind === kind.name).map(rel => rel.target.name); const dependenciesOfSameKindNodes = relations.filter(rel => rel.target.name === selectedNode.name && rel.target.kind === kind.name && rel.source.kind === kind.name).map(rel => rel.source.name); nodes.sort((a, b) => { // Define group: // 1: selected node, 2: nodes in relatedOfSameKindNodes, 3: rest of the nodes const group = (node: NodeProperties): number => { if (dependenciesOfSameKindNodes.length > 0) { if (dependenciesOfSameKindNodes.includes(node.name)) return 1; if (node.name === selectedNode.name) return 2; } else { if (node.name === selectedNode.name) return 1; if (relatedOfSameKindNodes.includes(node.name)) return 2; } return 3; }; const groupA = group(a); const groupB = group(b); if (groupA !== groupB) return groupA - groupB; return a.name.localeCompare(b.name); }); } else { const relations = this.getRelations(); nodes.sort((a, b) => { const aPrevousNodes = relations.filter(rel => rel.target.name === a.name && rel.target.kind === a.kind); const bPrevousNodes = relations.filter(rel => rel.target.name === b.name && rel.target.kind === b.kind); const aIndex = previousKinds.findIndex(item => aPrevousNodes.some(rel => rel.source.name === item.name)); const bIndex = previousKinds.findIndex(item => bPrevousNodes.some(rel => rel.source.name === item.name)); if (aIndex !== -1 || bIndex !== -1) { if (aIndex === bIndex) { return a.name.localeCompare(b.name); } return bIndex > aIndex ? -1 : 1; } return a.name.localeCompare(b.name); }); } }; private sortRelations() { const combinedNodes: { [key: string]: number } = {}; const shift = 100000; Object.keys(this.nodesByKinds).forEach(kind => { let i = shift; this.nodesByKinds[kind].forEach(node => { combinedNodes[kind + '::' + node.name] = (i++); }); }); const relations = this.getRelations(); relations?.sort((a, b) => { const aSourceKey = `${a.source.kind}::${a.source.name}`; const bSourceKey = `${b.source.kind}::${b.source.name}`; const aTargetKey = `${a.target.kind}::${a.target.name}`; const bTargetKey = `${b.target.kind}::${b.target.name}`; const aSourceIndex = combinedNodes[aSourceKey] ?? Number.MAX_SAFE_INTEGER; const bSourceIndex = combinedNodes[bSourceKey] ?? Number.MAX_SAFE_INTEGER; const aTargetIndex = combinedNodes[aTargetKey] ?? Number.MAX_SAFE_INTEGER; const bTargetIndex = combinedNodes[bTargetKey] ?? Number.MAX_SAFE_INTEGER; const aIndex = aSourceIndex * shift + aTargetIndex; const bIndex = bSourceIndex * shift + bTargetIndex; return aIndex - bIndex; }); } private initializeSortRelations() { this.originalData.relations?.sort((a, b) => { if (a.source.kind !== b.source.kind) { return a.source.kind.localeCompare(b.source.kind); } else { return a.source.name.localeCompare(b.source.name); } }).sort((a, b) => { if (a.source.kind === b.source.kind && a.source.name === b.source.name) { if (a.target.kind !== b.target.kind) { return a.target.kind.localeCompare(b.target.kind); } else { return a.target.name.localeCompare(b.target.name); } } return 0; }); } private initializeRelationsInfo() { const summary: { [key: string]: { sourceCount: number; targetCount: number; sameKindCount: number } } = {}; this.originalData.relations?.forEach((link) => { const key = link.source.kind + '::' + link.source.name; if (!summary[key]) { summary[key] = { sourceCount: 0, targetCount: 0, sameKindCount: 0 }; } if (link.source.kind === link.target.kind) { summary[key].sameKindCount++; } else { summary[key].sourceCount++; } const targetKey = link.target.kind + '::' + link.target.name; if (!summary[targetKey]) { summary[targetKey] = { sourceCount: 0, targetCount: 0, sameKindCount: 0 }; } summary[targetKey].targetCount++; }); this.originalData.nodes.forEach((node) => { const cardinality = summary[node.kind + '::' + node.name]; node.color = this.getNodeTagColor(node); node.cardinality = cardinality; if (node.targetCount) { node.cardinality = { targetCount: node.targetCount, sameKindCount: 0 }; } if (node.sourceCount) { node.cardinality = Object.assign(node.cardinality ?? {}, { sourceCount: node.sourceCount, sameKindCount: 0 }); } }); } getNodeTagColor = (node: Node): string | undefined => { const color = node.tags ? node.tags.map(tag => this.options.tagColorMap?.[tag]).find(color => color !== undefined) : this.options.defaultColor; return node.color || color; }; searchByName(node: { kind: string; name: string }): NodeProperties[] { if (!node.kind || !node.name) { throw new Error('Filter criteria is empty'); } return this.originalData.nodes.filter(item => item.kind === node.kind && item.name.includes(node.name)); } filterDependencies(selectedNode: NodeProperties, selectedKind?: KindMeta): { relations: Relation[]; hasRelatedSourceOfOtherKinds: boolean } { let relatedRelations: Relation[] = []; const kindNames = this.options.kinds.map(k => k.name); // this.getKinds().map(kind => kind.name); let targetRelations = this.originalData.relations.filter(relation => { return relation.source.kind === selectedNode.kind && relation.source.name === selectedNode.name && (kindNames.length > 0 ? kindNames.includes(relation.target.kind) : true); }); if (targetRelations.length == 0) { const selectedSources = this.originalData.relations.filter(relation => { return relation.target.kind === selectedNode.kind && relation.target.name === selectedNode.name && (kindNames.length > 0 ? kindNames.includes(relation.source.kind) : true); }); const selectedSourceNames = selectedSources.map(relation => relation.source.name); targetRelations = this.originalData.relations.filter(relation => { return relation.source.kind === selectedNode.kind && selectedSourceNames.includes(relation.source.name); }); targetRelations.push(...selectedSources); } const targetKeys = targetRelations ? [...new Set(targetRelations.flatMap(relation => `${relation.target.kind}::${relation.target.name}`))] : []; const targetTargetRelations = this.targetTargetRelations(selectedNode.kind, kindNames, targetKeys); if (selectedKind?.includeAlternative) { const relatedKindKeys = [...new Set(targetRelations.flatMap(relation => `${relation.target.kind}::${relation.target.name}`))]; relatedRelations = this.originalData.relations.filter(relation => { return relatedKindKeys.includes(`${relation.target.kind}::${relation.target.name}`) && selectedKind.name === relation.source.kind; }); } const sourceRelations = this.originalData.relations.filter(relation => { return (kindNames.length > 0 ? kindNames.includes(relation.target.kind) : true) && relation.target.kind === selectedNode.kind && relation.target.name === selectedNode.name; }); const sourceKeys = sourceRelations ? [...new Set(sourceRelations.flatMap(relation => `${relation.target.kind}::${relation.target.name}`))] : []; const sourceSourceRelations = this.originalData.relations.filter(relation => { return (kindNames.length > 0 ? kindNames.includes(relation.target.kind) : true) && sourceKeys.includes(`${relation.target.kind}::${relation.target.name}`); }); const distinctRelations = [...new Set([...targetRelations, ...targetTargetRelations, ...sourceSourceRelations, ...relatedRelations, ...sourceRelations].map(rel => JSON.stringify(rel)))].map(relString => JSON.parse(relString)); selectedNode.hasRelationsOfSameKinds = distinctRelations.find(relation => relation.source.kind === selectedNode.kind || relation.target.kind === selectedNode.kind) ? true : false; return { relations: distinctRelations, hasRelatedSourceOfOtherKinds: relatedRelations.length > 0 }; } private targetTargetRelations(kind: string, kindNames: string[], targetKeys: string[]) { if (this.options.showSameKindsOnNonSelected) { return this.originalData.relations.filter(relation => { return (kindNames.length > 0 ? kindNames.includes(relation.target.kind) : true) && targetKeys.includes(relation.source.kind + '::' + relation.source.name); }); } return this.originalData.relations.filter(relation => { return ((relation.target.kind === relation.source.kind) ? relation.source.kind === kind : true) && (kindNames.length > 0 ? kindNames.includes(relation.target.kind) : true) && targetKeys.includes(relation.source.kind + '::' + relation.source.name); }); } filterNodes(relations: Relation[]): NodeProperties[] { const relationKeys = relations.flatMap(relation => `${relation.target.kind}::${relation.target.name}`); const relationSourceKeys = relations.flatMap(relation => `${relation.source.kind}::${relation.source.name}`); if (this.selectedNode) { relationSourceKeys.push(`${this.selectedNode.kind}::${this.selectedNode.name}`) } const distinctKeys = [...new Set(relationKeys.concat(relationSourceKeys))]; return this.originalData.nodes.filter(node => distinctKeys.includes(`${node.kind}::${node.name}`)); } private mergeData(originData: { nodes: NodeProperties[]; relations: Relation[] }, appendData: { nodes: NodeProperties[]; relations: Relation[] }): { nodes: NodeProperties[]; relations: Relation[] } { appendData.nodes.forEach(node => { const index = originData.nodes.findIndex(existingNode => existingNode.kind === node.kind && existingNode.name === node.name); if (index !== -1) { const existingNode = originData.nodes[index]; const foundRelationsToRemove = originData.relations.filter(relation => { return existingNode.kind === relation.source.kind && existingNode.name === relation.source.name || existingNode.kind === relation.target.kind && existingNode.name === relation.target.name; }); foundRelationsToRemove.forEach(relation => { const relationIndex = originData.relations.indexOf(relation); if (relationIndex !== -1) { originData.relations.splice(relationIndex, 1); } }); originData.nodes[index] = node; } else { originData.nodes.push(node); } }); appendData.relations.forEach(relation => { const existingRelationIndex = originData.relations.findIndex(existingRelation => existingRelation.source.kind === relation.source.kind && existingRelation.source.name === relation.source.name && existingRelation.target.kind === relation.target.kind && existingRelation.target.name === relation.target.name ); if (existingRelationIndex === -1) { originData.relations.push(relation); } }); return originData; } } export { SankeyChartData, NodeProperties, Relation, Magnitude, SankeyChartDataOptions, Kind, Analytics, Node, IncludeKind, Cardinality };