/** * A parsed wardley map. * @typedef {Object} Map * @property {Entity[]} entities - The list of parsed entities */ /** * Any of the potential entities in the map. * @typedef {Component|Dependency|Note|Stage|Group|Inertia|Evolution} Entity */ /** * Any of the potential shapes in a component. * @typedef {"x"|"square"|"triangle"|"circle"} Shape */ /** * A component in the map. * @typedef {Object} Component * @property {'component'} type - Entity type * @property {string} label - Component label * @property {[number, number]} coordinates - X and Y coordinates * @property {Shape} shape - Shape of the component */ /** * A dependency between two components. * @typedef {Object} Dependency * @property {'dependency'} type - Entity type * @property {string} from - Source component label * @property {string} to - Target component label * @property {boolean} isDirected - Whether the dependency is directed (->) or undirected (--) */ /** * A note. * @typedef {Object} Note * @property {'note'} type - Entity type * @property {[number, number]} coordinates - X and Y coordinates * @property {string} text - Note text content */ /** * An override for the width of an evolution stage. * @typedef {Object} Stage * @property {'stage'} type - Entity type * @property {string} stage - Stage number (i, ii, iii, iv) * @property {number} value - Stage value */ /** * A group of components. * @typedef {Object} Group * @property {'group'} type - Entity type * @property {string[]} components - Array of component labels in the group */ /** * Inertia associated with a component. * @typedef {Object} Inertia * @property {'inertia'} type - Entity type * @property {string} component - Component label with inertia */ /** * Evolution associated with a component. * @typedef {Object} Evolution * @property {'evolution'} type - Entity type * @property {string} component - Component label * @property {number} value - Evolution value */ /** * Helper: Check if character is whitespace */ function isWhitespace(character) { return character === " " || character === "\t"; } /** * Helper: Check if character is a digit or dot */ function isDigitOrDot(character) { return (character >= "0" && character <= "9") || character === "."; } /** * wmap format Wardley Map parser. * @param {string} source - The wmap source code to parse * @returns {Map} - Parsed map with entities */ export function parse(source) { const entities = []; const lines = source.split(/\r\n|\r|\n/); for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; const entity = parseLine(line); if (entity) { entities.push(entity); } } return { entities }; } /** * Parse a line */ function parseLine(line) { const length = line.length; let i = 0; // Skip leading whitespace while (i < length && isWhitespace(line[i])) i++; if (i >= length) return null; if (line[i] === "[") { return parseKeywordEntity(line, i, length); } return parseComponentOrDependency(line, length); } /** * Parse entities that start with keywords in brackets: [Stage], [Note], [Group], [Inertia], [Evolution] */ function parseKeywordEntity(line, start, length) { let i = start + 1; // skip '[' const keywordStart = i; // Read until ']' while (i < length && line[i] !== "]") i++; if (i >= length) return null; const keyword = line.substring(keywordStart, i).trim().toLowerCase(); i++; // skip ']' // Skip whitespace after ']' while (i < length && isWhitespace(line[i])) i++; // Stage: [I|II|III|IV] stage if ( keyword === "i" || keyword === "ii" || keyword === "iii" || keyword === "iv" ) { const numStart = i; while (i < length && isDigitOrDot(line[i])) i++; if (i > numStart) { return { type: "stage", stage: keyword, value: parseFloat(line.substring(numStart, i)), }; } return null; } // Note: [Note] (x, y) text if (keyword === "note") { // Expect '(' if (i >= length || line[i] !== "(") return null; i++; // Read x while (i < length && isWhitespace(line[i])) i++; const xStart = i; while (i < length && isDigitOrDot(line[i])) i++; if (i === xStart) return null; const x = parseFloat(line.substring(xStart, i)); // Expect ',' while (i < length && isWhitespace(line[i])) i++; if (i >= length || line[i] !== ",") return null; i++; // Read y while (i < length && isWhitespace(line[i])) i++; const yStart = i; while (i < length && isDigitOrDot(line[i])) i++; if (i === yStart) return null; const y = parseFloat(line.substring(yStart, i)); // Expect ')' while (i < length && isWhitespace(line[i])) i++; if (i >= length || line[i] !== ")") return null; i++; // Read text while (i < length && isWhitespace(line[i])) i++; const text = line.substring(i).trim(); return { type: "note", coordinates: [x, y], text, }; } // Group: [Group] label1, label2, ... if (keyword === "group") { const components = line .substring(i) .split(",") .map(v => v.trim()) .filter(v => v); return components.length > 0 ? { type: "group", components } : null; } // Inertia: [Inertia] label if (keyword === "inertia") { const component = line.substring(i).trim(); return component ? { type: "inertia", component } : null; } // Evolution: [Evolution] label +/- number if (keyword === "evolution") { let indexOfSign = -1; let signCharacter = null; // Find the sign for (let j = i; j < length; j++) { if (line[j] === "+" || line[j] === "-") { // Make sure it's not part of a dependency arrow if (j + 1 < length && (line[j + 1] === ">" || line[j + 1] === "-")) continue; indexOfSign = j; signCharacter = line[j]; break; } } if (indexOfSign === -1 || indexOfSign === i) return null; const component = line.substring(i, indexOfSign).trim(); if (!component) return null; // Read number after sign let indexOfNumber = indexOfSign + 1; while (indexOfNumber < length && isWhitespace(line[indexOfNumber])) indexOfNumber++; const numStart = indexOfNumber; while (indexOfNumber < length && isDigitOrDot(line[indexOfNumber])) indexOfNumber++; if (indexOfNumber === numStart) return null; return { type: "evolution", component, value: (signCharacter === "+" ? 1 : -1) * parseFloat(line.substring(numStart, indexOfNumber)), }; } return null; } /** * Parse component or dependency */ function parseComponentOrDependency(line, length) { // Use indexOf to quickly find potential dependency arrows and parentheses const indexOfArrow = line.indexOf("->"); const indexOfDash = line.indexOf("--"); const indexOfParenthesis = line.indexOf("("); // Check for directed dependency (->) if (indexOfArrow !== -1 && (indexOfParenthesis === -1 || indexOfArrow < indexOfParenthesis)) { const from = line.substring(0, indexOfArrow).trim(); const to = line.substring(indexOfArrow + 2).trim(); if (from && to) { return { type: "dependency", from, to, isDirected: true, }; } return null; } // Check for undirected dependency (--) if (indexOfDash !== -1 && (indexOfParenthesis === -1 || indexOfDash < indexOfParenthesis)) { const from = line.substring(0, indexOfDash).trim(); const to = line.substring(indexOfDash + 2).trim(); if (from && to) { return { type: "dependency", from, to, isDirected: false, }; } return null; } // Check for component (opening parenthesis) if (indexOfParenthesis > 0) { return parseComponent(line, indexOfParenthesis, length); } return null; } /** * Parse component starting from the opening parenthesis */ function parseComponent(line, indexOfParenthesis, length) { const label = line.substring(0, indexOfParenthesis).trim(); if (!label) return null; let i = indexOfParenthesis + 1; // Read x while (i < length && isWhitespace(line[i])) i++; const xStart = i; while (i < length && isDigitOrDot(line[i])) i++; if (i === xStart) return null; const x = parseFloat(line.substring(xStart, i)); // Expect ',' while (i < length && isWhitespace(line[i])) i++; if (i >= length || line[i] !== ",") return null; i++; // Read y while (i < length && isWhitespace(line[i])) i++; const yStart = i; while (i < length && isDigitOrDot(line[i])) i++; if (i === yStart) return null; const y = parseFloat(line.substring(yStart, i)); // Expect ')' while (i < length && isWhitespace(line[i])) i++; if (i >= length || line[i] !== ")") return null; i++; // Optional shape: [shape] let shape = "circle"; while (i < length && isWhitespace(line[i])) i++; if (i < length && line[i] === "[") { i++; const shapeStart = i; while (i < length && line[i] !== "]") i++; if (i > shapeStart && i < length) { shape = line.substring(shapeStart, i).trim().toLowerCase(); } } return { type: "component", label, coordinates: [x, y], shape, }; }