diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts
index 6fd8ca3f54..7b1697f7fb 100644
--- a/.build/jsonSchema.ts
+++ b/.build/jsonSchema.ts
@@ -19,6 +19,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..996a9fa496
--- /dev/null
+++ b/demos/contextMap.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+ Context Map Language Quick Test Page
+
+
+
+
+
+ Context Map demo
+
+context-map-beta
+
+ContextMap DDDSampleMap {
+ contains CargoBookingContext
+ contains VoyagePlanningContext
+ contains LocationContext
+
+ CargoBookingContext [SK]<->[SK] VoyagePlanningContext
+ CargoBookingContext [D]<-[U,OHS,PL] LocationContext
+ VoyagePlanningContext [D]<-[U,OHS,PL] LocationContext
+}
+
+
+
+context-map-beta
+
+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 61a86a2aa0..d559067836 100644
--- a/demos/index.html
+++ b/demos/index.html
@@ -1,93 +1,94 @@
-
-
-
-
-
- Mermaid Quick Test Page
-
-
-
-
-
- Mermaid quick test and demo pages
-
- Some of these pages have duplicates; some are slow to load because they have so many graphs.
-
- You can test custom code in the development page.
-
- If you'd like to clean up one of the pages, please feel free to
- submit a pull request (PR).
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
+
+
+
+
+
+ Mermaid Quick Test Page
+
+
+
+
+
+ Mermaid quick test and demo pages
+
+ Some of these pages have duplicates; some are slow to load because they have so many graphs.
+
+ You can test custom code in the development page.
+
+ If you'd like to clean up one of the pages, please feel free to
+ submit a pull request (PR).
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json
index 982b9d0633..ea02aa46d4 100644
--- a/packages/mermaid/package.json
+++ b/packages/mermaid/package.json
@@ -22,6 +22,7 @@
"class diagram",
"git graph",
"mindmap",
+ "context map language",
"packet diagram",
"c4 diagram",
"er diagram",
diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts
index b7cf27e72b..58f611be5c 100644
--- a/packages/mermaid/src/config.type.ts
+++ b/packages/mermaid/src/config.type.ts
@@ -155,6 +155,7 @@ export interface MermaidConfig {
xyChart?: XYChartConfig;
requirement?: RequirementDiagramConfig;
mindmap?: MindmapDiagramConfig;
+ contextMap?: ContextMapLanguageDiagramConfig;
gitGraph?: GitGraphDiagramConfig;
c4?: C4DiagramConfig;
sankey?: SankeyDiagramConfig;
@@ -972,6 +973,41 @@ export interface MindmapDiagramConfig extends BaseDiagramConfig {
padding?: number;
maxNodeWidth?: number;
}
+/**
+ * The object containing configurations specific for context map language diagrams
+ *
+ * This interface was referenced by `MermaidConfig`'s JSON-Schema
+ * via the `definition` "ContextMapDiagramConfig".
+ */
+export interface ContextMapLanguageDiagramConfig 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` "GitGraphDiagramConfig".
diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts
index 727842bba1..1d4ace971f 100644
--- a/packages/mermaid/src/defaultConfig.ts
+++ b/packages/mermaid/src/defaultConfig.ts
@@ -53,6 +53,11 @@ const config: RequiredDeep = {
tickInterval: undefined,
useWidth: undefined, // can probably be removed since `configKeys` already includes this
},
+ contextMap: {
+ ...defaultConfigJson.contextMap,
+ useWidth: undefined,
+ useMaxWidth: false,
+ },
c4: {
...defaultConfigJson.c4,
useWidth: undefined,
diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.spec.ts
index 4517ff6229..ff6de38503 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: 'context-map-beta', expected: 'context-map-beta' },
{ 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 55d05c9aaa..659d7ece77 100644
--- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts
+++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts
@@ -6,6 +6,7 @@ import git from '../diagrams/git/gitGraphDetector.js';
import gantt from '../diagrams/gantt/ganttDetector.js';
import { info } from '../diagrams/info/infoDetector.js';
import { pie } from '../diagrams/pie/pieDetector.js';
+import { contextMap } from '../diagrams/context-map/contextMapDetector.js';
import quadrantChart from '../diagrams/quadrant-chart/quadrantDetector.js';
import xychart from '../diagrams/xychart/xychartDetector.js';
import requirement from '../diagrams/requirement/requirementDetector.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.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..1535cb75bc
--- /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];
+
+export type Arrow = 'left' | 'right';
+export type RawLabel = BoxLabel | BodyLabel;
+export interface RawLink {
+ source: { id: string; type: RawLabel[] };
+ target: { id: string; type: RawLabel[] };
+ arrow: Arrow[];
+}
+export interface 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..ab0b540460
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapDb.ts
@@ -0,0 +1,44 @@
+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);
+}
+
+interface ContextMap {
+ contextMap: string | undefined;
+ nodes: typeof nodes;
+ edges: typeof edges;
+}
+export function getGraph(): ContextMap {
+ return { contextMap, nodes, edges };
+}
+
+export function clear() {
+ nodes = [];
+ edges = [];
+ contextMap = undefined;
+}
+
+const contextMapDb = {
+ setContextMapName,
+ addNode,
+ addEdge,
+ getGraph,
+ clear,
+};
+
+export type ContextMapDb = typeof contextMapDb;
+
+export default contextMapDb;
diff --git a/packages/mermaid/src/diagrams/context-map/contextMapDetector.ts b/packages/mermaid/src/diagrams/context-map/contextMapDetector.ts
new file mode 100644
index 0000000000..50c75e27e7
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapDetector.ts
@@ -0,0 +1,22 @@
+import type {
+ DiagramDetector,
+ DiagramLoader,
+ ExternalDiagramDefinition,
+} from '../../diagram-api/types.js';
+
+const id = 'context-map-beta';
+
+const detector: DiagramDetector = (txt) => {
+ return /^\s*context-map-beta/.test(txt);
+};
+
+const loader: DiagramLoader = async () => {
+ const { diagram } = await import('./contextMapDiagram.js');
+ return { id, diagram };
+};
+
+export const contextMap: ExternalDiagramDefinition = {
+ id,
+ detector,
+ loader,
+};
diff --git a/packages/mermaid/src/diagrams/context-map/contextMapDiagram.ts b/packages/mermaid/src/diagrams/context-map/contextMapDiagram.ts
new file mode 100644
index 0000000000..ba8073712f
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapDiagram.ts
@@ -0,0 +1,10 @@
+import type { DiagramDefinition } from '../../diagram-api/types.js';
+import { parser } from './contextMapParser.js';
+import db from './contextMapDb.js';
+import { renderer } from './contextMapRenderer.js';
+
+export const diagram: DiagramDefinition = {
+ parser,
+ db,
+ renderer,
+};
diff --git a/packages/mermaid/src/diagrams/context-map/contextMapParser.spec.ts b/packages/mermaid/src/diagrams/context-map/contextMapParser.spec.ts
new file mode 100644
index 0000000000..ebe003e124
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapParser.spec.ts
@@ -0,0 +1,310 @@
+import { parser } from './contextMapParser.js';
+import contextMapDb from './contextMapDb.js';
+
+describe('check context map syntax', () => {
+ beforeEach(() => {
+ contextMapDb.clear();
+ });
+
+ it('comments are ignored', async () => {
+ const grammar = `
+context-map-beta
+
+/* 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.
+*/
+ContextMap DDDSampleMap {
+
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).toEqual({ contextMap: 'DDDSampleMap', nodes: [], edges: [] });
+ });
+
+ it('recognize empty contextMap block', async () => {
+ const grammar = `
+context-map-beta
+
+ContextMap DDDSampleMap {
+
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).toEqual({ contextMap: 'DDDSampleMap', nodes: [], edges: [] });
+ });
+
+ it('recognize contains as nodes', async () => {
+ const grammar = `
+context-map-beta
+
+ContextMap DDDSampleMap {
+ contains CargoBookingContext
+ contains VoyagePlanningContext
+ contains LocationContext
+}
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).toEqual({
+ contextMap: 'DDDSampleMap',
+ nodes: [
+ { id: 'CargoBookingContext' },
+ { id: 'VoyagePlanningContext' },
+ { id: 'LocationContext' },
+ ],
+ edges: [],
+ });
+ });
+
+ it('recognize simple edges', async () => {
+ const grammar = `
+context-map-beta
+ContextMap DDDSampleMap {
+ contains CargoBookingContext
+ contains VoyagePlanningContext
+
+ CargoBookingContext <-> VoyagePlanningContext
+ CargoBookingContext <- VoyagePlanningContext
+ CargoBookingContext -> VoyagePlanningContext
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).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', async () => {
+ const grammar = `
+context-map-beta
+
+ContextMap DDDSampleMap {
+ contains CargoBookingContext
+ contains VoyagePlanningContext
+ contains LocationContext
+
+ CargoBookingContext [SK]<->[SK] VoyagePlanningContext
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).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', async () => {
+ const grammar = `
+context-map-beta
+
+ContextMap DDDSampleMap {
+ contains CargoBookingContext
+ contains VoyagePlanningContext
+ contains LocationContext
+
+ CargoBookingContext [SK]<->[SK] VoyagePlanningContext
+ CargoBookingContext [D]<-[U,OHS,PL] LocationContext
+ VoyagePlanningContext [D]<-[U,OHS,PL] LocationContext
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).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', async () => {
+ const grammar = `
+context-map-beta
+
+/* 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
+
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).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', async () => {
+ const grammar = `
+context-map-beta
+
+/* 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
+}
+
+`;
+ await parser.parse(grammar);
+ expect(contextMapDb.getGraph()).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/context-map/contextMapParser.ts b/packages/mermaid/src/diagrams/context-map/contextMapParser.ts
new file mode 100644
index 0000000000..be1daefecc
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapParser.ts
@@ -0,0 +1,51 @@
+import type { ContextMap, ContextMapLink, ContextMapNode } from '@mermaid-js/parser';
+import { parse } from '@mermaid-js/parser';
+import { log } from '../../logger.js';
+import type { ParserDefinition } from '../../diagram-api/types.js';
+import db from './contextMapDb.js';
+import type { ContextMapDb } from './contextMapDb.js';
+import type { RawLink, RawLabel, Arrow } from './contextMap.js';
+
+const populateDb = (ast: ContextMap, db: ContextMapDb) => {
+ db.setContextMapName(ast.blocks[0].name);
+ for (const anyNode of ast.blocks[0].body.filter((n) => n.$type === 'ContextMapNode')) {
+ db.addNode((anyNode as ContextMapNode).name);
+ }
+ for (const anyLink of ast.blocks[0].body.filter((n) => n.$type === 'ContextMapLink')) {
+ const link = anyLink as ContextMapLink;
+ const leftNodeId = link.leftNode.$refText;
+ const leftLink = link.leftLabelBox;
+ const rightNodeId = link.rightNode.$refText;
+ const rightLink = link.rightLabelBox;
+ if (!leftNodeId) {
+ continue;
+ }
+ if (!rightNodeId) {
+ continue;
+ }
+ const rawLink: RawLink = {
+ source: { id: leftNodeId, type: leftLink?.labels.map((l) => l as RawLabel) ?? [] },
+ target: { id: rightNodeId, type: rightLink?.labels.map((l) => l as RawLabel) ?? [] },
+ arrow: directionToArrow(link.direction),
+ };
+ db.addEdge(rawLink);
+ }
+};
+
+function directionToArrow(direction: '<-' | '->' | '<->'): Arrow[] {
+ if (direction === '->') {
+ return ['right'];
+ } else if (direction === '<-') {
+ return ['left'];
+ }
+
+ return ['left', 'right'];
+}
+
+export const parser: ParserDefinition = {
+ parse: async (input: string): Promise => {
+ const ast: ContextMap = await parse('contextMap', input);
+ log.debug(ast);
+ populateDb(ast, db);
+ },
+};
diff --git a/packages/mermaid/src/diagrams/context-map/contextMapRenderer.ts b/packages/mermaid/src/diagrams/context-map/contextMapRenderer.ts
new file mode 100644
index 0000000000..a7272672ab
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/contextMapRenderer.ts
@@ -0,0 +1,61 @@
+import { log } from '../../logger.js';
+import { configureSvgSize } from '../../setupGraphViewbox.js';
+import { getConfig } from '../../diagram-api/diagramAPI.js';
+import { calculateTextHeight, calculateTextWidth } from '../../utils.js';
+import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
+import type { MermaidConfig } from '../../config.type.js';
+import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
+import type { ContextMapDb } from './contextMapDb.js';
+import { mapEdgeLabels } from './contextMap.js';
+import { buildGraph, Configuration } from './drawSvg.js';
+import type { Font } from './drawSvg.js';
+
+/**
+ * Draws a Pie Chart with the data given in text.
+ *
+ * @param text - pie chart code
+ * @param id - diagram id
+ * @param _version - MermaidJS version from package.json.
+ * @param diagObj - A standard diagram containing the DB and the text and type etc of the diagram.
+ */
+export const draw: DrawDefinition = (text, id, _version, diagObj) => {
+ log.debug('rendering context map chart\n' + text);
+ const db = diagObj.db as ContextMapDb;
+ const globalConfig: MermaidConfig = getConfig();
+ const conf = globalConfig.contextMap;
+
+ if (!conf) {
+ return;
+ }
+
+ const svg: SVG = selectSvgElement(id);
+ //group.attr('transform', 'translate(' + pieWidth / 2 + ',' + height / 2 + ')');
+
+ const graph = db.getGraph();
+
+ log.debug('graph\n' + JSON.stringify(graph));
+
+ 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 as 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 const renderer = { draw };
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..6669c740db
--- /dev/null
+++ b/packages/mermaid/src/diagrams/context-map/drawSvg.ts
@@ -0,0 +1,441 @@
+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 interface Node {
+ id: string;
+}
+
+export interface Font {
+ fontFamily: string;
+ fontSize: number;
+ fontWeight: number;
+}
+interface EllipseSize {
+ rx: number;
+ ry: number;
+}
+interface 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
+ ) {}
+}
+
+interface Point {
+ x: number;
+ y: number;
+}
+interface 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/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml
index d798ec63b2..0d2f61cbec 100644
--- a/packages/mermaid/src/schemas/config.schema.yaml
+++ b/packages/mermaid/src/schemas/config.schema.yaml
@@ -47,6 +47,7 @@ required:
- xyChart
- requirement
- mindmap
+ - contextMap
- gitGraph
- c4
- sankey
@@ -227,6 +228,8 @@ properties:
$ref: '#/$defs/RequirementDiagramConfig'
mindmap:
$ref: '#/$defs/MindmapDiagramConfig'
+ contextMap:
+ $ref: '#/$defs/ContextMapDiagramConfig'
gitGraph:
$ref: '#/$defs/GitGraphDiagramConfig'
c4:
@@ -890,6 +893,69 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
type: number
default: 200
+ ContextMapDiagramConfig:
+ title: Context Map Language Diagram Config
+ allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }]
+ description: The object containing configurations specific for 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' }]
diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json
index c750f049d5..510facdbec 100644
--- a/packages/parser/langium-config.json
+++ b/packages/parser/langium-config.json
@@ -15,6 +15,11 @@
"id": "pie",
"grammar": "src/language/pie/pie.langium",
"fileExtensions": [".mmd", ".mermaid"]
+ },
+ {
+ "id": "context-map",
+ "grammar": "src/language/contextMap/contextMap.langium",
+ "fileExtensions": [".mmd", ".mermaid"]
}
],
"mode": "production",
diff --git a/packages/parser/src/language/contextMap/contextMap.langium b/packages/parser/src/language/contextMap/contextMap.langium
new file mode 100644
index 0000000000..96adc8160a
--- /dev/null
+++ b/packages/parser/src/language/contextMap/contextMap.langium
@@ -0,0 +1,21 @@
+grammar ContextMap
+import "../common/common";
+
+entry ContextMap:
+ NEWLINE*
+ "context-map-beta" NEWLINE*
+ TitleAndAccessibilities? NEWLINE*
+ blocks+=ContextMapBlock*
+ NEWLINE*
+ ;
+
+ContextMapBlock: ('ContextMap' name=ID '{' body+=(ContextMapNode | ContextMapLink)* '}');
+ContextMapNode: 'contains' name=ID;
+ContextMapLabel returns string: 'D' | 'U' | 'CF' | 'ACL' | 'OHS'| 'PL' | 'SK' | 'C' | 'S' | 'P';
+ContextMapLabelBox: '[' labels+=ContextMapLabel ((',' labels+=ContextMapLabel)?)* ']';
+ContextMapArrow returns string: '->' | '<-' | '<->';
+ContextMapLink: leftNode=[ContextMapNode:ID] leftLabelBox=(ContextMapLabelBox)? direction=ContextMapArrow rightLabelBox=(ContextMapLabelBox)? rightNode=[ContextMapNode:ID];
+terminal ID returns string: /[_a-zA-Z][\w_]*/;
+hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;
+hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
+hidden terminal WS: /\s+/;
diff --git a/packages/parser/src/language/contextMap/index.ts b/packages/parser/src/language/contextMap/index.ts
new file mode 100644
index 0000000000..fd3c604b08
--- /dev/null
+++ b/packages/parser/src/language/contextMap/index.ts
@@ -0,0 +1 @@
+export * from './module.js';
diff --git a/packages/parser/src/language/contextMap/module.ts b/packages/parser/src/language/contextMap/module.ts
new file mode 100644
index 0000000000..e43fdea13a
--- /dev/null
+++ b/packages/parser/src/language/contextMap/module.ts
@@ -0,0 +1,79 @@
+import type {
+ DefaultSharedCoreModuleContext,
+ LangiumCoreServices,
+ LangiumSharedCoreServices,
+ Module,
+ PartialLangiumCoreServices,
+} from 'langium';
+import {
+ EmptyFileSystem,
+ createDefaultCoreModule,
+ createDefaultSharedCoreModule,
+ inject,
+} from 'langium';
+
+import { CommonValueConverter } from '../common/valueConverter.js';
+import { MermaidGeneratedSharedModule, ContextMapGeneratedModule } from '../generated/module.js';
+import { ContextMapTokenBuilder } from './tokenBuilder.js';
+
+/**
+ * Declaration of `ContextMap` services.
+ */
+interface ContextMapAddedServices {
+ parser: {
+ TokenBuilder: ContextMapTokenBuilder;
+ ValueConverter: CommonValueConverter;
+ };
+}
+
+/**
+ * Union of Langium default services and `ContextMap` services.
+ */
+export type ContextMapServices = LangiumCoreServices & ContextMapAddedServices;
+
+/**
+ * Dependency injection module that overrides Langium default services and
+ * contributes the declared `ContextMap` services.
+ */
+export const ContextMapModule: Module<
+ ContextMapServices,
+ PartialLangiumCoreServices & ContextMapAddedServices
+> = {
+ parser: {
+ TokenBuilder: () => new ContextMapTokenBuilder(),
+ ValueConverter: () => new CommonValueConverter(),
+ },
+};
+
+/**
+ * Create the full set of services required by Langium.
+ *
+ * First inject the shared services by merging two modules:
+ * - Langium default shared services
+ * - Services generated by langium-cli
+ *
+ * Then inject the language-specific services by merging three modules:
+ * - Langium default language-specific services
+ * - Services generated by langium-cli
+ * - Services specified in this file
+ * @param context - Optional module context with the LSP connection
+ * @returns An object wrapping the shared services and the language-specific services
+ */
+export function createContextMapServices(
+ context: DefaultSharedCoreModuleContext = EmptyFileSystem
+): {
+ shared: LangiumSharedCoreServices;
+ ContextMap: ContextMapServices;
+} {
+ const shared: LangiumSharedCoreServices = inject(
+ createDefaultSharedCoreModule(context),
+ MermaidGeneratedSharedModule
+ );
+ const ContextMap: ContextMapServices = inject(
+ createDefaultCoreModule({ shared }),
+ ContextMapGeneratedModule,
+ ContextMapModule
+ );
+ shared.ServiceRegistry.register(ContextMap);
+ return { shared, ContextMap };
+}
diff --git a/packages/parser/src/language/contextMap/tokenBuilder.ts b/packages/parser/src/language/contextMap/tokenBuilder.ts
new file mode 100644
index 0000000000..43a2ddff79
--- /dev/null
+++ b/packages/parser/src/language/contextMap/tokenBuilder.ts
@@ -0,0 +1,7 @@
+import { AbstractMermaidTokenBuilder } from '../common/index.js';
+
+export class ContextMapTokenBuilder extends AbstractMermaidTokenBuilder {
+ public constructor() {
+ super(['context-map-beta']);
+ }
+}
diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts
index 9f1d92ba8a..8fdbd52986 100644
--- a/packages/parser/src/language/index.ts
+++ b/packages/parser/src/language/index.ts
@@ -11,15 +11,20 @@ export {
isPacketBlock,
isPie,
isPieSection,
+ ContextMap,
+ ContextMapNode,
+ ContextMapLink,
} from './generated/ast.js';
export {
InfoGeneratedModule,
MermaidGeneratedSharedModule,
PacketGeneratedModule,
PieGeneratedModule,
+ ContextMapGeneratedModule,
} from './generated/module.js';
export * from './common/index.js';
export * from './info/index.js';
export * from './packet/index.js';
export * from './pie/index.js';
+export * from './contextMap/index.js';
diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts
index 992b965060..ce460dd6f2 100644
--- a/packages/parser/src/parse.ts
+++ b/packages/parser/src/parse.ts
@@ -1,8 +1,8 @@
import type { LangiumParser, ParseResult } from 'langium';
-import type { Info, Packet, Pie } from './index.js';
+import type { Info, Packet, Pie, ContextMap } from './index.js';
-export type DiagramAST = Info | Packet | Pie;
+export type DiagramAST = Info | Packet | Pie | ContextMap;
const parsers: Record = {};
const initializers = {
@@ -21,11 +21,18 @@ const initializers = {
const parser = createPieServices().Pie.parser.LangiumParser;
parsers.pie = parser;
},
+ contextMap: async () => {
+ const { createContextMapServices } = await import('./language/contextMap/index.js');
+ const parser = createContextMapServices().ContextMap.parser.LangiumParser;
+ parsers.contextMap = parser;
+ },
} as const;
export async function parse(diagramType: 'info', text: string): Promise;
export async function parse(diagramType: 'packet', text: string): Promise;
export async function parse(diagramType: 'pie', text: string): Promise;
+export async function parse(diagramType: 'contextMap', text: string): Promise;
+
export async function parse(
diagramType: keyof typeof initializers,
text: string
diff --git a/packages/parser/tests/contextMap.test.ts b/packages/parser/tests/contextMap.test.ts
new file mode 100644
index 0000000000..71d98a689b
--- /dev/null
+++ b/packages/parser/tests/contextMap.test.ts
@@ -0,0 +1,172 @@
+import { parse } from '../src/parse.js';
+import { describe, expect, it } from 'vitest';
+import { expandToString as s } from 'langium/generate';
+import { isContextMap as isModel } from '../src/language/generated/ast.js';
+import type { ContextMap as Model, ContextMapNode, ContextMapLink} from '../src/language/generated/ast.js';
+
+describe('contextMap parsing', () => {
+ const exampleGrammar = `
+context-map-beta
+
+/* 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
+ }
+ `;
+
+ it('should not produce error', async () => {
+ const actionShouldNotFail = async () => {
+ await parse('contextMap', exampleGrammar);
+ };
+
+ await expect(actionShouldNotFail()).resolves.not.toThrow();
+ });
+
+ it('validate model', async () => {
+ const ast: Model = await parse('contextMap', exampleGrammar);
+
+ expect(isModel(ast)).toBeTruthy();
+ });
+
+ it('the grammar contains the right number of entities', async () => {
+ const ast: Model = await parse('contextMap', exampleGrammar);
+
+ expect(ast.blocks[0].body.length).toBe(13);
+ expect(ast.blocks[0].body?.filter((n) => n.$type === 'ContextMapNode').length).toBe(6);
+ expect(ast.blocks[0].body?.filter((n) => n.$type === 'ContextMapLink').length).toBe(7);
+ });
+
+ it('parse simple model', async () => {
+ const ast: Model = await parse('contextMap', exampleGrammar);
+
+ const text = s`
+ Body:
+ name: ${ast.blocks[0].name}
+ Nodes:
+ ${ast.blocks[0].body
+ ?.filter((n) => n.$type === 'ContextMapNode')
+ .map((rawNode) => {
+ const node = rawNode as ContextMapNode
+ return s`
+ Node:
+ name: ${node.name}
+ `;
+ })
+ ?.join('\n')}
+ Edges:
+ ${ast.blocks[0].body
+ ?.filter((n) => n.$type === 'ContextMapLink')
+ .map((rawLink) => {
+ const link = rawLink as ContextMapLink
+ return s`
+ Edge:
+ direction: ${link.direction}
+ LeftNode:
+ name: ${link.leftNode.$refText}
+ labels: ${link.leftLabelBox?.labels.join(',')}
+ RightNode:
+ name: ${link.rightNode.$refText}
+ labels: ${link.leftLabelBox?.labels.join(',')}
+ `;
+ })
+ ?.join('\n')}
+ `;
+ expect(text).toBe(s`
+ Body:
+ name: InsuranceContextMap
+ Nodes:
+ Node:
+ name: CustomerManagementContext
+ Node:
+ name: CustomerSelfServiceContext
+ Node:
+ name: PrintingContext
+ Node:
+ name: PolicyManagementContext
+ Node:
+ name: RiskManagementContext
+ Node:
+ name: DebtCollection
+ Edges:
+ Edge:
+ direction: <-
+ LeftNode:
+ name: CustomerSelfServiceContext
+ labels: D,C
+ RightNode:
+ name: CustomerManagementContext
+ labels: D,C
+ Edge:
+ direction: <-
+ LeftNode:
+ name: CustomerManagementContext
+ labels: D,ACL
+ RightNode:
+ name: PrintingContext
+ labels: D,ACL
+ Edge:
+ direction: ->
+ LeftNode:
+ name: PrintingContext
+ labels: U,OHS,PL
+ RightNode:
+ name: PolicyManagementContext
+ labels: U,OHS,PL
+ Edge:
+ direction: <->
+ LeftNode:
+ name: RiskManagementContext
+ labels: P
+ RightNode:
+ name: PolicyManagementContext
+ labels: P
+ Edge:
+ direction: <-
+ LeftNode:
+ name: PolicyManagementContext
+ labels: D,CF
+ RightNode:
+ name: CustomerManagementContext
+ labels: D,CF
+ Edge:
+ direction: <-
+ LeftNode:
+ name: DebtCollection
+ labels: D,ACL
+ RightNode:
+ name: PrintingContext
+ labels: D,ACL
+ Edge:
+ direction: <->
+ LeftNode:
+ name: PolicyManagementContext
+ labels: SK
+ RightNode:
+ name: DebtCollection
+ labels: SK
+ `);
+ });
+});