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 + `); + }); +});