diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 39d6363d836..bf12b38b461 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -32,6 +32,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'eventmodeling', 'radar', 'venn', + 'cynefin', ] as const; /** diff --git a/.changeset/add-cynefin-diagram.md b/.changeset/add-cynefin-diagram.md new file mode 100644 index 00000000000..7a43bd1924f --- /dev/null +++ b/.changeset/add-cynefin-diagram.md @@ -0,0 +1,24 @@ +--- +'mermaid': minor +--- + +Add Cynefin framework diagram type (beta) + +Adds the Cynefin framework as a new diagram type to Mermaid (available as `cynefin-beta`). The Cynefin framework, created by Dave Snowden, is a decision-making framework that categorizes problems into five complexity domains, widely used in agile, incident management, strategy, and organizational design. + +Features: + +- Five fixed domains in canonical layout: Complex, Complicated, Clear, Chaotic, and Confusion +- Wavy organic boundaries between domains using deterministic SVG bezier curves +- The "cliff" between Clear and Chaotic (catastrophic transition indicator) +- Confusion/Disorder center ellipse overlay +- Domain metadata including decision models (Probe/Sense/Respond etc.) and practice types +- Items placed as text badges within each domain +- Transition arrows between domains with optional labels +- Full theme integration across all five mermaid themes +- Schema-driven configuration: width, height, padding, showDomainDescriptions, boundaryAmplitude +- Accessibility: ARIA roles, labels, descriptions + +This is the first text-based DSL for Cynefin diagrams in any diagramming tool. + +Implementation includes Langium grammar, modular renderer, unit tests, integration tests, Cypress e2e tests, and comprehensive documentation. diff --git a/.cspell/mermaid-terms.txt b/.cspell/mermaid-terms.txt index 5a934356ecd..29f87f5a1e0 100644 --- a/.cspell/mermaid-terms.txt +++ b/.cspell/mermaid-terms.txt @@ -7,6 +7,7 @@ catmull compositTitleSize cose curv +cynefin deaccelerator Deaccelerator deaccelerators diff --git a/cypress/integration/rendering/cynefin/cynefin.spec.js b/cypress/integration/rendering/cynefin/cynefin.spec.js new file mode 100644 index 00000000000..166b480aa00 --- /dev/null +++ b/cypress/integration/rendering/cynefin/cynefin.spec.js @@ -0,0 +1,208 @@ +import { imgSnapshotTest } from '../../../helpers/util'; + +describe('cynefin framework', () => { + it('should render a simple cynefin diagram with all five domains', () => { + imgSnapshotTest( + `cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" + ` + ); + }); + + it('should render a cynefin diagram with transitions', () => { + imgSnapshotTest( + `cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" + ` + ); + }); + + it('should render an empty cynefin framework', () => { + imgSnapshotTest( + `cynefin-beta + title Empty Framework + + complex + complicated + clear + chaotic + ` + ); + }); + + it('should render cynefin with many items per domain', () => { + imgSnapshotTest( + `cynefin-beta + title Software Delivery + + complex + "New product discovery" + "User behavior analysis" + "A/B testing strategy" + + complicated + "Performance optimization" + "Security audit" + "Database migration" + + clear + "Deploy to staging" + "Run test suite" + "Merge pull request" + + chaotic + "Production outage" + "Data breach response" + ` + ); + }); + + it('should render cynefin with config override', () => { + imgSnapshotTest( + `cynefin-beta + complex + "Adaptive work" + clear + "Standard work" + `, + { cynefin: { width: 1000, height: 700, boundaryAmplitude: 15 } } + ); + }); + + it('should render cynefin without domain descriptions', () => { + imgSnapshotTest( + `cynefin-beta + title Minimal Labels + + complex + "Item A" + clear + "Item B" + `, + { cynefin: { showDomainDescriptions: false } } + ); + }); + + it('should render cynefin with theme override', () => { + imgSnapshotTest( + `cynefin-beta + complex + "Test item" + clear + "Standard" + `, + { + theme: 'base', + themeVariables: { + cynefin: { + complexBg: '#FFE4B5', + clearBg: '#E6E6FA', + boundaryColor: '#FF0000', + }, + }, + } + ); + }); + + it('should render cynefin with straight boundaries when amplitude is zero', () => { + imgSnapshotTest( + `cynefin-beta + title Straight Boundaries + + complex + "Item A" + complicated + "Item B" + clear + "Item C" + chaotic + "Item D" + `, + { cynefin: { boundaryAmplitude: 0 } } + ); + }); + + it('should render cynefin with confusion domain items without overflow', () => { + imgSnapshotTest( + `cynefin-beta + title Confusion Items + + confusion + "Unknown A" + "Unknown B" + "Unknown C" + "Unknown D" + "Unknown E" + ` + ); + }); + + it('should render cynefin with self-loop transitions silently dropped', () => { + imgSnapshotTest( + `cynefin-beta + title Self-loop Handling + + complex + "Emergent work" + complicated + "Expert work" + + complex --> complicated : "Pattern found" + complex --> complex : "Self-loop (dropped)" + ` + ); + }); + + it('should render cynefin with accessibility directives', () => { + imgSnapshotTest( + `cynefin-beta + accTitle: Cynefin framework for software delivery + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "Feature discovery" + complicated + "Refactoring" + clear + "Hotfix" + chaotic + "Incident" + ` + ); + }); +}); diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 9afcde1e349..1c0a1dff4f4 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:311](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L311) +Defined in: [packages/mermaid/src/defaultConfig.ts:314](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L314) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index fea6ebaac4d..0df41633544 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -65,6 +65,14 @@ Defined in: [packages/mermaid/src/config.type.ts:224](https://github.com/mermaid --- +### cynefin? + +> `optional` **cynefin**: `CynefinDiagramConfig` + +Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) + +--- + ### darkMode? > `optional` **darkMode**: `boolean` @@ -105,7 +113,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:245](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L245) +Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) --- @@ -187,7 +195,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L247) +Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L248) --- @@ -313,7 +321,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:248](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L248) +Defined in: [packages/mermaid/src/config.type.ts:249](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L249) --- @@ -445,7 +453,7 @@ Defined in: [packages/mermaid/src/config.type.ts:225](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:254](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L254) +Defined in: [packages/mermaid/src/config.type.ts:255](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L255) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -515,7 +523,7 @@ Defined in: [packages/mermaid/src/config.type.ts:244](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:246](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L246) +Defined in: [packages/mermaid/src/config.type.ts:247](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L247) --- diff --git a/docs/syntax/cynefin.md b/docs/syntax/cynefin.md new file mode 100644 index 00000000000..33d7d411efc --- /dev/null +++ b/docs/syntax/cynefin.md @@ -0,0 +1,278 @@ +> **Warning** +> +> ## THIS IS AN AUTOGENERATED FILE. DO NOT EDIT. +> +> ## Please edit the corresponding file in [/packages/mermaid/src/docs/syntax/cynefin.md](../../packages/mermaid/src/docs/syntax/cynefin.md). + +# Cynefin Framework Diagram (v\+) + +> The Cynefin framework is a sense-making framework created by [Dave Snowden](https://en.wikipedia.org/wiki/Dave_Snowden) that categorizes problems into five complexity domains. It helps teams match their response to the nature of the situation they are facing. The name is Welsh for "place" or "habitat". + +You can read more at [The Cynefin Co](https://thecynefin.co/about-us/about-cynefin-framework). + +## Introduction + +The Cynefin framework divides the world into five domains, each with its own decision-making approach: + +- **Clear** (formerly Obvious/Simple): Cause and effect are obvious. Sense → Categorise → Respond. Apply **best practices**. +- **Complicated**: Cause and effect require analysis or expertise. Sense → Analyse → Respond. Apply **good practices**. +- **Complex**: Cause and effect can only be deduced in retrospect. Probe → Sense → Respond. Apply **emergent practices**. +- **Chaotic**: No perceivable cause and effect. Act → Sense → Respond. Apply **novel practices**. +- **Confusion** (or Disorder): You do not know which domain you are in. The goal is to move items out of this state into one of the other four. + +The signature visual feature is the wavy, organic boundary between the ordered (Clear, Complicated) and unordered (Complex, Chaotic) halves, and the "cliff" between Clear and Chaotic representing the risk of complacency leading to crisis. + +## Syntax + +A Cynefin diagram is declared with `cynefin-beta` followed by domain blocks and optional transitions. + +```md +cynefin-beta +title Optional Diagram Title + +complex +"Item label" +"Another item" + +complicated +"Expert analysis needed" + +clear +"Known procedure" + +chaotic +"Crisis response" + +confusion +"Item of unknown domain" + +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +``` + +### Keywords + +| Keyword | Meaning | +| -------------- | ---------------------------------------------------------------------- | +| `cynefin-beta` | Diagram type declaration (required, first line after any front matter) | +| `title` | Optional diagram title | +| `complex` | Opens the Complex domain block | +| `complicated` | Opens the Complicated domain block | +| `clear` | Opens the Clear domain block | +| `chaotic` | Opens the Chaotic domain block | +| `confusion` | Opens the Confusion / Disorder domain block | +| `-->` | Declares a transition from one domain to another | + +### Items + +Items are quoted string labels placed on their own lines inside a domain block. Each item renders as a text badge within the domain region. + +``` +complex + "Investigate root cause" + "Run chaos experiment" +``` + +Keep per-domain item lists short — the quadrants have fixed layout and long lists can visually overflow their boxes. The confusion ellipse caps at three items and shows a `+N more` badge; the four quadrant domains do not clip, so prefer a handful of items each. + +### Transitions + +Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. + +``` +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +chaotic --> complex : "Stabilized" +``` + +Common transitions: + +- **Complex → Complicated**: An emerging pattern has become understood and can now be analyzed. +- **Chaotic → Complex**: A crisis has been stabilized enough to begin probing. +- **Clear → Chaotic**: Complacency or over-constraint has led to collapse (the "cliff"). +- **Complicated → Clear**: Analysis has codified a solution into a standard practice. + +## Examples + +### Basic example + +```mermaid-example +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +```mermaid +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +### With transitions + +```mermaid-example +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +```mermaid +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +### Empty framework + +The domains themselves render even with no items, useful as a teaching or worksheet template. + +```mermaid-example +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +```mermaid +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +## Configuration + +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: + +| Option | Type | Default | Description | +| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | +| `width` | number | `800` | Width of the diagram in pixels | +| `height` | number | `600` | Height of the diagram in pixels | +| `padding` | number | `40` | Padding around the diagram | +| `showDomainDescriptions` | boolean | `true` | Show decision model and practice type subtitles per domain | +| `boundaryAmplitude` | number | `8` | Waviness amplitude of domain boundaries in pixels (set to `0` for straight lines) | + +Example: + +``` +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +cynefin-beta + complex + "Adaptive work" +``` + +## Theming + +Cynefin diagrams use the following theme variables, which can be overridden via `themeVariables.cynefin`: + +| Variable | Description | +| ---------------- | ------------------------------------------------ | +| `complexBg` | Background color for the Complex domain | +| `complicatedBg` | Background color for the Complicated domain | +| `clearBg` | Background color for the Clear domain | +| `chaoticBg` | Background color for the Chaotic domain | +| `confusionBg` | Background color for the Confusion center region | +| `boundaryColor` | Color of the wavy domain boundaries | +| `boundaryWidth` | Stroke width of the boundaries | +| `cliffColor` | Color of the Clear/Chaotic cliff | +| `cliffWidth` | Stroke width of the cliff | +| `arrowColor` | Color of transition arrows | +| `arrowWidth` | Stroke width of transition arrows | +| `labelColor` | Color of domain name labels | +| `textColor` | Color of item and subtitle text | +| `domainFontSize` | Font size of domain name labels | +| `itemFontSize` | Font size of item badges and subtitles | + +## Notes + +- Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. +- Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- The `confusion` domain has a compact center ellipse. Up to 3 items are shown inside it; if more are provided a `+N more` overflow badge is displayed. In practice, the confusion domain should contain very few items — its purpose is to surface unknowns so they can be moved to one of the four main domains. +- Self-loop transitions (e.g. `complex --> complex`) are silently ignored. Transitions must connect two different domains. +- Handdrawn mode is not currently supported. +- The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. + +## Accessibility + +Cynefin diagrams support the standard mermaid accessibility directives: + +``` +cynefin-beta + accTitle: Cynefin framework for software delivery decisions + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "New feature discovery" +``` diff --git a/packages/examples/src/examples/cynefin.ts b/packages/examples/src/examples/cynefin.ts new file mode 100644 index 00000000000..df1c8965851 --- /dev/null +++ b/packages/examples/src/examples/cynefin.ts @@ -0,0 +1,37 @@ +import type { DiagramMetadata } from '../types.js'; + +export default { + id: 'cynefin', + name: 'Cynefin Framework', + description: 'Decision-making framework for categorizing problems by complexity', + examples: [ + { + title: 'Incident Response', + isDefault: true, + code: `cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" + + complex --> complicated : "Pattern identified" + clear --> chaotic : "Complacency" +`, + }, + ], +} satisfies DiagramMetadata; diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index f5fc9dc6258..dc3a8ea7674 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -26,6 +26,7 @@ import eventmodelingDiagram from './examples/eventmodeling.js'; import vennDiagram from './examples/venn.js'; import treeViewDiagram from './examples/tree-view.js'; import wardleyDiagram from './examples/wardley.js'; +import cynefinDiagram from './examples/cynefin.js'; export const diagramData: DiagramMetadata[] = [ flowChart, @@ -55,4 +56,5 @@ export const diagramData: DiagramMetadata[] = [ vennDiagram, treeViewDiagram, wardleyDiagram, + cynefinDiagram, ]; diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index adec8be0ce0..2bdd57eaeb4 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -242,6 +242,7 @@ export interface MermaidConfig { radar?: RadarDiagramConfig; venn?: VennDiagramConfig; 'wardley-beta'?: WardleyDiagramConfig; + cynefin?: CynefinDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; @@ -1820,6 +1821,34 @@ export interface WardleyDiagramConfig extends BaseDiagramConfig { */ showGrid?: boolean; } +/** + * Configuration for Cynefin framework diagrams. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "CynefinDiagramConfig". + */ +export interface CynefinDiagramConfig extends BaseDiagramConfig { + /** + * The width of the Cynefin diagram. + */ + width?: number; + /** + * The height of the Cynefin diagram. + */ + height?: number; + /** + * Padding around the diagram. + */ + padding?: number; + /** + * Show decision model and practice type labels. + */ + showDomainDescriptions?: boolean; + /** + * Waviness amplitude of domain boundaries (0 for straight). + */ + boundaryAmplitude?: number; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "FontConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 18380d45e6b..87318154ce5 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -295,6 +295,9 @@ const config: RequiredDeep = { venn: { ...defaultConfigJson.venn, }, + cynefin: { + ...defaultConfigJson.cynefin, + }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index dd406e552f7..ac7e92d3311 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -33,6 +33,7 @@ import { registerLazyLoadedDiagrams } from './detectType.js'; import { registerDiagram } from './diagramAPI.js'; import { treemap } from '../diagrams/treemap/detector.js'; import wardley from '../diagrams/wardley/wardleyDetector.js'; +import { cynefin } from '../diagrams/cynefin/cynefinDetector.js'; import '../type.d.ts'; let hasLoadedDiagrams = false; @@ -111,6 +112,7 @@ export const addDiagrams = () => { ishikawa, treemap, venn, - wardley + wardley, + cynefin ); }; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts new file mode 100644 index 00000000000..aafdab0f436 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.integration.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { parser } from './cynefinParser.js'; +import { db } from './cynefinDb.js'; + +describe('Cynefin Parsing - Basic', () => { + beforeEach(() => db.clear()); + + it('should parse basic cynefin diagram with domains', async () => { + await parser.parse(`cynefin-beta + complex + "Emergent practice" + "Probe-sense-respond" + clear + "Best practice" +`); + const domains = db.getDomains(); + expect(domains.size).toBe(2); + expect(domains.get('complex')!.items[0].label).toBe('Emergent practice'); + expect(domains.get('complex')!.items[1].label).toBe('Probe-sense-respond'); + expect(domains.get('clear')!.items[0].label).toBe('Best practice'); + }); + + it('should parse all five domains', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + clear + "C" + chaotic + "D" + confusion + "E" +`); + const domains = db.getDomains(); + expect(domains.size).toBe(5); + expect(domains.has('complex')).toBe(true); + expect(domains.has('complicated')).toBe(true); + expect(domains.has('clear')).toBe(true); + expect(domains.has('chaotic')).toBe(true); + expect(domains.has('confusion')).toBe(true); + }); + + it('should parse empty domains', async () => { + await parser.parse(`cynefin-beta + complex +`); + const domains = db.getDomains(); + expect(domains.has('complex')).toBe(true); + expect(domains.get('complex')!.items).toHaveLength(0); + }); + + it('should parse domain with multiple items', async () => { + await parser.parse(`cynefin-beta + complex + "First item" + "Second item" + "Third item" +`); + const items = db.getDomains().get('complex')!.items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('First item'); + expect(items[1].label).toBe('Second item'); + expect(items[2].label).toBe('Third item'); + }); + + it('should parse comments', async () => { + await parser.parse(`cynefin-beta + %% This is a comment + complex + "Item" + %% Another comment +`); + const domains = db.getDomains(); + expect(domains.size).toBe(1); + expect(domains.get('complex')!.items[0].label).toBe('Item'); + }); +}); + +describe('Cynefin Parsing - Transitions', () => { + beforeEach(() => db.clear()); + + it('should parse transition between domains', async () => { + await parser.parse(`cynefin-beta + complex + "Practice" + complicated + "Analysis" + complex --> complicated : "Pattern found" +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should parse transition without label', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + complex --> complicated +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBeUndefined(); + }); + + it('should parse multiple transitions', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + clear + "C" + complex --> complicated : "Analyzed" + complicated --> clear : "Codified" + chaotic --> complex : "Stabilized" +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(3); + expect(transitions[0].from).toBe('complex'); + expect(transitions[1].from).toBe('complicated'); + expect(transitions[2].from).toBe('chaotic'); + }); +}); + +describe('Cynefin Parsing - Accessibility', () => { + beforeEach(() => db.clear()); + + it('should parse accTitle', async () => { + await parser.parse(`cynefin-beta + accTitle: Cynefin Framework Overview + complex + "Emergent" +`); + expect(db.getAccTitle()).toBe('Cynefin Framework Overview'); + }); + + it('should parse accDescr', async () => { + await parser.parse(`cynefin-beta + accDescr: A diagram showing the five Cynefin domains + complex + "Emergent" +`); + expect(db.getAccDescription()).toBe('A diagram showing the five Cynefin domains'); + }); + + it('should parse title', async () => { + await parser.parse(`cynefin-beta + title My Cynefin Map + complex + "Emergent" +`); + expect(db.getDiagramTitle()).toBe('My Cynefin Map'); + }); +}); + +describe('Cynefin Parsing - Complex diagram', () => { + beforeEach(() => db.clear()); + + it('should parse a full diagram with all features', async () => { + await parser.parse(`cynefin-beta + title Team Practices + accTitle: Cynefin for team practices + complex + "Retrospectives" + "Pair programming" + complicated + "Code review" + "Architecture decisions" + clear + "Deployment checklist" + chaotic + "Incident response" + confusion + "New initiative" + complex --> complicated : "Pattern emerges" + complicated --> clear : "Best practice found" + chaotic --> complex : "Stabilized" + confusion --> chaotic : "Crisis detected" +`); + expect(db.getDiagramTitle()).toBe('Team Practices'); + expect(db.getAccTitle()).toBe('Cynefin for team practices'); + + const domains = db.getDomains(); + expect(domains.size).toBe(5); + expect(domains.get('complex')!.items).toHaveLength(2); + expect(domains.get('complicated')!.items).toHaveLength(2); + expect(domains.get('clear')!.items).toHaveLength(1); + expect(domains.get('chaotic')!.items).toHaveLength(1); + expect(domains.get('confusion')!.items).toHaveLength(1); + + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(4); + expect(transitions[0]).toMatchObject({ + from: 'complex', + to: 'complicated', + label: 'Pattern emerges', + }); + expect(transitions[3]).toMatchObject({ + from: 'confusion', + to: 'chaotic', + label: 'Crisis detected', + }); + }); +}); + +describe('Cynefin Parsing - Self-loop transitions', () => { + beforeEach(() => db.clear()); + + it('should drop self-loop transitions and keep valid ones', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complicated + "B" + complex --> complex : "Self-reflection" + complex --> complicated : "Pattern found" + complicated --> complicated +`); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should produce zero transitions when all are self-loops', async () => { + await parser.parse(`cynefin-beta + complex + "A" + complex --> complex +`); + expect(db.getTransitions()).toHaveLength(0); + }); +}); + +describe('Cynefin Parsing - Re-parse after clear', () => { + beforeEach(() => db.clear()); + + it('should have fresh state after clear and re-parse', async () => { + await parser.parse(`cynefin-beta + title First Diagram + complex + "Alpha" + "Beta" + complicated + "Gamma" + complex --> complicated : "Move" +`); + expect(db.getDomains().size).toBe(2); + expect(db.getTransitions()).toHaveLength(1); + expect(db.getDiagramTitle()).toBe('First Diagram'); + + db.clear(); + + await parser.parse(`cynefin-beta + title Second Diagram + chaotic + "Delta" +`); + expect(db.getDomains().size).toBe(1); + expect(db.getDomains().has('chaotic')).toBe(true); + expect(db.getDomains().has('complex')).toBe(false); + expect(db.getTransitions()).toHaveLength(0); + expect(db.getDiagramTitle()).toBe('Second Diagram'); + }); +}); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts new file mode 100644 index 00000000000..4c1bc97d52c --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefin.spec.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { DomainBlock, Transition } from '@mermaid-js/parser'; +import { db } from './cynefinDb.js'; +import { + seededRandom, + hashString, + generateFoldPath, + generateHorizontalBoundary, + generateCliffPath, + generateConfusionPath, +} from './cynefinBoundaries.js'; + +/** Test helper: build a partial DomainBlock with just the fields the DB reads. */ +const block = (domain: string, items: { label: string }[] = []): DomainBlock => + ({ domain, items: items.map((i) => ({ label: i.label })) }) as unknown as DomainBlock; + +/** Test helper: build a partial Transition with just the fields the DB reads. */ +const tx = (from: string, to: string, label?: string): Transition => + ({ from, to, label: label ?? '' }) as unknown as Transition; + +describe('Cynefin Database', () => { + beforeEach(() => db.clear()); + + it('should start empty', () => { + expect(db.getDomains().size).toBe(0); + expect(db.getTransitions().length).toBe(0); + }); + + it('should set domains from AST blocks', () => { + db.setDomains([block('complex', [{ label: 'Test' }]), block('clear')]); + expect(db.getDomains().size).toBe(2); + expect(db.getDomains().get('complex')!.items).toHaveLength(1); + expect(db.getDomains().get('clear')!.items).toHaveLength(0); + }); + + it('should set transitions', () => { + db.setTransitions([tx('complex', 'complicated', 'Pattern found')]); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + expect(transitions[0].label).toBe('Pattern found'); + }); + + it('should set transitions without labels', () => { + db.setTransitions([tx('chaotic', 'complex')]); + expect(db.getTransitions()[0].label).toBeUndefined(); + }); + + it('should filter out self-loop transitions', () => { + db.setTransitions([ + tx('complex', 'complex'), + tx('complex', 'complicated', 'Pattern found'), + tx('clear', 'clear'), + ]); + const transitions = db.getTransitions(); + expect(transitions).toHaveLength(1); + expect(transitions[0].from).toBe('complex'); + expect(transitions[0].to).toBe('complicated'); + }); + + it('should handle a list of only self-loops gracefully', () => { + db.setTransitions([tx('complex', 'complex'), tx('chaotic', 'chaotic')]); + expect(db.getTransitions()).toHaveLength(0); + }); + + it('should handle null/undefined blocks', () => { + expect(() => db.setDomains(null as unknown as DomainBlock[])).not.toThrow(); + expect(() => db.setTransitions(null as unknown as Transition[])).not.toThrow(); + }); + + it('should clear all data', () => { + db.setDomains([block('complex', [{ label: 'A' }])]); + db.setTransitions([tx('complex', 'chaotic')]); + db.clear(); + expect(db.getDomains().size).toBe(0); + expect(db.getTransitions().length).toBe(0); + }); + + it('should handle multiple domains', () => { + db.setDomains([ + block('complex'), + block('complicated'), + block('clear'), + block('chaotic'), + block('confusion'), + ]); + expect(db.getDomains().size).toBe(5); + }); + + it('should handle domain with multiple items', () => { + db.setDomains([ + block('complex', [{ label: 'A' }, { label: 'B' }, { label: 'C' }, { label: 'D' }]), + ]); + expect(db.getDomains().get('complex')!.items).toHaveLength(4); + }); + + it('should return config', () => { + const config = db.getConfig(); + expect(typeof config).toBe('object'); + }); +}); + +describe('Cynefin Boundaries', () => { + it('seededRandom should return deterministic values', () => { + expect(seededRandom(42)).toBe(seededRandom(42)); + }); + + it('seededRandom should return values between 0 and 1', () => { + for (const seed of [0, 1, 100, 999999, -50]) { + const val = seededRandom(seed); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThan(1); + } + }); + + it('hashString should return consistent hashes', () => { + expect(hashString('cynefin')).toBe(hashString('cynefin')); + }); + + it('hashString should return different hashes for different strings', () => { + expect(hashString('complex')).not.toBe(hashString('chaotic')); + }); + + it('generateFoldPath should return valid SVG path', () => { + const path = generateFoldPath(800, 600, 42); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateFoldPath should be deterministic', () => { + const a = generateFoldPath(800, 600, 42); + const b = generateFoldPath(800, 600, 42); + expect(a).toBe(b); + }); + + it('generateHorizontalBoundary should return valid SVG path', () => { + const path = generateHorizontalBoundary(800, 600, 42); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateCliffPath should return valid SVG path', () => { + const path = generateCliffPath(800, 600); + expect(path).toMatch(/^M/); + expect(path).toContain('C'); + }); + + it('generateConfusionPath should return valid ellipse path', () => { + const path = generateConfusionPath(400, 300, 50, 40); + expect(path).toMatch(/^M/); + expect(path).toContain('A'); + expect(path).toMatch(/Z$/); + }); + + it('generateConfusionPath should use provided center and radii', () => { + const path = generateConfusionPath(400, 300, 50, 40); + expect(path).toContain('350'); // cx - rx = 400 - 50 + expect(path).toContain('450'); // cx + rx = 400 + 50 + expect(path).toContain('300'); // cy + }); +}); diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts new file mode 100644 index 00000000000..eb7b77309b2 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinBoundaries.ts @@ -0,0 +1,151 @@ +/** + * Deterministic pseudo-random number generator (mulberry32). + * Returns a function that produces values in [0, 1). + */ +export function seededRandom(seed: number): number { + let t = (seed + 0x6d2b79f5) | 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +/** + * Simple string hash for seeding the PRNG. + */ +export function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = (hash << 5) - hash + ch; + hash |= 0; + } + return hash; +} + +/** + * Generate a vertical wavy line (the "fold") through the center of the diagram. + * Uses cubic bezier segments with alternating control point offsets. + * @param width - diagram width + * @param height - diagram height + * @param seed - deterministic seed for variation + * @param amplitudeOverride - optional amplitude in pixels; falls back to 1.5% of width + * @returns SVG path d attribute string + */ +export function generateFoldPath( + width: number, + height: number, + seed: number, + amplitudeOverride?: number +): string { + const cx = width / 2; + const amplitude = amplitudeOverride ?? width * 0.015; + const segments = 7; + const segHeight = height / segments; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const jitter = seededRandom(seed + i * 17) * amplitude * 2 - amplitude; + points.push({ x: cx + jitter, y: i * segHeight }); + } + + let d = `M${points[0].x},${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + const midY = (p0.y + p1.y) / 2; + const dir = i % 2 === 0 ? 1 : -1; + const offset = amplitude * 1.5 * dir * seededRandom(seed + i * 31 + 7); + const cp1x = p0.x + offset; + const cp1y = midY; + const cp2x = p1.x - offset; + const cp2y = midY; + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p1.x},${p1.y}`; + } + + return d; +} + +/** + * Generate a horizontal wavy line through the center of the diagram. + * @param width - diagram width + * @param height - diagram height + * @param seed - deterministic seed for variation + * @param amplitudeOverride - optional amplitude in pixels; falls back to 1.5% of height + * @returns SVG path d attribute string + */ +export function generateHorizontalBoundary( + width: number, + height: number, + seed: number, + amplitudeOverride?: number +): string { + const cy = height / 2; + const amplitude = amplitudeOverride ?? height * 0.015; + const segments = 7; + const segWidth = width / segments; + const points: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const jitter = seededRandom(seed + i * 23) * amplitude * 2 - amplitude; + points.push({ x: i * segWidth, y: cy + jitter }); + } + + let d = `M${points[0].x},${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + const midX = (p0.x + p1.x) / 2; + const dir = i % 2 === 0 ? 1 : -1; + const offset = amplitude * 1.5 * dir * seededRandom(seed + i * 37 + 11); + const cp1x = midX; + const cp1y = p0.y + offset; + const cp2x = midX; + const cp2y = p1.y - offset; + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p1.x},${p1.y}`; + } + + return d; +} + +/** + * Generate the "cliff" path between Clear (bottom-right) and Chaotic (bottom-left). + * This is a thicker, more abrupt boundary near the bottom center. + * @param width - diagram width + * @param height - diagram height + * @returns SVG path d attribute string + */ +export function generateCliffPath(width: number, height: number): string { + const cx = width / 2; + const topY = height * 0.5; + const bottomY = height; + const amplitude = width * 0.03; + + // A steep S-curve from the center downward + return [ + `M${cx},${topY}`, + `C${cx + amplitude},${topY + (bottomY - topY) * 0.2}`, + `${cx - amplitude * 1.5},${topY + (bottomY - topY) * 0.55}`, + `${cx + amplitude * 0.5},${topY + (bottomY - topY) * 0.75}`, + `C${cx - amplitude},${topY + (bottomY - topY) * 0.85}`, + `${cx + amplitude * 0.3},${topY + (bottomY - topY) * 0.95}`, + `${cx},${bottomY}`, + ].join(' '); +} + +/** + * Generate an ellipse SVG path for the confusion/disorder region at the center. + * @param cx - center x + * @param cy - center y + * @param rx - horizontal radius + * @param ry - vertical radius + * @returns SVG path d attribute string for an ellipse + */ +export function generateConfusionPath(cx: number, cy: number, rx: number, ry: number): string { + // Draw an ellipse using two arc commands + return [ + `M${cx - rx},${cy}`, + `A${rx},${ry} 0 1,1 ${cx + rx},${cy}`, + `A${rx},${ry} 0 1,1 ${cx - rx},${cy}`, + 'Z', + ].join(' '); +} diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts new file mode 100644 index 00000000000..d4613e1e507 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDb.ts @@ -0,0 +1,96 @@ +import type { DomainBlock, Transition } from '@mermaid-js/parser'; +import { getConfig as commonGetConfig } from '../../config.js'; +import type { CynefinDiagramConfig } from '../../config.type.js'; +import DEFAULT_CONFIG from '../../defaultConfig.js'; +import { log } from '../../logger.js'; +import { cleanAndMerge } from '../../utils.js'; +import { + clear as commonClear, + getAccDescription, + getAccTitle, + getDiagramTitle, + setAccDescription, + setAccTitle, + setDiagramTitle, +} from '../common/commonDb.js'; +import type { CynefinDB, CynefinDomain, CynefinTransition, DomainName } from './types.js'; + +interface CynefinData { + domains: Map; + transitions: CynefinTransition[]; +} + +const createDefaultData = (): CynefinData => ({ + domains: new Map(), + transitions: [], +}); + +let data: CynefinData = createDefaultData(); + +const getDomains = (): Map => data.domains; + +const getTransitions = (): CynefinTransition[] => data.transitions; + +const setDomains = (blocks: DomainBlock[]) => { + if (!blocks) { + return; + } + for (const block of blocks) { + const domainName = block.domain as DomainName; + const items = (block.items ?? []).map((item) => ({ + label: item.label, + })); + data.domains.set(domainName, { + name: domainName, + items, + }); + } +}; + +const setTransitions = (transitions: Transition[]) => { + if (!transitions) { + return; + } + data.transitions = transitions + .filter((t) => { + if (t.from === t.to) { + log.warn( + `Cynefin: self-loop transition on domain "${t.from}" is not meaningful and will be skipped.` + ); + return false; + } + return true; + }) + .map((t) => ({ + from: t.from as DomainName, + to: t.to as DomainName, + label: t.label || undefined, + })); +}; + +const getConfig = (): Required => { + return cleanAndMerge({ + ...DEFAULT_CONFIG.cynefin, + ...commonGetConfig().cynefin, + }); +}; + +const clear = () => { + commonClear(); + data = createDefaultData(); +}; + +export const db: CynefinDB = { + getDomains, + getTransitions, + setDomains, + setTransitions, + getConfig, + clear, + setAccTitle, + getAccTitle, + setDiagramTitle, + getDiagramTitle, + getAccDescription, + setAccDescription, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts new file mode 100644 index 00000000000..45fa1fe4395 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDetector.ts @@ -0,0 +1,22 @@ +import type { + ExternalDiagramDefinition, + DiagramDetector, + DiagramLoader, +} from '../../diagram-api/types.js'; + +const id = 'cynefin'; + +const detector: DiagramDetector = (txt) => { + return /^\s*cynefin-beta(?:[\s:]|$)/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./cynefinDiagram.js'); + return { id, diagram }; +}; + +export const cynefin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts b/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts new file mode 100644 index 00000000000..c6529f1361a --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinDiagram.ts @@ -0,0 +1,12 @@ +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { parser } from './cynefinParser.js'; +import { db } from './cynefinDb.js'; +import { renderer } from './cynefinRenderer.js'; +import styles from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts b/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts new file mode 100644 index 00000000000..61c010ed3dd --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinParser.ts @@ -0,0 +1,20 @@ +import type { Cynefin } from '@mermaid-js/parser'; +import { parse } from '@mermaid-js/parser'; +import type { ParserDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import { populateCommonDb } from '../common/populateCommonDb.js'; +import { db } from './cynefinDb.js'; + +const populate = (ast: Cynefin) => { + populateCommonDb(ast, db); + db.setDomains(ast.domains); + db.setTransitions(ast.transitions); +}; + +export const parser: ParserDefinition = { + parse: async (input: string): Promise => { + const ast: Cynefin = await parse('cynefin', input); + log.debug(ast); + populate(ast); + }, +}; diff --git a/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts new file mode 100644 index 00000000000..52e6288a101 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/cynefinRenderer.ts @@ -0,0 +1,436 @@ +import type { Diagram } from '../../Diagram.js'; +import type { DiagramRenderer, DrawDefinition, SVG } from '../../diagram-api/types.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { configureSvgSize } from '../../setupGraphViewbox.js'; +import { log } from '../../logger.js'; +import { getConfig as getConfigAPI } from '../../config.js'; +import { getThemeVariables } from '../../themes/theme-default.js'; +import { cleanAndMerge } from '../../utils.js'; +import type { CynefinDB, CynefinDomain, CynefinTransition, DomainName } from './types.js'; +import { + generateFoldPath, + generateHorizontalBoundary, + generateCliffPath, + generateConfusionPath, + hashString, +} from './cynefinBoundaries.js'; + +interface DomainMeta { + model: string; + practice: string; +} + +const DOMAIN_META: Record = { + complex: { model: 'Probe \u2192 Sense \u2192 Respond', practice: 'Emergent Practices' }, + complicated: { model: 'Sense \u2192 Analyse \u2192 Respond', practice: 'Good Practices' }, + clear: { model: 'Sense \u2192 Categorise \u2192 Respond', practice: 'Best Practices' }, + chaotic: { model: 'Act \u2192 Sense \u2192 Respond', practice: 'Novel Practices' }, + confusion: { model: '', practice: 'Disorder' }, +}; + +interface DomainLayout { + cx: number; + cy: number; + x: number; + y: number; + w: number; + h: number; +} + +const getDomainLayouts = (width: number, height: number): Record => { + const hw = width / 2; + const hh = height / 2; + return { + complex: { cx: hw / 2, cy: hh / 2, x: 0, y: 0, w: hw, h: hh }, + complicated: { cx: hw + hw / 2, cy: hh / 2, x: hw, y: 0, w: hw, h: hh }, + chaotic: { cx: hw / 2, cy: hh + hh / 2, x: 0, y: hh, w: hw, h: hh }, + clear: { cx: hw + hw / 2, cy: hh + hh / 2, x: hw, y: hh, w: hw, h: hh }, + confusion: { cx: hw, cy: hh, x: hw * 0.7, y: hh * 0.7, w: hw * 0.6, h: hh * 0.6 }, + }; +}; + +interface CynefinDomainColors { + complexBg: string; + complicatedBg: string; + chaoticBg: string; + clearBg: string; + confusionBg: string; +} + +/** Resolve only the domain background colors from the cynefin theme block. */ +const getCynefinDomainColors = (): CynefinDomainColors => { + const defaultThemeVariables = getThemeVariables(); + const currentConfig = getConfigAPI(); + const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); + return themeVariables.cynefin as CynefinDomainColors; +}; + +/** Maximum items rendered inside the confusion ellipse before overflow badge is shown. */ +const MAX_CONFUSION_ITEMS = 3; + +const draw: DrawDefinition = (_text, id, _version, diagram: Diagram) => { + const db = diagram.db as CynefinDB; + const domains = db.getDomains(); + const transitions = db.getTransitions(); + const title = db.getDiagramTitle(); + const accTitle = db.getAccTitle(); + const accDescription = db.getAccDescription(); + const config = db.getConfig(); + const domainColors = getCynefinDomainColors(); + + log.debug('Rendering Cynefin diagram'); + + const width = config.width; + const height = config.height; + const padding = config.padding; + const showDomainDescriptions = config.showDomainDescriptions; + const boundaryAmplitude = config.boundaryAmplitude; + const totalWidth = width + padding * 2; + const totalHeight = height + padding * 2; + + const domainBg: Record = { + complex: domainColors.complexBg, + complicated: domainColors.complicatedBg, + clear: domainColors.clearBg, + chaotic: domainColors.chaoticBg, + confusion: domainColors.confusionBg, + }; + + const svg: SVG = selectSvgElement(id); + + configureSvgSize(svg, totalHeight, totalWidth, config.useMaxWidth ?? true); + svg.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`); + + // Accessibility: use element (W3C recommendation for SVG) + if (accTitle) { + svg.append('title').text(accTitle); + } + if (accDescription) { + svg.append('desc').text(accDescription); + } + + const root = svg.append('g').attr('transform', `translate(${padding}, ${padding})`); + + const layouts = getDomainLayouts(width, height); + const seed = hashString(id); + + // 1. Domain background rectangles + const bgGroup = root.append('g').attr('class', 'cynefin-backgrounds'); + const quadrantDomains: DomainName[] = ['complex', 'complicated', 'chaotic', 'clear']; + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + bgGroup + .append('rect') + .attr('class', 'cynefinDomain') + .attr('x', layout.x) + .attr('y', layout.y) + .attr('width', layout.w) + .attr('height', layout.h) + .attr('fill', domainBg[domainName]) + .attr('fill-opacity', 0.4) + .attr('stroke', 'none'); + } + + // 2. Wavy boundaries — stroke handled by .cynefinBoundary CSS class + const boundaryGroup = root.append('g').attr('class', 'cynefin-boundaries'); + + boundaryGroup + .append('path') + .attr('class', 'cynefinBoundary') + .attr('d', generateFoldPath(width, height, seed, boundaryAmplitude)) + .attr('fill', 'none'); + + boundaryGroup + .append('path') + .attr('class', 'cynefinBoundary') + .attr('d', generateHorizontalBoundary(width, height, seed + 100, boundaryAmplitude)) + .attr('fill', 'none'); + + // 3. The cliff (thicker, between Clear and Chaotic) — stroke handled by .cynefinCliff CSS class + boundaryGroup + .append('path') + .attr('class', 'cynefinCliff') + .attr('d', generateCliffPath(width, height)) + .attr('fill', 'none'); + + // 4. Confusion ellipse (center overlay) — stroke handled by .cynefinConfusion CSS class + // Using width*0.15 and height*0.15 gives enough room for up to ~3 item badges + const confusionRx = width * 0.15; + const confusionRy = height * 0.15; + root + .append('path') + .attr('class', 'cynefinConfusion') + .attr('d', generateConfusionPath(width / 2, height / 2, confusionRx, confusionRy)) + .attr('fill', domainBg.confusion) + .attr('fill-opacity', 0.5); + + // 5. Domain name labels — text styling handled by .cynefinDomainLabel CSS class + const labelGroup = root.append('g').attr('class', 'cynefin-labels'); + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + labelGroup + .append('text') + .attr('class', 'cynefinDomainLabel') + .attr('x', layout.cx) + .attr('y', showDomainDescriptions ? layout.cy - 30 : layout.cy) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(domainName.charAt(0).toUpperCase() + domainName.slice(1)); + } + + // Confusion label + labelGroup + .append('text') + .attr('class', 'cynefinDomainLabel') + .attr('x', width / 2) + .attr('y', showDomainDescriptions ? height / 2 - 10 : height / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text('Confusion'); + + // 6. Domain description subtitles — text styling handled by .cynefinSubtitle CSS class + if (showDomainDescriptions) { + const subtitleGroup = root.append('g').attr('class', 'cynefin-subtitles'); + for (const domainName of quadrantDomains) { + const layout = layouts[domainName]; + const meta = DOMAIN_META[domainName]; + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy - 10) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(meta.model); + + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', layout.cx) + .attr('y', layout.cy + 5) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(meta.practice); + } + + // Confusion subtitle + subtitleGroup + .append('text') + .attr('class', 'cynefinSubtitle') + .attr('x', width / 2) + .attr('y', height / 2 + 8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(DOMAIN_META.confusion.practice); + } + + // 7. Items as text badges within each domain + const itemGroup = root.append('g').attr('class', 'cynefin-items'); + const itemHeight = 26; + const itemPaddingX = 10; + + const allDomains: DomainName[] = ['complex', 'complicated', 'chaotic', 'clear', 'confusion']; + for (const domainName of allDomains) { + const domain: CynefinDomain | undefined = domains.get(domainName); + if (!domain || domain.items.length === 0) { + continue; + } + + const layout = layouts[domainName]; + const isConfusion = domainName === 'confusion'; + + // For confusion: cap items and center the block around the ellipse center. + // For quadrant domains: start below the label/subtitle area. + let itemsToRender = domain.items; + let overflowCount = 0; + if (isConfusion && domain.items.length > MAX_CONFUSION_ITEMS) { + overflowCount = domain.items.length - MAX_CONFUSION_ITEMS; + itemsToRender = domain.items.slice(0, MAX_CONFUSION_ITEMS); + } + + let startY: number; + if (isConfusion) { + // Center the item block below the label within the ellipse + const labelOffset = showDomainDescriptions ? 22 : 14; + startY = layout.cy + labelOffset; + } else { + startY = layout.cy + (showDomainDescriptions ? 25 : 15); + } + + // Render item badges using getBBox() for accurate width measurement + [...itemsToRender].forEach((item, idx) => { + const itemY = startY + idx * (itemHeight + 4); + const g = itemGroup.append('g'); + + // Append text first to measure actual rendered width + const textEl = g + .append('text') + .attr('class', 'cynefinItemText') + .attr('x', 0) + .attr('y', itemHeight / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .text(item.label); + + // Measure rendered text width; fall back to character-count heuristic if unavailable + let measuredWidth = item.label.length * 7; + const textNode = textEl.node(); + if (textNode && typeof textNode.getBBox === 'function') { + const bbox = textNode.getBBox(); + if (bbox.width > 0) { + measuredWidth = bbox.width; + } + } + + const badgeWidth = measuredWidth + itemPaddingX * 2; + const itemX = layout.cx - badgeWidth / 2; + + g.attr('transform', `translate(${itemX}, ${itemY})`); + + // Insert rect behind the text, sized to measured badge width + g.insert('rect', 'text') + .attr('class', 'cynefinItem') + .attr('x', 0) + .attr('y', 0) + .attr('width', badgeWidth) + .attr('height', itemHeight) + .attr('rx', 4) + .attr('ry', 4) + .attr('fill', domainBg[domainName]) + .attr('fill-opacity', 0.95); + + // Centre text within badge + textEl.attr('x', badgeWidth / 2).attr('y', itemHeight / 2); + }); + + // Overflow badge: "+N more" when confusion has more items than MAX_CONFUSION_ITEMS + if (overflowCount > 0) { + const overflowY = startY + itemsToRender.length * (itemHeight + 4); + const overflowLabel = `+${overflowCount} more`; + const g = itemGroup.append('g'); + + const textEl = g + .append('text') + .attr('class', 'cynefinItemText') + .attr('x', 0) + .attr('y', itemHeight / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .text(overflowLabel); + + let measuredWidth = overflowLabel.length * 7; + const textNode = textEl.node(); + if (textNode && typeof textNode.getBBox === 'function') { + const bbox = textNode.getBBox(); + if (bbox.width > 0) { + measuredWidth = bbox.width; + } + } + + const badgeWidth = measuredWidth + itemPaddingX * 2; + const itemX = layout.cx - badgeWidth / 2; + + g.attr('transform', `translate(${itemX}, ${overflowY})`); + + g.insert('rect', 'text') + .attr('class', 'cynefinItemOverflow') + .attr('x', 0) + .attr('y', 0) + .attr('width', badgeWidth) + .attr('height', itemHeight) + .attr('rx', 4) + .attr('ry', 4) + .attr('fill', domainBg[domainName]) + .attr('fill-opacity', 0.6); + + textEl.attr('x', badgeWidth / 2).attr('y', itemHeight / 2); + } + } + + // 8. Transition arrows between domain centers + if (transitions.length > 0) { + const defs = svg.select('defs').empty() ? svg.append('defs') : svg.select('defs'); + const markerId = `cynefin-arrow-${id}`; + + defs + .append('marker') + .attr('id', markerId) + .attr('viewBox', '0 0 10 10') + .attr('refX', 9) + .attr('refY', 5) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto-start-reverse') + .append('path') + .attr('d', 'M 0 0 L 10 5 L 0 10 z') + .attr('class', 'cynefinArrowHead'); + + const arrowGroup = root.append('g').attr('class', 'cynefin-arrows'); + + transitions.forEach((transition: CynefinTransition) => { + const fromLayout = layouts[transition.from]; + const toLayout = layouts[transition.to]; + if (!fromLayout || !toLayout) { + return; + } + + // Self-loops are filtered at the DB level; guard here handles any edge case + if (transition.from === transition.to) { + log.warn(`Cynefin renderer: skipping self-loop on domain "${transition.from}"`); + return; + } + + // All math in root-local (unpadded) coordinates to match the <g> transform. + const x1 = fromLayout.cx; + const y1 = fromLayout.cy; + const x2 = toLayout.cx; + const y2 = toLayout.cy; + + // Quadratic bezier with perpendicular offset + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + const offsetAmount = len * 0.15; + const nx = -dy / len; + const ny = dx / len; + const cpx = mx + nx * offsetAmount; + const cpy = my + ny * offsetAmount; + + arrowGroup + .append('path') + .attr('class', 'cynefinArrowLine') + .attr('d', `M${x1},${y1} Q${cpx},${cpy} ${x2},${y2}`) + .attr('fill', 'none') + .attr('marker-end', `url(#${markerId})`); + + // Optional label — text styling handled by .cynefinArrowLabel CSS class + if (transition.label) { + arrowGroup + .append('text') + .attr('class', 'cynefinArrowLabel') + .attr('x', cpx) + .attr('y', cpy - 6) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'auto') + .text(transition.label); + } + }); + } + + // Title — text styling handled by .cynefinTitle CSS class + if (title) { + root + .append('text') + .attr('class', 'cynefinTitle') + .attr('x', width / 2) + .attr('y', -padding / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .text(title); + } +}; + +export const renderer: DiagramRenderer = { draw }; diff --git a/packages/mermaid/src/diagrams/cynefin/styles.ts b/packages/mermaid/src/diagrams/cynefin/styles.ts new file mode 100644 index 00000000000..d0b7b48140f --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/styles.ts @@ -0,0 +1,95 @@ +import type { DiagramStylesProvider } from '../../diagram-api/types.js'; +import { cleanAndMerge } from '../../utils.js'; +import { getThemeVariables } from '../../themes/theme-default.js'; +import { getConfig as getConfigAPI } from '../../config.js'; + +/** + * Resolve the cynefin-specific theme block from the current mermaid theme. + * All presentation values (colors, font sizes, stroke widths) are sourced from + * here rather than duplicated as inline SVG attributes in the renderer. + */ +const getCynefinTheme = () => { + const defaultThemeVariables = getThemeVariables(); + const currentConfig = getConfigAPI(); + const themeVariables = cleanAndMerge(defaultThemeVariables, currentConfig.themeVariables); + return themeVariables.cynefin as { + domainFontSize: number; + itemFontSize: number; + boundaryColor: string; + boundaryWidth: number; + cliffColor: string; + cliffWidth: number; + arrowColor: string; + arrowWidth: number; + textColor: string; + labelColor: string; + }; +}; + +export const styles: DiagramStylesProvider = () => { + const t = getCynefinTheme(); + return ` + .cynefinDomain { + stroke: none; + } + .cynefinDomainLabel { + font-size: ${t.domainFontSize}px; + font-weight: bold; + fill: ${t.labelColor}; + } + .cynefinSubtitle { + font-size: ${t.itemFontSize - 1}px; + fill: ${t.textColor}; + font-style: italic; + } + .cynefinItem { + fill-opacity: 0.95; + stroke: ${t.boundaryColor}; + stroke-width: 1; + } + .cynefinItemText { + font-size: ${t.itemFontSize}px; + fill: ${t.textColor}; + } + .cynefinItemOverflow { + fill-opacity: 0.6; + stroke: ${t.boundaryColor}; + stroke-width: 1; + stroke-dasharray: 3 2; + } + .cynefinBoundary { + stroke: ${t.boundaryColor}; + stroke-width: ${t.boundaryWidth}; + stroke-dasharray: 6 3; + } + .cynefinCliff { + stroke: ${t.cliffColor}; + stroke-width: ${t.cliffWidth}; + } + .cynefinConfusion { + stroke: ${t.boundaryColor}; + stroke-width: 1.5; + stroke-dasharray: 4 2; + } + .cynefinArrowLine { + stroke: ${t.arrowColor}; + stroke-width: ${t.arrowWidth}; + fill: none; + } + .cynefinArrowHead { + fill: ${t.arrowColor}; + stroke: none; + } + .cynefinArrowLabel { + font-size: ${t.itemFontSize - 1}px; + fill: ${t.textColor}; + } + .cynefinTitle { + font-size: ${t.domainFontSize + 2}px; + font-weight: bold; + fill: ${t.labelColor}; + } + `; +}; + +export default styles; diff --git a/packages/mermaid/src/diagrams/cynefin/types.ts b/packages/mermaid/src/diagrams/cynefin/types.ts new file mode 100644 index 00000000000..abeaf493209 --- /dev/null +++ b/packages/mermaid/src/diagrams/cynefin/types.ts @@ -0,0 +1,34 @@ +import type { DomainBlock, Transition } from '@mermaid-js/parser'; +import type { CynefinDiagramConfig } from '../../config.type.js'; + +export type DomainName = 'complex' | 'complicated' | 'clear' | 'chaotic' | 'confusion'; + +export interface CynefinItem { + label: string; +} + +export interface CynefinDomain { + name: DomainName; + items: CynefinItem[]; +} + +export interface CynefinTransition { + from: DomainName; + to: DomainName; + label?: string; +} + +export interface CynefinDB { + clear(): void; + getDiagramTitle(): string; + setDiagramTitle(title: string): void; + getAccTitle(): string; + setAccTitle(title: string): void; + getAccDescription(): string; + setAccDescription(description: string): void; + getDomains(): Map<DomainName, CynefinDomain>; + getTransitions(): CynefinTransition[]; + setDomains(blocks: DomainBlock[]): void; + setTransitions(transitions: Transition[]): void; + getConfig(): Required<CynefinDiagramConfig>; +} diff --git a/packages/mermaid/src/docs/.vitepress/config.ts b/packages/mermaid/src/docs/.vitepress/config.ts index 6c2846b6b6d..d33c662398d 100644 --- a/packages/mermaid/src/docs/.vitepress/config.ts +++ b/packages/mermaid/src/docs/.vitepress/config.ts @@ -193,6 +193,7 @@ function sidebarSyntax() { { text: 'Venn 🔥', link: '/syntax/venn' }, { text: 'Ishikawa 🔥', link: '/syntax/ishikawa' }, { text: 'Wardley 🔥', link: '/syntax/wardley' }, + { text: 'Cynefin 🔥', link: '/syntax/cynefin' }, { text: 'TreeView 🔥', link: '/syntax/treeView' }, { text: 'Other Examples', link: '/syntax/examples' }, ], diff --git a/packages/mermaid/src/docs/syntax/cynefin.md b/packages/mermaid/src/docs/syntax/cynefin.md new file mode 100644 index 00000000000..61f3a495572 --- /dev/null +++ b/packages/mermaid/src/docs/syntax/cynefin.md @@ -0,0 +1,217 @@ +# Cynefin Framework Diagram (v<MERMAID_RELEASE_VERSION>+) + +> The Cynefin framework is a sense-making framework created by [Dave Snowden](https://en.wikipedia.org/wiki/Dave_Snowden) that categorizes problems into five complexity domains. It helps teams match their response to the nature of the situation they are facing. The name is Welsh for "place" or "habitat". + +You can read more at [The Cynefin Co](https://thecynefin.co/about-us/about-cynefin-framework). + +## Introduction + +The Cynefin framework divides the world into five domains, each with its own decision-making approach: + +- **Clear** (formerly Obvious/Simple): Cause and effect are obvious. Sense → Categorise → Respond. Apply **best practices**. +- **Complicated**: Cause and effect require analysis or expertise. Sense → Analyse → Respond. Apply **good practices**. +- **Complex**: Cause and effect can only be deduced in retrospect. Probe → Sense → Respond. Apply **emergent practices**. +- **Chaotic**: No perceivable cause and effect. Act → Sense → Respond. Apply **novel practices**. +- **Confusion** (or Disorder): You do not know which domain you are in. The goal is to move items out of this state into one of the other four. + +The signature visual feature is the wavy, organic boundary between the ordered (Clear, Complicated) and unordered (Complex, Chaotic) halves, and the "cliff" between Clear and Chaotic representing the risk of complacency leading to crisis. + +## Syntax + +A Cynefin diagram is declared with `cynefin-beta` followed by domain blocks and optional transitions. + +```md +cynefin-beta +title Optional Diagram Title + +complex +"Item label" +"Another item" + +complicated +"Expert analysis needed" + +clear +"Known procedure" + +chaotic +"Crisis response" + +confusion +"Item of unknown domain" + +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +``` + +### Keywords + +| Keyword | Meaning | +| -------------- | ---------------------------------------------------------------------- | +| `cynefin-beta` | Diagram type declaration (required, first line after any front matter) | +| `title` | Optional diagram title | +| `complex` | Opens the Complex domain block | +| `complicated` | Opens the Complicated domain block | +| `clear` | Opens the Clear domain block | +| `chaotic` | Opens the Chaotic domain block | +| `confusion` | Opens the Confusion / Disorder domain block | +| `-->` | Declares a transition from one domain to another | + +### Items + +Items are quoted string labels placed on their own lines inside a domain block. Each item renders as a text badge within the domain region. + +``` +complex + "Investigate root cause" + "Run chaos experiment" +``` + +Keep per-domain item lists short — the quadrants have fixed layout and long lists can visually overflow their boxes. The confusion ellipse caps at three items and shows a `+N more` badge; the four quadrant domains do not clip, so prefer a handful of items each. + +### Transitions + +Transitions represent movement of items between domains over time. They are declared at the top level using `-->` between two domain names, with an optional label. + +``` +complex --> complicated : "Pattern identified" +clear --> chaotic : "Complacency" +chaotic --> complex : "Stabilized" +``` + +Common transitions: + +- **Complex → Complicated**: An emerging pattern has become understood and can now be analyzed. +- **Chaotic → Complex**: A crisis has been stabilized enough to begin probing. +- **Clear → Chaotic**: Complacency or over-constraint has led to collapse (the "cliff"). +- **Complicated → Clear**: Analysis has codified a solution into a standard practice. + +## Examples + +### Basic example + +```mermaid-example +cynefin-beta + title Incident Response + + complex + "Investigate root cause" + "Run chaos experiment" + + complicated + "Analyze performance data" + "Expert review needed" + + clear + "Restart service" + "Apply known fix" + + chaotic + "Page on-call immediately" + + confusion + "Unknown failure mode" +``` + +### With transitions + +```mermaid-example +cynefin-beta + title Strategy Categorization + + complex + "Market research" + + complicated + "Competitive analysis" + + clear + "Standard pricing" + + chaotic + "Crisis management" + + complex --> complicated : "Pattern identified" + complicated --> clear : "Best practice codified" + clear --> chaotic : "Complacency" + chaotic --> complex : "Stabilized" +``` + +### Empty framework + +The domains themselves render even with no items, useful as a teaching or worksheet template. + +```mermaid-example +cynefin-beta + title Cynefin Framework + + complex + complicated + clear + chaotic +``` + +## Configuration + +Cynefin diagrams accept the following configuration under the `cynefin` key in the mermaid config: + +| Option | Type | Default | Description | +| ------------------------ | ------- | ------- | --------------------------------------------------------------------------------- | +| `width` | number | `800` | Width of the diagram in pixels | +| `height` | number | `600` | Height of the diagram in pixels | +| `padding` | number | `40` | Padding around the diagram | +| `showDomainDescriptions` | boolean | `true` | Show decision model and practice type subtitles per domain | +| `boundaryAmplitude` | number | `8` | Waviness amplitude of domain boundaries in pixels (set to `0` for straight lines) | + +Example: + +``` +%%{init: {'cynefin': {'width': 1000, 'showDomainDescriptions': false}}}%% +cynefin-beta + complex + "Adaptive work" +``` + +## Theming + +Cynefin diagrams use the following theme variables, which can be overridden via `themeVariables.cynefin`: + +| Variable | Description | +| ---------------- | ------------------------------------------------ | +| `complexBg` | Background color for the Complex domain | +| `complicatedBg` | Background color for the Complicated domain | +| `clearBg` | Background color for the Clear domain | +| `chaoticBg` | Background color for the Chaotic domain | +| `confusionBg` | Background color for the Confusion center region | +| `boundaryColor` | Color of the wavy domain boundaries | +| `boundaryWidth` | Stroke width of the boundaries | +| `cliffColor` | Color of the Clear/Chaotic cliff | +| `cliffWidth` | Stroke width of the cliff | +| `arrowColor` | Color of transition arrows | +| `arrowWidth` | Stroke width of transition arrows | +| `labelColor` | Color of domain name labels | +| `textColor` | Color of item and subtitle text | +| `domainFontSize` | Font size of domain name labels | +| `itemFontSize` | Font size of item badges and subtitles | + +## Notes + +- Domain names are fixed keywords. Only `complex`, `complicated`, `clear`, `chaotic`, and `confusion` are recognized. +- Domains can be declared in any order; their position in the diagram is always the same (Complex top-left, Complicated top-right, Chaotic bottom-left, Clear bottom-right, Confusion center). +- The `confusion` domain has a compact center ellipse. Up to 3 items are shown inside it; if more are provided a `+N more` overflow badge is displayed. In practice, the confusion domain should contain very few items — its purpose is to surface unknowns so they can be moved to one of the four main domains. +- Self-loop transitions (e.g. `complex --> complex`) are silently ignored. Transitions must connect two different domains. +- Handdrawn mode is not currently supported. +- The wavy boundary rendering is deterministic: the same input always produces the same diagram, so diffs are stable across builds. + +## Accessibility + +Cynefin diagrams support the standard mermaid accessibility directives: + +``` +cynefin-beta + accTitle: Cynefin framework for software delivery decisions + accDescr: A Cynefin map categorizing software tasks by complexity domain + + complex + "New feature discovery" +``` diff --git a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts index 2015aa45f1f..198bec0c7e9 100644 --- a/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts +++ b/packages/mermaid/src/rendering-util/multi-diagram-id-uniqueness.spec.ts @@ -179,6 +179,17 @@ union A, B`, Electric Kettle -> Kettle Smart Kettle -> Kettle`, + cynefin: `cynefin-beta + title Incident Response + complex + "Investigate root cause" + complicated + "Analyze metrics" + clear + "Restart service" + chaotic + "Page on-call"`, + eventmodeling: `eventmodeling tf 01 evt Start tf 02 evt End diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index a1366e1f0ed..9f744b1a268 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -335,6 +335,8 @@ properties: $ref: '#/$defs/VennDiagramConfig' wardley-beta: $ref: '#/$defs/WardleyDiagramConfig' + cynefin: + $ref: '#/$defs/CynefinDiagramConfig' dompurifyConfig: title: DOM Purify Configuration description: Configuration options to pass to the `dompurify` library. @@ -2558,6 +2560,39 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) type: boolean default: false + CynefinDiagramConfig: + title: Cynefin Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: Configuration for Cynefin framework diagrams. + type: object + unevaluatedProperties: false + properties: + width: + description: The width of the Cynefin diagram. + type: number + minimum: 1 + default: 800 + height: + description: The height of the Cynefin diagram. + type: number + minimum: 1 + default: 600 + padding: + description: Padding around the diagram. + type: number + minimum: 0 + default: 40 + showDomainDescriptions: + description: Show decision model and practice type labels. + type: boolean + default: true + boundaryAmplitude: + description: Waviness amplitude of domain boundaries (0 for straight). + type: number + minimum: 0 + maximum: 50 + default: 8 + FontCalculator: title: Font Calculator description: | diff --git a/packages/mermaid/src/themes/theme-base.js b/packages/mermaid/src/themes/theme-base.js index 11c086d9726..314aebc3a94 100644 --- a/packages/mermaid/src/themes/theme-base.js +++ b/packages/mermaid/src/themes/theme-base.js @@ -249,6 +249,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* radar */ this.radar = { axisColor: this.radar?.axisColor || this.lineColor, diff --git a/packages/mermaid/src/themes/theme-dark.js b/packages/mermaid/src/themes/theme-dark.js index affd62dc0f1..4f4a361b1bc 100644 --- a/packages/mermaid/src/themes/theme-dark.js +++ b/packages/mermaid/src/themes/theme-dark.js @@ -258,6 +258,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#FF6B6B', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#1B5E20', + complicatedBg: this.cynefin?.complicatedBg || '#0D47A1', + chaoticBg: this.cynefin?.chaoticBg || '#BF360C', + clearBg: this.cynefin?.clearBg || '#F57F17', + confusionBg: this.cynefin?.confusionBg || '#4A148C', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-default.js b/packages/mermaid/src/themes/theme-default.js index 22c6bd1d928..9bed4594ac3 100644 --- a/packages/mermaid/src/themes/theme-default.js +++ b/packages/mermaid/src/themes/theme-default.js @@ -291,6 +291,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-forest.js b/packages/mermaid/src/themes/theme-forest.js index a569d028562..454890a61ac 100644 --- a/packages/mermaid/src/themes/theme-forest.js +++ b/packages/mermaid/src/themes/theme-forest.js @@ -254,6 +254,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B4513', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#C8E6C9', + complicatedBg: this.cynefin?.complicatedBg || '#DCEDC8', + chaoticBg: this.cynefin?.chaoticBg || '#FFE0B2', + clearBg: this.cynefin?.clearBg || '#FFF9C4', + confusionBg: this.cynefin?.confusionBg || '#D7CCC8', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/mermaid/src/themes/theme-neutral.js b/packages/mermaid/src/themes/theme-neutral.js index 07e6886e51a..e0a0209e8a8 100644 --- a/packages/mermaid/src/themes/theme-neutral.js +++ b/packages/mermaid/src/themes/theme-neutral.js @@ -279,6 +279,25 @@ class Theme { this.vennTitleTextColor = this.vennTitleTextColor ?? this.titleColor; this.vennSetTextColor = this.vennSetTextColor ?? this.textColor; + /* cynefin */ + this.cynefin = { + domainFontSize: this.cynefin?.domainFontSize || 16, + itemFontSize: this.cynefin?.itemFontSize || 12, + boundaryColor: this.cynefin?.boundaryColor || this.lineColor, + boundaryWidth: this.cynefin?.boundaryWidth || 2, + cliffColor: this.cynefin?.cliffColor || '#8B0000', + cliffWidth: this.cynefin?.cliffWidth || 4, + arrowColor: this.cynefin?.arrowColor || this.lineColor, + arrowWidth: this.cynefin?.arrowWidth || 2, + complexBg: this.cynefin?.complexBg || '#E8F5E9', + complicatedBg: this.cynefin?.complicatedBg || '#E3F2FD', + chaoticBg: this.cynefin?.chaoticBg || '#FBE9E7', + clearBg: this.cynefin?.clearBg || '#FFF8E1', + confusionBg: this.cynefin?.confusionBg || '#F3E5F5', + textColor: this.cynefin?.textColor || this.textColor, + labelColor: this.cynefin?.labelColor || this.primaryTextColor, + }; + /* quadrant-graph */ this.quadrant1Fill = this.quadrant1Fill || this.primaryColor; this.quadrant2Fill = this.quadrant2Fill || adjust(this.primaryColor, { r: 5, g: 5, b: 5 }); diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index 7991586fe9a..524414d3b32 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -50,6 +50,11 @@ "id": "wardley", "grammar": "src/language/wardley/wardley.langium", "fileExtensions": [".mmd", ".mermaid"] + }, + { + "id": "cynefin", + "grammar": "src/language/cynefin/cynefin.langium", + "fileExtensions": [".mmd", ".mermaid"] } ], "mode": "production", diff --git a/packages/parser/src/language/cynefin/cynefin.langium b/packages/parser/src/language/cynefin/cynefin.langium new file mode 100644 index 00000000000..b95d0115b1a --- /dev/null +++ b/packages/parser/src/language/cynefin/cynefin.langium @@ -0,0 +1,30 @@ +grammar CynefinGrammar +import "../common/common"; + +entry Cynefin: + NEWLINE* + // Accept bare keyword or colon-terminated form; both are equivalent + ('cynefin-beta' | 'cynefin-beta:') + NEWLINE* + ( + TitleAndAccessibilities + | domains+=DomainBlock + | transitions+=Transition + | NEWLINE + )* +; + +DomainBlock: + domain=DOMAIN_NAME NEWLINE* + (items+=DomainItem NEWLINE*)* +; + +DomainItem: + label=STRING +; + +Transition: + from=DOMAIN_NAME '-->' to=DOMAIN_NAME (':' label=STRING)? EOL +; + +terminal DOMAIN_NAME returns string: 'complex' | 'complicated' | 'clear' | 'chaotic' | 'confusion'; diff --git a/packages/parser/src/language/cynefin/index.ts b/packages/parser/src/language/cynefin/index.ts new file mode 100644 index 00000000000..fd3c604b084 --- /dev/null +++ b/packages/parser/src/language/cynefin/index.ts @@ -0,0 +1 @@ +export * from './module.js'; diff --git a/packages/parser/src/language/cynefin/module.ts b/packages/parser/src/language/cynefin/module.ts new file mode 100644 index 00000000000..4f33d97022a --- /dev/null +++ b/packages/parser/src/language/cynefin/module.ts @@ -0,0 +1,55 @@ +import type { + DefaultSharedCoreModuleContext, + LangiumCoreServices, + LangiumSharedCoreServices, + Module, + PartialLangiumCoreServices, +} from 'langium'; +import { + EmptyFileSystem, + createDefaultCoreModule, + createDefaultSharedCoreModule, + inject, +} from 'langium'; +import { CommonValueConverter } from '../common/valueConverter.js'; +import { + MermaidGeneratedSharedModule, + CynefinGrammarGeneratedModule as CynefinGeneratedModule, +} from '../generated/module.js'; +import { CynefinTokenBuilder } from './tokenBuilder.js'; + +interface CynefinAddedServices { + parser: { + TokenBuilder: CynefinTokenBuilder; + ValueConverter: CommonValueConverter; + }; +} + +export type CynefinServices = LangiumCoreServices & CynefinAddedServices; + +export const CynefinModule: Module< + CynefinServices, + PartialLangiumCoreServices & CynefinAddedServices +> = { + parser: { + TokenBuilder: () => new CynefinTokenBuilder(), + ValueConverter: () => new CommonValueConverter(), + }, +}; + +export function createCynefinServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): { + shared: LangiumSharedCoreServices; + Cynefin: CynefinServices; +} { + const shared: LangiumSharedCoreServices = inject( + createDefaultSharedCoreModule(context), + MermaidGeneratedSharedModule + ); + const Cynefin: CynefinServices = inject( + createDefaultCoreModule({ shared }), + CynefinGeneratedModule, + CynefinModule + ); + shared.ServiceRegistry.register(Cynefin); + return { shared, Cynefin }; +} diff --git a/packages/parser/src/language/cynefin/tokenBuilder.ts b/packages/parser/src/language/cynefin/tokenBuilder.ts new file mode 100644 index 00000000000..cce9c374b4f --- /dev/null +++ b/packages/parser/src/language/cynefin/tokenBuilder.ts @@ -0,0 +1,7 @@ +import { AbstractMermaidTokenBuilder } from '../common/index.js'; + +export class CynefinTokenBuilder extends AbstractMermaidTokenBuilder { + public constructor() { + super(['cynefin-beta']); + } +} diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index 1e3418900aa..a13ab7617a2 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -10,6 +10,14 @@ export { Radar, Treemap, Wardley, + Cynefin, + DomainBlock, + DomainItem, + Transition, + isCynefin, + isDomainBlock, + isDomainItem, + isTransition, Branch, Commit, Merge, @@ -49,6 +57,7 @@ export { TreemapGrammarGeneratedModule as TreemapGeneratedModule, TreeViewGrammarGeneratedModule as TreeViewGeneratedModule, WardleyGrammarGeneratedModule as WardleyGeneratedModule, + CynefinGrammarGeneratedModule as CynefinGeneratedModule, } from './generated/module.js'; export * from './gitGraph/index.js'; @@ -62,3 +71,4 @@ export * from './eventmodeling/index.js'; export * from './radar/index.js'; export * from './treemap/index.js'; export * from './wardley/index.js'; +export * from './cynefin/index.js'; diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 12476b69a28..7d157b47083 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -11,6 +11,7 @@ import type { Treemap, TreeView, Wardley, + Cynefin, } from './index.js'; export type DiagramAST = @@ -22,7 +23,8 @@ export type DiagramAST = | EventModel | Radar | TreeView - | Wardley; + | Wardley + | Cynefin; const parsers: Record<string, LangiumParser> = {}; const initializers = { @@ -76,6 +78,11 @@ const initializers = { const parser = createWardleyServices().Wardley.parser.LangiumParser; parsers.wardley = parser; }, + cynefin: async () => { + const { createCynefinServices } = await import('./language/cynefin/index.js'); + const parser = createCynefinServices().Cynefin.parser.LangiumParser; + parsers.cynefin = parser; + }, } as const; export async function parse(diagramType: 'info', text: string): Promise<Info>; @@ -88,6 +95,7 @@ export async function parse(diagramType: 'eventmodeling', text: string): Promise export async function parse(diagramType: 'radar', text: string): Promise<Radar>; export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>; export async function parse(diagramType: 'wardley', text: string): Promise<Wardley>; +export async function parse(diagramType: 'cynefin', text: string): Promise<Cynefin>; export async function parse<T extends DiagramAST>( diagramType: keyof typeof initializers,