/**
* Represents a cubic spline interpolation for smooth color transitions.
* @private
*/
class Spline {
/**
* Creates a new Spline instance.
* @param {number[]} xs - X coordinates array
* @param {number[]} ys - Y coordinates array
*/
constructor(xs, ys) {
this.xs = xs;
this.ys = ys;
this.ks = this.getNaturalKs(new Float64Array(this.xs.length));
}
getNaturalKs(ks) {
const n = this.xs.length - 1;
const A = Spline.zerosMat(n + 1, n + 2);
for (let i = 1; i < n; i++ // rows
) {
A[i][i - 1] = 1 / (this.xs[i] - this.xs[i - 1]);
A[i][i] =
2 *
(1 / (this.xs[i] - this.xs[i - 1]) + 1 / (this.xs[i + 1] - this.xs[i]));
A[i][i + 1] = 1 / (this.xs[i + 1] - this.xs[i]);
A[i][n + 1] =
3 *
((this.ys[i] - this.ys[i - 1]) /
((this.xs[i] - this.xs[i - 1]) * (this.xs[i] - this.xs[i - 1])) +
(this.ys[i + 1] - this.ys[i]) /
((this.xs[i + 1] - this.xs[i]) * (this.xs[i + 1] - this.xs[i])));
}
A[0][0] = 2 / (this.xs[1] - this.xs[0]);
A[0][1] = 1 / (this.xs[1] - this.xs[0]);
A[0][n + 1] =
(3 * (this.ys[1] - this.ys[0])) /
((this.xs[1] - this.xs[0]) * (this.xs[1] - this.xs[0]));
A[n][n - 1] = 1 / (this.xs[n] - this.xs[n - 1]);
A[n][n] = 2 / (this.xs[n] - this.xs[n - 1]);
A[n][n + 1] =
(3 * (this.ys[n] - this.ys[n - 1])) /
((this.xs[n] - this.xs[n - 1]) * (this.xs[n] - this.xs[n - 1]));
return Spline.solve(A, ks);
}
/**
* Finds index of the point before the target value using binary search.
* Inspired by https://stackoverflow.com/a/40850313/4417327
* @param {number} target - Value to search for
* @returns {number} Index of the point before target
* @private
*/
getIndexBefore(target) {
let low = 0;
let high = this.xs.length;
let mid = 0;
while (low < high) {
mid = Math.floor((low + high) / 2);
if (this.xs[mid] < target && mid !== low) {
low = mid;
}
else if (this.xs[mid] >= target && mid !== high) {
high = mid;
}
else {
high = low;
}
}
if (low === this.xs.length - 1) {
return this.xs.length - 1;
}
return low + 1;
}
/**
* Calculates interpolated value at given point.
* @param {number} x - Point to interpolate at
* @returns {number} Interpolated value
*/
at(x) {
let i = this.getIndexBefore(x);
const t = (x - this.xs[i - 1]) / (this.xs[i] - this.xs[i - 1]);
const a = this.ks[i - 1] * (this.xs[i] - this.xs[i - 1]) -
(this.ys[i] - this.ys[i - 1]);
const b = -this.ks[i] * (this.xs[i] - this.xs[i - 1]) +
(this.ys[i] - this.ys[i - 1]);
const q = (1 - t) * this.ys[i - 1] +
t * this.ys[i] +
t * (1 - t) * (a * (1 - t) + b * t);
return q;
}
// Utilities
static solve(A, ks) {
const m = A.length;
let h = 0;
let k = 0;
while (h < m && k <= m) {
let i_max = 0;
let max = -Infinity;
for (let i = h; i < m; i++) {
const v = Math.abs(A[i][k]);
if (v > max) {
i_max = i;
max = v;
}
}
if (A[i_max][k] === 0) {
k++;
}
else {
Spline.swapRows(A, h, i_max);
for (let i = h + 1; i < m; i++) {
const f = A[i][k] / A[h][k];
A[i][k] = 0;
for (let j = k + 1; j <= m; j++)
A[i][j] -= A[h][j] * f;
}
h++;
k++;
}
}
for (let i = m - 1; i >= 0; i-- // rows = columns
) {
var v = 0;
if (A[i][i]) {
v = A[i][m] / A[i][i];
}
ks[i] = v;
for (let j = i - 1; j >= 0; j-- // rows
) {
A[j][m] -= A[j][i] * v;
A[j][i] = 0;
}
}
return ks;
}
static zerosMat(r, c) {
const A = [];
for (let i = 0; i < r; i++)
A.push(new Float64Array(c));
return A;
}
static swapRows(m, k, l) {
let p = m[k];
m[k] = m[l];
m[l] = p;
}
}
/**
* Represents a color in RGBA format with values normalized between 0 and 1.
*/
class Color {
/**
* Creates a new Color instance.
* @param {number|string} r - Red component [0.0, 1.0] or color string ('#RGB', '#RGBA', '#RRGGBB', '#RRGGBBAA', 'rgb()', 'rgba()')
* @param {number} [g] - Green component [0.0, 1.0]
* @param {number} [b] - Blue component [0.0, 1.0]
* @param {number} [a] - Alpha component [0.0, 1.0]
* @throws {Error} If string value is not a valid color format
*/
constructor(r, g = undefined, b = undefined, a = undefined) {
if (typeof (r) == 'string') {
if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(r)) {
let c = r.substring(1).split('');
if (c.length == 3) {
c = [c[0], c[0], c[1], c[1], c[2], c[2]];
}
c = '0x' + c.join('') + 'FF';
r = Color.normalizedRGBA(c >> 24);
g = Color.normalizedRGBA(c >> 16);
b = Color.normalizedRGBA(c >> 8);
a = Color.normalizedRGBA(c);
} else if (/^#([A-Fa-f0-9]{4}){1,2}$/.test(r)) {
let c = r.substring(1).split('');
c = '0x' + c.join('');
r = Color.normalizedRGBA(c >> 24);
g = Color.normalizedRGBA(c >> 16);
b = Color.normalizedRGBA(c >> 8);
a = Color.normalizedRGBA(c);
} else if (/^rgb\(/.test(r)) {
let c = r.split("(")[1].split(")")[0];
c = c.split(',');
r = Color.clamp(c[0] / 255);
g = Color.clamp(c[1] / 255);
b = Color.clamp(c[2] / 255);
a = 1.0;
} else if (/^rgba\(/.test(r)) {
let c = r.split("(")[1].split(")")[0];
c = c.split(',');
r = Color.clamp(c[0] / 255);
g = Color.clamp(c[1] / 255);
b = Color.clamp(c[2] / 255);
a = Color.clamp(c[3] / 255);
} else {
throw Error("Value is not a color");
}
}
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
static clamp = (num, min = 0.0, max = 1.0) => Math.min(Math.max(num, min), max);
static hex(c) {
var hex = c.toString(16).toUpperCase();
return hex.length == 1 ? "0" + hex : hex;
}
static normalizedRGBA(c) {
return Color.clamp((c & 255) / 255);
}
static rgbToHex(r, g, b) {
const rgb = b | (g << 8) | (r << 16);
return '#' + ((0x1000000 | rgb).toString(16).substring(1)).toUpperCase();
}
static rgbToHexa(r, g, b, a) {
return '#' + Color.hex(r) + Color.hex(g) + Color.hex(b) + Color.hex(a);
}
/**
* Gets color components as an array.
* @returns {number[]} Array of [r, g, b, a] values
*/
value() {
return [this.r, this.g, this.b, this.a];
}
/**
* Converts color to RGB values [0, 255].
* @returns {number[]} Array of [r, g, b] values
*/
toRGB() {
const rgb = [this.r * 255, this.g * 255, this.b * 255];
rgb.forEach((e, idx, arr) => {
arr[idx] = Color.clamp(Math.round(e), 0, 255);
});
return rgb;
}
/**
* Converts color to hexadecimal string.
* @returns {string} Color in '#RRGGBB' format
*/
toHex() {
const rgb = this.toRGB();
return Color.rgbToHex(rgb[0], rgb[1], rgb[2]);
}
/**
* Converts color to hexadecimal string with alpha.
* @returns {string} Color in '#RRGGBBAA' format
*/
toHexa() {
const rgba = this.toRGBA();
return Color.rgbToHexa(rgba[0], rgba[1], rgba[2], rgba[3]);
}
/**
* Converts color to RGBA values [0-255].
* @returns {number[]} Array of [r, g, b, a] values
*/
toRGBA() {
const rgba = [this.r * 255, this.g * 255, this.b * 255, this.a * 255];
rgba.forEach((e, idx, arr) => {
arr[idx] = Color.clamp(Math.round(e), 0, 255);
});
return rgba;
}
}
/**
* Creates a colormap for mapping numerical values to colors.
* Supports linear, spline, and bar interpolation between colors.
*/
class Colormap {
/**
* Creates a new Colormap instance.
* @param {Color[]} [colors=[black, white]] - Array of colors to interpolate between
* @param {Object} [options] - Configuration options
* @param {number[]} [options.domain=[0,1]] - Domain range for mapping
* @param {Color} [options.lowColor] - Color for values below domain (defaults to first color)
* @param {Color} [options.highColor] - Color for values above domain (defaults to last color)
* @param {string} [options.description=''] - Description of the colormap
* @param {('linear'|'spline'|'bar')} [options.type='linear'] - Interpolation type
* @throws {Error} If colors/domain format is invalid
*/
constructor(colors = [new Color(0, 0, 0, 1), new Color(1, 1, 1, 1)], options = '') {
options = Object.assign({
domain: [0.0, 1.0],
lowColor: null,
highColor: null,
description: '',
type: 'linear'
}, options);
Object.assign(this, options);
const nval = colors.length;
if (!this.lowColor) this.lowColor = colors[0];
if (!this.highColor) this.highColor = colors[nval - 1];
const nd = this.domain.length;
if (nval < 2 && nd != 2 && this.nval != nd && this.domain[nd - 1] <= this.domain[0]) {
throw Error("Colormap colors/domain bad format");
}
const delta = (this.domain[nd - 1] - this.domain[0]) / (nval - 1);
this.xarr = [];
this.rarr = [];
this.garr = [];
this.barr = [];
this.aarr = [];
for (let i = 0; i < nval; i++) {
if (nd == 2)
this.xarr.push(this.domain[0] + i * delta);
else
this.xarr.push(this.domain[i]);
this.rarr.push(colors[i].r);
this.garr.push(colors[i].g);
this.barr.push(colors[i].b);
this.aarr.push(colors[i].a);
}
this.rspline = new Spline(this.xarr, this.rarr);
this.gspline = new Spline(this.xarr, this.garr);
this.bspline = new Spline(this.xarr, this.barr);
this.aspline = new Spline(this.xarr, this.aarr);
}
static clamp = (num, min, max) => Math.min(Math.max(num, min), max);
/**
* Gets the domain range of the colormap.
* @returns {number[]} Array containing [min, max] of domain
*/
rangeDomain() {
return [this.domain[0], this.domain[this.domain.length - 1]];
}
/**
* Gets color for a value using bar interpolation.
* @param {number} x - Value to get color for
* @returns {Color} Corresponding color
* @private
*/
bar(x) {
if (x < this.xarr[0]) return this.lowColor;
if (x > this.xarr[this.xarr.length - 1]) return this.highColor;
const c = new Color(this.rarr[0], this.garr[0], this.barr[0], this.aarr[0]);
for (let i = 0; i < this.xarr.length - 1; i++) {
if (x > this.xarr[i] && x <= this.xarr[i + 1]) {
c.r = this.rarr[i];
c.g = this.garr[i];
c.b = this.barr[i];
c.a = this.aarr[i];
}
}
return c;
}
/**
* Gets color for a value using linear interpolation.
* @param {number} x - Value to get color for
* @returns {Color} Corresponding color
* @private
*/
linear(x) {
if (x < this.xarr[0]) return this.lowColor;
if (x > this.xarr[this.xarr.length - 1]) return this.highColor;
const c = new Color(this.rarr[0], this.garr[0], this.barr[0], this.aarr[0]);
for (let i = 0; i < this.xarr.length - 1; i++) {
if (x > this.xarr[i] && x <= this.xarr[i + 1]) {
c.r = (this.rarr[i + 1] - this.rarr[i]) * (x - this.xarr[i]) / (this.xarr[i + 1] - this.xarr[i]) + this.rarr[i];
c.g = (this.garr[i + 1] - this.garr[i]) * (x - this.xarr[i]) / (this.xarr[i + 1] - this.xarr[i]) + this.garr[i];
c.b = (this.barr[i + 1] - this.barr[i]) * (x - this.xarr[i]) / (this.xarr[i + 1] - this.xarr[i]) + this.barr[i];
c.a = (this.aarr[i + 1] - this.aarr[i]) * (x - this.xarr[i]) / (this.xarr[i + 1] - this.xarr[i]) + this.aarr[i];
}
}
return c;
}
/**
* Gets color for a value using spline interpolation.
* @param {number} x - Value to get color for
* @returns {Color} Corresponding color
* @private
*/
spline(x) {
if (x < this.xarr[0]) return this.lowColor;
if (x > this.xarr[this.xarr.length - 1]) return this.highColor;
return new Color(this.rspline.at(x), this.gspline.at(x), this.bspline.at(x), this.aspline.at(x));
}
/**
* Gets color for a value using configured interpolation type.
* @param {number} x - Value to get color for
* @returns {Color} Corresponding color
* @throws {Error} If interpolation type is invalid
*/
at(x) {
let result = null;
switch (this.type) {
case 'linear':
result = this.linear(x);
break;
case 'spline':
result = this.spline(x);
break;
case 'bar':
result = this.bar(x);
break;
default:
throw Error("Interpolant type not exist");
break;
}
return result;
}
/**
* Samples the colormap into a buffer.
* @param {number} maxSteps - Number of samples to generate
* @returns {{min: number, max: number, buffer: Uint8Array}} Sample data and buffer
*/
sample(maxSteps) {
let min = this.xarr[0];
let max = this.xarr[this.xarr.length - 1];
//if (this.domain.length == 2) maxSteps = this.xarr.length;
let buffer = new Uint8Array(maxSteps * 4);
let delta = (max - min) / maxSteps;
for (let i = 0; i < maxSteps; i++) {
let c = this.at(min + i * delta).toRGBA();
buffer[i * 4 + 0] = c[0];
buffer[i * 4 + 1] = c[1];
buffer[i * 4 + 2] = c[2];
buffer[i * 4 + 3] = c[3];
}
return { min, max, buffer };
}
}
/**
* Creates a visual legend for a colormap.
*/
class ColormapLegend {
/**
* Creates a new ColormapLegend instance.
* @param {Object} viewer - Viewer instance to attach legend to
* @param {Colormap} colorscale - Colormap to create legend for
* @param {Object} [options] - Configuration options
* @param {number} [options.nticks=6] - Number of ticks/divisions in legend
* @param {number} [options.legendWidth=25] - Width of legend as percentage
* @param {string} [options.textColor='#fff'] - Color of text labels
* @param {string} [options.class='openlime-legend'] - CSS class for legend container
*/
constructor(viewer, colorscale, options) {
options = Object.assign({
nticks: 6,
legendWidth: 25,
textColor: '#fff',
class: 'openlime-legend'
}, options);
Object.assign(this, options);
this.viewer = viewer;
this.colorscale = colorscale;
this.container = document.querySelector(`.${this.class}`);
if (!this.container) {
this.container = document.createElement('div');
this.container.classList.add(this.class);
}
this.scale = document.createElement('div');
this.scale.style = `display: flex; border-radius: 20px; height: 22px; color: ${this.textColor};
font-weight: bold; overflow: hidden; margin: 0px 2px 4px 0px; background-color: #7c7c7c;
font-family: Arial,Helvetica,sans-serif; font-size:12px;
border: 1px solid #000;`;
this.container.appendChild(this.scale);
this.viewer.containerElement.appendChild(this.container);
const domain = colorscale.rangeDomain();
const legend = document.createElement('div');
legend.style = `display: flex; align-items: center; justify-content: center;
background: ${colorscale.linear(domain[0]).toHex()}; width: ${this.legendWidth}%; margin: 0`;
legend.textContent = colorscale.description;
this.scale.appendChild(legend);
if (this.colorscale.type == 'linear') this.legendLinear();
if (this.colorscale.type == 'bar') this.legendBar();
}
/**
* Creates legend for linear interpolation.
* @private
*/
legendLinear() {
const domain = this.colorscale.rangeDomain();
const delta = (domain[1] - domain[0]) / this.nticks;
const deltaWidth = (100 - this.legendWidth) / this.nticks;
let vl = domain[0];
for (let i = 0; i < this.nticks; i++) {
let v = domain[0] + delta * i;
let vr = i < (this.nticks - 1) ? domain[0] + delta * (i + 0.5) : v;
const c = this.colorscale.at(v);
const cl = this.colorscale.at(vl);
const cr = this.colorscale.at(vr);
const value = document.createElement('div');
const bkg = `background: linear-gradient(to right, ${cl.toHex()}, ${c.toHex()}, ${cr.toHex()})`
value.style = `display: flex; align-items: center; justify-content: center;
${bkg}; width: ${deltaWidth}%; margin: 0`;
value.textContent = v.toFixed(1);
this.scale.appendChild(value);
vl = vr;
}
}
/**
* Creates legend for bar interpolation.
* @private
*/
legendBar() {
const deltaWidth = (100 - this.legendWidth) / this.colorscale.domain.length;
for (let i = 0; i < this.colorscale.xarr.length; i++) {
const c = new Color(this.colorscale.rarr[i], this.colorscale.garr[i], this.colorscale.barr[i], this.colorscale.aarr[i]);
const v = this.colorscale.xarr[i];
const value = document.createElement('div');
const bkg = `background: ${c.toHex()}`;
value.style = `display: flex; align-items: center; justify-content: center;
${bkg}; width: ${deltaWidth}%; margin: 0`;
value.textContent = v.toFixed(1);
this.scale.appendChild(value);
}
}
}
export { Color, Colormap, ColormapLegend }