UNPKG

vis-graph3d

Version:

Create interactive, animated 3d graphs. Surfaces, lines, dots and block styling out of the box.

1,929 lines (1,697 loc) 135 kB
/** * vis-graph3d * https://visjs.github.io/vis-graph3d/ * * Create interactive, animated 3d graphs. Surfaces, lines, dots and block styling out of the box. * * @version 7.0.2 * @date 2025-09-15T17:38:11.097Z * * @copyright (c) 2011-2017 Almende B.V, http://almende.com * @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs * * @license * vis.js is dual licensed under both * * 1. The Apache 2.0 License * http://www.apache.org/licenses/LICENSE-2.0 * * and * * 2. The MIT License * http://opensource.org/licenses/MIT * * vis.js may be distributed under either license. */ import Emitter from 'component-emitter'; import * as util from 'vis-util/esnext/umd/vis-util.js'; import { Validator, VALIDATOR_PRINT_STYLE } from 'vis-util/esnext/umd/vis-util.js'; import { DataView, DataSet } from 'vis-data/esnext/umd/vis-data.js'; /** * @param {number} [x] * @param {number} [y] * @param {number} [z] */ function Point3d(x, y, z) { this.x = x !== undefined ? x : 0; this.y = y !== undefined ? y : 0; this.z = z !== undefined ? z : 0; } /** * Subtract the two provided points, returns a-b * @param {Point3d} a * @param {Point3d} b * @returns {Point3d} a-b */ Point3d.subtract = function (a, b) { const sub = new Point3d(); sub.x = a.x - b.x; sub.y = a.y - b.y; sub.z = a.z - b.z; return sub; }; /** * Add the two provided points, returns a+b * @param {Point3d} a * @param {Point3d} b * @returns {Point3d} a+b */ Point3d.add = function (a, b) { const sum = new Point3d(); sum.x = a.x + b.x; sum.y = a.y + b.y; sum.z = a.z + b.z; return sum; }; /** * Calculate the average of two 3d points * @param {Point3d} a * @param {Point3d} b * @returns {Point3d} The average, (a+b)/2 */ Point3d.avg = function (a, b) { return new Point3d((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); }; /** * Scale the provided point by a scalar, returns p*c * @param {Point3d} p * @param {number} c * @returns {Point3d} p*c */ Point3d.scalarProduct = function (p, c) { return new Point3d(p.x * c, p.y * c, p.z * c); }; /** * Calculate the dot product of the two provided points, returns a.b * Documentation: http://en.wikipedia.org/wiki/Dot_product * @param {Point3d} a * @param {Point3d} b * @returns {Point3d} dot product a.b */ Point3d.dotProduct = function (a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }; /** * Calculate the cross product of the two provided points, returns axb * Documentation: http://en.wikipedia.org/wiki/Cross_product * @param {Point3d} a * @param {Point3d} b * @returns {Point3d} cross product axb */ Point3d.crossProduct = function (a, b) { const crossproduct = new Point3d(); crossproduct.x = a.y * b.z - a.z * b.y; crossproduct.y = a.z * b.x - a.x * b.z; crossproduct.z = a.x * b.y - a.y * b.x; return crossproduct; }; /** * Retrieve the length of the vector (or the distance from this point to the origin * @returns {number} length */ Point3d.prototype.length = function () { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); }; /** * Return a normalized vector pointing in the same direction. * @returns {Point3d} normalized */ Point3d.prototype.normalize = function () { return Point3d.scalarProduct(this, 1 / this.length()); }; /** * @param {number} [x] * @param {number} [y] */ function Point2d(x, y) { this.x = x !== undefined ? x : 0; this.y = y !== undefined ? y : 0; } /** * An html slider control with start/stop/prev/next buttons * @function Object() { [native code] } Slider * @param {Element} container The element where the slider will be created * @param {object} options Available options: * {boolean} visible If true (default) the * slider is visible. */ function Slider(container, options) { if (container === undefined) { throw new Error("No container element defined"); } this.container = container; this.visible = options && options.visible != undefined ? options.visible : true; if (this.visible) { this.frame = document.createElement("DIV"); //this.frame.style.backgroundColor = '#E5E5E5'; this.frame.style.width = "100%"; this.frame.style.position = "relative"; this.container.appendChild(this.frame); this.frame.prev = document.createElement("INPUT"); this.frame.prev.type = "BUTTON"; this.frame.prev.value = "Prev"; this.frame.appendChild(this.frame.prev); this.frame.play = document.createElement("INPUT"); this.frame.play.type = "BUTTON"; this.frame.play.value = "Play"; this.frame.appendChild(this.frame.play); this.frame.next = document.createElement("INPUT"); this.frame.next.type = "BUTTON"; this.frame.next.value = "Next"; this.frame.appendChild(this.frame.next); this.frame.bar = document.createElement("INPUT"); this.frame.bar.type = "BUTTON"; this.frame.bar.style.position = "absolute"; this.frame.bar.style.border = "1px solid red"; this.frame.bar.style.width = "100px"; this.frame.bar.style.height = "6px"; this.frame.bar.style.borderRadius = "2px"; this.frame.bar.style.MozBorderRadius = "2px"; this.frame.bar.style.border = "1px solid #7F7F7F"; this.frame.bar.style.backgroundColor = "#E5E5E5"; this.frame.appendChild(this.frame.bar); this.frame.slide = document.createElement("INPUT"); this.frame.slide.type = "BUTTON"; this.frame.slide.style.margin = "0px"; this.frame.slide.value = " "; this.frame.slide.style.position = "relative"; this.frame.slide.style.left = "-100px"; this.frame.appendChild(this.frame.slide); // create events const me = this; this.frame.slide.onmousedown = function (event) { me._onMouseDown(event); }; this.frame.prev.onclick = function (event) { me.prev(event); }; this.frame.play.onclick = function (event) { me.togglePlay(event); }; this.frame.next.onclick = function (event) { me.next(event); }; } this.onChangeCallback = undefined; this.values = []; this.index = undefined; this.playTimeout = undefined; this.playInterval = 1000; // milliseconds this.playLoop = true; } /** * Select the previous index */ Slider.prototype.prev = function () { let index = this.getIndex(); if (index > 0) { index--; this.setIndex(index); } }; /** * Select the next index */ Slider.prototype.next = function () { let index = this.getIndex(); if (index < this.values.length - 1) { index++; this.setIndex(index); } }; /** * Select the next index */ Slider.prototype.playNext = function () { const start = new Date(); let index = this.getIndex(); if (index < this.values.length - 1) { index++; this.setIndex(index); } else if (this.playLoop) { // jump to the start index = 0; this.setIndex(index); } const end = new Date(); const diff = end - start; // calculate how much time it to to set the index and to execute the callback // function. const interval = Math.max(this.playInterval - diff, 0); // document.title = diff // TODO: cleanup const me = this; this.playTimeout = setTimeout(function () { me.playNext(); }, interval); }; /** * Toggle start or stop playing */ Slider.prototype.togglePlay = function () { if (this.playTimeout === undefined) { this.play(); } else { this.stop(); } }; /** * Start playing */ Slider.prototype.play = function () { // Test whether already playing if (this.playTimeout) return; this.playNext(); if (this.frame) { this.frame.play.value = "Stop"; } }; /** * Stop playing */ Slider.prototype.stop = function () { clearInterval(this.playTimeout); this.playTimeout = undefined; if (this.frame) { this.frame.play.value = "Play"; } }; /** * Set a callback function which will be triggered when the value of the * slider bar has changed. * @param {Function} callback */ Slider.prototype.setOnChangeCallback = function (callback) { this.onChangeCallback = callback; }; /** * Set the interval for playing the list * @param {number} interval The interval in milliseconds */ Slider.prototype.setPlayInterval = function (interval) { this.playInterval = interval; }; /** * Retrieve the current play interval * @returns {number} interval The interval in milliseconds */ Slider.prototype.getPlayInterval = function () { return this.playInterval; }; /** * Set looping on or off * @param {boolean} doLoop If true, the slider will jump to the start when * the end is passed, and will jump to the end * when the start is passed. */ Slider.prototype.setPlayLoop = function (doLoop) { this.playLoop = doLoop; }; /** * Execute the onchange callback function */ Slider.prototype.onChange = function () { if (this.onChangeCallback !== undefined) { this.onChangeCallback(); } }; /** * redraw the slider on the correct place */ Slider.prototype.redraw = function () { if (this.frame) { // resize the bar this.frame.bar.style.top = this.frame.clientHeight / 2 - this.frame.bar.offsetHeight / 2 + "px"; this.frame.bar.style.width = this.frame.clientWidth - this.frame.prev.clientWidth - this.frame.play.clientWidth - this.frame.next.clientWidth - 30 + "px"; // position the slider button const left = this.indexToLeft(this.index); this.frame.slide.style.left = left + "px"; } }; /** * Set the list with values for the slider * @param {Array} values A javascript array with values (any type) */ Slider.prototype.setValues = function (values) { this.values = values; if (this.values.length > 0) this.setIndex(0); else this.index = undefined; }; /** * Select a value by its index * @param {number} index */ Slider.prototype.setIndex = function (index) { if (index < this.values.length) { this.index = index; this.redraw(); this.onChange(); } else { throw new Error("Index out of range"); } }; /** * retrieve the index of the currently selected vaue * @returns {number} index */ Slider.prototype.getIndex = function () { return this.index; }; /** * retrieve the currently selected value * @returns {*} value */ Slider.prototype.get = function () { return this.values[this.index]; }; Slider.prototype._onMouseDown = function (event) { // only react on left mouse button down const leftButtonDown = event.which ? event.which === 1 : event.button === 1; if (!leftButtonDown) return; this.startClientX = event.clientX; this.startSlideX = parseFloat(this.frame.slide.style.left); this.frame.style.cursor = "move"; // add event listeners to handle moving the contents // we store the function onmousemove and onmouseup in the graph, so we can // remove the eventlisteners lateron in the function mouseUp() const me = this; this.onmousemove = function (event) { me._onMouseMove(event); }; this.onmouseup = function (event) { me._onMouseUp(event); }; document.addEventListener("mousemove", this.onmousemove); document.addEventListener("mouseup", this.onmouseup); util.preventDefault(event); }; Slider.prototype.leftToIndex = function (left) { const width = parseFloat(this.frame.bar.style.width) - this.frame.slide.clientWidth - 10; const x = left - 3; let index = Math.round((x / width) * (this.values.length - 1)); if (index < 0) index = 0; if (index > this.values.length - 1) index = this.values.length - 1; return index; }; Slider.prototype.indexToLeft = function (index) { const width = parseFloat(this.frame.bar.style.width) - this.frame.slide.clientWidth - 10; const x = (index / (this.values.length - 1)) * width; const left = x + 3; return left; }; Slider.prototype._onMouseMove = function (event) { const diff = event.clientX - this.startClientX; const x = this.startSlideX + diff; const index = this.leftToIndex(x); this.setIndex(index); util.preventDefault(); }; Slider.prototype._onMouseUp = function () { this.frame.style.cursor = "auto"; // remove event listeners document.removeEventListener("mousemove", this.onmousemove); document.removeEventListener("mouseup", this.onmouseup); util.preventDefault(); }; /** * The class StepNumber is an iterator for Numbers. You provide a start and end * value, and a best step size. StepNumber itself rounds to fixed values and * a finds the step that best fits the provided step. * * If prettyStep is true, the step size is chosen as close as possible to the * provided step, but being a round value like 1, 2, 5, 10, 20, 50, .... * * Example usage: * var step = new StepNumber(0, 10, 2.5, true); * step.start(); * while (!step.end()) { * alert(step.getCurrent()); * step.next(); * } * * Version: 1.0 * @param {number} start The start value * @param {number} end The end value * @param {number} step Optional. Step size. Must be a positive value. * @param {boolean} prettyStep Optional. If true, the step size is rounded * To a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ function StepNumber(start, end, step, prettyStep) { // set default values this._start = 0; this._end = 0; this._step = 1; this.prettyStep = true; this.precision = 5; this._current = 0; this.setRange(start, end, step, prettyStep); } /** * Check for input values, to prevent disasters from happening * * Source: http://stackoverflow.com/a/1830844 * @param {string} n * @returns {boolean} */ StepNumber.prototype.isNumeric = function (n) { return !isNaN(parseFloat(n)) && isFinite(n); }; /** * Set a new range: start, end and step. * @param {number} start The start value * @param {number} end The end value * @param {number} step Optional. Step size. Must be a positive value. * @param {boolean} prettyStep Optional. If true, the step size is rounded * To a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ StepNumber.prototype.setRange = function (start, end, step, prettyStep) { if (!this.isNumeric(start)) { throw new Error("Parameter 'start' is not numeric; value: " + start); } if (!this.isNumeric(end)) { throw new Error("Parameter 'end' is not numeric; value: " + start); } if (!this.isNumeric(step)) { throw new Error("Parameter 'step' is not numeric; value: " + start); } this._start = start ? start : 0; this._end = end ? end : 0; this.setStep(step, prettyStep); }; /** * Set a new step size * @param {number} step New step size. Must be a positive value * @param {boolean} prettyStep Optional. If true, the provided step is rounded * to a pretty step size (like 1, 2, 5, 10, 20, 50, ...) */ StepNumber.prototype.setStep = function (step, prettyStep) { if (step === undefined || step <= 0) return; if (prettyStep !== undefined) this.prettyStep = prettyStep; if (this.prettyStep === true) this._step = StepNumber.calculatePrettyStep(step); else this._step = step; }; /** * Calculate a nice step size, closest to the desired step size. * Returns a value in one of the ranges 1*10^n, 2*10^n, or 5*10^n, where n is an * integer Number. For example 1, 2, 5, 10, 20, 50, etc... * @param {number} step Desired step size * @returns {number} Nice step size */ StepNumber.calculatePrettyStep = function (step) { const log10 = function (x) { return Math.log(x) / Math.LN10; }; // try three steps (multiple of 1, 2, or 5 const step1 = Math.pow(10, Math.round(log10(step))), step2 = 2 * Math.pow(10, Math.round(log10(step / 2))), step5 = 5 * Math.pow(10, Math.round(log10(step / 5))); // choose the best step (closest to minimum step) let prettyStep = step1; if (Math.abs(step2 - step) <= Math.abs(prettyStep - step)) prettyStep = step2; if (Math.abs(step5 - step) <= Math.abs(prettyStep - step)) prettyStep = step5; // for safety if (prettyStep <= 0) { prettyStep = 1; } return prettyStep; }; /** * returns the current value of the step * @returns {number} current value */ StepNumber.prototype.getCurrent = function () { return parseFloat(this._current.toPrecision(this.precision)); }; /** * returns the current step size * @returns {number} current step size */ StepNumber.prototype.getStep = function () { return this._step; }; /** * Set the current to its starting value. * * By default, this will be the largest value smaller than start, which * is a multiple of the step size. * * Parameters checkFirst is optional, default false. * If set to true, move the current value one step if smaller than start. * @param {boolean} [checkFirst] */ StepNumber.prototype.start = function (checkFirst) { if (checkFirst === undefined) { checkFirst = false; } this._current = this._start - (this._start % this._step); if (checkFirst) { if (this.getCurrent() < this._start) { this.next(); } } }; /** * Do a step, add the step size to the current value */ StepNumber.prototype.next = function () { this._current += this._step; }; /** * Returns true whether the end is reached * @returns {boolean} True if the current value has passed the end value. */ StepNumber.prototype.end = function () { return this._current > this._end; }; /** * The camera is mounted on a (virtual) camera arm. The camera arm can rotate * The camera is always looking in the direction of the origin of the arm. * This way, the camera always rotates around one fixed point, the location * of the camera arm. * * Documentation: * http://en.wikipedia.org/wiki/3D_projection * @class Camera */ function Camera() { this.armLocation = new Point3d(); this.armRotation = {}; this.armRotation.horizontal = 0; this.armRotation.vertical = 0; this.armLength = 1.7; this.cameraOffset = new Point3d(); this.offsetMultiplier = 0.6; this.cameraLocation = new Point3d(); this.cameraRotation = new Point3d(0.5 * Math.PI, 0, 0); this.calculateCameraOrientation(); } /** * Set offset camera in camera coordinates * @param {number} x offset by camera horisontal * @param {number} y offset by camera vertical */ Camera.prototype.setOffset = function (x, y) { const abs = Math.abs, sign = Math.sign, mul = this.offsetMultiplier, border = this.armLength * mul; if (abs(x) > border) { x = sign(x) * border; } if (abs(y) > border) { y = sign(y) * border; } this.cameraOffset.x = x; this.cameraOffset.y = y; this.calculateCameraOrientation(); }; /** * Get camera offset by horizontal and vertical * @returns {number} */ Camera.prototype.getOffset = function () { return this.cameraOffset; }; /** * Set the location (origin) of the arm * @param {number} x Normalized value of x * @param {number} y Normalized value of y * @param {number} z Normalized value of z */ Camera.prototype.setArmLocation = function (x, y, z) { this.armLocation.x = x; this.armLocation.y = y; this.armLocation.z = z; this.calculateCameraOrientation(); }; /** * Set the rotation of the camera arm * @param {number} horizontal The horizontal rotation, between 0 and 2*PI. * Optional, can be left undefined. * @param {number} vertical The vertical rotation, between 0 and 0.5*PI * if vertical=0.5*PI, the graph is shown from the * top. Optional, can be left undefined. */ Camera.prototype.setArmRotation = function (horizontal, vertical) { if (horizontal !== undefined) { this.armRotation.horizontal = horizontal; } if (vertical !== undefined) { this.armRotation.vertical = vertical; if (this.armRotation.vertical < 0) this.armRotation.vertical = 0; if (this.armRotation.vertical > 0.5 * Math.PI) this.armRotation.vertical = 0.5 * Math.PI; } if (horizontal !== undefined || vertical !== undefined) { this.calculateCameraOrientation(); } }; /** * Retrieve the current arm rotation * @returns {object} An object with parameters horizontal and vertical */ Camera.prototype.getArmRotation = function () { const rot = {}; rot.horizontal = this.armRotation.horizontal; rot.vertical = this.armRotation.vertical; return rot; }; /** * Set the (normalized) length of the camera arm. * @param {number} length A length between 0.71 and 5.0 */ Camera.prototype.setArmLength = function (length) { if (length === undefined) return; this.armLength = length; // Radius must be larger than the corner of the graph, // which has a distance of sqrt(0.5^2+0.5^2) = 0.71 from the center of the // graph if (this.armLength < 0.71) this.armLength = 0.71; if (this.armLength > 5.0) this.armLength = 5.0; this.setOffset(this.cameraOffset.x, this.cameraOffset.y); this.calculateCameraOrientation(); }; /** * Retrieve the arm length * @returns {number} length */ Camera.prototype.getArmLength = function () { return this.armLength; }; /** * Retrieve the camera location * @returns {Point3d} cameraLocation */ Camera.prototype.getCameraLocation = function () { return this.cameraLocation; }; /** * Retrieve the camera rotation * @returns {Point3d} cameraRotation */ Camera.prototype.getCameraRotation = function () { return this.cameraRotation; }; /** * Calculate the location and rotation of the camera based on the * position and orientation of the camera arm */ Camera.prototype.calculateCameraOrientation = function () { // calculate location of the camera this.cameraLocation.x = this.armLocation.x - this.armLength * Math.sin(this.armRotation.horizontal) * Math.cos(this.armRotation.vertical); this.cameraLocation.y = this.armLocation.y - this.armLength * Math.cos(this.armRotation.horizontal) * Math.cos(this.armRotation.vertical); this.cameraLocation.z = this.armLocation.z + this.armLength * Math.sin(this.armRotation.vertical); // calculate rotation of the camera this.cameraRotation.x = Math.PI / 2 - this.armRotation.vertical; this.cameraRotation.y = 0; this.cameraRotation.z = -this.armRotation.horizontal; const xa = this.cameraRotation.x; const za = this.cameraRotation.z; const dx = this.cameraOffset.x; const dy = this.cameraOffset.y; const sin = Math.sin, cos = Math.cos; this.cameraLocation.x = this.cameraLocation.x + dx * cos(za) + dy * -sin(za) * cos(xa); this.cameraLocation.y = this.cameraLocation.y + dx * sin(za) + dy * cos(za) * cos(xa); this.cameraLocation.z = this.cameraLocation.z + dy * sin(xa); }; //////////////////////////////////////////////////////////////////////////////// // This modules handles the options for Graph3d. // //////////////////////////////////////////////////////////////////////////////// // enumerate the available styles const STYLE = { BAR: 0, BARCOLOR: 1, BARSIZE: 2, DOT: 3, DOTLINE: 4, DOTCOLOR: 5, DOTSIZE: 6, GRID: 7, LINE: 8, SURFACE: 9, }; // The string representations of the styles const STYLENAME = { dot: STYLE.DOT, "dot-line": STYLE.DOTLINE, "dot-color": STYLE.DOTCOLOR, "dot-size": STYLE.DOTSIZE, line: STYLE.LINE, grid: STYLE.GRID, surface: STYLE.SURFACE, bar: STYLE.BAR, "bar-color": STYLE.BARCOLOR, "bar-size": STYLE.BARSIZE, }; /** * Field names in the options hash which are of relevance to the user. * * Specifically, these are the fields which require no special handling, * and can be directly copied over. */ const OPTIONKEYS = [ "width", "height", "filterLabel", "legendLabel", "xLabel", "yLabel", "zLabel", "xValueLabel", "yValueLabel", "zValueLabel", "showXAxis", "showYAxis", "showZAxis", "showGrayBottom", "showGrid", "showPerspective", "showShadow", "showSurfaceGrid", "keepAspectRatio", "rotateAxisLabels", "verticalRatio", "dotSizeRatio", "dotSizeMinFraction", "dotSizeMaxFraction", "showAnimationControls", "animationInterval", "animationPreload", "animationAutoStart", "axisColor", "axisFontSize", "axisFontType", "gridColor", "xCenter", "yCenter", "zoomable", "tooltipDelay", "ctrlToZoom", ]; /** * Field names in the options hash which are of relevance to the user. * * Same as OPTIONKEYS, but internally these fields are stored with * prefix 'default' in the name. */ const PREFIXEDOPTIONKEYS = [ "xBarWidth", "yBarWidth", "valueMin", "valueMax", "xMin", "xMax", "xStep", "yMin", "yMax", "yStep", "zMin", "zMax", "zStep", ]; // Placeholder for DEFAULTS reference let DEFAULTS = undefined; /** * Check if given hash is empty. * * Source: http://stackoverflow.com/a/679937 * @param {object} obj * @returns {boolean} */ function isEmpty(obj) { for (const prop in obj) { if (Object.prototype.hasOwnProperty.call(obj, prop)) return false; } return true; } /** * Make first letter of parameter upper case. * * Source: http://stackoverflow.com/a/1026087 * @param {string} str * @returns {string} */ function capitalize(str) { if (str === undefined || str === "" || typeof str != "string") { return str; } return str.charAt(0).toUpperCase() + str.slice(1); } /** * Add a prefix to a field name, taking style guide into account * @param {string} prefix * @param {string} fieldName * @returns {string} */ function prefixFieldName(prefix, fieldName) { if (prefix === undefined || prefix === "") { return fieldName; } return prefix + capitalize(fieldName); } /** * Forcibly copy fields from src to dst in a controlled manner. * * A given field in dst will always be overwitten. If this field * is undefined or not present in src, the field in dst will * be explicitly set to undefined. * * The intention here is to be able to reset all option fields. * * Only the fields mentioned in array 'fields' will be handled. * @param {object} src * @param {object} dst * @param {Array<string>} fields array with names of fields to copy * @param {string} [prefix] prefix to use for the target fields. */ function forceCopy(src, dst, fields, prefix) { let srcKey; let dstKey; for (let i = 0; i < fields.length; ++i) { srcKey = fields[i]; dstKey = prefixFieldName(prefix, srcKey); dst[dstKey] = src[srcKey]; } } /** * Copy fields from src to dst in a safe and controlled manner. * * Only the fields mentioned in array 'fields' will be copied over, * and only if these are actually defined. * @param {object} src * @param {object} dst * @param {Array<string>} fields array with names of fields to copy * @param {string} [prefix] prefix to use for the target fields. */ function safeCopy(src, dst, fields, prefix) { let srcKey; let dstKey; for (let i = 0; i < fields.length; ++i) { srcKey = fields[i]; if (src[srcKey] === undefined) continue; dstKey = prefixFieldName(prefix, srcKey); dst[dstKey] = src[srcKey]; } } /** * Initialize dst with the values in src. * * src is the hash with the default values. * A reference DEFAULTS to this hash is stored locally for * further handling. * * For now, dst is assumed to be a Graph3d instance. * @param {object} src * @param {object} dst */ function setDefaults(src, dst) { if (src === undefined || isEmpty(src)) { throw new Error("No DEFAULTS passed"); } if (dst === undefined) { throw new Error("No dst passed"); } // Remember defaults for future reference DEFAULTS = src; // Handle the defaults which can be simply copied over forceCopy(src, dst, OPTIONKEYS); forceCopy(src, dst, PREFIXEDOPTIONKEYS, "default"); // Handle the more complex ('special') fields setSpecialSettings(src, dst); // Following are internal fields, not part of the user settings dst.margin = 10; // px dst.showTooltip = false; dst.onclick_callback = null; dst.eye = new Point3d(0, 0, -1); // TODO: set eye.z about 3/4 of the width of the window? } /** * * @param {object} options * @param {object} dst */ function setOptions(options, dst) { if (options === undefined) { return; } if (dst === undefined) { throw new Error("No dst passed"); } if (DEFAULTS === undefined || isEmpty(DEFAULTS)) { throw new Error("DEFAULTS not set for module Settings"); } // Handle the parameters which can be simply copied over safeCopy(options, dst, OPTIONKEYS); safeCopy(options, dst, PREFIXEDOPTIONKEYS, "default"); // Handle the more complex ('special') fields setSpecialSettings(options, dst); } /** * Special handling for certain parameters * * 'Special' here means: setting requires more than a simple copy * @param {object} src * @param {object} dst */ function setSpecialSettings(src, dst) { if (src.backgroundColor !== undefined) { setBackgroundColor(src.backgroundColor, dst); } setDataColor(src.dataColor, dst); setStyle(src.style, dst); if (src.surfaceColors !== undefined) { console.warn( "`options.surfaceColors` is deprecated and may be removed in a future " + "version. Please use `options.colormap` instead. Note that the `colormap` " + "option uses the inverse array ordering (running from vMin to vMax).", ); if (src.colormap !== undefined) { throw new Error( "The `colormap` and `surfaceColors` options are mutually exclusive.", ); } if (dst.style !== "surface") { console.warn( "Ignoring `surfaceColors` in graph style `" + dst.style + "` for " + "backward compatibility (only effective in `surface` plots).", ); } else { setSurfaceColor(src.surfaceColors, dst); } } else { setColormap(src.colormap, dst); } setShowLegend(src.showLegend, dst); setCameraPosition(src.cameraPosition, dst); // As special fields go, this is an easy one; just a translation of the name. // Can't use this.tooltip directly, because that field exists internally if (src.tooltip !== undefined) { dst.showTooltip = src.tooltip; } if (src.onclick != undefined) { dst.onclick_callback = src.onclick; console.warn( "`options.onclick` is deprecated and may be removed in a future version." + " Please use `Graph3d.on('click', handler)` instead.", ); } if (src.tooltipStyle !== undefined) { util.selectiveDeepExtend(["tooltipStyle"], dst, src); } } /** * Set the value of setting 'showLegend' * * This depends on the value of the style fields, so it must be called * after the style field has been initialized. * @param {boolean} showLegend * @param {object} dst */ function setShowLegend(showLegend, dst) { if (showLegend === undefined) { // If the default was auto, make a choice for this field const isAutoByDefault = DEFAULTS.showLegend === undefined; if (isAutoByDefault) { // these styles default to having legends const isLegendGraphStyle = dst.style === STYLE.DOTCOLOR || dst.style === STYLE.DOTSIZE; dst.showLegend = isLegendGraphStyle; } } else { dst.showLegend = showLegend; } } /** * Retrieve the style index from given styleName * @param {string} styleName Style name such as 'dot', 'grid', 'dot-line' * @returns {number} styleNumber Enumeration value representing the style, or -1 * when not found */ function getStyleNumberByName(styleName) { const number = STYLENAME[styleName]; if (number === undefined) { return -1; } return number; } /** * Check if given number is a valid style number. * @param {string | number} style * @returns {boolean} true if valid, false otherwise */ function checkStyleNumber(style) { let valid = false; for (const n in STYLE) { if (STYLE[n] === style) { valid = true; break; } } return valid; } /** * * @param {string | number} style * @param {object} dst */ function setStyle(style, dst) { if (style === undefined) { return; // Nothing to do } let styleNumber; if (typeof style === "string") { styleNumber = getStyleNumberByName(style); if (styleNumber === -1) { throw new Error("Style '" + style + "' is invalid"); } } else { // Do a pedantic check on style number value if (!checkStyleNumber(style)) { throw new Error("Style '" + style + "' is invalid"); } styleNumber = style; } dst.style = styleNumber; } /** * Set the background styling for the graph * @param {string | {fill: string, stroke: string, strokeWidth: string}} backgroundColor * @param {object} dst */ function setBackgroundColor(backgroundColor, dst) { let fill = "white"; let stroke = "gray"; let strokeWidth = 1; if (typeof backgroundColor === "string") { fill = backgroundColor; stroke = "none"; strokeWidth = 0; } else if (typeof backgroundColor === "object") { if (backgroundColor.fill !== undefined) fill = backgroundColor.fill; if (backgroundColor.stroke !== undefined) stroke = backgroundColor.stroke; if (backgroundColor.strokeWidth !== undefined) strokeWidth = backgroundColor.strokeWidth; } else { throw new Error("Unsupported type of backgroundColor"); } dst.frame.style.backgroundColor = fill; dst.frame.style.borderColor = stroke; dst.frame.style.borderWidth = strokeWidth + "px"; dst.frame.style.borderStyle = "solid"; } /** * * @param {string | object} dataColor * @param {object} dst */ function setDataColor(dataColor, dst) { if (dataColor === undefined) { return; // Nothing to do } if (dst.dataColor === undefined) { dst.dataColor = {}; } if (typeof dataColor === "string") { dst.dataColor.fill = dataColor; dst.dataColor.stroke = dataColor; } else { if (dataColor.fill) { dst.dataColor.fill = dataColor.fill; } if (dataColor.stroke) { dst.dataColor.stroke = dataColor.stroke; } if (dataColor.strokeWidth !== undefined) { dst.dataColor.strokeWidth = dataColor.strokeWidth; } } } /** * * @param {object | Array<string>} surfaceColors Either an object that describes the HUE, or an array of HTML hex color codes * @param {object} dst */ function setSurfaceColor(surfaceColors, dst) { if (surfaceColors === undefined || surfaceColors === true) { return; // Nothing to do } if (surfaceColors === false) { dst.surfaceColors = undefined; return; } if (dst.surfaceColors === undefined) { dst.surfaceColors = {}; } let rgbColors; if (Array.isArray(surfaceColors)) { rgbColors = parseColorArray(surfaceColors); } else if (typeof surfaceColors === "object") { rgbColors = parseColorObject(surfaceColors.hue); } else { throw new Error("Unsupported type of surfaceColors"); } // for some reason surfaceColors goes from vMax to vMin: rgbColors.reverse(); dst.colormap = rgbColors; } /** * * @param {object | Array<string>} colormap Either an object that describes the HUE, or an array of HTML hex color codes * @param {object} dst */ function setColormap(colormap, dst) { if (colormap === undefined) { return; } let rgbColors; if (Array.isArray(colormap)) { rgbColors = parseColorArray(colormap); } else if (typeof colormap === "object") { rgbColors = parseColorObject(colormap.hue); } else if (typeof colormap === "function") { rgbColors = colormap; } else { throw new Error("Unsupported type of colormap"); } dst.colormap = rgbColors; } /** * * @param {Array} colormap */ function parseColorArray(colormap) { if (colormap.length < 2) { throw new Error("Colormap array length must be 2 or above."); } return colormap.map(function (colorCode) { if (!util.isValidHex(colorCode)) { throw new Error(`Invalid hex color code supplied to colormap.`); } return util.hexToRGB(colorCode); }); } /** * Converts an object to a certain amount of hex color stops. At which point: * the HTML hex color codes is converted into an RGB color object. * @param {object} hues */ function parseColorObject(hues) { if (hues === undefined) { throw new Error("Unsupported type of colormap"); } if (!(hues.saturation >= 0 && hues.saturation <= 100)) { throw new Error("Saturation is out of bounds. Expected range is 0-100."); } if (!(hues.brightness >= 0 && hues.brightness <= 100)) { throw new Error("Brightness is out of bounds. Expected range is 0-100."); } if (!(hues.colorStops >= 2)) { throw new Error("colorStops is out of bounds. Expected 2 or above."); } const hueStep = (hues.end - hues.start) / (hues.colorStops - 1); const rgbColors = []; for (let i = 0; i < hues.colorStops; ++i) { const hue = ((hues.start + hueStep * i) % 360) / 360; rgbColors.push( util.HSVToRGB( hue < 0 ? hue + 1 : hue, hues.saturation / 100, hues.brightness / 100, ), ); } return rgbColors; } /** * * @param {object} cameraPosition * @param {object} dst */ function setCameraPosition(cameraPosition, dst) { const camPos = cameraPosition; if (camPos === undefined) { return; } if (dst.camera === undefined) { dst.camera = new Camera(); } dst.camera.setArmRotation(camPos.horizontal, camPos.vertical); dst.camera.setArmLength(camPos.distance); } /** * This object contains all possible options. It will check if the types are correct, if required if the option is one * of the allowed values. * * __any__ means that the name of the property does not matter. * __type__ is a required field for all objects and contains the allowed types of all objects */ const string = "string"; const bool = "boolean"; const number = "number"; const object = "object"; // should only be in a __type__ property const array = "array"; // Following not used here, but useful for reference //let dom = 'dom'; //let any = 'any'; const colorOptions = { fill: { string }, stroke: { string }, strokeWidth: { number }, __type__: { string, object, undefined: "undefined" }, }; const surfaceColorsOptions = { hue: { start: { number }, end: { number }, saturation: { number }, brightness: { number }, colorStops: { number }, __type__: { object }, }, __type__: { boolean: bool, array, object, undefined: "undefined" }, }; const colormapOptions = { hue: { start: { number }, end: { number }, saturation: { number }, brightness: { number }, colorStops: { number }, __type__: { object }, }, __type__: { array, object, function: "function", undefined: "undefined" }, }; /** * Order attempted to be alphabetical. * - x/y/z-prefixes ignored in sorting * - __type__ always at end * - globals at end */ const allOptions = { animationAutoStart: { boolean: bool, undefined: "undefined" }, animationInterval: { number }, animationPreload: { boolean: bool }, axisColor: { string }, axisFontSize: { number: number }, axisFontType: { string: string }, backgroundColor: colorOptions, xBarWidth: { number, undefined: "undefined" }, yBarWidth: { number, undefined: "undefined" }, cameraPosition: { distance: { number }, horizontal: { number }, vertical: { number }, __type__: { object }, }, zoomable: { boolean: bool }, ctrlToZoom: { boolean: bool }, xCenter: { string }, yCenter: { string }, colormap: colormapOptions, dataColor: colorOptions, dotSizeMinFraction: { number }, dotSizeMaxFraction: { number }, dotSizeRatio: { number }, filterLabel: { string }, gridColor: { string }, onclick: { function: "function" }, keepAspectRatio: { boolean: bool }, xLabel: { string }, yLabel: { string }, zLabel: { string }, legendLabel: { string }, xMin: { number, undefined: "undefined" }, yMin: { number, undefined: "undefined" }, zMin: { number, undefined: "undefined" }, xMax: { number, undefined: "undefined" }, yMax: { number, undefined: "undefined" }, zMax: { number, undefined: "undefined" }, showAnimationControls: { boolean: bool, undefined: "undefined" }, showGrayBottom: { boolean: bool }, showGrid: { boolean: bool }, showLegend: { boolean: bool, undefined: "undefined" }, showPerspective: { boolean: bool }, showShadow: { boolean: bool }, showSurfaceGrid: { boolean: bool }, showXAxis: { boolean: bool }, showYAxis: { boolean: bool }, showZAxis: { boolean: bool }, rotateAxisLabels: { boolean: bool }, surfaceColors: surfaceColorsOptions, xStep: { number, undefined: "undefined" }, yStep: { number, undefined: "undefined" }, zStep: { number, undefined: "undefined" }, style: { number, // TODO: either Graph3d.DEFAULT has string, or number allowed in documentation string: [ "bar", "bar-color", "bar-size", "dot", "dot-line", "dot-color", "dot-size", "line", "grid", "surface", ], }, tooltip: { boolean: bool, function: "function" }, tooltipDelay: { number: number }, tooltipStyle: { content: { color: { string }, background: { string }, border: { string }, borderRadius: { string }, boxShadow: { string }, padding: { string }, __type__: { object }, }, line: { borderLeft: { string }, height: { string }, width: { string }, pointerEvents: { string }, __type__: { object }, }, dot: { border: { string }, borderRadius: { string }, height: { string }, width: { string }, pointerEvents: { string }, __type__: { object }, }, __type__: { object }, }, xValueLabel: { function: "function" }, yValueLabel: { function: "function" }, zValueLabel: { function: "function" }, valueMax: { number, undefined: "undefined" }, valueMin: { number, undefined: "undefined" }, verticalRatio: { number }, //globals : height: { string }, width: { string }, __type__: { object }, }; /** * Helper class to make working with related min and max values easier. * * The range is inclusive; a given value is considered part of the range if: * * this.min <= value <= this.max */ function Range() { this.min = undefined; this.max = undefined; } /** * Adjust the range so that the passed value fits in it. * * If the value is outside of the current extremes, adjust * the min or max so that the value is within the range. * @param {number} value Numeric value to fit in range */ Range.prototype.adjust = function (value) { if (value === undefined) return; if (this.min === undefined || this.min > value) { this.min = value; } if (this.max === undefined || this.max < value) { this.max = value; } }; /** * Adjust the current range so that the passed range fits in it. * @param {Range} range Range instance to fit in current instance */ Range.prototype.combine = function (range) { this.add(range.min); this.add(range.max); }; /** * Expand the range by the given value * * min will be lowered by given value; * max will be raised by given value * * Shrinking by passing a negative value is allowed. * @param {number} val Amount by which to expand or shrink current range with */ Range.prototype.expand = function (val) { if (val === undefined) { return; } const newMin = this.min - val; const newMax = this.max + val; // Note that following allows newMin === newMax. // This should be OK, since method expand() allows this also. if (newMin > newMax) { throw new Error("Passed expansion value makes range invalid"); } this.min = newMin; this.max = newMax; }; /** * Determine the full range width of current instance. * @returns {num} The calculated width of this range */ Range.prototype.range = function () { return this.max - this.min; }; /** * Determine the central point of current instance. * @returns {number} the value in the middle of min and max */ Range.prototype.center = function () { return (this.min + this.max) / 2; }; /** * @class Filter * @param {DataGroup} dataGroup the data group * @param {number} column The index of the column to be filtered * @param {Graph3d} graph The graph */ function Filter(dataGroup, column, graph) { this.dataGroup = dataGroup; this.column = column; this.graph = graph; // the parent graph this.index = undefined; this.value = undefined; // read all distinct values and select the first one this.values = dataGroup.getDistinctValues(this.column); if (this.values.length > 0) { this.selectValue(0); } // create an array with the filtered datapoints. this will be loaded afterwards this.dataPoints = []; this.loaded = false; this.onLoadCallback = undefined; if (graph.animationPreload) { this.loaded = false; this.loadInBackground(); } else { this.loaded = true; } } /** * Return the label * @returns {string} label */ Filter.prototype.isLoaded = function () { return this.loaded; }; /** * Return the loaded progress * @returns {number} percentage between 0 and 100 */ Filter.prototype.getLoadedProgress = function () { const len = this.values.length; let i = 0; while (this.dataPoints[i]) { i++; } return Math.round((i / len) * 100); }; /** * Return the label * @returns {string} label */ Filter.prototype.getLabel = function () { return this.graph.filterLabel; }; /** * Return the columnIndex of the filter * @returns {number} columnIndex */ Filter.prototype.getColumn = function () { return this.column; }; /** * Return the currently selected value. Returns undefined if there is no selection * @returns {*} value */ Filter.prototype.getSelectedValue = function () { if (this.index === undefined) return undefined; return this.values[this.index]; }; /** * Retrieve all values of the filter * @returns {Array} values */ Filter.prototype.getValues = function () { return this.values; }; /** * Retrieve one value of the filter * @param {number} index * @returns {*} value */ Filter.prototype.getValue = function (index) { if (index >= this.values.length) throw new Error("Index out of range"); return this.values[index]; }; /** * Retrieve the (filtered) dataPoints for the currently selected filter index * @param {number} [index] (optional) * @returns {Array} dataPoints */ Filter.prototype._getDataPoints = function (index) { if (index === undefined) index = this.index; if (index === undefined) return []; let dataPoints; if (this.dataPoints[index]) { dataPoints = this.dataPoints[index]; } else { const f = {}; f.column = this.column; f.value = this.values[index]; const dataView = new DataView(this.dataGroup.getDataSet(), { filter: function (item) { return item[f.column] == f.value; }, }).get(); dataPoints = this.dataGroup._getDataPoints(dataView); this.dataPoints[index] = dataPoints; } return dataPoints; }; /** * Set a callback function when the filter is fully loaded. * @param {Function} callback */ Filter.prototype.setOnLoadCallback = function (callback) { this.onLoadCallback = callback; }; /** * Add a value to the list with available values for this filter * No double entries will be created. * @param {number} index */ Filter.prototype.selectValue = function (index) { if (index >= this.values.length) throw new Error("Index out of range"); this.index = index; this.value = this.values[index]; }; /** * Load all filtered rows in the background one by one * Start this method without providing an index! * @param {number} [index] */ Filter.prototype.loadInBackground = function (index) { if (index === undefined) index = 0; const frame = this.graph.frame; if (index < this.values.length) { // create a progress box if (frame.progress === undefined) { frame.progress = document.createElement("DIV"); frame.progress.style.position = "absolute"; frame.progress.style.color = "gray"; frame.appendChild(frame.progress); } const progress = this.getLoadedProgress(); frame.progress.innerHTML = "Loading animation... " + progress + "%"; // TODO: this is no nice solution... frame.progress.style.bottom = 60 + "px"; // TODO: use height of slider frame.progress.style.left = 10 + "px"; const me = this; setTimeout(function () { me.loadInBackground(index + 1); }, 10); this.loaded = false; } else { this.loaded = true; // remove the progress box if (frame.progress !== undefined) { frame.removeChild(frame.progress); frame.progress = undefined; } if (this.onLoadCallback) this.onLoadCallback(); } }; /** * Creates a container for all data of one specific 3D-graph. * * On construction, the container is totally empty; the data * needs to be initialized with method initializeData(). * Failure to do so will result in the following exception begin thrown * on instantiation of Graph3D: * * Error: Array, DataSet, or DataView expected * @function Object() { [native code] } DataGroup */ function DataGroup() { this.dataTable = null; // The original data table } /** * Initializes the instance from the passed data. * * Calculates minimum and maximum values and column index values. * * The graph3d instance is used internally to access the settings for * the given instance. * TODO: Pass settings only instead. * @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance. * @param {Array | DataSet | DataView} rawData The data containing the items for * the Graph. * @param {number} style Style Number * @returns {Array.<object>} */ DataGroup.prototype.initializeData = function (graph3d, rawData, style) { if (rawData === undefined) return; if (Array.isArray(rawData)) { rawData = new DataSet(rawData); } let data; if (rawData instanceof DataSet || rawData instanceof DataView) { data = rawData.get(); } else { throw new Error("Array, DataSet, or DataView expected"); } if (d