diff --git a/HISTORY.md b/HISTORY.md index 355fbf1c..9bb41faf 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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. diff --git a/index.js b/index.js index 3b1bf159..d84e82e8 100644 --- a/index.js +++ b/index.js @@ -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'), diff --git a/lib/timeline/DataStep.js b/lib/timeline/DataStep.js deleted file mode 100644 index ac2ae7fd..00000000 --- a/lib/timeline/DataStep.js +++ /dev/null @@ -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; diff --git a/lib/timeline/component/DataAxis.js b/lib/timeline/component/DataAxis.js index 4055bb11..0f3ec598 100644 --- a/lib/timeline/component/DataAxis.js +++ b/lib/timeline/component/DataAxis.js @@ -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); }; /** diff --git a/lib/timeline/component/DataScale.js b/lib/timeline/component/DataScale.js new file mode 100644 index 00000000..5065f0e3 --- /dev/null +++ b/lib/timeline/component/DataScale.js @@ -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; \ No newline at end of file diff --git a/lib/timeline/component/LineGraph.js b/lib/timeline/component/LineGraph.js index e0700d8d..f2be089b 100644 --- a/lib/timeline/component/LineGraph.js +++ b/lib/timeline/component/LineGraph.js @@ -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 {