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