src/render/dot/index.mts
/* eslint-disable max-lines */
/* eslint-disable no-use-before-define */
/* eslint-disable complexity */
import he from "he";
import type {
IStateMachine,
IRenderOptions,
IState,
ITransition,
StateType,
IActionType,
} from "../../../types/state-machine-cat.mjs";
import { getOptionValue } from "../../options.mjs";
import StateMachineModel from "../../state-machine-model.mjs";
import {
buildGraphAttributes,
buildNodeAttributes,
buildEdgeAttributes,
} from "./attributebuilder.mjs";
import {
escapeLabelString,
formatActionType,
getTransitionPorts,
isCompositeSelf,
isVertical,
type IStateNormalized,
noteToLabel,
normalizeState,
stateNote,
} from "./utl.mjs";
function initial(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " penwidth=3.0" : "";
return `${pIndent} "${pState.name}" [shape=circle style=filled class="${pState.class}" color="${pState.color}" fillcolor="${pState.color}" fixedsize=true height=0.15 label=""${lActiveAttribute}]${pState.noteText}`;
}
function regularStateActions(pActions: IActionType[], pIndent: string): string {
return pActions
.map((pAction) =>
he.escape(`${formatActionType(pAction.type)}${pAction.body}`),
)
.map((pActionString, pIndex) => {
let lReturnValue = `<tr><td align="left" cellpadding="2">${pActionString}</td></tr>`;
if (pIndex === 0) {
lReturnValue = `<hr/>${lReturnValue}`;
}
return `\n${pIndent} ${lReturnValue}`;
})
.join("");
}
// TODO: regularStateActions and compositeStateActions differ by the 'cellpadding' attribute
// - would it hurt to add it to the composite, so we can have just one function?
// - if not - parametrize?
function compositeStateActions(
pActions: IActionType[],
pIndent: string,
): string {
return pActions
.map((pAction) =>
he.escape(`${formatActionType(pAction.type)}${pAction.body}`),
)
.map((pActionString, pIndex) => {
let lReturnValue = `<tr><td align="left">${pActionString}</td></tr>`;
if (pIndex === 0) {
lReturnValue = `<hr/>${lReturnValue}`;
}
return `\n${pIndent} ${lReturnValue}`;
})
.join("");
}
function atomicRegular(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " peripheries=1 style=rounded" : "";
// eslint-disable-next-line no-magic-numbers
const lCellPadding = (pState.actions?.length ?? 0) > 0 ? 2 : 7;
const lActions = regularStateActions(pState?.actions ?? [], pIndent);
const lLabel = pState.active ? `<i>${pState.label}</i>` : pState.label;
const lLabelTag = `
${pIndent} <table align="center" cellborder="0" border="2" style="rounded" width="48">
${pIndent} <tr><td width="48" cellpadding="${lCellPadding}">${lLabel}</td></tr>${lActions}
${pIndent} </table>`;
return `${pIndent} "${pState.name}" [margin=0 class="${pState.class}" label= <${lLabelTag}
${pIndent} >${pState.colorAttribute}${pState.fontColorAttribute}${lActiveAttribute}]${pState.noteText}`;
}
function compositeRegular(
pState: IStateNormalized,
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
// eslint-disable-next-line no-nested-ternary
const lPenWidth = pState.isParallelArea
? "1.0"
: pState.active
? "3.0"
: "2.0";
const lStyle = pState.isParallelArea ? "dashed" : "rounded";
const lActions = compositeStateActions(pState?.actions ?? [], pIndent);
const lLabel = pState.active ? `<i>${pState.label}</i>` : pState.label;
const lLabelTag = `${pIndent} <table cellborder="0" border="0">
${pIndent} <tr><td>${lLabel}</td></tr>${lActions}
${pIndent} </table>`;
const lSelfTransitionHelperPoints = pModel
.findExternalSelfTransitions(pState.name)
.map(
(pTransition) =>
`${pIndent} "self_tr_${pTransition.from}_${pTransition.to}_${pTransition.id}" [shape=point style=invis width=0 height=0 fixedsize=true]\n`,
)
.join("");
return `${lSelfTransitionHelperPoints}${pIndent} subgraph "cluster_${pState.name}" {
${pIndent} class="${pState.class}" label= <
${lLabelTag}
${pIndent} > style=${lStyle} penwidth=${lPenWidth}${pState.colorAttribute}${pState.fontColorAttribute}
${pIndent} "${pState.name}" [shape=point style=invis margin=0 width=0 height=0 fixedsize=true]
${states(pState?.statemachine?.states ?? [], `${pIndent} `, pOptions, pModel)}
${pIndent} }${pState.noteText}`;
}
function regular(
pState: IStateNormalized,
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
if (pState.statemachine) {
return compositeRegular(pState, pIndent, pOptions, pModel);
}
return atomicRegular(pState, pIndent);
}
function history(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " peripheries=2 penwidth=3.0" : "";
return `${pIndent} "${pState.name}" [shape=circle class="${pState.class}" label="H"${pState.colorAttribute}${pState.fontColorAttribute}${lActiveAttribute}]${pState.noteText}`;
}
function deepHistory(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " peripheries=2 penwidth=3.0" : "";
return `${pIndent} "${pState.name}" [shape=circle class="${pState.class}" label="H*"${pState.colorAttribute}${pState.fontColorAttribute}${lActiveAttribute}]${pState.noteText}`;
}
function choiceActions(pActions: IActionType[], pActive: boolean): string {
return pActions
.map((pAction) => {
let lReturnValue = he.escape(
`${formatActionType(pAction.type)}${pAction.body}`,
);
if (pActive) {
lReturnValue = `<i>${lReturnValue}</i>`;
}
return lReturnValue;
})
.join("\\n");
}
function choice(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? "penwidth=3.0 " : "";
const lActions = choiceActions(
pState?.actions ?? [],
pState?.active ?? false,
);
const lLabelTag = lActions;
const lDiamond = `${pIndent} "${pState.name}" [shape=diamond fixedsize=true width=0.35 height=0.35 fontsize=10 label=" " class="${pState.class}"${pState.colorAttribute}${lActiveAttribute}]`;
const lLabelConstruct = `${pIndent} "${pState.name}" -> "${pState.name}" [color="#FFFFFF01" fontcolor="${pState.color}" class="${pState.class}" label=<${lLabelTag}>]`;
return `${lDiamond}\n${lLabelConstruct}${pState.noteText}`;
}
function forkjoin(
pState: IStateNormalized,
pIndent: string,
pOptions: IRenderOptions,
): string {
const lActiveAttribute = pState.active ? "penwidth=3.0 " : "";
const lDirection = getOptionValue(pOptions, "direction") as string;
const lSizingExtras = isVertical(lDirection) ? " height=0.1" : " width=0.1";
return `${pIndent} "${pState.name}" [shape=rect fixedsize=true label=" " style=filled class="${pState.class}" color="${pState.color}" fillcolor="${pState.color}"${lActiveAttribute}${lSizingExtras}]${pState.noteText}`;
}
function junction(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " penwidth=3.0" : "";
const lNote = stateNote(pState, pIndent);
return `${pIndent} "${pState.name}" [shape=circle fixedsize=true height=0.15 label="" style=filled class="${pState.class}" color="${pState.color}" fillcolor="${pState.color}"${lActiveAttribute}]${lNote}`;
}
function terminate(pState: IStateNormalized, pIndent: string): string {
const lLabelTag = `
${pIndent} <table align="center" cellborder="0" border="0">
${pIndent} <tr><td cellpadding="0"><font color="${pState.color}" point-size="20">X</font></td></tr>
${pIndent} <tr><td cellpadding="0"><font color="${pState.color}">${pState.label}</font></td></tr>
${pIndent} </table>`;
return `${pIndent} "${pState.name}" [label= <${lLabelTag}
${pIndent} > class="${pState.class}"]${pState.noteText}`;
}
function final(pState: IStateNormalized, pIndent: string): string {
const lActiveAttribute = pState.active ? " peripheries=2 penwidth=3.0" : "";
return `${pIndent} "${pState.name}" [shape=circle style=filled class="${pState.class}" color="${pState.color}" fillcolor="${pState.color}" fixedsize=true height=0.15 peripheries=2 label=""${lActiveAttribute}]${pState.noteText}`;
}
// @ts-expect-error - TS is yapping about something that just works :shrug:
const STATE_TYPE2FUNCTION = new Map<
StateType,
(
pState: IStateNormalized,
pIndent: string,
pOptions: IRenderOptions,
pModel?: StateMachineModel,
) => string
>([
["initial", initial],
["regular", regular],
["history", history],
["deephistory", deepHistory],
["choice", choice],
["fork", forkjoin],
["forkjoin", forkjoin],
["join", forkjoin],
["junction", junction],
["terminate", terminate],
["final", final],
// parallel
]);
function state(
pState: IState,
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
const lState = normalizeState(pState, pOptions, pIndent);
return (
// eslint-disable-next-line prefer-template
(STATE_TYPE2FUNCTION.get(pState.type) ?? regular)(
lState,
pIndent,
pOptions,
pModel,
) + "\n"
);
}
function states(
pStates: IState[],
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
return pStates
.map((pState) => state(pState, pIndent, pOptions, pModel))
.join("");
}
// eslint-disable-next-line max-statements, max-lines-per-function
function transition(
pTransition: ITransition,
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
// TODO: should also be he.escape'd?
const lLabel = `${escapeLabelString(pTransition.label ?? " ")}`;
// using a default color (`pTransition.color ?? "black"`) makes the output
// look more consistent and easier to check, but it also blocks the 'inheritance'
//
const lColorAttribute = pTransition.color
? ` color="${pTransition.color}"`
: "";
const lFontColorAttribute = pTransition.color
? ` fontcolor="${pTransition.color}"`
: "";
const lPenWidth = pTransition.width ? ` penwidth=${pTransition.width}` : "";
const lClass = pTransition.class
? // eslint-disable-next-line prefer-template
`transition${pTransition.type ? " " + pTransition.type + " " : " "}${pTransition.class}`
: // eslint-disable-next-line prefer-template
`transition${pTransition.type ? " " + pTransition.type : ""}`;
// for transitions to/ from composite states put the _cluster_ as the head
// instead of the state itself
const lTail = pModel.findStateByName(pTransition.from)?.statemachine
? ` ltail="cluster_${pTransition.from}"`
: "";
const lHead = pModel.findStateByName(pTransition.to)?.statemachine
? ` lhead="cluster_${pTransition.to}"`
: "";
const lTransitionName = `tr_${pTransition.from}_${pTransition.to}_${pTransition.id}`;
// to attach a note, split the transition in half, reconnect them via an
// in-between point and connect the note to that in-between point as well
if (pTransition.note) {
const lNoteName = `note_${lTransitionName}`;
const lNoteNodeName = `i_${lNoteName}`;
const lNoteNode = `\n${pIndent} "${lNoteNodeName}" [shape=point style=invis margin=0 width=0 height=0 fixedsize=true]`;
const lTransitionFrom = `\n${pIndent} "${pTransition.from}" -> "${lNoteNodeName}" [arrowhead=none${lTail}${lColorAttribute}]`;
const lTransitionTo = `\n${pIndent} "${lNoteNodeName}" -> "${pTransition.to}" [label="${lLabel}"${lHead}${lColorAttribute}${lFontColorAttribute}]`;
const lLineToNote = `\n${pIndent} "${lNoteNodeName}" -> "${lNoteName}" [style=dashed arrowtail=none arrowhead=none weight=0]`;
const lNote = `\n${pIndent} "${lNoteName}" [label="${noteToLabel(pTransition.note)}" shape=note fontsize=10 color=black fontcolor=black fillcolor="#ffffcc" penwidth=1.0]`;
return lNoteNode + lTransitionFrom + lTransitionTo + lLineToNote + lNote;
}
if (isCompositeSelf(pModel, pTransition)) {
// for self-transitions to/ from composite states ensure the transition leaves
// and enters to/ from the right side of the state
const { lTailPorts, lHeadPorts } = getTransitionPorts(
pOptions,
pModel,
pTransition,
);
// the invisible 'self' node is declared with the state. If we do it later
// the transition is going to look ugly
// TODO shouldn't there be a penwidth in the from transition as well?
const lTransitionFrom = `\n${pIndent} "${pTransition.from}" -> "self_tr_${pTransition.from}_${pTransition.to}_${pTransition.id}" [label="${lLabel}" arrowhead=none class="${lClass}"${lTailPorts}${lTail}${lColorAttribute}${lFontColorAttribute}]`;
const lTransitionTo = `\n${pIndent} "self_tr_${pTransition.from}_${pTransition.to}_${pTransition.id}" -> "${pTransition.to}" [class="${lClass}"${lHead}${lHeadPorts}${lColorAttribute}${lPenWidth}]`;
return lTransitionFrom + lTransitionTo;
}
// TODO: corner case
// a composite self transition with a note wasn't handled in the original code
// either - so you'd get a self transition with a note only, which works
// but doesn't look great.
return `\n${pIndent} "${pTransition.from}" -> "${pTransition.to}" [label="${lLabel}" class="${lClass}"${lTail}${lHead}${lColorAttribute}${lFontColorAttribute}${lPenWidth}]`;
}
function transitions(
pTransitions: ITransition[],
pIndent: string,
pOptions: IRenderOptions,
pModel: StateMachineModel,
): string {
return pTransitions
.map((pTransition) => transition(pTransition, pIndent, pOptions, pModel))
.join("");
}
export default function renderDot(
pStateMachine: IStateMachine,
pOptions: IRenderOptions = {},
pIndent: string = "",
): string {
const lGraphAttributes = buildGraphAttributes(
getOptionValue(pOptions, "engine") as string,
getOptionValue(pOptions, "direction") as string,
pOptions?.dotGraphAttrs || [],
);
const lNodeAttributes = buildNodeAttributes(pOptions.dotNodeAttrs || []);
const lEdgeAttributes = buildEdgeAttributes(pOptions.dotEdgeAttrs || []);
const lModel = new StateMachineModel(pStateMachine);
const lStates = states(pStateMachine.states, pIndent, pOptions, lModel);
// ideally, we render transitions together with the states. However, in graphviz
// that only renders as we want to if we if the transition is _within_ the state.
// In this guy 'a' is rendered within cluster_b, though
// digraph {
// a
// subgraph "cluster_b" {
// label=b
// ba
// ba -> a
// }
// }
// This is documented and defined behavior in graphviz, so we will have to
// work around that...
// one way to escape that is to render all transitions separately in one go
// which (accidentally) did in the previous render engine. For now we're
// going to do that here as well.
// Another way would be to render the transitions in the most outer state of
// the (to, from).
const lTransitions = transitions(
lModel.flattenedTransitions,
pIndent,
pOptions,
lModel,
);
return `digraph "state transitions" {
${lGraphAttributes}
node [${lNodeAttributes}]
edge [${lEdgeAttributes}]
${lStates}${lTransitions}
}
`;
}