openlime
Version:
Open Layered IMage Explorer
1,420 lines (1,297 loc) • 727 kB
JavaScript
// ##########################################
// 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