ssube/cautious-journey

View on GitHub
src/graph.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
import { mustExist } from '@apextoaster/js-utils';

import { BaseLabel, FlagLabel, getValueName, StateChange, StateLabel } from './labels.js';
import { ChangeVerb } from './resolve.js';
import { defaultTo, defaultUntil } from './utils.js';

export const COLOR_CHANGE = 'aaaaaa';
export const COLOR_LABEL = 'cccccc';

export enum EdgeType {
  /**
   * Both directions, arrows on both ends.
   */
  BOTH = 'both',

  /**
   * Source to target, arrow at target.
   */
  FORWARD = 'forward',
}

export interface Node {
  color: string;
  name: string;
}

export interface Edge {
  source: string;
  target: string;
  type: EdgeType;
  verb: ChangeVerb;
}

export interface Graph {
  children: Array<Graph>;
  edges: Array<Edge>;
  name: string;
  nodes: Array<Node>;
}

export interface GraphOptions {
  flags: Array<FlagLabel>;
  name: string;
  states: Array<StateLabel>;
}

export function labelEdges(label: BaseLabel, edges: Array<Edge>) {
  for (const add of label.adds) {
    edges.push({
      source: label.name,
      target: add.name,
      type: EdgeType.FORWARD,
      verb: ChangeVerb.CREATED,
    });
  }

  for (const remove of label.removes) {
    edges.push({
      source: label.name,
      target: remove.name,
      type: EdgeType.FORWARD,
      verb: ChangeVerb.REMOVED,
    });
  }

  for (const require of label.requires) {
    edges.push({
      source: label.name,
      target: require.name,
      type: EdgeType.FORWARD,
      verb: ChangeVerb.REQUIRED,
    });
  }
}

export function mergeEdges(edges: Array<Edge>): Array<Edge> {
  const uniqueEdges = new Map<string, Edge>();

  for (const edge of edges) {
    const sortedNodes = [edge.source, edge.target].sort();
    const dirName = [edge.verb, ...sortedNodes].join(':');

    if (uniqueEdges.has(dirName)) {
      const prevEdge = mustExist(uniqueEdges.get(dirName));
      if (edge.type !== prevEdge.type || edge.source !== prevEdge.source) {
        prevEdge.type = EdgeType.BOTH;
      }
    } else {
      uniqueEdges.set(dirName, edge);
    }
  }

  return Array.from(uniqueEdges.values());
}

export function graphChange(root: Graph, change: StateChange, name: string, priority: number): void {
  const matchNames = change.matches.map((it) => it.name).join(',');
  const matchLabel = `${name} with (${matchNames})`;

  root.nodes.push({
    color: COLOR_CHANGE,
    name: matchLabel,
  });

  root.edges.push({
    source: name,
    target: matchLabel,
    type: EdgeType.FORWARD,
    verb: ChangeVerb.BECAME,
  });

  labelEdges({
    adds: change.adds,
    name: matchLabel,
    priority,
    removes: change.removes,
    requires: change.matches,
  }, root.edges);
}

export function graphState(state: StateLabel): Graph {
  const child: Graph = {
    children: [],
    edges: [],
    name: state.name,
    nodes: [],
  };

  for (const value of state.values) {
    const name = getValueName(state, value);
    const priority = defaultUntil(value.priority, state.priority, 0);

    child.nodes.push({
      color: defaultUntil(value.color, state.color, COLOR_LABEL),
      name,
    });

    labelEdges({
      ...value,
      name,
    }, child.edges);

    for (const otherValue of state.values) {
      if (value !== otherValue) {
        const otherName = getValueName(state, otherValue);
        child.edges.push({
          source: name,
          target: otherName,
          type: EdgeType.FORWARD,
          verb: ChangeVerb.CONFLICTED,
        });
      }
    }

    for (const change of value.becomes) {
      graphChange(child, change, name, priority);
    }
  }

  return child;
}

export function graphProject(options: GraphOptions): Graph {
  const root: Graph = {
    children: [],
    edges: [],
    name: options.name,
    nodes: [],
  };

  for (const flag of options.flags) {
    root.nodes.push({
      color: defaultTo(flag.color, COLOR_LABEL),
      name: flag.name,
    });

    labelEdges(flag, root.edges);
  }

  for (const state of options.states) {
    const child = graphState(state);
    root.children.push(child);
  }

  return root;
}

export function cleanName(name: string): string {
  return name.replace(/[^a-z0-9_]/g, '_').replace('__', '_').replace(/(^_|_$)/g, '');
}

export function edgeColor(verb: ChangeVerb): string {
  switch (verb) {
    case ChangeVerb.BECAME:
      return 'purple';
    case ChangeVerb.CONFLICTED:
      return 'orange';
    case ChangeVerb.CREATED:
      return 'green';
    case ChangeVerb.REMOVED:
      return 'red';
    case ChangeVerb.REQUIRED:
      return 'blue';
    default:
      return 'gray';
  }
}

export function edgeStyle(edge: Edge) {
  const color = edgeColor(edge.verb);
  const dir = edge.type;

  switch (edge.verb) {
    case ChangeVerb.BECAME:
    case ChangeVerb.REQUIRED:
      return `[dir="${dir}" color="${color}" arrowhead="onormal"]`;
    case ChangeVerb.CONFLICTED:
      return `[dir="${dir}" color="${color}" weight=0.1]`;
    case ChangeVerb.CREATED:
      return `[dir="${dir}" color="${color}" weight=0.8]`;
    case ChangeVerb.REMOVED:
    default:
      return `[dir="${dir}" color="${color}"]`;
  }
}

export function dotGraph(graph: Graph): string {
  const lines = [];
  const name = cleanName(graph.name);
  lines.push(`digraph ${name} {`);

  // flag nodes
  lines.push('subgraph cluster_flags {');
  lines.push('label = "flags";');
  lines.push('color = gray');

  for (const node of graph.nodes) {
    const nodeName = cleanName(node.name);
    lines.push(`${nodeName} [color="#${node.color}" label="${node.name}" style=filled];`);
  }

  lines.push('}');

  // state clusters
  for (const sub of graph.children) {
    const subName = cleanName(sub.name);
    lines.push(`subgraph cluster_${subName} {`);
    lines.push(`label = "${subName}";`);
    lines.push('color = gray');

    for (const edge of mergeEdges(sub.edges)) {
      const source = cleanName(edge.source);
      const target = cleanName(edge.target);
      lines.push(`${source} -> ${target} ${edgeStyle(edge)};`);
    }

    for (const node of sub.nodes) {
      const nodeName = cleanName(node.name);
      lines.push(`${nodeName} [color="#${node.color}" label="${node.name}" style=filled];`);
    }

    lines.push('}');
  }

  // remaining edges
  for (const edge of mergeEdges(graph.edges)) {
    const source = cleanName(edge.source);
    const target = cleanName(edge.target);
    lines.push(`${source} -> ${target} ${edgeStyle(edge)};`);
  }

  lines.push('}');
  return lines.join('\n');
}