/** * @typedef {import('./configuration.js').tConfiguration} tConfiguration * @typedef {import('./stage_type.js').StageType} StageType * @typedef {import('wmap-parser').Map} ParsedMap */ import { createPattern, patterns } from "./patterns.js"; import { createComponentMap, percentToPixel, getStageValues } from "./utils.js"; import { calculateOptimalLabelPosition, collectAllLines, collectNoteRects, collectInertiaRects, } from "./smart-label-positioning.js"; import concave from "@turf/concave"; import { featureCollection, point } from "@turf/helpers"; // Pattern cache to avoid recreating patterns on every render const patternCache = new WeakMap(); // Offscreen canvas cache for groups let offscreenCanvasCache = null; let offscreenCanvasContext = null; /** * Map rendering constants and utilities */ const internals = { // Padding around the axes kAxisLabelPadding: 5, // The default axis labels kAxisLabels: { xAxis: { leading: "Uncharted", trailing: "Industrialised", }, yAxis: { top: "Visible", bottom: "Invisible", }, }, }; /** * Draw the map background with patterns for evolution stages * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} stages - Stage values * @param {tConfiguration} configuration - Configuration object */ async function drawBackground(ctx, stages, config) { const { mapWidth, mapHeight } = config.theme.sizes; const { stageForeground, stageBackground } = config.theme.colors; const width = mapWidth; const height = mapHeight; // Check cache first let cachedPatterns = patternCache.get(ctx); if ( !cachedPatterns || cachedPatterns.fg !== stageForeground || cachedPatterns.bg !== stageBackground ) { // Create patterns and cache them cachedPatterns = { fg: stageForeground, bg: stageBackground, stitch: await createPattern( ctx, patterns.stitch, 1, stageForeground, stageBackground, ), shingles: await createPattern( ctx, patterns.shingles, 1, stageForeground, stageBackground, ), shadowGrid: await createPattern( ctx, patterns.shadowGrid, 1, stageForeground, stageBackground, ), wicker: await createPattern( ctx, patterns.wicker, 1, stageForeground, stageBackground, ), }; patternCache.set(ctx, cachedPatterns); } const w = (dim) => percentToPixel(dim, width); ctx.fillStyle = cachedPatterns.stitch; ctx.fillRect(0, 0, w(stages[0]), height); ctx.fillStyle = cachedPatterns.shingles; ctx.fillRect(w(stages[0]), 0, w(stages[1]) - w(stages[0]), height); ctx.fillStyle = cachedPatterns.shadowGrid; ctx.fillRect(w(stages[1]), 0, w(stages[2]) - w(stages[1]), height); ctx.fillStyle = cachedPatterns.wicker; ctx.fillRect(w(stages[2]), 0, width - w(stages[2]), height); } /** * Draw the map axes (X and Y) * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} stages - Stage values * @param {StageType} [stageType=StageType.ACTIVITIES] - The labels to use for each stage in the map. * @param {tConfiguration} configuration - Configuration object */ function drawAxes(ctx, stages, stageType, config) { const { mapWidth, mapHeight, lineWidth } = config.theme.sizes; const { axis, label } = config.theme.colors; const { family: fontFamily, axisLabel } = config.theme.fonts; const width = mapWidth; const height = mapHeight; const xLabels = internals.kAxisLabels.xAxis; const yLabels = internals.kAxisLabels.yAxis; const w = (dim) => percentToPixel(dim, width); ctx.strokeStyle = axis; ctx.lineWidth = lineWidth * 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, height); ctx.lineTo(width, height); ctx.stroke(); ctx.fillStyle = label; ctx.font = `${axisLabel}px ${fontFamily}`; ctx.textBaseline = "top"; ctx.save(); ctx.translate(-20, 45); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabels.top, 0, 0); ctx.restore(); ctx.save(); ctx.translate(-20, height); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabels.bottom, 0, 0); ctx.restore(); const stageHeight = config.theme.sizes.stageHeight; ctx.textAlign = "left"; ctx.fillText(xLabels.leading, 0, -stageHeight / 4); ctx.textAlign = "right"; ctx.fillText(xLabels.trailing, width, -stageHeight / 4); ctx.textAlign = "left"; ctx.fillText(stageType.stages[0], 0, height + internals.kAxisLabelPadding); ctx.fillText( stageType.stages[1], w(stages[0]), height + internals.kAxisLabelPadding, ); ctx.fillText( stageType.stages[2], w(stages[1]), height + internals.kAxisLabelPadding, ); ctx.fillText( stageType.stages[3], w(stages[2]), height + internals.kAxisLabelPadding, ); } /** * Draw vertical stage lines * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} stages - Stage values * @param {tConfiguration} configuration - Configuration object */ function drawStages(ctx, stages, config) { const { mapWidth, mapHeight, lineWidth } = config.theme.sizes; const { axis } = config.theme.colors; const width = mapWidth; const height = mapHeight; const w = (dim) => percentToPixel(dim, width); ctx.strokeStyle = axis; ctx.lineWidth = lineWidth * 2; ctx.setLineDash([10, 18]); ctx.globalAlpha = 1.0; ctx.beginPath(); ctx.moveTo(w(stages[0]), 0); ctx.lineTo(w(stages[0]), height); ctx.moveTo(w(stages[1]), 0); ctx.lineTo(w(stages[1]), height); ctx.moveTo(w(stages[2]), 0); ctx.lineTo(w(stages[2]), height); ctx.stroke(); ctx.setLineDash([]); } /** * Draw a vertex shape * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {number} x - X position in pixels * @param {number} y - Y position in pixels * @param {string} shape - Shape type (circle, square, triangle, x) * @param {tConfiguration} configuration - Configuration object */ function drawVertexShape(ctx, x, y, shape, config) { const { vertexWidth, vertexHeight } = config.theme.sizes; const { vertex } = config.theme.colors; const width = vertexWidth; const height = vertexHeight; ctx.fillStyle = vertex; switch (shape) { case "circle": ctx.beginPath(); ctx.ellipse( x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2, ); ctx.fill(); break; case "square": ctx.fillRect(x, y, width, height); break; case "triangle": ctx.beginPath(); ctx.moveTo(x, y + height); ctx.lineTo(x + width, y + height); ctx.lineTo(x + width / 2, y); ctx.closePath(); ctx.fill(); break; case "x": ctx.strokeStyle = vertex; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + width, y + height); ctx.moveTo(x + width, y); ctx.lineTo(x, y + height); ctx.stroke(); break; default: ctx.beginPath(); ctx.ellipse( x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2, ); ctx.fill(); } } /** * Draw map components (vertices) * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} components - Array of component objects * @param {Object} map - Full map object (for smart label positioning) * @param {Map} componentMap - Map of component labels to coordinates * @param {tConfiguration} configuration - Configuration object */ function drawVertices(ctx, components, map, componentMap, configuration) { const { options: { smartLabelPositioning }, theme: { sizes: { mapWidth, mapHeight, vertexWidth }, colors: { label }, fonts: { family: fontFamily, vertexLabel: fontSize }, }, } = configuration; const width = mapWidth; const height = mapHeight; const w = (dim) => percentToPixel(dim, width); const h = (dim) => percentToPixel(dim, height); ctx.font = `${fontSize}px ${fontFamily}`; ctx.fillStyle = label; ctx.textBaseline = "middle"; ctx.textAlign = "left"; // Collect lines, notes, and inertias once if using smart positioning let allLines, noteRects, inertiaRects; if (smartLabelPositioning) { allLines = collectAllLines(map, configuration, componentMap); noteRects = collectNoteRects(map, configuration, ctx); inertiaRects = collectInertiaRects(map, configuration, componentMap); } for (const component of components) { const [xPercent, yPercent] = component.coordinates; const x = w(xPercent); const y = h(yPercent); drawVertexShape(ctx, x, y, component.shape, configuration); let labelX, labelY; if (smartLabelPositioning) { const position = calculateOptimalLabelPosition( component, map, configuration, ctx, allLines, noteRects, inertiaRects, ); labelX = position.x; labelY = position.y; ctx.textBaseline = "top"; } else { // Use default positioning (to the right) labelX = x + vertexWidth + internals.kAxisLabelPadding; labelY = y + 7; ctx.textBaseline = "middle"; } ctx.fillText(component.label, labelX, labelY); } } /** * Draw dependencies between components * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} dependencies - Array of dependency objects * @param {Map} componentMap - Map of component labels to coordinates * @param {tConfiguration} configuration - Configuration object */ function drawDependencies(ctx, dependencies, componentMap, configuration) { const { theme: { sizes: { vertexWidth, vertexHeight, lineWidth, arrowheadSize }, colors: { vertex }, }, } = configuration; ctx.strokeStyle = vertex; ctx.lineWidth = lineWidth; const halfWidth = vertexWidth / 2; const halfHeight = vertexHeight / 2; const arrowAngle = Math.PI / 4; for (const dependency of dependencies) { const fromCoordinates = componentMap[dependency.from.toLowerCase()]; const toCoordinates = componentMap[dependency.to.toLowerCase()]; if (!fromCoordinates || !toCoordinates) continue; const originX = fromCoordinates[0] + halfWidth; const originY = fromCoordinates[1] + halfHeight; const destX = toCoordinates[0] + halfWidth; const destY = toCoordinates[1] + halfHeight; const angle = Math.atan2(destY - originY, destX - originX); const cosine = Math.cos(angle); const sine = Math.sin(angle); const offsetOriginX = originX + halfWidth * cosine; const offsetOriginY = originY + halfHeight * sine; const offsetDestX = destX - halfWidth * cosine; const offsetDestY = destY - halfHeight * sine; ctx.beginPath(); ctx.moveTo(offsetOriginX, offsetOriginY); ctx.lineTo(offsetDestX, offsetDestY); ctx.stroke(); if (dependency.isDirected) { const upperAngle = angle - arrowAngle; const lowerAngle = angle + arrowAngle; ctx.beginPath(); ctx.moveTo( offsetDestX - arrowheadSize * Math.cos(upperAngle), offsetDestY - arrowheadSize * Math.sin(upperAngle), ); ctx.lineTo(offsetDestX, offsetDestY); ctx.lineTo( offsetDestX - arrowheadSize * Math.cos(lowerAngle), offsetDestY - arrowheadSize * Math.sin(lowerAngle), ); ctx.stroke(); } } } /** * Draw notes on the map * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} notes - Array of note objects * @param {tConfiguration} configuration - Configuration object */ function drawNotes(ctx, notes, configuration) { const { theme: { sizes: { mapWidth, mapHeight, lineWidth }, colors: { background, vertex, label }, fonts: { family: fontFamily, note: fontSize }, lineHeights: { note: lineHeight }, }, } = configuration; const w = (dim) => percentToPixel(dim, mapWidth); const h = (dim) => percentToPixel(dim, mapHeight); const maxWidth = 400; const padding = internals.kAxisLabelPadding; ctx.font = `${fontSize}px ${fontFamily}`; ctx.textBaseline = "top"; ctx.textAlign = "left"; ctx.lineWidth = lineWidth; // Batch all background fills first ctx.fillStyle = background; const noteData = []; for (const note of notes) { const x = w(note.coordinates[0]); const y = h(note.coordinates[1]); const lines = note.text.replace(/\\n/g, "\n").split("\n"); let widestLine = 0; for (let i = 0; i < lines.length; i++) { const lineWidth = ctx.measureText(lines[i]).width; if (lineWidth > widestLine) widestLine = lineWidth; } const boxWidth = Math.min(maxWidth, widestLine + padding * 2); const boxHeight = lines.length * lineHeight + padding * 2; ctx.fillRect(x, y, boxWidth, boxHeight); noteData.push({ x, y, boxWidth, boxHeight, lines }); } // Batch all strokes ctx.strokeStyle = vertex; for (const { x, y, boxWidth, boxHeight } of noteData) { ctx.strokeRect(x, y, boxWidth, boxHeight); } // Batch all text ctx.fillStyle = label; for (const { x, y, lines } of noteData) { for (let i = 0; i < lines.length; i++) { ctx.fillText(lines[i], x + padding, y + padding + i * lineHeight); } } } /** * Draw inertia indicators * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} inertias - Array of inertia objects * @param {Map} componentMap - Map of component labels to coordinates * @param {tConfiguration} configuration - Configuration object */ function drawInertias(ctx, inertias, componentMap, configuration) { const { theme: { sizes: { vertexWidth, vertexHeight }, colors: { inertia }, }, } = configuration; const cornerRadius = 2; ctx.fillStyle = inertia; for (const inertia of inertias) { if (!inertia.component) return; const coordinates = componentMap[inertia.component.toLowerCase()]; if (!coordinates) return; const x = coordinates[0] + 3 * vertexWidth; const y = coordinates[1] - (vertexHeight * 2) / 3; const rectWidth = vertexWidth / 2; const rectHeight = vertexHeight * 2; ctx.beginPath(); ctx.roundRect(x, y, rectWidth, rectHeight, cornerRadius); ctx.fill(); } } /** * Draw evolution arrows (opportunities) * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} evolutions - Array of evolution objects * @param {Map} componentMap - Map of component labels to coordinates * @param {tConfiguration} configuration - Configuration object */ function drawEvolutions(ctx, evolutions, componentMap, configuration) { const { theme: { sizes: { mapWidth, vertexWidth, vertexHeight, lineWidth, arrowheadSize, }, colors: { evolution }, }, } = configuration; ctx.strokeStyle = evolution; ctx.lineWidth = lineWidth * 2; ctx.setLineDash([10]); ctx.globalAlpha = 1.0; evolutions.forEach((evolutionItem) => { const coords = componentMap[evolutionItem.component.toLowerCase()]; if (!coords) return; const originX = coords[0]; const originY = coords[1]; const destX = Math.max( 0, Math.min( mapWidth, originX + percentToPixel(evolutionItem.value, mapWidth), ), ); const destY = originY; const multiplier = destX > originX ? 1 : -1; const upperAngle = -Math.PI / 4; const lowerAngle = Math.PI / 4; const offsetOriginX = originX + multiplier * (vertexWidth / 2) + vertexWidth / 2; const offsetOriginY = originY + vertexHeight / 2; const offsetDestX = destX - multiplier * (vertexWidth / 2) + vertexWidth / 2; const offsetDestY = destY + vertexHeight / 2; ctx.beginPath(); ctx.moveTo(offsetOriginX, offsetOriginY); ctx.lineTo(offsetDestX, offsetDestY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(offsetDestX, offsetDestY); ctx.lineTo( offsetDestX - multiplier * arrowheadSize * Math.cos(upperAngle), offsetDestY - multiplier * arrowheadSize * Math.sin(upperAngle), ); ctx.stroke(); ctx.beginPath(); ctx.moveTo(offsetDestX, offsetDestY); ctx.lineTo( offsetDestX - multiplier * arrowheadSize * Math.cos(lowerAngle), offsetDestY - multiplier * arrowheadSize * Math.sin(lowerAngle), ); ctx.stroke(); }); ctx.setLineDash([]); ctx.globalAlpha = 1.0; } /** * Draw groups with concave hulls * @param {CanvasRenderingContext2D} ctx - Canvas context * @param {Array} groups - Array of group objects * @param {Map} componentMap - Map of component labels to coordinates * @param {tConfiguration} configuration - Configuration object */ async function drawGroups(ctx, groups, componentMap, configuration) { const { theme: { colors: { groups: groupColors }, sizes: { mapWidth, mapHeight, vertexWidth, vertexHeight }, opacity: { groups: groupOpacity }, }, } = configuration; const width = mapWidth; const height = mapHeight; // Create/reuse offscreen canvas for groups to flatten fill + stroke before applying opacity const isNode = typeof window === "undefined"; if ( !offscreenCanvasCache || offscreenCanvasCache.width !== width + vertexWidth || offscreenCanvasCache.height !== height + vertexHeight ) { if (isNode) { const { createCanvas } = await import("canvas"); offscreenCanvasCache = createCanvas( width + vertexWidth, height + vertexHeight, ); } else { offscreenCanvasCache = document.createElement("canvas"); offscreenCanvasCache.width = width + vertexWidth; offscreenCanvasCache.height = height + vertexHeight; } offscreenCanvasContext = offscreenCanvasCache.getContext("2d"); } groups.forEach((group, groupIndex) => { const componentCoords = group.components .map((label) => componentMap[label.toLowerCase()]) .filter(Boolean); if (componentCoords.length === 0) return; const color = groupColors[groupIndex % groupColors.length]; let coords; if (componentCoords.length === 1) { // For 1 component, create a small circle around it const [x, y] = componentCoords[0]; const radius = 3; // Small radius in percentage units const segments = 16; coords = []; for (let i = 0; i <= segments; i++) { const angle = (i / segments) * 2 * Math.PI; coords.push([ x + radius * Math.cos(angle), y + radius * Math.sin(angle), ]); } } else if (componentCoords.length === 2) { // For 2 components, create a simple line between them coords = [componentCoords[0], componentCoords[1]]; } else { // For 3+ components, use concave hull const points = componentCoords.map(([x, y]) => point([x, y])); const fc = featureCollection(points); const hull = concave(fc); if (!hull) return; coords = hull.geometry.coordinates[0]; } // Clear the offscreen canvas offscreenCanvasContext.clearRect( 0, 0, offscreenCanvasCache.width, offscreenCanvasCache.height, ); // Reset transform and then translate offscreenCanvasContext.setTransform(1, 0, 0, 1, 0, 0); offscreenCanvasContext.translate(vertexWidth / 2, vertexHeight / 2); // Create path offscreenCanvasContext.beginPath(); coords.forEach(([x, y], i) => { const px = x; const py = y; if (i === 0) { offscreenCanvasContext.moveTo(px, py); } else { offscreenCanvasContext.lineTo(px, py); } }); // For lines (2 components), don't close the path or fill const isLine = componentCoords.length === 2; if (!isLine) { offscreenCanvasContext.closePath(); offscreenCanvasContext.fillStyle = color; offscreenCanvasContext.fill(); } // Stroke with thick line offscreenCanvasContext.strokeStyle = color; offscreenCanvasContext.lineWidth = 1.75 * vertexWidth; offscreenCanvasContext.lineCap = "round"; offscreenCanvasContext.lineJoin = "round"; offscreenCanvasContext.stroke(); // Draw the offscreen canvas to the main canvas with opacity ctx.save(); ctx.globalAlpha = groupOpacity; ctx.drawImage(offscreenCanvasCache, 0, 0); ctx.restore(); }); } /** * Render a parsed Wardley map to a canvas context * @param {ParsedMap} map - Parsed map object from wmap-parser * @param {CanvasRenderingContext2D} ctx - Canvas context to render to * @param {StageType} stageType - The labels to use for each stage in the map. * @param {tConfiguration} configuration - Configuration object * @returns {Promise} */ export async function render(map, ctx, stageType, configuration) { const { options: { showBackground }, theme: { colors: { background }, sizes: { mapWidth, mapHeight, padding }, }, } = configuration; const stages = getStageValues(map.stages); // Create component map once for all functions to use const componentMap = createComponentMap( map.components, mapWidth, mapHeight, ); // Fill entire canvas with white background BEFORE translate ctx.fillStyle = background; const totalWidth = mapWidth + padding * 2; const totalHeight = mapHeight + padding * 2; ctx.fillRect(0, 0, totalWidth, totalHeight); ctx.save(); ctx.translate(padding, padding); // Fill map area with background color (in case patterns don't fill completely) ctx.fillStyle = background; ctx.fillRect(0, 0, mapWidth, mapHeight); if (showBackground) { await drawBackground(ctx, stages, configuration); } drawAxes(ctx, stages, stageType, configuration); drawStages(ctx, stages, configuration); drawDependencies(ctx, map.dependencies, componentMap, configuration); // Draw groups first (with low opacity) await drawGroups(ctx, map.groups, componentMap, configuration); drawVertices(ctx, map.components, map, componentMap, configuration); drawInertias(ctx, map.inertias, componentMap, configuration); drawEvolutions(ctx, map.evolutions, componentMap, configuration); drawNotes(ctx, map.notes, configuration); ctx.restore(); }