diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/configuration.js | 176 | ||||
| -rw-r--r-- | src/image_type.js | 9 | ||||
| -rw-r--r-- | src/index.js | 153 | ||||
| -rw-r--r-- | src/patterns.js | 74 | ||||
| -rw-r--r-- | src/renderer.js | 776 | ||||
| -rw-r--r-- | src/smart-label-positioning.js | 524 | ||||
| -rw-r--r-- | src/stage_type.js | 187 | ||||
| -rw-r--r-- | src/utils.js | 50 |
8 files changed, 1949 insertions, 0 deletions
diff --git a/src/configuration.js b/src/configuration.js new file mode 100644 index 0000000..2c58e88 --- /dev/null +++ b/src/configuration.js @@ -0,0 +1,176 @@ +/** + * The configuration options to control the render. + * @typedef {Object} tConfiguration + * + * @property {tConfigurationOptions} options - Options that control how the map is rendered. + * @property {tConfigurationTheme} theme - Options that control the look of the map. + */ + +/** + * Options that control how the map is rendered + * @typedef {Object} tConfigurationOptions + * + * @property {boolean} showBackground - Whether to render the background patterns. + * @property {boolean} smartLabelPositioning - Whether to activate smart label positioning. + */ + +/** + * Options that control how the map is rendered + * @typedef {Object} tConfigurationTheme + * + * @property {tConfigurationThemeColors} colors - The colors of map elements. + * @property {tConfigurationThemeSizes} sizes - The sizes of map elements. + * @property {tConfigurationThemeFonts} fonts - The font family and sizes. + * @property {tConfigurationThemeLineHeights} lineHeights - The line heights of map text. + * @property {tConfigurationThemeOpacity} opacity - Opacity of map elements. + */ + +/** + * The colors of map elements + * @typedef {Object} tConfigurationThemeColors + * + * @property {string} background - The color of the background. + * @property {string} axis: - The color of the axis lines at the edge of the map. + * @property {string} vertex - The color of component shapes. + * @property {string} label - The color of component labels. + * @property {string} stageForeground - The first color of the stage bitmap when the background is drawn. + * @property {string} stageBackground - The second color of the stage bitmap when the background is drawn. + * @property {string} inertia - The color of the inertia element. + * @property {string} evolution - The color of the evolution arrow. + * @property {Array<string>} groups - A list of colors to use for groups. + */ + +/** + * The sizes of map elements + * @typedef {Object} tConfigurationThemeSizes + * + * @property {number} mapWidth - The width of the map. + * @property {number} mapHeight - The height of the map. + * @property {number} padding - The padding around the map for axis labels. + * @property {number} lineWidth - The default line width. + * @property {number} vertexWidth - Width of component shapes. + * @property {number} vertexHeight - Height of component shapes. + * @property {number} arrowheadSize - Size of arrowheads. + * @property {number} stageHeight - Height of stage lines. + */ + +/** + * The sizes of map elements + * @typedef {Object} tConfigurationThemeFonts + * + * @property {string} family - The font family to use, in CSS form. + * @property {number} axisLabel - Font size of the axis labels. + * @property {number} vertexLabel - Font size of vertex labels. + * @property {number} note - Font size of note text. + */ + +/** + * The line heights of map text + * @typedef {Object} tConfigurationThemeLineHeights + * + * @property {number} vertexLabel - Line height of vertex labels. + * @property {number} note - Line height of note text. + */ + +/** + * The opacity of map elements. + * @typedef {Object} tConfigurationThemeOpacity + * + * @property {number} groups - Opacity of the color in groups + */ + +const internals = { + kDefaults: { + options: { + showBackground: true, + smartLabelPositioning: true, + }, + theme: { + colors: { + background: "#FFFFFF", + axis: "#0F261F", + vertex: "#0F261F", + label: "#0F261F", + stageForeground: "#DAE6E3", + stageBackground: "#FFFFFF", + inertia: "#FA2B00", + evolution: "#4F8FE6", + groups: [ + "#4F8FE6", // Olympic Blue + "#FA2B00", // Jasper Red + "#23C17C", // Light Porcelain Green + "#FAED8F", // Naples Yellow + "#FFB3F0", // Hermosa Pink + ], + }, + sizes: { + mapWidth: 1300, + mapHeight: 1000, + padding: 42, + lineWidth: 0.5, + vertexWidth: 25, + vertexHeight: 25, + arrowheadSize: 10, + stageHeight: 100, + }, + fonts: { + family: '"Helvetica Neue", Helvetica, Arial, sans-serif', + axisLabel: 14, + vertexLabel: 12, + note: 12, + }, + lineHeights: { + vertexLabel: 18, + note: 18, + }, + opacity: { + groups: 0.1, + }, + }, + }, +}; + +/** + * Returns a configuration by extending the defaults with the user overrides. + * + * @param {tConfiguration} [overrides={}] - User overrides. + * @returns {tConfiguration} The extended configuration. + */ +export function extendConfiguration(overrides = {}) { + const configuration = { + options: Object.assign( + {}, + internals.kDefaults.options, + overrides.options, + ), + theme: { + colors: Object.assign( + {}, + internals.kDefaults.theme.colors, + overrides.theme?.colors, + ), + sizes: Object.assign( + {}, + internals.kDefaults.theme.sizes, + overrides.theme?.sizes, + ), + fonts: Object.assign( + {}, + internals.kDefaults.theme.fonts, + overrides.theme?.fonts, + ), + lineHeights: Object.assign( + {}, + internals.kDefaults.theme.lineHeights, + overrides.theme?.lineHeights, + ), + opacity: Object.assign( + {}, + internals.kDefaults.theme.opacity, + overrides.theme?.opacity, + ), + }, + }; + + return configuration; +} diff --git a/src/image_type.js b/src/image_type.js new file mode 100644 index 0000000..c462455 --- /dev/null +++ b/src/image_type.js @@ -0,0 +1,9 @@ +/** + * Image output format for Node.js rendering + * @enum {string} ImageType + */ +export const ImageType = { + PNG: "png", + JPEG: "jpeg", + PDF: "pdf", +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2a58ad5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,153 @@ +import { render } from "./renderer.js"; +import { extendConfiguration } from "./configuration.js"; +import { ImageType } from "./image_type.js"; +import { StageType } from "./stage_type.js"; + +export { ImageType } from "./image_type.js"; +export { StageType } from "./stage_type.js"; + +/** + * @typedef {import('./configuration.js').tConfiguration} tConfiguration + * @typedef {import('./image_type.js').ImageType} ImageType + * @typedef {import('./stage_type.js').StageType} StageType + * @typedef {import('wmap-parser').Map} ParsedMap + * @typedef {import('canvas').Canvas} Canvas + */ + +/** + * Render a parsed Wardley map to a canvas element + * Works in both browser and Node.js environments + * + * @param {ParsedMap} map - Parsed map object from wmap-parser + * @param {HTMLCanvasElement|Canvas} canvas - Canvas element (browser) or Canvas instance (Node.js) + * @param {StageType} [stageType=StageType.ACTIVITIES] - The labels to use for each stage in the map. + * @param {tConfiguration} overrides - User configuration overrides + * @returns {Promise<void>} + * + * @example + * // Browser + * import { parse } from 'wmap-parser'; + * import { renderToCanvas } from 'wmap-renderer-canvas'; + * + * const map = parse(wmapSource); + * const canvas = document.getElementById('myCanvas'); + * await renderToCanvas(map, canvas); + * + * @example + * // Node.js + * import { createCanvas } from 'canvas'; + * import { parse } from 'wmap-parser'; + * import { renderToCanvas } from 'wmap-renderer-canvas'; + * + * const map = parse(wmapSource); + * const canvas = createCanvas(1384, 1084); + * await renderToCanvas(map, canvas); + */ +export async function renderToCanvas( + map, + canvas, + stageType = StageType.ACTIVITIES, + overrides = {}, +) { + if (!map) { + throw new Error("Map object is required"); + } + + if (!canvas) { + throw new Error("Canvas is required"); + } + + const configuration = extendConfiguration(overrides); + const { mapWidth, mapHeight, padding } = configuration.theme.sizes; + const totalWidth = mapWidth + padding * 2; + const totalHeight = mapHeight + padding * 2; + + if (canvas.width !== totalWidth || canvas.height !== totalHeight) { + canvas.width = totalWidth; + canvas.height = totalHeight; + } + + const context = canvas.getContext("2d"); + await render(map, context, stageType, configuration); +} + +/** + * Render a parsed Wardley map to an image stream + * Only available in Node.js environments + * + * @param {ParsedMap} map - Parsed map object from wmap-parser + * @param {ImageType} imageType - Output image format ('png', 'jpeg', or 'pdf') + * @param {StageType} [stageType=StageType.ACTIVITIES] - The labels to use for each stage in the map. + * @param {tConfiguration} overrides - User configuration overrides + * @returns {Promise<Stream>} Stream of the rendered image + * + * @throws {Error} If not in Node.js environment + * @throws {Error} If canvas module is not available + * @throws {Error} If imageType is not supported + * + * @example + * import { parse } from 'wmap-parser'; + * import { renderToImage, ImageType } from 'wmap-renderer-canvas'; + * import fs from 'fs'; + * + * const map = parse(wmapSource); + * const stream = await renderToImage(map, ImageType.PNG); + * const out = fs.createWriteStream('map.png'); + * stream.pipe(out); + */ +export async function renderToImage( + map, + imageType = ImageType.PNG, + stageType = StageType.ACTIVITIES, + overrides = {}, +) { + if (typeof window !== "undefined") { + throw new Error( + "renderToImage is only available in Node.js environments", + ); + } + + if (!map) { + throw new Error("Map object is required"); + } + + const validTypes = ["png", "jpeg", "pdf"]; + if (!validTypes.includes(imageType)) { + throw new Error( + `Invalid image type. Must be one of: ${validTypes.join(", ")}`, + ); + } + + let createCanvas; + try { + const canvasModule = await import("canvas"); + createCanvas = + canvasModule.default?.createCanvas || canvasModule.createCanvas; + } catch { + throw new Error("canvas module is required for renderToImage."); + } + + const configuration = extendConfiguration(overrides); + const { mapWidth, mapHeight, padding } = configuration.theme.sizes; + const totalWidth = mapWidth + padding * 2; + const totalHeight = mapHeight + padding * 2; + + const canvas = + imageType === "pdf" + ? createCanvas(totalWidth, totalHeight, "pdf") + : createCanvas(totalWidth, totalHeight); + const ctx = canvas.getContext("2d"); + + await render(map, ctx, stageType, configuration); + + switch (imageType) { + case "png": + return canvas.createPNGStream(); + case "jpeg": + return canvas.createJPEGStream(); + case "pdf": + return canvas.createPDFStream(); + default: + throw new Error(`Unsupported image type: ${imageType}`); + } +} diff --git a/src/patterns.js b/src/patterns.js new file mode 100644 index 0000000..2960dea --- /dev/null +++ b/src/patterns.js @@ -0,0 +1,74 @@ +/** + * Pattern definitions - 8x8 1-bit patterns + * 0 = foreground color, 1 = background color + */ +export const patterns = { + stitch: [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, + 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, + ], + shingles: [ + 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, + 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, + ], + shadowGrid: [ + 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, + 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, + ], + wicker: [ + 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, + 1, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, + 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, + ], +}; + +/** + * Create a canvas pattern from a pattern definition + * @param {CanvasRenderingContext2D} ctx - Canvas context + * @param {Array<number>} patternData - 8x8 pattern array + * @param {number} pixelSize - Size of each pattern pixel + * @param {string} fgColor - Foreground color (for 0 values) + * @param {string} bgColor - Background color (for 1 values) + * @returns {Promise<CanvasPattern>} Canvas pattern + */ +export async function createPattern( + ctx, + patternData, + pixelSize, + fgColor, + bgColor, +) { + const size = 8; + let patternCanvas; + + const isNode = typeof window === "undefined"; + + if (isNode) { + const { createCanvas } = await import("canvas"); + patternCanvas = createCanvas(size * pixelSize, size * pixelSize); + } else { + patternCanvas = window.document.createElement("canvas"); + patternCanvas.width = size * pixelSize; + patternCanvas.height = size * pixelSize; + } + + const patternCtx = patternCanvas.getContext("2d"); + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const value = patternData[y * size + x]; + patternCtx.fillStyle = value === 0 ? fgColor : bgColor; + patternCtx.fillRect( + x * pixelSize, + y * pixelSize, + pixelSize, + pixelSize, + ); + } + } + + return ctx.createPattern(patternCanvas, "repeat"); +} 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(); +} diff --git a/src/smart-label-positioning.js b/src/smart-label-positioning.js new file mode 100644 index 0000000..5a48e29 --- /dev/null +++ b/src/smart-label-positioning.js @@ -0,0 +1,524 @@ +/** + * Smart label positioning algorithm + * Finds optimal label positions to avoid collisions with lines and notes + */ + +import { percentToPixel } from "./utils.js"; + +/** + * Calculate optimal label position for a component + * @param {Object} component - Component object with label and coordinates + * @param {Object} mapData - Map data containing all components, edges, notes, etc. + * @param {Object} config - Configuration object + * @param {CanvasRenderingContext2D} ctx - Canvas context for text measurement + * @param {Array} allLines - Pre-collected lines from edges, evolutions, etc. + * @param {Array} noteRects - Pre-collected note rectangles + * @param {Array} inertiaRects - Pre-collected inertia rectangles + * @returns {Object} Position object with x, y coordinates in pixels + */ +export function calculateOptimalLabelPosition( + component, + mapData, + config, + ctx, + allLines, + noteRects, + inertiaRects, +) { + const { mapWidth, mapHeight, vertexWidth, vertexHeight } = + config.theme.sizes; + const { family: fontFamily, vertexLabel: fontSize } = config.theme.fonts; + + const vertexPixelPos = { + x: percentToPixel(component.coordinates[0], mapWidth), + y: percentToPixel(component.coordinates[1], mapHeight), + }; + + // Measure label size + ctx.font = `${fontSize}px ${fontFamily}`; + const actualLabelSize = calculateLabelSize(component.label, ctx, config); + + // Generate 8 candidate positions + const candidatePositions = generateCandidatePositions( + vertexPixelPos, + { width: vertexWidth, height: vertexHeight }, + actualLabelSize, + ); + + // Check default position (right side) first + const defaultPosition = candidatePositions[0]; + const defaultRect = { + x: defaultPosition.x, + y: defaultPosition.y, + width: actualLabelSize.width, + height: actualLabelSize.height, + }; + + if ( + countCollisions(defaultRect, allLines) === 0 && + countNoteCollisions(defaultRect, noteRects) === 0 && + countNoteCollisions(defaultRect, inertiaRects) === 0 + ) { + return defaultPosition; + } + + // Find best position by scoring all candidates + let bestPosition = defaultPosition; + let bestScore = Infinity; + + for (let index = 0; index < candidatePositions.length; index++) { + const position = candidatePositions[index]; + const labelRect = { + x: position.x, + y: position.y, + width: actualLabelSize.width, + height: actualLabelSize.height, + }; + + const collisionCount = countCollisions(labelRect, allLines); + const noteCollisionCount = countNoteCollisions(labelRect, noteRects); + const inertiaCollisionCount = countNoteCollisions( + labelRect, + inertiaRects, + ); + + const distance = Math.sqrt( + Math.pow(position.x - (vertexPixelPos.x + vertexWidth / 2), 2) + + Math.pow(position.y - (vertexPixelPos.y + vertexHeight / 2), 2), + ); + + const ownershipPenalty = calculateOwnershipPenalty( + labelRect, + component, + mapData.components, + config, + ); + + const score = + collisionCount * 100 + + noteCollisionCount * 500 + + inertiaCollisionCount * 300 + + distance * 0.5 + + index * 0.1 + + ownershipPenalty; + + if (score < bestScore) { + bestScore = score; + bestPosition = position; + } + } + + return bestPosition; +} + +/** + * Calculate label size including padding + */ +function calculateLabelSize(text, ctx, config) { + const cleanText = text.replace(/\\n/g, "\n"); + const lines = cleanText.split("\n"); + const lineHeight = config.theme.lineHeights.vertexLabel; + + let maxWidth = 0; + let totalHeight = 0; + + for (const line of lines) { + const metrics = ctx.measureText(line); + maxWidth = Math.max(maxWidth, metrics.width); + totalHeight += lineHeight; + } + + return { + width: Math.ceil(maxWidth) + 4, + height: Math.ceil(totalHeight) + 2, + }; +} + +/** + * Generate 8 candidate positions around the vertex + * Order: right, left, top, bottom, top-right, top-left, bottom-right, bottom-left + */ +function generateCandidatePositions(vertexPixelPos, vertexSize, labelSize) { + const padding = 5; + + return [ + // Right + { + x: vertexPixelPos.x + vertexSize.width + padding, + y: vertexPixelPos.y + (vertexSize.height - labelSize.height) / 2, + }, + // Left + { + x: vertexPixelPos.x - labelSize.width - padding, + y: vertexPixelPos.y + (vertexSize.height - labelSize.height) / 2, + }, + // Top + { + x: vertexPixelPos.x + (vertexSize.width - labelSize.width) / 2, + y: vertexPixelPos.y - labelSize.height - padding, + }, + // Bottom + { + x: vertexPixelPos.x + (vertexSize.width - labelSize.width) / 2, + y: vertexPixelPos.y + vertexSize.height + padding, + }, + // Top-right + { + x: vertexPixelPos.x + vertexSize.width + padding, + y: vertexPixelPos.y - labelSize.height - padding, + }, + // Top-left + { + x: vertexPixelPos.x - labelSize.width - padding, + y: vertexPixelPos.y - labelSize.height - padding, + }, + // Bottom-right + { + x: vertexPixelPos.x + vertexSize.width + padding, + y: vertexPixelPos.y + vertexSize.height + padding, + }, + // Bottom-left + { + x: vertexPixelPos.x - labelSize.width - padding, + y: vertexPixelPos.y + vertexSize.height + padding, + }, + ]; +} + +/** + * Collect all lines from edges, evolutions, inertias, and axes + */ +export function collectAllLines(mapData, config, componentMap) { + const { mapWidth, mapHeight, vertexWidth, vertexHeight } = + config.theme.sizes; + const lines = []; + + // Lines from edges (dependencies) + if (mapData.dependencies) { + mapData.dependencies.forEach((edge) => { + const originCoords = componentMap[edge.origin]; + const destCoords = componentMap[edge.destination]; + if (!originCoords || !destCoords) return; + + const origin = { + x: originCoords[0], + mapWidth, + y: originCoords[1], + mapHeight, + }; + const destination = { + x: destCoords[0], + mapWidth, + y: destCoords[1], + mapHeight, + }; + + const slope = + (destination.y - origin.y) / (destination.x - origin.x); + const angle = Math.atan(slope); + const multiplier = slope < 0 ? -1.0 : 1.0; + + const offsetOrigin = { + x: + origin.x + + multiplier * (vertexWidth / 2.0) * Math.cos(angle), + y: + origin.y + + multiplier * (vertexHeight / 2.0) * Math.sin(angle), + }; + const offsetDestination = { + x: + destination.x - + multiplier * (vertexWidth / 2.0) * Math.cos(angle), + y: + destination.y - + multiplier * (vertexHeight / 2.0) * Math.sin(angle), + }; + + const adjustedOrigin = { + x: offsetOrigin.x + vertexWidth / 2.0, + y: offsetOrigin.y + vertexHeight / 2.0, + }; + const adjustedDestination = { + x: offsetDestination.x + vertexWidth / 2.0, + y: offsetDestination.y + vertexHeight / 2.0, + }; + + lines.push({ start: adjustedOrigin, end: adjustedDestination }); + }); + } + + // Lines from evolutions + if (mapData.evolutions) { + mapData.evolutions.forEach((evolution) => { + const coords = componentMap[evolution.component]; + if (!coords) return; + + const originX = coords[0]; + const originY = coords[1]; + const destX = evolution.value; + + const origin = { + x: originX, + mapWidth, + y: originY, + mapHeight, + }; + const destination = { + x: destX, + mapWidth, + y: originY, + mapHeight, + }; + + const multiplier = destX > originX ? 1.0 : -1.0; + + const offsetOrigin = { + x: origin.x + multiplier * (vertexWidth / 2.0), + y: origin.y, + }; + const offsetDestination = { + x: destination.x - multiplier * (vertexWidth / 2.0), + y: destination.y, + }; + + const adjustedOrigin = { + x: offsetOrigin.x + vertexWidth / 2.0, + y: offsetOrigin.y + vertexHeight / 2.0, + }; + const adjustedDestination = { + x: offsetDestination.x + vertexWidth / 2.0, + y: offsetDestination.y + vertexHeight / 2.0, + }; + + lines.push({ start: adjustedOrigin, end: adjustedDestination }); + }); + } + + // Axis lines (Y-axis and X-axis) + lines.push( + { start: { x: 0, y: 0 }, end: { x: 0, y: mapHeight } }, + { start: { x: 0, y: mapHeight }, end: { x: mapWidth, y: mapHeight } }, + ); + + return lines; +} + +/** + * Collect all note rectangles + */ +export function collectNoteRects(mapData, config, ctx) { + if (!mapData.notes) return []; + + const { mapWidth, mapHeight } = config.theme.sizes; + + return mapData.notes.map((note) => { + const notePixelPos = { + x: percentToPixel(note.coordinates[0], mapWidth), + y: percentToPixel(note.coordinates[1], mapHeight), + }; + const noteSize = calculateLabelSize(note.text, ctx, config); + + return { + x: notePixelPos.x, + y: notePixelPos.y, + width: noteSize.width, + height: noteSize.height, + }; + }); +} + +/** + * Collect all inertia rectangles + */ +export function collectInertiaRects(mapData, config, componentMap) { + if (!mapData.inertias) return []; + + const { vertexWidth, vertexHeight } = config.theme.sizes; + + return mapData.inertias + .map((inertia) => { + if (!inertia.component) return null; + const coordinates = componentMap[inertia.component.toLowerCase()]; + if (!coordinates) return null; + + const x = coordinates[0] + 3 * vertexWidth; + const y = coordinates[1] - (vertexHeight * 2) / 3; + const rectWidth = vertexWidth / 2; + const rectHeight = vertexHeight * 2; + + return { + x, + y, + width: rectWidth, + height: rectHeight, + }; + }) + .filter((rect) => rect !== null); +} + +/** + * Count how many lines intersect with the label rectangle + */ +function countCollisions(labelRect, lines) { + let collisions = 0; + for (const line of lines) { + if (lineIntersectsRect(line, labelRect)) { + collisions++; + } + } + return collisions; +} + +/** + * Count how many notes intersect with the label rectangle + */ +function countNoteCollisions(labelRect, noteRects) { + let collisions = 0; + for (const noteRect of noteRects) { + if (rectsIntersect(labelRect, noteRect)) { + collisions++; + } + } + return collisions; +} + +/** + * Check if two rectangles intersect + */ +function rectsIntersect(rect1, rect2) { + return !( + rect1.x + rect1.width < rect2.x || + rect2.x + rect2.width < rect1.x || + rect1.y + rect1.height < rect2.y || + rect2.y + rect2.height < rect1.y + ); +} + +/** + * Check if a line intersects a rectangle + */ +function lineIntersectsRect(line, rect) { + const rectLines = [ + { + start: { x: rect.x, y: rect.y }, + end: { x: rect.x + rect.width, y: rect.y }, + }, + { + start: { x: rect.x + rect.width, y: rect.y }, + end: { x: rect.x + rect.width, y: rect.y + rect.height }, + }, + { + start: { x: rect.x + rect.width, y: rect.y + rect.height }, + end: { x: rect.x, y: rect.y + rect.height }, + }, + { + start: { x: rect.x, y: rect.y + rect.height }, + end: { x: rect.x, y: rect.y }, + }, + ]; + + for (const rectLine of rectLines) { + if (linesIntersect(line, rectLine)) { + return true; + } + } + + // Check if line endpoints are inside rect + return pointInRect(line.start, rect) || pointInRect(line.end, rect); +} + +/** + * Check if a point is inside a rectangle + */ +function pointInRect(point, rect) { + return ( + point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height + ); +} + +/** + * Check if two line segments intersect + */ +function linesIntersect(line1, line2) { + const x1 = line1.start.x; + const y1 = line1.start.y; + const x2 = line1.end.x; + const y2 = line1.end.y; + const x3 = line2.start.x; + const y3 = line2.start.y; + const x4 = line2.end.x; + const y4 = line2.end.y; + + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + + if (Math.abs(denom) < 1e-10) { + return false; + } + + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; + + return t >= 0 && t <= 1 && u >= 0 && u <= 1; +} + +/** + * Calculate penalty if label is closer to another vertex than its own + */ +function calculateOwnershipPenalty( + labelRect, + ownComponent, + allComponents, + config, +) { + if (allComponents.length === 0) return 0; + + const { mapWidth, mapHeight, vertexWidth, vertexHeight } = + config.theme.sizes; + + const labelCenter = { + x: labelRect.x + labelRect.width / 2, + y: labelRect.y + labelRect.height / 2, + }; + + const ownVertexPixelPos = { + x: percentToPixel(ownComponent.coordinates[0], mapWidth), + y: percentToPixel(ownComponent.coordinates[1], mapHeight), + }; + const ownVertexCenter = { + x: ownVertexPixelPos.x + vertexWidth / 2, + y: ownVertexPixelPos.y + vertexHeight / 2, + }; + + const distanceToOwnVertex = Math.sqrt( + Math.pow(labelCenter.x - ownVertexCenter.x, 2) + + Math.pow(labelCenter.y - ownVertexCenter.y, 2), + ); + + for (const otherComponent of allComponents) { + if (otherComponent === ownComponent) continue; + + const otherVertexPixelPos = { + x: percentToPixel(otherComponent.coordinates[0], mapWidth), + y: percentToPixel(otherComponent.coordinates[1], mapHeight), + }; + const otherVertexCenter = { + x: otherVertexPixelPos.x + vertexWidth / 2, + y: otherVertexPixelPos.y + vertexHeight / 2, + }; + + const distanceToOtherVertex = Math.sqrt( + Math.pow(labelCenter.x - otherVertexCenter.x, 2) + + Math.pow(labelCenter.y - otherVertexCenter.y, 2), + ); + + if (distanceToOtherVertex < distanceToOwnVertex) { + return 50.0; + } + } + + return 0; +} diff --git a/src/stage_type.js b/src/stage_type.js new file mode 100644 index 0000000..6b50f73 --- /dev/null +++ b/src/stage_type.js @@ -0,0 +1,187 @@ +/** + * The definition of a stage type used to analyze different aspects of a map. + * @typedef {Object} tStageType + * + * @property {string} name - The name of the stage type + * @property {[string, string, string, string]} stages - The four stages + */ + +/** + * Stage type used to render the image. + * @enum {tStageType} StageType + */ +export const StageType = { + BEHAVIOR: { + name: "Behavior", + stages: [ + "Uncertain when to use", + "Learning when to use", + "Learning through use", + "Known / common usage", + ], + }, + CERTAINTY: { + name: "Certainty", + stages: [ + "Poorly Understood / exploring the unknown", + "Rapid Increase In Learning / discovery becomes refining", + "Rapid increase in use / increasing fit for purpose", + "Commonly understood (in terms of use)", + ], + }, + COMPARISON: { + name: "Comparison", + stages: [ + "Constantly changing / a differential / unstable", + "Learning from others / testing the water / some evidential support", + "Competing models / feature difference / evidential support", + "Essential / any advantage is operational / accepted norm", + ], + }, + CYNEFIN: { + name: "Cynefin", + stages: ["Chaotic", "Complex", "Complicated", "Clear"], + }, + DATA: { + name: "Data", + stages: ["Unmodelled", "Divergent", "Convergent", "Modelled"], + }, + DECISION_DRIVERS: { + name: "Decision Drivers", + stages: [ + "Heritage / culture", + "Analyses & synthesis", + "Analyses & synthesis", + "Previous Experience", + ], + }, + EFFICIENCY: { + name: "Efficiency", + stages: [ + "Reducing the cost of change (experimentation)", + "Reducing cost of waste (Learning)", + "Reducing cost of waste (Learning)", + "Reducing cost of deviation (Volume)", + ], + }, + FAILURE: { + name: "Failure", + stages: [ + "High / tolerated / assumed to be wrong", + "Moderate / unsurprising if wrong but disappointed", + "Not tolerated / focus on constant improvement / assumed to be in the right direction / resistance to changing the model", + "Surprised by failure / focus on operational efficiency", + ], + }, + FOCUS_OF_VALUE: { + name: "Focus Of Value", + stages: [ + "High future worth but immediate investment", + "Seeking ways to profit and a ROI / seeking confirmation of value", + "High profitability per unit / a valuable model / a feeling of understanding / focus on exploitation", + "High volume / reducing margin / important but invisible / an essential component of something more complex", + ], + }, + ACTIVITIES: { + name: "Activities", + stages: [ + "Genesis", + "Custom", + "Product (+rental)", + "Commodity (+utility)", + ], + }, + KNOWLEDGE_MANAGEMENT: { + name: "Knowledge Management", + stages: [ + "Uncertain", + "Learning on use / focused on testing prediction", + "Learning on operation / using prediction / verification", + "Known / accepted", + ], + }, + KNOWLEDGE: { + name: "Knowledge", + stages: ["Concept", "Hypothesis", "Theory", "Accepted"], + }, + MARKET_ACTION: { + name: "Market Action", + stages: [ + "Gambling / driven by gut", + 'Exploring a "found" value', + "Market analysis / listening to customers", + "Metric driven / build what is needed", + ], + }, + MARKET_PERCEPTION: { + name: "Market Perception", + stages: [ + 'Chaotic (non-linear) / domain of the "crazy"', + 'Domain of "experts"', + 'Increasing expectation of use / domain of "professionals"', + "Ordered (appearance of being linear) / trivial / formula to be applied", + ], + }, + MARKET: { + name: "Market", + stages: [ + "Undefined Market", + "Forming Market / an array of competing forms and models of understanding", + "Growing Market / consolidation to a few competing but more accepted forms", + "Mature Market / stabilised to an accepted form", + ], + }, + PERCEPTION_IN_INDUSTRY: { + name: "Perception In Industry", + stages: [ + "Future source of competitive advantage / unpredictable / unknown", + "Seen as a scompetitive advantage / a differential / ROI / case examples", + "Advantage through implementation / features / this model is better than that", + "Cost of doing business / accepted / specific defined models", + ], + }, + EVOLUTION_STAGE: { + name: "Evolution Stage", + stages: ["Stage I", "Stage II", "Stage III", "Stage IV"], + }, + PRACTICE: { + name: "Practice", + stages: ["Novel", "Emerging", "Good", "Best"], + }, + PUBLICATION_TYPES: { + name: "Publication Types", + stages: [ + "Describe the wonder of the thing / the discovery of some marvel / a new land / an unknown frontier", + "Focused on build / construct / awareness and learning / many models of explanation / no accepted forms / a wild west", + "Maintenance / operations / installation / comparison between competing forms / feature analysis", + "Focused on use / increasingly an accepted, almost invisible component", + ], + }, + UBIQUITY: { + name: "Ubiquity", + stages: [ + "Rare", + "Slowly Increasing", + "Rapidly Increasing", + "Widespread in the applicable market / ecosystem", + ], + }, + UNDERSTANDING: { + name: "Understanding", + stages: [ + "Poorly Understood / unpredictable", + "Increasing understanding / development of measures", + "Increasing education / constant refinement of needs / measures", + "Believed to be well defined / stable / measurable", + ], + }, + USER_PERCEPTION: { + name: "User Perception", + stages: [ + "Different / confusing / exciting / surprising / dangerous", + "Leading edge / emerging / unceirtanty over results", + "Increasingly common / disappointed if not used or available / feeling left behind", + "Standard / expected / feeling of shock if not used", + ], + }, +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..8f6458e --- /dev/null +++ b/src/utils.js @@ -0,0 +1,50 @@ +const internals = { + kDefaultStages: [25, 50, 75], +}; + +/** + * Convert percentage to pixel size + * @param {number} percentage - Percentage value (can be negative) + * @param {number} dimension - Dimension in pixels + * @returns {number} Pixel value + */ +export function percentToPixel(percentage, dimension) { + return (percentage * dimension) / 100; +} + +/** + * Get stage values from parsed map or use defaults + * @param {Array} stageOverrides - Stage overrides from parsed map + * @returns {Array<number>} Array of 3 stage values + */ +export function getStageValues(stageOverrides) { + const stages = [...internals.kDefaultStages]; + + if (stageOverrides && stageOverrides.length > 0) { + stageOverrides.forEach((override) => { + const index = { i: 0, ii: 1, iii: 2 }[override.stage]; + if (index !== undefined) { + stages[index] = override.value; + } + }); + } + + return stages; +} + +/** + * Creates a render-friendly component map. + * @param {Array<Object>} The list of components + * @param {number} mapWidth - Map width in pixels + * @param {number} mapHeight - Map height in pixels + * @returns {Map} Pixel value + */ +export function createComponentMap(components, width, height) { + return components.reduce((map, current) => { + map[current.label.toLowerCase()] = [ + percentToPixel(current.coordinates[0], width), + percentToPixel(current.coordinates[1], height), + ]; + return map; + }, {}); +} |