/** * A parsed wardley map. * @typedef {Object} Map * @property {Component[]} components - List of components * @property {Dependency[]} dependencies - List of dependencies * @property {Note[]} notes - List of notes * @property {Stage[]} stages - List of stages * @property {Group[]} groups - List of groups * @property {Inertia[]} inertias - List of inertias * @property {Evolution[]} evolutions - List of evolutions */ /** * Any of the potential shapes in a component. * @typedef {"x"|"square"|"triangle"|"circle"} Shape */ /** * A component in the map. * @typedef {Object} Component * @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 {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 {[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 {string} stage - Stage number (i, ii, iii, iv) * @property {number} value - Stage value */ /** * A group of components. * @typedef {Object} Group * @property {string[]} components - Array of component labels in the group */ /** * Inertia associated with a component. * @typedef {Object} Inertia * @property {string} component - Component label with inertia */ /** * Evolution associated with a component. * @typedef {Object} Evolution * @property {string} component - Component label * @property {number} value - Evolution value */ /* CONSTANTS ******************************************************************/ const kLineSplitter = /\r\n|\r|\n/; /* PUBLIC API *****************************************************************/ /** * wmap format Wardley Map parser. * @param {string} source - The wmap source code to parse * @returns {Map} - Parsed map with separate arrays for each entity type */ export function parse(source) { const components = []; const dependencies = []; const notes = []; const stages = []; const groups = []; const inertias = []; const evolutions = []; const lines = source.split(kLineSplitter); for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; const entity = parseLine(line); if (!entity) continue; switch (entity.type) { case "component": components.push({ label: entity.label, coordinates: entity.coordinates, shape: entity.shape, }); break; case "dependency": dependencies.push({ from: entity.from, to: entity.to, isDirected: entity.isDirected, }); break; case "note": notes.push({ coordinates: entity.coordinates, text: entity.text, }); break; case "stage": stages.push({ stage: entity.stage, value: entity.value, }); break; case "group": groups.push({ components: entity.components, }); break; case "inertia": inertias.push({ component: entity.component, }); break; case "evolution": evolutions.push({ component: entity.component, value: entity.value, }); break; } } return { components, dependencies, notes, stages, groups, inertias, evolutions, }; } /* PARSERS ********************************************************************/ /** * Routes a line to either the keyword parser, or the component / dependency * parser. */ 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 any entity that start with keywords in brackets: [Stage], [Note], * [Group], [Inertia], [Evolution]. */ function parseKeywordEntity(line, start, length) { // Read after [... let i = start + 1; const keywordStart = i; // ...but before ] while (i < length && line[i] !== "]") i++; if (i >= length) return null; const keyword = line.substring(keywordStart, i).trim().toLowerCase(); // Skip ] and whitespace i++; while (i < length && isWhitespace(line[i])) i++; // 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 startOfNumber = indexOfNumber; while (indexOfNumber < length && isDigitOrDot(line[indexOfNumber])) indexOfNumber++; if (indexOfNumber === startOfNumber) return null; return { type: "evolution", component, value: (signCharacter === "+" ? 1 : -1) * parseFloat(line.substring(startOfNumber, indexOfNumber)), }; } // Stage: [I|II|III|IV] stage // Least common. Max 4 in a file. if ( keyword === "i" || keyword === "ii" || keyword === "iii" || keyword === "iv" ) { const startOfNumber = i; while (i < length && isDigitOrDot(line[i])) i++; if (i > startOfNumber) { return { type: "stage", stage: keyword, value: parseFloat(line.substring(startOfNumber, i)), }; } } return null; } /** * Parse component or dependency */ function parseComponentOrDependency(line, length) { const indexOfArrow = line.indexOf("->"); const indexOfDash = line.indexOf("--"); const indexOfParenthesis = line.indexOf("("); // 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; } // 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; } // Component if (indexOfParenthesis > 0) { return parseComponent(line, indexOfParenthesis, length); } return null; } /** * Parse a component. */ 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, }; } /* HELPER FUNCTIONS ***********************************************************/ /** * 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 === "."; }