github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/ui/dashboard/src/components/dashboards/common/index.ts (about) 1 import has from "lodash/has"; 2 import isEmpty from "lodash/isEmpty"; 3 import { 4 Category, 5 CategoryMap, 6 Edge, 7 EdgeMap, 8 KeyValuePairs, 9 Node, 10 NodeCategoryMap, 11 NodeMap, 12 NodesAndEdges, 13 } from "./types"; 14 import { ChartProperties, ChartTransform, ChartType } from "../charts/types"; 15 import { DashboardRunState } from "../../../types"; 16 import { ExpandedNodes } from "../graphs/common/useGraph"; 17 import { FlowProperties, FlowType } from "../flows/types"; 18 import { getColumn } from "../../../utils/data"; 19 import { Graph, json } from "graphlib"; 20 import { GraphProperties, GraphType, NodeAndEdgeData } from "../graphs/types"; 21 import { HierarchyProperties, HierarchyType } from "../hierarchies/types"; 22 23 export type Width = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; 24 25 export type BasePrimitiveProps = { 26 base?: string; 27 dashboard: string; 28 name: string; 29 panel_type: string; 30 display_type?: string; 31 title?: string; 32 width?: Width; 33 }; 34 35 export type LeafNodeDataColumn = { 36 name: string; 37 data_type: string; 38 }; 39 40 export type LeafNodeDataRow = { 41 [key: string]: any; 42 }; 43 44 export type LeafNodeData = { 45 columns: LeafNodeDataColumn[]; 46 rows: LeafNodeDataRow[]; 47 }; 48 49 export type ExecutablePrimitiveProps = { 50 sql?: string; 51 data?: LeafNodeData; 52 error?: Error; 53 status: DashboardRunState; 54 }; 55 56 export type ColorOverride = "alert" | "info" | "ok" | string; 57 58 export type EChartsType = "bar" | "line" | "pie" | "sankey" | "tree" | "graph"; 59 60 const toEChartsType = ( 61 type: ChartType | FlowType | GraphType | HierarchyType 62 ): EChartsType => { 63 // A column chart in chart.js is a bar chart with different options 64 if (type === "column") { 65 return "bar"; 66 } 67 // Different spelling 68 if (type === "donut") { 69 return "pie"; 70 } 71 return type as EChartsType; 72 }; 73 74 type ChartDatasetResponse = { 75 dataset: any[][]; 76 rowSeriesLabels: string[]; 77 transform: ChartTransform; 78 }; 79 80 const crosstabDataTransform = (data: LeafNodeData): ChartDatasetResponse => { 81 if (data.columns.length < 3) { 82 return { dataset: [], rowSeriesLabels: [], transform: "none" }; 83 } 84 const xAxis = {}; 85 const series = {}; 86 const xAxisLabels: string[] = []; 87 const seriesLabels: string[] = []; 88 for (const row of data.rows) { 89 const xAxisLabel = row[data.columns[0].name]; 90 const seriesName = row[data.columns[1].name]; 91 const seriesValue = row[data.columns[2].name]; 92 93 if (!xAxis[xAxisLabel]) { 94 xAxis[xAxisLabel] = {}; 95 xAxisLabels.push(xAxisLabel); 96 } 97 98 xAxis[xAxisLabel] = xAxis[xAxisLabel] || {}; 99 100 if (seriesName) { 101 const existing = xAxis[xAxisLabel][seriesName]; 102 xAxis[xAxisLabel][seriesName] = existing 103 ? existing + seriesValue 104 : seriesValue; 105 106 if (!series[seriesName]) { 107 series[seriesName] = true; 108 seriesLabels.push(seriesName); 109 } 110 } 111 } 112 113 const dataset: any[] = []; 114 const headerRow: string[] = []; 115 headerRow.push(data.columns[0].name); 116 for (const seriesLabel of seriesLabels) { 117 headerRow.push(seriesLabel); 118 } 119 dataset.push(headerRow); 120 121 for (const xAxisLabel of xAxisLabels) { 122 const row = [xAxisLabel]; 123 for (const seriesLabel of seriesLabels) { 124 const seriesValue = xAxis[xAxisLabel][seriesLabel]; 125 row.push(seriesValue === undefined ? null : seriesValue); 126 } 127 dataset.push(row); 128 } 129 130 return { dataset, rowSeriesLabels: seriesLabels, transform: "crosstab" }; 131 }; 132 133 const defaultDataTransform = (data: LeafNodeData): ChartDatasetResponse => { 134 return { 135 dataset: [ 136 data.columns.map((col) => col.name), 137 ...data.rows.map((row) => data.columns.map((col) => row[col.name])), 138 ], 139 rowSeriesLabels: [], 140 transform: "none", 141 }; 142 }; 143 144 const isNumericCol = (data_type: string | null | undefined) => { 145 if (!data_type) { 146 return false; 147 } 148 return ( 149 data_type.toLowerCase().indexOf("int") >= 0 || 150 data_type.toLowerCase().indexOf("float") >= 0 || 151 data_type.toLowerCase().indexOf("numeric") >= 0 152 ); 153 }; 154 155 const automaticDataTransform = (data: LeafNodeData): ChartDatasetResponse => { 156 // We want to check if the data looks like something that can be crosstab transformed. 157 // If that's 3 columns, with the first 2 non-numeric and the last numeric, we'll apply 158 // a crosstab transform, else we'll apply the default transform 159 if (data.columns.length === 3) { 160 const col1Type = data.columns[0].data_type; 161 const col2Type = data.columns[1].data_type; 162 const col3Type = data.columns[2].data_type; 163 if ( 164 !isNumericCol(col1Type) && 165 !isNumericCol(col2Type) && 166 isNumericCol(col3Type) 167 ) { 168 return crosstabDataTransform(data); 169 } 170 } 171 return defaultDataTransform(data); 172 }; 173 174 const buildChartDataset = ( 175 data: LeafNodeData | undefined, 176 properties: ChartProperties | undefined 177 ): ChartDatasetResponse => { 178 if (!data || !data.columns) { 179 return { dataset: [], rowSeriesLabels: [], transform: "none" }; 180 } 181 182 const transform = properties?.transform; 183 184 switch (transform) { 185 case "crosstab": 186 return crosstabDataTransform(data); 187 case "none": 188 return defaultDataTransform(data); 189 // Must be not specified or "auto", which should check to see 190 // if the data matches crosstab format and transform if it is 191 default: 192 return automaticDataTransform(data); 193 } 194 }; 195 196 const adjust = (value, divisor, direction = "asc") => { 197 const remainder = value % divisor; 198 if (direction === "asc") { 199 return remainder === 0 ? value + divisor : value + (divisor - remainder); 200 } else { 201 return remainder === 0 ? value - divisor : value - (divisor + remainder); 202 } 203 }; 204 205 const adjustMinValue = (initial) => { 206 if (initial >= 0) { 207 return 0; 208 } 209 210 let min = initial; 211 if (initial <= -10000) { 212 min = adjust(min, 1000, "desc"); 213 } else if (initial <= -1000) { 214 min = adjust(min, 100, "desc"); 215 } else if (initial <= -200) { 216 min = adjust(min, 50, "desc"); 217 } else if (initial <= -50) { 218 min = adjust(min, 10, "desc"); 219 } else if (initial <= -20) { 220 min = adjust(min, 5, "desc"); 221 } else if (initial <= -10) { 222 min = adjust(min, 2, "desc"); 223 } else { 224 min -= 1; 225 } 226 return min; 227 }; 228 229 const adjustMaxValue = (initial) => { 230 if (initial <= 0) { 231 return 0; 232 } 233 234 let max = initial; 235 if (initial < 10) { 236 max += 1; 237 } else if (initial < 20) { 238 max = adjust(max, 2); 239 } else if (initial < 50) { 240 max = adjust(max, 5); 241 } else if (initial < 200) { 242 max = adjust(max, 10); 243 } else if (initial < 1000) { 244 max = adjust(max, 50); 245 } else if (initial < 10000) { 246 max = adjust(max, 100); 247 } else { 248 max = adjust(max, 1000); 249 } 250 return max; 251 }; 252 253 const recordEdge = ( 254 edge_lookup, 255 from_id: string, 256 to_id: string, 257 title: string | null = null, 258 category: string | null = null, 259 row_data: LeafNodeDataRow | null = null 260 ) => { 261 let duplicate_edge = false; 262 // Find any existing edge 263 const edge_id = `${from_id}_${to_id}`; 264 const existingNode = edge_lookup[edge_id]; 265 266 const edge: Edge = { 267 id: edge_id, 268 from_id, 269 to_id, 270 title, 271 category, 272 row_data, 273 isFolded: false, 274 }; 275 276 if (existingNode) { 277 duplicate_edge = true; 278 } else { 279 edge_lookup[edge_id] = edge; 280 } 281 282 return { 283 edge, 284 duplicate_edge, 285 }; 286 }; 287 288 const createNode = ( 289 node_lookup, 290 nodes_by_category, 291 id: string, 292 title: string | null = null, 293 category: string | null = null, 294 depth: number | null = null, 295 row_data: LeafNodeDataRow | null = null, 296 categories: CategoryMap = {}, 297 isFolded: boolean = false 298 ) => { 299 let symbol: string | null = null; 300 let href: string | null = null; 301 if (category && categories) { 302 const matchingCategory = categories[category]; 303 if (matchingCategory && matchingCategory.icon) { 304 symbol = matchingCategory.icon; 305 } 306 if (matchingCategory && matchingCategory.href) { 307 href = matchingCategory.href; 308 } 309 } 310 311 const node: Node = { 312 id, 313 category, 314 title, 315 depth, 316 row_data, 317 symbol, 318 href, 319 isFolded, 320 }; 321 node_lookup[id] = node; 322 323 if (category) { 324 nodes_by_category[category] = nodes_by_category[category] || {}; 325 nodes_by_category[category][id] = node; 326 } 327 return node; 328 }; 329 330 const getCategoriesWithFold = (categories: CategoryMap): CategoryMap => { 331 if (!categories) { 332 return {}; 333 } 334 return Object.entries(categories) 335 .filter(([_, info]) => !!info.fold) 336 .reduce((res, [category, info]) => { 337 res[category] = info; 338 return res; 339 }, {}); 340 }; 341 342 const foldNodesAndEdges = ( 343 nodesAndEdges: NodesAndEdges, 344 expandedNodes: ExpandedNodes = {} 345 ): NodesAndEdges => { 346 const categoriesWithFold = getCategoriesWithFold(nodesAndEdges.categories); 347 348 if (isEmpty(categoriesWithFold)) { 349 return nodesAndEdges; 350 } 351 352 const newNodesAndEdges = { 353 ...nodesAndEdges, 354 }; 355 356 const graph = json.read(json.write(nodesAndEdges.graph)); 357 358 for (const [category, info] of Object.entries(categoriesWithFold)) { 359 // Keep track of the number of folded nodes we've added 360 let foldedNodeCount = 0; 361 362 // Find all nodes of this given category 363 const nodesForCategory = nodesAndEdges.nodeCategoryMap[category]; 364 365 // If we have no nodes for this category, continue 366 if (!nodesForCategory) { 367 continue; 368 } 369 370 // If the number of nodes for this category is less than the threshold, it's 371 // not possible that any would require folding, regardless of the graph structure 372 const categoryNodesById = Object.entries(nodesForCategory); 373 374 if (categoryNodesById.length < (info.fold?.threshold || 0)) { 375 continue; 376 } 377 378 // Now we're here we know that we have enough nodes of this category in the 379 // graph that it "might" be possible to fold, but we'll examine the 380 // node and edge structure now to determine that 381 382 const categoryEdgeGroupings: KeyValuePairs = {}; 383 384 // Iterate over the category nodes 385 for (const [, node] of categoryNodesById) { 386 let sourceNodes: string[] = []; 387 let targetNodes: string[] = []; 388 389 // Get all the in edges to this node 390 const inEdges = graph.inEdges(node.id); 391 392 // Get all the out edges from this node 393 const outEdges = graph.outEdges(node.id); 394 395 // Record the nodes pointing to this node 396 for (const inEdge of inEdges || []) { 397 sourceNodes.push(inEdge.v); 398 } 399 400 // Record the nodes this node points to 401 for (const outEdge of outEdges || []) { 402 targetNodes.push(outEdge.w); 403 } 404 405 // Sort to ensure consistent 406 sourceNodes = sourceNodes.sort(); 407 targetNodes = targetNodes.sort(); 408 409 // Build a key that we can uniquely identify each unique combo category / source nodes / target nodes 410 // and record all the nodes for that key. If we have any keys that have >= fold threshold, fold them 411 const categoryGroupingKey = `category:${node.category}`; 412 const edgeSourceGroupingKey = 413 sourceNodes.length > 0 ? `source:${sourceNodes.join(",")}` : null; 414 const edgeTargetGroupingKey = 415 targetNodes.length > 0 ? `target:${targetNodes.join(",")}` : null; 416 const edgeGroupingKey = `${categoryGroupingKey}${ 417 edgeSourceGroupingKey ? `_${edgeSourceGroupingKey}` : "" 418 }${edgeTargetGroupingKey ? `_${edgeTargetGroupingKey}` : ""}`; 419 categoryEdgeGroupings[edgeGroupingKey] = categoryEdgeGroupings[ 420 edgeGroupingKey 421 ] || { 422 category: info, 423 threshold: info.fold?.threshold, 424 nodes: [], 425 source: sourceNodes, 426 target: targetNodes, 427 }; 428 categoryEdgeGroupings[edgeGroupingKey].nodes.push(node); 429 } 430 431 // Find any nodes that can be folded 432 for (const [, groupingInfo] of Object.entries(categoryEdgeGroupings) 433 // @ts-ignore 434 .filter( 435 ([_, g]) => 436 g.threshold !== null && 437 g.threshold !== undefined && 438 g.nodes.length >= g.threshold 439 )) { 440 const removedNodes: any[] = []; 441 442 // Create a structure to capture the category and title of each edge that 443 // is being folded into this node. Later, if they are all the same, we can 444 // use that same category and title for the new folded edge. 445 const deletedSourceEdges = { categories: {}, titles: {} }; 446 const deletedTargetEdges = { categories: {}, titles: {} }; 447 448 // We want to fold nodes that are not expanded 449 for (const node of groupingInfo.nodes) { 450 // This node is expanded, don't fold it 451 if (expandedNodes[node.id]) { 452 continue; 453 } 454 455 // Remove this node 456 graph.removeNode(node.id); 457 delete newNodesAndEdges.nodeMap[node.id]; 458 delete newNodesAndEdges.nodeCategoryMap[category][node.id]; 459 // Remove edges pointing to this node 460 for (const sourceNode of groupingInfo.source) { 461 const sourceEdgeKey = `${sourceNode}_${node.id}`; 462 const sourceEdge = newNodesAndEdges.edgeMap[sourceEdgeKey]; 463 const sourceEdgeTitle = sourceEdge.title || "none"; 464 const sourceEdgeCategory = sourceEdge.category || "none"; 465 deletedSourceEdges.categories[sourceEdgeCategory] = 466 deletedSourceEdges.categories[sourceEdgeCategory] || 0; 467 deletedSourceEdges.categories[sourceEdgeCategory]++; 468 deletedSourceEdges.titles[sourceEdgeTitle] = 469 deletedSourceEdges.titles[sourceEdgeTitle] || 0; 470 deletedSourceEdges.titles[sourceEdgeTitle]++; 471 delete newNodesAndEdges.edgeMap[sourceEdgeKey]; 472 graph.removeEdge(sourceNode, node.id); 473 } 474 // Remove edges coming from this node 475 for (const targetNode of groupingInfo.target) { 476 const targetEdgeKey = `${node.id}_${targetNode}`; 477 const targetEdge = newNodesAndEdges.edgeMap[targetEdgeKey]; 478 const targetEdgeTitle = 479 targetEdge.title || targetEdge.category || "none"; 480 const targetEdgeCategory = targetEdge.category || "none"; 481 deletedTargetEdges.categories[targetEdgeCategory] = 482 deletedTargetEdges.categories[targetEdgeCategory] || 0; 483 deletedTargetEdges.categories[targetEdgeCategory]++; 484 deletedTargetEdges.titles[targetEdgeTitle] = 485 deletedTargetEdges.titles[targetEdgeTitle] || 0; 486 deletedTargetEdges.titles[targetEdgeTitle]++; 487 delete newNodesAndEdges.edgeMap[targetEdgeKey]; 488 graph.removeEdge(node.id, targetNode); 489 } 490 removedNodes.push({ id: node.id, title: node.title }); 491 } 492 493 // Now let's add a folded node 494 if (removedNodes.length > 0) { 495 const foldedNode = { 496 id: `fold-${category}-${++foldedNodeCount}`, 497 category, 498 icon: info.fold?.icon, 499 title: info.fold?.title ? info.fold.title : null, 500 isFolded: true, 501 foldedNodes: removedNodes, 502 row_data: null, 503 href: null, 504 depth: null, 505 symbol: null, 506 }; 507 graph.setNode(foldedNode.id); 508 newNodesAndEdges.nodeCategoryMap[category][foldedNode.id] = foldedNode; 509 newNodesAndEdges.nodeMap[foldedNode.id] = foldedNode; 510 511 // We want to add the color and category if all edges to this node have a common color or category 512 const deletedSourceEdgeCategoryKeys = Object.keys( 513 deletedSourceEdges.categories 514 ); 515 const deletedSourceEdgeTitleKeys = Object.keys( 516 deletedSourceEdges.titles 517 ); 518 const sourceEdgeCategory = 519 deletedSourceEdgeCategoryKeys.length === 1 && 520 deletedSourceEdgeCategoryKeys[0] !== "none" 521 ? deletedSourceEdgeCategoryKeys[0] 522 : null; 523 const sourceEdgeTitle = 524 deletedSourceEdgeTitleKeys.length === 1 && 525 deletedSourceEdgeTitleKeys[0] !== "none" 526 ? deletedSourceEdgeTitleKeys[0] 527 : null; 528 529 // Add the source edges back to the folded node 530 for (const sourceNode of groupingInfo.source) { 531 graph.setEdge(sourceNode, foldedNode.id); 532 const edge: Edge = { 533 id: `${sourceNode}_${foldedNode.id}`, 534 from_id: sourceNode, 535 to_id: foldedNode.id, 536 category: sourceEdgeCategory, 537 title: sourceEdgeTitle, 538 isFolded: true, 539 row_data: null, 540 }; 541 newNodesAndEdges.edgeMap[edge.id] = edge; 542 } 543 544 // We want to add the category and title if all edges from this node have a common category or title 545 const deletedTargetEdgeCategoryKeys = Object.keys( 546 deletedTargetEdges.categories 547 ); 548 const deletedTargetEdgeTitleKeys = Object.keys( 549 deletedTargetEdges.titles 550 ); 551 const targetEdgeCategory = 552 deletedTargetEdgeCategoryKeys.length === 1 && 553 deletedTargetEdgeCategoryKeys[0] !== "none" 554 ? deletedTargetEdgeCategoryKeys[0] 555 : null; 556 const targetEdgeTitle = 557 deletedTargetEdgeTitleKeys.length === 1 && 558 deletedTargetEdgeTitleKeys[0] !== "none" 559 ? deletedTargetEdgeTitleKeys[0] 560 : null; 561 562 // Add the target edges back from the folded node 563 for (const targetNode of groupingInfo.target) { 564 graph.setEdge(foldedNode.id, targetNode); 565 const edge = { 566 id: `${foldedNode.id}_${targetNode}`, 567 from_id: foldedNode.id, 568 to_id: targetNode, 569 category: targetEdgeCategory, 570 title: targetEdgeTitle, 571 }; 572 newNodesAndEdges.edgeMap[edge.id] = edge; 573 } 574 } 575 } 576 } 577 578 return { 579 ...newNodesAndEdges, 580 nodes: graph.nodes().map((nodeId) => newNodesAndEdges.nodeMap[nodeId]), 581 edges: graph 582 .edges() 583 .map((edgeObj) => newNodesAndEdges.edgeMap[`${edgeObj.v}_${edgeObj.w}`]), 584 }; 585 }; 586 587 const buildNodesAndEdges = ( 588 categories: CategoryMap = {}, 589 rawData: NodeAndEdgeData | undefined, 590 properties: FlowProperties | GraphProperties | HierarchyProperties = {}, 591 namedThemeColors = {}, 592 defaultCategoryColor = true 593 ): NodesAndEdges => { 594 if (!rawData || !rawData.columns || !rawData.rows) { 595 return { 596 graph: new Graph(), 597 nodes: [], 598 edges: [], 599 nodeCategoryMap: {}, 600 nodeMap: {}, 601 edgeMap: {}, 602 root_nodes: {}, 603 categories: {}, 604 next_color_index: 0, 605 }; 606 } 607 608 const graph = new Graph({ directed: true }); 609 610 let categoryProperties = {}; 611 if (properties && properties.categories) { 612 categoryProperties = properties.categories; 613 } 614 615 const id_col = getColumn(rawData.columns, "id"); 616 const from_col = getColumn(rawData.columns, "from_id"); 617 const to_col = getColumn(rawData.columns, "to_id"); 618 619 if (!id_col && !from_col && !to_col) { 620 return { 621 graph: new Graph(), 622 nodes: [], 623 edges: [], 624 nodeCategoryMap: {}, 625 nodeMap: {}, 626 edgeMap: {}, 627 root_nodes: {}, 628 categories: {}, 629 next_color_index: 0, 630 }; 631 } 632 633 const node_lookup: NodeMap = {}; 634 const root_node_lookup: NodeMap = {}; 635 const nodes_by_category: NodeCategoryMap = {}; 636 const edge_lookup: EdgeMap = {}; 637 const nodes: Node[] = []; 638 const edges: Edge[] = []; 639 640 let contains_duplicate_edges = false; 641 let colorIndex = 0; 642 643 rawData.rows.forEach((row) => { 644 const node_id: string | null = 645 has(row, "id") && row.id !== null && row.id !== undefined 646 ? row.id.toString() 647 : null; 648 const from_id: string | null = 649 has(row, "from_id") && row.from_id !== null && row.from_id !== undefined 650 ? row.from_id.toString() 651 : null; 652 const to_id: string | null = 653 has(row, "to_id") && row.to_id !== null && row.to_id !== undefined 654 ? row.to_id.toString() 655 : null; 656 const title: string | null = row.title || null; 657 const category: string | null = row.category || null; 658 const depth: number | null = 659 typeof row.depth === "number" ? row.depth : null; 660 661 if (category && !categories[category]) { 662 const overrides = categoryProperties[category]; 663 const categorySettings: Category = {}; 664 if (overrides) { 665 const overrideColor = overrides.color; 666 // @ts-ignore 667 categorySettings.color = overrideColor 668 ? overrideColor 669 : defaultCategoryColor 670 ? themeColors[colorIndex++] 671 : null; 672 if (has(overrides, "depth")) { 673 categorySettings.depth = overrides.depth; 674 } 675 if (has(overrides, "properties")) { 676 categorySettings.properties = overrides.properties; 677 } 678 if (has(overrides, "icon")) { 679 categorySettings.icon = overrides.icon; 680 } 681 if (has(overrides, "href")) { 682 categorySettings.href = overrides.href; 683 } 684 if (has(overrides, "fold")) { 685 categorySettings.fold = overrides.fold; 686 } 687 } else { 688 // @ts-ignore 689 categorySettings.color = defaultCategoryColor 690 ? themeColors[colorIndex++] 691 : null; 692 } 693 categories[category] = categorySettings; 694 } 695 696 // 5 types of row: 697 // 698 // id = node 1 1 699 // from_id & id = node & edge 1 2 3 700 // id & to_id = node & edge 1 4 5 701 // from_id & to_id = edge 2 4 6 702 // id, from_id & to_id = node & edge 1 2 4 7 703 704 const nodeAndEdgeMask = 705 (node_id ? 1 : 0) + (from_id ? 2 : 0) + (to_id ? 4 : 0); 706 const allowedNodeAndEdgeMasks = [1, 3, 5, 6, 7]; 707 708 // We must have at least a node id or an edge from_id / to_id pairing 709 if (!allowedNodeAndEdgeMasks.includes(nodeAndEdgeMask)) { 710 return new Error( 711 `Encountered dataset row with no node or edge definition: ${JSON.stringify( 712 row 713 )}` 714 ); 715 } 716 717 // If this row is a node 718 if (!!node_id) { 719 const existingNode = node_lookup[node_id]; 720 721 // Even if the node already existed, it will only have minimal info, as it 722 // could only have been created implicitly through an edge definition, so 723 // build a full node and update it 724 const node = createNode( 725 node_lookup, 726 nodes_by_category, 727 node_id, 728 title, 729 category, 730 depth, 731 row, 732 categories 733 ); 734 735 // Ensure that any existing references to this node are also updated 736 if (existingNode) { 737 const nodeIndex = nodes.findIndex((n) => n.id === node.id); 738 if (nodeIndex >= 0) { 739 nodes[nodeIndex] = node; 740 } 741 if (root_node_lookup[node.id]) { 742 root_node_lookup[node.id] = node; 743 } 744 } else { 745 graph.setNode(node_id); 746 nodes.push(node); 747 748 // Record this as a root node for now - we may remove that once we process the edges 749 root_node_lookup[node_id] = node; 750 } 751 752 // If this has an edge from another node 753 if (!!from_id && !to_id) { 754 // If we've previously recorded this as a root node, remove it 755 delete root_node_lookup[node_id]; 756 757 const existingNode = node_lookup[from_id]; 758 if (!existingNode) { 759 const node = createNode( 760 node_lookup, 761 nodes_by_category, 762 from_id, 763 null, 764 null, 765 null, 766 null, 767 {} 768 ); 769 graph.setNode(from_id); 770 nodes.push(node); 771 772 // Record this as a root node for now - we may remove that once we process the edges 773 root_node_lookup[from_id] = node; 774 } 775 776 const { edge, duplicate_edge } = recordEdge( 777 edge_lookup, 778 from_id, 779 node_id 780 ); 781 if (duplicate_edge) { 782 contains_duplicate_edges = true; 783 } 784 graph.setEdge(from_id, node_id); 785 edges.push(edge); 786 } 787 // Else if this has an edge to another node 788 else if (!!to_id && !from_id) { 789 // If we've previously recorded the target as a root node, remove it 790 delete root_node_lookup[to_id]; 791 792 const existingNode = node_lookup[to_id]; 793 if (!existingNode) { 794 const node = createNode( 795 node_lookup, 796 nodes_by_category, 797 to_id, 798 null, 799 null, 800 null, 801 null, 802 {} 803 ); 804 graph.setNode(to_id); 805 nodes.push(node); 806 } 807 808 const { edge, duplicate_edge } = recordEdge( 809 edge_lookup, 810 node_id, 811 to_id 812 ); 813 if (duplicate_edge) { 814 contains_duplicate_edges = true; 815 } 816 graph.setEdge(node_id, to_id); 817 edges.push(edge); 818 } 819 } 820 821 // If this row looks like an edge 822 if (!!from_id && !!to_id) { 823 // If we've previously recorded this as a root node, remove it 824 delete root_node_lookup[to_id]; 825 826 // Record implicit nodes from edge definition 827 const existingFromNode = node_lookup[from_id]; 828 if (!existingFromNode) { 829 const node = createNode( 830 node_lookup, 831 nodes_by_category, 832 from_id, 833 null, 834 null, 835 null, 836 null, 837 {} 838 ); 839 graph.setNode(from_id); 840 nodes.push(node); 841 // Record this as a root node for now - we may remove that once we process the edges 842 root_node_lookup[from_id] = node; 843 } 844 const existingToNode = node_lookup[to_id]; 845 if (!existingToNode) { 846 const node = createNode( 847 node_lookup, 848 nodes_by_category, 849 to_id, 850 null, 851 null, 852 null, 853 null, 854 {} 855 ); 856 graph.setNode(to_id); 857 nodes.push(node); 858 } 859 860 const { edge, duplicate_edge } = recordEdge( 861 edge_lookup, 862 from_id, 863 to_id, 864 title, 865 category, 866 nodeAndEdgeMask === 6 ? row : null 867 ); 868 if (duplicate_edge) { 869 contains_duplicate_edges = true; 870 } 871 graph.setEdge(from_id, to_id); 872 edges.push(edge); 873 } 874 }); 875 876 return { 877 graph, 878 nodes, 879 edges, 880 nodeCategoryMap: nodes_by_category, 881 nodeMap: node_lookup, 882 edgeMap: edge_lookup, 883 root_nodes: root_node_lookup, 884 categories, 885 metadata: { 886 has_multiple_roots: Object.keys(root_node_lookup).length > 1, 887 contains_duplicate_edges, 888 }, 889 next_color_index: colorIndex, 890 }; 891 }; 892 893 const buildSankeyDataInputs = (nodesAndEdges: NodesAndEdges) => { 894 const data: any[] = []; 895 const links: any[] = []; 896 const nodeDepths = {}; 897 898 nodesAndEdges.edges.forEach((edge) => { 899 let categoryOverrides: Category = {}; 900 if (edge.category && nodesAndEdges.categories[edge.category]) { 901 categoryOverrides = nodesAndEdges.categories[edge.category]; 902 } 903 904 const existingFromDepth = nodeDepths[edge.from_id]; 905 if (!existingFromDepth) { 906 nodeDepths[edge.from_id] = 0; 907 } 908 const existingToDepth = nodeDepths[edge.to_id]; 909 if (!existingToDepth) { 910 nodeDepths[edge.to_id] = nodeDepths[edge.from_id] + 1; 911 } 912 links.push({ 913 source: edge.from_id, 914 target: edge.to_id, 915 value: 0.01, 916 lineStyle: { 917 color: 918 categoryOverrides && categoryOverrides.color 919 ? categoryOverrides.color 920 : "target", 921 }, 922 }); 923 }); 924 925 nodesAndEdges.nodes.forEach((node) => { 926 let categoryOverrides; 927 if (node.category && nodesAndEdges.categories[node.category]) { 928 categoryOverrides = nodesAndEdges.categories[node.category]; 929 } 930 const dataNode = { 931 id: node.id, 932 name: node.title, 933 depth: 934 node.depth !== null 935 ? node.depth 936 : has(categoryOverrides, "depth") 937 ? categoryOverrides.depth 938 : nodeDepths[node.id], 939 itemStyle: { 940 color: 941 categoryOverrides && categoryOverrides.color 942 ? categoryOverrides.color 943 : themeColors[ 944 has(nodesAndEdges, "next_color_index") 945 ? // @ts-ignore 946 nodesAndEdges.next_color_index++ 947 : 0 948 ], 949 }, 950 }; 951 data.push(dataNode); 952 }); 953 954 return { 955 data, 956 links, 957 }; 958 }; 959 960 type Item = { 961 [key: string]: any; 962 }; 963 964 type TreeItem = { 965 [key: string]: Item | TreeItem[] | any; 966 }; 967 968 // Taken from https://github.com/philipstanislaus/performant-array-to-tree 969 const nodesAndEdgesToTree = (nodesAndEdges: NodesAndEdges): TreeItem[] => { 970 // const rootParentIds = { "": true }; 971 972 // the resulting unflattened tree 973 // const rootItems: TreeItem[] = []; 974 975 // stores all already processed items with their ids as key so we can easily look them up 976 const lookup: { [id: string]: TreeItem } = {}; 977 978 // stores all item ids that have not been added to the resulting unflattened tree yet 979 // this is an opt-in property, since it has a slight runtime overhead 980 // const orphanIds: null | Set<string | number> = new Set(); 981 982 let colorIndex = 0; 983 984 // Add in the nodes to the lookup 985 for (const node of nodesAndEdges.nodes) { 986 // look whether item already exists in the lookup table 987 if (!lookup[node.id]) { 988 // item is not yet there, so add a preliminary item (its data will be added later) 989 lookup[node.id] = { children: [] }; 990 } 991 992 let color; 993 if (node.category && nodesAndEdges.categories[node.category]) { 994 const categoryOverrides = nodesAndEdges.categories[node.category]; 995 if (categoryOverrides.color) { 996 color = categoryOverrides.color; 997 colorIndex++; 998 } else { 999 color = themeColors[colorIndex++]; 1000 } 1001 } 1002 1003 lookup[node.id] = { 1004 ...node, 1005 name: node.title, 1006 itemStyle: { 1007 color, 1008 }, 1009 children: lookup[node.id].children, 1010 }; 1011 } 1012 // Fill in the children with the edge relationships 1013 for (const edge of nodesAndEdges.edges) { 1014 const childId = edge.to_id; 1015 const parentId = edge.from_id; 1016 1017 // look whether the parent already exists in the lookup table 1018 if (!lookup[parentId]) { 1019 // parent is not yet there, so add a preliminary parent (its data will be added later) 1020 lookup[parentId] = { children: [] }; 1021 } 1022 1023 const childItem = lookup[childId]; 1024 1025 // add the current item to the parent 1026 lookup[parentId].children.push(childItem); 1027 } 1028 return Object.values(lookup).filter( 1029 (node) => nodesAndEdges.root_nodes[node.id] 1030 ); 1031 }; 1032 1033 const buildTreeDataInputs = (nodesAndEdges: NodesAndEdges) => { 1034 const tree = nodesAndEdgesToTree(nodesAndEdges); 1035 return { 1036 data: tree, 1037 }; 1038 }; 1039 1040 // TODO color scheme - need to find something better? 1041 const generateColors = () => { 1042 // echarts vintage 1043 // return [ 1044 // "#d87c7c", 1045 // "#919e8b", 1046 // "#d7ab82", 1047 // "#6e7074", 1048 // "#61a0a8", 1049 // "#efa18d", 1050 // "#787464", 1051 // "#cc7e63", 1052 // "#724e58", 1053 // "#4b565b", 1054 // ]; 1055 // tableau.Tableau20 1056 return [ 1057 "#4E79A7", 1058 "#A0CBE8", 1059 "#F28E2B", 1060 "#FFBE7D", 1061 "#59A14F", 1062 "#8CD17D", 1063 "#B6992D", 1064 "#F1CE63", 1065 "#499894", 1066 "#86BCB6", 1067 "#E15759", 1068 "#FF9D9A", 1069 "#79706E", 1070 "#BAB0AC", 1071 "#D37295", 1072 "#FABFD2", 1073 "#B07AA1", 1074 "#D4A6C8", 1075 "#9D7660", 1076 "#D7B5A6", 1077 ]; 1078 }; 1079 1080 const themeColors = generateColors(); 1081 1082 const getColorOverride = (colorOverride, namedThemeColors) => { 1083 if (colorOverride === "alert") { 1084 return namedThemeColors.alert; 1085 } 1086 if (colorOverride === "info") { 1087 return namedThemeColors.info; 1088 } 1089 if (colorOverride === "ok") { 1090 return namedThemeColors.ok; 1091 } 1092 return colorOverride; 1093 }; 1094 1095 export { 1096 adjustMinValue, 1097 adjustMaxValue, 1098 buildChartDataset, 1099 buildNodesAndEdges, 1100 buildSankeyDataInputs, 1101 buildTreeDataInputs, 1102 foldNodesAndEdges, 1103 getColorOverride, 1104 isNumericCol, 1105 themeColors, 1106 toEChartsType, 1107 };