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  };