openlime
Version:
Open Layered IMage Explorer
1,564 lines (1,433 loc) • 836 kB
JavaScript
// ##########################################
// 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)]