aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/configuration.js176
-rw-r--r--src/image_type.js9
-rw-r--r--src/index.js153
-rw-r--r--src/patterns.js74
-rw-r--r--src/renderer.js776
-rw-r--r--src/smart-label-positioning.js524
-rw-r--r--src/stage_type.js187
-rw-r--r--src/utils.js50
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;
+ }, {});
+}