diff --git a/packages/mermaid/src/diagrams/kanban/detector.ts b/packages/mermaid/src/diagrams/kanban/detector.ts new file mode 100644 index 0000000000..3c07ca4dff --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/detector.ts @@ -0,0 +1,23 @@ +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; +const id = 'kanban'; + +const detector: DiagramDetector = (txt) => { + return /^\s*kanban/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./kanban-definition.js'); + return { id, diagram }; +}; + +const plugin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; + +export default plugin; diff --git a/packages/mermaid/src/diagrams/kanban/kanban-definition.ts b/packages/mermaid/src/diagrams/kanban/kanban-definition.ts new file mode 100644 index 0000000000..24617a1e9f --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanban-definition.ts @@ -0,0 +1,13 @@ +// @ts-ignore: JISON doesn't support types +import parser from './parser/mindmap.jison'; +import db from './kanbanDb.js'; +import renderer from './kanbanRenderer.js'; +import styles from './styles.js'; +import type { DiagramDefinition } from '../../diagram-api/types.js'; + +export const diagram: DiagramDefinition = { + db, + renderer, + parser, + styles, +}; diff --git a/packages/mermaid/src/diagrams/kanban/kanban.spec.ts b/packages/mermaid/src/diagrams/kanban/kanban.spec.ts new file mode 100644 index 0000000000..2b35f54ed7 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanban.spec.ts @@ -0,0 +1,365 @@ +// @ts-expect-error No types available for JISON +import { parser as kanban } from './parser/kanban.jison'; +import kanbanDB from './kanbanDb.js'; +// Todo fix utils functions for tests +import { setLogLevel } from '../../diagram-api/diagramAPI.js'; + +describe('when parsing a kanban ', function () { + beforeEach(function () { + kanban.yy = kanbanDB; + kanban.yy.clear(); + setLogLevel('trace'); + }); + describe('hiearchy', function () { + it('KNBN-1 should handle a simple root definition abc122', function () { + const str = `kanban + root`; + + kanban.parse(str); + // console.log('Time for checks', kanban.yy.getMindmap().descr); + expect(kanban.yy.getMindmap().descr).toEqual('root'); + }); + it('KNBN-2 should handle a hierachial kanban definition', function () { + const str = `kanban + root + child1 + child2 + `; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('root'); + expect(mm.children.length).toEqual(2); + expect(mm.children[0].descr).toEqual('child1'); + expect(mm.children[1].descr).toEqual('child2'); + }); + + it('3 should handle a simple root definition with a shape and without an id abc123', function () { + const str = `kanban + (root)`; + + kanban.parse(str); + // console.log('Time for checks', kanban.yy.getMindmap().descr); + expect(kanban.yy.getMindmap().descr).toEqual('root'); + }); + + it('KNBN-4 should handle a deeper hierachial kanban definition', function () { + const str = `kanban + root + child1 + leaf1 + child2`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('root'); + expect(mm.children.length).toEqual(2); + expect(mm.children[0].descr).toEqual('child1'); + expect(mm.children[0].children[0].descr).toEqual('leaf1'); + expect(mm.children[1].descr).toEqual('child2'); + }); + it('5 Multiple roots are illegal', function () { + const str = `kanban + root + fakeRoot`; + + expect(() => kanban.parse(str)).toThrow( + 'There can be only one root. No parent could be found for ("fakeRoot")' + ); + }); + it('KNBN-6 real root in wrong place', function () { + const str = `kanban + root + fakeRoot + realRootWrongPlace`; + expect(() => kanban.parse(str)).toThrow( + 'There can be only one root. No parent could be found for ("fakeRoot")' + ); + }); + }); + describe('nodes', function () { + it('KNBN-7 should handle an id and type for a node definition', function () { + const str = `kanban + root[The root] + `; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('The root'); + expect(mm.type).toEqual(kanban.yy.nodeType.RECT); + }); + it('KNBN-8 should handle an id and type for a node definition', function () { + const str = `kanban + root + theId(child1)`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('root'); + expect(mm.children.length).toEqual(1); + const child = mm.children[0]; + expect(child.descr).toEqual('child1'); + expect(child.nodeId).toEqual('theId'); + expect(child.type).toEqual(kanban.yy.nodeType.ROUNDED_RECT); + }); + it('KNBN-9 should handle an id and type for a node definition', function () { + const str = `kanban +root + theId(child1)`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('root'); + expect(mm.children.length).toEqual(1); + const child = mm.children[0]; + expect(child.descr).toEqual('child1'); + expect(child.nodeId).toEqual('theId'); + expect(child.type).toEqual(kanban.yy.nodeType.ROUNDED_RECT); + }); + it('KNBN-10 multiple types (circle)', function () { + const str = `kanban + root((the root)) + `; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('the root'); + expect(mm.children.length).toEqual(0); + expect(mm.type).toEqual(kanban.yy.nodeType.CIRCLE); + }); + + it('KNBN-11 multiple types (cloud)', function () { + const str = `kanban + root)the root( +`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('the root'); + expect(mm.children.length).toEqual(0); + expect(mm.type).toEqual(kanban.yy.nodeType.CLOUD); + }); + it('KNBN-12 multiple types (bang)', function () { + const str = `kanban + root))the root(( +`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.descr).toEqual('the root'); + expect(mm.children.length).toEqual(0); + expect(mm.type).toEqual(kanban.yy.nodeType.BANG); + }); + + it('KNBN-12-a multiple types (hexagon)', function () { + const str = `kanban + root{{the root}} +`; + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.type).toEqual(kanban.yy.nodeType.HEXAGON); + expect(mm.descr).toEqual('the root'); + expect(mm.children.length).toEqual(0); + }); + }); + describe('decorations', function () { + it('KNBN-13 should be possible to set an icon for the node', function () { + const str = `kanban + root[The root] + ::icon(bomb) + `; + // ::class1 class2 + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('The root'); + expect(mm.type).toEqual(kanban.yy.nodeType.RECT); + expect(mm.icon).toEqual('bomb'); + }); + it('KNBN-14 should be possible to set classes for the node', function () { + const str = `kanban + root[The root] + :::m-4 p-8 + `; + // ::class1 class2 + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('The root'); + expect(mm.type).toEqual(kanban.yy.nodeType.RECT); + expect(mm.class).toEqual('m-4 p-8'); + }); + it('KNBN-15 should be possible to set both classes and icon for the node', function () { + const str = `kanban + root[The root] + :::m-4 p-8 + ::icon(bomb) + `; + // ::class1 class2 + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('The root'); + expect(mm.type).toEqual(kanban.yy.nodeType.RECT); + expect(mm.class).toEqual('m-4 p-8'); + expect(mm.icon).toEqual('bomb'); + }); + it('KNBN-16 should be possible to set both classes and icon for the node', function () { + const str = `kanban + root[The root] + ::icon(bomb) + :::m-4 p-8 + `; + // ::class1 class2 + + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('The root'); + expect(mm.type).toEqual(kanban.yy.nodeType.RECT); + expect(mm.class).toEqual('m-4 p-8'); + expect(mm.icon).toEqual('bomb'); + }); + }); + describe('descriptions', function () { + it('KNBN-17 should be possible to use node syntax in the descriptions', function () { + const str = `kanban + root["String containing []"] +`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('String containing []'); + }); + it('KNBN-18 should be possible to use node syntax in the descriptions in children', function () { + const str = `kanban + root["String containing []"] + child1["String containing ()"] +`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('String containing []'); + expect(mm.children.length).toEqual(1); + expect(mm.children[0].descr).toEqual('String containing ()'); + }); + it('KNBN-19 should be possible to have a child after a class assignment', function () { + const str = `kanban + root(Root) + Child(Child) + :::hot + a(a) + b[New Stuff]`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('Root'); + expect(mm.children.length).toEqual(1); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('Child'); + expect(child.children[0].nodeId).toEqual('a'); + expect(child.children.length).toEqual(2); + expect(child.children[1].nodeId).toEqual('b'); + }); + }); + it('KNBN-20 should be possible to have meaningless empty rows in a kanban abc124', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) + + b[New Stuff]`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('Root'); + expect(mm.children.length).toEqual(1); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('Child'); + expect(child.children[0].nodeId).toEqual('a'); + expect(child.children.length).toEqual(2); + expect(child.children[1].nodeId).toEqual('b'); + }); + it('KNBN-21 should be possible to have comments in a kanban', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) + + %% This is a comment + b[New Stuff]`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('Root'); + expect(mm.children.length).toEqual(1); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('Child'); + expect(child.children[0].nodeId).toEqual('a'); + expect(child.children.length).toEqual(2); + expect(child.children[1].nodeId).toEqual('b'); + }); + + it('KNBN-22 should be possible to have comments at the end of a line', function () { + const str = `kanban + root(Root) + Child(Child) + a(a) %% This is a comment + b[New Stuff]`; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.descr).toEqual('Root'); + expect(mm.children.length).toEqual(1); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('Child'); + expect(child.children[0].nodeId).toEqual('a'); + expect(child.children.length).toEqual(2); + expect(child.children[1].nodeId).toEqual('b'); + }); + it('KNBN-23 Rows with only spaces should not interfere', function () { + const str = 'kanban\nroot\n A\n \n\n B'; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.children.length).toEqual(2); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('A'); + const child2 = mm.children[1]; + expect(child2.nodeId).toEqual('B'); + }); + it('KNBN-24 Handle rows above the kanban declarations', function () { + const str = '\n \nkanban\nroot\n A\n \n\n B'; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.children.length).toEqual(2); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('A'); + const child2 = mm.children[1]; + expect(child2.nodeId).toEqual('B'); + }); + it('KNBN-25 Handle rows above the kanban declarations, no space', function () { + const str = '\n\n\nkanban\nroot\n A\n \n\n B'; + kanban.parse(str); + const mm = kanban.yy.getMindmap(); + expect(mm.nodeId).toEqual('root'); + expect(mm.children.length).toEqual(2); + + const child = mm.children[0]; + expect(child.nodeId).toEqual('A'); + const child2 = mm.children[1]; + expect(child2.nodeId).toEqual('B'); + }); +}); diff --git a/packages/mermaid/src/diagrams/kanban/kanbanDb.ts b/packages/mermaid/src/diagrams/kanban/kanbanDb.ts new file mode 100644 index 0000000000..04a2d445e0 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanDb.ts @@ -0,0 +1,159 @@ +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { D3Element } from '../../types.js'; +import { sanitizeText } from '../../diagrams/common/common.js'; +import { log } from '../../logger.js'; +import type { MindmapNode } from './kanbanTypes.js'; +import defaultConfig from '../../defaultConfig.js'; + +let nodes: MindmapNode[] = []; +let cnt = 0; +let elements: Record = {}; + +const clear = () => { + nodes = []; + cnt = 0; + elements = {}; +}; + +const getParent = function (level: number) { + for (let i = nodes.length - 1; i >= 0; i--) { + if (nodes[i].level < level) { + return nodes[i]; + } + } + // No parent found + return null; +}; + +const getMindmap = () => { + return nodes.length > 0 ? nodes[0] : null; +}; + +const addNode = (level: number, id: string, descr: string, type: number) => { + log.info('addNode', level, id, descr, type); + const conf = getConfig(); + let padding: number = conf.mindmap?.padding ?? defaultConfig.mindmap.padding; + switch (type) { + case nodeType.ROUNDED_RECT: + case nodeType.RECT: + case nodeType.HEXAGON: + padding *= 2; + } + + const node = { + id: cnt++, + nodeId: sanitizeText(id, conf), + level, + descr: sanitizeText(descr, conf), + type, + children: [], + width: conf.mindmap?.maxNodeWidth ?? defaultConfig.mindmap.maxNodeWidth, + padding, + } satisfies MindmapNode; + + const parent = getParent(level); + if (parent) { + parent.children.push(node); + // Keep all nodes in the list + nodes.push(node); + } else { + if (nodes.length === 0) { + // First node, the root + nodes.push(node); + } else { + // Syntax error ... there can only bee one root + throw new Error( + 'There can be only one root. No parent could be found for ("' + node.descr + '")' + ); + } + } +}; + +const nodeType = { + DEFAULT: 0, + NO_BORDER: 0, + ROUNDED_RECT: 1, + RECT: 2, + CIRCLE: 3, + CLOUD: 4, + BANG: 5, + HEXAGON: 6, +}; + +const getType = (startStr: string, endStr: string): number => { + log.debug('In get type', startStr, endStr); + switch (startStr) { + case '[': + return nodeType.RECT; + case '(': + return endStr === ')' ? nodeType.ROUNDED_RECT : nodeType.CLOUD; + case '((': + return nodeType.CIRCLE; + case ')': + return nodeType.CLOUD; + case '))': + return nodeType.BANG; + case '{{': + return nodeType.HEXAGON; + default: + return nodeType.DEFAULT; + } +}; + +const setElementForId = (id: number, element: D3Element) => { + elements[id] = element; +}; + +const decorateNode = (decoration?: { class?: string; icon?: string }) => { + if (!decoration) { + return; + } + const config = getConfig(); + const node = nodes[nodes.length - 1]; + if (decoration.icon) { + node.icon = sanitizeText(decoration.icon, config); + } + if (decoration.class) { + node.class = sanitizeText(decoration.class, config); + } +}; + +const type2Str = (type: number) => { + switch (type) { + case nodeType.DEFAULT: + return 'no-border'; + case nodeType.RECT: + return 'rect'; + case nodeType.ROUNDED_RECT: + return 'rounded-rect'; + case nodeType.CIRCLE: + return 'circle'; + case nodeType.CLOUD: + return 'cloud'; + case nodeType.BANG: + return 'bang'; + case nodeType.HEXAGON: + return 'hexgon'; // cspell: disable-line + default: + return 'no-border'; + } +}; + +// Expose logger to grammar +const getLogger = () => log; +const getElementById = (id: number) => elements[id]; + +const db = { + clear, + addNode, + getMindmap, + nodeType, + getType, + setElementForId, + decorateNode, + type2Str, + getLogger, + getElementById, +} as const; + +export default db; diff --git a/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts b/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts new file mode 100644 index 0000000000..62e066e61f --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanRenderer.ts @@ -0,0 +1,203 @@ +import cytoscape from 'cytoscape'; +// @ts-expect-error No types available +import coseBilkent from 'cytoscape-cose-bilkent'; +import { select } from 'd3'; +import type { MermaidConfig } from '../../config.type.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import type { DrawDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import type { D3Element } from '../../types.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { setupGraphViewbox } from '../../setupGraphViewbox.js'; +import type { FilledMindMapNode, MindmapDB, MindmapNode } from './kanbanTypes.js'; +import { drawNode, positionNode } from './svgDraw.js'; +import defaultConfig from '../../defaultConfig.js'; + +// Inject the layout algorithm into cytoscape +cytoscape.use(coseBilkent); + +async function drawNodes( + db: MindmapDB, + svg: D3Element, + mindmap: FilledMindMapNode, + section: number, + conf: MermaidConfig +) { + await drawNode(db, svg, mindmap, section, conf); + if (mindmap.children) { + await Promise.all( + mindmap.children.map((child, index) => + drawNodes(db, svg, child, section < 0 ? index : section, conf) + ) + ); + } +} + +declare module 'cytoscape' { + interface EdgeSingular { + _private: { + bodyBounds: unknown; + rscratch: { + startX: number; + startY: number; + midX: number; + midY: number; + endX: number; + endY: number; + }; + }; + } +} + +function drawEdges(edgesEl: D3Element, cy: cytoscape.Core) { + cy.edges().map((edge, id) => { + const data = edge.data(); + if (edge[0]._private.bodyBounds) { + const bounds = edge[0]._private.rscratch; + log.trace('Edge: ', id, data); + edgesEl + .insert('path') + .attr( + 'd', + `M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} ` + ) + .attr('class', 'edge section-edge-' + data.section + ' edge-depth-' + data.depth); + } + }); +} + +function addNodes(mindmap: MindmapNode, cy: cytoscape.Core, conf: MermaidConfig, level: number) { + cy.add({ + group: 'nodes', + data: { + id: mindmap.id.toString(), + labelText: mindmap.descr, + height: mindmap.height, + width: mindmap.width, + level: level, + nodeId: mindmap.id, + padding: mindmap.padding, + type: mindmap.type, + }, + position: { + x: mindmap.x!, + y: mindmap.y!, + }, + }); + if (mindmap.children) { + mindmap.children.forEach((child) => { + addNodes(child, cy, conf, level + 1); + cy.add({ + group: 'edges', + data: { + id: `${mindmap.id}_${child.id}`, + source: mindmap.id, + target: child.id, + depth: level, + section: child.section, + }, + }); + }); + } +} + +function layoutMindmap(node: MindmapNode, conf: MermaidConfig): Promise { + return new Promise((resolve) => { + // Add temporary render element + const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none'); + const cy = cytoscape({ + container: document.getElementById('cy'), // container to render in + style: [ + { + selector: 'edge', + style: { + 'curve-style': 'bezier', + }, + }, + ], + }); + // Remove element after layout + renderEl.remove(); + addNodes(node, cy, conf, 0); + + // Make cytoscape care about the dimensions of the nodes + cy.nodes().forEach(function (n) { + n.layoutDimensions = () => { + const data = n.data(); + return { w: data.width, h: data.height }; + }; + }); + + cy.layout({ + name: 'cose-bilkent', + // @ts-ignore Types for cose-bilkent are not correct? + quality: 'proof', + styleEnabled: false, + animate: false, + }).run(); + cy.ready((e) => { + log.info('Ready', e); + resolve(cy); + }); + }); +} + +function positionNodes(db: MindmapDB, cy: cytoscape.Core) { + cy.nodes().map((node, id) => { + const data = node.data(); + data.x = node.position().x; + data.y = node.position().y; + positionNode(db, data); + const el = db.getElementById(data.nodeId); + log.info('Id:', id, 'Position: (', node.position().x, ', ', node.position().y, ')', data); + el.attr( + 'transform', + `translate(${node.position().x - data.width / 2}, ${node.position().y - data.height / 2})` + ); + el.attr('attr', `apa-${id})`); + }); +} + +export const draw: DrawDefinition = async (text, id, _version, diagObj) => { + log.debug('Rendering mindmap diagram\n' + text); + + const db = diagObj.db as MindmapDB; + const mm = db.getMindmap(); + if (!mm) { + return; + } + + const conf = getConfig(); + conf.htmlLabels = false; + + const svg = selectSvgElement(id); + + // Draw the graph and start with drawing the nodes without proper position + // this gives us the size of the nodes and we can set the positions later + + const edgesElem = svg.append('g'); + edgesElem.attr('class', 'mindmap-edges'); + const nodesElem = svg.append('g'); + nodesElem.attr('class', 'mindmap-nodes'); + await drawNodes(db, nodesElem, mm as FilledMindMapNode, -1, conf); + + // Next step is to layout the mindmap, giving each node a position + + const cy = await layoutMindmap(mm, conf); + + // After this we can draw, first the edges and the then nodes with the correct position + drawEdges(edgesElem, cy); + positionNodes(db, cy); + + // Setup the view box and size of the svg element + setupGraphViewbox( + undefined, + svg, + conf.mindmap?.padding ?? defaultConfig.mindmap.padding, + conf.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth + ); +}; + +export default { + draw, +}; diff --git a/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts b/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts new file mode 100644 index 0000000000..4447b12e52 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/kanbanTypes.ts @@ -0,0 +1,22 @@ +import type { RequiredDeep } from 'type-fest'; +import type mindmapDb from './kanbanDb.js'; + +export interface MindmapNode { + id: number; + nodeId: string; + level: number; + descr: string; + type: number; + children: MindmapNode[]; + width: number; + padding: number; + section?: number; + height?: number; + class?: string; + icon?: string; + x?: number; + y?: number; +} + +export type FilledMindMapNode = RequiredDeep; +export type MindmapDB = typeof mindmapDb; diff --git a/packages/mermaid/src/diagrams/kanban/parser/kanban.jison b/packages/mermaid/src/diagrams/kanban/parser/kanban.jison new file mode 100644 index 0000000000..022823294d --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/parser/kanban.jison @@ -0,0 +1,127 @@ +/** mermaid + * https://knsv.github.io/mermaid + * (c) 2015 Knut Sveidqvist + * MIT license. + */ +%lex + +%options case-insensitive + +%{ + // Pre-lexer code can go here +%} +%x NODE +%x NSTR +%x NSTR2 +%x ICON +%x CLASS + +%% + +\s*\%\%.* {yy.getLogger().trace('Found comment',yytext); return 'SPACELINE';} +// \%\%[^\n]*\n /* skip comments */ +"kanban" return 'MINDMAP'; +":::" { this.begin('CLASS'); } +.+ { this.popState();return 'CLASS'; } +\n { this.popState();} +// [\s]*"::icon(" { this.begin('ICON'); } +"::icon(" { yy.getLogger().trace('Begin icon');this.begin('ICON'); } +[\s]+[\n] {yy.getLogger().trace('SPACELINE');return 'SPACELINE' /* skip all whitespace */ ;} +[\n]+ return 'NL'; +[^\)]+ { return 'ICON'; } +\) {yy.getLogger().trace('end icon');this.popState();} +"-)" { yy.getLogger().trace('Exploding node'); this.begin('NODE');return 'NODE_DSTART'; } +"(-" { yy.getLogger().trace('Cloud'); this.begin('NODE');return 'NODE_DSTART'; } +"))" { yy.getLogger().trace('Explosion Bang'); this.begin('NODE');return 'NODE_DSTART'; } +")" { yy.getLogger().trace('Cloud Bang'); this.begin('NODE');return 'NODE_DSTART'; } +"((" { this.begin('NODE');return 'NODE_DSTART'; } +"{{" { this.begin('NODE');return 'NODE_DSTART'; } +"(" { this.begin('NODE');return 'NODE_DSTART'; } +"[" { this.begin('NODE');return 'NODE_DSTART'; } +[\s]+ return 'SPACELIST' /* skip all whitespace */ ; +// !(-\() return 'NODE_ID'; +[^\(\[\n\)\{\}]+ return 'NODE_ID'; +<> return 'EOF'; +["][`] { this.begin("NSTR2");} +[^`"]+ { return "NODE_DESCR";} +[`]["] { this.popState();} +["] { yy.getLogger().trace('Starting NSTR');this.begin("NSTR");} +[^"]+ { yy.getLogger().trace('description:', yytext); return "NODE_DESCR";} +["] {this.popState();} +[\)]\) {this.popState();yy.getLogger().trace('node end ))');return "NODE_DEND";} +[\)] {this.popState();yy.getLogger().trace('node end )');return "NODE_DEND";} +[\]] {this.popState();yy.getLogger().trace('node end ...',yytext);return "NODE_DEND";} +"}}" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +"(-" {this.popState();yy.getLogger().trace('node end (-');return "NODE_DEND";} +"-)" {this.popState();yy.getLogger().trace('node end (-');return "NODE_DEND";} +"((" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +"(" {this.popState();yy.getLogger().trace('node end ((');return "NODE_DEND";} +[^\)\]\(\}]+ { yy.getLogger().trace('Long description:', yytext); return 'NODE_DESCR';} +.+(?!\(\() { yy.getLogger().trace('Long description:', yytext); return 'NODE_DESCR';} +// [\[] return 'NODE_START'; +// .+ return 'TXT' ; + +/lex + +%start start + +%% /* language grammar */ + +start +// %{ : info document 'EOF' { return yy; } } + : mindMap + | spaceLines mindMap + ; + +spaceLines + : SPACELINE + | spaceLines SPACELINE + | spaceLines NL + ; + +mindMap + : MINDMAP document { return yy; } + | MINDMAP NL document { return yy; } + ; + +stop + : NL {yy.getLogger().trace('Stop NL ');} + | EOF {yy.getLogger().trace('Stop EOF ');} + | SPACELINE + | stop NL {yy.getLogger().trace('Stop NL2 ');} + | stop EOF {yy.getLogger().trace('Stop EOF2 ');} + ; +document + : document statement stop + | statement stop + ; + +statement + : SPACELIST node { yy.getLogger().info('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type); } + | SPACELIST ICON { yy.getLogger().trace('Icon: ',$2);yy.decorateNode({icon: $2}); } + | SPACELIST CLASS { yy.decorateNode({class: $2}); } + | SPACELINE { yy.getLogger().trace('SPACELIST');} + | node { yy.getLogger().trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type); } + | ICON { yy.decorateNode({icon: $1}); } + | CLASS { yy.decorateNode({class: $1}); } + | SPACELIST + ; + + + +node + :nodeWithId + |nodeWithoutId + ; + +nodeWithoutId + : NODE_DSTART NODE_DESCR NODE_DEND + { yy.getLogger().trace("node found ..", $1); $$ = { id: $2, descr: $2, type: yy.getType($1, $3) }; } + ; + +nodeWithId + : NODE_ID { $$ = { id: $1, descr: $1, type: yy.nodeType.DEFAULT }; } + | NODE_ID NODE_DSTART NODE_DESCR NODE_DEND + { yy.getLogger().trace("node found ..", $1); $$ = { id: $1, descr: $3, type: yy.getType($2, $4) }; } + ; +%% diff --git a/packages/mermaid/src/diagrams/kanban/styles.ts b/packages/mermaid/src/diagrams/kanban/styles.ts new file mode 100644 index 0000000000..fffa6e4d9d --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/styles.ts @@ -0,0 +1,84 @@ +// @ts-expect-error Incorrect khroma types +import { darken, lighten, isDark } from 'khroma'; +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; + +const genSections: DiagramStylesProvider = (options) => { + let sections = ''; + + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + options['lineColor' + i] = options['lineColor' + i] || options['cScaleInv' + i]; + if (isDark(options['lineColor' + i])) { + options['lineColor' + i] = lighten(options['lineColor' + i], 20); + } else { + options['lineColor' + i] = darken(options['lineColor' + i], 20); + } + } + + for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) { + const sw = '' + (17 - 3 * i); + sections += ` + .section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${ + i - 1 + } polygon, .section-${i - 1} path { + fill: ${options['cScale' + i]}; + } + .section-${i - 1} text { + fill: ${options['cScaleLabel' + i]}; + } + .node-icon-${i - 1} { + font-size: 40px; + color: ${options['cScaleLabel' + i]}; + } + .section-edge-${i - 1}{ + stroke: ${options['cScale' + i]}; + } + .edge-depth-${i - 1}{ + stroke-width: ${sw}; + } + .section-${i - 1} line { + stroke: ${options['cScaleInv' + i]} ; + stroke-width: 3; + } + + .disabled, .disabled circle, .disabled text { + fill: lightgray; + } + .disabled text { + fill: #efefef; + } + `; + } + return sections; +}; + +// TODO: These options seem incorrect. +const getStyles: DiagramStylesProvider = (options) => + ` + .edge { + stroke-width: 3; + } + ${genSections(options)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${options.git0}; + } + .section-root text { + fill: ${options.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`; +export default getStyles; diff --git a/packages/mermaid/src/diagrams/kanban/svgDraw.ts b/packages/mermaid/src/diagrams/kanban/svgDraw.ts new file mode 100644 index 0000000000..133a4fa349 --- /dev/null +++ b/packages/mermaid/src/diagrams/kanban/svgDraw.ts @@ -0,0 +1,308 @@ +import { createText } from '../../rendering-util/createText.js'; +import type { FilledMindMapNode, MindmapDB } from './kanbanTypes.js'; +import type { Point, D3Element } from '../../types.js'; +import { parseFontSize } from '../../utils.js'; +import type { MermaidConfig } from '../../config.type.js'; + +const MAX_SECTIONS = 12; + +type ShapeFunction = ( + db: MindmapDB, + elem: D3Element, + node: FilledMindMapNode, + section?: number +) => void; + +const defaultBkg: ShapeFunction = function (db, elem, node, section) { + const rd = 5; + elem + .append('path') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr( + 'd', + `M0 ${node.height - rd} v${-node.height + 2 * rd} q0,-5 5,-5 h${ + node.width - 2 * rd + } q5,0 5,5 v${node.height - rd} H0 Z` + ); + + elem + .append('line') + .attr('class', 'node-line-' + section) + .attr('x1', 0) + .attr('y1', node.height) + .attr('x2', node.width) + .attr('y2', node.height); +}; + +const rectBkg: ShapeFunction = function (db, elem, node) { + elem + .append('rect') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr('height', node.height) + .attr('width', node.width); +}; + +const cloudBkg: ShapeFunction = function (db, elem, node) { + const w = node.width; + const h = node.height; + const r1 = 0.15 * w; + const r2 = 0.25 * w; + const r3 = 0.35 * w; + const r4 = 0.2 * w; + elem + .append('path') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr( + 'd', + `M0 0 a${r1},${r1} 0 0,1 ${w * 0.25},${-1 * w * 0.1} + a${r3},${r3} 1 0,1 ${w * 0.4},${-1 * w * 0.1} + a${r2},${r2} 1 0,1 ${w * 0.35},${1 * w * 0.2} + + a${r1},${r1} 1 0,1 ${w * 0.15},${1 * h * 0.35} + a${r4},${r4} 1 0,1 ${-1 * w * 0.15},${1 * h * 0.65} + + a${r2},${r1} 1 0,1 ${-1 * w * 0.25},${w * 0.15} + a${r3},${r3} 1 0,1 ${-1 * w * 0.5},${0} + a${r1},${r1} 1 0,1 ${-1 * w * 0.25},${-1 * w * 0.15} + + a${r1},${r1} 1 0,1 ${-1 * w * 0.1},${-1 * h * 0.35} + a${r4},${r4} 1 0,1 ${w * 0.1},${-1 * h * 0.65} + + H0 V0 Z` + ); +}; + +const bangBkg: ShapeFunction = function (db, elem, node) { + const w = node.width; + const h = node.height; + const r = 0.15 * w; + elem + .append('path') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr( + 'd', + `M0 0 a${r},${r} 1 0,0 ${w * 0.25},${-1 * h * 0.1} + a${r},${r} 1 0,0 ${w * 0.25},${0} + a${r},${r} 1 0,0 ${w * 0.25},${0} + a${r},${r} 1 0,0 ${w * 0.25},${1 * h * 0.1} + + a${r},${r} 1 0,0 ${w * 0.15},${1 * h * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 ${0},${1 * h * 0.34} + a${r},${r} 1 0,0 ${-1 * w * 0.15},${1 * h * 0.33} + + a${r},${r} 1 0,0 ${-1 * w * 0.25},${h * 0.15} + a${r},${r} 1 0,0 ${-1 * w * 0.25},${0} + a${r},${r} 1 0,0 ${-1 * w * 0.25},${0} + a${r},${r} 1 0,0 ${-1 * w * 0.25},${-1 * h * 0.15} + + a${r},${r} 1 0,0 ${-1 * w * 0.1},${-1 * h * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 ${0},${-1 * h * 0.34} + a${r},${r} 1 0,0 ${w * 0.1},${-1 * h * 0.33} + + H0 V0 Z` + ); +}; + +const circleBkg: ShapeFunction = function (db, elem, node) { + elem + .append('circle') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr('r', node.width / 2); +}; + +function insertPolygonShape( + parent: D3Element, + w: number, + h: number, + points: Point[], + node: FilledMindMapNode +) { + return parent + .insert('polygon', ':first-child') + .attr( + 'points', + points + .map(function (d) { + return d.x + ',' + d.y; + }) + .join(' ') + ) + .attr('transform', 'translate(' + (node.width - w) / 2 + ', ' + h + ')'); +} + +const hexagonBkg: ShapeFunction = function ( + _db: MindmapDB, + elem: D3Element, + node: FilledMindMapNode +) { + const h = node.height; + const f = 4; + const m = h / f; + const w = node.width - node.padding + 2 * m; + const points: Point[] = [ + { x: m, y: 0 }, + { x: w - m, y: 0 }, + { x: w, y: -h / 2 }, + { x: w - m, y: -h }, + { x: m, y: -h }, + { x: 0, y: -h / 2 }, + ]; + insertPolygonShape(elem, w, h, points, node); +}; + +const roundedRectBkg: ShapeFunction = function (db, elem, node) { + elem + .append('rect') + .attr('id', 'node-' + node.id) + .attr('class', 'node-bkg node-' + db.type2Str(node.type)) + .attr('height', node.height) + .attr('rx', node.padding) + .attr('ry', node.padding) + .attr('width', node.width); +}; + +/** + * @param db - The database + * @param elem - The D3 dom element in which the node is to be added + * @param node - The node to be added + * @param fullSection - ? + * @param conf - The configuration object + * @returns The height nodes dom element + */ +export const drawNode = async function ( + db: MindmapDB, + elem: D3Element, + node: FilledMindMapNode, + fullSection: number, + conf: MermaidConfig +): Promise { + const htmlLabels = conf.htmlLabels; + const section = fullSection % (MAX_SECTIONS - 1); + const nodeElem = elem.append('g'); + node.section = section; + let sectionClass = 'section-' + section; + if (section < 0) { + sectionClass += ' section-root'; + } + nodeElem.attr('class', (node.class ? node.class + ' ' : '') + 'mindmap-node ' + sectionClass); + const bkgElem = nodeElem.append('g'); + + // Create the wrapped text element + const textElem = nodeElem.append('g'); + const description = node.descr.replace(/()/g, '\n'); + await createText( + textElem, + description, + { + useHtmlLabels: htmlLabels, + width: node.width, + classes: 'mindmap-node-label', + }, + conf + ); + + if (!htmlLabels) { + textElem + .attr('dy', '1em') + .attr('alignment-baseline', 'middle') + .attr('dominant-baseline', 'middle') + .attr('text-anchor', 'middle'); + } + const bbox = textElem.node().getBBox(); + const [fontSize] = parseFontSize(conf.fontSize); + node.height = bbox.height + fontSize! * 1.1 * 0.5 + node.padding; + node.width = bbox.width + 2 * node.padding; + if (node.icon) { + if (node.type === db.nodeType.CIRCLE) { + node.height += 50; + node.width += 50; + const icon = nodeElem + .append('foreignObject') + .attr('height', '50px') + .attr('width', node.width) + .attr('style', 'text-align: center;'); + icon + .append('div') + .attr('class', 'icon-container') + .append('i') + .attr('class', 'node-icon-' + section + ' ' + node.icon); + textElem.attr( + 'transform', + 'translate(' + node.width / 2 + ', ' + (node.height / 2 - 1.5 * node.padding) + ')' + ); + } else { + node.width += 50; + const orgHeight = node.height; + node.height = Math.max(orgHeight, 60); + const heightDiff = Math.abs(node.height - orgHeight); + const icon = nodeElem + .append('foreignObject') + .attr('width', '60px') + .attr('height', node.height) + .attr('style', 'text-align: center;margin-top:' + heightDiff / 2 + 'px;'); + + icon + .append('div') + .attr('class', 'icon-container') + .append('i') + .attr('class', 'node-icon-' + section + ' ' + node.icon); + textElem.attr( + 'transform', + 'translate(' + (25 + node.width / 2) + ', ' + (heightDiff / 2 + node.padding / 2) + ')' + ); + } + } else { + if (!htmlLabels) { + const dx = node.width / 2; + const dy = node.padding / 2; + textElem.attr('transform', 'translate(' + dx + ', ' + dy + ')'); + // textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')'); + } else { + const dx = (node.width - bbox.width) / 2; + const dy = (node.height - bbox.height) / 2; + textElem.attr('transform', 'translate(' + dx + ', ' + dy + ')'); + } + } + + switch (node.type) { + case db.nodeType.DEFAULT: + defaultBkg(db, bkgElem, node, section); + break; + case db.nodeType.ROUNDED_RECT: + roundedRectBkg(db, bkgElem, node, section); + break; + case db.nodeType.RECT: + rectBkg(db, bkgElem, node, section); + break; + case db.nodeType.CIRCLE: + bkgElem.attr('transform', 'translate(' + node.width / 2 + ', ' + +node.height / 2 + ')'); + circleBkg(db, bkgElem, node, section); + break; + case db.nodeType.CLOUD: + cloudBkg(db, bkgElem, node, section); + break; + case db.nodeType.BANG: + bangBkg(db, bkgElem, node, section); + break; + case db.nodeType.HEXAGON: + hexagonBkg(db, bkgElem, node, section); + break; + } + + db.setElementForId(node.id, nodeElem); + return node.height; +}; + +export const positionNode = function (db: MindmapDB, node: FilledMindMapNode) { + const nodeElem = db.getElementById(node.id); + + const x = node.x || 0; + const y = node.y || 0; + // Position the node to its coordinate + nodeElem.attr('transform', 'translate(' + x + ',' + y + ')'); +};