import { parse } from "wmap-parser"; import { renderToSVG, StageType } from "wmap-renderer-svg"; import DOMPurify from "isomorphic-dompurify"; //////////////////////////////////////////////////////////////////////////////// // Sourcing //////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Walks the lineup of items preceding $item and collects each // .wmap-source's already-expanded text. Returns [] outside the wiki // (no lineup to walk). function lineup($item) { if (typeof wiki === "undefined") return []; const results = []; const candidates = $(".item:lt(" + $(".item").index($item) + ")"); const sources = candidates.filter(".wmap-source"); for (const div of sources) { results.push(div.wmapData()); } return results; } // Replaces every bare "LINEUP" line with the concatenated text of every // .wmap-source item earlier in the lineup, then returns the joined text. // Each upstream wmapData() has already done its own expansion, so the // recursion is implicit. The wmap-parser is tolerant of duplicates and // unknown lines, so we don't pre-parse or dedupe here. function expandLineup(text, $item) { return text .split("\n") .map((line) => line.trim() === "LINEUP" ? lineup($item).join("\n") : line, ) .join("\n"); } // Finds the name for this map by walking the DOM to the nearest preceding // heading on the same page. When multiple .wmap blocks share that // heading, suffixes " #2", " #3", … in document order. function findName($element) { const page = $element.closest(".page"); if (!page) return "Unknown"; const headings = page.querySelectorAll("h1, h2, h3, h4, h5, h6"); let closestHeading = null; for (const heading of headings) { const elementFollowsHeading = heading.compareDocumentPosition($element) & Node.DOCUMENT_POSITION_FOLLOWING; if (elementFollowsHeading) closestHeading = heading; } if (!closestHeading) return "Unknown"; const baseName = closestHeading.textContent.trim(); const wmaps = page.querySelectorAll(".wmap.item"); let position = 0; for (const wmap of wmaps) { const wmapFollowsHeading = closestHeading.compareDocumentPosition(wmap) & Node.DOCUMENT_POSITION_FOLLOWING; if (!wmapFollowsHeading) continue; position++; if (wmap === $element) break; } return position <= 1 ? baseName : `${baseName} #${position}`; } // Default stage boundaries (in 0-1 component-coordinate units), matching // wmap-renderer-svg's defaults [25, 50, 75] divided by 100. const kDefaultStageBoundaries = [0.25, 0.5, 0.75]; const kStageNameToIndex = { i: 0, ii: 1, iii: 2 }; // Returns [b0, b1, b2] — the upper x boundaries of stages 1, 2, and 3. // Falls back to defaults for any boundary the parser didn't override. function stageBoundaries(map) { const boundaries = [...kDefaultStageBoundaries]; for (const override of map.stages) { const index = kStageNameToIndex[override.stage]; if (index !== undefined) boundaries[index] = override.value; } return boundaries; } // Maps an x coordinate to its 1-indexed evolution stage. function stageFor(x, boundaries) { if (x < boundaries[0]) return 1; if (x < boundaries[1]) return 2; if (x < boundaries[2]) return 3; return 4; } // Pre-built per-stage description tables keyed by StageType.name. Stage N's // table is at index N-1. const kStageDescriptions = [1, 2, 3, 4].map((stage) => { const descriptions = {}; for (const stageType of Object.values(StageType)) { descriptions[stageType.name] = stageType.stages[stage - 1]; } return descriptions; }); // Creates the aspect data type out of the existing text. // tAspectData := { // +name: String, // +graph: tAspectGraph // } // tAspectGraph := { // nodes: Array, // rels: Array // } // tAspectNode := { // type: String, // in: Array, // out: Array, // props: Object // } // tAspectRelation := { // type: String, // from: Number, // to: Number, // props: Object // } function aspectData($element, text) { const $item = $($element); const map = parse(expandLineup(text, $item)); const boundaries = stageBoundaries(map); const aspectNodes = []; const rels = []; const componentIndexByName = new Map(); for (const component of map.components) { const index = aspectNodes.length; componentIndexByName.set(component.label, index); const stage = stageFor(component.coordinates[0], boundaries); aspectNodes.push({ type: "component", in: [], out: [], props: { name: component.label, position: component.coordinates, evolutionStage: stage, ...kStageDescriptions[stage - 1], }, }); } for (const dep of map.dependencies) { const from = componentIndexByName.get(dep.from); const to = componentIndexByName.get(dep.to); if (from === undefined || to === undefined) continue; const forwardIndex = rels.length; rels.push({ type: "depends on", from, to, props: {} }); aspectNodes[from].out.push(forwardIndex); aspectNodes[to].in.push(forwardIndex); } for (const inertia of map.inertias) { const targetIndex = componentIndexByName.get(inertia.component); if (targetIndex === undefined) continue; const inertiaIndex = aspectNodes.length; aspectNodes.push({ type: "inertia", in: [], out: [], props: { name: inertia.component }, }); const relIndex = rels.length; rels.push({ type: "obstructs", from: inertiaIndex, to: targetIndex, props: {}, }); aspectNodes[inertiaIndex].out.push(relIndex); aspectNodes[targetIndex].in.push(relIndex); } for (const evolution of map.evolutions) { const targetIndex = componentIndexByName.get(evolution.component); if (targetIndex === undefined) continue; const [targetX, targetY] = aspectNodes[targetIndex].props.position; const newX = targetX + evolution.value; const stage = stageFor(newX, boundaries); const evolutionIndex = aspectNodes.length; aspectNodes.push({ type: "evolution of", in: [], out: [], props: { name: evolution.component, position: [newX, targetY], evolutionStage: stage, ...kStageDescriptions[stage - 1], }, }); const relIndex = rels.length; rels.push({ type: "evolves from", from: evolutionIndex, to: targetIndex, props: {}, }); aspectNodes[evolutionIndex].out.push(relIndex); aspectNodes[targetIndex].in.push(relIndex); } return [ { name: findName($element), graph: { nodes: aspectNodes, rels }, }, ]; } // Returns this map's text with every LINEUP expanded, ready for upstream // consumers to splice in. Kept as a string (not a parsed map) so the // downstream wmap-parser only runs once at the top of the chain. function wmapData(item, $item) { return expandLineup(item.text, $item); } //////////////////////////////////////////////////////////////////////////////// // Internals /////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// const internals = { kStyleSheet: "/plugins/wmap/wmap.css", kDimensions: { full: { map: { mapWidth: 1300, mapHeight: 1000, padding: 42, }, fonts: {}, }, thumbnail: { map: { mapWidth: 403, mapHeight: 310, padding: 15, vertexWidth: 8, vertexHeight: 8, arrowHeadSize: 3, stageHeight: 50, }, fonts: { axisLabel: 8, vertexLabel: 9, note: 9, }, }, }, // Returns HTML that shows a message in the wiki. message(text) { return `
${text}
`; }, // Creates a wmap PNG from text. renderImage(source, dimensions) { const parsedText = parse(source); // Further Improvements. The zoomed version could have a drop down // to show the different StageTypes and allow one to see the image // with the different types. // This could also potentially be in the toolbar that appears // on hover to also show this in the download. It requires some // thought on what the right interaction model would be. return renderToSVG(parsedText, StageType.ACTIVITIES, { theme: { sizes: { ...dimensions.map, }, fonts: { ...dimensions.fonts, }, }, }); }, // Starts a download of the image. download(filename, text) { const svgWithNamespace = text.replace( " styleSheet.href?.endsWith(internals.kStyleSheet), ); if (!hasStyleSheet) { console.log("Adding wmap stylesheet."); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = internals.kStyleSheet; link.type = "text/css"; document.getElementsByTagName("head")[0].appendChild(link); } return $item.append(internals.message("Loading Wardley Map")); }; /** * Bind to events on the rendered block. Called once per block. * Convention is that double click opens the editor. * * @param {JQuery} $item the HTML element representing the block. * @param {Object} item the configuration for that particular object. */ const bind = function bind($item, item) { $item.on("dblclick", () => { return wiki.textEditor($item, item); }); $item.on("click", "a", (event) => { const { currentTarget } = event; const action = currentTarget.dataset?.action; if (!action) { return; } event.stopPropagation(); event.preventDefault(); switch (action) { case "download": { let imageData = internals.renderImage( expandLineup(item.text, $item), internals.kDimensions.full, ); const slug = $item.parents(".page").attr("id"); internals.download(`${slug}.svg`, imageData); } break; case "zoom": { const shouldOpenStandalone = !!event.shiftKey; const target = shouldOpenStandalone ? "_blank" : "wmap"; const dialog = window.open( "/plugins/wmap/dialog/#", target, "popup,height=600,width=800", ); if (!dialog || dialog.closed) { console.error("wmap: Failed to open dialog."); return; } let imageData = internals.renderImage( expandLineup(item.text, $item), internals.kDimensions.full, ); // We *MUST* check both readyState and href, because // readyState will be complete when it first loads, // and this will be an empty window. if ( dialog.document.readyState === "complete" && dialog.document.location.href.includes( "/plugins/wmap/dialog/", ) ) { internals.replaceImage(dialog, imageData); } else { console.info("We not loaded"); dialog.addEventListener( "load", () => { internals.replaceImage(dialog, imageData); }, { once: true }, ); } } break; } }); try { // Register the source hooks before rendering so any sibling // binding concurrently can resolve this item via lineup(). const root = $item.get(0); root.classList.add("aspect-source"); root.classList.add("wmap-source"); root.aspectData = () => aspectData(root, item.text); root.wmapData = () => wmapData(item, $item); let thumbnailData = internals.renderImage( expandLineup(item.text, $item), internals.kDimensions.thumbnail, ); $item.find(".viewer").html(`
${thumbnailData}
`); $item.find('svg text:gt(7)').on('click', function(event) { const title = this.innerHTML; let $page = $item.parents('.page') wiki.pageHandler.context = wiki.lineup.atKey($page.data('key')).getContext() wiki.doInternalLink(title, event.shiftKey ? null : $page) }); } catch (err) { console.log("Failed to parse wardley map: ", err); $item.html(internals.message(err.message)); } }; /** * On load, attempt to add the emit and bind functions to window, as these * are called by the wiki plugin manager. */ if (typeof window !== "undefined" && window !== null) { if (!window.plugins.wmap) { window.plugins.wmap = { emit, bind }; } } const expand = function (text) { return DOMPurify.sanitize(text); }; export const wmap = typeof window === "undefined" ? { expand } : undefined;