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 @@
+
+
+
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.ts b/packages/mermaid/src/diagrams/context-map/contextMapDb.ts
new file mode 100644
index 0000000000..a0d496f1bc
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapDb.ts
@@ -0,0 +1,35 @@
+import { type RawLink } from './contextMap.js';
+
+let contextMap: string | undefined = undefined;
+let nodes: { id: string }[] = [];
+let edges: RawLink[] = [];
+
+export function setContextMapName(name: string) {
+ contextMap = name;
+}
+
+export function addNode(name: string) {
+ nodes.push({ id: name });
+}
+
+export function addEdge(obj: RawLink) {
+ 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..6443b5ba31
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/drawSvg.spec.ts
@@ -0,0 +1,205 @@
+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('node size', () => {
+ const nodeId = 'CustomerSelfServiceContext';
+ const node = { 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 contextMapNode = ContextMapNode.createContextMapNode(node, config);
+
+ 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)
+ // 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
+ // 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 config = new Configuration(
+ 500,
+ 500,
+ fakeFont,
+ (text?: string): number => 0,
+ (_) => 15,
+ { rx: 50, ry: 10 },
+ { horizontal: 0, vertical: 0 }
+ );
+
+ 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'),
+ ];
+
+ const disposedNodes = ContextMapNode.disposeNodesInThePlane(contextMapNodes, {
+ width: 500,
+ height: 500,
+ });
+
+ 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);
+ expect(botLeftNodeX + botRightNodeX).toBe(0);
+ expect(botLeftNodeY).toBe(botRightNodeY);
+ });
+
+ 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');
+ });
+});
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..bf83ee5b70
--- /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 = 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.ts b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.ts
new file mode 100644
index 0000000000..5f360323d1
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/parser/contextMap.spec.ts
@@ -0,0 +1,289 @@
+// @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(() => {
+ 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..b0ef5a1b4a 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' }]