UNPKG

openlime

Version:
1,420 lines (1,297 loc) 727 kB
// ########################################## // OpenLIME - Open Layered IMage Explorer // Author: CNR ISTI - Visual Computing Lab // Author: CRS4 Visual and Data-intensive Computing Group // openlime v1.0.1 - GPL-3.0 License // Documentation: https://cnr-isti-vclab.github.io/openlime/ // Repository: https://github.com/cnr-isti-vclab/openlime.git // ########################################## (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.OpenLIME = global.OpenLIME || {})); })(this, (function (exports) { 'use strict'; // HELPERS window.structuredClone = typeof (structuredClone) == "function" ? structuredClone : function (value) { return JSON.parse(JSON.stringify(value)); }; /** * Utility class providing various helper functions for OpenLIME. * Includes methods for SVG manipulation, file loading, image processing, and string handling. * * * @static */ class Util { /** * Pads a number with leading zeros * @param {number} num - Number to pad * @param {number} size - Desired string length * @returns {string} Zero-padded number string * * @example * ```javascript * Util.padZeros(42, 5); // Returns "00042" * ``` */ static padZeros(num, size) { return num.toString().padStart(size, '0'); } /** * Prints source code with line numbers * Useful for shader debugging * @param {string} str - Source code to print * @private */ static printSrcCode(str) { let result = ''; str.split(/\r\n|\r|\n/).forEach((line, i) => { const nline = Util.padZeros(i + 1, 5); result += `${nline} ${line}\n`; }); console.log(result); } /** * Creates an SVG element with optional attributes * @param {string} tag - SVG element tag name * @param {Object} [attributes] - Key-value pairs of attributes * @returns {SVGElement} Created SVG element * * @example * ```javascript * const circle = Util.createSVGElement('circle', { * cx: '50', * cy: '50', * r: '40' * }); * ``` */ static createSVGElement(tag, attributes) { const e = document.createElementNS('http://www.w3.org/2000/svg', tag); if (attributes) for (const [key, value] of Object.entries(attributes)) e.setAttribute(key, value); return e; } /** * Parses SVG string into DOM element * @param {string} text - SVG content string * @returns {SVGElement} Parsed SVG element * @throws {Error} If parsing fails */ static SVGFromString(text) { const parser = new DOMParser(); return parser.parseFromString(text, "image/svg+xml").documentElement; } /** * Loads SVG file from URL * @param {string} url - URL to SVG file * @returns {Promise<SVGElement>} Loaded and parsed SVG * @throws {Error} If fetch fails or content isn't SVG * * @example * ```javascript * const svg = await Util.loadSVG('icons/icon.svg'); * document.body.appendChild(svg); * ``` */ static async loadSVG(url) { let response = await fetch(url); if (!response.ok) { const message = `An error has occured: ${response.status}`; throw new Error(message); } let data = await response.text(); let result = null; if (Util.isSVGString(data)) { result = Util.SVGFromString(data); } else { const message = `${url} is not an SVG file`; throw new Error(message); } return result; }; /** * Loads HTML content from URL * @param {string} url - URL to HTML file * @returns {Promise<string>} HTML content * @throws {Error} If fetch fails */ static async loadHTML(url) { let response = await fetch(url); if (!response.ok) { const message = `An error has occured: ${response.status}`; throw new Error(message); } let data = await response.text(); return data; }; /** * Loads and parses JSON from URL * @param {string} url - URL to JSON file * @returns {Promise<Object>} Parsed JSON data * @throws {Error} If fetch or parsing fails */ static async loadJSON(url) { let response = await fetch(url); if (!response.ok) { const message = `An error has occured: ${response.status}`; throw new Error(message); } let data = await response.json(); return data; } /** * Loads image from URL * @param {string} url - Image URL * @returns {Promise<HTMLImageElement>} Loaded image * @throws {Error} If image loading fails */ static async loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.addEventListener('load', () => resolve(img)); img.addEventListener('error', (err) => reject(err)); img.src = url; }); } /** * Appends loaded image to container * @param {HTMLElement} container - Target container * @param {string} url - Image URL * @param {string} [imgClass] - Optional CSS class * @returns {Promise<void>} */ static async appendImg(container, url, imgClass = null) { const img = await Util.loadImage(url); if (imgClass) img.classList.add(imgClass); container.appendChild(img); return img; } /** * Appends multiple images to container * @param {HTMLElement} container - Target container * @param {string[]} urls - Array of image URLs * @param {string} [imgClass] - Optional CSS class * @returns {Promise<void>} */ static async appendImgs(container, urls, imgClass = null) { for (const u of urls) { const img = await Util.loadImage(u); if (imgClass) img.classList.add(imgClass); container.appendChild(img); } } /** * Tests if string is valid SVG content * @param {string} input - String to test * @returns {boolean} True if string is valid SVG */ static isSVGString(input) { const regex = /^\s*(?:<\?xml[^>]*>\s*)?(?:<!doctype svg[^>]*\s*(?:\[?(?:\s*<![^>]*>\s*)*\]?)*[^>]*>\s*)?(?:<svg[^>]*>[^]*<\/svg>|<svg[^/>]*\/\s*>)\s*$/i; if (input == undefined || input == null) return false; input = input.toString().replace(/\s*<!Entity\s+\S*\s*(?:"|')[^"]+(?:"|')\s*>/img, ''); input = input.replace(/<!--([\s\S]*?)-->/g, ''); return Boolean(input) && regex.test(input); } /** * Computes Signed Distance Field from image data * Implementation based on Felzenszwalb & Huttenlocher algorithm * * @param {Uint8Array} buffer - Input image data * @param {number} w - Image width * @param {number} h - Image height * @param {number} [cutoff=0.25] - Distance field cutoff * @param {number} [radius=8] - Maximum distance to compute * @returns {Float32Array|Array} Computed distance field * * Technical Details: * - Uses 2D Euclidean distance transform * - Separate inner/outer distance fields * - Optimized grid computation * - Sub-pixel accuracy */ static computeSDF(buffer, w, h, cutoff = 0.25, radius = 8) { // 2D Euclidean distance transform by Felzenszwalb & Huttenlocher https://cs.brown.edu/~pff/dt/ function edt(data, width, height, f, d, v, z) { for (let x = 0; x < width; x++) { for (let y = 0; y < height; y++) { f[y] = data[y * width + x]; } edt1d(f, d, v, z, height); for (let y = 0; y < height; y++) { data[y * width + x] = d[y]; } } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { f[x] = data[y * width + x]; } edt1d(f, d, v, z, width); for (let x = 0; x < width; x++) { data[y * width + x] = Math.sqrt(d[x]); } } } // 1D squared distance transform function edt1d(f, d, v, z, n) { v[0] = 0; z[0] = -INF; z[1] = +INF; for (let q = 1, k = 0; q < n; q++) { var s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); while (s <= z[k]) { k--; s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); } k++; v[k] = q; z[k] = s; z[k + 1] = +INF; } for (let q = 0, k = 0; q < n; q++) { while (z[k + 1] < q) k++; d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; } } var data = new Uint8ClampedArray(buffer); const INF = 1e20; const size = Math.max(w, h); // temporary arrays for the distance transform const gridOuter = Array(w * h); const gridInner = Array(w * h); const f = Array(size); const d = Array(size); const z = Array(size + 1); const v = Array(size); for (let i = 0; i < w * h; i++) { var a = data[i] / 255.0; gridOuter[i] = a === 1 ? 0 : a === 0 ? INF : Math.pow(Math.max(0, 0.5 - a), 2); gridInner[i] = a === 1 ? INF : a === 0 ? 0 : Math.pow(Math.max(0, a - 0.5), 2); } edt(gridOuter, w, h, f, d, v, z); edt(gridInner, w, h, f, d, v, z); const dist = window.Float32Array ? new Float32Array(w * h) : new Array(w * h); for (let i = 0; i < w * h; i++) { dist[i] = Math.min(Math.max(1 - ((gridOuter[i] - gridInner[i]) / radius + cutoff), 0), 1); } return dist; } /** * Rasterizes SVG to ImageData * @param {string} url - SVG URL * @param {number[]} [size=[64,64]] - Output dimensions [width, height] * @returns {Promise<ImageData>} Rasterized image data * * Processing steps: * 1. Loads SVG file * 2. Sets up canvas context * 3. Handles aspect ratio * 4. Centers image * 5. Renders to ImageData * * @example * ```javascript * const imageData = await Util.rasterizeSVG('icon.svg', [128, 128]); * context.putImageData(imageData, 0, 0); * ``` */ static async rasterizeSVG(url, size = [64, 64]) { const svg = await Util.loadSVG(url); const svgWidth = svg.getAttribute('width'); const svgHeight = svg.getAttribute('height'); const canvas = document.createElement("canvas"); canvas.width = size[0]; canvas.height = size[1]; svg.setAttributeNS(null, 'width', `100%`); svg.setAttributeNS(null, 'height', `100%`); const ctx = canvas.getContext("2d"); const data = (new XMLSerializer()).serializeToString(svg); const DOMURL = window.URL || window.webkitURL || window; const img = new Image(); const svgBlob = new Blob([data], { type: 'image/svg+xml;charset=utf-8' }); const svgurl = DOMURL.createObjectURL(svgBlob); img.src = svgurl; return new Promise((resolve, reject) => { img.onload = () => { const aCanvas = size[0] / size[1]; const aSvg = svgWidth / svgHeight; let wSvg = 0; let hSvg = 0; if (aSvg < aCanvas) { hSvg = size[1]; wSvg = hSvg * aSvg; } else { wSvg = size[0]; hSvg = wSvg / aSvg; } let dy = (size[1] - hSvg) * 0.5; let dx = (size[0] - wSvg) * 0.5; ctx.translate(dx, dy); ctx.drawImage(img, 0, 0); DOMURL.revokeObjectURL(svgurl); const imageData = ctx.getImageData(0, 0, size[0], size[1]); // const imgURI = canvas // .toDataURL('image/png') // .replace('image/png', 'image/octet-stream'); // console.log(imgURI); resolve(imageData); }; img.onerror = (e) => reject(e); }); } } /** * Represents an axis-aligned rectangular bounding box that can be wrapped tightly around geometric elements. * The box is defined by two opposite vertices (low and high corners) and provides a comprehensive set of * utility methods for manipulating and analyzing bounding boxes. */ class BoundingBox { /** * Creates a new BoundingBox instance. * @param {Object} [options] - Configuration options for the bounding box * @param {number} [options.xLow=1e20] - X coordinate of the lower corner * @param {number} [options.yLow=1e20] - Y coordinate of the lower corner * @param {number} [options.xHigh=-1e20] - X coordinate of the upper corner * @param {number} [options.yHigh=-1e20] - Y coordinate of the upper corner */ constructor(options) { Object.assign(this, { xLow: 1e20, yLow: 1e20, xHigh: -1e20, yHigh: -1e20 }); Object.assign(this, options); } /** * Initializes the bounding box from an array of coordinates. * @param {number[]} x - Array containing coordinates in order [xLow, yLow, xHigh, yHigh] */ fromArray(x) { this.xLow = x[0]; this.yLow = x[1]; this.xHigh = x[2]; this.yHigh = x[3]; } /** * Resets the bounding box to an empty state by setting coordinates to extreme values. */ toEmpty() { this.xLow = 1e20; this.yLow = 1e20; this.xHigh = -1e20; this.yHigh = -1e20; } /** * Checks if the bounding box is empty (has no valid area). * A box is considered empty if its low corner coordinates are greater than its high corner coordinates. * @returns {boolean} True if the box is empty, false otherwise */ isEmpty() { return this.xLow > this.xHigh || this.yLow > this.yHigh; } /** * Converts the bounding box coordinates to an array. * @returns {number[]} Array of coordinates in order [xLow, yLow, xHigh, yHigh] */ toArray() { return [this.xLow, this.yLow, this.xHigh, this.yHigh]; } /** * Creates a space-separated string representation of the bounding box coordinates. * @returns {string} String in format "xLow yLow xHigh yHigh" */ toString() { return this.xLow.toString() + " " + this.yLow.toString() + " " + this.xHigh.toString() + " " + this.yHigh.toString(); } /** * Enlarges this bounding box to include another bounding box. * If this box is empty, it will adopt the dimensions of the input box. * If the input box is null, no changes are made. * @param {BoundingBox|null} box - The bounding box to merge with this one */ mergeBox(box) { if (box == null) return; if (this.isEmpty()) Object.assign(this, box); else { this.xLow = Math.min(this.xLow, box.xLow); this.yLow = Math.min(this.yLow, box.yLow); this.xHigh = Math.max(this.xHigh, box.xHigh); this.yHigh = Math.max(this.yHigh, box.yHigh); } } /** * Enlarges this bounding box to include a point. * @param {{x: number, y: number}} p - The point to include in the bounding box */ mergePoint(p) { this.xLow = Math.min(this.xLow, p.x); this.yLow = Math.min(this.yLow, p.y); this.xHigh = Math.max(this.xHigh, p.x); this.yHigh = Math.max(this.yHigh, p.y); } /** * Moves the bounding box by the specified displacement. * @param {number} dx - Displacement along the x-axis * @param {number} dy - Displacement along the y-axis */ shift(dx, dy) { this.xLow += dx; this.yLow += dy; this.xHigh += dx; this.yHigh += dy; } /** * Quantizes the bounding box coordinates by dividing by a specified value and rounding down. * This creates a grid-aligned bounding box. * @param {number} side - The value to divide coordinates by */ quantize(side) { this.xLow = Math.floor(this.xLow / side); this.yLow = Math.floor(this.yLow / side); this.xHigh = Math.floor((this.xHigh - 1) / side) + 1; this.yHigh = Math.floor((this.yHigh - 1) / side) + 1; } /** * Calculates the width of the bounding box. * @returns {number} The difference between xHigh and xLow */ width() { return this.xHigh - this.xLow; } /** * Calculates the height of the bounding box. * @returns {number} The difference between yHigh and yLow */ height() { return this.yHigh - this.yLow; } /** * Calculates the center point of the bounding box. * @returns {{x: number, y: number}} The coordinates of the center point */ center() { return { x: (this.xLow + this.xHigh) / 2, y: (this.yLow + this.yHigh) / 2 }; } /** * Gets the coordinates of a specific corner of the bounding box. * @param {number} i - Corner index (0: bottom-left, 1: bottom-right, 2: top-left, 3: top-right) * @returns {{x: number, y: number}} The coordinates of the specified corner */ corner(i) { // To avoid the switch let v = this.toArray(); return { x: v[0 + (i & 0x1) << 1], y: v[1 + (i & 0x2)] }; } /** * Checks if this bounding box intersects with another bounding box. * @param {BoundingBox} box - The other bounding box to check intersection with * @returns {boolean} True if the boxes intersect, false otherwise */ intersects(box) { return xLow <= box.xHigh && xHigh >= box.xLow && yLow <= box.yHigh && yHigh >= box.yLow; } /** * Prints the bounding box coordinates to the console in a formatted string. * Output format: "BOX=xLow, yLow, xHigh, yHigh" with values rounded to 2 decimal places */ print() { console.log("BOX=" + this.xLow.toFixed(2) + ", " + this.yLow.toFixed(2) + ", " + this.xHigh.toFixed(2) + ", " + this.yHigh.toFixed(2)); } } /** * @typedef {Array<number>} APoint * A tuple of [x, y] representing a 2D point. * @property {number} 0 - X coordinate * @property {number} 1 - Y coordinate * * @example * ```javascript * const point: APoint = [10, 20]; // [x, y] * const x = point[0]; // x coordinate * const y = point[1]; // y coordinate * ``` */ /** * @typedef {Object} Point * Object representation of a 2D point * @property {number} x - X coordinate * @property {number} y - Y coordinate */ /** * @typedef {Object} TransformParameters * @property {number} [x=0] - X translation component * @property {number} [y=0] - Y translation component * @property {number} [a=0] - Rotation angle in degrees * @property {number} [z=1] - Scale factor * @property {number} [t=0] - Timestamp for animations */ /** * @typedef {'linear'|'ease-out'|'ease-in-out'} EasingFunction * Animation easing function type */ /** * * Implements a 2D affine transformation system for coordinate manipulation. * Provides a complete set of operations for coordinate system conversions, * camera transformations, and animation support. * * Mathematical Model: * Transformation of point P to P' follows the equation: * * P' = z * R(a) * P + T * * where: * - z: scale factor * - R(a): rotation matrix for angle 'a' * - T: translation vector (x,y) * * Key Features: * - Full affine transformation support * - Camera positioning utilities * - Animation interpolation * - Viewport projection * - Coordinate system conversions * - Bounding box transformations * * * Coordinate Systems and Transformations: * * 1. Scene Space: * - Origin at image center * - Y-axis points up * - Unit scale * * 2. Viewport Space: * - Origin at top-left * - Y-axis points down * - Pixel units [0..w-1, 0..h-1] * * 3. WebGL Space: * - Origin at center * - Y-axis points up * - Range [-1..1, -1..1] * * Transform Pipeline: * ``` * Scene -> Transform -> Viewport -> WebGL * ``` * * Animation System: * - Time-based interpolation * - Multiple easing functions * - Smooth transitions * * Performance Considerations: * - Matrix operations optimized for 2D * - Cached transformation results * - Efficient composition */ class Transform { //FIXME Add translation to P? /** * Creates a new Transform instance * @param {TransformParameters} [options] - Transform configuration * * @example * ```javascript * // Create identity transform * const t1 = new Transform(); * * // Create custom transform * const t2 = new Transform({ * x: 100, // Translate 100 units in x * y: 50, // Translate 50 units in y * a: 45, // Rotate 45 degrees * z: 2 // Scale by factor of 2 * }); * ``` */ constructor(options) { Object.assign(this, { x: 0, y: 0, z: 1, a: 0, t: 0 }); if (!this.t) this.t = performance.now(); if (typeof (options) == 'object') Object.assign(this, options); } /** * Creates a deep copy of the transform * @returns {Transform} New transform with identical parameters */ copy() { let transform = new Transform(); Object.assign(transform, this); return transform; } /** * Applies transform to a point (x,y) * Performs full affine transformation: scale, rotate, translate * * @param {number} x - X coordinate to transform * @param {number} y - Y coordinate to transform * @returns {Point} Transformed point * * @example * ```javascript * const transform = new Transform({x: 10, y: 20, a: 45, z: 2}); * const result = transform.apply(5, 5); * // Returns rotated, scaled, and translated point * ``` */ apply(x, y) { //TODO! ROTATE let r = Transform.rotate(x, y, this.a); return { x: r.x * this.z + this.x, y: r.y * this.z + this.y } } /** * Computes inverse transformation * Creates transform that undoes this transform's effects * @returns {Transform} Inverse transform */ inverse() { let r = Transform.rotate(this.x / this.z, this.y / this.z, -this.a); return new Transform({ x: -r.x, y: -r.y, z: 1 / this.z, a: -this.a, t: this.t }); } /** * Normalizes angle to range [0, 360] * @param {number} a - Angle in degrees * @returns {number} Normalized angle * @static */ static normalizeAngle(a) { while (a > 360) a -= 360; while (a < 0) a += 360; return a; } /** * Rotates point (x,y) by angle a around Z axis * @param {number} x - X coordinate to rotate * @param {number} y - Y coordinate to rotate * @param {number} a - Rotation angle in degrees * @returns {Point} Rotated point * @static */ static rotate(x, y, a) { a = Math.PI * (a / 180); let ex = Math.cos(a) * x - Math.sin(a) * y; let ey = Math.sin(a) * x + Math.cos(a) * y; return { x: ex, y: ey }; } /** * Composes two transforms: this * transform * Applies this transform first, then the provided transform * * @param {Transform} transform - Transform to compose with * @returns {Transform} Combined transformation * * @example * ```javascript * const t1 = new Transform({x: 10, a: 45}); * const t2 = new Transform({z: 2}); * const combined = t1.compose(t2); * // Results in rotation, then scale, then translation * ``` */ compose(transform) { let a = this.copy(); let b = transform; a.z *= b.z; a.a += b.a; var r = Transform.rotate(a.x, a.y, b.a); a.x = r.x * b.z + b.x; a.y = r.y * b.z + b.y; return a; } /** * Transforms a bounding box through this transform * @param {BoundingBox} box - Box to transform * @returns {BoundingBox} Transformed bounding box */ transformBox(lbox) { let box = new BoundingBox(); for (let i = 0; i < 4; i++) { let c = lbox.corner(i); let p = this.apply(c.x, c.y); box.mergePoint(p); } return box; } /** * Computes viewport bounds in image space * Accounts for coordinate system differences between viewport and image * * @param {Viewport} viewport - Current viewport * @returns {BoundingBox} Bounds in image space */ getInverseBox(viewport) { let inverse = this.inverse(); let corners = [ { x: viewport.x, y: viewport.y }, { x: viewport.x + viewport.dx, y: viewport.y }, { x: viewport.x, y: viewport.y + viewport.dy }, { x: viewport.x + viewport.dx, y: viewport.y + viewport.dy } ]; let box = new BoundingBox(); for (let corner of corners) { let p = inverse.apply(corner.x - viewport.w / 2, -corner.y + viewport.h / 2); box.mergePoint(p); } return box; } /** * Interpolates between two transforms * @param {Transform} source - Starting transform * @param {Transform} target - Ending transform * @param {number} time - Current time for interpolation * @param {EasingFunction} easing - Easing function type * @returns {Transform} Interpolated transform * @static * * @example * ```javascript * const start = new Transform({x: 0, y: 0}); * const end = new Transform({x: 100, y: 100}); * const mid = Transform.interpolate(start, end, 500, 'ease-out'); * ``` */ static interpolate(source, target, time, easing) { //FIXME STATIC console.assert(!isNaN(source.x)); console.assert(!isNaN(target.x)); const pos = new Transform(); let dt = (target.t - source.t); if (time < source.t) { Object.assign(pos, source); } else if (time > target.t || dt < 0.001) { Object.assign(pos, target); } else { let tt = (time - source.t) / dt; switch (easing) { case 'ease-out': tt = 1 - Math.pow(1 - tt, 2); break; case 'ease-in-out': tt = tt < 0.5 ? 2 * tt * tt : 1 - Math.pow(-2 * tt + 2, 2) / 2; break; } let st = 1 - tt; for (let i of ['x', 'y', 'z', 'a']) pos[i] = (st * source[i] + tt * target[i]); } pos.t = time; return pos; } /** * Generates WebGL projection matrix * Combines transform with viewport for rendering * * @param {Viewport} viewport - Current viewport * @returns {number[]} 4x4 projection matrix in column-major order */ projectionMatrix(viewport) { let z = this.z; // In coords with 0 in lower left corner map x0 to -1, and x0+v.w to 1 // In coords with 0 at screen center and x0 at 0, map -v.w/2 -> -1, v.w/2 -> 1 // With x0 != 0: x0 -> x0-v.w/2 -> -1, and x0+dx -> x0+v.dx-v.w/2 -> 1 // Where dx is viewport width, while w is window width //0, 0 <-> viewport.x + viewport.dx/2 (if x, y = let zx = 2 / viewport.dx; let zy = 2 / viewport.dy; let dx = zx * this.x + (2 / viewport.dx) * (viewport.w / 2 - viewport.x) - 1; let dy = zy * this.y + (2 / viewport.dy) * (viewport.h / 2 - viewport.y) - 1; let a = Math.PI * this.a / 180; let matrix = [ Math.cos(a) * zx * z, Math.sin(a) * zy * z, 0, 0, -Math.sin(a) * zx * z, Math.cos(a) * zy * z, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1]; return matrix; } /** * Converts scene coordinates to viewport coordinates * @param {Viewport} viewport - Current viewport * @param {APoint} p - Point in scene space * @returns {APoint} Point in viewport space [0..w-1, 0..h-1] */ sceneToViewportCoords(viewport, p) { //FIXME Point is an array, but in other places it is an Object... return [p[0] * this.z + this.x - viewport.x + viewport.w / 2, p[1] * this.z - this.y + viewport.y + viewport.h / 2]; } /** * Converts viewport coordinates to scene coordinates * @param {Viewport} viewport - Current viewport * @param {APoint} p - Point in viewport space [0..w-1, 0..h-1] * @returns {APoint} Point in scene space */ viewportToSceneCoords(viewport, p) { return [(p[0] + viewport.x - viewport.w / 2 - this.x) / this.z, (p[1] - viewport.y - viewport.h / 2 + this.y) / this.z]; } /** * Prints transform parameters for debugging * @param {string} [str=""] - Prefix string * @param {number} [precision=0] - Decimal precision */ print(str = "", precision = 0) { const p = precision; console.log(str + " x:" + this.x.toFixed(p) + ", y:" + this.y.toFixed(p) + ", z:" + this.z.toFixed(p) + ", a:" + this.a.toFixed(p) + ", t:" + this.t.toFixed(p)); } } /** * @typedef {Object} SignalHandler * @property {Object.<string, Function[]>} signals - Map of event names to arrays of callback functions * @property {string[]} allSignals - List of all registered signal names */ /** * Adds event handling capabilities to a prototype. * Creates a simple event system that allows objects to emit and listen to events. * * The function modifies the prototype by adding: * - Event registration methods * - Event emission methods * - Signal initialization * - Signal storage * * * Implementation Details * * The signal system works by: * 1. Extending the prototype with signal tracking properties * 2. Maintaining arrays of callbacks for each signal type * 3. Providing methods to register and trigger callbacks * * Signal Storage Structure: * ```javascript * { * signals: { * 'eventName1': [callback1, callback2, ...], * 'eventName2': [callback3, callback4, ...] * }, * allSignals: ['eventName1', 'eventName2', ...] * } * ``` * * Performance Considerations: * - Callbacks are stored in arrays for fast iteration * - Signals are initialized lazily on first use * - Direct property access for quick event emission * * Usage Notes: * - Events must be registered before they can be used * - Multiple callbacks can be registered for the same event * - Callbacks are executed synchronously * - Parameters are passed through to callbacks unchanged * * @function * @param {Object} proto - The prototype to enhance with signal capabilities * @param {...string} signals - Names of signals to register * * @example * ```javascript * // Add events to a class * class MyClass {} * addSignals(MyClass, 'update', 'change'); * * // Use events * const obj = new MyClass(); * obj.addEvent('update', () => console.log('Updated!')); * obj.emit('update'); * ``` * * @example * ```javascript * // Multiple signals * class DataHandler {} * addSignals(DataHandler, * 'dataLoaded', * 'dataProcessed', * 'error' * ); * * const handler = new DataHandler(); * handler.addEvent('dataLoaded', (data) => { * console.log('Data loaded:', data); * }); * ``` */ function addSignals(proto, ...signals) { proto.prototype.allSignals ??= []; proto.prototype.allSignals = [...proto.prototype.allSignals, ...signals]; /** * Methods added to the prototype */ /** * Initializes the signals system for an instance. * Creates the signals storage object and populates it with empty arrays * for each registered signal type. * * @memberof SignalHandler * @instance * @private */ proto.prototype.initSignals = function () { // Use nullish coalescing for signal initialization this.signals ??= Object.fromEntries(this.allSignals.map(s => [s, []])); }; /** * Registers a callback function for a specific event. * * @memberof SignalHandler * @instance * @param {string} event - The event name to listen for * @param {Function} callback - Function to be called when event is emitted * @throws {Error} Implicitly if event doesn't exist * * @example * ```javascript * obj.addEvent('update', (param1, param2) => { * console.log('Update occurred with:', param1, param2); * }); * ``` */ proto.prototype.addEvent = function (event, callback) { // Use optional chaining for safer access this.signals?.hasOwnProperty(event) || this.initSignals(); this.signals[event].push(callback); }; /** * Adds a one-time event listener that will be automatically removed after first execution. * Once the event is emitted, the listener is automatically removed before the callback * is executed. * * @memberof SignalHandler * @instance * @param {string} event - The event name to listen for once * @param {Function} callback - Function to be called once when event is emitted * @throws {Error} Implicitly if event doesn't exist or callback is not a function * * @example * ```javascript * obj.once('update', (param) => { * console.log('This will only run once:', param); * }); * ``` */ proto.prototype.once = function (event, callback) { if (!callback || typeof callback !== 'function') { console.error('Callback must be a function'); return; } const wrappedCallback = (...args) => { // Remove the listener before calling the callback // to prevent recursion if the callback emits the same event this.removeEvent(event, wrappedCallback); callback.apply(this, args); }; this.addEvent(event, wrappedCallback); }; /** * Removes an event callback or all callbacks for a specific event. * If no callback is provided, all callbacks for the event are removed. * If a callback is provided, only that specific callback is removed. * * @memberof SignalHandler * @instance * @param {string} event - The event name to remove callback(s) from * @param {Function} [callback] - Optional specific callback function to remove * @returns {boolean} True if callback(s) were removed, false if event or callback not found * @throws {Error} Implicitly if event doesn't exist * * @example * ```javascript * // Remove specific callback * const callback = (data) => console.log(data); * obj.addEvent('update', callback); * obj.removeEvent('update', callback); * * // Remove all callbacks for an event * obj.removeEvent('update'); * ``` */ proto.prototype.removeEvent = function (event, callback) { if (!this.signals) { this.initSignals(); return false; } if (!this.signals[event]) { return false; } if (callback === undefined) { // Remove all callbacks for this event const hadCallbacks = this.signals[event].length > 0; this.signals[event] = []; return hadCallbacks; } // Find and remove specific callback const initialLength = this.signals[event].length; this.signals[event] = this.signals[event].filter(cb => cb !== callback); return initialLength > this.signals[event].length; }; /** * Emits an event, triggering all registered callbacks. * Callbacks are executed in the order they were registered. * Creates a copy of the callbacks array before iteration to prevent * issues if callbacks modify the listeners during emission. * * @memberof SignalHandler * @instance * @param {string} event - The event name to emit * @param {...*} parameters - Parameters to pass to the callback functions * * @example * ```javascript * obj.emit('update', 'param1', 42); * ``` */ proto.prototype.emit = function (event, ...parameters) { if (!this.signals) this.initSignals(); // Create a copy of the callbacks array to safely iterate even if // callbacks modify the listeners const callbacks = [...this.signals[event]]; for (let r of callbacks) r(...parameters); }; } /** * Defines a rectangular viewing region inside a canvas area. * @typedef {Object} Viewport * @property {number} x - X-coordinate of the lower-left corner * @property {number} y - Y-coordinate of the lower-left corner * @property {number} dx - Width of the viewport * @property {number} dy - Height of the viewport * @property {number} w - Total canvas width * @property {number} h - Total canvas height */ /** * Camera class that manages viewport parameters and camera transformations. * Acts as a container for parameters needed to define the viewport and camera position, * supporting smooth animations between positions using source and target transforms. * * The camera maintains two Transform objects: * - source: represents current position * - target: represents destination position * * Animation between positions is handled automatically by the OpenLIME system * unless manually interrupted by user input. */ class Camera { /** * Creates a new Camera instance. * @param {Object} [options] - Configuration options * @param {boolean} [options.bounded=true] - Whether to limit camera translation to scene boundaries * @param {number} [options.maxFixedZoom=2] - Maximum allowed pixel size * @param {number} [options.minScreenFraction=1] - Minimum portion of screen to show when zoomed in * @param {Transform} [options.target] - Initial target transform * @fires Camera#update */ constructor(options) { Object.assign(this, { viewport: null, bounded: true, minScreenFraction: 1, maxFixedZoom: 2, maxZoom: 2, minZoom: 1, boundingBox: new BoundingBox, }); Object.assign(this, options); this.target = new Transform(this.target); this.source = this.target.copy(); this.easing = 'linear'; } /** * Creates a deep copy of the camera instance. * @returns {Camera} A new Camera instance with copied properties */ copy() { let camera = new Camera(); Object.assign(camera, this); return camera; } /** * Updates the viewport while maintaining the camera position as close as possible to the previous one. * @param {Viewport} view - The new viewport in CSS coordinates */ setViewport(view) { if (this.viewport) { let rz = Math.sqrt((view.w / this.viewport.w) * (view.h / this.viewport.h)); this.viewport = view; const { x, y, z, a } = this.target; this.setPosition(0, x, y, z * rz, a); } else { this.viewport = view; } } /** * Returns the current viewport in device coordinates (accounting for device pixel ratio). * @returns {Viewport} The current viewport scaled for device pixels */ glViewport() { let d = window.devicePixelRatio; let viewport = {}; for (let i in this.viewport) viewport[i] = this.viewport[i] * d; return viewport; } /** * Converts canvas coordinates to scene coordinates using the specified transform. * @param {number} x - X coordinate relative to canvas * @param {number} y - Y coordinate relative to canvas * @param {Transform} transform - Transform to use for conversion * @returns {{x: number, y: number}} Coordinates in scene space relative to viewport center */ mapToScene(x, y, transform) { //compute coords relative to the center of the viewport. x -= this.viewport.w / 2; y -= this.viewport.h / 2; x -= transform.x; y -= transform.y; x /= transform.z; y /= transform.z; let r = Transform.rotate(x, y, -transform.a); return { x: r.x, y: r.y }; } /** * Converts scene coordinates to canvas coordinates using the specified transform. * @param {number} x - X coordinate in scene space * @param {number} y - Y coordinate in scene space * @param {Transform} transform - Transform to use for conversion * @returns {{x: number, y: number}} Coordinates in canvas space */ sceneToCanvas(x, y, transform) { let r = Transform.rotate(x, y, transform.a); x = r.x * transform.z + transform.x - this.viewport.x + this.viewport.w / 2; y = r.y * transform.z - transform.y + this.viewport.y + this.viewport.h / 2; return { x: x, y: y }; } /** * Sets the camera target parameters for a new position. * @param {number} dt - Animation duration in milliseconds * @param {number} x - X component of translation * @param {number} y - Y component of translation * @param {number} z - Zoom factor * @param {number} a - Rotation angle in degrees * @param {string} [easing] - Easing function name for animation * @fires Camera#update */ setPosition(dt, x, y, z, a, easing) { /** * The event is fired when the camera target is changed. * @event Camera#update */ // Discard events due to cursor outside window //if (Math.abs(x) > 64000 || Math.abs(y) > 64000) return; this.easing = easing || this.easing; if (this.bounded) { const sw = this.viewport.dx; const sh = this.viewport.dy; // let xform = new Transform({ x: x, y: y, z: z, a: a, t: 0 }); let tbox = xform.transformBox(this.boundingBox); const bw = tbox.width(); const bh = tbox.height(); // Screen space offset between image boundary and screen boundary // Do not let transform offet go beyond this limit. // if (scaled-image-size < screen) it remains fully contained // else the scaled-image boundary closest to the screen cannot enter the screen. const dx = Math.abs(bw - sw) / 2;// + this.boundingBox.center().x- tbox.center().x; x = Math.min(Math.max(-dx, x), dx); const dy = Math.abs(bh - sh) / 2;// + this.boundingBox.center().y - tbox.center().y; y = Math.min(Math.max(-dy, y), dy); } let now = performance.now(); this.source = this.getCurrentTransform(now); //the angle needs to be interpolated in the shortest direction. //target it is kept between 0 and +360, source is kept relative. a = Transform.normalizeAngle(a); this.source.a = Transform.normalizeAngle(this.source.a); if (a - this.source.a > 180) this.source.a += 360; if (this.source.a - a > 180) this.source.a -= 360; Object.assign(this.target, { x: x, y: y, z: z, a: a, t: now + dt }); console.assert(!isNaN(this.target.x)); this.emit('update'); } /** * Pans the camera by a specified amount in canvas coordinates. * @param {number} dt - Animation duration in milliseconds * @param {number} dx - Horizontal displacement * @param {number} dy - Vertical displacement */ pan(dt, dx, dy) { let now = performance.now(); let m = this.getCurrentTransform(now); m.x += dx; m.y += dy; this.setPosition(dt, m.x, m.y, m.z, m.a); } /** * Zooms the camera to a specific point in canvas coordinates. * @param {number} dt - Animation duration in milliseconds * @param {number} z - Target zoom level * @param {number} [x=0] - X coordinate to zoom towards * @param {number} [y=0] - Y coordinate to zoom towards */ zoom(dt, z, x, y) { if (!x) x = 0; if (!y) y = 0; let now = performance.now(); let m = this.getCurrentTransform(now); if (this.bounded) { z = Math.min(Math.max(z, this.minZoom), this.maxZoom); } //x, an y should be the center of the zoom. m.x += (m.x + x) * (m.z - z) / m.z; m.y += (m.y + y) * (m.z - z) / m.z; this.setPosition(dt, m.x, m.y, z, m.a); } /** * Rotates the camera around its z-axis. * @param {number} dt - Animation duration in milliseconds * @param {number} a - Rotation angle in degrees */ rotate(dt, a) { let now = performance.now(); let m = this.getCurrentTransform(now); this.setPosition(dt, m.x, m.y, m.z, this.target.a + a); } /** * Applies a relative zoom change at a specific point. * @param {number} dt - Animation duration in milliseconds * @param {number} dz - Relative zoom change factor * @param {number} [x=0] - X coordinate to zoom around * @param {number} [y=0] - Y coordinate to zoom around */ deltaZoom(dt, dz, x = 0, y = 0) { let now = performance.now(); let m = this.getCurrentTransform(now); //rapid firing wheel event need to compound. //but the x, y in input are relative to the current transform. dz *= this.target.z / m.z; if (this.bounded) { if (m.z * dz < this.minZoom) dz = this.minZoom / m.z; if (m.z * dz > this.maxZoom) dz = this.maxZoom / m.z; } //transform is x*z + dx = X , there x is positrion in scene, X on screen //we want x*z*dz + dx1 = X (stay put, we need to find dx1. let r = Transform.rotate(x, y, m.a); m.x += r.x * m.z * (1 - dz); m.y += r.y * m.z * (1 - dz); this.setPosition(dt, m.x, m.y, m.z * dz, m.a); } /** * Gets the camera transform at a specific time. * @param {number} time - Current time in milliseconds (from performance.now()) * @returns {Transform} The interpolated transform at the specified time */ getCurrentTransform(time) { if (time > this.target.t) this.easing = 'linear'; return Transform.interpolate(this.source, this.target, time, this.easing); } /** * Gets the camera transform at a specific time in device coordinates. * @param {number} time - Current time in milliseconds (from performance.now()) * @returns {Transform} The interpolated transform scaled for device pixels */ getGlCurrentTransform(time) { const pos = this.getCurrentTransform(time); pos.x *= window.devicePixelRatio; pos.y *= windo