From 70cd2852d3ef3a3a70c65984f99be085b0c641ef Mon Sep 17 00:00:00 2001 From: Angelo Ceccato Date: Sat, 2 Mar 2024 18:29:25 +0100 Subject: [PATCH 1/2] feat(1624): first draft, prototype of context map grammar and diagram --- .vite/jsonSchemaPlugin.ts | 1 + demos/contextMap.html | 74 +++ demos/index.html | 3 + docs/config/setup/modules/defaultConfig.md | 2 +- .../scripts/create-types-from-json-schema.mts | 1 + packages/mermaid/src/config.type.ts | 36 ++ packages/mermaid/src/defaultConfig.ts | 4 + .../diagram-api/diagram-orchestration.spec.ts | 1 + .../src/diagram-api/diagram-orchestration.ts | 2 + .../context-map/contextMap-definition.ts | 11 + .../diagrams/context-map/contextMap.spec.ts | 116 +++++ .../src/diagrams/context-map/contextMap.ts | 85 ++++ .../src/diagrams/context-map/contextMapDb.js | 47 ++ .../context-map/contextMapRenderer.js | 57 +++ .../src/diagrams/context-map/detector.ts | 23 + .../src/diagrams/context-map/drawSvg.spec.ts | 263 +++++++++++ .../src/diagrams/context-map/drawSvg.ts | 426 ++++++++++++++++++ .../context-map/parser/contextMap.jison | 74 +++ .../context-map/parser/contextMap.spec.js | 287 ++++++++++++ .../src/diagrams/mindmap/parser/mindmap.jison | 1 + .../mermaid/src/schemas/config.schema.yaml | 66 +++ 21 files changed, 1579 insertions(+), 1 deletion(-) create mode 100644 demos/contextMap.html create mode 100644 packages/mermaid/src/diagrams/context-map/contextMap-definition.ts create mode 100644 packages/mermaid/src/diagrams/context-map/contextMap.spec.ts create mode 100644 packages/mermaid/src/diagrams/context-map/contextMap.ts create mode 100644 packages/mermaid/src/diagrams/context-map/contextMapDb.js create mode 100644 packages/mermaid/src/diagrams/context-map/contextMapRenderer.js create mode 100644 packages/mermaid/src/diagrams/context-map/detector.ts create mode 100644 packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts create mode 100644 packages/mermaid/src/diagrams/context-map/drawSvg.ts create mode 100644 packages/mermaid/src/diagrams/context-map/parser/contextMap.jison create mode 100644 packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js diff --git a/.vite/jsonSchemaPlugin.ts b/.vite/jsonSchemaPlugin.ts index dd9af8cc55..0456c485dd 100644 --- a/.vite/jsonSchemaPlugin.ts +++ b/.vite/jsonSchemaPlugin.ts @@ -21,6 +21,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'xyChart', 'requirement', 'mindmap', + 'contextMap', 'timeline', 'gitGraph', 'c4', diff --git a/demos/contextMap.html b/demos/contextMap.html new file mode 100644 index 0000000000..2d9bea413b --- /dev/null +++ b/demos/contextMap.html @@ -0,0 +1,74 @@ + + + + + + Context Map Language Quick Test Page + + + + + +

Context Map demo

+
+    ContextMap DDDSampleMap {
+      contains CargoBookingContext
+      contains VoyagePlanningContext
+      contains LocationContext
+
+      CargoBookingContext [SK]<->[SK] VoyagePlanningContext
+      CargoBookingContext [D]<-[U,OHS,PL] LocationContext
+      VoyagePlanningContext [D]<-[U,OHS,PL] LocationContext
+    }
+   
+ +
+  ContextMap InsuranceContextMap {
+    contains CustomerManagementContext 
+    contains CustomerSelfServiceContext 
+    contains PrintingContext
+    contains PolicyManagementContext 
+    contains RiskManagementContext 
+    contains DebtCollection
+
+    CustomerSelfServiceContext [D,C]<-[U,S] CustomerManagementContext 
+    CustomerManagementContext [D,ACL]<-[U,OHS,PL] PrintingContext 
+    PrintingContext [U,OHS,PL]->[D,ACL] PolicyManagementContext 
+    RiskManagementContext [P]<->[P] PolicyManagementContext 
+    PolicyManagementContext [D,CF]<-[U,OHS,PL] CustomerManagementContext 
+    DebtCollection [D,ACL]<-[U,OHS,PL] PrintingContext 
+    PolicyManagementContext [SK]<->[SK] DebtCollection 
+  }
+
+
+   
+ + + + diff --git a/demos/index.html b/demos/index.html index efe054b4d5..01cc05f192 100644 --- a/demos/index.html +++ b/demos/index.html @@ -54,6 +54,9 @@

Journey

  • Mindmap

  • +
  • +

    Context Map

    +
  • Pie

  • diff --git a/docs/config/setup/modules/defaultConfig.md b/docs/config/setup/modules/defaultConfig.md index 7a9b891c43..b329cfd0bd 100644 --- a/docs/config/setup/modules/defaultConfig.md +++ b/docs/config/setup/modules/defaultConfig.md @@ -14,7 +14,7 @@ #### Defined in -[defaultConfig.ts:272](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L272) +[defaultConfig.ts:276](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L276) --- diff --git a/packages/mermaid/scripts/create-types-from-json-schema.mts b/packages/mermaid/scripts/create-types-from-json-schema.mts index b028fe818d..0acb4c471c 100644 --- a/packages/mermaid/scripts/create-types-from-json-schema.mts +++ b/packages/mermaid/scripts/create-types-from-json-schema.mts @@ -50,6 +50,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'xyChart', 'requirement', 'mindmap', + 'contextMap', 'timeline', 'gitGraph', 'c4', diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 0ba3178680..28b645e7ae 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -166,6 +166,7 @@ export interface MermaidConfig { xyChart?: XYChartConfig; requirement?: RequirementDiagramConfig; mindmap?: MindmapDiagramConfig; + contextMap?: MiniContextMapLanguageDiagramConfig; gitGraph?: GitGraphDiagramConfig; c4?: C4DiagramConfig; sankey?: SankeyDiagramConfig; @@ -639,6 +640,41 @@ export interface MindmapDiagramConfig extends BaseDiagramConfig { padding?: number; maxNodeWidth?: number; } +/** + * The object containing configurations specific for mini context map language diagrams + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "MiniContextMapLanguageDiagramConfig". + */ +export interface MiniContextMapLanguageDiagramConfig extends BaseDiagramConfig { + width?: number; + height?: number; + nodeMargin?: ContextMapNodeMargin; + nodePadding?: ContextMapNodePadding; + font?: ContextMapFont; +} +/** + * margins of nodes + */ +export interface ContextMapNodeMargin { + horizontal?: number; + vertical?: number; +} +/** + * padding of nodes + */ +export interface ContextMapNodePadding { + horizontal?: number; + vertical?: number; +} +/** + * Font of all Context Map texts + */ +export interface ContextMapFont { + fontFamily?: string; + fontSize?: number; + fontWeight?: number; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "PieDiagramConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index fb9db0c6a9..46265d6231 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -257,6 +257,10 @@ const config: RequiredDeep = { // TODO: can we make this default to `true` instead? useMaxWidth: false, }, + contextMap: { + ...defaultConfigJson.contextMap, + useMaxWidth: false, + }, }; const keyify = (obj: any, prefix = ''): string[] => diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts index d6c6b30463..b0cda94848 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts @@ -31,6 +31,7 @@ describe('diagram-orchestration', () => { { text: 'info', expected: 'info' }, { text: 'sequenceDiagram', expected: 'sequence' }, { text: 'mindmap', expected: 'mindmap' }, + { text: 'ContextMap', expected: 'contextMap' }, { text: 'timeline', expected: 'timeline' }, { text: 'gitGraph', expected: 'gitGraph' }, { text: 'stateDiagram', expected: 'state' }, diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index eb123c4a20..feeb95142a 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -19,6 +19,7 @@ import errorDiagram from '../diagrams/error/errorDiagram.js'; import flowchartElk from '../diagrams/flowchart/elk/detector.js'; import timeline from '../diagrams/timeline/detector.js'; import mindmap from '../diagrams/mindmap/detector.js'; +import contextMap from '../diagrams/context-map/detector.js'; import sankey from '../diagrams/sankey/sankeyDetector.js'; import block from '../diagrams/block/blockDetector.js'; import { registerLazyLoadedDiagrams } from './detectType.js'; @@ -81,6 +82,7 @@ export const addDiagrams = () => { flowchartV2, flowchart, mindmap, + contextMap, timeline, git, stateV2, diff --git a/packages/mermaid/src/diagrams/context-map/contextMap-definition.ts b/packages/mermaid/src/diagrams/context-map/contextMap-definition.ts new file mode 100644 index 0000000000..f6cd4b6185 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/contextMap-definition.ts @@ -0,0 +1,11 @@ +// @ts-ignore: JISON doesn't support types +import contextMapParser from './parser/contextMap.jison'; +import * as contextMapDb from './contextMapDb.js'; +import contextMapRenderer from './contextMapRenderer.js'; + +export const diagram = { + db: contextMapDb, + renderer: contextMapRenderer, + parser: contextMapParser, + styles: undefined, +}; diff --git a/packages/mermaid/src/diagrams/context-map/contextMap.spec.ts b/packages/mermaid/src/diagrams/context-map/contextMap.spec.ts new file mode 100644 index 0000000000..2d5a0b51a1 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/contextMap.spec.ts @@ -0,0 +1,116 @@ +import { describe, test, expect } from 'vitest'; +import type { Link, RawLink } from './contextMap.js'; +import { mapEdgeLabels } from './contextMap.js'; + +describe('graph construction', () => { + test.each([ + { + rawLink: { + source: { id: 'CargoBookingContext', type: ['SK'] }, + target: { + id: 'VoyagePlanningContextVoyagePlanningContextVoyagePlanningContext', + type: ['SK'], + }, + arrow: ['left', 'right'], + }, + link: { + source: { id: 'CargoBookingContext', boxText: undefined, bodyText: undefined }, + target: { + id: 'VoyagePlanningContextVoyagePlanningContextVoyagePlanningContext', + boxText: undefined, + bodyText: undefined, + }, + middleText: 'Shared Kernel', + }, + }, + { + rawLink: { + source: { id: 'CustomerSelfServiceContext', type: ['D', 'C'] }, + target: { id: 'CustomerManagementContext', type: ['U', 'S'] }, + arrow: ['right'], + }, + link: { + source: { id: 'CustomerSelfServiceContext', boxText: 'D', bodyText: undefined }, + target: { id: 'CustomerManagementContext', boxText: 'U', bodyText: undefined }, + middleText: 'Customer/Supplier', + }, + }, + { + rawLink: { + source: { id: 'CustomerManagementContext', type: ['D', 'ACL'] }, + target: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['right'], + }, + link: { + source: { id: 'CustomerManagementContext', boxText: 'D', bodyText: 'ACL' }, + target: { id: 'PrintingContext', boxText: 'U', bodyText: 'OHS, PL' }, + middleText: undefined, + }, + }, + { + rawLink: { + source: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + target: { id: 'PolicyManagementContext', type: ['D', 'ACL'] }, + arrow: ['right'], + }, + link: { + source: { id: 'PrintingContext', boxText: 'U', bodyText: 'OHS, PL' }, + target: { id: 'PolicyManagementContext', boxText: 'D', bodyText: 'ACL' }, + middleText: undefined, + }, + }, + { + rawLink: { + source: { id: 'RiskManagementContext', type: ['P'] }, + target: { id: 'PolicyManagementContext', type: ['P'] }, + arrow: ['left', 'right'], + }, + link: { + source: { id: 'RiskManagementContext', boxText: undefined, bodyText: undefined }, + target: { id: 'PolicyManagementContext', boxText: undefined, bodyText: undefined }, + middleText: 'Partnership', + }, + }, + { + rawLink: { + source: { id: 'PolicyManagementContext', type: ['D', 'CF'] }, + target: { id: 'CustomerManagementContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['right'], + }, + link: { + source: { id: 'PolicyManagementContext', boxText: 'D', bodyText: 'CF' }, + target: { id: 'CustomerManagementContext', boxText: 'U', bodyText: 'OHS, PL' }, + middleText: undefined, + }, + }, + { + rawLink: { + source: { id: 'DebtCollection', type: ['D', 'ACL'] }, + target: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['right'], + }, + link: { + source: { id: 'DebtCollection', boxText: 'D', bodyText: 'ACL' }, + target: { id: 'PrintingContext', boxText: 'U', bodyText: 'OHS, PL' }, + middleText: undefined, + }, + }, + { + rawLink: { + source: { id: 'CustomersBackofficeTeam', type: ['U', 'S'] }, + target: { id: 'CustomersFrontofficeTeam', type: ['D', 'C'] }, + arrow: ['right'], + }, + link: { + source: { id: 'CustomersBackofficeTeam', boxText: 'U', bodyText: undefined }, + target: { id: 'CustomersFrontofficeTeam', boxText: 'D', bodyText: undefined }, + middleText: 'Customer/Supplier', + }, + }, + ] as { rawLink: RawLink; link: Link }[])( + 'map labels for source: $rawLink.source.type, and target: $rawLink.target.type', + ({ rawLink, link }: { rawLink: RawLink; link: Link }) => { + expect(mapEdgeLabels(rawLink)).toStrictEqual(link); + } + ); +}); diff --git a/packages/mermaid/src/diagrams/context-map/contextMap.ts b/packages/mermaid/src/diagrams/context-map/contextMap.ts new file mode 100644 index 0000000000..3ab3198c53 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/contextMap.ts @@ -0,0 +1,85 @@ +export const boxLabels = ['D', 'U'] as const; +export const bodyLabels = ['CF', 'ACL', 'OHS', 'PL', 'SK', 'C', 'S', 'P'] as const; +export const middleLabels = ['Shared Kernel', 'Partnership', 'Customer/Supplier'] as const; +export const middleLabelsRelations: Partial> = { + SK: 'Shared Kernel', + P: 'Partnership', + C: 'Customer/Supplier', + S: 'Customer/Supplier', +}; +export type BoxLabel = (typeof boxLabels)[number]; +export type BodyLabel = (typeof bodyLabels)[number]; +export type MiddleLabel = (typeof middleLabels)[number]; + +type Arrow = 'left' | 'right'; +type RawLabel = BoxLabel | BodyLabel; +export type RawLink = { + source: { id: string; type: RawLabel[] }; + target: { id: string; type: RawLabel[] }; + arrow: Arrow[]; +}; +export type Link = { + source: { id: string; boxText?: string; bodyText?: string }; + target: { id: string; boxText?: string; bodyText?: string }; + middleText?: string; +}; + +export function mapEdgeLabels(rawLink: RawLink): Link { + let middleText: MiddleLabel | undefined = undefined; + let boxTarget: BoxLabel | undefined = undefined; + let boxSource: BoxLabel | undefined = undefined; + let bodyTarget: BodyLabel | undefined = undefined; + let bodySource: BodyLabel | undefined = undefined; + for (const bodyLabel of bodyLabels) { + if ( + rawLink.source.type.includes(bodyLabel) && + rawLink.target.type.includes(bodyLabel) && + !middleText + ) { + middleText = middleLabelsRelations[bodyLabel]; + } + } + if ( + ((rawLink.source.type.includes('C') && rawLink.target.type.includes('S')) || + (rawLink.source.type.includes('S') && rawLink.target.type.includes('C'))) && + !middleText + ) { + middleText = 'Customer/Supplier'; + } + for (const boxLabel of boxLabels) { + if (rawLink.source.type.includes(boxLabel)) { + boxSource = boxLabel; + } + if (rawLink.target.type.includes(boxLabel)) { + boxTarget = boxLabel; + } + } + + for (const bodyLabel of bodyLabels) { + if (Object.keys(middleLabelsRelations).includes(bodyLabel)) { + break; + } + + if (rawLink.source.type.includes(bodyLabel)) { + if (!bodySource) { + bodySource = bodyLabel; + } else { + bodySource += ', ' + bodyLabel; + } + } + + if (rawLink.target.type.includes(bodyLabel)) { + if (!bodyTarget) { + bodyTarget = bodyLabel; + } else { + bodyTarget += ', ' + bodyLabel; + } + } + } + + return { + source: { id: rawLink.source.id, boxText: boxSource, bodyText: bodySource }, + target: { id: rawLink.target.id, boxText: boxTarget, bodyText: bodyTarget }, + middleText: middleText, + }; +} diff --git a/packages/mermaid/src/diagrams/context-map/contextMapDb.js b/packages/mermaid/src/diagrams/context-map/contextMapDb.js new file mode 100644 index 0000000000..546fc07b21 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/contextMapDb.js @@ -0,0 +1,47 @@ +let contextMap = undefined; +let nodes = []; +let edges = []; + +/** + * + * @param name + */ +export function setContextMapName(name) { + contextMap = name; +} +/** + * + * @param name + */ +export function addNode(name) { + nodes.push({ id: name }); +} +/** + * + * @param obj + */ +export function addEdge(obj) { + edges.push(obj); +} +/** + * + */ +export function getGraph() { + return { contextMap, nodes, edges }; +} +/** + * + */ +export function clear() { + nodes = []; + edges = []; + contextMap = undefined; +} + +export default { + setContextMapName, + addNode, + addEdge, + getGraph, + clear, +}; diff --git a/packages/mermaid/src/diagrams/context-map/contextMapRenderer.js b/packages/mermaid/src/diagrams/context-map/contextMapRenderer.js new file mode 100644 index 0000000000..8ab911fcc1 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/contextMapRenderer.js @@ -0,0 +1,57 @@ +import * as d3 from 'd3'; +import { log } from '../../logger.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import * as db from './contextMapDb.js'; +import { configureSvgSize } from '../../setupGraphViewbox.js'; +import { buildGraph, Configuration } from './drawSvg.js'; +import { mapEdgeLabels } from './contextMap.js'; +import { calculateTextHeight, calculateTextWidth } from '../../utils.js'; + +export const draw = async (text, id, version, diagObj) => { + const conf = getConfig().contextMap; + + log.debug('things', conf.font); + log.debug('Rendering cml\n' + text, diagObj.parser); + + const securityLevel = getConfig().securityLevel; + // Handle root and Document for when rendering in sandbox mode + let sandboxElement; + if (securityLevel === 'sandbox') { + sandboxElement = d3.select('#i' + id); + } + const root = + securityLevel === 'sandbox' + ? d3.select(sandboxElement.nodes()[0].contentDocument.body) + : d3.select('body'); + // Parse the graph definition + + var svg = root.select('#' + id); + + const graph = db.getGraph(); + + const nodes = graph.nodes.map((node) => ({ id: node.id, name: node.id })); + const links = graph.edges.map((edge) => { + return mapEdgeLabels(edge); + }); + + const width = conf.width; + const height = conf.height; + const fontConfig = conf.font; + const config = new Configuration( + height, + width, + fontConfig, + (text) => calculateTextWidth(text, fontConfig), + (text) => calculateTextHeight(text, fontConfig), + { rx: conf.nodePadding.horizontal, ry: conf.nodePadding.vertical }, + { horizontal: conf.nodeMargin.horizontal, vertical: conf.nodeMargin.vertical } + ); + + buildGraph(svg, { nodes, links }, config); + + configureSvgSize(svg, width, height, true); +}; + +export default { + draw, +}; diff --git a/packages/mermaid/src/diagrams/context-map/detector.ts b/packages/mermaid/src/diagrams/context-map/detector.ts new file mode 100644 index 0000000000..edfc5289e4 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/detector.ts @@ -0,0 +1,23 @@ +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; +const id = 'contextMap'; + +const detector: DiagramDetector = (txt) => { + return /^\s*ContextMap/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./contextMap-definition.js'); + return { id, diagram }; +}; + +const plugin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; + +export default plugin; diff --git a/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts b/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts new file mode 100644 index 0000000000..0518f03fb9 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts @@ -0,0 +1,263 @@ +import * as d3 from 'd3'; + +import { Configuration, ContextMap, ContextMapLink, ContextMapNode } from './drawSvg.js'; +import { describe, test, expect } from 'vitest'; + +describe('graph construction', () => { + const fakeFont = { fontSize: 0, fontFamily: 'any', fontWeight: 0 }; + + test('svg size', () => { + const svg = d3.create('svg'); + const config = new Configuration( + 500, + 500, + fakeFont, + () => 0, + () => 0, + { rx: 0, ry: 0 }, + { horizontal: 0, vertical: 0 } + ); + const contextMap = new ContextMap(config); + + contextMap.create(svg, [], []); + + expect(svg.attr('viewBox')).toBe('-250,-250,500,500'); + }); + + test('node size', () => { + const svg = d3.create('svg'); + const nodeId = 'CustomerSelfServiceContext'; + const nodes = [{ id: nodeId }]; + const calculateTextWidth = (text?: string): number => text?.length ?? 0; + const textHeight = 15; + const fontSize = 10; + const fontFamily = 'arial'; + const fontWeight = 8; + const ellipseSize = { rx: 50, ry: 10 }; + const config = new Configuration( + 500, + 500, + { fontSize, fontFamily, fontWeight }, + calculateTextWidth, + (_) => textHeight, + ellipseSize, + { horizontal: 0, vertical: 0 } + ); + + const contextMap = new ContextMap(config); + + contextMap.create(svg, [], nodes); + + const d3Nodes = svg.selectAll('g').nodes(); + expect(d3.select(d3Nodes[0]).attr('transform')).toBe('translate(0,0)'); + + const ellipses = svg.selectAll('ellipse').nodes(); + expect(d3.select(ellipses[0]).attr('rx')).toBe( + ((ellipseSize.rx + calculateTextWidth(nodeId)) / 2).toString() + ); + expect(d3.select(ellipses[0]).attr('ry')).toBe(((ellipseSize.ry + textHeight) / 2).toString()); + + const texts = svg.selectAll('text').nodes(); + expect(d3.select(texts[0]).text()).toBe('CustomerSelfServiceContext'); + expect(d3.select(texts[0]).attr('font-size')).toBe(fontSize.toString()); + expect(d3.select(texts[0]).attr('font-weight')).toBe(fontWeight.toString()); + expect(d3.select(texts[0]).attr('font-family')).toBe(fontFamily); + expect(d3.select(texts[0]).attr('x')).toBe((-calculateTextWidth(nodeId) / 2).toString()); + expect(d3.select(texts[0]).attr('y')).toBe((textHeight / 4).toString()); + }); + + // const textWidth = configuration.calculateTextWidth(node.id) + // const textHeight = configuration.calculateTextHeight(node.id) + // const width = configuration.ellipseSize.rx+textWidth + // const height = configuration.ellipseSize.ry+textHeight + // const textX = -(textWidth/2) + // const textY = textHeight/4 + + test('distribute nodes in the plane', () => { + const svg = d3.create('svg'); + const nodes = [ + { id: 'CustomerSelfServiceContext' }, + { id: 'CustomerSelfServiceContext' }, + { id: 'CustomerManagementContext' }, + { id: 'PrintingContext' }, + ]; + + const config = new Configuration( + 500, + 500, + fakeFont, + (text?: string): number => 0, + (_) => 15, + { rx: 50, ry: 10 }, + { horizontal: 0, vertical: 0 } + ); + + const contextMap = new ContextMap(config); + + contextMap.create(svg, [], nodes); + + const d3Nodes = svg.selectAll('g').nodes(); + const [topLeftNodeX, topLeftNodeY] = pickD3TransformTranslatePos(d3Nodes[0]); + const [topRightNodeX, topRightNodeY] = pickD3TransformTranslatePos(d3Nodes[1]); + const [botLeftNodeX, botLeftNodeY] = pickD3TransformTranslatePos(d3Nodes[2]); + const [botRightNodeX, botRightNodeY] = pickD3TransformTranslatePos(d3Nodes[3]); + + expect(topLeftNodeX + topRightNodeX).toBe(0); + expect(topLeftNodeY).toBe(topRightNodeY); + expect(botLeftNodeX + botRightNodeX).toBe(0); + expect(botLeftNodeY).toBe(botRightNodeY); + }); + + test('position a link in the plane', () => { + const svg = d3.create('svg'); + const links = [ + { + source: { id: 'CustomerSelfServiceContext', boxText: undefined, bodyText: undefined }, + target: { id: 'PrintingContext', boxText: undefined, bodyText: undefined }, + middleText: 'Shared Kernel', + }, + ]; + const nodes = [{ id: 'CustomerSelfServiceContext' }, { id: 'PrintingContext' }]; + + const config = new Configuration( + 500, + 500, + fakeFont, + (text?: string): number => text?.length ?? 0, + (_) => 15, + { rx: 50, ry: 10 }, + { horizontal: 0, vertical: 0 } + ); + + const contextMap = new ContextMap(config); + + contextMap.create(svg, links, nodes); + + const d3Nodes = svg.selectAll('g').nodes(); + // expect(d3.select(d3Nodes[0]).attr("transform")).toBe("translate(-57.5,0)") + // expect(d3.select(d3Nodes[1]).attr("transform")).toBe("translate(63,0)") + + // const paths = svg.selectAll("path").nodes() + // expect(d3.select(paths[0]).attr("d")).toBe("M-57.5,0A0,0 0 0,1 63,0") + }); + + test('distribute 2 nodes in the plane', () => { + const nodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + ]; + + ContextMapNode.disposeNodesInThePlane(nodes, { width: 500, height: 500 }); + + expect(nodes[0].position).toStrictEqual({ x: -50, y: 0 }); + expect(nodes[1].position).toStrictEqual({ x: 50, y: 0 }); + }); + + test('distribute 4 nodes in the plane', () => { + const nodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + ]; + + ContextMapNode.disposeNodesInThePlane(nodes, { width: 500, height: 500 }); + + expect(nodes[0].position).toStrictEqual({ x: -50, y: +10 }); + + expect(nodes[1].position).toStrictEqual({ x: +50, y: 10 }); + + expect(nodes[2].position).toStrictEqual({ x: -50, y: -10 }); + + expect(nodes[3].position).toStrictEqual({ x: +50, y: -10 }); + }); + + test('distribute 4 nodes in the plane with little width', () => { + const nodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + ]; + + ContextMapNode.disposeNodesInThePlane(nodes, { width: 120, height: 800 }); + + expect(nodes[0].position).toStrictEqual({ x: 0, y: 30 }); + + expect(nodes[1].position).toStrictEqual({ x: 0, y: 10 }); + + expect(nodes[2].position).toStrictEqual({ x: 0, y: -10 }); + + expect(nodes[3].position).toStrictEqual({ x: 0, y: -30 }); + }); + + test('distribute 4 nodes in the plane considering margins', () => { + const nodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + ]; + + ContextMapNode.disposeNodesInThePlane( + nodes, + { width: 400, height: 800 }, + { horizontal: 10, vertical: 10 } + ); + + expect(nodes[0].position).toStrictEqual({ x: -60, y: 20 }); + + expect(nodes[1].position).toStrictEqual({ x: +60, y: 20 }); + + expect(nodes[2].position).toStrictEqual({ x: -60, y: -20 }); + + expect(nodes[3].position).toStrictEqual({ x: +60, y: -20 }); + }); + + test('crete link between two nodes', () => { + const config = new Configuration( + 500, + 500, + fakeFont, + (text?: string): number => text?.length ?? 0, + (_) => 15, + { rx: 50, ry: 10 }, + { horizontal: 0, vertical: 0 } + ); + + const nodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'A', { x: -100, y: 0 }), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'B', { x: 100, y: 0 }), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'C', { x: 200, y: 200 }), + ]; + const links = [ + { + source: { id: 'A', boxText: undefined, bodyText: undefined }, + target: { id: 'B', boxText: undefined, bodyText: undefined }, + middleText: undefined, + }, + { + source: { id: 'A', boxText: undefined, bodyText: undefined }, + target: { id: 'C', boxText: undefined, bodyText: undefined }, + middleText: undefined, + }, + ]; + + const contextMapLinks = ContextMapLink.createContextMapLinksFromNodes(nodes, links, config); + + expect(contextMapLinks[0].link.source.id).toBe('A'); + expect(contextMapLinks[0].link.target.id).toBe('B'); + + expect(contextMapLinks[1].link.source.id).toBe('A'); + expect(contextMapLinks[1].link.target.id).toBe('C'); + }); + + function parseTranslate(translate: string): [number, number] { + const [_text, x, y] = translate.split(/[(),]/); + return [parseInt(x), parseInt(y)]; + } + + function pickD3TransformTranslatePos(d3Node: d3.BaseType): [number, number] { + return parseTranslate(d3.select(d3Node).attr('transform')); + } +}); diff --git a/packages/mermaid/src/diagrams/context-map/drawSvg.ts b/packages/mermaid/src/diagrams/context-map/drawSvg.ts new file mode 100644 index 0000000000..c92c12372f --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/drawSvg.ts @@ -0,0 +1,426 @@ +import type * as d3 from 'd3'; +import type { Link } from './contextMap.js'; + +export function buildGraph( + svg: D3Svg, + { nodes, links }: { nodes: Node[]; links: Link[] }, + configuration: Configuration +) { + const contextMap = new ContextMap(configuration); + contextMap.create(svg, links, nodes); + + // show the center svg.append("circle").attr("x", 0).attr("y", 0).attr("r", 5).attr("fill", "green") +} + +export type D3Svg = d3.Selection; +export class ContextMap { + constructor(private configuration: Configuration) {} + + create(svg: D3Svg, links: Link[], nodes: Node[]) { + const height = this.configuration.height; + const width = this.configuration.width; + + svg.attr('viewBox', [-width / 2, -height / 2, width, height]); + + const contextMapNodes = nodes.map((node) => + ContextMapNode.createContextMapNode(node, this.configuration) + ); + ContextMapNode.disposeNodesInThePlane( + contextMapNodes, + { width, height }, + this.configuration.nodeMargin + ); + + const contextMapLinks = ContextMapLink.createContextMapLinksFromNodes( + contextMapNodes, + links, + this.configuration + ); + + contextMapLinks.forEach((contextMapLink) => contextMapLink.appendPathTo(svg)); + contextMapNodes.forEach((contextMapNode) => contextMapNode.appendTo(svg)); + contextMapLinks.forEach((contextMapLink) => contextMapLink.appendLabelsTo(svg)); + } +} + +export type Node = { id: string }; + +export type Font = { fontFamily: string; fontSize: number; fontWeight: number }; +type EllipseSize = { rx: number; ry: number }; +type NodeMargin = { horizontal: number; vertical: number }; +export class Configuration { + constructor( + public width: number, + public height: number, + public font: Font, + public calculateTextWidth: (text?: string) => number, + public calculateTextHeight: (text?: string) => number, + public ellipseSize: EllipseSize, + public nodeMargin: NodeMargin + ) {} +} + +type Point = { x: number; y: number }; +type LabelSize = { + labelCenter: Point; + labelPosition: Point; + boxWidth: number; + boxHeight: number; + boxTextPosition: Point; + bodyWidth: number; + bodyPosition: Point; + bodyTextPosition: Point; + font: Font; +}; +export class ContextMapLink { + constructor( + public link: Link, + private targetLabelSize: LabelSize, + private sourceLabelSize: LabelSize, + private middleLabelSize: { font: Font }, + public targetPoint: Point, + public sourcePoint: Point + ) {} + + static createContextMapLinksFromNodes( + nodes: ContextMapNode[], + links: Link[], + config: Configuration + ): ContextMapLink[] { + const nodeMap = nodes.reduce((map, node) => { + map.set(node.id, node); + return map; + }, new Map()); + + const contextMapLinks: ContextMapLink[] = []; + for (const link of links) { + const sourceNode = nodeMap.get(link.source.id); + const targetNode = nodeMap.get(link.target.id); + if (sourceNode && targetNode) { + contextMapLinks.push( + ContextMapLink.createContextMapLink(sourceNode, targetNode, link, config) + ); + } + } + return contextMapLinks; + } + + static createContextMapLink( + sourceNode: ContextMapNode, + targetNode: ContextMapNode, + link: Link, + config: Configuration + ) { + const sourceLabelIntersection = targetNode.calculateIntersection(sourceNode.position); + const targetLabelSize: LabelSize = ContextMapLink.calculateLabelSize( + config, + link.target.boxText, + link.target.bodyText, + sourceLabelIntersection + ); + + const targetLabelIntersection = sourceNode.calculateIntersection(targetNode.position); + const sourceLabelSize: LabelSize = ContextMapLink.calculateLabelSize( + config, + link.source.boxText, + link.source.bodyText, + targetLabelIntersection + ); + + const contextMapLink = new ContextMapLink( + link, + targetLabelSize, + sourceLabelSize, + { font: config.font }, + targetNode.position, + sourceNode.position + ); + return contextMapLink; + } + + appendLabelsTo(svg: D3Svg) { + this.appendLabel( + svg, + this.targetLabelSize, + this.link.target.boxText, + this.link.target.bodyText + ); + this.appendLabel( + svg, + this.sourceLabelSize, + this.link.source.boxText, + this.link.source.bodyText + ); + this.appendMiddleLabel(svg); + } + + appendPathTo(svg: D3Svg) { + this.appendPath(svg); + } + + private static calculateLabelSize( + config: Configuration, + boxText: string | undefined, + bodyText: string | undefined, + labelIntersection: Point + ) { + const boxTextWidth = config.calculateTextWidth(boxText); + const bodyTextWidth = config.calculateTextWidth(bodyText); + const boxHeight = Math.max( + config.calculateTextHeight(boxText), + config.calculateTextHeight(bodyText) + ); + const targetWidth = boxTextWidth + bodyTextWidth; + const targetLabelSize: LabelSize = { + labelCenter: labelIntersection, + labelPosition: { + x: labelIntersection.x - targetWidth / 2, + y: labelIntersection.y - boxHeight / 2, + }, + boxWidth: targetWidth, + boxHeight: boxHeight, + boxTextPosition: { x: 1, y: boxHeight - 3 }, + bodyWidth: bodyTextWidth + 2, + bodyPosition: { x: boxTextWidth, y: 0 }, + bodyTextPosition: { x: boxTextWidth + 1, y: boxHeight - 3 }, + font: config.font, + }; + return targetLabelSize; + } + + private appendPath(svg: D3Svg) { + const sourceLabelPos = this.sourceLabelSize.labelCenter; + const targetLabelPos = this.targetLabelSize.labelCenter; + + svg + .append('path') + .attr('stroke', 'black') + .attr('stroke-width', 0.5) + .attr( + 'd', + `M${sourceLabelPos.x},${sourceLabelPos.y}A0,0 0 0,1 ${targetLabelPos.x},${targetLabelPos.y}` + ); + } + + private appendMiddleLabel(svg: D3Svg) { + const calculateMidPoint = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => [ + (x1 + x2) / 2, + (y1 + y2) / 2, + ]; + + const midPoint = calculateMidPoint( + [this.sourcePoint.x, this.sourcePoint.y], + [this.targetPoint.x, this.targetPoint.y] + ); + + const middleLabel = svg.append('g'); + middleLabel + .append('text') + .attr('font-size', this.middleLabelSize.font.fontSize) + .attr('font-family', this.middleLabelSize.font.fontFamily) + .attr('font-weight', this.middleLabelSize.font.fontWeight) + .text(this.link.middleText ?? '') + .attr('x', midPoint[0]) + .attr('y', midPoint[1]); + } + + private appendLabel( + svg: D3Svg, + { + boxWidth, + bodyWidth, + boxHeight, + font, + labelPosition, + boxTextPosition, + bodyPosition, + bodyTextPosition, + }: LabelSize, + boxText?: string, + bodyText?: string + ) { + const label = svg + .append('g') + .attr('transform', `translate(${labelPosition.x},${labelPosition.y})`); + + label + .append('rect') + .attr('height', boxHeight) + .attr('width', boxWidth) + .attr('fill', 'white') + .attr('x', 0) + .attr('y', 0) + .attr('display', boxText?.length ?? 0 ? null : 'none'); + + label + .append('text') + .attr('font-size', font.fontSize) + .attr('font-family', font.fontFamily) + .attr('font-weight', font.fontWeight) + .attr('x', boxTextPosition.x) + .attr('y', boxTextPosition.y) + .text(boxText ?? ''); + + label + .append('rect') + .attr('width', bodyWidth) + .attr('height', boxHeight) + .attr('stroke-width', 1) + .attr('stroke', 'black') + .attr('fill', 'white') + .attr('x', bodyPosition.x) + .attr('y', bodyPosition.y) + .attr('display', bodyText?.length ?? 0 ? null : 'none'); + + label + .append('text') + .attr('font-size', font.fontSize) + .attr('font-family', font.fontFamily) + .attr('font-weight', font.fontWeight) + .attr('x', bodyTextPosition.x) + .attr('y', bodyTextPosition.y) + .text(bodyText ?? ''); + } +} + +export class ContextMapNode { + private rx: number; + private ry: number; + constructor( + public width: number, + public height: number, + + private textWidth: number, + private textHeight: number, + private font: Font, + + public id: string, + + public textPosition: Point = { x: 0, y: 0 }, + public position: Point = { x: 0, y: 0 } + ) { + this.rx = width / 2; + this.ry = height / 2; + } + + static disposeNodesInThePlane( + nodes: ContextMapNode[], + boxSize: { width: number; height: number }, + margin = { horizontal: 0, vertical: 0 } + ) { + const center = { x: 0, y: 0 }; + const nodeNumber = nodes.length; + + const proposedColumns = Math.ceil(Math.sqrt(nodeNumber)); + + const perRowTotalWidth: number[] = []; + const perRowTotalHeight: number[] = []; + let totalHeight: number = 0; + + let counter = 0; + let row = 0; + while (counter < nodes.length) { + let maxRowHeight = 0; + for (let column = 0; column < proposedColumns; column++) { + const node = nodes?.[counter]; + if (!node) { + break; + } + const largerWidth = (perRowTotalWidth[row] ?? 0) + node.width + margin.horizontal * 2; + if (largerWidth < boxSize.width) { + perRowTotalWidth[row] = largerWidth; + if (node.height > maxRowHeight) { + maxRowHeight = node.height + margin.vertical * 2; + } + counter++; + } + } + perRowTotalHeight[row] = (perRowTotalHeight?.[row] ?? 0) + maxRowHeight; + totalHeight += maxRowHeight; + row++; + } + + row = 0; + let inCurrentRowUsedWidth = 0; + let inCurrentRowWidthStartingPoint = center.x - perRowTotalWidth[row] / 2; + let heightStartingPoint = center.y + totalHeight / 2; + + for (const node of nodes) { + if (perRowTotalWidth[row] <= inCurrentRowUsedWidth) { + row++; + inCurrentRowUsedWidth = 0; + inCurrentRowWidthStartingPoint = center.x - perRowTotalWidth[row] / 2; + heightStartingPoint -= perRowTotalHeight[row]; + } + + const width = node.width + margin.horizontal * 2; + const x = inCurrentRowWidthStartingPoint + width / 2; + const y = heightStartingPoint - perRowTotalHeight[row] / 2; + inCurrentRowWidthStartingPoint += width; + inCurrentRowUsedWidth += width; + + node.setPosition({ x, y }); + } + + return nodes; + } + + static createContextMapNode(node: Node, configuration: Configuration) { + const textWidth = configuration.calculateTextWidth(node.id); + const textHeight = configuration.calculateTextHeight(node.id); + const width = configuration.ellipseSize.rx + textWidth; + const height = configuration.ellipseSize.ry + textHeight; + const textX = -(textWidth / 2); + const textY = textHeight / 4; + return new ContextMapNode(width, height, textWidth, textHeight, configuration.font, node.id, { + x: textX, + y: textY, + }); + } + + calculateIntersection( + { x: x2, y: y2 }: Point, + { x: centerX, y: centerY }: Point = { x: this.position.x, y: this.position.y }, + { rx: a, ry: b }: EllipseSize = { rx: this.rx, ry: this.ry } + ) { + const deltaX = x2 - centerX; + const deltaY = y2 - centerY; + const angle = Math.atan((deltaY / deltaX) * (a / b)); + let x: number, y: number; + if (deltaX >= 0) { + x = centerX + a * Math.cos(angle); + y = centerY + b * Math.sin(angle); + } else { + x = centerX - a * Math.cos(angle); + y = centerY - b * Math.sin(angle); + } + return { x: x, y: y }; + } + + setPosition(position: Point) { + this.position = position; + } + + appendTo(svg: D3Svg) { + const node = svg + .append('g') + .attr('transform', `translate(${this.position.x},${this.position.y})`); + + node + .append('ellipse') + .attr('stroke', 'black') + .attr('stroke-width', 1.5) + .attr('rx', this.rx) + .attr('ry', this.ry) + .attr('fill', 'white'); + + node + .append('text') + .attr('font-size', this.font.fontSize) + .attr('font-family', this.font.fontFamily) + .attr('font-weight', this.font.fontWeight) + .attr('x', this.textPosition.x) + .attr('y', this.textPosition.y) + .text(this.id); + } +} diff --git a/packages/mermaid/src/diagrams/context-map/parser/contextMap.jison b/packages/mermaid/src/diagrams/context-map/parser/contextMap.jison new file mode 100644 index 0000000000..9d7db9a653 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/parser/contextMap.jison @@ -0,0 +1,74 @@ + +%{ + function edgeType(text){ + return text.includes(",") ? text.split(",") : [text] + } +%} + +%lex + +%x comment +%s context + +%% + +\s+ { /* skip */ } +"/*" { this.pushState("comment"); } +"*/" { this.popState(); } +[^"*"]|[^"/"] { /* skip */ } + +"ContextMap" { this.pushState("context"); return "ContextMap"; } +"contains" { return "contains"; } + +(\w|",")+ { return "WORD"; } + +"{" { return "{"; } +"}" { this.popState(); return "}"; } +"[" { return "["; } +"]" { return "]"; } +"<->" { return "<->"; } +"<-" { return "<-"; } +"->" { return "->"; } + +<> { return "EOF"; } + +/lex + + +%start e + +%% + +arrow + : "<-" {$$ = ["left"]} + | "->" {$$ = ["right"]} + | "<->" {$$ = ["left", "right"]} + ; + +edge + : "WORD" "[" "WORD" "]" arrow "[" "WORD" "]" "WORD" { yy.addEdge({ source: { id: $1, type: edgeType($3) }, target: { id: $9, type: edgeType($7) }, arrow: $5 }) } + | "WORD" arrow "WORD" { yy.addEdge({ source: { id: $1, type: [] }, target: { id: $3, type: [] }, arrow: $2 }) } + ; + +node + : "contains" "WORD" { yy.addNode($2) } + ; + +contextMap + : "ContextMap" "WORD" { yy.setContextMapName($2) } + ; + +w + : contextMap + | node + | edge + | "{" + | "}" + ; + +e + : w + | e w + | EOF { return yy.getGraph() } + | e EOF { return yy.getGraph() } + ; diff --git a/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js new file mode 100644 index 0000000000..43256165f0 --- /dev/null +++ b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js @@ -0,0 +1,287 @@ +import parser from './contextMap.jison'; +import contextMapDb from '../contextMapDb.js'; + +describe('check context map syntax', function () { + beforeEach(() => { + parser.parser.yy = contextMapDb; + parser.parser.yy.clear(); + }); + + it('comments are ignored', function () { + const grammar = ` + /* Note that the splitting of the LocationContext is not mentioned in the original DDD sample of Evans. + * However, locations and the management around them, can somehow be seen as a separated concept which is used by other + * bounded contexts. But this is just an example, since we want to demonstrate our DSL with multiple bounded contexts. + */ +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ contextMap: undefined, nodes: [], edges: [] }); + }); + + it('recognize empty contextMap block', function () { + const grammar = ` +ContextMap DDDSampleMap { + +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ contextMap: 'DDDSampleMap', nodes: [], edges: [] }); + }); + + it('recognize contains as nodes', function () { + const grammar = ` +ContextMap DDDSampleMap { + contains CargoBookingContext + contains VoyagePlanningContext + contains LocationContext +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'DDDSampleMap', + nodes: [ + { id: 'CargoBookingContext' }, + { id: 'VoyagePlanningContext' }, + { id: 'LocationContext' }, + ], + edges: [], + }); + }); + + it('recognize simple edges', function () { + const grammar = ` +ContextMap DDDSampleMap { + contains CargoBookingContext + contains VoyagePlanningContext + + CargoBookingContext <-> VoyagePlanningContext + CargoBookingContext <- VoyagePlanningContext + CargoBookingContext -> VoyagePlanningContext +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'DDDSampleMap', + nodes: [{ id: 'CargoBookingContext' }, { id: 'VoyagePlanningContext' }], + edges: [ + { + source: { id: 'CargoBookingContext', type: [] }, + target: { id: 'VoyagePlanningContext', type: [] }, + arrow: ['left', 'right'], + }, + { + source: { id: 'CargoBookingContext', type: [] }, + target: { id: 'VoyagePlanningContext', type: [] }, + arrow: ['left'], + }, + { + source: { id: 'CargoBookingContext', type: [] }, + target: { id: 'VoyagePlanningContext', type: [] }, + arrow: ['right'], + }, + ], + }); + }); + + it('recognize complex edge', function () { + const grammar = ` +ContextMap DDDSampleMap { + contains CargoBookingContext + contains VoyagePlanningContext + contains LocationContext + + CargoBookingContext [SK]<->[SK] VoyagePlanningContext +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'DDDSampleMap', + nodes: [ + { id: 'CargoBookingContext' }, + { id: 'VoyagePlanningContext' }, + { id: 'LocationContext' }, + ], + edges: [ + { + source: { id: 'CargoBookingContext', type: ['SK'] }, + target: { id: 'VoyagePlanningContext', type: ['SK'] }, + arrow: ['left', 'right'], + }, + ], + }); + }); + + it('recognize mutiple edges and multiple types', function () { + const grammar = ` +ContextMap DDDSampleMap { + contains CargoBookingContext + contains VoyagePlanningContext + contains LocationContext + + CargoBookingContext [SK]<->[SK] VoyagePlanningContext + CargoBookingContext [D]<-[U,OHS,PL] LocationContext + VoyagePlanningContext [D]<-[U,OHS,PL] LocationContext +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'DDDSampleMap', + nodes: [ + { id: 'CargoBookingContext' }, + { id: 'VoyagePlanningContext' }, + { id: 'LocationContext' }, + ], + edges: [ + { + source: { id: 'CargoBookingContext', type: ['SK'] }, + target: { id: 'VoyagePlanningContext', type: ['SK'] }, + arrow: ['left', 'right'], + }, + { + source: { id: 'CargoBookingContext', type: ['D'] }, + target: { id: 'LocationContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + { + source: { id: 'VoyagePlanningContext', type: ['D'] }, + target: { id: 'LocationContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + ], + }); + }); + + it('recognize edges and nodes with comments', function () { + const grammar = ` +/* The DDD Cargo sample application modeled in CML. Note that we split the application into multiple bounded contexts. */ +ContextMap DDDSampleMap { + contains CargoBookingContext + contains VoyagePlanningContext + contains LocationContext + + /* As Evans mentions in his book (Bounded Context chapter): The voyage planning can be seen as + * separated bounded context. However, it still shares code with the booking application (CargoBookingContext). + * Thus, they are in a 'Shared-Kernel' relationship. + */ + CargoBookingContext [SK]<->[SK] VoyagePlanningContext + + /* Note that the splitting of the LocationContext is not mentioned in the original DDD sample of Evans. + * However, locations and the management around them, can somehow be seen as a separated concept which is used by other + * bounded contexts. But this is just an example, since we want to demonstrate our DSL with multiple bounded contexts. + */ + CargoBookingContext [D]<-[U,OHS,PL] LocationContext + + VoyagePlanningContext [D]<-[U,OHS,PL] LocationContext + +} +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'DDDSampleMap', + nodes: [ + { id: 'CargoBookingContext' }, + { id: 'VoyagePlanningContext' }, + { id: 'LocationContext' }, + ], + edges: [ + { + source: { id: 'CargoBookingContext', type: ['SK'] }, + target: { id: 'VoyagePlanningContext', type: ['SK'] }, + arrow: ['left', 'right'], + }, + { + source: { id: 'CargoBookingContext', type: ['D'] }, + target: { id: 'LocationContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + { + source: { id: 'VoyagePlanningContext', type: ['D'] }, + target: { id: 'LocationContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + ], + }); + }); + + it('recognize edges and nodes of another example', function () { + const grammar = ` +/* Example Context Map written with 'ContextMapper DSL' */ +ContextMap InsuranceContextMap { + + /* Add bounded contexts to this context map: */ + contains CustomerManagementContext + contains CustomerSelfServiceContext + contains PrintingContext + contains PolicyManagementContext + contains RiskManagementContext + contains DebtCollection + + /* Define the context relationships: */ + + CustomerSelfServiceContext [D,C]<-[U,S] CustomerManagementContext + + CustomerManagementContext [D,ACL]<-[U,OHS,PL] PrintingContext + + PrintingContext [U,OHS,PL]->[D,ACL] PolicyManagementContext + + RiskManagementContext [P]<->[P] PolicyManagementContext + + PolicyManagementContext [D,CF]<-[U,OHS,PL] CustomerManagementContext + + DebtCollection [D,ACL]<-[U,OHS,PL] PrintingContext + + PolicyManagementContext [SK]<->[SK] DebtCollection +} + +`; + const result = parser.parser.parse(grammar); + expect(result).toEqual({ + contextMap: 'InsuranceContextMap', + nodes: [ + { id: 'CustomerManagementContext' }, + { id: 'CustomerSelfServiceContext' }, + { id: 'PrintingContext' }, + { id: 'PolicyManagementContext' }, + { id: 'RiskManagementContext' }, + { id: 'DebtCollection' }, + ], + edges: [ + { + source: { id: 'CustomerSelfServiceContext', type: ['D', 'C'] }, + target: { id: 'CustomerManagementContext', type: ['U', 'S'] }, + arrow: ['left'], + }, + { + source: { id: 'CustomerManagementContext', type: ['D', 'ACL'] }, + target: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + { + source: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + target: { id: 'PolicyManagementContext', type: ['D', 'ACL'] }, + arrow: ['right'], + }, + { + source: { id: 'RiskManagementContext', type: ['P'] }, + target: { id: 'PolicyManagementContext', type: ['P'] }, + arrow: ['left', 'right'], + }, + { + source: { id: 'PolicyManagementContext', type: ['D', 'CF'] }, + target: { id: 'CustomerManagementContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + { + source: { id: 'DebtCollection', type: ['D', 'ACL'] }, + target: { id: 'PrintingContext', type: ['U', 'OHS', 'PL'] }, + arrow: ['left'], + }, + { + source: { id: 'PolicyManagementContext', type: ['SK'] }, + target: { id: 'DebtCollection', type: ['SK'] }, + arrow: ['left', 'right'], + }, + ], + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/mindmap/parser/mindmap.jison b/packages/mermaid/src/diagrams/mindmap/parser/mindmap.jison index afd5e2300d..fee52f0913 100644 --- a/packages/mermaid/src/diagrams/mindmap/parser/mindmap.jison +++ b/packages/mermaid/src/diagrams/mindmap/parser/mindmap.jison @@ -10,6 +10,7 @@ %{ // Pre-lexer code can go here %} + %x NODE %x NSTR %x NSTR2 diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 3e7fd58ec5..8f1ffac572 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -46,6 +46,7 @@ required: - xyChart - requirement - mindmap + - contextMap - gitGraph - c4 - sankey @@ -219,6 +220,8 @@ properties: $ref: '#/$defs/RequirementDiagramConfig' mindmap: $ref: '#/$defs/MindmapDiagramConfig' + contextMap: + $ref: '#/$defs/MiniContextMapLanguageDiagramConfig' gitGraph: $ref: '#/$defs/GitGraphDiagramConfig' c4: @@ -871,6 +874,69 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: number default: 200 + MiniContextMapLanguageDiagramConfig: + title: Mini Context Map Language Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: The object containing configurations specific for mini context map language diagrams + type: object + unevaluatedProperties: false + required: + - height + - width + - useMaxWidth + - nodeMargin + - nodePadding + - font + properties: + width: + type: number + default: 600 + height: + type: number + default: 600 + nodeMargin: + title: Context Map Node Margin + description: margins of nodes + type: object + unevaluatedProperties: false + properties: + horizontal: + type: number + vertical: + type: number + default: + horizontal: 20 + vertical: 30 + nodePadding: + title: Context Map Node Padding + description: padding of nodes + type: object + unevaluatedProperties: false + properties: + horizontal: + type: number + vertical: + type: number + default: + horizontal: 100 + vertical: 40 + font: + title: Context Map Font + description: Font of all Context Map texts + type: object + unevaluatedProperties: false + properties: + fontFamily: + type: string + fontSize: + type: number + fontWeight: + type: number + default: + fontFamily: "Arial" + fontSize: 12 + fontWeight: 400 + PieDiagramConfig: title: Pie Diagram Config allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] From e5e341524c366a16b459253754415d4aaf885b61 Mon Sep 17 00:00:00 2001 From: AngeloChecked Date: Sun, 3 Mar 2024 01:50:03 +0100 Subject: [PATCH 2/2] feat(1624): fix formatting and tests --- .../{contextMapDb.js => contextMapDb.ts} | 36 ++--- .../src/diagrams/context-map/drawSvg.spec.ts | 134 +++++------------- .../src/diagrams/context-map/drawSvg.ts | 2 +- ...{contextMap.spec.js => contextMap.spec.ts} | 2 + .../mermaid/src/schemas/config.schema.yaml | 26 ++-- 5 files changed, 66 insertions(+), 134 deletions(-) rename packages/mermaid/src/diagrams/context-map/{contextMapDb.js => contextMapDb.ts} (51%) rename packages/mermaid/src/diagrams/context-map/parser/{contextMap.spec.js => contextMap.spec.ts} (98%) diff --git a/packages/mermaid/src/diagrams/context-map/contextMapDb.js b/packages/mermaid/src/diagrams/context-map/contextMapDb.ts similarity index 51% rename from packages/mermaid/src/diagrams/context-map/contextMapDb.js rename to packages/mermaid/src/diagrams/context-map/contextMapDb.ts index 546fc07b21..a0d496f1bc 100644 --- a/packages/mermaid/src/diagrams/context-map/contextMapDb.js +++ b/packages/mermaid/src/diagrams/context-map/contextMapDb.ts @@ -1,37 +1,25 @@ -let contextMap = undefined; -let nodes = []; -let edges = []; +import { type RawLink } from './contextMap.js'; -/** - * - * @param name - */ -export function setContextMapName(name) { +let contextMap: string | undefined = undefined; +let nodes: { id: string }[] = []; +let edges: RawLink[] = []; + +export function setContextMapName(name: string) { contextMap = name; } -/** - * - * @param name - */ -export function addNode(name) { + +export function addNode(name: string) { nodes.push({ id: name }); } -/** - * - * @param obj - */ -export function addEdge(obj) { + +export function addEdge(obj: RawLink) { edges.push(obj); } -/** - * - */ + export function getGraph() { return { contextMap, nodes, edges }; } -/** - * - */ + export function clear() { nodes = []; edges = []; diff --git a/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts b/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts index 0518f03fb9..6443b5ba31 100644 --- a/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts +++ b/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts @@ -2,32 +2,14 @@ import * as d3 from 'd3'; import { Configuration, ContextMap, ContextMapLink, ContextMapNode } from './drawSvg.js'; import { describe, test, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; describe('graph construction', () => { const fakeFont = { fontSize: 0, fontFamily: 'any', fontWeight: 0 }; - test('svg size', () => { - const svg = d3.create('svg'); - const config = new Configuration( - 500, - 500, - fakeFont, - () => 0, - () => 0, - { rx: 0, ry: 0 }, - { horizontal: 0, vertical: 0 } - ); - const contextMap = new ContextMap(config); - - contextMap.create(svg, [], []); - - expect(svg.attr('viewBox')).toBe('-250,-250,500,500'); - }); - test('node size', () => { - const svg = d3.create('svg'); const nodeId = 'CustomerSelfServiceContext'; - const nodes = [{ id: nodeId }]; + const node = { id: nodeId }; const calculateTextWidth = (text?: string): number => text?.length ?? 0; const textHeight = 15; const fontSize = 10; @@ -44,26 +26,16 @@ describe('graph construction', () => { { horizontal: 0, vertical: 0 } ); - const contextMap = new ContextMap(config); - - contextMap.create(svg, [], nodes); + const contextMapNode = ContextMapNode.createContextMapNode(node, config); - const d3Nodes = svg.selectAll('g').nodes(); - expect(d3.select(d3Nodes[0]).attr('transform')).toBe('translate(0,0)'); - - const ellipses = svg.selectAll('ellipse').nodes(); - expect(d3.select(ellipses[0]).attr('rx')).toBe( - ((ellipseSize.rx + calculateTextWidth(nodeId)) / 2).toString() - ); - expect(d3.select(ellipses[0]).attr('ry')).toBe(((ellipseSize.ry + textHeight) / 2).toString()); - - const texts = svg.selectAll('text').nodes(); - expect(d3.select(texts[0]).text()).toBe('CustomerSelfServiceContext'); - expect(d3.select(texts[0]).attr('font-size')).toBe(fontSize.toString()); - expect(d3.select(texts[0]).attr('font-weight')).toBe(fontWeight.toString()); - expect(d3.select(texts[0]).attr('font-family')).toBe(fontFamily); - expect(d3.select(texts[0]).attr('x')).toBe((-calculateTextWidth(nodeId) / 2).toString()); - expect(d3.select(texts[0]).attr('y')).toBe((textHeight / 4).toString()); + expect(contextMapNode.position).toStrictEqual({ x: 0, y: 0 }); + expect(contextMapNode.width).toBe(ellipseSize.rx + calculateTextWidth(nodeId)); + expect(contextMapNode.height).toBe(ellipseSize.ry + textHeight); + expect(contextMapNode.id).toBe('CustomerSelfServiceContext'); + expect(contextMapNode.textPosition).toStrictEqual({ + x: -calculateTextWidth(nodeId) / 2, + y: textHeight / 4, + }); }); // const textWidth = configuration.calculateTextWidth(node.id) @@ -72,16 +44,21 @@ describe('graph construction', () => { // const height = configuration.ellipseSize.ry+textHeight // const textX = -(textWidth/2) // const textY = textHeight/4 + // const targetNode = ContextMapNode.createContextMapNode(node, config) + // const link = { + // source: { id: 'CustomerSelfServiceContext', boxText: undefined, bodyText: undefined }, + // target: { id: 'PrintingContext', boxText: undefined, bodyText: undefined }, + // middleText: 'Shared Kernel', + // } + + // const contextMapLink = ContextMapLink.createContextMapLink( + // sourceNode, + // targetNode, + // link, + // config + // ) test('distribute nodes in the plane', () => { - const svg = d3.create('svg'); - const nodes = [ - { id: 'CustomerSelfServiceContext' }, - { id: 'CustomerSelfServiceContext' }, - { id: 'CustomerManagementContext' }, - { id: 'PrintingContext' }, - ]; - const config = new Configuration( 500, 500, @@ -92,15 +69,22 @@ describe('graph construction', () => { { horizontal: 0, vertical: 0 } ); - const contextMap = new ContextMap(config); + const contextMapNodes = [ + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), + ]; - contextMap.create(svg, [], nodes); + const disposedNodes = ContextMapNode.disposeNodesInThePlane(contextMapNodes, { + width: 500, + height: 500, + }); - const d3Nodes = svg.selectAll('g').nodes(); - const [topLeftNodeX, topLeftNodeY] = pickD3TransformTranslatePos(d3Nodes[0]); - const [topRightNodeX, topRightNodeY] = pickD3TransformTranslatePos(d3Nodes[1]); - const [botLeftNodeX, botLeftNodeY] = pickD3TransformTranslatePos(d3Nodes[2]); - const [botRightNodeX, botRightNodeY] = pickD3TransformTranslatePos(d3Nodes[3]); + const { x: topLeftNodeX, y: topLeftNodeY } = disposedNodes[0].position; + const { x: topRightNodeX, y: topRightNodeY } = disposedNodes[1].position; + const { x: botLeftNodeX, y: botLeftNodeY } = disposedNodes[2].position; + const { x: botRightNodeX, y: botRightNodeY } = disposedNodes[3].position; expect(topLeftNodeX + topRightNodeX).toBe(0); expect(topLeftNodeY).toBe(topRightNodeY); @@ -108,39 +92,6 @@ describe('graph construction', () => { expect(botLeftNodeY).toBe(botRightNodeY); }); - test('position a link in the plane', () => { - const svg = d3.create('svg'); - const links = [ - { - source: { id: 'CustomerSelfServiceContext', boxText: undefined, bodyText: undefined }, - target: { id: 'PrintingContext', boxText: undefined, bodyText: undefined }, - middleText: 'Shared Kernel', - }, - ]; - const nodes = [{ id: 'CustomerSelfServiceContext' }, { id: 'PrintingContext' }]; - - const config = new Configuration( - 500, - 500, - fakeFont, - (text?: string): number => text?.length ?? 0, - (_) => 15, - { rx: 50, ry: 10 }, - { horizontal: 0, vertical: 0 } - ); - - const contextMap = new ContextMap(config); - - contextMap.create(svg, links, nodes); - - const d3Nodes = svg.selectAll('g').nodes(); - // expect(d3.select(d3Nodes[0]).attr("transform")).toBe("translate(-57.5,0)") - // expect(d3.select(d3Nodes[1]).attr("transform")).toBe("translate(63,0)") - - // const paths = svg.selectAll("path").nodes() - // expect(d3.select(paths[0]).attr("d")).toBe("M-57.5,0A0,0 0 0,1 63,0") - }); - test('distribute 2 nodes in the plane', () => { const nodes = [ new ContextMapNode(100, 20, 0, 0, fakeFont, 'any'), @@ -251,13 +202,4 @@ describe('graph construction', () => { expect(contextMapLinks[1].link.source.id).toBe('A'); expect(contextMapLinks[1].link.target.id).toBe('C'); }); - - function parseTranslate(translate: string): [number, number] { - const [_text, x, y] = translate.split(/[(),]/); - return [parseInt(x), parseInt(y)]; - } - - function pickD3TransformTranslatePos(d3Node: d3.BaseType): [number, number] { - return parseTranslate(d3.select(d3Node).attr('transform')); - } }); diff --git a/packages/mermaid/src/diagrams/context-map/drawSvg.ts b/packages/mermaid/src/diagrams/context-map/drawSvg.ts index c92c12372f..bf83ee5b70 100644 --- a/packages/mermaid/src/diagrams/context-map/drawSvg.ts +++ b/packages/mermaid/src/diagrams/context-map/drawSvg.ts @@ -315,7 +315,7 @@ export class ContextMapNode { const perRowTotalWidth: number[] = []; const perRowTotalHeight: number[] = []; - let totalHeight: number = 0; + let totalHeight = 0; let counter = 0; let row = 0; diff --git a/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.ts similarity index 98% rename from packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js rename to packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.ts index 43256165f0..5f360323d1 100644 --- a/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.js +++ b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.ts @@ -1,5 +1,7 @@ +// @ts-ignore: JISON doesn't support types import parser from './contextMap.jison'; import contextMapDb from '../contextMapDb.js'; +import { describe, it, beforeEach, expect } from 'vitest'; describe('check context map syntax', function () { beforeEach(() => { diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 8f1ffac572..b0ef5a1b4a 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -890,11 +890,11 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) properties: width: type: number - default: 600 + default: 600 height: type: number - default: 600 - nodeMargin: + default: 600 + nodeMargin: title: Context Map Node Margin description: margins of nodes type: object @@ -902,12 +902,12 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) properties: horizontal: type: number - vertical: + vertical: type: number - default: + default: horizontal: 20 vertical: 30 - nodePadding: + nodePadding: title: Context Map Node Padding description: padding of nodes type: object @@ -915,7 +915,7 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) properties: horizontal: type: number - vertical: + vertical: type: number default: horizontal: 100 @@ -928,15 +928,15 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) properties: fontFamily: type: string - fontSize: + fontSize: type: number - fontWeight: + fontWeight: type: number default: - fontFamily: "Arial" - fontSize: 12 - fontWeight: 400 - + fontFamily: 'Arial' + fontSize: 12 + fontWeight: 400 + PieDiagramConfig: title: Pie Diagram Config allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]