diff options
Diffstat (limited to 'src/renderer.js')
| -rw-r--r-- | src/renderer.js | 776 |
1 files changed, 776 insertions, 0 deletions
diff --git a/src/renderer.js b/src/renderer.js new file mode 100644 index 0000000..f2001f0 --- /dev/null +++ b/src/renderer.js @@ -0,0 +1,776 @@ +/** + * @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<number>} 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<number>} 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<number>} 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<void>} + */ +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(); +} |