/** * 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 }