import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; import { select } from 'd3'; import { getConfig } from '../../config.js'; import { render } from '../../dagre-wrapper/index.js'; import { log } from '../../logger.js'; import { configureSvgSize } from '../../setupGraphViewbox.js'; import common from '../common/common.js'; import utils from '../../utils.js'; import { DEFAULT_DIAGRAM_DIRECTION, DEFAULT_NESTED_DOC_DIR, STMT_STATE, STMT_RELATION, DEFAULT_STATE_TYPE, DIVIDER_TYPE, } from './stateCommon.js'; // -------------------------------------- // Shapes const SHAPE_STATE = 'rect'; const SHAPE_STATE_WITH_DESC = 'rectWithTitle'; const SHAPE_START = 'start'; const SHAPE_END = 'end'; const SHAPE_DIVIDER = 'divider'; const SHAPE_GROUP = 'roundedWithTitle'; const SHAPE_NOTE = 'note'; const SHAPE_NOTEGROUP = 'noteGroup'; // -------------------------------------- // CSS classes const CSS_DIAGRAM = 'statediagram'; const CSS_STATE = 'state'; const CSS_DIAGRAM_STATE = `${CSS_DIAGRAM}-${CSS_STATE}`; const CSS_EDGE = 'transition'; const CSS_NOTE = 'note'; const CSS_NOTE_EDGE = 'note-edge'; const CSS_EDGE_NOTE_EDGE = `${CSS_EDGE} ${CSS_NOTE_EDGE}`; const CSS_DIAGRAM_NOTE = `${CSS_DIAGRAM}-${CSS_NOTE}`; const CSS_CLUSTER = 'cluster'; const CSS_DIAGRAM_CLUSTER = `${CSS_DIAGRAM}-${CSS_CLUSTER}`; const CSS_CLUSTER_ALT = 'cluster-alt'; const CSS_DIAGRAM_CLUSTER_ALT = `${CSS_DIAGRAM}-${CSS_CLUSTER_ALT}`; // -------------------------------------- // DOM and element IDs const PARENT = 'parent'; const NOTE = 'note'; const DOMID_STATE = 'state'; const DOMID_TYPE_SPACER = '----'; const NOTE_ID = `${DOMID_TYPE_SPACER}${NOTE}`; const PARENT_ID = `${DOMID_TYPE_SPACER}${PARENT}`; // -------------------------------------- // Graph edge settings const G_EDGE_STYLE = 'fill:none'; const G_EDGE_ARROWHEADSTYLE = 'fill: #333'; const G_EDGE_LABELPOS = 'c'; const G_EDGE_LABELTYPE = 'text'; const G_EDGE_THICKNESS = 'normal'; // -------------------------------------- // List of nodes created from the parsed diagram statement items let nodeDb = {}; let graphItemCount = 0; // used to construct ids, etc. // Configuration const conf = {}; // ----------------------------------------------------------------------- export const setConf = function (cnf) { const keys = Object.keys(cnf); for (const key of keys) { conf[key] = cnf[key]; } }; /** * Returns the all the classdef styles (a.k.a. classes) from classDef statements in the graph definition. * * @param {string} text - the diagram text to be parsed * @param diagramObj * @returns {Record} ClassDef styles (a Map with keys = strings, values = ) */ export const getClasses = function (text, diagramObj) { diagramObj.db.extract(diagramObj.db.getRootDocV2()); return diagramObj.db.getClasses(); }; /** * Get classes from the db for the info item. * If there aren't any or if dbInfoItem isn't defined, return an empty string. * Else create 1 string from the list of classes found * * @param {undefined | null | object} dbInfoItem * @returns {string} */ function getClassesFromDbInfo(dbInfoItem) { if (dbInfoItem === undefined || dbInfoItem === null) { return ''; } else { if (dbInfoItem.classes) { return dbInfoItem.classes.join(' '); } else { return ''; } } } /** * Create a standard string for the dom ID of an item. * If a type is given, insert that before the counter, preceded by the type spacer * * @param itemId * @param counter * @param {string | null} type * @param typeSpacer * @returns {string} */ export function stateDomId(itemId = '', counter = 0, type = '', typeSpacer = DOMID_TYPE_SPACER) { const typeStr = type !== null && type.length > 0 ? `${typeSpacer}${type}` : ''; return `${DOMID_STATE}-${itemId}${typeStr}-${counter}`; } /** * Create a graph node based on the statement information * * @param g - graph * @param {object} parent * @param {object} parsedItem - parsed statement item * @param {object[]} diagramStates - the list of all known states for the diagram * @param {object} diagramDb * @param {boolean} altFlag - for clusters, add the "statediagram-cluster-alt" CSS class */ const setupNode = (g, parent, parsedItem, diagramStates, diagramDb, altFlag) => { const itemId = parsedItem.id; const classStr = getClassesFromDbInfo(diagramStates[itemId]); if (itemId !== 'root') { let shape = SHAPE_STATE; if (parsedItem.start === true) { shape = SHAPE_START; } if (parsedItem.start === false) { shape = SHAPE_END; } if (parsedItem.type !== DEFAULT_STATE_TYPE) { shape = parsedItem.type; } // Add the node to our list (nodeDb) if (!nodeDb[itemId]) { nodeDb[itemId] = { id: itemId, shape, description: common.sanitizeText(itemId, getConfig()), classes: `${classStr} ${CSS_DIAGRAM_STATE}`, }; } const newNode = nodeDb[itemId]; // Save data for description and group so that for instance a statement without description overwrites // one with description @todo TODO What does this mean? If important, add a test for it // Build of the array of description strings if (parsedItem.description) { if (Array.isArray(newNode.description)) { // There already is an array of strings,add to it newNode.shape = SHAPE_STATE_WITH_DESC; newNode.description.push(parsedItem.description); } else { if (newNode.description.length > 0) { // if there is a description already transform it to an array newNode.shape = SHAPE_STATE_WITH_DESC; if (newNode.description === itemId) { // If the previous description was this, remove it newNode.description = [parsedItem.description]; } else { newNode.description = [newNode.description, parsedItem.description]; } } else { newNode.shape = SHAPE_STATE; newNode.description = parsedItem.description; } } newNode.description = common.sanitizeTextOrArray(newNode.description, getConfig()); } // If there's only 1 description entry, just use a regular state shape if (newNode.description.length === 1 && newNode.shape === SHAPE_STATE_WITH_DESC) { newNode.shape = SHAPE_STATE; } // group if (!newNode.type && parsedItem.doc) { log.info('Setting cluster for ', itemId, getDir(parsedItem)); newNode.type = 'group'; newNode.dir = getDir(parsedItem); newNode.shape = parsedItem.type === DIVIDER_TYPE ? SHAPE_DIVIDER : SHAPE_GROUP; newNode.classes = newNode.classes + ' ' + CSS_DIAGRAM_CLUSTER + ' ' + (altFlag ? CSS_DIAGRAM_CLUSTER_ALT : ''); } // This is what will be added to the graph const nodeData = { labelStyle: '', shape: newNode.shape, labelText: newNode.description, // typeof newNode.description === 'object' // ? newNode.description[0] // : newNode.description, classes: newNode.classes, style: '', //styles.style, id: itemId, dir: newNode.dir, domId: stateDomId(itemId, graphItemCount), type: newNode.type, padding: 15, //getConfig().flowchart.padding }; // if (useHtmlLabels) { nodeData.centerLabel = true; // } if (parsedItem.note) { // Todo: set random id const noteData = { labelStyle: '', shape: SHAPE_NOTE, labelText: parsedItem.note.text, classes: CSS_DIAGRAM_NOTE, // useHtmlLabels: false, style: '', // styles.style, id: itemId + NOTE_ID + '-' + graphItemCount, domId: stateDomId(itemId, graphItemCount, NOTE), type: newNode.type, padding: 15, //getConfig().flowchart.padding }; const groupData = { labelStyle: '', shape: SHAPE_NOTEGROUP, labelText: parsedItem.note.text, classes: newNode.classes, style: '', // styles.style, id: itemId + PARENT_ID, domId: stateDomId(itemId, graphItemCount, PARENT), type: 'group', padding: 0, //getConfig().flowchart.padding }; graphItemCount++; const parentNodeId = itemId + PARENT_ID; g.setNode(parentNodeId, groupData); g.setNode(noteData.id, noteData); g.setNode(itemId, nodeData); g.setParent(itemId, parentNodeId); g.setParent(noteData.id, parentNodeId); let from = itemId; let to = noteData.id; if (parsedItem.note.position === 'left of') { from = noteData.id; to = itemId; } g.setEdge(from, to, { arrowhead: 'none', arrowType: '', style: G_EDGE_STYLE, labelStyle: '', classes: CSS_EDGE_NOTE_EDGE, arrowheadStyle: G_EDGE_ARROWHEADSTYLE, labelpos: G_EDGE_LABELPOS, labelType: G_EDGE_LABELTYPE, thickness: G_EDGE_THICKNESS, }); } else { g.setNode(itemId, nodeData); } } if (parent && parent.id !== 'root') { log.trace('Setting node ', itemId, ' to be child of its parent ', parent.id); g.setParent(itemId, parent.id); } if (parsedItem.doc) { log.trace('Adding nodes children '); setupDoc(g, parsedItem, parsedItem.doc, diagramStates, diagramDb, !altFlag); } }; /** * Turn parsed statements (item.stmt) into nodes, relationships, etc. for a document. * (A document may be nested within others.) * * @param g * @param parentParsedItem - parsed Item that is the parent of this document (doc) * @param doc - the document to set up; it is a list of parsed statements * @param {object[]} diagramStates - the list of all known states for the diagram * @param diagramDb * @param {boolean} altFlag * @todo This duplicates some of what is done in stateDb.js extract method */ const setupDoc = (g, parentParsedItem, doc, diagramStates, diagramDb, altFlag) => { // graphItemCount = 0; log.trace('items', doc); doc.forEach((item) => { switch (item.stmt) { case STMT_STATE: setupNode(g, parentParsedItem, item, diagramStates, diagramDb, altFlag); break; case DEFAULT_STATE_TYPE: setupNode(g, parentParsedItem, item, diagramStates, diagramDb, altFlag); break; case STMT_RELATION: { setupNode(g, parentParsedItem, item.state1, diagramStates, diagramDb, altFlag); setupNode(g, parentParsedItem, item.state2, diagramStates, diagramDb, altFlag); const edgeData = { id: 'edge' + graphItemCount, arrowhead: 'normal', arrowTypeEnd: 'arrow_barb', style: G_EDGE_STYLE, labelStyle: '', label: common.sanitizeText(item.description, getConfig()), arrowheadStyle: G_EDGE_ARROWHEADSTYLE, labelpos: G_EDGE_LABELPOS, labelType: G_EDGE_LABELTYPE, thickness: G_EDGE_THICKNESS, classes: CSS_EDGE, }; g.setEdge(item.state1.id, item.state2.id, edgeData, graphItemCount); graphItemCount++; } break; } }); }; /** * Get the direction from the statement items. * Look through all of the documents (docs) in the parsedItems * Because is a _document_ direction, the default direction is not necessarily the same as the overall default _diagram_ direction. * @param {object[]} parsedItem - the parsed statement item to look through * @param [defaultDir] - the direction to use if none is found * @returns {string} */ const getDir = (parsedItem, defaultDir = DEFAULT_NESTED_DOC_DIR) => { let dir = defaultDir; if (parsedItem.doc) { for (let i = 0; i < parsedItem.doc.length; i++) { const parsedItemDoc = parsedItem.doc[i]; if (parsedItemDoc.stmt === 'dir') { dir = parsedItemDoc.value; } } } return dir; }; /** * Draws a state diagram in the tag with id: id based on the graph definition in text. * * @param {any} text * @param {any} id * @param _version * @param diag */ export const draw = async function (text, id, _version, diag) { log.info('Drawing state diagram (v2)', id); nodeDb = {}; // Fetch the default direction, use TD if none was found let dir = diag.db.getDirection(); if (dir === undefined) { dir = DEFAULT_DIAGRAM_DIRECTION; } const { securityLevel, state: conf } = getConfig(); const nodeSpacing = conf.nodeSpacing || 50; const rankSpacing = conf.rankSpacing || 50; log.info(diag.db.getRootDocV2()); // This parses the diagram text and sets the classes, relations, styles, classDefs, etc. diag.db.extract(diag.db.getRootDocV2()); log.info(diag.db.getRootDocV2()); const diagramStates = diag.db.getStates(); // Create the input mermaid.graph const g = new graphlib.Graph({ multigraph: true, compound: true, }) .setGraph({ rankdir: getDir(diag.db.getRootDocV2()), nodesep: nodeSpacing, ranksep: rankSpacing, marginx: 8, marginy: 8, }) .setDefaultEdgeLabel(function () { return {}; }); setupNode(g, undefined, diag.db.getRootDocV2(), diagramStates, diag.db, true); // Set up an SVG group so that we can translate the final graph. let sandboxElement; if (securityLevel === 'sandbox') { sandboxElement = select('#i' + id); } const root = securityLevel === 'sandbox' ? select(sandboxElement.nodes()[0].contentDocument.body) : select('body'); const svg = root.select(`[id="${id}"]`); // Run the renderer. This is what draws the final graph. const element = root.select('#' + id + ' g'); await render(element, g, ['barb'], CSS_DIAGRAM, id); const padding = 8; utils.insertTitle(svg, 'statediagramTitleText', conf.titleTopMargin, diag.db.getDiagramTitle()); const bounds = svg.node().getBBox(); const width = bounds.width + padding * 2; const height = bounds.height + padding * 2; // Zoom in a bit svg.attr('class', CSS_DIAGRAM); const svgBounds = svg.node().getBBox(); configureSvgSize(svg, height, width, conf.useMaxWidth); // Ensure the viewBox includes the whole svgBounds area with extra space for padding const vBox = `${svgBounds.x - padding} ${svgBounds.y - padding} ${width} ${height}`; log.debug(`viewBox ${vBox}`); svg.attr('viewBox', vBox); // Add label rects for non html labels // if (!evaluate(conf.htmlLabels) || true) { const labels = document.querySelectorAll('[id="' + id + '"] .edgeLabel .label'); for (const label of labels) { // Get dimensions of label const dim = label.getBBox(); const rect = document.createElementNS('http://www.w3.org/2000/svg', SHAPE_STATE); rect.setAttribute('rx', 0); rect.setAttribute('ry', 0); rect.setAttribute('width', dim.width); rect.setAttribute('height', dim.height); label.insertBefore(rect, label.firstChild); // } } }; export default { setConf, getClasses, draw, };