Browse Source

Major redesign of data axis/scales, with large focus on creating a sane slave axis setup

codeClimate
Ludo Stellingwerff 9 years ago
parent
commit
00436ab177
6 changed files with 285 additions and 321 deletions
  1. +1
    -0
      HISTORY.md
  2. +1
    -1
      index.js
  3. +0
    -219
      lib/timeline/DataStep.js
  4. +46
    -98
      lib/timeline/component/DataAxis.js
  5. +235
    -0
      lib/timeline/component/DataScale.js
  6. +2
    -3
      lib/timeline/component/LineGraph.js

+ 1
- 0
HISTORY.md View File

@ -29,6 +29,7 @@ http://visjs.org
### Graph2d
- Major redesign of data axis/scales, with large focus on creating a sane slave axis setup
- Fixed #1585: Allow bar groups to exclude from stacking
- Fixed #1580: Invisible timeline/graph should not be drawn, as most inputs are invalid
- Fixed #1177: Fix custom range of slaved right axis.

+ 1
- 1
index.js View File

@ -23,7 +23,6 @@ exports.Timeline = require('./lib/timeline/Timeline');
exports.Graph2d = require('./lib/timeline/Graph2d');
exports.timeline = {
Core: require('./lib/timeline/Core'),
DataStep: require('./lib/timeline/DataStep'),
DateUtil: require('./lib/timeline/DateUtil'),
Range: require('./lib/timeline/Range'),
stack: require('./lib/timeline/Stack'),
@ -43,6 +42,7 @@ exports.timeline = {
CurrentTime: require('./lib/timeline/component/CurrentTime'),
CustomTime: require('./lib/timeline/component/CustomTime'),
DataAxis: require('./lib/timeline/component/DataAxis'),
DataScale: require('./lib/timeline/component/DataScale'),
GraphGroup: require('./lib/timeline/component/GraphGroup'),
Group: require('./lib/timeline/component/Group'),
ItemSet: require('./lib/timeline/component/ItemSet'),

+ 0
- 219
lib/timeline/DataStep.js View File

@ -1,219 +0,0 @@
/**
* @constructor DataStep
* The class DataStep is an iterator for data for the lineGraph. You provide a start data point and an
* end data point. The class itself determines the best scale (step size) based on the
* provided start Date, end Date, and minimumStep.
*
* If minimumStep is provided, the step size is chosen as close as possible
* to the minimumStep but larger than minimumStep. If minimumStep is not
* provided, the scale is set to 1 DAY.
* The minimumStep should correspond with the onscreen size of about 6 characters
*
* Alternatively, you can set a scale by hand.
* After creation, you can initialize the class by executing first(). Then you
* can iterate from the start date to the end date via next(). You can check if
* the end date is reached with the function hasNext(). After each step, you can
* retrieve the current date via getCurrent().
* The DataStep has scales ranging from milliseconds, seconds, minutes, hours,
* days, to years.
*
* Version: 1.2
*
* @param {Date} [start] The start date, for example new Date(2010, 9, 21)
* or new Date(2010, 9, 21, 23, 45, 00)
* @param {Date} [end] The end date
* @param {Number} [minimumStep] Optional. Minimum step size in milliseconds
*/
function DataStep(start, end, minimumStep, containerHeight, customRange, formattingFunction, alignZeros) {
// variables
this.current = -1;
this.stepIndex = 0;
this.step = 1;
this.scale = 1;
this.formattingFunction = formattingFunction;
this.marginStart;
this.marginEnd;
this.majorSteps = [1, 2, 5, 10];
this.minorSteps = [0.25, 0.5, 1, 2];
this.alignZeros = alignZeros;
this.setRange(start, end, minimumStep, containerHeight, customRange);
}
/**
* Set a new range
* If minimumStep is provided, the step size is chosen as close as possible
* to the minimumStep but larger than minimumStep. If minimumStep is not
* provided, the scale is set to 1 DAY.
* The minimumStep should correspond with the onscreen size of about 6 characters
* @param {Number} [start] The start date and time.
* @param {Number} [end] The end date and time.
* @param {Number} [minimumStep] Optional. Minimum step size in milliseconds
*/
DataStep.prototype.setRange = function(start, end, minimumStep, containerHeight, customRange) {
if (customRange === undefined) {
customRange = {};
}
this._start = customRange.min === undefined ? start : customRange.min;
this._end = customRange.max === undefined ? end : customRange.max;
if (this._start === this._end) {
this._start = customRange.min === undefined ? this._start - 0.75 : this._start;
this._end = customRange.max === undefined ? this._end + 1 : this._end;
}
this.setMinimumStep(minimumStep, containerHeight);
this.setFirst(customRange);
};
/**
* Automatically determine the scale that bests fits the provided minimum step
* @param {Number} [minimumStep] The minimum step size in pixels
*/
DataStep.prototype.setMinimumStep = function(minimumStep, containerHeight) {
// round to floor
var range = this._end - this._start;
var safeRange = range * 1.2;
var minimumStepValue = minimumStep * (safeRange / containerHeight);
var orderOfMagnitude = Math.round(Math.log(safeRange)/Math.LN10);
var minorStepIdx = -1;
var magnitudefactor = Math.pow(10,orderOfMagnitude);
var start = 0;
if (orderOfMagnitude < 0) {
start = orderOfMagnitude;
}
var solutionFound = false;
for (var i = start; Math.abs(i) <= Math.abs(orderOfMagnitude); i++) {
magnitudefactor = Math.pow(10,i);
for (var j = 0; j < this.minorSteps.length; j++) {
var stepSize = magnitudefactor * this.minorSteps[j];
if (stepSize >= minimumStepValue) {
solutionFound = true;
minorStepIdx = j;
break;
}
}
if (solutionFound === true) {
break;
}
}
this.stepIndex = minorStepIdx;
this.scale = magnitudefactor;
this.step = magnitudefactor * this.minorSteps[minorStepIdx];
};
/**
* Round the current date to the first minor date value
* This must be executed once when the current date is set to start Date
*/
DataStep.prototype.setFirst = function(customRange) {
if (customRange === undefined) {
customRange = {};
}
var niceStart = customRange.min === undefined ? this._start - (this.scale * 2 * this.minorSteps[this.stepIndex]) : customRange.min;
var niceEnd = customRange.max === undefined ? this._end + (this.scale * this.minorSteps[this.stepIndex]) : customRange.max;
this.marginEnd = customRange.max === undefined ? this.roundToMinor(niceEnd) : customRange.max;
this.marginStart = customRange.min === undefined ? this.roundToMinor(niceStart) : customRange.min;
// if we need to align the zero's we need to make sure that there is a zero to use.
if (this.alignZeros === true && (this.marginEnd - this.marginStart) % this.step != 0) {
this.marginEnd += this.marginEnd % this.step;
}
this.marginRange = this.marginEnd - this.marginStart;
this.current = this.marginEnd;
};
DataStep.prototype.roundToMinor = function(value) {
var rounded = value - (value % (this.scale * this.minorSteps[this.stepIndex]));
if (value % (this.scale * this.minorSteps[this.stepIndex]) > 0.5 * (this.scale * this.minorSteps[this.stepIndex])) {
return rounded + (this.scale * this.minorSteps[this.stepIndex]);
}
else {
return rounded;
}
}
/**
* Check if the there is a next step
* @return {boolean} true if the current date has not passed the end date
*/
DataStep.prototype.hasNext = function () {
return (this.current >= this.marginStart);
};
/**
* Do the next step
*/
DataStep.prototype.next = function() {
var prev = this.current;
this.current -= this.step;
// safety mechanism: if current time is still unchanged, move to the end
if (this.current === prev) {
this.current = this._end;
}
};
/**
* Do the next step
*/
DataStep.prototype.previous = function() {
this.current += this.step;
this.marginEnd += this.step;
this.marginRange = this.marginEnd - this.marginStart;
};
/**
* Get the current datetime
* @return {String} current The current date
*/
DataStep.prototype.getCurrent = function() {
// prevent round-off errors when close to zero
var current = (Math.abs(this.current) < this.step / 2) ? 0 : this.current;
var returnValue = current;
if (typeof this.formattingFunction === 'function') {
return this.formattingFunction(current);
}
return '' + returnValue.toPrecision(3);
};
/**
* Check if the current value is a major value (for example when the step
* is DAY, a major value is each first day of the MONTH)
* @return {boolean} true if current date is major, else false.
*/
DataStep.prototype.isMajor = function() {
return (this.current % (this.scale * this.majorSteps[this.stepIndex]) === 0);
};
DataStep.prototype.shift = function(steps) {
if (steps < 0) {
for (let i = 0; i < -steps; i++) {
this.previous();
}
}
else if (steps > 0) {
for (let i = 0; i < steps; i++) {
this.next();
}
}
}
module.exports = DataStep;

+ 46
- 98
lib/timeline/component/DataAxis.js View File

@ -1,8 +1,7 @@
var util = require('../../util');
var DOMutil = require('../../DOMutil');
var Component = require('./Component');
var DataStep = require('../DataStep');
var DataScale = require('./DataScale');
/**
* A horizontal time axis
* @param {Object} [options] See DataAxis.setOptions for the available
@ -50,7 +49,7 @@ function DataAxis (body, options, svg, linegraphOptions) {
};
this.dom = {};
this.scale= undefined;
this.range = {start:0, end:0};
this.options = util.extend({}, this.defaultOptions);
@ -68,10 +67,10 @@ function DataAxis (body, options, svg, linegraphOptions) {
this.lineOffset = 0;
this.master = true;
this.masterAxis = null;
this.svgElements = {};
this.iconsRemoved = false;
this.groups = {};
this.amountOfGroups = 0;
@ -132,10 +131,9 @@ DataAxis.prototype.setOptions = function (options) {
'right',
'alignZeros'
];
util.selectiveExtend(fields, this.options, options);
util.selectiveDeepExtend(fields, this.options, options);
this.minWidth = Number(('' + this.options.width).replace("px",""));
if (redraw === true && this.dom.frame) {
this.hide();
this.show();
@ -248,11 +246,6 @@ DataAxis.prototype.hide = function() {
* @param end
*/
DataAxis.prototype.setRange = function (start, end) {
if (this.master === false && this.options.alignZeros === true && this.zeroCrossing != -1) {
if (start > 0) {
start = 0;
}
}
this.range.start = start;
this.range.end = end;
};
@ -264,7 +257,7 @@ DataAxis.prototype.setRange = function (start, end) {
DataAxis.prototype.redraw = function () {
var resized = false;
var activeGroups = 0;
// Make sure the line container adheres to the vertical scrolling.
this.dom.lineContainer.style.top = this.body.domProps.scrollTop + 'px';
@ -352,103 +345,60 @@ DataAxis.prototype._redrawLabels = function () {
DOMutil.prepareElements(this.DOMelements.lines);
DOMutil.prepareElements(this.DOMelements.labels);
var orientation = this.options['orientation'];
var customRange = this.options[orientation].range != undefined? this.options[orientation].range:{};
// get the range for the slaved axis
var step, stepSize, rangeStart, rangeEnd;
if (this.master === false) {
if (this.zeroCrossing !== -1 && this.options.alignZeros === true) {
if (this.range.end > 0) {
stepSize = this.range.end / this.zeroCrossing; // size of one step
rangeStart = this.range.end - this.amountOfSteps * stepSize;
rangeEnd = this.range.end;
}
else {
// all of the range (including start) has to be done before the zero crossing.
stepSize = -1 * this.range.start / (this.amountOfSteps - this.zeroCrossing); // absolute size of a step
rangeStart = this.range.start;
rangeEnd = this.range.start + stepSize * this.amountOfSteps;
}
}
else {
rangeStart = this.range.start;
rangeEnd = this.range.end;
}
//Override range with manual options:
var autoScaleEnd = true;
if (customRange.max != undefined){
this.range.end = customRange.max;
autoScaleEnd = false;
}
else {
// calculate range and step (step such that we have space for 7 characters per label)
rangeStart = this.range.start;
rangeEnd = this.range.end;
var autoScaleStart = true;
if (customRange.min != undefined){
this.range.start = customRange.min;
autoScaleStart = false;
}
var minimumStep = this.props.majorCharHeight;
this.step = step = new DataStep(
rangeStart,
rangeEnd,
minimumStep,
this.scale = new DataScale(
this.range.start,
this.range.end,
autoScaleStart,
autoScaleEnd,
this.dom.frame.offsetHeight,
this.options[this.options.orientation].range,
this.options[this.options.orientation].format,
this.master === false && this.options.alignZeros // does the step have to align zeros? only if not master and the options is on
this.props.majorCharHeight,
this.options.alignZeros,
this.options[orientation].format
);
// the slave axis needs to use the same horizontal lines as the master axis.
if (this.master === true) {
this.stepPixels = ((this.dom.frame.offsetHeight) / step.marginRange) * step.step;
this.amountOfSteps = Math.ceil(this.dom.frame.offsetHeight / this.stepPixels);
if (this.master === false && this.masterAxis != undefined){
this.scale.followScale(this.masterAxis.scale);
}
else {
// align with zero
if (this.options.alignZeros === true && this.zeroCrossing !== -1) {
// distance is the amount of steps away from the zero crossing we are.
let distance = (step.current - this.zeroCrossing * step.step) / step.step;
this.step.shift(distance);
}
}
// value at the bottom of the SVG
this.valueAtBottom = step.marginEnd;
//Is updated in side-effect of _redrawLabel():
this.maxLabelSize = 0;
var y = 0; // init value
var stepIndex = 0; // init value
var isMajor = false; // init value
while (stepIndex < this.amountOfSteps) {
y = Math.round(stepIndex * this.stepPixels);
isMajor = step.isMajor();
if (stepIndex > 0 && stepIndex !== this.amountOfSteps) {
if (this.options['showMinorLabels'] && isMajor === false || this.master === false && this.options['showMinorLabels'] === true) {
this._redrawLabel(y - 2, step.getCurrent(), orientation, 'vis-y-axis vis-minor', this.props.minorCharHeight);
}
if (isMajor && this.options['showMajorLabels'] && this.master === true ||
this.options['showMinorLabels'] === false && this.master === false && isMajor === true) {
var lines = this.scale.getLines();
lines.forEach(
line=> {
var y = line.y;
var isMajor = line.major;
if (this.options['showMinorLabels'] && isMajor === false) {
this._redrawLabel(y - 2, line.val, orientation, 'vis-y-axis vis-minor', this.props.minorCharHeight);
}
if (isMajor) {
if (y >= 0) {
this._redrawLabel(y - 2, step.getCurrent(), orientation, 'vis-y-axis vis-major', this.props.majorCharHeight);
this._redrawLabel(y - 2, line.val, orientation, 'vis-y-axis vis-major', this.props.majorCharHeight);
}
this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-major', this.options.majorLinesOffset, this.props.majorLineWidth);
}
else {
this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-minor', this.options.minorLinesOffset, this.props.minorLineWidth);
if (this.master === true) {
if (isMajor) {
this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-major', this.options.majorLinesOffset, this.props.majorLineWidth);
}
else {
this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-minor', this.options.minorLinesOffset, this.props.minorLineWidth);
}
}
}
// get zero crossing
if (this.master === true && step.current === 0) {
this.zeroCrossing = stepIndex;
}
step.next();
stepIndex += 1;
}
// get zero crossing if it's the last step
if (this.master === true && step.current === 0) {
this.zeroCrossing = stepIndex;
}
this.conversionFactor = this.stepPixels / step.step;
});
// Note that title is rotated, so we're using the height, not width!
var titleWidth = 0;
@ -485,13 +435,11 @@ DataAxis.prototype._redrawLabels = function () {
};
DataAxis.prototype.convertValue = function (value) {
var invertedValue = this.valueAtBottom - value;
var convertedValue = invertedValue * this.conversionFactor;
return convertedValue;
return this.scale.convertValue(value);
};
DataAxis.prototype.screenToValue = function (x) {
return this.valueAtBottom - (x / this.conversionFactor);
return this.scale.screenToValue(x);
};
/**

+ 235
- 0
lib/timeline/component/DataScale.js View File

@ -0,0 +1,235 @@
/**
* Created by ludo on 25-1-16.
*/
function DataScale(start, end, autoScaleStart, autoScaleEnd, containerHeight, majorCharHeight, zeroAlign = false, formattingFunction=false) {
this.majorSteps = [1, 2, 5, 10];
this.minorSteps = [0.25, 0.5, 1, 2];
this.customLines = null;
this.containerHeight = containerHeight;
this.majorCharHeight = majorCharHeight;
this._start = start;
this._end = end;
this.scale = 1;
this.minorStepIdx = -1;
this.magnitudefactor = 1;
this.determineScale();
this.zeroAlign = zeroAlign;
this.autoScaleStart = autoScaleStart;
this.autoScaleEnd = autoScaleEnd;
this.formattingFunction = formattingFunction;
if (autoScaleStart || autoScaleEnd) {
var me = this;
var roundToMinor = function (value) {
var rounded = value - (value % (me.magnitudefactor * me.minorSteps[me.minorStepIdx]));
if (value % (me.magnitudefactor * me.minorSteps[me.minorStepIdx]) > 0.5 * (me.magnitudefactor * me.minorSteps[me.minorStepIdx])) {
return rounded + (me.magnitudefactor * me.minorSteps[me.minorStepIdx]);
}
else {
return rounded;
}
};
if (autoScaleStart) {
this._start -= this.magnitudefactor * 2 * this.minorSteps[this.minorStepIdx];
this._start = roundToMinor(this._start);
}
if (autoScaleEnd) {
this._end += this.magnitudefactor * this.minorSteps[this.minorStepIdx];
this._end = roundToMinor(this._end);
}
this.determineScale();
}
}
DataScale.prototype.setCharHeight = function (majorCharHeight) {
this.majorCharHeight = majorCharHeight;
};
DataScale.prototype.setHeight = function (containerHeight) {
this.containerHeight = containerHeight;
};
DataScale.prototype.determineScale = function () {
var range = this._end - this._start;
this.scale = this.containerHeight / range;
var minimumStepValue = this.majorCharHeight / this.scale;
var orderOfMagnitude = Math.round(Math.log(range) / Math.LN10);
this.minorStepIdx = -1;
this.magnitudefactor = Math.pow(10, orderOfMagnitude);
var start = 0;
if (orderOfMagnitude < 0) {
start = orderOfMagnitude;
}
var solutionFound = false;
for (var l = start; Math.abs(l) <= Math.abs(orderOfMagnitude); l++) {
this.magnitudefactor = Math.pow(10, l);
for (var j = 0; j < this.minorSteps.length; j++) {
var stepSize = this.magnitudefactor * this.minorSteps[j];
if (stepSize >= minimumStepValue) {
solutionFound = true;
this.minorStepIdx = j;
break;
}
}
if (solutionFound === true) {
break;
}
}
};
DataScale.prototype.is_major = function (value) {
return (value % (this.magnitudefactor * this.majorSteps[this.minorStepIdx]) === 0);
};
DataScale.prototype.getStep = function(){
return this.magnitudefactor * this.minorSteps[this.minorStepIdx];
};
DataScale.prototype.getFirstMajor = function(){
var majorStep = this.magnitudefactor * this.majorSteps[this.minorStepIdx];
return this.convertValue(this._start + ((majorStep - (this._start % majorStep)) % majorStep));
};
DataScale.prototype.formatValue = function(current) {
var returnValue = current.toPrecision(5);
if (typeof this.formattingFunction === 'function') {
returnValue = this.formattingFunction(current);
}
if (typeof returnValue === 'number') {
return '' + returnValue;
}
else if (typeof returnValue === 'string') {
return returnValue;
}
else {
return current.toPrecision(5);
}
};
DataScale.prototype.getLines = function () {
var lines = [];
var step = this.getStep();
var bottomOffset = (step - (this._start % step)) % step;
for (var i = (this._start + bottomOffset); this._end-i > 0.00001; i += step) {
if (i != this._start) { //Skip the bottom line
lines.push({major: this.is_major(i), y: this.convertValue(i), val: this.formatValue(i)});
}
}
return lines;
};
DataScale.prototype.followScale = function (other) {
var oldStepIdx = this.minorStepIdx;
var oldStart = this._start;
var oldEnd = this._end;
var me = this;
var increaseMagnitude = function () {
me.magnitudefactor *= 2;
};
var decreaseMagnitude = function () {
me.magnitudefactor /= 2;
};
if ((other.minorStepIdx <= 1 && this.minorStepIdx <= 1) || (other.minorStepIdx > 1 && this.minorStepIdx > 1)) {
//easy, no need to change stepIdx nor multiplication factor
} else if (other.minorStepIdx < this.minorStepIdx) {
//I'm 5, they are 4 per major.
this.minorStepIdx = 1;
if (oldStepIdx == 2) {
increaseMagnitude();
} else {
increaseMagnitude();
increaseMagnitude();
}
} else {
//I'm 4, they are 5 per major
this.minorStepIdx = 2;
if (oldStepIdx == 1) {
decreaseMagnitude();
} else {
decreaseMagnitude();
decreaseMagnitude();
}
}
//Get masters stats:
var lines = other.getLines();
var otherZero = other.convertValue(0);
var otherStep = other.getStep() * other.scale;
var done = false;
var count = 0;
//Loop until magnitude is correct for given constrains.
while (!done && count++ <5) {
//Get my stats:
this.scale = otherStep / (this.minorSteps[this.minorStepIdx] * this.magnitudefactor);
var newRange = this.containerHeight / this.scale;
//For the case the magnitudefactor has changed:
this._start = oldStart;
this._end = this._start + newRange;
var myOriginalZero = this._end * this.scale;
var majorStep = this.magnitudefactor * this.majorSteps[this.minorStepIdx];
var majorOffset = this.getFirstMajor() - other.getFirstMajor();
if (this.zeroAlign) {
var zeroOffset = otherZero - myOriginalZero;
this._end += (zeroOffset / this.scale);
this._start = this._end - newRange;
} else {
if (!this.autoScaleStart) {
this._start += majorStep - (majorOffset / this.scale);
this._end = this._start + newRange;
} else {
this._start -= majorOffset / this.scale;
this._end = this._start + newRange;
}
}
if (!this.autoScaleEnd && this._end > oldEnd+0.00001) {
//Need to decrease magnitude to prevent scale overshoot! (end)
decreaseMagnitude();
done = false;
continue;
}
if (!this.autoScaleStart && this._start < oldStart-0.00001) {
if (this.zeroAlign && oldStart >= 0) {
console.warn("Can't adhere to given 'min' range, due to zeroalign");
} else {
//Need to decrease magnitude to prevent scale overshoot! (start)
decreaseMagnitude();
done = false;
continue;
}
}
if (this.autoScaleStart && this.autoScaleEnd && newRange < (oldEnd-oldStart)){
increaseMagnitude();
done = false;
continue;
}
done = true;
}
};
DataScale.prototype.convertValue = function (value) {
return this.containerHeight - ((value - this._start) * this.scale);
};
DataScale.prototype.screenToValue = function (pixels) {
return ((this.containerHeight - pixels) / this.scale) + this._start;
};
module.exports = DataScale;

+ 2
- 3
lib/timeline/component/LineGraph.js View File

@ -963,6 +963,8 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) {
this.yAxisRight.drawIcons = false;
}
this.yAxisRight.master = !yAxisLeftUsed;
this.yAxisRight.masterAxis = this.yAxisLeft;
if (this.yAxisRight.master == false) {
if (yAxisRightUsed == true) {
this.yAxisLeft.lineOffset = this.yAxisRight.width;
@ -972,9 +974,6 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) {
}
resized = this.yAxisLeft.redraw() || resized;
this.yAxisRight.stepPixels = this.yAxisLeft.stepPixels;
this.yAxisRight.zeroCrossing = this.yAxisLeft.zeroCrossing;
this.yAxisRight.amountOfSteps = this.yAxisLeft.amountOfSteps;
resized = this.yAxisRight.redraw() || resized;
}
else {

Loading…
Cancel
Save