core/formatters/diagram.formatter.ts

core/formatters/diagram.formatter.ts is a file in GovLab Docs. 99 lines of code and 27 definitions.

import type {
    AccessibleText,
    ClassDef,
    EdgeWeight,
    GraphEdge,
    GraphModel,
    GraphNode,
    GraphSubgraph,
} from "#types/diagram.types";
import { idSanitize, label } from "#core/normalizers/diagram.normalizer";
import { byString } from "#core/selectors/base.selector";

const INDENT = "    ";
const NESTED_INDENT = "        ";
const ELK_HEADER: readonly string[] = ["---", "config:", "  layout: elk", "---"];
const DASHED_WEIGHTS: ReadonlySet<EdgeWeight | undefined> = new Set<EdgeWeight | undefined>(["teardown", "dependency"]);

const byId = byString<GraphNode>((node) => node.id);
const byEndpoints = byString<GraphEdge>((edge) => `${edge.from} ${edge.to} ${edge.weight ?? ""} ${edge.label ?? ""}`);
const bySubgraphId = byString<GraphSubgraph>((subgraph) => subgraph.id);
const byClassName = byString<ClassDef>((def) => def.name);

export const accLines = function accLines(model: AccessibleText): string[] {
    return [
        ...(typeof model.accTitle === "string" ? [`${INDENT}accTitle: ${label(model.accTitle)}`] : []),
        ...(typeof model.accDescr === "string" ? [`${INDENT}accDescr: ${label(model.accDescr)}`] : []),
    ];
};

const wrapNode = function wrapNode(node: GraphNode): string {
    const id = idSanitize(node.id);
    const text = label(node.label);
    if (node.shape === "decision") {
        return `${id}{"${text}"}`;
    }
    return node.shape === "collaborator" ? `${id}[["${text}"]]` : `${id}["${text}"]`;
};

const arrowBody = function arrowBody(weight: EdgeWeight | undefined): string {
    if (weight === "hook") {
        return "==>";
    }
    return DASHED_WEIGHTS.has(weight) ? "-.->" : "-->";
};

const edgeArrow = function edgeArrow(weight: EdgeWeight | undefined, caption: string): string {
    const body = arrowBody(weight);
    return caption.length === 0 ? body : `${body}|${caption}|`;
};

const subgraphMembers = function subgraphMembers(subgraphs: readonly GraphSubgraph[]): Set<string> {
    return new Set(subgraphs.flatMap((subgraph) => subgraph.nodeIds));
};

const looseNodeLines = function looseNodeLines(nodes: readonly GraphNode[], inSubgraph: ReadonlySet<string>): string[] {
    return nodes.filter((node) => !inSubgraph.has(node.id)).map((node) => `${INDENT}${wrapNode(node)}`);
};

const subgraphLines = function subgraphLines(
    subgraphs: readonly GraphSubgraph[],
    nodes: readonly GraphNode[],
): string[] {
    return subgraphs.flatMap((subgraph) => {
        const members = new Set(subgraph.nodeIds);
        return [
            `${INDENT}subgraph ${idSanitize(subgraph.id)}["${label(subgraph.title)}"]`,
            ...nodes.filter((node) => members.has(node.id)).map((node) => `${NESTED_INDENT}${wrapNode(node)}`),
            `${INDENT}end`,
        ];
    });
};

const edgeLine = function edgeLine(edge: GraphEdge): string {
    const caption = typeof edge.label === "string" ? label(edge.label) : "";
    return `${INDENT}${idSanitize(edge.from)} ${edgeArrow(edge.weight, caption)} ${idSanitize(edge.to)}`;
};

const classDefLines = function classDefLines(defs: readonly ClassDef[]): string[] {
    return defs
        .toSorted(byClassName)
        .map((def) => `${INDENT}classDef ${idSanitize(def.name)} stroke:${def.stroke},stroke-width:2px;`);
};

const classMemberLines = function classMemberLines(nodes: readonly GraphNode[]): string[] {
    const members = new Map<string, string[]>();
    for (const node of nodes) {
        if (typeof node.class === "string") {
            members.set(node.class, [...(members.get(node.class) ?? []), idSanitize(node.id)]);
        }
    }
    return [...members.keys()]
        .toSorted((left, right) => left.localeCompare(right))
        .map((name) => {
            const ids = (members.get(name) ?? []).toSorted((left, right) => left.localeCompare(right));
            return `${INDENT}class ${ids.join(",")} ${idSanitize(name)};`;
        });
};

export const emitGraph = function emitGraph(model: GraphModel): string {
    const nodes = model.nodes.toSorted(byId);
    const subgraphs = (model.subgraphs ?? []).toSorted(bySubgraphId);
    return [
        ...ELK_HEADER,
        `${model.kind} ${model.direction}`,
        ...accLines(model),
        ...looseNodeLines(nodes, subgraphMembers(subgraphs)),
        ...subgraphLines(subgraphs, nodes),
        ...model.edges.toSorted(byEndpoints).map(edgeLine),
        ...classDefLines(model.classDefs ?? []),
        ...classMemberLines(nodes),
    ].join("\n");
};