UNPKG

openlime

Version:
1,564 lines (1,433 loc) 836 kB
// ########################################## // OpenLIME - Open Layered IMage Explorer // Author: CNR ISTI - Visual Computing Lab // Author: CRS4 Visual and Data-intensive Computing Group // openlime v1.2.6 - GPL-3.0 License // Documentation: https://cnr-isti-vclab.github.io/openlime/ // Repository: https://github.com/cnr-isti-vclab/openlime.git // ########################################## 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); // 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 Math.max(0, this.xHigh - this.xLow); } /** * Calculates the height of the bounding box. * @returns {number} The difference between yHigh and yLow */ height() { return Math.max(0, this.yHigh - this.yLow); } /** * Calculates the area of the bounding box. * @returns {number} The area (width × height) */ area() { return this.width() * this.height(); } /** * 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) { if (!box || box.isEmpty() || this.isEmpty()) { return false; } return ( this.xLow <= box.xHigh && this.xHigh >= box.xLow && this.yLow <= box.yHigh && this.yHigh >= box.yLow ); } /** * Calculates the intersection of this bounding box with another box. * @param {BoundingBox} box - The other bounding box * @returns {BoundingBox|null} A new bounding box representing the intersection, or null if there is no intersection */ intersection(box) { if (!this.intersects(box)) { return null; } return new BoundingBox({ xLow: Math.max(this.xLow, box.xLow), yLow: Math.max(this.yLow, box.yLow), xHigh: Math.min(this.xHigh, box.xHigh), yHigh: Math.min(this.yHigh, box.yHigh) }); } /** * Creates a clone of this bounding box. * @returns {BoundingBox} A new BoundingBox instance with the same coordinates */ clone() { return new BoundingBox({ xLow: this.xLow, yLow: this.yLow, xHigh: this.xHigh, yHigh: this.yHigh }); } /** * Checks if a point is contained within this bounding box. * A point is considered inside if its coordinates are greater than or equal to * the low corner and less than or equal to the high corner. * * @param {{x: number, y: number}} p - The point to check * @param {number} [epsilon=0] - Optional tolerance value for boundary checks * @returns {boolean} True if the point is inside the box, false otherwise * * @example * // Check if a point is inside a box * const box = new BoundingBox({xLow: 0, yLow: 0, xHigh: 10, yHigh: 10}); * const point = {x: 5, y: 5}; * const isInside = box.containsPoint(point); // true * * // Using epsilon tolerance for boundary cases * const boundaryPoint = {x: 10.001, y: 10}; * const isInsideWithTolerance = box.containsPoint(boundaryPoint, 0.01); // true */ containsPoint(p, epsilon = 0) { if (this.isEmpty()) { return false; } return ( p.x >= this.xLow - epsilon && p.x <= this.xHigh + epsilon && p.y >= this.yLow - epsilon && p.y <= this.yHigh + epsilon ); }; /** * 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; } /** * Checks if the transform has reached its target state for animation * @param {number} currentTime - Current time in milliseconds * @returns {boolean} True if animation is complete (reached target) */ isAtTarget(currentTime) { return currentTime >= this.t; } /** * 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 with isComplete property * @static */ static interpolate(source, target, time, easing) { console.assert(!isNaN(source.x)); console.assert(!isNaN(target.x)); const pos = new Transform(); let dt = (target.t - source.t); // PHASE 1: Before animation starts if (time < source.t) { Object.assign(pos, source); pos.isComplete = false; // FIX: always false before start } // PHASE 2: After animation ends (or duration too short) else if (time > target.t || dt < 0.001) { Object.assign(pos, target); pos.isComplete = false; // FIX: always false before start } // PHASE 3: During animation else { let tt = (time - source.t) / dt; // Apply easing 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; // 'linear' or default: tt remains unchanged } let st = 1 - tt; // Interpolate all values pos.x = st * source.x + tt * target.x; pos.y = st * source.y + tt * target.y; pos.z = st * source.z + tt * target.z; pos.a = st * source.a + tt * target.a; pos.isComplete = false; // FIX: always false during animation } 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); }; } // Tile level x y index ----- tex missing() start/end (tarzoom) ----- time, priority size(byte) /** * @typedef {Object} TileProperties * @property {number} index - Unique identifier for the tile * @property {number[]} bbox - Bounding box coordinates [minX, minY, maxX, maxY] * @property {number} level - Zoom level in the pyramid (for tiled layouts) * @property {number} x - Horizontal grid position * @property {number} y - Vertical grid position * @property {number} w - Tile width (for image layouts) * @property {number} h - Tile height (for image layouts) * @property {number} start - Starting byte position in dataset (for tar-based formats) * @property {number} end - Ending byte position in dataset (for tar-based formats) * @property {WebGLTexture[]} tex - Array of WebGL textures (one per channel) * @property {number} missing - Count of pending channel data requests * @property {number} time - Creation timestamp for cache management * @property {number} priority - Loading priority for cache management * @property {number} size - Total size in bytes for cache management */ /** * * Represents a single tile in an image tiling system. * Tiles are fundamental units used to manage large images through regular grid subdivision. * Supports both traditional pyramid tiling and specialized formats like RTI/BRDF. * * Features: * - Multi-channel texture support * - Cache management properties * - Format-specific byte positioning * - Flexible layout compatibility * - Priority-based loading * * Usage Contexts: * 1. Tiled Layouts: * - Part of zoom level pyramid * - Grid-based positioning (x, y, level) * * 2. Image Layouts: * - Direct image subdivision * - Dimensional specification (w, h) * * 3. Specialized Formats: * - RTI (Reflectance Transformation Imaging) * - BRDF (Bidirectional Reflectance Distribution Function) * - TAR-based formats (tarzoom, itarzoom) * * * Implementation Details * * Property Categories: * * 1. Identification: * ```javascript * { * index: number, // Unique tile ID * bbox: number[], // Spatial bounds * } * ``` * * 2. Positioning: * ```javascript * { * // Tiled Layout Properties * level: number, // Zoom level * x: number, // Grid X * y: number, // Grid Y * * // Image Layout Properties * w: number, // Width * h: number, // Height * } * ``` * * 3. Data Access: * ```javascript * { * start: number, // Byte start * end: number, // Byte end * tex: WebGLTexture[], // Channel textures * missing: number, // Pending channels * } * ``` * * 4. Cache Management: * ```javascript * { * time: number, // Creation time * priority: number, // Load priority * size: number // Memory size * } * ``` * * Format-Specific Considerations: * * 1. Standard Tiling: * - Uses level, x, y for pyramid positioning * - Single texture per tile * * 2. RTI/BRDF: * - Multiple textures per tile (channels) * - Missing counter tracks channel loading * * 3. TAR Formats: * - Uses start/end for byte positioning * - Enables direct data access in archives * * Cache Management: * - time: Used for LRU (Least Recently Used) calculations * - priority: Influences loading order * - size: Helps manage memory constraints */ class Tile { /** * Creates a new Tile instance with default properties * * @example * ```javascript * // Create a basic tile * const tile = new Tile(); * tile.level = 2; * tile.x = 3; * tile.y = 4; * tile.priority = 1; * ``` * * @example * ```javascript * // Create a multi-channel tile * const tile = new Tile(); * tile.tex = new Array(3); // For RGB channels * tile.missing = 3; // Waiting for all channels * ``` */ constructor() { Object.assign(this, { index: null, bbox: null, level: null, //used only in LayoutTiles x: null, y: null, w: null, // used only in LayoutImages h: null, // used only in LayoutImages start: null, end: null, tex: [], missing: null, time: null, priority: null, size: null }); } } /** * Contain functions to pass between different coordinate system. * Here described the coordinate system in sequence * - CanvasHTML: Html coordinates: 0,0 left,top to width height at bottom right (y Down) * - CanvasContext: Same as Html, but scaled by devicePixelRatio (y Down) (required for WebGL, not for SVG) * - Viewport: 0,0 left,bottom to (width,height) at top right (y Up) * - Center: 0,0 at viewport center (y Up) * - Scene: 0,0 at dataset center (y Up). The dataset is placed here through the camera transform * - Layer: 0,0 at Layer center (y Up). Layer is placed over the dataset by the layer transform * - Image: 0,0 at left,top (y Down) * - Layout: 0,0 at left,top (y Down). Depends on layout */ class CoordinateSystem { /** * Transform point from Viewport to CanvasHTML * @param {*} p point in Viewport: 0,0 at left,bottom * @param {Camera} camera Camera which contains viewport information * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns point in CanvasHtml: 0,0 left,top */ static fromViewportToCanvasHtml(p, camera, useGL) { const viewport = this.getViewport(camera, useGL); let result = this.invertY(p, viewport); return useGL ? this.scale(result, 1 / window.devicePixelRatio) : result; } /** * Transform point from CanvasHTML to GLViewport * @param {*} p point in CanvasHtml: 0,0 left,top y Down * @param {Camera} camera Camera * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns point in GLViewport: 0,0 left,bottom, scaled by devicePixelRatio */ static fromCanvasHtmlToViewport(p, camera, useGL) { let result = useGL ? this.scale(p, window.devicePixelRatio) : p; const viewport = this.getViewport(camera, useGL); return this.invertY(result, viewport); } /** * Transform a point from Viewport to Layer coordinates * @param {*} p point {x,y} in Viewport (0,0 left,bottom, y Up) * @param {Camera} camera camera * @param {Transform} layerT layer transform * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns point in Layer coordinates (0, 0 at layer center, y Up) */ static fromViewportToLayer(p, camera, layerT, useGL) { // M = InvLayerT * InvCameraT * Tr(-Vw/2, -Vh/2) const cameraT = this.getCurrentTransform(camera, useGL); const invCameraT = cameraT.inverse(); const invLayerT = layerT.inverse(); const v2c = this.getFromViewportToCenterTransform(camera, useGL); const M = v2c.compose(invCameraT.compose(invLayerT)); // First apply v2c, then invCamera, then invLayer return M.apply(p.x, p.y); } /** * Transform a point from Layer to Viewport coordinates * @param {*} p point {x,y} Layer (0,0 at Layer center y Up) * @param {Camera} camera * @param {Transform} layerT layer transform * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns point in viewport coordinates (0,0 at left,bottom y Up) */ static fromLayerToViewport(p, camera, layerT, useGL) { const M = this.getFromLayerToViewportTransform(camera, layerT, useGL); return M.apply(p.x, p.y); } /** * Transform a point from Layer to Center * @param {*} p point {x,y} in Layer coordinates (0,0 at Layer center) * @param {Camera} camera camera * @param {Transform} layerT layer transform * @returns point in Center (0, 0 at glViewport center) coordinates. */ static fromLayerToCenter(p, camera, layerT, useGL) { // M = cameraT * layerT const cameraT = this.getCurrentTransform(camera, useGL); const M = layerT.compose(cameraT); return M.apply(p.x, p.y); } ////////////// CHECKED UP TO HERE //////////////////// /** * Transform a point from Layer to Image coordinates * @param {*} p point {x, y} Layer coordinates (0,0 at Layer center) * @param {*} layerSize {w, h} Size in pixel of the Layer * @returns Point in Image coordinates (0,0 at left,top, y Down) */ static fromLayerToImage(p, layerSize) { // InvertY * Tr(Lw/2, Lh/2) let result = { x: p.x + layerSize.w / 2, y: p.y + layerSize.h / 2 }; return this.invertY(result, layerSize); } /** * Transform a point from CanvasHtml to Scene * @param {*} p point {x, y} in CanvasHtml (0,0 left,top, y Down) * @param {Camera} camera camera * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns Point in Scene coordinates (0,0 at scene center, y Up) */ static fromCanvasHtmlToScene(p, camera, useGL) { // invCameraT * Tr(-Vw/2, -Vh/2) * InvertY * [Scale(devPixRatio)] let result = this.fromCanvasHtmlToViewport(p, camera, useGL); const v2c = this.getFromViewportToCenterTransform(camera, useGL); const invCameraT = this.getCurrentTransform(camera, useGL).inverse(); const M = v2c.compose(invCameraT); return M.apply(result.x, result.y); } /** * Transform a point from Scene to CanvasHtml * @param {*} p point {x, y} Scene coordinates (0,0 at scene center, y Up) * @param {Camera} camera camera * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns Point in CanvasHtml (0,0 left,top, y Down) */ static fromSceneToCanvasHtml(p, camera, useGL) { // invCameraT * Tr(-Vw/2, -Vh/2) * InvertY * [Scale(devPixRatio)] let result = this.fromSceneToViewport(p, camera, useGL); return this.fromViewportToCanvasHtml(result, camera, useGL); } /** * Transform a point from Scene to Viewport * @param {*} p point {x, y} Scene coordinates (0,0 at scene center, y Up) * @param {Camera} camera camera * @param {bool} useGL True to work with WebGL, false for SVG. When true, it uses devPixelRatio scale * @returns Point in Viewport (0,0 left,bottom, y Up) */ static fromSceneToViewport(p, camera, useGL) { // FromCenterToViewport * CamT const c2v = this.getFromViewportToCenterTransform(camera, useGL).inverse(); const CameraT = this.getCurrentTransform(camera, useGL); const M = CameraT.compose(c2v); return M.apply(p.x, p.y); } /** * Transform a point from Scene to Viewport, using given transform and viewport * @param {*} p point {x, y} Scene coordinates (0,0 at scene center, y Up) * @param {Transform} cameraT camera transform * @param {*} viewport viewport {x,y,dx,dy,w,h} * @returns Point in Viewport (0,0 left,bottom, y Up) */ static fromSceneToViewportNoCamera(p, cameraT, viewport) { // invCameraT * Tr(-Vw/2, -Vh/2) * InvertY * [Scale(devPixRatio)]