vis-graph3d
Version:
Create interactive, animated 3d graphs. Surfaces, lines, dots and block styling out of the box.
4,880 lines • 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.1
* @date 2025-07-13T08:06:02.859Z
*
* @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/esm/vis-util.js';
import { Validator, VALIDATOR_PRINT_STYLE } from 'vis-util/esnext/esm/vis-util.js';
import { DataView, DataSet } from 'vis-data/esnext/esm/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 (data.length == 0) return;
this.style = style;
// unsubscribe from the dataTable
if (this.dataSet) {
this.dataSet.off("*", this._onChange);
}
this.dataSet = rawData;
this.dataTable = data;
// subscribe to changes in the dataset
const me = this;
this._onChange = function () {
graph3d.setData(me.dataSet);
};
this.dataSet.on("*", this._onChange);
// determine the location of x,y,z,value,filter columns
this.colX = "x";
this.colY = "y";
this.colZ = "z";
const withBars = graph3d.hasBars(style);
// determine barWidth from data
if (withBars) {
if (graph3d.defaultXBarWidth !== undefined) {
this.xBarWidth = graph3d.defaultXBarWidth;
} else {
this.xBarWidth = this.getSmallestDifference(data, this.colX) || 1;
}
if (graph3d.defaultYBarWidth !== undefined) {
this.yBarWidth = graph3d.defaultYBarWidth;
} else {
this.yBarWidth = this.getSmallestDifference(data, this.colY) || 1;
}
}
// calculate minima and maxima
this._initializeRange(data, this.colX, graph3d, withBars);
this._initializeRange(data, this.colY, graph3d, withBars);
this._initializeRange(data, this.colZ, graph3d, false);
if (Object.prototype.hasOwnProperty.call(data[0], "style")) {
this.colValue = "style";
const valueRange = this.getColumnRange(data, this.colValue);
this._setRangeDefaults(
valueRange,
graph3d.defaultValueMin,
graph3d.defaultValueMax,
);
this.valueRange = valueRange;
} else {
this.colValue = "z";
this.valueRange = this.zRange;
}
// Initialize data filter if a filter column is provided
const table = this.getDataTable();
if (Object.prototype.hasOwnProperty.call(table[0], "filter")) {
if (this.dataFilter === undefined) {
this.dataFilter = new Filter(this, "filter", graph3d);
this.dataFilter.setOnLoadCallback(function () {
graph3d.redraw();
});
}
}
let dataPoints;
if (this.dataFilter) {
// apply filtering
dataPoints = this.dataFilter._getDataPoints();
} else {
// no filtering. load all data
dataPoints = this._getDataPoints(this.getDataTable());
}
return dataPoints;
};
/**
* Collect the range settings for the given data column.
*
* This internal method is intended to make the range
* initalization more generic.
*
* TODO: if/when combined settings per axis defined, get rid of this.
* @private
* @param {'x'|'y'|'z'} column The data column to process
* @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance;
* required for access to settings
* @returns {object}
*/
DataGroup.prototype._collectRangeSettings = function (column, graph3d) {
const index = ["x", "y", "z"].indexOf(column);
if (index == -1) {
throw new Error("Column '" + column + "' invalid");
}
const upper = column.toUpperCase();
return {
barWidth: this[column + "BarWidth"],
min: graph3d["default" + upper + "Min"],
max: graph3d["default" + upper + "Max"],
step: graph3d["default" + upper + "Step"],
range_label: column + "Range", // Name of instance field to write to
step_label: column + "Step", // Name of instance field to write to
};
};
/**
* Initializes the settings per given column.
*
* TODO: if/when combined settings per axis defined, rewrite this.
* @private
* @param {DataSet | DataView} data The data containing the items for the Graph
* @param {'x'|'y'|'z'} column The data column to process
* @param {vis.Graph3d} graph3d Reference to the calling Graph3D instance;
* required for access to settings
* @param {boolean} withBars True if initializing for bar graph
*/
DataGroup.prototype._initializeRange = function (
data,
column,
graph3d,
withBars,
) {
const NUMSTEPS = 5;
const settings = this._collectRangeSettings(column, graph3d);
const range = this.getColumnRange(data, column);
if (withBars && column != "z") {
// Safeguard for 'z'; it doesn't have a bar width
range.expand(settings.barWidth / 2);
}
this._setRangeDefaults(range, settings.min, settings.max);
this[settings.range_label] = range;
this[settings.step_label] =
settings.step !== undefined ? settings.step : range.range() / NUMSTEPS;
};
/**
* Creates a list with all the different values in the data for the given column.
*
* If no data passed, use the internal data of this instance.
* @param {'x'|'y'|'z'} column The data column to process
* @param {DataSet|DataView|undefined} data The data containing the items for the Graph
* @returns {Array} All distinct values in the given column data, sorted ascending.
*/
DataGroup.prototype.getDistinctValues = function (column, data) {
if (data === undefined) {
data = this.dataTable;
}
const values = [];
for (let i = 0; i < data.length; i++) {
const value = data[i][column] || 0;
if (values.indexOf(value) === -1) {
values.push(value);
}
}
return values.sort(function (a, b) {
return a - b;
});
};
/**
* Determine the smallest difference between the values for given
* column in the passed data set.
* @param {DataSet|DataView|undefined} data The data containing the items for the Graph
* @param {'x'|'y'|'z'} column The data column to process
* @returns {number|null} Smallest difference value or
* null, if it can't be determined.
*/
DataGroup.prototype.getSmallestDifference = function (data, column) {
const values = this.getDistinctValues(data, column);
// Get all the distinct diffs
// Array values is assumed to be sorted here
let smallest_diff = null;
for (let i = 1; i < values.length; i++) {
const diff = values[i] - values[i - 1];
if (smallest_diff == null || smallest_diff > diff) {
smallest_diff = diff;
}
}
return smallest_diff;
};
/**
* Get the absolute min/max values for the passed data column.
* @param {DataSet|DataView|undefined} data The data containing the items for the Graph
* @param {'x'|'y'|'z'} column The data column to process
* @returns {Range} A Range instance with min/max members properly set.
*/
DataGroup.prototype.getColumnRange = function (data, column) {
const range = new Range();
// Adjust the range so that it covers all values in the passed data elements.
for (let i = 0; i < data.length; i++) {
const item = data[i][column];
range.adjust(item);
}
return range;
};
/**
* Determines the number of rows in the current data.
* @returns {number}
*/
DataGroup.prototype.getNumberOfRows = function () {
return this.dataTable.length;
};
/**
* Set default values for range
*
* The default values override the range values, if defined.
*
* Because it's possible that only defaultMin or defaultMax is set, it's better
* to pass in a range already set with the min/max set from the data. Otherwise,
* it's quite hard to process the min/max properly.
* @param {vis.Range} range
* @param {number} [defaultMin]
* @param {number} [defaultMax]
* @private
*/
DataGroup.prototype._setRangeDefaults = function (
range,
defaultMin,
defaultMax,
) {
if (defaultMin !== undefined) {
range.min = defaultMin;
}
if (defaultMax !== undefined) {
range.max = defaultMax;
}
// This is the original way that the default min/max values were adjusted.
// TODO: Perhaps it's better if an error is thrown if the values do not agree.
// But this will change the behaviour.
if (range.max <= range.min) range.max = range.min + 1;
};
DataGroup.prototype.getDataTable = function () {
return this.dataTable;
};
DataGroup.prototype.getDataSet = function () {
return this.dataSet;
};
/**
* Return all data values as a list of Point3d objects
* @param {Array.<object>} data
* @returns {Array.<object>}
*/
DataGroup.prototype.getDataPoints = function (data) {
const dataPoints = [];
for (let i = 0; i < data.length; i++) {
const point = new Point3d();
point.x = data[i][this.colX] || 0;
point.y = data[i][this.colY] || 0;
point.z = data[i][this.colZ] || 0;
point.data = data[i];
point.value = data[i][this.colValue] || 0;
const obj = {};
obj.point = point;
obj.bottom = new Point3d(point.x, point.y, this.zRange.min);
obj.trans = undefined;
obj.screen = undefined;
dataPoints.push(obj);
}
return dataPoints;
};
/**
* Copy all values from the data table to a matrix.
*
* The provided values are supposed to form a grid of (x,y) positions.
* @param {Array.<object>} data
* @returns {Array.<object>}
* @private
*/
DataGroup.prototype.initDataAsMatrix = function (data) {
// TODO: store the created matrix dataPoints in the filters instead of
// reloading each time.
let x, y, i, obj;
// create two lists with all present x and y values
const dataX = this.getDistinctValues(this.colX, data);
const dataY = this.getDistinctValues(this.colY, data);
const dataPoints = this.getDataPoints(data);
// create a grid, a 2d matrix, with all values.
const dataMatrix = []; // temporary data matrix
for (i = 0; i < dataPoints.length; i++) {
obj = dataPoints[i];
// TODO: implement Array().indexOf() for Internet Explorer
const xIndex = dataX.indexOf(obj.point.x);
const yIndex = dataY.indexOf(obj.point.y);
if (dataMatrix[xIndex] === undefined) {
dataMatrix[xIndex] = [];
}
dataMatrix[xIndex][yIndex] = obj;
}
// fill in the pointers to the neighbors.
for (x = 0; x < dataMatrix.length; x++) {
for (y = 0; y < dataMatrix[x].length; y++) {
if (dataMatrix[x][y]) {
dataMatrix[x][y].pointRight =
x < dataMatrix.length - 1 ? dataMatrix[x + 1][y] : undefined;
dataMatrix[x][y].pointTop =
y < dataMatrix[x].length - 1 ? dataMatrix[x][y + 1] : undefined;
dataMatrix[x][y].pointCross =
x < dataMatrix.length - 1 && y < dataMatrix[x].length - 1
? dataMatrix[x + 1][y + 1]
: undefined;
}
}
}
return dataPoints;
};
/**
* Return common information, if present
* @returns {string}
*/
DataGroup.prototype.getInfo = function () {
const dataFilter = this.dataFilter;
if (!dataFilter) return undefined;
return dataFilter.getLabel() + ": " + dataFilter.getSelectedValue();
};
/**
* Reload the data
*/
DataGroup.prototype.reload = function () {
if (this.dataTable) {
this.setData(this.dataTable);
}
};
/**
* Filter the data based on the current filter
* @param {Array} data
* @returns {Array} dataPoints Array with point objects which can be drawn on
* screen
*/
DataGroup.prototype._getDataPoints = function (data) {
let dataPoints = [];
if (this.style === STYLE.GRID || this.style === STYLE.SURFACE) {
dataPoints = this.initDataAsMatrix(data);
} else {
// 'dot', 'dot-line', etc.
dataPoints = this.getDataPoints(data);
if (this.style === STYLE.LINE) {
// Add next member points for line drawing
for (let i = 0; i < dataPoints.length; i++) {
if (i > 0) {
dataPoints[i - 1].pointNext = dataPoints[i];
}
}
}
}
return dataPoints;
};
/// enumerate the available styles
Graph3d.STYLE = STYLE;
/**
* Following label is used in the settings to describe values which should be
* determined by the code while running, from the current data and graph style.
*
* Using 'undefined' directly achieves the same thing, but this is more
* descriptive by describing the intent.
*/
const autoByDefault = undefined;
/**
* Default values for option settings.
*
* These are the values used when a Graph3d instance is initialized without
* custom settings.
*
* If a field is not in this list, a default value of 'autoByDefault' is assumed,
* which is just an alias for 'undefined'.
*/
Graph3d.DEFAULTS = {
width: "400px",
height: "400px",
filterLabel: "time",
legendLabel: "value",
xLabel: "x",
yLabel: "y",
zLabel: "z",
xValueLabel: function (v) {
return v;
},
yValueLabel: function (v) {
return v;
},
zValueLabel: function (v) {
return v;
},
showXAxis: true,
showYAxis: true,
showZAxis: true,
showGrayBottom: false,
showGrid: true,
showPerspective: true,
showShadow: false,
showSurfaceGrid: true,
keepAspectRatio: true,
rotateAxisLabels: true,
verticalRatio: 0.5, // 0.1 to 1.0, where 1.0 results in a 'cube'
dotSizeRatio: 0.02, // size of the dots as a fraction of the graph width
dotSizeMinFraction: 0.5, // size of min-value dot as a fraction of dotSizeRatio
dotSizeMaxFraction: 2.5, // size of max-value dot as a fraction of dotSizeRatio
showAnimationControls: autoByDefault,
animationInterval: 1000, // milliseconds
animationPreload: false,
animationAutoStart: autoByDefault,
axisFontSize: 14,
axisFontType: "arial",
axisColor: "#4D4D4D",
gridColor: "#D3D3D3",
xCenter: "55%",
yCenter: "50%",
style: Graph3d.STYLE.DOT,
tooltip: false,
tooltipDelay: 300, // milliseconds
tooltipStyle: {
content: {
padding: "10px",
border: "1px solid #4d4d4d",
color: "#1a1a1a",
background: "rgba(255,255,255,0.7)",
borderRadius: "2px",
boxShadow: "5px 5px 10px rgba(128,128,128,0.5)",
},
line: {
height: "40px",
width: "0",
borderLeft: "1px solid #4d4d4d",
pointerEvents: "none",
},
dot: {
height: "0",
width: "0",
border: "5px solid #4d4d4d",
borderRadius: "5px",
pointerEvents: "none",
},
},
dataColor: {
fill: "#7DC1FF",
stroke: "#3267D2",
strokeWidth: 1, // px
},
surfaceColors: autoByDefault,
colormap: autoByDefault,
cameraPosition: {
horizontal: 1.0,
vertical: 0.5,
distance: 1.7,
},
zoomable: true,
ctrlToZoom: false,
/*
The following fields are 'auto by default', see above.
*/
showLegend: autoByDefault, // determined by graph style
backgroundColor: autoByDefault,
xBarWidth: autoByDefault,
yBarWidth: autoByDefault,
valueMin: autoByDefault,
valueMax: autoByDefault,
xMin: autoByDefault,
xMax: autoByDefault,
xStep: autoByDefault,
yMin: autoByDefault,
yMax: autoByDefault,
yStep: autoByDefault,
zMin: autoByDefault,
zMax: autoByDefault,
zStep: autoByDefault,
};
// -----------------------------------------------------------------------------
// Class Graph3d
// -----------------------------------------------------------------------------
/**
* Graph3d displays data in 3d.
*
* Graph3d is developed in javascript as a Google Visualization Chart.
* @function Object() { [native code] } Graph3d
* @param {Element} container The DOM element in which the Graph3d will
* be created. Normally a div element.
* @param {DataSet | DataView | Array} [data]
* @param {object} [options]
*/
function Graph3d(container, data, options) {
if (!(this instanceof Graph3d)) {
throw new SyntaxError("Constructor must be called with the new operator");
}
// create variables and set default values
this.containerElement = container;
this.dataGroup = new DataGroup();
this.dataPoints = null; // The table with point objects
// create a frame and canvas
this.create();
setDefaults(Graph3d.DEFAULTS, this);
// the column indexes
this.colX = undefined;
this.colY = undefined;
this.colZ = undefined;
this.colValue = undefined;
// TODO: customize axis range
// apply options (also when undefined)
this.setOptions(options);
// apply data
this.setData(data);
}
// Extend Graph3d with an Emitter mixin
Emitter(Graph3d.prototype);
/**
* Calculate the scaling values, dependent on the range in x, y, and z direction
*/
Graph3d.prototype._setScale = function () {
this.scale = new Point3d(
1 / this.xRange.range(),
1 / this.yRange.range(),
1 / this.zRange.range(),
);
// keep aspect ration between x and y scale if desired
if (this.keepAspectRatio) {
if (this.scale.x < this.scale.y) {
//noinspection JSSuspiciousNameCombination
this.scale.y = this.scale.x;
} else {
//noinspection JSSuspiciousNameCombination
this.scale.x = this.scale.y;
}
}
// scale the vertical axis
this.scale.z *= this.verticalRatio;
// TODO: can this be automated? verticalRatio?
// determine scale for (optional) value
if (this.valueRange !== undefined) {
this.scale.value = 1 / this.valueRange.range();
}
// position the camera arm
const xCenter = this.xRange.center() * this.scale.x;
const yCenter = this.yRange.center() * this.scale.y;
const zCenter = this.zRange.center() * this.scale.z;
this.camera.setArmLocation(xCenter, yCenter, zCenter);
};
/**
* Convert a 3D location to a 2D location on screen
* Source: ttp://en.wikipedia.org/wiki/3D_projection
* @param {Point3d} point3d A 3D point with parameters x, y, z
* @returns {Point2d} point2d A 2D point with parameters x, y
*/
Graph3d.prototype._convert3Dto2D = function (point3d) {
const translation = this._convertPointToTranslation(point3d);
return this._convertTranslationToScreen(translation);
};
/**
* Convert a 3D location its translation seen from the camera
* Source: http://en.wikipedia.org/wiki/3D_projection
* @param {Point3d} point3d A 3D point with parameters x, y, z
* @returns {Point3d} translation A 3D point with parameters x, y, z This is
* the translation of the point, seen from the
* camera.
*/
Graph3d.prototype._convertPointToTranslation = function (point3d) {
const cameraLocation = this.camera.getCameraLocation(),
cameraRotation = this.camera.getCameraRotation(),
ax = point3d.x * this.scale.x,
ay = point3d.y * this.scale.y,
az = point3d.z * this.scale.z,
cx = cameraLocation.x,
cy = cameraLocation.y,
cz = cameraLocation.z,
// calculate angles
sinTx = Math.sin(cameraRotation.x),
cosTx = Math.cos(cameraRotation.x),
sinTy = Math.sin(cameraRotation.y),
cosTy = Math.cos(cameraRotation.y),
sinTz = Math.sin(cameraRotation.z),
cosTz = Math.cos(cameraRotation.z),
// calculate translation
dx = cosTy * (sinTz * (ay - cy) + cosTz * (ax - cx)) - sinTy * (az - cz),
dy =
sinTx *
(cosTy * (az - cz) + sinTy * (sinTz * (ay - cy) + cosTz * (ax - cx))) +
cosTx * (cosTz * (ay - cy) - sinTz * (ax - cx)),
dz =
cosTx *
(cosTy * (az - cz) + sinTy * (sinTz * (ay - cy) + cosTz * (ax - cx))) -
sinTx * (cosTz * (ay - cy) - sinTz * (ax - cx));
return new Point3d(dx, dy, dz);
};
/**
* Convert a translation point to a point on the screen
* @param {Point3d} translation A 3D point with parameters x, y, z This is
* the translation of the point, seen from the
* camera.
* @returns {Point2d} point2d A 2D point with parameters x, y
*/
Graph3d.prototype._convertTranslationToScreen = function (translation) {
const ex = this.eye.x,
ey = this.eye.y,
ez = this.eye.z,
dx = translation.x,
dy = translation.y,
dz = translation.z;
// calculate position on screen from translation
let bx;
let by;
if (this.showPerspective) {
bx = (dx - ex) * (ez / dz);
by = (dy - ey) * (ez / dz);
} else {
bx = dx * -(ez / this.camera.getArmLength());
by = dy * -(ez / this.camera.getArmLength());
}
// shift and scale the point to the center of the screen
// use the width of the graph to scale both horizontally and vertically.
return new Point2d(
this.currentXCenter + bx * this.frame.canvas.clientWidth,
this.currentYCenter - by * this.frame.canvas.clientWidth,
);
};
/**
* Calculate the translations and screen positions of all points
* @param {Array.<Point3d>} points
* @private
*/
Graph3d.prototype._calcTranslations = function (points) {
for (let i = 0; i < points.length; i++) {
const point = points[i];
point.trans = this._convertPointToTranslation(point.point);
point.screen = this._convertTranslationToScreen(point.trans);
// calculate the translation of the point at the bottom (needed for sorting)
const transBottom = this._convertPointToTranslation(point.bottom);
point.dist = this.showPerspective ? transBottom.length() : -transBottom.z;
}
// sort the points on depth of their (x,y) position (not on z)
const sortDepth = function (a, b) {
return b.dist - a.dist;
};
points.sort(sortDepth);
};
/**
* Transfer min/max values to the Graph3d instance.
*/
Graph3d.prototype._initializeRanges = function () {
// TODO: later on, all min/maxes of all datagroups will be combined here
const dg = this.dataGroup;
this.xRange = dg.xRange;
this.yRange = dg.yRange;
this.zRange = dg.zRange;
this.valueRange = dg.valueRange;
// Values currently needed but which need to be sorted out for
// the multiple graph case.
this.xStep = dg.xStep;
this.yStep = dg.yStep;
this.zStep = dg.zStep;
this.xBarWidth = dg.xBarWidth;
this.yBarWidth = dg.yBarWidth;
this.colX = dg.colX;
this.colY = dg.colY;
this.colZ = dg.colZ;
this.colValue = dg.colValue;
// set the scale dependent on the ranges.
this._setScale();
};
/**
* Return all data values as a list of Point3d objects
* @param {vis.DataSet} data
* @returns {Array.<object>}
*/
Graph3d.prototype.getDataPoints = function (data) {
const dataPoints = [];
for (let i = 0; i < data.length; i++) {
const point = new Point3d();
point.x = data[i][this.colX] || 0;
point.y = data[i][this.colY] || 0;
point.z = data[i][this.colZ] || 0;
point.data = data[i];
point.value = data[i][this.colValue] || 0;
const obj = {};
obj.point = point;
obj.bottom = new Point3d(point.x, point.y, this.zRange.min);
obj.trans = undefined;
obj.screen = undefined;
dataPoints.push(obj);
}
return dataPoints;
};
/**
* Filter the data based on the current filter
* @param {Array} data
* @returns {Array} dataPoints Array with point objects which can be drawn on
* screen
*/
Graph3d.prototype._getDataPoints = function (data) {
// TODO: store the created matrix dataPoints in the filters instead of
// reloading each time.
let x, y, i, obj;
let dataPoints = [];
if (
this.style === Graph3d.STYLE.GRID ||
this.style === Graph3d.STYLE.SURFACE
) {
// copy all values from the data table to a matrix
// the provided values are supposed to form a grid of (x,y) positions
// create two lists with all present x and y values
const dataX = this.dataGroup.getDistinctValues(this.colX, data);
const dataY = this.dataGroup.getDistinctValues(this.colY, data);
dataPoints = this.getDataPoints(data);
// create a grid, a 2d matrix, with all values.
const dataMatrix = []; // temporary data matrix
for (i = 0; i < dataPoints.length; i++) {
obj = dataPoints[i];
// TODO: implement Array().indexOf() for Internet Explorer
const xIndex = dataX.indexOf(obj.point.x);
const yIndex = dataY.indexOf(obj.point.y);
if (dataMatrix[xIndex] === undefined) {
dataMatrix[xIndex] = [];
}
dataMatrix[xIndex][yIndex] = obj;
}
// fill in the pointers to the neighbors.
for (x = 0; x < dataMatrix.length; x++) {
for (y = 0; y < dataMatrix[x].length; y++) {
if (dataMatrix[x][y]) {
dataMatrix[x][y].pointRight =
x < dataMatrix.length - 1 ? dataMatrix[x + 1][y] : undefined;
dataMatrix[x][y].pointTop =
y < dataMatrix[x].length - 1 ? dataMatrix[x][y + 1] : undefined;
dataMatrix[x][y].pointCross =
x < dataMatrix.length - 1 && y < dataMatrix[x].length - 1
? dataMatrix[x + 1][y + 1]
: undefined;
}
}
}
} else {
// 'dot', 'dot-line', etc.
dataPoints = this.getDataPoints(data);
if (this.style === Graph3d.STYLE.LINE) {
// Add next member points for line drawing
for (i = 0; i < dataPoints.length; i++) {
if (i > 0) {
dataPoints[i - 1].pointNext = dataPoints[i];
}
}
}
}
return dataPoints;
};
/**
* Create the main frame for the Graph3d.
*
* This function is executed once when a Graph3d object is created. The frame
* contains a canvas, and this canvas contains all objects like the axis and
* nodes.
*/
Graph3d.prototype.create = function () {
// remove all elements from the container element.
while (this.containerElement.hasChildNodes()) {
this.containerElement.removeChild(this.containerElement.firstChild);
}
this.frame = document.createElement("div");
this.frame.style.position = "relative";
this.frame.style.overflow = "hidden";
// create the graph canvas (HTML canvas element)
this.frame.canvas = document.createElement("canvas");
this.frame.canvas.style.position = "relative";
this.frame.appendChild(this.frame.canvas);
//if (!this.frame.canvas.getContext) {
{
const noCanvas = document.createElement("DIV");
noCanvas.style.color = "red";
noCanvas.style.fontWeight = "bold";
noCanvas.style.padding = "10px";
noCanvas.innerHTML = "Error: your browser does not support HTML canvas";
this.frame.canvas.appendChild(noCanvas);
}
this.frame.filter = document.createElement("div");
this.frame.filter.style.position = "absolute";
this.frame.filter.style.bottom = "0px";
this.frame.filter.style.left = "0px";
this.frame.filter.style.width = "100%";
this.frame.appendChild(this.frame.filter);
// add event listeners to handle moving and zooming the contents
const me = this;
const onmousedown = function (event) {
me._onMouseDown(event);
};
const ontouchstart = function (event) {
me._onTouchStart(event);
};
const onmousewheel = function (event) {
me._onWheel(event);
};
const ontooltip = function (event) {
me._onTooltip(event);
};
const onclick = function (event) {
me._onClick(event);
};
// TODO: these events are never cleaned up... can give a 'memory leakage'
this.frame.canvas.addEventListener("mousedown", onmousedown);
this.frame.canvas.addEventListener("touchstart", ontouchstart);
this.frame.canvas.addEventListener("mousewheel", onmousewheel);
this.frame.canvas.addEventListener("mousemove", ontooltip);
this.frame.canvas.addEventListener("click", onclick);
// add the new graph to the container element
this.containerElement.appendChild(this.frame);
};
/**
* Set a new size for the graph
* @param {number} width
* @param {number} height
* @private
*/
Graph3d.prototype._setSize = function (width, height) {
this.frame.style.width = width;
this.frame.style.height = height;
this._resizeCanvas();
};
/**
* Resize the canvas to the current size of the frame
*/
Graph3d.prototype._resizeCanvas = function () {
this.frame.canvas.style.width = "100%";
this.frame.canvas.style.height = "100%";
this.frame.canvas.width = this.frame.canvas.clientWidth;
this.frame.canvas.height = this.frame.canvas.clientHeight;
// adjust with for margin
this.frame.filter.style.width = this.frame.canvas.clientWidth - 2 * 10 + "px";
};
/**
* Start playing the animation, if requested and filter present. Only applicable
* when animation data is available.
*/
Graph3d.prototype.animationStart = function () {
// start animation when option is true
if (!this.animationAutoStart || !this.dataGroup.dataFilter) return;
if (!this.frame.filter || !this.frame.filter.slider)
throw new Error("No animation available");
this.frame.filter.slider.play();
};
/**
* Stop animation
*/
Graph3d.prototype.animationStop = function () {
if (!this.frame.filter || !this.frame.filter.slider) return;
this.frame.filter.slider.stop();
};
/**
* Resize the center position based on the current values in this.xCenter
* and this.yCenter (which are strings with a percentage or a value
* in pixels). The center positions are the variables this.currentXCenter
* and this.currentYCenter
*/
Graph3d.prototype._resizeCenter = function () {
// calculate the horizontal center position
if (this.xCenter.charAt(this.xCenter.length - 1) === "%") {
this.currentXCenter =
(parseFloat(this.xCenter) / 100) * this.frame.canvas.clientWidth;
} else {
this.currentXCenter = parseFloat(this.xCenter); // supposed to be in px
}
// calculate the vertical center position
if (this.yCenter.charAt(this.yCenter.length - 1) === "%") {
this.currentYCenter =
(parseFloat(this.yCenter) / 100) *
(this.frame.canvas.clientHeight - this.frame.filter.clientHeight);
} else {
this.currentYCenter = parseFloat(this.yCenter); // supposed to be in px
}
};
/**
* Retrieve the current camera rotation
* @returns {object} An object with parameters horizontal, vertical, and
* distance
*/
Graph3d.prototype.getCameraPosition = function () {
const pos = this.camera.getArmRotation();
pos.distance = this.camera.getArmLength();
return pos;
};
/**
* Load data into the 3D Graph
* @param {vis.DataSet} data
* @private
*/
Graph3d.prototype._readData = function (data) {
// read the data
this.dataPoints = this.dataGroup.initializeData(this, data, this.style);
this._initializeRanges();
this._redrawFilter();
};
/**
* Replace the dataset of the Graph3d
* @param {Array | DataSet | DataView} data
*/
Graph3d.prototype.setData = function (data) {
if (data === undefined || data === null) return;
this._readData(data);
this.redraw();
this.animationStart();
};
/**
* Update the options. Options will be merged with current options
* @param {object} options
*/
Graph3d.prototype.setOptions = function (options) {
if (options === undefined) return;
const errorFound = Validator.validate(options, allOptions);
if (errorFound === true) {
console.error(
"%cErrors have been found in the supplied options object.",
VALIDATOR_PRINT_STYLE,
);
}
this.animationStop();
setOptions(options, this);
this.setPointDrawingMethod();
this._setSize(this.width, this.height);
this.setAxisLabelMethod();
this.setData(this.dataGroup.getDataTable());
this.animationStart();
};
/**
* Determine which point drawing method to use for the current graph style.
*/
Graph3d.prototype.setPointDrawingMethod = function () {
let method = undefined;
switch (this.style) {
case Graph3d.STYLE.BAR:
method = this._redrawBarGraphPoint;
break;
case Graph3d.STYLE.BARCOLOR:
method = this._redrawBarColorGraphPoint;
break;
case Graph3d.STYLE.BARSIZE:
method = this._redrawBarSizeGraphPoint;
break;
case Graph3d.STYLE.DOT:
method = this._redrawDotGraphPoint;
break;
case Graph3d.STYLE.DOTLINE:
method = this._redrawDotLineGraphPoint;
break;
case Graph3d.STYLE.DOTCOLOR:
method = this._redrawDotColorGraphPoint;
break;
case Graph3d.STYLE.DOTSIZE:
method = this._redrawDotSizeGraphPoint;
break;
case Graph3d.STYLE.SURFACE:
method = this._redrawSurfaceGraphPoint;
break;
case Graph3d.STYLE.GRID:
method = this._redrawGridGraphPoint;
break;
case Graph3d.STYLE.LINE:
method = this._redrawLineGraphPoint;
break;
default:
throw new Error(
"Can not determine point drawing method " +
"for graph style '" +
this.style +
"'",
);
}
this._pointDrawingMethod = method;
};
/**
* Determine which functions to use to draw axis labels.
*/
Graph3d.prototype.setAxisLabelMethod = function () {
if (this.rotateAxisLabels) {
this._drawAxisLabelX = this.drawAxisLabelXRotate;
this._drawAxisLabelY = this.drawAxisLabelYRotate;
this._drawAxisLabelZ = this.drawAxisLabelZRotate;
} else {
this._drawAxisLabelX = this.drawAxisLabelX;
this._drawAxisLabelY = this.drawAxisLabelY;
this._drawAxisLabelZ = this.drawAxisLabelZ;
}
};
/**
* Redraw the Graph.
*/
Graph3d.prototype.redraw = function () {
if (this.dataPoints === undefined) {
throw new Error("Graph data not initialized");
}
this._resizeCanvas();
this._resizeCenter();
this._redrawSlider();
this._redrawClear();
this._redrawAxis();
this._redrawDataGraph();
this._redrawInfo();
this._redrawLegend();
};
/**
* Get drawing context without exposing canvas
* @returns {CanvasRenderingContext2D}
* @private
*/
Graph3d.prototype._getContext = function () {
const canvas = this.frame.canvas;
const ctx = canvas.getContext("2d");
ctx.lineJoin = "round";
ctx.lineCap = "round";
return ctx;
};
/**
* Clear the canvas before redrawing
*/
Graph3d.prototype._redrawClear = function () {
const canvas = this.frame.canvas;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
Graph3d.prototype._dotSize = function () {
return this.frame.clientWidth * this.dotSizeRatio;
};
/**
* Get legend width
* @returns {*}
* @private
*/
Graph3d.prototype._getLegendWidth = function () {
let width;
if (this.style === Graph3d.STYLE.DOTSIZE) {
const dotSize = this._dotSize();
//width = dotSize / 2 + dotSize * 2;
width = dotSize * this.dotSizeMaxFraction;
} else if (this.style === Graph3d.STYLE.BARSIZE) {
width = this.xBarWidth;
} else {
width = 20;
}
return width;
};
/**
* Redraw the legend based on size, dot color, or surface height
*/
Graph3d.prototype._redrawLegend = function () {
//Return without drawing anything, if no legend is specified
if (this.showLegend !== true) {
return;
}
// Do not draw legend when graph style does not support
if (
this.style === Graph3d.STYLE.LINE ||
this.style === Graph3d.STYLE.BARSIZE //TODO add legend support for BARSIZE
) {
return;
}
// Legend types - size and color. Determine if size legend.
const isSizeLegend =
this.style === Graph3d.STYLE.BARSIZE ||
this.style === Graph3d.STYLE.DOTSIZE;
// Legend is either tracking z values or style values. This flag if false means use z values.
const isValueLegend =
this.style === Graph3d.STYLE.DOTSIZE ||
this.style === Graph3d.STYLE.DOTCOLOR ||
this.style === Graph3d.STYLE.SURFACE ||
this.style === Graph3d.STYLE.BARCOLOR;
const height = Math.max(this.frame.clientHeight * 0.25, 100);
const top = this.margin;
const width = this._getLegendWidth(); // px - overwritten by size legend
const right = this.frame.clientWidth - this.margin;
const left = right - width;
const bottom = top + height;
const ctx = this._getContext();
ctx.lineWidth = 1;
ctx.font = "14px arial"; // TODO: put in options
if (isSizeLegend === false) {
// draw the color bar
const ymin = 0;
const ymax = height; // Todo: make height customizable
let y;
for (y = ymin; y < ymax; y++) {
// Need (1 - x) because y runs from top to bottom:
const f = 1 - (y - ymin) / (ymax - ymin);
const color = this._colormap(f, 1);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(left, top + y);
ctx.lineTo(right, top + y);
ctx.stroke();
}
ctx.strokeStyle = this.axisColor;
ctx.strokeRect(left, top, width, height);
} else {
// draw the size legend box
let widthMin;
if (this.style === Graph3d.STYLE.DOTSIZE) {
// Get the proportion to max and min right
widthMin = width * (this.dotSizeMinFraction / this.dotSizeMaxFraction);
} else if (this.style === Graph3d.STYLE.BARSIZE) ;
ctx.strokeStyle = this.axisColor;
ctx.fillStyle = this.dataColor.fill;
ctx.beginPath();
ctx.moveTo(left, top);
ctx.lineTo(right, top);
ctx.lineTo(left + widthMin, bottom);
ctx.lineTo(left, bottom);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// print value text along the legend edge
const gridLineLen = 5; // px
const legendMin = isValueLegend ? this.valueRange.min : this.zRange.min;
const legendMax = isValueLegend ? this.valueRange.max : this.zRange.max;
const step = new StepNumber(
legendMin,
legendMax,
(legendMax - legendMin) / 5,
true,
);
step.start(true);
while (!step.end()) {
const y =
bottom -
((step.getCurrent() - legendMin) / (legendMax - legendMin)) * height;
const from = new Point2d(left - gridLineLen, y);
const to = new Point2d(left, y);
this._line(ctx, from, to);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(step.getCurrent(), left - 2 * gridLineLen, y);
step.next();
}
ctx.textAlign = "right";
ctx.textBaseline = "top";
const label = this.legendLabel;
ctx.fillText(label, right, bottom + this.margin);
};
/**
* Redraw the filter
*/
Graph3d.prototype._redrawFilter = function () {
const dataFilter = this.dataGroup.dataFilter;
const filter = this.frame.filter;
filter.innerHTML = "";
if (!dataFilter) {
filter.slider = undefined;
return;
}
const options = {
visible: this.showAnimationControls,
};
const slider = new Slider(filter, options);
filter.slider = slider;
// TODO: css here is not nice here...
filter.style.padding = "10px";
//this.frame.filter.style.backgroundColor = '#EFEFEF';
slider.setValues(dataFilter.values);
slider.setPlayInterval(this.animationInterval);
// create an event handler
const me = this;
const onchange = function () {
const dataFilter = me.dataGroup.dataFilter;
const index = slider.getIndex();
dataFilter.selectValue(index);
me.dataPoints = dataFilter._getDataPoints();
me.redraw();
};
slider.setOnChangeCallback(onchange);
};
/**
* Redraw the slider
*/
Graph3d.prototype._redrawSlider = function () {
if (this.frame.filter.slider !== undefined) {
this.frame.filter.slider.redraw();
}
};
/**
* Redraw common information
*/
Graph3d.prototype._redrawInfo = function () {
const info = this.dataGroup.getInfo();
if (info === undefined) return;
const ctx = this._getContext();
ctx.font = "14px arial"; // TODO: put in options
ctx.lineStyle = "gray";
ctx.fillStyle = "gray";
ctx.textAlign = "left";
ctx.textBaseline = "top";
const x = this.margin;
const y = this.margin;
ctx.fillText(info, x, y);
};
/**
* Draw a line between 2d points 'from' and 'to'.
*
* If stroke style specified, set that as well.
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point2d} from
* @param {vis.Point2d} to
* @param {string} [strokeStyle]
* @private
*/
Graph3d.prototype._line = function (ctx, from, to, strokeStyle) {
if (strokeStyle !== undefined) {
ctx.strokeStyle = strokeStyle;
}
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} armAngle
* @param {number} [yMargin]
*/
Graph3d.prototype.drawAxisLabelX = function (
ctx,
point3d,
text,
armAngle,
yMargin,
) {
if (yMargin === undefined) {
yMargin = 0;
}
const point2d = this._convert3Dto2D(point3d);
if (Math.cos(armAngle * 2) > 0) {
ctx.textAlign = "center";
ctx.textBaseline = "top";
point2d.y += yMargin;
} else if (Math.sin(armAngle * 2) < 0) {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
} else {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
}
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} armAngle
* @param {number} [yMargin]
*/
Graph3d.prototype.drawAxisLabelY = function (
ctx,
point3d,
text,
armAngle,
yMargin,
) {
if (yMargin === undefined) {
yMargin = 0;
}
const point2d = this._convert3Dto2D(point3d);
if (Math.cos(armAngle * 2) < 0) {
ctx.textAlign = "center";
ctx.textBaseline = "top";
point2d.y += yMargin;
} else if (Math.sin(armAngle * 2) > 0) {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
} else {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
}
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} [offset]
*/
Graph3d.prototype.drawAxisLabelZ = function (ctx, point3d, text, offset) {
if (offset === undefined) {
offset = 0;
}
const point2d = this._convert3Dto2D(point3d);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x - offset, point2d.y);
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} armAngle
* @param {number} [yMargin]
*/
Graph3d.prototype.drawAxisLabelXRotate = function (
ctx,
point3d,
text,
armAngle,
yMargin,
) {
const point2d = this._convert3Dto2D(point3d);
if (Math.cos(armAngle * 2) > 0) {
ctx.save();
ctx.translate(point2d.x, point2d.y);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, 0, 0);
ctx.restore();
} else if (Math.sin(armAngle * 2) < 0) {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
} else {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
}
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} armAngle
* @param {number} [yMargin]
*/
Graph3d.prototype.drawAxisLabelYRotate = function (
ctx,
point3d,
text,
armAngle,
yMargin,
) {
const point2d = this._convert3Dto2D(point3d);
if (Math.cos(armAngle * 2) < 0) {
ctx.save();
ctx.translate(point2d.x, point2d.y);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, 0, 0);
ctx.restore();
} else if (Math.sin(armAngle * 2) > 0) {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
} else {
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x, point2d.y);
}
};
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point3d} point3d
* @param {string} text
* @param {number} [offset]
*/
Graph3d.prototype.drawAxisLabelZRotate = function (ctx, point3d, text, offset) {
if (offset === undefined) {
offset = 0;
}
const point2d = this._convert3Dto2D(point3d);
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = this.axisColor;
ctx.fillText(text, point2d.x - offset, point2d.y);
};
/**
/**
Draw a line between 2d points 'from' and 'to'.
If stroke style specified, set that as well.
* @param {CanvasRenderingContext2D} ctx
* @param {vis.Point2d} from
* @param {vis.Point2d} to
* @param {string} [strokeStyle]
* @private
*/
Graph3d.prototype._line3d = function (ctx, from, to, strokeStyle) {
const from2d = this._convert3Dto2D(from);
const to2d = this._convert3Dto2D(to);
this._line(ctx, from2d, to2d, strokeStyle);
};
/**
* Redraw the axis
*/
Graph3d.prototype._redrawAxis = function () {
const ctx = this._getContext();
let from,
to,
step,
prettyStep,
text,
xText,
yText,
zText,
offset,
xOffset,
yOffset;
// TODO: get the actual rendered style of the containerElement
//ctx.font = this.containerElement.style.font;
//ctx.font = 24 / this.camera.getArmLength() + 'px arial';
ctx.font =
this.axisFontSize / this.camera.getArmLength() + "px " + this.axisFontType;
// calculate the length for the short grid lines
const gridLenX = 0.025 / this.scale.x;
const gridLenY = 0.025 / this.scale.y;
const textMargin = 5 / this.camera.getArmLength(); // px
const armAngle = this.camera.getArmRotation().horizontal;
const armVector = new Point2d(Math.cos(armAngle), Math.sin(armAngle));
const xRange = this.xRange;
const yRange = this.yRange;
const zRange = this.zRange;
let point3d;
// draw x-grid lines
ctx.lineWidth = 1;
prettyStep = this.defaultXStep === undefined;
step = new StepNumber(xRange.min, xRange.max, this.xStep, prettyStep);
step.start(true);
while (!step.end()) {
const x = step.getCurrent();
if (this.showGrid) {
from = new Point3d(x, yRange.min, zRange.min);
to = new Point3d(x, yRange.max, zRange.min);
this._line3d(ctx, from, to, this.gridColor);
} else if (this.showXAxis) {
from = new Point3d(x, yRange.min, zRange.min);
to = new Point3d(x, yRange.min + gridLenX, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
from = new Point3d(x, yRange.max, zRange.min);
to = new Point3d(x, yRange.max - gridLenX, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
}
if (this.showXAxis) {
yText = armVector.x > 0 ? yRange.min : yRange.max;
point3d = new Point3d(x, yText, zRange.min);
const msg = " " + this.xValueLabel(x) + " ";
this._drawAxisLabelX.call(this, ctx, point3d, msg, armAngle, textMargin);
}
step.next();
}
// draw y-grid lines
ctx.lineWidth = 1;
prettyStep = this.defaultYStep === undefined;
step = new StepNumber(yRange.min, yRange.max, this.yStep, prettyStep);
step.start(true);
while (!step.end()) {
const y = step.getCurrent();
if (this.showGrid) {
from = new Point3d(xRange.min, y, zRange.min);
to = new Point3d(xRange.max, y, zRange.min);
this._line3d(ctx, from, to, this.gridColor);
} else if (this.showYAxis) {
from = new Point3d(xRange.min, y, zRange.min);
to = new Point3d(xRange.min + gridLenY, y, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
from = new Point3d(xRange.max, y, zRange.min);
to = new Point3d(xRange.max - gridLenY, y, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
}
if (this.showYAxis) {
xText = armVector.y > 0 ? xRange.min : xRange.max;
point3d = new Point3d(xText, y, zRange.min);
const msg = " " + this.yValueLabel(y) + " ";
this._drawAxisLabelY.call(this, ctx, point3d, msg, armAngle, textMargin);
}
step.next();
}
// draw z-grid lines and axis
if (this.showZAxis) {
ctx.lineWidth = 1;
prettyStep = this.defaultZStep === undefined;
step = new StepNumber(zRange.min, zRange.max, this.zStep, prettyStep);
step.start(true);
xText = armVector.x > 0 ? xRange.min : xRange.max;
yText = armVector.y < 0 ? yRange.min : yRange.max;
while (!step.end()) {
const z = step.getCurrent();
// TODO: make z-grid lines really 3d?
const from3d = new Point3d(xText, yText, z);
const from2d = this._convert3Dto2D(from3d);
to = new Point2d(from2d.x - textMargin, from2d.y);
this._line(ctx, from2d, to, this.axisColor);
const msg = this.zValueLabel(z) + " ";
this._drawAxisLabelZ.call(this, ctx, from3d, msg, 5);
step.next();
}
ctx.lineWidth = 1;
from = new Point3d(xText, yText, zRange.min);
to = new Point3d(xText, yText, zRange.max);
this._line3d(ctx, from, to, this.axisColor);
}
// draw x-axis
if (this.showXAxis) {
let xMin2d;
let xMax2d;
ctx.lineWidth = 1;
// line at yMin
xMin2d = new Point3d(xRange.min, yRange.min, zRange.min);
xMax2d = new Point3d(xRange.max, yRange.min, zRange.min);
this._line3d(ctx, xMin2d, xMax2d, this.axisColor);
// line at ymax
xMin2d = new Point3d(xRange.min, yRange.max, zRange.min);
xMax2d = new Point3d(xRange.max, yRange.max, zRange.min);
this._line3d(ctx, xMin2d, xMax2d, this.axisColor);
}
// draw y-axis
if (this.showYAxis) {
ctx.lineWidth = 1;
// line at xMin
from = new Point3d(xRange.min, yRange.min, zRange.min);
to = new Point3d(xRange.min, yRange.max, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
// line at xMax
from = new Point3d(xRange.max, yRange.min, zRange.min);
to = new Point3d(xRange.max, yRange.max, zRange.min);
this._line3d(ctx, from, to, this.axisColor);
}
// draw x-label
const xLabel = this.xLabel;
if (xLabel.length > 0 && this.showXAxis) {
yOffset = 0.1 / this.scale.y;
xText = (xRange.max + 3 * xRange.min) / 4;
yText = armVector.x > 0 ? yRange.min - yOffset : yRange.max + yOffset;
text = new Point3d(xText, yText, zRange.min);
this.drawAxisLabelX(ctx, text, xLabel, armAngle);
}
// draw y-label
const yLabel = this.yLabel;
if (yLabel.length > 0 && this.showYAxis) {
xOffset = 0.1 / this.scale.x;
xText = armVector.y > 0 ? xRange.min - xOffset : xRange.max + xOffset;
yText = (yRange.max + 3 * yRange.min) / 4;
text = new Point3d(xText, yText, zRange.min);
this.drawAxisLabelY(ctx, text, yLabel, armAngle);
}
// draw z-label
const zLabel = this.zLabel;
if (zLabel.length > 0 && this.showZAxis) {
offset = 30; // pixels. // TODO: relate to the max width of the values on the z axis?
xText = armVector.x > 0 ? xRange.min : xRange.max;
yText = armVector.y < 0 ? yRange.min : yRange.max;
zText = (zRange.max + 3 * zRange.min) / 4;
text = new Point3d(xText, yText, zText);
this.drawAxisLabelZ(ctx, text, zLabel, offset);
}
};
/**
*
* @param {vis.Point3d} point
* @returns {*}
* @private
*/
Graph3d.prototype._getStrokeWidth = function (point) {
if (point !== undefined) {
if (this.showPerspective) {
return (1 / -point.trans.z) * this.dataColor.strokeWidth;
} else {
return (
-(this.eye.z / this.camera.getArmLength()) * this.dataColor.strokeWidth
);
}
}
return this.dataColor.strokeWidth;
};
// -----------------------------------------------------------------------------
// Drawing primitives for the graphs
// -----------------------------------------------------------------------------
/**
* Draw a bar element in the view with the given properties.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @param {number} xWidth
* @param {number} yWidth
* @param {string} color
* @param {string} borderColor
* @private
*/
Graph3d.prototype._redrawBar = function (
ctx,
point,
xWidth,
yWidth,
color,
borderColor,
) {
let surface;
// calculate all corner points
const me = this;
const point3d = point.point;
const zMin = this.zRange.min;
const top = [
{ point: new Point3d(point3d.x - xWidth, point3d.y - yWidth, point3d.z) },
{ point: new Point3d(point3d.x + xWidth, point3d.y - yWidth, point3d.z) },
{ point: new Point3d(point3d.x + xWidth, point3d.y + yWidth, point3d.z) },
{ point: new Point3d(point3d.x - xWidth, point3d.y + yWidth, point3d.z) },
];
const bottom = [
{ point: new Point3d(point3d.x - xWidth, point3d.y - yWidth, zMin) },
{ point: new Point3d(point3d.x + xWidth, point3d.y - yWidth, zMin) },
{ point: new Point3d(point3d.x + xWidth, point3d.y + yWidth, zMin) },
{ point: new Point3d(point3d.x - xWidth, point3d.y + yWidth, zMin) },
];
// calculate screen location of the points
top.forEach(function (obj) {
obj.screen = me._convert3Dto2D(obj.point);
});
bottom.forEach(function (obj) {
obj.screen = me._convert3Dto2D(obj.point);
});
// create five sides, calculate both corner points and center points
const surfaces = [
{ corners: top, center: Point3d.avg(bottom[0].point, bottom[2].point) },
{
corners: [top[0], top[1], bottom[1], bottom[0]],
center: Point3d.avg(bottom[1].point, bottom[0].point),
},
{
corners: [top[1], top[2], bottom[2], bottom[1]],
center: Point3d.avg(bottom[2].point, bottom[1].point),
},
{
corners: [top[2], top[3], bottom[3], bottom[2]],
center: Point3d.avg(bottom[3].point, bottom[2].point),
},
{
corners: [top[3], top[0], bottom[0], bottom[3]],
center: Point3d.avg(bottom[0].point, bottom[3].point),
},
];
point.surfaces = surfaces;
// calculate the distance of each of the surface centers to the camera
for (let j = 0; j < surfaces.length; j++) {
surface = surfaces[j];
const transCenter = this._convertPointToTranslation(surface.center);
surface.dist = this.showPerspective ? transCenter.length() : -transCenter.z;
// TODO: this dept calculation doesn't work 100% of the cases due to perspective,
// but the current solution is fast/simple and works in 99.9% of all cases
// the issue is visible in example 14, with graph.setCameraPosition({horizontal: 2.97, vertical: 0.5, distance: 0.9})
}
// order the surfaces by their (translated) depth
surfaces.sort(function (a, b) {
const diff = b.dist - a.dist;
if (diff) return diff;
// if equal depth, sort the top surface last
if (a.corners === top) return 1;
if (b.corners === top) return -1;
// both are equal
return 0;
});
// draw the ordered surfaces
ctx.lineWidth = this._getStrokeWidth(point);
ctx.strokeStyle = borderColor;
ctx.fillStyle = color;
// NOTE: we start at j=2 instead of j=0 as we don't need to draw the two surfaces at the backside
for (let j = 2; j < surfaces.length; j++) {
surface = surfaces[j];
this._polygon(ctx, surface.corners);
}
};
/**
* Draw a polygon using the passed points and fill it with the passed style and stroke.
* @param {CanvasRenderingContext2D} ctx
* @param {Array.<vis.Point3d>} points an array of points.
* @param {string} [fillStyle] the fill style to set
* @param {string} [strokeStyle] the stroke style to set
*/
Graph3d.prototype._polygon = function (ctx, points, fillStyle, strokeStyle) {
if (points.length < 2) {
return;
}
if (fillStyle !== undefined) {
ctx.fillStyle = fillStyle;
}
if (strokeStyle !== undefined) {
ctx.strokeStyle = strokeStyle;
}
ctx.beginPath();
ctx.moveTo(points[0].screen.x, points[0].screen.y);
for (let i = 1; i < points.length; ++i) {
const point = points[i];
ctx.lineTo(point.screen.x, point.screen.y);
}
ctx.closePath();
ctx.fill();
ctx.stroke(); // TODO: only draw stroke when strokeWidth > 0
};
/**
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @param {string} color
* @param {string} borderColor
* @param {number} [size]
* @private
*/
Graph3d.prototype._drawCircle = function (
ctx,
point,
color,
borderColor,
size,
) {
const radius = this._calcRadius(point, size);
ctx.lineWidth = this._getStrokeWidth(point);
ctx.strokeStyle = borderColor;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(point.screen.x, point.screen.y, radius, 0, Math.PI * 2, true);
ctx.fill();
ctx.stroke();
};
/**
* Determine the colors for the 'regular' graph styles.
* @param {object} point
* @returns {{fill, border}}
* @private
*/
Graph3d.prototype._getColorsRegular = function (point) {
const f = (point.point.value - this.valueRange.min) * this.scale.value;
const color = this._colormap(f, 1);
const borderColor = this._colormap(f, 0.8);
return {
fill: color,
border: borderColor,
};
};
/**
* Get the colors for the 'color' graph styles.
* These styles are currently: 'bar-color' and 'dot-color'
* Color may be set as a string representation of HTML color, like #ff00ff,
* or calculated from a number, for example, distance from this point
* The first option is useful when we have some pre-given legend, to which we have to adjust ourselves
* The second option is useful when we are interested in automatically setting the color, from some value,
* using some color scale
* @param {object} point
* @returns {{fill: *, border: *}}
* @private
*/
Graph3d.prototype._getColorsColor = function (point) {
// calculate the color based on the value
let color, borderColor, pointStyle;
if (point && point.point && point.point.data && point.point.data.style) {
pointStyle = point.point.data.style;
}
if (
pointStyle &&
typeof pointStyle === "object" &&
pointStyle.fill &&
pointStyle.stroke
) {
return {
fill: pointStyle.fill,
border: pointStyle.stroke,
};
}
if (typeof point.point.value === "string") {
color = point.point.value;
borderColor = point.point.value;
} else {
const f = (point.point.value - this.valueRange.min) * this.scale.value;
color = this._colormap(f, 1);
borderColor = this._colormap(f, 0.8);
}
return {
fill: color,
border: borderColor,
};
};
/**
* Get the colors for the 'size' graph styles.
* These styles are currently: 'bar-size' and 'dot-size'
* @returns {{fill: *, border: (string|colorOptions.stroke|{string, undefined}|string|colorOptions.stroke|{string}|*)}}
* @private
*/
Graph3d.prototype._getColorsSize = function () {
return {
fill: this.dataColor.fill,
border: this.dataColor.stroke,
};
};
/**
* Determine the color corresponding to a given value on the color scale.
* @param {number} [x] the data value to be mapped running from 0 to 1
* @param {number} [v] scale factor between 0 and 1 for the color brightness
* @returns {string}
* @private
*/
Graph3d.prototype._colormap = function (x, v = 1) {
let r, g, b, a;
const colormap = this.colormap;
if (Array.isArray(colormap)) {
const maxIndex = colormap.length - 1;
const startIndex = Math.max(Math.floor(x * maxIndex), 0);
const endIndex = Math.min(startIndex + 1, maxIndex);
const innerRatio = x * maxIndex - startIndex;
const min = colormap[startIndex];
const max = colormap[endIndex];
r = min.r + innerRatio * (max.r - min.r);
g = min.g + innerRatio * (max.g - min.g);
b = min.b + innerRatio * (max.b - min.b);
} else if (typeof colormap === "function") {
({ r, g, b, a } = colormap(x));
} else {
const hue = (1 - x) * 240;
({ r, g, b } = util.HSVToRGB(hue / 360, 1, 1));
}
if (typeof a === "number" && !Number.isNaN(a)) {
return `RGBA(${Math.round(r * v)}, ${Math.round(g * v)}, ${Math.round(
b * v,
)}, ${a})`;
} else {
return `RGB(${Math.round(r * v)}, ${Math.round(g * v)}, ${Math.round(
b * v,
)})`;
}
};
/**
* Determine the size of a point on-screen, as determined by the
* distance to the camera.
* @param {object} point
* @param {number} [size] the size that needs to be translated to screen coordinates.
* optional; if not passed, use the default point size.
* @returns {number}
* @private
*/
Graph3d.prototype._calcRadius = function (point, size) {
if (size === undefined) {
size = this._dotSize();
}
let radius;
if (this.showPerspective) {
radius = size / -point.trans.z;
} else {
radius = size * -(this.eye.z / this.camera.getArmLength());
}
if (radius < 0) {
radius = 0;
}
return radius;
};
// -----------------------------------------------------------------------------
// Methods for drawing points per graph style.
// -----------------------------------------------------------------------------
/**
* Draw single datapoint for graph style 'bar'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawBarGraphPoint = function (ctx, point) {
const xWidth = this.xBarWidth / 2;
const yWidth = this.yBarWidth / 2;
const colors = this._getColorsRegular(point);
this._redrawBar(ctx, point, xWidth, yWidth, colors.fill, colors.border);
};
/**
* Draw single datapoint for graph style 'bar-color'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawBarColorGraphPoint = function (ctx, point) {
const xWidth = this.xBarWidth / 2;
const yWidth = this.yBarWidth / 2;
const colors = this._getColorsColor(point);
this._redrawBar(ctx, point, xWidth, yWidth, colors.fill, colors.border);
};
/**
* Draw single datapoint for graph style 'bar-size'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawBarSizeGraphPoint = function (ctx, point) {
// calculate size for the bar
const fraction =
(point.point.value - this.valueRange.min) / this.valueRange.range();
const xWidth = (this.xBarWidth / 2) * (fraction * 0.8 + 0.2);
const yWidth = (this.yBarWidth / 2) * (fraction * 0.8 + 0.2);
const colors = this._getColorsSize();
this._redrawBar(ctx, point, xWidth, yWidth, colors.fill, colors.border);
};
/**
* Draw single datapoint for graph style 'dot'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawDotGraphPoint = function (ctx, point) {
const colors = this._getColorsRegular(point);
this._drawCircle(ctx, point, colors.fill, colors.border);
};
/**
* Draw single datapoint for graph style 'dot-line'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawDotLineGraphPoint = function (ctx, point) {
// draw a vertical line from the XY-plane to the graph value
const from = this._convert3Dto2D(point.bottom);
ctx.lineWidth = 1;
this._line(ctx, from, point.screen, this.gridColor);
this._redrawDotGraphPoint(ctx, point);
};
/**
* Draw single datapoint for graph style 'dot-color'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawDotColorGraphPoint = function (ctx, point) {
const colors = this._getColorsColor(point);
this._drawCircle(ctx, point, colors.fill, colors.border);
};
/**
* Draw single datapoint for graph style 'dot-size'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawDotSizeGraphPoint = function (ctx, point) {
const dotSize = this._dotSize();
const fraction =
(point.point.value - this.valueRange.min) / this.valueRange.range();
const sizeMin = dotSize * this.dotSizeMinFraction;
const sizeRange = dotSize * this.dotSizeMaxFraction - sizeMin;
const size = sizeMin + sizeRange * fraction;
const colors = this._getColorsSize();
this._drawCircle(ctx, point, colors.fill, colors.border, size);
};
/**
* Draw single datapoint for graph style 'surface'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawSurfaceGraphPoint = function (ctx, point) {
const right = point.pointRight;
const top = point.pointTop;
const cross = point.pointCross;
if (
point === undefined ||
right === undefined ||
top === undefined ||
cross === undefined
) {
return;
}
let topSideVisible = true;
let fillStyle;
let strokeStyle;
let cosViewAngle;
if (this.showGrayBottom || this.showShadow) {
// calculate the cross product of the two vectors from center
// to left and right, in order to know whether we are looking at the
// bottom or at the top side. We can also use the cross product
// for calculating light intensity
const aDiff = Point3d.subtract(cross.trans, point.trans);
const bDiff = Point3d.subtract(top.trans, right.trans);
const surfaceNormal = Point3d.crossProduct(aDiff, bDiff);
if (this.showPerspective) {
const surfacePosition = Point3d.avg(
Point3d.avg(point.trans, cross.trans),
Point3d.avg(right.trans, top.trans),
);
// This corresponds to diffuse lighting with light source at (0, 0, 0).
// More generally, we would need `surfacePosition - lightPosition`:
cosViewAngle = -Point3d.dotProduct(
surfaceNormal.normalize(),
surfacePosition.normalize(),
);
} else {
cosViewAngle = surfaceNormal.z / surfaceNormal.length();
}
topSideVisible = cosViewAngle > 0;
}
if (topSideVisible || !this.showGrayBottom) {
const vAvg =
(point.point.value +
right.point.value +
top.point.value +
cross.point.value) /
4;
const ratio = (vAvg - this.valueRange.min) * this.scale.value;
// lighting factor. TODO: let user specify lighting model as function(?)
const v = this.showShadow ? (1 + cosViewAngle) / 2 : 1;
fillStyle = this._colormap(ratio, v);
} else {
fillStyle = "gray";
}
if (this.showSurfaceGrid) {
strokeStyle = this.axisColor; // TODO: should be customizable
} else {
strokeStyle = fillStyle;
}
ctx.lineWidth = this._getStrokeWidth(point);
// TODO: only draw stroke when strokeWidth > 0
const points = [point, right, cross, top];
this._polygon(ctx, points, fillStyle, strokeStyle);
};
/**
* Helper method for _redrawGridGraphPoint()
* @param {CanvasRenderingContext2D} ctx
* @param {object} from
* @param {object} to
* @private
*/
Graph3d.prototype._drawGridLine = function (ctx, from, to) {
if (from === undefined || to === undefined) {
return;
}
const vAvg = (from.point.value + to.point.value) / 2;
const f = (vAvg - this.valueRange.min) * this.scale.value;
ctx.lineWidth = this._getStrokeWidth(from) * 2;
ctx.strokeStyle = this._colormap(f, 1);
this._line(ctx, from.screen, to.screen);
};
/**
* Draw single datapoint for graph style 'Grid'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawGridGraphPoint = function (ctx, point) {
this._drawGridLine(ctx, point, point.pointRight);
this._drawGridLine(ctx, point, point.pointTop);
};
/**
* Draw single datapoint for graph style 'line'.
* @param {CanvasRenderingContext2D} ctx
* @param {object} point
* @private
*/
Graph3d.prototype._redrawLineGraphPoint = function (ctx, point) {
if (point.pointNext === undefined) {
return;
}
ctx.lineWidth = this._getStrokeWidth(point);
ctx.strokeStyle = this.dataColor.stroke;
this._line(ctx, point.screen, point.pointNext.screen);
};
/**
* Draw all datapoints for currently selected graph style.
*
*/
Graph3d.prototype._redrawDataGraph = function () {
const ctx = this._getContext();
let i;
if (this.dataPoints === undefined || this.dataPoints.length <= 0) return; // TODO: throw exception?
this._calcTranslations(this.dataPoints);
for (i = 0; i < this.dataPoints.length; i++) {
const point = this.dataPoints[i];
// Using call() ensures that the correct context is used
this._pointDrawingMethod.call(this, ctx, point);
}
};
// -----------------------------------------------------------------------------
// End methods for drawing points per graph style.
// -----------------------------------------------------------------------------
/**
* Store startX, startY and startOffset for mouse operations
* @param {Event} event The event that occurred
*/
Graph3d.prototype._storeMousePosition = function (event) {
// get mouse position (different code for IE and all other browsers)
this.startMouseX = getMouseX(event);
this.startMouseY = getMouseY(event);
this._startCameraOffset = this.camera.getOffset();
};
/**
* Start a moving operation inside the provided parent element
* @param {Event} event The event that occurred (required for
* retrieving the mouse position)
*/
Graph3d.prototype._onMouseDown = function (event) {
event = event || window.event;
// check if mouse is still down (may be up when focus is lost for example
// in an iframe)
if (this.leftButtonDown) {
this._onMouseUp(event);
}
// only react on left mouse button down
this.leftButtonDown = event.which ? event.which === 1 : event.button === 1;
if (!this.leftButtonDown && !this.touchDown) return;
this._storeMousePosition(event);
this.startStart = new Date(this.start);
this.startEnd = new Date(this.end);
this.startArmRotation = this.camera.getArmRotation();
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", me.onmousemove);
document.addEventListener("mouseup", me.onmouseup);
util.preventDefault(event);
};
/**
* Perform moving operating.
* This function activated from within the funcion Graph.mouseDown().
* @param {Event} event Well, eehh, the event
*/
Graph3d.prototype._onMouseMove = function (event) {
this.moving = true;
event = event || window.event;
// calculate change in mouse position
const diffX = parseFloat(getMouseX(event)) - this.startMouseX;
const diffY = parseFloat(getMouseY(event)) - this.startMouseY;
// move with ctrl or rotate by other
if (event && event.ctrlKey === true) {
// calculate change in mouse position
const scaleX = this.frame.clientWidth * 0.5;
const scaleY = this.frame.clientHeight * 0.5;
const offXNew =
(this._startCameraOffset.x || 0) -
(diffX / scaleX) * this.camera.armLength * 0.8;
const offYNew =
(this._startCameraOffset.y || 0) +
(diffY / scaleY) * this.camera.armLength * 0.8;
this.camera.setOffset(offXNew, offYNew);
this._storeMousePosition(event);
} else {
let horizontalNew = this.startArmRotation.horizontal + diffX / 200;
let verticalNew = this.startArmRotation.vertical + diffY / 200;
const snapAngle = 4; // degrees
const snapValue = Math.sin((snapAngle / 360) * 2 * Math.PI);
// snap horizontally to nice angles at 0pi, 0.5pi, 1pi, 1.5pi, etc...
// the -0.001 is to take care that the vertical axis is always drawn at the left front corner
if (Math.abs(Math.sin(horizontalNew)) < snapValue) {
horizontalNew = Math.round(horizontalNew / Math.PI) * Math.PI - 0.001;
}
if (Math.abs(Math.cos(horizontalNew)) < snapValue) {
horizontalNew =
(Math.round(horizontalNew / Math.PI - 0.5) + 0.5) * Math.PI - 0.001;
}
// snap vertically to nice angles
if (Math.abs(Math.sin(verticalNew)) < snapValue) {
verticalNew = Math.round(verticalNew / Math.PI) * Math.PI;
}
if (Math.abs(Math.cos(verticalNew)) < snapValue) {
verticalNew = (Math.round(verticalNew / Math.PI - 0.5) + 0.5) * Math.PI;
}
this.camera.setArmRotation(horizontalNew, verticalNew);
}
this.redraw();
// fire a cameraPositionChange event
const parameters = this.getCameraPosition();
this.emit("cameraPositionChange", parameters);
util.preventDefault(event);
};
/**
* Stop moving operating.
* This function activated from within the funcion Graph.mouseDown().
* @param {Event} event The event
*/
Graph3d.prototype._onMouseUp = function (event) {
this.frame.style.cursor = "auto";
this.leftButtonDown = false;
// remove event listeners here
document.removeEventListener("mousemove", this.onmousemove);
document.removeEventListener("mouseup", this.onmouseup);
util.preventDefault(event);
};
/**
* @param {Event} event The event
*/
Graph3d.prototype._onClick = function (event) {
// NOTE: onclick_callback is deprecated and may be removed in a future version.
if (!this.onclick_callback && !this.hasListeners("click")) return;
if (!this.moving) {
const boundingRect = this.frame.getBoundingClientRect();
const mouseX = getMouseX(event) - boundingRect.left;
const mouseY = getMouseY(event) - boundingRect.top;
const dataPoint = this._dataPointFromXY(mouseX, mouseY);
if (dataPoint) {
if (this.onclick_callback) this.onclick_callback(dataPoint.point.data);
this.emit("click", dataPoint.point.data);
}
} else {
// disable onclick callback, if it came immediately after rotate/pan
this.moving = false;
}
util.preventDefault(event);
};
/**
* After having moved the mouse, a tooltip should pop up when the mouse is resting on a data point
* @param {Event} event A mouse move event
*/
Graph3d.prototype._onTooltip = function (event) {
const delay = this.tooltipDelay; // ms
const boundingRect = this.frame.getBoundingClientRect();
const mouseX = getMouseX(event) - boundingRect.left;
const mouseY = getMouseY(event) - boundingRect.top;
if (!this.showTooltip) {
return;
}
if (this.tooltipTimeout) {
clearTimeout(this.tooltipTimeout);
}
// (delayed) display of a tooltip only if no mouse button is down
if (this.leftButtonDown) {
this._hideTooltip();
return;
}
if (this.tooltip && this.tooltip.dataPoint) {
// tooltip is currently visible
const dataPoint = this._dataPointFromXY(mouseX, mouseY);
if (dataPoint !== this.tooltip.dataPoint) {
// datapoint changed
if (dataPoint) {
this._showTooltip(dataPoint);
} else {
this._hideTooltip();
}
}
} else {
// tooltip is currently not visible
const me = this;
this.tooltipTimeout = setTimeout(function () {
me.tooltipTimeout = null;
// show a tooltip if we have a data point
const dataPoint = me._dataPointFromXY(mouseX, mouseY);
if (dataPoint) {
me._showTooltip(dataPoint);
}
}, delay);
}
};
/**
* Event handler for touchstart event on mobile devices
* @param {Event} event The event
*/
Graph3d.prototype._onTouchStart = function (event) {
this.touchDown = true;
const me = this;
this.ontouchmove = function (event) {
me._onTouchMove(event);
};
this.ontouchend = function (event) {
me._onTouchEnd(event);
};
document.addEventListener("touchmove", me.ontouchmove);
document.addEventListener("touchend", me.ontouchend);
this._onMouseDown(event);
};
/**
* Event handler for touchmove event on mobile devices
* @param {Event} event The event
*/
Graph3d.prototype._onTouchMove = function (event) {
this._onMouseMove(event);
};
/**
* Event handler for touchend event on mobile devices
* @param {Event} event The event
*/
Graph3d.prototype._onTouchEnd = function (event) {
this.touchDown = false;
document.removeEventListener("touchmove", this.ontouchmove);
document.removeEventListener("touchend", this.ontouchend);
this._onMouseUp(event);
};
/**
* Event handler for mouse wheel event, used to zoom the graph
* Code from http://adomas.org/javascript-mouse-wheel/
* @param {Event} event The event
*/
Graph3d.prototype._onWheel = function (event) {
if (!event) /* For IE. */ event = window.event;
if (this.zoomable && (!this.ctrlToZoom || event.ctrlKey)) {
// retrieve delta
let delta = 0;
if (event.wheelDelta) {
/* IE/Opera. */
delta = event.wheelDelta / 120;
} else if (event.detail) {
/* Mozilla case. */
// In Mozilla, sign of delta is different than in IE.
// Also, delta is multiple of 3.
delta = -event.detail / 3;
}
// If delta is nonzero, handle it.
// Basically, delta is now positive if wheel was scrolled up,
// and negative, if wheel was scrolled down.
if (delta) {
const oldLength = this.camera.getArmLength();
const newLength = oldLength * (1 - delta / 10);
this.camera.setArmLength(newLength);
this.redraw();
this._hideTooltip();
}
// fire a cameraPositionChange event
const parameters = this.getCameraPosition();
this.emit("cameraPositionChange", parameters);
// Prevent default actions caused by mouse wheel.
// That might be ugly, but we handle scrolls somehow
// anyway, so don't bother here..
util.preventDefault(event);
}
};
/**
* Test whether a point lies inside given 2D triangle
* @param {vis.Point2d} point
* @param {vis.Point2d[]} triangle
* @returns {boolean} true if given point lies inside or on the edge of the
* triangle, false otherwise
* @private
*/
Graph3d.prototype._insideTriangle = function (point, triangle) {
const a = triangle[0],
b = triangle[1],
c = triangle[2];
/**
*
* @param {number} x
* @returns {number}
*/
function sign(x) {
return x > 0 ? 1 : x < 0 ? -1 : 0;
}
const as = sign(
(b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x),
);
const bs = sign(
(c.x - b.x) * (point.y - b.y) - (c.y - b.y) * (point.x - b.x),
);
const cs = sign(
(a.x - c.x) * (point.y - c.y) - (a.y - c.y) * (point.x - c.x),
);
// each of the three signs must be either equal to each other or zero
return (
(as == 0 || bs == 0 || as == bs) &&
(bs == 0 || cs == 0 || bs == cs) &&
(as == 0 || cs == 0 || as == cs)
);
};
/**
* Find a data point close to given screen position (x, y)
* @param {number} x
* @param {number} y
* @returns {object | null} The closest data point or null if not close to any
* data point
* @private
*/
Graph3d.prototype._dataPointFromXY = function (x, y) {
const distMax = 100; // px
const center = new Point2d(x, y);
let i,
dataPoint = null,
closestDataPoint = null,
closestDist = null;
if (
this.style === Graph3d.STYLE.BAR ||
this.style === Graph3d.STYLE.BARCOLOR ||
this.style === Graph3d.STYLE.BARSIZE
) {
// the data points are ordered from far away to closest
for (i = this.dataPoints.length - 1; i >= 0; i--) {
dataPoint = this.dataPoints[i];
const surfaces = dataPoint.surfaces;
if (surfaces) {
for (let s = surfaces.length - 1; s >= 0; s--) {
// split each surface in two triangles, and see if the center point is inside one of these
const surface = surfaces[s];
const corners = surface.corners;
const triangle1 = [
corners[0].screen,
corners[1].screen,
corners[2].screen,
];
const triangle2 = [
corners[2].screen,
corners[3].screen,
corners[0].screen,
];
if (
this._insideTriangle(center, triangle1) ||
this._insideTriangle(center, triangle2)
) {
// return immediately at the first hit
return dataPoint;
}
}
}
}
} else {
// find the closest data point, using distance to the center of the point on 2d screen
for (i = 0; i < this.dataPoints.length; i++) {
dataPoint = this.dataPoints[i];
const point = dataPoint.screen;
if (point) {
const distX = Math.abs(x - point.x);
const distY = Math.abs(y - point.y);
const dist = Math.sqrt(distX * distX + distY * distY);
if ((closestDist === null || dist < closestDist) && dist < distMax) {
closestDist = dist;
closestDataPoint = dataPoint;
}
}
}
}
return closestDataPoint;
};
/**
* Determine if the given style has bars
* @param {number} style the style to check
* @returns {boolean} true if bar style, false otherwise
*/
Graph3d.prototype.hasBars = function (style) {
return (
style == Graph3d.STYLE.BAR ||
style == Graph3d.STYLE.BARCOLOR ||
style == Graph3d.STYLE.BARSIZE
);
};
/**
* Display a tooltip for given data point
* @param {object} dataPoint
* @private
*/
Graph3d.prototype._showTooltip = function (dataPoint) {
let content, line, dot;
if (!this.tooltip) {
content = document.createElement("div");
Object.assign(content.style, {}, this.tooltipStyle.content);
content.style.position = "absolute";
line = document.createElement("div");
Object.assign(line.style, {}, this.tooltipStyle.line);
line.style.position = "absolute";
dot = document.createElement("div");
Object.assign(dot.style, {}, this.tooltipStyle.dot);
dot.style.position = "absolute";
this.tooltip = {
dataPoint: null,
dom: {
content: content,
line: line,
dot: dot,
},
};
} else {
content = this.tooltip.dom.content;
line = this.tooltip.dom.line;
dot = this.tooltip.dom.dot;
}
this._hideTooltip();
this.tooltip.dataPoint = dataPoint;
if (typeof this.showTooltip === "function") {
content.innerHTML = this.showTooltip(dataPoint.point);
} else {
content.innerHTML =
"<table>" +
"<tr><td>" +
this.xLabel +
":</td><td>" +
dataPoint.point.x +
"</td></tr>" +
"<tr><td>" +
this.yLabel +
":</td><td>" +
dataPoint.point.y +
"</td></tr>" +
"<tr><td>" +
this.zLabel +
":</td><td>" +
dataPoint.point.z +
"</td></tr>" +
"</table>";
}
content.style.left = "0";
content.style.top = "0";
this.frame.appendChild(content);
this.frame.appendChild(line);
this.frame.appendChild(dot);
// calculate sizes
const contentWidth = content.offsetWidth;
const contentHeight = content.offsetHeight;
const lineHeight = line.offsetHeight;
const dotWidth = dot.offsetWidth;
const dotHeight = dot.offsetHeight;
let left = dataPoint.screen.x - contentWidth / 2;
left = Math.min(
Math.max(left, 10),
this.frame.clientWidth - 10 - contentWidth,
);
line.style.left = dataPoint.screen.x + "px";
line.style.top = dataPoint.screen.y - lineHeight + "px";
content.style.left = left + "px";
content.style.top = dataPoint.screen.y - lineHeight - contentHeight + "px";
dot.style.left = dataPoint.screen.x - dotWidth / 2 + "px";
dot.style.top = dataPoint.screen.y - dotHeight / 2 + "px";
};
/**
* Hide the tooltip when displayed
* @private
*/
Graph3d.prototype._hideTooltip = function () {
if (this.tooltip) {
this.tooltip.dataPoint = null;
for (const prop in this.tooltip.dom) {
if (Object.prototype.hasOwnProperty.call(this.tooltip.dom, prop)) {
const elem = this.tooltip.dom[prop];
if (elem && elem.parentNode) {
elem.parentNode.removeChild(elem);
}
}
}
}
};
/**--------------------------------------------------------------------------**/
/**
* Get the horizontal mouse position from a mouse event
* @param {Event} event
* @returns {number} mouse x
*/
function getMouseX(event) {
if ("clientX" in event) return event.clientX;
return (event.targetTouches[0] && event.targetTouches[0].clientX) || 0;
}
/**
* Get the vertical mouse position from a mouse event
* @param {Event} event
* @returns {number} mouse y
*/
function getMouseY(event) {
if ("clientY" in event) return event.clientY;
return (event.targetTouches[0] && event.targetTouches[0].clientY) || 0;
}
// -----------------------------------------------------------------------------
// Public methods for specific settings
// -----------------------------------------------------------------------------
/**
* Set the rotation and distance of the camera
* @param {object} pos An object with the camera position
* @param {number} [pos.horizontal] The horizontal rotation, between 0 and 2*PI.
* Optional, can be left undefined.
* @param {number} [pos.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.
* @param {number} [pos.distance] The (normalized) distance of the camera to the
* center of the graph, a value between 0.71 and
* 5.0. Optional, can be left undefined.
*/
Graph3d.prototype.setCameraPosition = function (pos) {
setCameraPosition(pos, this);
this.redraw();
};
/**
* Set a new size for the graph
* @param {string} width Width in pixels or percentage (for example '800px'
* or '50%')
* @param {string} height Height in pixels or percentage (for example '400px'
* or '30%')
*/
Graph3d.prototype.setSize = function (width, height) {
this._setSize(width, height);
this.redraw();
};
export { Graph3d, Camera as Graph3dCamera, Filter as Graph3dFilter, Point2d as Graph3dPoint2d, Point3d as Graph3dPoint3d, Slider as Graph3dSlider, StepNumber as Graph3dStepNumber };
//# sourceMappingURL=vis-graph3d.mjs.map