diff --git a/README.md b/README.md index 432b000..b849647 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
- -

Frappé Charts

+ +

Frappe Charts

GitHub-inspired modern, intuitive and responsive charts with zero dependencies

diff --git a/dist/frappe-charts.esm.js b/dist/frappe-charts.esm.js index 4e6b081..0c91fbb 100644 --- a/dist/frappe-charts.esm.js +++ b/dist/frappe-charts.esm.js @@ -35,7 +35,7 @@ $.create = (tag, o) => { return element; }; -function offset(element) { +function getOffset(element) { let rect = element.getBoundingClientRect(); return { // https://stackoverflow.com/a/7436602/6495043 @@ -66,31 +66,11 @@ function getElementContentWidth(element) { return element.clientWidth - padding; } -$.bind = (element, o) => { - if (element) { - for (var event in o) { - var callback = o[event]; - event.split(/\s+/).forEach(function (event) { - element.addEventListener(event, callback); - }); - } - } -}; -$.unbind = (element, o) => { - if (element) { - for (var event in o) { - var callback = o[event]; - event.split(/\s+/).forEach(function(event) { - element.removeEventListener(event, callback); - }); - } - } -}; -$.fire = (target, type, properties) => { +function fire(target, type, properties) { var evt = document.createEvent("HTMLEvents"); evt.initEvent(type, true, true ); @@ -100,34 +80,339 @@ $.fire = (target, type, properties) => { } return target.dispatchEvent(evt); -}; +} + +class SvgTip { + constructor({ + parent = null, + colors = [] + }) { + this.parent = parent; + this.colors = colors; + this.titleName = ''; + this.titleValue = ''; + this.listValues = []; + this.titleValueFirst = 0; + + this.x = 0; + this.y = 0; + + this.top = 0; + this.left = 0; + + this.setup(); + } + + setup() { + this.makeTooltip(); + } + + refresh() { + this.fill(); + this.calcPosition(); + // this.showTip(); + } + + makeTooltip() { + this.container = $.create('div', { + inside: this.parent, + className: 'graph-svg-tip comparison', + innerHTML: ` + +
` + }); + this.hideTip(); + + this.title = this.container.querySelector('.title'); + this.dataPointList = this.container.querySelector('.data-point-list'); + + this.parent.addEventListener('mouseleave', () => { + this.hideTip(); + }); + } + + fill() { + let title; + if(this.index) { + this.container.setAttribute('data-point-index', this.index); + } + if(this.titleValueFirst) { + title = `${this.titleValue}${this.titleName}`; + } else { + title = `${this.titleName}${this.titleValue}`; + } + this.title.innerHTML = title; + this.dataPointList.innerHTML = ''; + + this.listValues.map((set, i) => { + const color = this.colors[i] || 'black'; + + let li = $.create('li', { + styles: { + 'border-top': `3px solid ${color}` + }, + innerHTML: `${ set.value === 0 || set.value ? set.value : '' } + ${set.title ? set.title : '' }` + }); + + this.dataPointList.appendChild(li); + }); + } + + calcPosition() { + let width = this.container.offsetWidth; + + this.top = this.y - this.container.offsetHeight; + this.left = this.x - width/2; + let maxLeft = this.parent.offsetWidth - width; + + let pointer = this.container.querySelector('.svg-pointer'); + + if(this.left < 0) { + pointer.style.left = `calc(50% - ${-1 * this.left}px)`; + this.left = 0; + } else if(this.left > maxLeft) { + let delta = this.left - maxLeft; + let pointerOffset = `calc(50% + ${delta}px)`; + pointer.style.left = pointerOffset; + + this.left = maxLeft; + } else { + pointer.style.left = `50%`; + } + } + + setValues(x, y, title = {}, listValues = [], index = -1) { + this.titleName = title.name; + this.titleValue = title.value; + this.listValues = listValues; + this.x = x; + this.y = y; + this.titleValueFirst = title.valueFirst || 0; + this.index = index; + this.refresh(); + } + + hideTip() { + this.container.style.top = '0px'; + this.container.style.left = '0px'; + this.container.style.opacity = '0'; + } + + showTip() { + this.container.style.top = this.top + 'px'; + this.container.style.left = this.left + 'px'; + this.container.style.opacity = '1'; + } +} + +const VERT_SPACE_OUTSIDE_BASE_CHART = 50; +const TRANSLATE_Y_BASE_CHART = 20; +const LEFT_MARGIN_BASE_CHART = 60; +const RIGHT_MARGIN_BASE_CHART = 40; +const Y_AXIS_MARGIN = 60; + +const INIT_CHART_UPDATE_TIMEOUT = 700; +const CHART_POST_ANIMATE_TIMEOUT = 400; + +const DEFAULT_AXIS_CHART_TYPE = 'line'; +const AXIS_DATASET_CHART_TYPES = ['line', 'bar']; + +const BAR_CHART_SPACE_RATIO = 0.5; +const MIN_BAR_PERCENT_HEIGHT = 0.01; + +const LINE_CHART_DOT_SIZE = 4; +const DOT_OVERLAY_SIZE_INCR = 4; + +const DEFAULT_CHAR_WIDTH = 7; + +// Universal constants +const ANGLE_RATIO = Math.PI / 180; +const FULL_ANGLE = 360; + +function floatTwo(d) { + return parseFloat(d.toFixed(2)); +} + +/** + * Returns whether or not two given arrays are equal. + * @param {Array} arr1 First array + * @param {Array} arr2 Second array + */ + + +/** + * Shuffles array in place. ES6 version + * @param {Array} array An array containing the items. + */ + + +/** + * Fill an array with extra points + * @param {Array} array Array + * @param {Number} count number of filler elements + * @param {Object} element element to fill with + * @param {Boolean} start fill at start? + */ +function fillArray(array, count, element, start=false) { + if(!element) { + element = start ? array[0] : array[array.length - 1]; + } + let fillerArray = new Array(Math.abs(count)).fill(element); + array = start ? fillerArray.concat(array) : array.concat(fillerArray); + return array; +} + +/** + * Returns pixel width of string. + * @param {String} string + * @param {Number} charWidth Width of single char in pixels + */ +function getStringWidth(string, charWidth) { + return (string+"").length * charWidth; +} + + + +function getPositionByAngle(angle, radius) { + return { + x:Math.sin(angle * ANGLE_RATIO) * radius, + y:Math.cos(angle * ANGLE_RATIO) * radius, + }; +} -function getBarHeightAndYAttr(yTop, zeroLine, totalHeight) { +function getBarHeightAndYAttr(yTop, zeroLine) { let height, y; if (yTop <= zeroLine) { height = zeroLine - yTop; y = yTop; - - // In case of invisible bars - if(height === 0) { - height = totalHeight * 0.01; - y -= height; - } } else { height = yTop - zeroLine; y = zeroLine; - - // In case of invisible bars - if(height === 0) { - height = totalHeight * 0.01; - } } return [height, y]; } -// Constants used +function equilizeNoOfElements(array1, array2, + extraCount = array2.length - array1.length) { + + // Doesn't work if either has zero elements. + if(extraCount > 0) { + array1 = fillArray(array1, extraCount); + } else { + array2 = fillArray(array2, extraCount); + } + return [array1, array2]; +} + +const UNIT_ANIM_DUR = 350; +const PATH_ANIM_DUR = 350; +const MARKER_LINE_ANIM_DUR = UNIT_ANIM_DUR; +const REPLACE_ALL_NEW_DUR = 250; + +const STD_EASING = 'easein'; + +function translate(unit, oldCoord, newCoord, duration) { + let old = typeof oldCoord === 'string' ? oldCoord : oldCoord.join(', '); + return [ + unit, + {transform: newCoord.join(', ')}, + duration, + STD_EASING, + "translate", + {transform: old} + ]; +} + +function translateVertLine(xLine, newX, oldX) { + return translate(xLine, [oldX, 0], [newX, 0], MARKER_LINE_ANIM_DUR); +} + +function translateHoriLine(yLine, newY, oldY) { + return translate(yLine, [0, oldY], [0, newY], MARKER_LINE_ANIM_DUR); +} + +function animateRegion(rectGroup, newY1, newY2, oldY2) { + let newHeight = newY1 - newY2; + let rect = rectGroup.childNodes[0]; + let width = rect.getAttribute("width"); + let rectAnim = [ + rect, + { height: newHeight, 'stroke-dasharray': `${width}, ${newHeight}` }, + MARKER_LINE_ANIM_DUR, + STD_EASING + ]; + + let groupAnim = translate(rectGroup, [0, oldY2], [0, newY2], MARKER_LINE_ANIM_DUR); + return [rectAnim, groupAnim]; +} + +function animateBar(bar, x, yTop, width, offset=0, index=0, meta={}) { + let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine); + y -= offset; + if(bar.nodeName !== 'rect') { + let rect = bar.childNodes[0]; + let rectAnim = [ + rect, + {width: width, height: height}, + UNIT_ANIM_DUR, + STD_EASING + ]; + + let oldCoordStr = bar.getAttribute("transform").split("(")[1].slice(0, -1); + let groupAnim = translate(bar, oldCoordStr, [x, y], MARKER_LINE_ANIM_DUR); + return [rectAnim, groupAnim]; + } else { + return [[bar, {width: width, height: height, x: x, y: y}, UNIT_ANIM_DUR, STD_EASING]]; + } + // bar.animate({height: args.newHeight, y: yTop}, UNIT_ANIM_DUR, mina.easein); +} + +function animateDot(dot, x, y) { + if(dot.nodeName !== 'circle') { + let oldCoordStr = dot.getAttribute("transform").split("(")[1].slice(0, -1); + let groupAnim = translate(dot, oldCoordStr, [x, y], MARKER_LINE_ANIM_DUR); + return [groupAnim]; + } else { + return [[dot, {cx: x, cy: y}, UNIT_ANIM_DUR, STD_EASING]]; + } + // dot.animate({cy: yTop}, UNIT_ANIM_DUR, mina.easein); +} + +function animatePath(paths, newXList, newYList, zeroLine) { + let pathComponents = []; + let pointsStr = newYList.map((y, i) => (newXList[i] + ',' + y)); + let pathStr = pointsStr.join("L"); + + const animPath = [paths.path, {d:"M"+pathStr}, PATH_ANIM_DUR, STD_EASING]; + pathComponents.push(animPath); + + if(paths.region) { + let regStartPt = `${newXList[0]},${zeroLine}L`; + let regEndPt = `L${newXList.slice(-1)[0]}, ${zeroLine}`; + + const animRegion = [ + paths.region, + {d:"M" + regStartPt + pathStr + regEndPt}, + PATH_ANIM_DUR, + STD_EASING + ]; + pathComponents.push(animRegion); + } + + return pathComponents; +} + +function animatePathStr(oldPath, pathStr) { + return [oldPath, {d: pathStr}, UNIT_ANIM_DUR, STD_EASING]; +} + +const AXIS_TICK_LENGTH = 6; +const LABEL_MARGIN = 4; +const FONT_SIZE = 10; +const BASE_LINE_COLOR = '#dadada'; function $$1(expr, con) { return typeof expr === "string"? (con || document).querySelector(expr) : expr || null; } @@ -208,6 +493,8 @@ function makeSVGGroup(parent, className, transform='') { }); } + + function makePath(pathStr, className='', stroke='none', fill='none') { return createSVG('path', { className: className, @@ -219,8 +506,18 @@ function makePath(pathStr, className='', stroke='none', fill='none') { }); } +function makeArcPathStr(startPosition, endPosition, center, radius, clockWise=1){ + let [arcStartX, arcStartY] = [center.x + startPosition.x, center.y + startPosition.y]; + let [arcEndX, arcEndY] = [center.x + endPosition.x, center.y + endPosition.y]; + + return `M${center.x} ${center.y} + L${arcStartX} ${arcStartY} + A ${radius} ${radius} 0 0 ${clockWise ? 1 : 0} + ${arcEndX} ${arcEndY} z`; +} + function makeGradient(svgDefElem, color, lighter = false) { - let gradientId ='path-fill-gradient' + '-' + color; + let gradientId ='path-fill-gradient' + '-' + color + '-' +(lighter ? 'lighter' : 'default'); let gradientDef = renderVerticalGradient(svgDefElem, gradientId); let opacities = [1, 0.6, 0.2]; if(lighter) { @@ -256,2312 +553,1536 @@ function makeText(className, x, y, content) { className: className, x: x, y: y, - dy: '.32em', + dy: (FONT_SIZE / 2) + 'px', + 'font-size': FONT_SIZE + 'px', innerHTML: content }); } -function makeXLine(height, textStartAt, point, labelClass, axisLineClass, xPos) { - let line = createSVG('line', { +function makeVertLine(x, label, y1, y2, options={}) { + if(!options.stroke) options.stroke = BASE_LINE_COLOR; + let l = createSVG('line', { + className: 'line-vertical ' + options.className, x1: 0, x2: 0, - y1: 0, - y2: height + y1: y1, + y2: y2, + styles: { + stroke: options.stroke + } }); let text = createSVG('text', { - className: labelClass, x: 0, - y: textStartAt, - dy: '.71em', - innerHTML: point + y: y1 > y2 ? y1 + LABEL_MARGIN : y1 - LABEL_MARGIN - FONT_SIZE, + dy: FONT_SIZE + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': 'middle', + innerHTML: label + "" }); - let xLine = createSVG('g', { - className: `tick ${axisLineClass}`, - transform: `translate(${ xPos }, 0)` + let line = createSVG('g', { + transform: `translate(${ x }, 0)` }); - xLine.appendChild(line); - xLine.appendChild(text); + line.appendChild(l); + line.appendChild(text); - return xLine; + return line; } -function makeYLine(startAt, width, textEndAt, point, labelClass, axisLineClass, yPos, darker=false, lineType="") { - let line = createSVG('line', { - className: lineType === "dashed" ? "dashed": "", - x1: startAt, - x2: width, +function makeHoriLine(y, label, x1, x2, options={}) { + if(!options.stroke) options.stroke = BASE_LINE_COLOR; + if(!options.lineType) options.lineType = ''; + let className = 'line-horizontal ' + options.className + + (options.lineType === "dashed" ? "dashed": ""); + + let l = createSVG('line', { + className: className, + x1: x1, + x2: x2, y1: 0, - y2: 0 + y2: 0, + styles: { + stroke: options.stroke + } }); let text = createSVG('text', { - className: labelClass, - x: textEndAt, + x: x1 < x2 ? x1 - LABEL_MARGIN : x1 + LABEL_MARGIN, y: 0, - dy: '.32em', - innerHTML: point+"" + dy: (FONT_SIZE / 2 - 2) + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': x1 < x2 ? 'end' : 'start', + innerHTML: label+"" }); - let yLine = createSVG('g', { - className: `tick ${axisLineClass}`, - transform: `translate(0, ${yPos})`, + let line = createSVG('g', { + transform: `translate(0, ${y})`, 'stroke-opacity': 1 }); - if(darker) { + if(text === 0 || text === '0') { line.style.stroke = "rgba(27, 31, 35, 0.6)"; } - yLine.appendChild(line); - yLine.appendChild(text); + line.appendChild(l); + line.appendChild(text); - return yLine; + return line; } -var UnitRenderer = (function() { - var UnitRenderer = function(totalHeight, zeroLine, avgUnitWidth) { - this.totalHeight = totalHeight; - this.zeroLine = zeroLine; - this.avgUnitWidth = avgUnitWidth; - }; +function yLine(y, label, width, options={}) { + if(!options.pos) options.pos = 'left'; + if(!options.offset) options.offset = 0; + if(!options.mode) options.mode = 'span'; + if(!options.stroke) options.stroke = BASE_LINE_COLOR; + if(!options.className) options.className = ''; - UnitRenderer.prototype = { - bar: function (x, yTop, args, color, index, datasetIndex, noOfDatasets) { - let totalWidth = this.avgUnitWidth - args.spaceWidth; - let startX = x - totalWidth/2; + let x1 = -1 * AXIS_TICK_LENGTH; + let x2 = options.mode === 'span' ? width + AXIS_TICK_LENGTH : 0; - let width = totalWidth / noOfDatasets; - let currentX = startX + width * datasetIndex; + if(options.mode === 'tick' && options.pos === 'right') { + x1 = width + AXIS_TICK_LENGTH; + x2 = width; + } - let [height, y] = getBarHeightAndYAttr(yTop, this.zeroLine, this.totalHeight); + x1 += options.offset; + x2 += options.offset; - return createSVG('rect', { - className: `bar mini`, - style: `fill: ${color}`, - 'data-point-index': index, - x: currentX, - y: y, - width: width, - height: height - }); - }, + return makeHoriLine(y, label, x1, x2, { + stroke: options.stroke, + className: options.className, + lineType: options.lineType + }); +} - dot: function(x, y, args, color, index) { - return createSVG('circle', { - style: `fill: ${color}`, - 'data-point-index': index, - cx: x, - cy: y, - r: args.radius - }); - } - }; +function xLine(x, label, height, options={}) { + if(!options.pos) options.pos = 'bottom'; + if(!options.offset) options.offset = 0; + if(!options.mode) options.mode = 'span'; + if(!options.stroke) options.stroke = BASE_LINE_COLOR; + if(!options.className) options.className = ''; + + // Draw X axis line in span/tick mode with optional label + // y2(span) + // | + // | + // x line | + // | + // | + // ---------------------+-- y2(tick) + // | + // y1 + + let y1 = height + AXIS_TICK_LENGTH; + let y2 = options.mode === 'span' ? -1 * AXIS_TICK_LENGTH : height; + + if(options.mode === 'tick' && options.pos === 'top') { + // top axis ticks + y1 = -1 * AXIS_TICK_LENGTH; + y2 = 0; + } + + return makeVertLine(x, label, y1, y2, { + stroke: options.stroke, + className: options.className, + lineType: options.lineType + }); +} - return UnitRenderer; -})(); +function yMarker(y, label, width, options={}) { + let labelSvg = createSVG('text', { + className: 'chart-label', + x: width - getStringWidth(label, 5) - LABEL_MARGIN, + y: 0, + dy: (FONT_SIZE / -2) + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': 'start', + innerHTML: label+"" + }); -var Animator = (function() { - var Animator = function(totalHeight, totalWidth, zeroLine, avgUnitWidth) { - // constants - this.totalHeight = totalHeight; - this.totalWidth = totalWidth; + let line = makeHoriLine(y, '', 0, width, { + stroke: options.stroke || BASE_LINE_COLOR, + className: options.className || '', + lineType: options.lineType + }); - // changeables - this.avgUnitWidth = avgUnitWidth; - this.zeroLine = zeroLine; - }; + line.appendChild(labelSvg); - Animator.prototype = { - bar: function(barObj, x, yTop, index, noOfDatasets) { - let start = x - this.avgUnitWidth/4; - let width = (this.avgUnitWidth/2)/noOfDatasets; - let [height, y] = getBarHeightAndYAttr(yTop, this.zeroLine, this.totalHeight); + return line; +} - x = start + (width * index); +function yRegion(y1, y2, width, label) { + // return a group + let height = y1 - y2; - return [barObj, {width: width, height: height, x: x, y: y}, 350, "easein"]; - // bar.animate({height: args.newHeight, y: yTop}, 350, mina.easein); + let rect = createSVG('rect', { + className: `bar mini`, // remove class + styles: { + fill: `rgba(228, 234, 239, 0.49)`, + stroke: BASE_LINE_COLOR, + 'stroke-dasharray': `${width}, ${height}` }, + // 'data-point-index': index, + x: 0, + y: 0, + width: width, + height: height + }); - dot: function(dotObj, x, yTop) { - return [dotObj, {cx: x, cy: yTop}, 350, "easein"]; - // dot.animate({cy: yTop}, 350, mina.easein); - }, + let labelSvg = createSVG('text', { + className: 'chart-label', + x: width - getStringWidth(label+"", 4.5) - LABEL_MARGIN, + y: 0, + dy: (FONT_SIZE / -2) + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': 'start', + innerHTML: label+"" + }); - path: function(d, pathStr) { - let pathComponents = []; - const animPath = [{unit: d.path, object: d, key: 'path'}, {d:"M"+pathStr}, 350, "easein"]; - pathComponents.push(animPath); - - if(d.regionPath) { - let regStartPt = `0,${this.zeroLine}L`; - let regEndPt = `L${this.totalWidth}, ${this.zeroLine}`; - - const animRegion = [ - {unit: d.regionPath, object: d, key: 'regionPath'}, - {d:"M" + regStartPt + pathStr + regEndPt}, - 350, - "easein" - ]; - pathComponents.push(animRegion); - } + let region = createSVG('g', { + transform: `translate(0, ${y2})` + }); - return pathComponents; - }, - }; + region.appendChild(rect); + region.appendChild(labelSvg); - return Animator; -})(); + return region; +} -// Leveraging SMIL Animations +function datasetBar(x, yTop, width, color, label='', index=0, offset=0, meta={}) { + let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine); + y -= offset; -const EASING = { - ease: "0.25 0.1 0.25 1", - linear: "0 0 1 1", - // easein: "0.42 0 1 1", - easein: "0.1 0.8 0.2 1", - easeout: "0 0 0.58 1", - easeinout: "0.42 0 0.58 1" -}; + let rect = createSVG('rect', { + className: `bar mini`, + style: `fill: ${color}`, + 'data-point-index': index, + x: x, + y: y, + width: width, + height: height || meta.minHeight // TODO: correct y for positive min height + }); -function animateSVG(element, props, dur, easingType="linear", type=undefined, oldValues={}) { + label += ""; - let animElement = element.cloneNode(true); - let newElement = element.cloneNode(true); + if(!label && !label.length) { + return rect; + } else { + rect.setAttribute('y', 0); + rect.setAttribute('x', 0); + let text = createSVG('text', { + className: 'data-point-value', + x: width/2, + y: 0, + dy: (FONT_SIZE / 2 * -1) + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': 'middle', + innerHTML: label + }); - for(var attributeName in props) { - let animateElement; - if(attributeName === 'transform') { - animateElement = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform"); - } else { - animateElement = document.createElementNS("http://www.w3.org/2000/svg", "animate"); - } - let currentValue = oldValues[attributeName] || element.getAttribute(attributeName); - let value = props[attributeName]; + let group = createSVG('g', { + 'data-point-index': index, + transform: `translate(${x}, ${y})` + }); + group.appendChild(rect); + group.appendChild(text); - let animAttr = { - attributeName: attributeName, - from: currentValue, - to: value, - begin: "0s", - dur: dur/1000 + "s", - values: currentValue + ";" + value, - keySplines: EASING[easingType], - keyTimes: "0;1", - calcMode: "spline", - fill: 'freeze' - }; - - if(type) { - animAttr["type"] = type; - } - - for (var i in animAttr) { - animateElement.setAttribute(i, animAttr[i]); - } - - animElement.appendChild(animateElement); - - if(type) { - newElement.setAttribute(attributeName, `translate(${value})`); - } else { - newElement.setAttribute(attributeName, value); - } - } - - return [animElement, newElement]; -} - -function transform(element, style) { // eslint-disable-line no-unused-vars - element.style.transform = style; - element.style.webkitTransform = style; - element.style.msTransform = style; - element.style.mozTransform = style; - element.style.oTransform = style; -} - -function runSVGAnimation(svgContainer, elements) { - let newElements = []; - let animElements = []; - - elements.map(element => { - let obj = element[0]; - let parent = obj.unit.parentNode; - - let animElement, newElement; - - element[0] = obj.unit; - [animElement, newElement] = animateSVG(...element); - - newElements.push(newElement); - animElements.push([animElement, parent]); - - parent.replaceChild(animElement, obj.unit); - - if(obj.array) { - obj.array[obj.index] = newElement; - } else { - obj.object[obj.key] = newElement; - } - }); - - let animSvg = svgContainer.cloneNode(true); - - animElements.map((animElement, i) => { - animElement[1].replaceChild(newElements[i], animElement[0]); - elements[i][0] = newElements[i]; - }); - - return animSvg; -} - -function normalize(x) { - // Calculates mantissa and exponent of a number - // Returns normalized number and exponent - // https://stackoverflow.com/q/9383593/6495043 - - if(x===0) { - return [0, 0]; - } - if(isNaN(x)) { - return {mantissa: -6755399441055744, exponent: 972}; - } - var sig = x > 0 ? 1 : -1; - if(!isFinite(x)) { - return {mantissa: sig * 4503599627370496, exponent: 972}; - } - - x = Math.abs(x); - var exp = Math.floor(Math.log10(x)); - var man = x/Math.pow(10, exp); - - return [sig * man, exp]; -} - -function getRangeIntervals(max, min=0) { - let upperBound = Math.ceil(max); - let lowerBound = Math.floor(min); - let range = upperBound - lowerBound; - - let noOfParts = range; - let partSize = 1; - - // To avoid too many partitions - if(range > 5) { - if(range % 2 !== 0) { - upperBound++; - // Recalc range - range = upperBound - lowerBound; - } - noOfParts = range/2; - partSize = 2; - } - - // Special case: 1 and 2 - if(range <= 2) { - noOfParts = 4; - partSize = range/noOfParts; - } - - // Special case: 0 - if(range === 0) { - noOfParts = 5; - partSize = 1; - } - - let intervals = []; - for(var i = 0; i <= noOfParts; i++){ - intervals.push(lowerBound + partSize * i); - } - return intervals; -} - -function getIntervals(maxValue, minValue=0) { - let [normalMaxValue, exponent] = normalize(maxValue); - let normalMinValue = minValue ? minValue/Math.pow(10, exponent): 0; - - // Allow only 7 significant digits - normalMaxValue = normalMaxValue.toFixed(6); - - let intervals = getRangeIntervals(normalMaxValue, normalMinValue); - intervals = intervals.map(value => value * Math.pow(10, exponent)); - return intervals; -} - -function calcIntervals(values, withMinimum=false) { - //*** Where the magic happens *** - - // Calculates best-fit y intervals from given values - // and returns the interval array - - let maxValue = Math.max(...values); - let minValue = Math.min(...values); - - // Exponent to be used for pretty print - let exponent = 0, intervals = []; // eslint-disable-line no-unused-vars - - function getPositiveFirstIntervals(maxValue, absMinValue) { - let intervals = getIntervals(maxValue); - - let intervalSize = intervals[1] - intervals[0]; - - // Then unshift the negative values - let value = 0; - for(var i = 1; value < absMinValue; i++) { - value += intervalSize; - intervals.unshift((-1) * value); - } - return intervals; - } - - // CASE I: Both non-negative - - if(maxValue >= 0 && minValue >= 0) { - exponent = normalize(maxValue)[1]; - if(!withMinimum) { - intervals = getIntervals(maxValue); - } else { - intervals = getIntervals(maxValue, minValue); - } - } - - // CASE II: Only minValue negative - - else if(maxValue > 0 && minValue < 0) { - // `withMinimum` irrelevant in this case, - // We'll be handling both sides of zero separately - // (both starting from zero) - // Because ceil() and floor() behave differently - // in those two regions - - let absMinValue = Math.abs(minValue); - - if(maxValue >= absMinValue) { - exponent = normalize(maxValue)[1]; - intervals = getPositiveFirstIntervals(maxValue, absMinValue); - } else { - // Mirror: maxValue => absMinValue, then change sign - exponent = normalize(absMinValue)[1]; - let posIntervals = getPositiveFirstIntervals(absMinValue, maxValue); - intervals = posIntervals.map(d => d * (-1)); - } - - } - - // CASE III: Both non-positive - - else if(maxValue <= 0 && minValue <= 0) { - // Mirrored Case I: - // Work with positives, then reverse the sign and array - - let pseudoMaxValue = Math.abs(minValue); - let pseudoMinValue = Math.abs(maxValue); - - exponent = normalize(pseudoMaxValue)[1]; - if(!withMinimum) { - intervals = getIntervals(pseudoMaxValue); - } else { - intervals = getIntervals(pseudoMaxValue, pseudoMinValue); - } - - intervals = intervals.reverse().map(d => d * (-1)); - } - - return intervals; -} - -function calcDistribution(values, distributionSize) { - // Assume non-negative values, - // implying distribution minimum at zero - - let dataMaxValue = Math.max(...values); - - let distributionStep = 1 / (distributionSize - 1); - let distribution = []; - - for(var i = 0; i < distributionSize; i++) { - let checkpoint = dataMaxValue * (distributionStep * i); - distribution.push(checkpoint); - } - - return distribution; -} - -function getMaxCheckpoint(value, distribution) { - return distribution.filter(d => d < value).length; -} - -/** - * Returns the value of a number upto 2 decimal places. - * @param {Number} d Any number - */ -function floatTwo(d) { - return parseFloat(d.toFixed(2)); -} - -/** - * Returns whether or not two given arrays are equal. - * @param {Array} arr1 First array - * @param {Array} arr2 Second array - */ -function arraysEqual(arr1, arr2) { - if(arr1.length !== arr2.length) return false; - let areEqual = true; - arr1.map((d, i) => { - if(arr2[i] !== d) areEqual = false; - }); - return areEqual; -} - -/** - * Shuffles array in place. ES6 version - * @param {Array} array An array containing the items. - */ - - -/** - * Returns pixel width of string. - * @param {String} string - * @param {Number} charWidth Width of single char in pixels - */ -function getStringWidth(string, charWidth) { - return (string+"").length * charWidth; -} - -class SvgTip { - constructor({ - parent = null, - colors = [] - }) { - this.parent = parent; - this.colors = colors; - this.title_name = ''; - this.title_value = ''; - this.list_values = []; - this.title_value_first = 0; - - this.x = 0; - this.y = 0; - - this.top = 0; - this.left = 0; - - this.setup(); - } - - setup() { - this.make_tooltip(); - } - - refresh() { - this.fill(); - this.calc_position(); - // this.show_tip(); - } - - make_tooltip() { - this.container = $.create('div', { - inside: this.parent, - className: 'graph-svg-tip comparison', - innerHTML: ` - -
` - }); - this.hide_tip(); - - this.title = this.container.querySelector('.title'); - this.data_point_list = this.container.querySelector('.data-point-list'); - - this.parent.addEventListener('mouseleave', () => { - this.hide_tip(); - }); - } - - fill() { - let title; - if(this.title_value_first) { - title = `${this.title_value}${this.title_name}`; - } else { - title = `${this.title_name}${this.title_value}`; - } - this.title.innerHTML = title; - this.data_point_list.innerHTML = ''; - - this.list_values.map((set, i) => { - const color = this.colors[i] || 'black'; - - let li = $.create('li', { - styles: { - 'border-top': `3px solid ${color}` - }, - innerHTML: `${ set.value === 0 || set.value ? set.value : '' } - ${set.title ? set.title : '' }` - }); - - this.data_point_list.appendChild(li); - }); - } - - calc_position() { - let width = this.container.offsetWidth; - - this.top = this.y - this.container.offsetHeight; - this.left = this.x - width/2; - let max_left = this.parent.offsetWidth - width; - - let pointer = this.container.querySelector('.svg-pointer'); - - if(this.left < 0) { - pointer.style.left = `calc(50% - ${-1 * this.left}px)`; - this.left = 0; - } else if(this.left > max_left) { - let delta = this.left - max_left; - let pointer_offset = `calc(50% + ${delta}px)`; - pointer.style.left = pointer_offset; - - this.left = max_left; - } else { - pointer.style.left = `50%`; - } - } - - set_values(x, y, title_name = '', title_value = '', list_values = [], title_value_first = 0) { - this.title_name = title_name; - this.title_value = title_value; - this.list_values = list_values; - this.x = x; - this.y = y; - this.title_value_first = title_value_first; - this.refresh(); - } - - hide_tip() { - this.container.style.top = '0px'; - this.container.style.left = '0px'; - this.container.style.opacity = '0'; - } - - show_tip() { - this.container.style.top = this.top + 'px'; - this.container.style.left = this.left + 'px'; - this.container.style.opacity = '1'; - } -} - -const PRESET_COLOR_MAP = { - 'light-blue': '#7cd6fd', - 'blue': '#5e64ff', - 'violet': '#743ee2', - 'red': '#ff5858', - 'orange': '#ffa00a', - 'yellow': '#feef72', - 'green': '#28a745', - 'light-green': '#98d85b', - 'purple': '#b554ff', - 'magenta': '#ffa3ef', - 'black': '#36114C', - 'grey': '#bdd3e6', - 'light-grey': '#f0f4f7', - 'dark-grey': '#b8c2cc' -}; - -const DEFAULT_COLORS = ['light-blue', 'blue', 'violet', 'red', 'orange', - 'yellow', 'green', 'light-green', 'purple', 'magenta']; - -function limitColor(r){ - if (r > 255) return 255; - else if (r < 0) return 0; - return r; -} - -function lightenDarkenColor(color, amt) { - let col = getColor(color); - let usePound = false; - if (col[0] == "#") { - col = col.slice(1); - usePound = true; - } - let num = parseInt(col,16); - let r = limitColor((num >> 16) + amt); - let b = limitColor(((num >> 8) & 0x00FF) + amt); - let g = limitColor((num & 0x0000FF) + amt); - return (usePound?"#":"") + (g | (b << 8) | (r << 16)).toString(16); -} - -function isValidColor(string) { - // https://stackoverflow.com/a/8027444/6495043 - return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(string); -} - -const getColor = (color) => { - return PRESET_COLOR_MAP[color] || color; -}; - -const ALL_CHART_TYPES = ['line', 'scatter', 'bar', 'percentage', 'heatmap', 'pie']; - -const COMPATIBLE_CHARTS = { - bar: ['line', 'scatter', 'percentage', 'pie'], - line: ['scatter', 'bar', 'percentage', 'pie'], - pie: ['line', 'scatter', 'percentage', 'bar'], - scatter: ['line', 'bar', 'percentage', 'pie'], - percentage: ['bar', 'line', 'scatter', 'pie'], - heatmap: [] -}; - -// TODO: Needs structure as per only labels/datasets -const COLOR_COMPATIBLE_CHARTS = { - bar: ['line', 'scatter'], - line: ['scatter', 'bar'], - pie: ['percentage'], - scatter: ['line', 'bar'], - percentage: ['pie'], - heatmap: [] -}; - -class BaseChart { - constructor({ - height = 240, - - title = '', - subtitle = '', - colors = [], - summary = [], - - is_navigable = 0, - has_legend = 0, - - type = '', - - parent, - data - }) { - this.raw_chart_args = arguments[0]; - - this.parent = typeof parent === 'string' ? document.querySelector(parent) : parent; - this.title = title; - this.subtitle = subtitle; - - this.data = data; - - this.specific_values = data.specific_values || []; - this.summary = summary; - - this.is_navigable = is_navigable; - if(this.is_navigable) { - this.current_index = 0; - } - this.has_legend = has_legend; - - this.setColors(colors, type); - this.set_margins(height); - } - - get_different_chart(type) { - if(type === this.type) return; - - if(!ALL_CHART_TYPES.includes(type)) { - console.error(`'${type}' is not a valid chart type.`); - } - - if(!COMPATIBLE_CHARTS[this.type].includes(type)) { - console.error(`'${this.type}' chart cannot be converted to a '${type}' chart.`); - } - - // whether the new chart can use the existing colors - const use_color = COLOR_COMPATIBLE_CHARTS[this.type].includes(type); - - // Okay, this is anticlimactic - // this function will need to actually be 'change_chart_type(type)' - // that will update only the required elements, but for now ... - return new Chart({ - parent: this.raw_chart_args.parent, - title: this.title, - data: this.raw_chart_args.data, - type: type, - height: this.raw_chart_args.height, - colors: use_color ? this.colors : undefined - }); - } - - setColors(colors, type) { - this.colors = colors; - - // TODO: Needs structure as per only labels/datasets - const list = type === 'percentage' || type === 'pie' - ? this.data.labels - : this.data.datasets; - - if(!this.colors || (list && this.colors.length < list.length)) { - this.colors = DEFAULT_COLORS; - } - - this.colors = this.colors.map(color => getColor(color)); - } - - set_margins(height) { - this.base_height = height; - this.height = height - 40; - this.translate_x = 60; - this.translate_y = 10; - } - - setup() { - if(!this.parent) { - console.error("No parent element to render on was provided."); - return; - } - if(this.validate_and_prepare_data()) { - this.bind_window_events(); - this.refresh(true); - } - } - - validate_and_prepare_data() { - return true; - } - - bind_window_events() { - window.addEventListener('resize', () => this.refresh()); - window.addEventListener('orientationchange', () => this.refresh()); - } - - refresh(init=false) { - this.setup_base_values(); - this.set_width(); - - this.setup_container(); - this.setup_components(); - - this.setup_values(); - this.setup_utils(); - - this.make_graph_components(init); - this.make_tooltip(); - - if(this.summary.length > 0) { - this.show_custom_summary(); - } else { - this.show_summary(); - } - - if(this.is_navigable) { - this.setup_navigation(init); - } - } - - set_width() { - let special_values_width = 0; - let char_width = 8; - this.specific_values.map(val => { - let str_width = getStringWidth((val.title + ""), char_width); - if(str_width > special_values_width) { - special_values_width = str_width - 40; - } - }); - this.base_width = getElementContentWidth(this.parent) - special_values_width; - this.width = this.base_width - this.translate_x * 2; - } - - setup_base_values() {} - - setup_container() { - this.container = $.create('div', { - className: 'chart-container', - innerHTML: `
${this.title}
-
${this.subtitle}
-
-
` - }); - - // Chart needs a dedicated parent element - this.parent.innerHTML = ''; - this.parent.appendChild(this.container); - - this.chart_wrapper = this.container.querySelector('.frappe-chart'); - this.stats_wrapper = this.container.querySelector('.graph-stats-container'); - - this.make_chart_area(); - this.make_draw_area(); - } - - make_chart_area() { - this.svg = makeSVGContainer( - this.chart_wrapper, - 'chart', - this.base_width, - this.base_height - ); - this.svg_defs = makeSVGDefs(this.svg); - return this.svg; - } - - make_draw_area() { - this.draw_area = makeSVGGroup( - this.svg, - this.type + '-chart', - `translate(${this.translate_x}, ${this.translate_y})` - ); + return group; } +} - setup_components() { } - - make_tooltip() { - this.tip = new SvgTip({ - parent: this.chart_wrapper, - colors: this.colors - }); - this.bind_tooltip(); - } +function datasetDot(x, y, radius, color, label='', index=0, meta={}) { + let dot = createSVG('circle', { + style: `fill: ${color}`, + 'data-point-index': index, + cx: x, + cy: y, + r: radius + }); + label += ""; - show_summary() {} - show_custom_summary() { - this.summary.map(d => { - let stats = $.create('div', { - className: 'stats', - innerHTML: ` - - ${d.title}: ${d.value} - ` - }); - this.stats_wrapper.appendChild(stats); + if(!label && !label.length) { + return dot; + } else { + dot.setAttribute('cy', 0); + dot.setAttribute('cx', 0); + + let text = createSVG('text', { + className: 'data-point-value', + x: 0, + y: 0, + dy: (FONT_SIZE / 2 * -1 - radius) + 'px', + 'font-size': FONT_SIZE + 'px', + 'text-anchor': 'middle', + innerHTML: label }); - } - - setup_navigation(init=false) { - this.make_overlay(); - - if(init) { - this.bind_overlay(); - - document.addEventListener('keydown', (e) => { - if(isElementInViewport(this.chart_wrapper)) { - e = e || window.event; - - if (e.keyCode == '37') { - this.on_left_arrow(); - } else if (e.keyCode == '39') { - this.on_right_arrow(); - } else if (e.keyCode == '38') { - this.on_up_arrow(); - } else if (e.keyCode == '40') { - this.on_down_arrow(); - } else if (e.keyCode == '13') { - this.on_enter_key(); - } - } - }); - } - } - - make_overlay() {} - bind_overlay() {} - bind_units() {} - on_left_arrow() {} - on_right_arrow() {} - on_up_arrow() {} - on_down_arrow() {} - on_enter_key() {} - - get_data_point(index=this.current_index) { - // check for length - let data_point = { - index: index - }; - let y = this.y[0]; - ['svg_units', 'y_tops', 'values'].map(key => { - let data_key = key.slice(0, key.length-1); - data_point[data_key] = y[key][index]; + let group = createSVG('g', { + 'data-point-index': index, + transform: `translate(${x}, ${y})` }); - data_point.label = this.x[index]; - return data_point; - } - - update_current_data_point(index) { - index = parseInt(index); - if(index < 0) index = 0; - if(index >= this.x.length) index = this.x.length - 1; - if(index === this.current_index) return; - this.current_index = index; - $.fire(this.parent, "data-select", this.get_data_point()); - } - - // Objects - setup_utils() { } + group.appendChild(dot); + group.appendChild(text); - makeDrawAreaComponent(className, transform='') { - return makeSVGGroup(this.draw_area, className, transform); + return group; } } -class AxisChart extends BaseChart { - constructor(args) { - super(args); - - this.x = this.data.labels || []; - this.y = this.data.datasets || []; - - this.is_series = args.is_series; - - this.format_tooltip_y = args.format_tooltip_y; - this.format_tooltip_x = args.format_tooltip_x; +function getPaths(xList, yList, color, options={}, meta={}) { + let pointsList = yList.map((y, i) => (xList[i] + ',' + y)); + let pointsStr = pointsList.join("L"); + let path = makePath("M"+pointsStr, 'line-graph-path', color); - this.zero_line = this.height; - - // this.old_values = {}; + // HeatLine + if(options.heatline) { + let gradient_id = makeGradient(meta.svgDefs, color); + path.style.stroke = `url(#${gradient_id})`; } - validate_and_prepare_data() { - return true; - } + let paths = { + path: path + }; - setup_values() { - this.data.datasets.map(d => { - d.values = d.values.map(val => (!isNaN(val) ? val : 0)); - }); - this.setup_x(); - this.setup_y(); + // Region + if(options.regionFill) { + let gradient_id_region = makeGradient(meta.svgDefs, color, true); + + // TODO: use zeroLine OR minimum + let pathStr = "M" + `${xList[0]},${meta.zeroLine}L` + pointsStr + `L${xList.slice(-1)[0]},${meta.zeroLine}`; + paths.region = makePath(pathStr, `region-fill`, 'none', `url(#${gradient_id_region})`); } - setup_x() { - this.set_avg_unit_width_and_x_offset(); + return paths; +} - if(this.x_axis_positions) { - this.x_old_axis_positions = this.x_axis_positions.slice(); +let makeOverlay = { + 'bar': (unit) => { + let transformValue; + if(unit.nodeName !== 'rect') { + transformValue = unit.getAttribute('transform'); + unit = unit.childNodes[0]; } - this.x_axis_positions = this.x.map((d, i) => - floatTwo(this.x_offset + i * this.avg_unit_width)); + let overlay = unit.cloneNode(); + overlay.style.fill = '#000000'; + overlay.style.opacity = '0.4'; - if(!this.x_old_axis_positions) { - this.x_old_axis_positions = this.x_axis_positions.slice(); + if(transformValue) { + overlay.setAttribute('transform', transformValue); } - } + return overlay; + }, - setup_y() { - if(this.y_axis_values) { - this.y_old_axis_values = this.y_axis_values.slice(); + 'dot': (unit) => { + let transformValue; + if(unit.nodeName !== 'circle') { + transformValue = unit.getAttribute('transform'); + unit = unit.childNodes[0]; } + let overlay = unit.cloneNode(); + let radius = unit.getAttribute('r'); + let fill = unit.getAttribute('fill'); + overlay.setAttribute('r', parseInt(radius) + DOT_OVERLAY_SIZE_INCR); + overlay.setAttribute('fill', fill); + overlay.style.opacity = '0.6'; - let values = this.get_all_y_values(); - - if(this.y_sums && this.y_sums.length > 0) { - values = values.concat(this.y_sums); + if(transformValue) { + overlay.setAttribute('transform', transformValue); } + return overlay; + } +}; - this.y_axis_values = calcIntervals(values, this.type === 'line'); +let updateOverlay = { + 'bar': (unit, overlay) => { + let transformValue; + if(unit.nodeName !== 'rect') { + transformValue = unit.getAttribute('transform'); + unit = unit.childNodes[0]; + } + let attributes = ['x', 'y', 'width', 'height']; + Object.values(unit.attributes) + .filter(attr => attributes.includes(attr.name) && attr.specified) + .map(attr => { + overlay.setAttribute(attr.name, attr.nodeValue); + }); - if(!this.y_old_axis_values) { - this.y_old_axis_values = this.y_axis_values.slice(); + if(transformValue) { + overlay.setAttribute('transform', transformValue); } + }, - const y_pts = this.y_axis_values; - const value_range = y_pts[y_pts.length-1] - y_pts[0]; - - if(this.multiplier) this.old_multiplier = this.multiplier; - this.multiplier = this.height / value_range; - if(!this.old_multiplier) this.old_multiplier = this.multiplier; - - const interval = y_pts[1] - y_pts[0]; - const interval_height = interval * this.multiplier; - - let zero_index; - - if(y_pts.indexOf(0) >= 0) { - // the range has a given zero - // zero-line on the chart - zero_index = y_pts.indexOf(0); - } else if(y_pts[0] > 0) { - // Minimum value is positive - // zero-line is off the chart: below - let min = y_pts[0]; - zero_index = (-1) * min / interval; - } else { - // Maximum value is negative - // zero-line is off the chart: above - let max = y_pts[y_pts.length - 1]; - zero_index = (-1) * max / interval + (y_pts.length - 1); + 'dot': (unit, overlay) => { + let transformValue; + if(unit.nodeName !== 'circle') { + transformValue = unit.getAttribute('transform'); + unit = unit.childNodes[0]; } - - if(this.zero_line) this.old_zero_line = this.zero_line; - this.zero_line = this.height - (zero_index * interval_height); - if(!this.old_zero_line) this.old_zero_line = this.zero_line; - } - - setup_components() { - super.setup_components(); - this.setup_marker_components(); - this.setup_aggregation_components(); - this.setup_graph_components(); - } - - setup_marker_components() { - this.y_axis_group = this.makeDrawAreaComponent('y axis'); - this.x_axis_group = this.makeDrawAreaComponent('x axis'); - this.specific_y_group = this.makeDrawAreaComponent('specific axis'); - } - - setup_aggregation_components() { - this.sum_group = this.makeDrawAreaComponent('data-points'); - this.average_group = this.makeDrawAreaComponent('chart-area'); - } - - setup_graph_components() { - this.svg_units_groups = []; - this.y.map((d, i) => { - this.svg_units_groups[i] = this.makeDrawAreaComponent( - 'data-points data-points-' + i); + let attributes = ['cx', 'cy']; + Object.values(unit.attributes) + .filter(attr => attributes.includes(attr.name) && attr.specified) + .map(attr => { + overlay.setAttribute(attr.name, attr.nodeValue); }); - } - make_graph_components(init=false) { - this.make_y_axis(); - this.make_x_axis(); - this.draw_graph(init); - this.make_y_specifics(); - } - - // make VERTICAL lines for x values - make_x_axis(animate=false) { - let char_width = 8; - let start_at, height, text_start_at, axis_line_class = ''; - if(this.x_axis_mode === 'span') { // long spanning lines - start_at = -7; - height = this.height + 15; - text_start_at = this.height + 25; - } else if(this.x_axis_mode === 'tick'){ // short label lines - start_at = this.height; - height = 6; - text_start_at = 9; - axis_line_class = 'x-axis-label'; + if(transformValue) { + overlay.setAttribute('transform', transformValue); } + } +}; - this.x_axis_group.setAttribute('transform', `translate(0,${start_at})`); +const PRESET_COLOR_MAP = { + 'light-blue': '#7cd6fd', + 'blue': '#5e64ff', + 'violet': '#743ee2', + 'red': '#ff5858', + 'orange': '#ffa00a', + 'yellow': '#feef72', + 'green': '#28a745', + 'light-green': '#98d85b', + 'purple': '#b554ff', + 'magenta': '#ffa3ef', + 'black': '#36114C', + 'grey': '#bdd3e6', + 'light-grey': '#f0f4f7', + 'dark-grey': '#b8c2cc' +}; - if(animate) { - this.make_anim_x_axis(height, text_start_at, axis_line_class); - return; - } +const DEFAULT_COLORS = ['light-blue', 'blue', 'violet', 'red', 'orange', + 'yellow', 'green', 'light-green', 'purple', 'magenta', 'light-grey', 'dark-grey']; - let allowed_space = this.avg_unit_width * 1.5; - let allowed_letters = allowed_space / 8; +function limitColor(r){ + if (r > 255) return 255; + else if (r < 0) return 0; + return r; +} - this.x_axis_group.textContent = ''; - this.x.map((point, i) => { - let space_taken = getStringWidth(point, char_width) + 2; - if(space_taken > allowed_space) { - if(this.is_series) { - // Skip some axis lines if X axis is a series - let skips = 1; - while((space_taken/skips)*2 > allowed_space) { - skips++; - } - if(i % skips !== 0) { - return; - } - } else { - point = point.slice(0, allowed_letters-3) + " ..."; - } - } - this.x_axis_group.appendChild( - makeXLine( - height, - text_start_at, - point, - 'x-value-text', - axis_line_class, - this.x_axis_positions[i] - ) - ); - }); +function lightenDarkenColor(color, amt) { + let col = getColor(color); + let usePound = false; + if (col[0] == "#") { + col = col.slice(1); + usePound = true; } + let num = parseInt(col,16); + let r = limitColor((num >> 16) + amt); + let b = limitColor(((num >> 8) & 0x00FF) + amt); + let g = limitColor((num & 0x0000FF) + amt); + return (usePound?"#":"") + (g | (b << 8) | (r << 16)).toString(16); +} - // make HORIZONTAL lines for y values - make_y_axis(animate=false) { - if(animate) { - this.make_anim_y_axis(); - this.make_anim_y_specifics(); - return; - } - - let [width, text_end_at, axis_line_class, start_at] = this.get_y_axis_line_props(); - - this.y_axis_group.textContent = ''; - this.y_axis_values.map((value, i) => { - this.y_axis_group.appendChild( - makeYLine( - start_at, - width, - text_end_at, - value, - 'y-value-text', - axis_line_class, - this.zero_line - value * this.multiplier, - (value === 0 && i !== 0) // Non-first Zero line - ) - ); - }); - } +function isValidColor(string) { + // https://stackoverflow.com/a/8027444/6495043 + return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(string); +} - get_y_axis_line_props(specific=false) { - if(specific) { - return[this.width, this.width + 5, 'specific-value', 0]; - } - let width, text_end_at = -9, axis_line_class = '', start_at = 0; - if(this.y_axis_mode === 'span') { // long spanning lines - width = this.width + 6; - start_at = -6; - } else if(this.y_axis_mode === 'tick'){ // short label lines - width = -6; - axis_line_class = 'y-axis-label'; - } +const getColor = (color) => { + return PRESET_COLOR_MAP[color] || color; +}; - return [width, text_end_at, axis_line_class, start_at]; - } +const ALL_CHART_TYPES = ['line', 'scatter', 'bar', 'percentage', 'heatmap', 'pie']; - draw_graph(init=false) { - if(this.raw_chart_args.hasOwnProperty("init") && !this.raw_chart_args.init) { - this.y.map((d, i) => { - d.svg_units = []; - this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, this.colors[i]); - this.make_new_units(d, i); - this.calc_y_dependencies(); - }); - return; - } - if(init) { - this.draw_new_graph_and_animate(); - return; - } - this.y.map((d, i) => { - d.svg_units = []; - this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, this.colors[i]); - this.make_new_units(d, i); - }); - } +const COMPATIBLE_CHARTS = { + bar: ['line', 'scatter', 'percentage', 'pie'], + line: ['scatter', 'bar', 'percentage', 'pie'], + pie: ['line', 'scatter', 'percentage', 'bar'], + scatter: ['line', 'bar', 'percentage', 'pie'], + percentage: ['bar', 'line', 'scatter', 'pie'], + heatmap: [] +}; - draw_new_graph_and_animate() { - let data = []; - this.y.map((d, i) => { - // Anim: Don't draw initial values, store them and update later - d.y_tops = new Array(d.values.length).fill(this.zero_line); // no value - data.push({values: d.values}); - d.svg_units = []; +// Needs structure as per only labels/datasets +const COLOR_COMPATIBLE_CHARTS = { + bar: ['line', 'scatter'], + line: ['scatter', 'bar'], + pie: ['percentage'], + scatter: ['line', 'bar'], + percentage: ['pie'], + heatmap: [] +}; - this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, this.colors[i]); - this.make_new_units(d, i); - }); +function getDifferentChart(type, current_type, parent, args) { + if(type === current_type) return; - setTimeout(() => { - this.update_values(data); - }, 350); + if(!ALL_CHART_TYPES.includes(type)) { + console.error(`'${type}' is not a valid chart type.`); } - setup_navigation(init) { - if(init) { - // Hack: defer nav till initial update_values - setTimeout(() => { - super.setup_navigation(init); - }, 500); - } else { - super.setup_navigation(init); - } + if(!COMPATIBLE_CHARTS[current_type].includes(type)) { + console.error(`'${current_type}' chart cannot be converted to a '${type}' chart.`); } - make_new_units(d, i) { - this.make_new_units_for_dataset( - this.x_axis_positions, - d.y_tops, - this.colors[i], - i, - this.y.length - ); - } + // whether the new chart can use the existing colors + const useColor = COLOR_COMPATIBLE_CHARTS[current_type].includes(type); - make_new_units_for_dataset(x_values, y_values, color, dataset_index, - no_of_datasets, units_group, units_array, unit) { + // Okay, this is anticlimactic + // this function will need to actually be 'changeChartType(type)' + // that will update only the required elements, but for now ... - if(!units_group) units_group = this.svg_units_groups[dataset_index]; - if(!units_array) units_array = this.y[dataset_index].svg_units; - if(!unit) unit = this.unit_args; + args.type = type; + args.colors = useColor ? args.colors : undefined; - units_group.textContent = ''; - units_array.length = 0; + return new Chart(parent, args); +} - let unit_renderer = new UnitRenderer(this.height, this.zero_line, this.avg_unit_width); +// Leveraging SMIL Animations - y_values.map((y, i) => { - let data_unit = unit_renderer[unit.type]( - x_values[i], - y, - unit.args, - color, - i, - dataset_index, - no_of_datasets - ); - units_group.appendChild(data_unit); - units_array.push(data_unit); - }); +const EASING = { + ease: "0.25 0.1 0.25 1", + linear: "0 0 1 1", + // easein: "0.42 0 1 1", + easein: "0.1 0.8 0.2 1", + easeout: "0 0 0.58 1", + easeinout: "0.42 0 0.58 1" +}; - if(this.is_navigable) { - this.bind_units(units_array); - } - } - - make_y_specifics() { - this.specific_y_group.textContent = ''; - this.specific_values.map(d => { - this.specific_y_group.appendChild( - makeYLine( - 0, - this.width, - this.width + 5, - d.title.toUpperCase(), - 'specific-value', - 'specific-value', - this.zero_line - d.value * this.multiplier, - false, - d.line_type - ) - ); - }); - } +function animateSVGElement(element, props, dur, easingType="linear", type=undefined, oldValues={}) { - bind_tooltip() { - // TODO: could be in tooltip itself, as it is a given functionality for its parent - this.chart_wrapper.addEventListener('mousemove', (e) => { - let o = offset(this.chart_wrapper); - let relX = e.pageX - o.left - this.translate_x; - let relY = e.pageY - o.top - this.translate_y; + let animElement = element.cloneNode(true); + let newElement = element.cloneNode(true); - if(relY < this.height + this.translate_y * 2) { - this.map_tooltip_x_position_and_show(relX); - } else { - this.tip.hide_tip(); - } - }); - } + for(var attributeName in props) { + let animateElement; + if(attributeName === 'transform') { + animateElement = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform"); + } else { + animateElement = document.createElementNS("http://www.w3.org/2000/svg", "animate"); + } + let currentValue = oldValues[attributeName] || element.getAttribute(attributeName); + let value = props[attributeName]; - map_tooltip_x_position_and_show(relX) { - if(!this.y_min_tops) return; + let animAttr = { + attributeName: attributeName, + from: currentValue, + to: value, + begin: "0s", + dur: dur/1000 + "s", + values: currentValue + ";" + value, + keySplines: EASING[easingType], + keyTimes: "0;1", + calcMode: "spline", + fill: 'freeze' + }; - let titles = this.x; - if(this.format_tooltip_x && this.format_tooltip_x(this.x[0])) { - titles = this.x.map(d=>this.format_tooltip_x(d)); + if(type) { + animAttr["type"] = type; } - let y_format = this.format_tooltip_y && this.format_tooltip_y(this.y[0].values[0]); - - for(var i=this.x_axis_positions.length - 1; i >= 0 ; i--) { - let x_val = this.x_axis_positions[i]; - // let delta = i === 0 ? this.avg_unit_width : x_val - this.x_axis_positions[i-1]; - if(relX > x_val - this.avg_unit_width/2) { - let x = x_val + this.translate_x; - let y = this.y_min_tops[i] + this.translate_y; + for (var i in animAttr) { + animateElement.setAttribute(i, animAttr[i]); + } - let title = titles[i]; - let values = this.y.map((set, j) => { - return { - title: set.title, - value: y_format ? this.format_tooltip_y(set.values[i]) : set.values[i], - color: this.colors[j], - }; - }); + animElement.appendChild(animateElement); - this.tip.set_values(x, y, title, '', values); - this.tip.show_tip(); - break; - } + if(type) { + newElement.setAttribute(attributeName, `translate(${value})`); + } else { + newElement.setAttribute(attributeName, value); } } - // API - show_sums() { - this.updating = true; - - this.y_sums = new Array(this.x_axis_positions.length).fill(0); - this.y.map(d => { - d.values.map( (value, i) => { - this.y_sums[i] += value; - }); - }); + return [animElement, newElement]; +} - // Remake y axis, animate - this.update_values(); +function transform(element, style) { // eslint-disable-line no-unused-vars + element.style.transform = style; + element.style.webkitTransform = style; + element.style.msTransform = style; + element.style.mozTransform = style; + element.style.oTransform = style; +} - // Then make sum units, don't animate - this.sum_units = []; +function animateSVG(svgContainer, elements) { + let newElements = []; + let animElements = []; - this.make_new_units_for_dataset( - this.x_axis_positions, - this.y_sums.map( val => floatTwo(this.zero_line - val * this.multiplier)), - '#f0f4f7', - 0, - 1, - this.sum_group, - this.sum_units - ); + elements.map(element => { + let unit = element[0]; + let parent = unit.parentNode; - // this.make_path && this.make_path(d, i, old_x, old_y, this.colors[i]); + let animElement, newElement; - this.updating = false; - } + element[0] = unit; + [animElement, newElement] = animateSVGElement(...element); - hide_sums() { - if(this.updating) return; - this.y_sums = []; - this.sum_group.textContent = ''; - this.sum_units = []; - this.update_values(); - } + newElements.push(newElement); + animElements.push([animElement, parent]); - show_averages() { - this.old_specific_values = this.specific_values.slice(); - this.y.map((d, i) => { - let sum = 0; - d.values.map(e => {sum+=e;}); - let average = sum/d.values.length; + parent.replaceChild(animElement, unit); + }); - this.specific_values.push({ - title: "AVG" + " " + (i+1), - line_type: "dashed", - value: average, - auto: 1 - }); - }); + let animSvg = svgContainer.cloneNode(true); - this.update_values(); - } + animElements.map((animElement, i) => { + animElement[1].replaceChild(newElements[i], animElement[0]); + elements[i][0] = newElements[i]; + }); - hide_averages() { - this.old_specific_values = this.specific_values.slice(); + return animSvg; +} - let indices_to_remove = []; - this.specific_values.map((d, i) => { - if(d.auto) indices_to_remove.unshift(i); - }); +function runSMILAnimation(parent, svgElement, elementsToAnimate) { + if(elementsToAnimate.length === 0) return; - indices_to_remove.map(index => { - this.specific_values.splice(index, 1); - }); + let animSvgElement = animateSVG(svgElement, elementsToAnimate); + if(svgElement.parentNode == parent) { + parent.removeChild(svgElement); + parent.appendChild(animSvgElement); - this.update_values(); } - update_values(new_y, new_x) { - if(!new_x) { - new_x = this.x; + // Replace the new svgElement (data has already been replaced) + setTimeout(() => { + if(animSvgElement.parentNode == parent) { + parent.removeChild(animSvgElement); + parent.appendChild(svgElement); } - this.elements_to_animate = []; - this.updating = true; + }, REPLACE_ALL_NEW_DUR); +} - this.old_x_values = this.x.slice(); - this.old_y_axis_tops = this.y.map(d => d.y_tops.slice()); +class BaseChart { + constructor(parent, options) { + this.rawChartArgs = options; - this.old_y_values = this.y.map(d => d.values); + this.parent = typeof parent === 'string' ? document.querySelector(parent) : parent; + if (!(this.parent instanceof HTMLElement)) { + throw new Error('No `parent` element to render on was provided.'); + } + + this.title = options.title || ''; + this.subtitle = options.subtitle || ''; + this.argHeight = options.height || 240; + this.type = options.type || ''; + + this.realData = this.prepareData(options.data); + this.data = this.prepareFirstData(this.realData); + this.colors = []; + this.config = { + showTooltip: 1, // calculate + showLegend: options.showLegend || 1, + isNavigable: options.isNavigable || 0, + animate: 1 + }; + this.state = {}; + this.options = {}; - this.no_of_extra_pts = new_x.length - this.x.length; + this.initTimeout = INIT_CHART_UPDATE_TIMEOUT; - // Just update values prop, setup_x/y() will do the rest - if(new_y) this.y.map((d, i) => {d.values = new_y[i].values;}); - if(new_x) this.x = new_x; + if(this.config.isNavigable) { + this.overlays = []; + } - this.setup_x(); - this.setup_y(); + this.configure(options); + } - // Change in data, so calculate dependencies - this.calc_y_dependencies(); + configure(args) { + this.setColors(); + this.setMargins(); - // Got the values? Now begin drawing - this.animator = new Animator(this.height, this.width, this.zero_line, this.avg_unit_width); + // Bind window events + window.addEventListener('resize', () => this.draw(true)); + window.addEventListener('orientationchange', () => this.draw(true)); + } - // Animate only if positions have changed - if(!arraysEqual(this.x_old_axis_positions, this.x_axis_positions)) { - this.make_x_axis(true); - setTimeout(() => { - if(!this.updating) this.make_x_axis(); - }, 350); - } + setColors() { + let args = this.rawChartArgs; - if(!arraysEqual(this.y_old_axis_values, this.y_axis_values) || - (this.old_specific_values && - !arraysEqual(this.old_specific_values, this.specific_values))) { + // Needs structure as per only labels/datasets, from config + const list = args.type === 'percentage' || args.type === 'pie' + ? args.data.labels + : args.data.datasets; - this.make_y_axis(true); - setTimeout(() => { - if(!this.updating) { - this.make_y_axis(); - this.make_y_specifics(); - } - }, 350); + if(!args.colors || (list && args.colors.length < list.length)) { + this.colors = DEFAULT_COLORS; + } else { + this.colors = args.colors; } - this.animate_graphs(); + this.colors = this.colors.map(color => getColor(color)); + } - // Trigger animation with the animatable elements in this.elements_to_animate - this.run_animation(); + setMargins() { + let height = this.argHeight; + this.baseHeight = height; + this.height = height - VERT_SPACE_OUTSIDE_BASE_CHART; + this.translateY = TRANSLATE_Y_BASE_CHART; - this.updating = false; + // Horizontal margins + this.leftMargin = LEFT_MARGIN_BASE_CHART; + this.rightMargin = RIGHT_MARGIN_BASE_CHART; } - add_data_point(y_point, x_point, index=this.x.length) { - let new_y = this.y.map(data_set => { return {values:data_set.values}; }); - new_y.map((d, i) => { d.values.splice(index, 0, y_point[i]); }); - let new_x = this.x.slice(); - new_x.splice(index, 0, x_point); - - this.update_values(new_y, new_x); + validate() { + return true; } - remove_data_point(index = this.x.length-1) { - if(this.x.length < 3) return; + setup() { + if(this.validate()) { + this._setup(); + } + } - let new_y = this.y.map(data_set => { return {values:data_set.values}; }); - new_y.map((d) => { d.values.splice(index, 1); }); - let new_x = this.x.slice(); - new_x.splice(index, 1); + _setup() { + this.makeContainer(); + this.makeTooltip(); - this.update_values(new_y, new_x); + this.draw(false, true); } - run_animation() { - let anim_svg = runSVGAnimation(this.svg, this.elements_to_animate); + setupComponents() { + this.components = new Map(); + } - if(this.svg.parentNode == this.chart_wrapper) { - this.chart_wrapper.removeChild(this.svg); - this.chart_wrapper.appendChild(anim_svg); + makeContainer() { + this.container = $.create('div', { + className: 'chart-container', + innerHTML: `
${this.title}
+
${this.subtitle}
+
+
` + }); - } + // Chart needs a dedicated parent element + this.parent.innerHTML = ''; + this.parent.appendChild(this.container); - // Replace the new svg (data has long been replaced) - setTimeout(() => { - if(anim_svg.parentNode == this.chart_wrapper) { - this.chart_wrapper.removeChild(anim_svg); - this.chart_wrapper.appendChild(this.svg); - } - }, 250); + this.chartWrapper = this.container.querySelector('.frappe-chart'); + this.statsWrapper = this.container.querySelector('.graph-stats-container'); } - animate_graphs() { - this.y.map((d, i) => { - // Pre-prep, equilize no of positions between old and new - let [old_x, old_y, new_x, new_y] = this.calc_old_and_new_postions(d, i); - if(this.no_of_extra_pts >= 0) { - this.make_path && this.make_path(d, i, old_x, old_y, this.colors[i]); - this.make_new_units_for_dataset(old_x, old_y, this.colors[i], i, this.y.length); - } - d.path && this.animate_path(d, i, old_x, old_y, new_x, new_y); - this.animate_units(d, i, old_x, old_y, new_x, new_y); + makeTooltip() { + this.tip = new SvgTip({ + parent: this.chartWrapper, + colors: this.colors }); - - // TODO: replace with real units - setTimeout(() => { - this.y.map((d, i) => { - this.make_path && this.make_path(d, i, this.x_axis_positions, d.y_tops, this.colors[i]); - this.make_new_units(d, i); - }); - }, 400); + this.bindTooltip(); } - animate_path(d, i, old_x, old_y, new_x, new_y) { - const newPointsList = new_y.map((y, i) => (new_x[i] + ',' + y)); - const newPathStr = newPointsList.join("L"); - this.elements_to_animate = this.elements_to_animate - .concat(this.animator['path'](d, newPathStr)); - } + bindTooltip() {} - animate_units(d, index, old_x, old_y, new_x, new_y) { - let type = this.unit_args.type; + draw(onlyWidthChange=false, init=false) { + this.calcWidth(); + this.calc(onlyWidthChange); + this.makeChartArea(); + this.setupComponents(); - d.svg_units.map((unit, i) => { - if(new_x[i] === undefined || new_y[i] === undefined) return; - this.elements_to_animate.push(this.animator[type]( - {unit:unit, array:d.svg_units, index: i}, // unit, with info to replace where it came from in the data - new_x[i], - new_y[i], - index, - this.y.length - )); - }); - } + this.components.forEach(c => c.setup(this.drawArea)); + // this.components.forEach(c => c.make()); + this.render(this.components, false); - calc_old_and_new_postions(d, i) { - let old_x = this.x_old_axis_positions.slice(); - let new_x = this.x_axis_positions.slice(); + if(init) { + this.data = this.realData; + setTimeout(() => {this.update();}, this.initTimeout); + } + + if(!onlyWidthChange) { + this.renderLegend(); + } - let old_y = this.old_y_axis_tops[i].slice(); - let new_y = d.y_tops.slice(); + this.setupNavigation(init); + } - const last_old_x_pos = old_x[old_x.length - 1]; - const last_old_y_pos = old_y[old_y.length - 1]; + calcWidth() { + this.baseWidth = getElementContentWidth(this.parent); + this.width = this.baseWidth - (this.leftMargin + this.rightMargin); + } - const last_new_x_pos = new_x[new_x.length - 1]; - const last_new_y_pos = new_y[new_y.length - 1]; + update(data=this.data) { + this.data = this.prepareData(data); + this.calc(); // builds state + this.render(); + } - if(this.no_of_extra_pts >= 0) { - // First substitute current path with a squiggled one - // (that looks the same but has more points at end), - // then animate to stretch it later to new points - // (new points already have more points) + prepareData(data=this.data) { + return data; + } - // Hence, the extra end points will correspond to current(old) positions - let filler_x = new Array(Math.abs(this.no_of_extra_pts)).fill(last_old_x_pos); - let filler_y = new Array(Math.abs(this.no_of_extra_pts)).fill(last_old_y_pos); + prepareFirstData(data=this.data) { + return data; + } - old_x = old_x.concat(filler_x); - old_y = old_y.concat(filler_y); + calc() {} // builds state + render(components=this.components, animate=true) { + if(this.config.isNavigable) { + // Remove all existing overlays + this.overlays.map(o => o.parentNode.removeChild(o)); + // ref.parentNode.insertBefore(element, ref); + } + let elementsToAnimate = []; + // Can decouple to this.refreshComponents() first to save animation timeout + components.forEach(c => { + elementsToAnimate = elementsToAnimate.concat(c.update(animate)); + }); + if(elementsToAnimate.length > 0) { + runSMILAnimation(this.chartWrapper, this.svg, elementsToAnimate); + setTimeout(() => { + components.forEach(c => c.make()); + this.updateNav(); + }, CHART_POST_ANIMATE_TIMEOUT); } else { - // Just modify the new points to have extra points - // with the same position at end - let filler_x = new Array(Math.abs(this.no_of_extra_pts)).fill(last_new_x_pos); - let filler_y = new Array(Math.abs(this.no_of_extra_pts)).fill(last_new_y_pos); - - new_x = new_x.concat(filler_x); - new_y = new_y.concat(filler_y); + components.forEach(c => c.make()); + this.updateNav(); } + } - return [old_x, old_y, new_x, new_y]; + updateNav() { + if(this.config.isNavigable) { + // if(!this.overlayGuides){ + this.makeOverlay(); + this.bindUnits(); + // } else { + // this.updateOverlay(); + // } + } } - make_anim_x_axis(height, text_start_at, axis_line_class) { - // Animate X AXIS to account for more or less axis lines + makeChartArea() { + if(this.svg) { + this.chartWrapper.removeChild(this.svg); + } + this.svg = makeSVGContainer( + this.chartWrapper, + 'chart', + this.baseWidth, + this.baseHeight + ); + this.svgDefs = makeSVGDefs(this.svg); - const old_pos = this.x_old_axis_positions; - const new_pos = this.x_axis_positions; + // I WISH !!! + // this.svg = makeSVGGroup( + // svgContainer, + // 'flipped-coord-system', + // `translate(0, ${this.baseHeight}) scale(1, -1)` + // ); - const old_vals = this.old_x_values; - const new_vals = this.x; + this.drawArea = makeSVGGroup( + this.svg, + this.type + '-chart', + `translate(${this.leftMargin}, ${this.translateY})` + ); + } - const last_line_pos = old_pos[old_pos.length - 1]; + renderLegend() {} - let add_and_animate_line = (value, old_pos, new_pos) => { - if(typeof new_pos === 'string') { - new_pos = parseInt(new_pos.substring(0, new_pos.length-1)); - } - const x_line = makeXLine( - height, - text_start_at, - value, // new value - 'x-value-text', - axis_line_class, - old_pos // old position - ); - this.x_axis_group.appendChild(x_line); - - this.elements_to_animate && this.elements_to_animate.push([ - {unit: x_line, array: [0], index: 0}, - {transform: `${ new_pos }, 0`}, - 350, - "easein", - "translate", - {transform: `${ old_pos }, 0`} - ]); - }; + setupNavigation(init=false) { + if(!this.config.isNavigable) return; - this.x_axis_group.textContent = ''; + if(init) { + this.bindOverlay(); + + this.keyActions = { + '13': this.onEnterKey.bind(this), + '37': this.onLeftArrow.bind(this), + '38': this.onUpArrow.bind(this), + '39': this.onRightArrow.bind(this), + '40': this.onDownArrow.bind(this), + }; - this.make_new_axis_anim_lines( - old_pos, - new_pos, - old_vals, - new_vals, - last_line_pos, - add_and_animate_line - ); + document.addEventListener('keydown', (e) => { + if(isElementInViewport(this.chartWrapper)) { + e = e || window.event; + if(this.keyActions[e.keyCode]) { + this.keyActions[e.keyCode](); + } + } + }); + } } - make_anim_y_axis() { - // Animate Y AXIS to account for more or less axis lines + makeOverlay() {} + updateOverlay() {} + bindOverlay() {} + bindUnits() {} - const old_pos = this.y_old_axis_values.map(value => - this.zero_line - value * this.multiplier); - const new_pos = this.y_axis_values.map(value => - this.zero_line - value * this.multiplier); + onLeftArrow() {} + onRightArrow() {} + onUpArrow() {} + onDownArrow() {} + onEnterKey() {} - const old_vals = this.y_old_axis_values; - const new_vals = this.y_axis_values; + getDataPoint(index = 0) {} + setCurrentDataPoint(point) {} - const last_line_pos = old_pos[old_pos.length - 1]; + updateDataset(dataset, index) {} + addDataset(dataset, index) {} + removeDataset(index = 0) {} - this.y_axis_group.textContent = ''; + updateDatasets(datasets) {} - this.make_new_axis_anim_lines( - old_pos, - new_pos, - old_vals, - new_vals, - last_line_pos, - this.add_and_animate_y_line.bind(this), - this.y_axis_group - ); - } + updateDataPoint(dataPoint, index = 0) {} + addDataPoint(dataPoint, index = 0) {} + removeDataPoint(index = 0) {} - make_anim_y_specifics() { - this.specific_y_group.textContent = ''; - this.specific_values.map((d) => { - this.add_and_animate_y_line( - d.title, - this.old_zero_line - d.value * this.old_multiplier, - this.zero_line - d.value * this.multiplier, - 0, - this.specific_y_group, - d.line_type, - true - ); - }); + getDifferentChart(type) { + return getDifferentChart(type, this.type, this.parent, this.rawChartArgs); } +} - make_new_axis_anim_lines(old_pos, new_pos, old_vals, new_vals, last_line_pos, add_and_animate_line, group) { - let superimposed_positions, superimposed_values; - let no_of_extras = new_vals.length - old_vals.length; - if(no_of_extras > 0) { - // More axis are needed - // First make only the superimposed (same position) ones - // Add in the extras at the end later - superimposed_positions = new_pos.slice(0, old_pos.length); - superimposed_values = new_vals.slice(0, old_vals.length); - } else { - // Axis have to be reduced - // Fake it by moving all current extra axis to the last position - // You'll need filler positions and values in the new arrays - const filler_vals = new Array(Math.abs(no_of_extras)).fill(""); - superimposed_values = new_vals.concat(filler_vals); - - const filler_pos = new Array(Math.abs(no_of_extras)).fill(last_line_pos + "F"); - superimposed_positions = new_pos.concat(filler_pos); - } - - superimposed_values.map((value, i) => { - add_and_animate_line(value, old_pos[i], superimposed_positions[i], i, group); - }); +class AggregationChart extends BaseChart { + constructor(parent, args) { + super(parent, args); + } - if(no_of_extras > 0) { - // Add in extra axis in the end - // and then animate to new positions - const extra_values = new_vals.slice(old_vals.length); - const extra_positions = new_pos.slice(old_pos.length); + configure(args) { + super.configure(args); - extra_values.map((value, i) => { - add_and_animate_line(value, last_line_pos, extra_positions[i], i, group); - }); - } + this.config.maxSlices = args.maxSlices || 20; + this.config.maxLegendPoints = args.maxLegendPoints || 20; } - add_and_animate_y_line(value, old_pos, new_pos, i, group, type, specific=false) { - let filler = false; - if(typeof new_pos === 'string') { - new_pos = parseInt(new_pos.substring(0, new_pos.length-1)); - filler = true; - } - let new_props = {transform: `0, ${ new_pos }`}; - let old_props = {transform: `0, ${ old_pos }`}; + calc() { + let s = this.state; + let maxSlices = this.config.maxSlices; + s.sliceTotals = []; - if(filler) { - new_props['stroke-opacity'] = 0; - // old_props['stroke-opacity'] = 1; - } + let allTotals = this.data.labels.map((label, i) => { + let total = 0; + this.data.datasets.map(e => { + total += e.values[i]; + }); + return [total, label]; + }).filter(d => { return d[0] > 0; }); // keep only positive results - let [width, text_end_at, axis_line_class, start_at] = this.get_y_axis_line_props(specific); - let axis_label_class = !specific ? 'y-value-text' : 'specific-value'; - value = !specific ? value : (value+"").toUpperCase(); - const y_line = makeYLine( - start_at, - width, - text_end_at, - value, - axis_label_class, - axis_line_class, - old_pos, // old position - (value === 0 && i !== 0), // Non-first Zero line - type - ); + let totals = allTotals; + if(allTotals.length > maxSlices) { + // Prune and keep a grey area for rest as per maxSlices + allTotals.sort((a, b) => { return b[0] - a[0]; }); - group.appendChild(y_line); + totals = allTotals.slice(0, maxSlices-1); + let remaining = allTotals.slice(maxSlices-1); - this.elements_to_animate && this.elements_to_animate.push([ - {unit: y_line, array: [0], index: 0}, - new_props, - 350, - "easein", - "translate", - old_props - ]); - } + let sumOfRemaining = 0; + remaining.map(d => {sumOfRemaining += d[0];}); + totals.push([sumOfRemaining, 'Rest']); + this.colors[maxSlices-1] = 'grey'; + } - set_avg_unit_width_and_x_offset() { - // Set the ... you get it - this.avg_unit_width = this.width/(this.x.length - 1); - this.x_offset = 0; + s.labels = []; + totals.map(d => { + s.sliceTotals.push(d[0]); + s.labels.push(d[1]); + }); } - get_all_y_values() { - let all_values = []; + renderLegend() { + let s = this.state; - // Add in all the y values in the datasets - this.y.map(d => { - all_values = all_values.concat(d.values); - }); + this.statsWrapper.textContent = ''; - // Add in all the specific values - return all_values.concat(this.specific_values.map(d => d.value)); - } + this.legendTotals = s.sliceTotals.slice(0, this.config.maxLegendPoints); - calc_y_dependencies() { - this.y_min_tops = new Array(this.x_axis_positions.length).fill(9999); - this.y.map(d => { - d.y_tops = d.values.map( val => floatTwo(this.zero_line - val * this.multiplier)); - d.y_tops.map( (y_top, i) => { - if(y_top < this.y_min_tops[i]) { - this.y_min_tops[i] = y_top; - } - }); + let xValues = s.labels; + this.legendTotals.map((d, i) => { + if(d) { + let stats = $.create('div', { + className: 'stats', + inside: this.statsWrapper + }); + stats.innerHTML = ` + + ${xValues[i]}: + ${d} + `; + } }); - // this.chart_wrapper.removeChild(this.tip.container); - // this.make_tooltip(); } } -class BarChart extends AxisChart { - constructor(args) { - super(args); +class PercentageChart extends AggregationChart { + constructor(parent, args) { + super(parent, args); + this.type = 'percentage'; - this.type = 'bar'; - this.x_axis_mode = args.x_axis_mode || 'tick'; - this.y_axis_mode = args.y_axis_mode || 'span'; this.setup(); } - setup_values() { - super.setup_values(); - this.x_offset = this.avg_unit_width; - this.unit_args = { - type: 'bar', - args: { - spaceWidth: this.avg_unit_width/2, - } - }; - } + makeChartArea() { + this.chartWrapper.className += ' ' + 'graph-focus-margin'; + this.chartWrapper.style.marginTop = '45px'; - make_overlay() { - // Just make one out of the first element - let index = this.x.length - 1; - let unit = this.y[0].svg_units[index]; - this.update_current_data_point(index); + this.statsWrapper.className += ' ' + 'graph-focus-margin'; + this.statsWrapper.style.marginBottom = '30px'; + this.statsWrapper.style.paddingTop = '0px'; - if(this.overlay) { - this.overlay.parentNode.removeChild(this.overlay); - } - this.overlay = unit.cloneNode(); - this.overlay.style.fill = '#000000'; - this.overlay.style.opacity = '0.4'; - this.draw_area.appendChild(this.overlay); - } + this.svg = $.create('div', { + className: 'div', + inside: this.chartWrapper + }); - bind_overlay() { - // on event, update overlay - this.parent.addEventListener('data-select', (e) => { - this.update_overlay(e.svg_unit); + this.chart = $.create('div', { + className: 'progress-chart', + inside: this.svg }); - } - bind_units(units_array) { - units_array.map(unit => { - unit.addEventListener('click', () => { - let index = unit.getAttribute('data-point-index'); - this.update_current_data_point(index); - }); + this.percentageBar = $.create('div', { + className: 'progress', + inside: this.chart }); } - update_overlay(unit) { - let attributes = []; - Object.keys(unit.attributes).map(index => { - attributes.push(unit.attributes[index]); + render() { + let s = this.state; + this.grandTotal = s.sliceTotals.reduce((a, b) => a + b, 0); + s.slices = []; + s.sliceTotals.map((total, i) => { + let slice = $.create('div', { + className: `progress-bar`, + 'data-index': i, + inside: this.percentageBar, + styles: { + background: this.colors[i], + width: total*100/this.grandTotal + "%" + } + }); + s.slices.push(slice); }); + } - attributes.filter(attr => attr.specified).map(attr => { - this.overlay.setAttribute(attr.name, attr.nodeValue); - }); + bindTooltip() { + let s = this.state; - this.overlay.style.fill = '#000000'; - this.overlay.style.opacity = '0.4'; - } + this.chartWrapper.addEventListener('mousemove', (e) => { + let slice = e.target; + if(slice.classList.contains('progress-bar')) { - on_left_arrow() { - this.update_current_data_point(this.current_index - 1); - } + let i = slice.getAttribute('data-index'); + let gOff = getOffset(this.chartWrapper), pOff = getOffset(slice); - on_right_arrow() { - this.update_current_data_point(this.current_index + 1); - } + let x = pOff.left - gOff.left + slice.offsetWidth/2; + let y = pOff.top - gOff.top - 6; + let title = (this.formattedLabels && this.formattedLabels.length>0 + ? this.formattedLabels[i] : this.state.labels[i]) + ': '; + let percent = (s.sliceTotals[i]*100/this.grandTotal).toFixed(1); - set_avg_unit_width_and_x_offset() { - this.avg_unit_width = this.width/(this.x.length + 1); - this.x_offset = this.avg_unit_width; + this.tip.setValues(x, y, {name: title, value: percent + "%"}); + this.tip.showTip(); + } + }); } } -class LineChart extends AxisChart { - constructor(args) { - super(args); +class ChartComponent { + constructor({ + layerClass = '', + layerTransform = '', + constants, - this.x_axis_mode = args.x_axis_mode || 'span'; - this.y_axis_mode = args.y_axis_mode || 'span'; + getData, + makeElements, + animateElements + }) { + this.layerTransform = layerTransform; + this.constants = constants; - if(args.hasOwnProperty('show_dots')) { - this.show_dots = args.show_dots; - } else { - this.show_dots = 1; - } - this.region_fill = args.region_fill; + this.makeElements = makeElements; + this.getData = getData; - if(Object.getPrototypeOf(this) !== LineChart.prototype) { - return; - } - this.dot_radius = args.dot_radius || 4; - this.heatline = args.heatline; - this.type = 'line'; + this.animateElements = animateElements; - this.setup(); - } + this.store = []; - setup_graph_components() { - this.setup_path_groups(); - super.setup_graph_components(); - } + this.layerClass = layerClass; + this.layerClass = typeof(this.layerClass) === 'function' + ? this.layerClass() : this.layerClass; - setup_path_groups() { - this.paths_groups = []; - this.y.map((d, i) => { - this.paths_groups[i] = makeSVGGroup( - this.draw_area, - 'path-group path-group-' + i - ); - }); + this.refresh(); } - setup_values() { - super.setup_values(); - this.unit_args = { - type: 'dot', - args: { radius: this.dot_radius } - }; + refresh(data) { + this.data = data || this.getData(); } - make_new_units_for_dataset(x_values, y_values, color, dataset_index, - no_of_datasets, units_group, units_array, unit) { - if(this.show_dots) { - super.make_new_units_for_dataset(x_values, y_values, color, dataset_index, - no_of_datasets, units_group, units_array, unit); - } + setup(parent) { + this.layer = makeSVGGroup(parent, this.layerClass, this.layerTransform); } - make_paths() { - this.y.map((d, i) => { - this.make_path(d, i, this.x_axis_positions, d.y_tops, d.color || this.colors[i]); - }); + make() { + this.render(this.data); + this.oldData = this.data; } - make_path(d, i, x_positions, y_positions, color) { - let points_list = y_positions.map((y, i) => (x_positions[i] + ',' + y)); - let points_str = points_list.join("L"); - - this.paths_groups[i].textContent = ''; + render(data) { + this.store = this.makeElements(data); - d.path = makePath("M"+points_str, 'line-graph-path', color); - this.paths_groups[i].appendChild(d.path); - - if(this.heatline) { - let gradient_id = makeGradient(this.svg_defs, color); - d.path.style.stroke = `url(#${gradient_id})`; - } - - if(this.region_fill) { - this.fill_region_for_dataset(d, i, color, points_str); - } + this.layer.textContent = ''; + this.store.forEach(element => { + this.layer.appendChild(element); + }); } - fill_region_for_dataset(d, i, color, points_str) { - let gradient_id = makeGradient(this.svg_defs, color, true); - let pathStr = "M" + `0,${this.zero_line}L` + points_str + `L${this.width},${this.zero_line}`; - - d.regionPath = makePath(pathStr, `region-fill`, 'none', `url(#${gradient_id})`); - this.paths_groups[i].appendChild(d.regionPath); + update(animate = true) { + this.refresh(); + let animateElements = []; + if(animate) { + animateElements = this.animateElements(this.data); + } + return animateElements; } } -class ScatterChart extends LineChart { - constructor(args) { - super(args); - - this.type = 'scatter'; +let componentConfigs = { + pieSlices: { + layerClass: 'pie-slices', + makeElements(data) { + return data.sliceStrings.map((s, i) =>{ + let slice = makePath(s, 'pie-path', 'none', data.colors[i]); + slice.style.transition = 'transform .3s;'; + return slice; + }); + }, - if(!args.dot_radius) { - this.dot_radius = 8; - } else { - this.dot_radius = args.dot_radius; + animateElements(newData) { + return this.store.map((slice, i) => + animatePathStr(slice, newData.sliceStrings[i]) + ); } + }, + yAxis: { + layerClass: 'y axis', + makeElements(data) { + return data.positions.map((position, i) => + yLine(position, data.labels[i], this.constants.width, + {mode: this.constants.mode, pos: this.constants.pos}) + ); + }, - this.setup(); - } - - setup_graph_components() { - this.setup_path_groups(); - super.setup_graph_components(); - } - - setup_path_groups() {} - - setup_values() { - super.setup_values(); - this.unit_args = { - type: 'dot', - args: { radius: this.dot_radius } - }; - } - - make_paths() {} - make_path() {} -} - -class PercentageChart extends BaseChart { - constructor(args) { - super(args); - this.type = 'percentage'; - - this.max_slices = 10; - this.max_legend_points = 6; + animateElements(newData) { + let newPos = newData.positions; + let newLabels = newData.labels; + let oldPos = this.oldData.positions; + let oldLabels = this.oldData.labels; - this.setup(); - } + [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); + [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); - make_chart_area() { - this.chart_wrapper.className += ' ' + 'graph-focus-margin'; - this.chart_wrapper.style.marginTop = '45px'; + this.render({ + positions: oldPos, + labels: newLabels + }); - this.stats_wrapper.className += ' ' + 'graph-focus-margin'; - this.stats_wrapper.style.marginBottom = '30px'; - this.stats_wrapper.style.paddingTop = '0px'; - } + return this.store.map((line, i) => { + return translateHoriLine( + line, newPos[i], oldPos[i] + ); + }); + } + }, - make_draw_area() { - this.chart_div = $.create('div', { - className: 'div', - inside: this.chart_wrapper - }); + xAxis: { + layerClass: 'x axis', + makeElements(data) { + return data.positions.map((position, i) => + xLine(position, data.calcLabels[i], this.constants.height, + {mode: this.constants.mode, pos: this.constants.pos}) + ); + }, - this.chart = $.create('div', { - className: 'progress-chart', - inside: this.chart_div - }); - } + animateElements(newData) { + let newPos = newData.positions; + let newLabels = newData.calcLabels; + let oldPos = this.oldData.positions; + let oldLabels = this.oldData.calcLabels; - setup_components() { - this.percentage_bar = $.create('div', { - className: 'progress', - inside: this.chart - }); - } + [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); + [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); - setup_values() { - this.slice_totals = []; - let all_totals = this.data.labels.map((d, i) => { - let total = 0; - this.data.datasets.map(e => { - total += e.values[i]; + this.render({ + positions: oldPos, + calcLabels: newLabels }); - return [total, d]; - }).filter(d => { return d[0] > 0; }); // keep only positive results - let totals = all_totals; + return this.store.map((line, i) => { + return translateVertLine( + line, newPos[i], oldPos[i] + ); + }); + } + }, - if(all_totals.length > this.max_slices) { - all_totals.sort((a, b) => { return b[0] - a[0]; }); + yMarkers: { + layerClass: 'y-markers', + makeElements(data) { + return data.map(marker => + yMarker(marker.position, marker.label, this.constants.width, + {pos:'right', mode: 'span', lineType: 'dashed'}) + ); + }, + animateElements(newData) { + [this.oldData, newData] = equilizeNoOfElements(this.oldData, newData); - totals = all_totals.slice(0, this.max_slices-1); - let others = all_totals.slice(this.max_slices-1); + let newPos = newData.map(d => d.position); + let newLabels = newData.map(d => d.label); - let sum_of_others = 0; - others.map(d => {sum_of_others += d[0];}); + let oldPos = this.oldData.map(d => d.position); - totals.push([sum_of_others, 'Rest']); + this.render(oldPos.map((pos, i) => { + return { + position: oldPos[i], + label: newLabels[i] + }; + })); - this.colors[this.max_slices-1] = 'grey'; + return this.store.map((line, i) => { + return translateHoriLine( + line, newPos[i], oldPos[i] + ); + }); } + }, - this.labels = []; - totals.map(d => { - this.slice_totals.push(d[0]); - this.labels.push(d[1]); - }); + yRegions: { + layerClass: 'y-regions', + makeElements(data) { + return data.map(region => + yRegion(region.startPos, region.endPos, this.constants.width, + region.label) + ); + }, + animateElements(newData) { + [this.oldData, newData] = equilizeNoOfElements(this.oldData, newData); + + let newPos = newData.map(d => d.endPos); + let newLabels = newData.map(d => d.label); + let newStarts = newData.map(d => d.startPos); + + let oldPos = this.oldData.map(d => d.endPos); + let oldStarts = this.oldData.map(d => d.startPos); + + this.render(oldPos.map((pos, i) => { + return { + startPos: oldStarts[i], + endPos: oldPos[i], + label: newLabels[i] + }; + })); + + let animateElements = []; + + this.store.map((rectGroup, i) => { + animateElements = animateElements.concat(animateRegion( + rectGroup, newStarts[i], newPos[i], oldPos[i] + )); + }); - this.legend_totals = this.slice_totals.slice(0, this.max_legend_points); - } + return animateElements; + } + }, + + barGraph: { + layerClass: function() { return 'dataset-units dataset-bars dataset-' + this.constants.index; }, + makeElements(data) { + let c = this.constants; + this.unitType = 'bar'; + this.units = data.yPositions.map((y, j) => { + return datasetBar( + data.xPositions[j], + y, + data.barWidth, + c.color, + data.labels[j], + j, + data.offsets[j], + { + zeroLine: data.zeroLine, + barsWidth: data.barsWidth, + minHeight: c.minHeight + } + ); + }); + return this.units; + }, + animateElements(newData) { + let c = this.constants; + + let newXPos = newData.xPositions; + let newYPos = newData.yPositions; + let newOffsets = newData.offsets; + let newLabels = newData.labels; + + let oldXPos = this.oldData.xPositions; + let oldYPos = this.oldData.yPositions; + let oldOffsets = this.oldData.offsets; + let oldLabels = this.oldData.labels; + + [oldXPos, newXPos] = equilizeNoOfElements(oldXPos, newXPos); + [oldYPos, newYPos] = equilizeNoOfElements(oldYPos, newYPos); + [oldOffsets, newOffsets] = equilizeNoOfElements(oldOffsets, newOffsets); + [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); + + this.render({ + xPositions: oldXPos, + yPositions: oldYPos, + offsets: oldOffsets, + labels: newLabels, + + zeroLine: this.oldData.zeroLine, + barsWidth: this.oldData.barsWidth, + barWidth: this.oldData.barWidth, + }); - make_graph_components() { - this.grand_total = this.slice_totals.reduce((a, b) => a + b, 0); - this.slices = []; - this.slice_totals.map((total, i) => { - let slice = $.create('div', { - className: `progress-bar`, - inside: this.percentage_bar, - styles: { - background: this.colors[i], - width: total*100/this.grand_total + "%" - } + let animateElements = []; + + this.store.map((bar, i) => { + animateElements = animateElements.concat(animateBar( + bar, newXPos[i], newYPos[i], newData.barWidth, newOffsets[i], c.index, + {zeroLine: newData.zeroLine} + )); }); - this.slices.push(slice); - }); - } - bind_tooltip() { - this.slices.map((slice, i) => { - slice.addEventListener('mouseenter', () => { - let g_off = offset(this.chart_wrapper), p_off = offset(slice); + return animateElements; + } + }, + + lineGraph: { + layerClass: function() { return 'dataset-units dataset-line dataset-' + this.constants.index; }, + makeElements(data) { + let c = this.constants; + this.unitType = 'dot'; + this.paths = {}; + if(!c.hideLine) { + this.paths = getPaths( + data.xPositions, + data.yPositions, + c.color, + { + heatline: c.heatline, + regionFill: c.regionFill + }, + { + svgDefs: c.svgDefs, + zeroLine: data.zeroLine + } + ); + } - let x = p_off.left - g_off.left + slice.offsetWidth/2; - let y = p_off.top - g_off.top - 6; - let title = (this.formatted_labels && this.formatted_labels.length>0 - ? this.formatted_labels[i] : this.labels[i]) + ': '; - let percent = (this.slice_totals[i]*100/this.grand_total).toFixed(1); + this.units = []; + if(!c.hideDots) { + this.units = data.yPositions.map((y, j) => { + return datasetDot( + data.xPositions[j], + y, + data.radius, + c.color, + (c.valuesOverPoints ? data.values[j] : ''), + j + ); + }); + } - this.tip.set_values(x, y, title, percent + "%"); - this.tip.show_tip(); + return Object.values(this.paths).concat(this.units); + }, + animateElements(newData) { + let newXPos = newData.xPositions; + let newYPos = newData.yPositions; + let newValues = newData.values; + + let oldXPos = this.oldData.xPositions; + let oldYPos = this.oldData.yPositions; + let oldValues = this.oldData.values; + + [oldXPos, newXPos] = equilizeNoOfElements(oldXPos, newXPos); + [oldYPos, newYPos] = equilizeNoOfElements(oldYPos, newYPos); + [oldValues, newValues] = equilizeNoOfElements(oldValues, newValues); + + this.render({ + xPositions: oldXPos, + yPositions: oldYPos, + values: newValues, + + zeroLine: this.oldData.zeroLine, + radius: this.oldData.radius, }); - }); - } - show_summary() { - let x_values = this.formatted_labels && this.formatted_labels.length > 0 - ? this.formatted_labels : this.labels; - this.legend_totals.map((d, i) => { - if(d) { - let stats = $.create('div', { - className: 'stats', - inside: this.stats_wrapper + let animateElements = []; + + if(Object.keys(this.paths).length) { + animateElements = animateElements.concat(animatePath( + this.paths, newXPos, newYPos, newData.zeroLine)); + } + + if(this.units.length) { + this.units.map((dot, i) => { + animateElements = animateElements.concat(animateDot( + dot, newXPos[i], newYPos[i])); }); - stats.innerHTML = ` - - ${x_values[i]}: - ${d} - `; } - }); + + return animateElements; + } } -} +}; -const ANGLE_RATIO = Math.PI / 180; -const FULL_ANGLE = 360; +function getComponent(name, constants, getData) { + let keys = Object.keys(componentConfigs).filter(k => name.includes(k)); + let config = componentConfigs[keys[0]]; + Object.assign(config, { + constants: constants, + getData: getData + }); + return new ChartComponent(config); +} -class PieChart extends BaseChart { - constructor(args) { - super(args); +class PieChart extends AggregationChart { + constructor(parent, args) { + super(parent, args); this.type = 'pie'; - this.elements_to_animate = null; - this.hoverRadio = args.hoverRadio || 0.1; - this.max_slices = 10; - this.max_legend_points = 6; - this.isAnimate = false; - this.startAngle = args.startAngle || 0; - this.clockWise = args.clockWise || false; - this.mouseMove = this.mouseMove.bind(this); - this.mouseLeave = this.mouseLeave.bind(this); + this.initTimeout = 0; + this.setup(); } - setup_values() { - this.centerX = this.width / 2; - this.centerY = this.height / 2; - this.radius = (this.height > this.width ? this.centerX : this.centerY); - this.slice_totals = []; - let all_totals = this.data.labels.map((d, i) => { - let total = 0; - this.data.datasets.map(e => { - total += e.values[i]; - }); - return [total, d]; - }).filter(d => { return d[0] > 0; }); // keep only positive results - let totals = all_totals; + configure(args) { + super.configure(args); + this.mouseMove = this.mouseMove.bind(this); + this.mouseLeave = this.mouseLeave.bind(this); - if(all_totals.length > this.max_slices) { - all_totals.sort((a, b) => { return b[0] - a[0]; }); + this.hoverRadio = args.hoverRadio || 0.1; + this.config.startAngle = args.startAngle || 0; - totals = all_totals.slice(0, this.max_slices-1); - let others = all_totals.slice(this.max_slices-1); + this.clockWise = args.clockWise || false; + } - let sum_of_others = 0; - others.map(d => {sum_of_others += d[0];}); + prepareFirstData(data=this.data) { + this.init = 1; + return data; + } - totals.push([sum_of_others, 'Rest']); + calc() { + super.calc(); + let s = this.state; - this.colors[this.max_slices-1] = 'grey'; - } + this.center = { + x: this.width / 2, + y: this.height / 2 + }; + this.radius = (this.height > this.width ? this.center.x : this.center.y); - this.labels = []; - totals.map(d => { - this.slice_totals.push(d[0]); - this.labels.push(d[1]); - }); + s.grandTotal = s.sliceTotals.reduce((a, b) => a + b, 0); - this.legend_totals = this.slice_totals.slice(0, this.max_legend_points); + this.calcSlices(); } - static getPositionByAngle(angle,radius){ - return { - x:Math.sin(angle * ANGLE_RATIO) * radius, - y:Math.cos(angle * ANGLE_RATIO) * radius, - }; - } - makeArcPath(startPosition,endPosition){ - const{centerX,centerY,radius,clockWise} = this; - return `M${centerX} ${centerY} L${centerX+startPosition.x} ${centerY+startPosition.y} A ${radius} ${radius} 0 0 ${clockWise ? 1 : 0} ${centerX+endPosition.x} ${centerY+endPosition.y} z`; - } - make_graph_components(init){ - const{radius,clockWise} = this; - this.grand_total = this.slice_totals.reduce((a, b) => a + b, 0); - const prevSlicesProperties = this.slicesProperties || []; - this.slices = []; - this.elements_to_animate = []; - this.slicesProperties = []; - let curAngle = 180 - this.startAngle; - this.slice_totals.map((total, i) => { + calcSlices() { + let s = this.state; + const { radius, clockWise } = this; + + const prevSlicesProperties = s.slicesProperties || []; + s.sliceStrings = []; + s.slicesProperties = []; + let curAngle = 180 - this.config.startAngle; + + s.sliceTotals.map((total, i) => { const startAngle = curAngle; - const originDiffAngle = (total / this.grand_total) * FULL_ANGLE; + const originDiffAngle = (total / s.grandTotal) * FULL_ANGLE; const diffAngle = clockWise ? -originDiffAngle : originDiffAngle; const endAngle = curAngle = curAngle + diffAngle; - const startPosition = PieChart.getPositionByAngle(startAngle,radius); - const endPosition = PieChart.getPositionByAngle(endAngle,radius); - const prevProperty = init && prevSlicesProperties[i]; + const startPosition = getPositionByAngle(startAngle, radius); + const endPosition = getPositionByAngle(endAngle, radius); + + const prevProperty = this.init && prevSlicesProperties[i]; + let curStart,curEnd; - if(init){ - curStart = prevProperty?prevProperty.startPosition : startPosition; - curEnd = prevProperty? prevProperty.endPosition : startPosition; - }else{ + if(this.init) { + curStart = prevProperty ? prevProperty.startPosition : startPosition; + curEnd = prevProperty ? prevProperty.endPosition : startPosition; + } else { curStart = startPosition; curEnd = endPosition; } - const curPath = this.makeArcPath(curStart,curEnd); - let slice = makePath(curPath, 'pie-path', 'none', this.colors[i]); - slice.style.transition = 'transform .3s;'; - this.draw_area.appendChild(slice); + const curPath = makeArcPathStr(curStart, curEnd, this.center, this.radius, this.clockWise); - this.slices.push(slice); - this.slicesProperties.push({ + s.sliceStrings.push(curPath); + s.slicesProperties.push({ startPosition, endPosition, value: total, - total: this.grand_total, + total: s.grandTotal, startAngle, endAngle, - angle:diffAngle + angle: diffAngle }); - if(init){ - this.elements_to_animate.push([{unit: slice, array: this.slices, index: this.slices.length - 1}, - {d:this.makeArcPath(startPosition,endPosition)}, - 650, "easein",null,{ - d:curPath - }]); - } }); - if(init){ - this.run_animation(); - } + this.init = 0; } - run_animation() { - // if(this.isAnimate) return ; - // this.isAnimate = true; - if(!this.elements_to_animate || this.elements_to_animate.length === 0) return; - let anim_svg = runSVGAnimation(this.svg, this.elements_to_animate); - if(this.svg.parentNode == this.chart_wrapper) { - this.chart_wrapper.removeChild(this.svg); - this.chart_wrapper.appendChild(anim_svg); + setupComponents() { + let s = this.state; - } + let componentConfigs = [ + [ + 'pieSlices', + { }, + function() { + return { + sliceStrings: s.sliceStrings, + colors: this.colors + }; + }.bind(this) + ] + ]; - // Replace the new svg (data has long been replaced) - setTimeout(() => { - // this.isAnimate = false; - if(anim_svg.parentNode == this.chart_wrapper) { - this.chart_wrapper.removeChild(anim_svg); - this.chart_wrapper.appendChild(this.svg); - } - }, 650); + this.components = new Map(componentConfigs + .map(args => { + let component = getComponent(...args); + return [args[0], component]; + })); } calTranslateByAngle(property){ const{radius,hoverRadio} = this; - const position = PieChart.getPositionByAngle(property.startAngle+(property.angle / 2),radius); + const position = getPositionByAngle(property.startAngle+(property.angle / 2),radius); return `translate3d(${(position.x) * hoverRadio}px,${(position.y) * hoverRadio}px,0)`; } + hoverSlice(path,i,flag,e){ if(!path) return; const color = this.colors[i]; - if(flag){ - transform(path,this.calTranslateByAngle(this.slicesProperties[i])); - path.style.fill = lightenDarkenColor(color,50); - let g_off = offset(this.svg); + if(flag) { + transform(path, this.calTranslateByAngle(this.state.slicesProperties[i])); + path.style.fill = lightenDarkenColor(color, 50); + let g_off = getOffset(this.svg); let x = e.pageX - g_off.left + 10; let y = e.pageY - g_off.top - 10; - let title = (this.formatted_labels && this.formatted_labels.length>0 - ? this.formatted_labels[i] : this.labels[i]) + ': '; - let percent = (this.slice_totals[i]*100/this.grand_total).toFixed(1); - this.tip.set_values(x, y, title, percent + "%"); - this.tip.show_tip(); - }else{ + let title = (this.formatted_labels && this.formatted_labels.length > 0 + ? this.formatted_labels[i] : this.state.labels[i]) + ': '; + let percent = (this.state.sliceTotals[i] * 100 / this.state.grandTotal).toFixed(1); + this.tip.setValues(x, y, {name: title, value: percent + "%"}); + this.tip.showTip(); + } else { transform(path,'translate3d(0,0,0)'); - this.tip.hide_tip(); + this.tip.hideTip(); path.style.fill = color; } } + bindTooltip() { + this.chartWrapper.addEventListener('mousemove', this.mouseMove); + this.chartWrapper.addEventListener('mouseleave', this.mouseLeave); + } + mouseMove(e){ const target = e.target; + let slices = this.components.get('pieSlices').store; let prevIndex = this.curActiveSliceIndex; let prevAcitve = this.curActiveSlice; - for(let i = 0; i < this.slices.length; i++){ - if(target === this.slices[i]){ - this.hoverSlice(prevAcitve,prevIndex,false); - this.curActiveSlice = target; - this.curActiveSliceIndex = i; - this.hoverSlice(target,i,true,e); - break; - } + if(slices.includes(target)) { + let i = slices.indexOf(target); + this.hoverSlice(prevAcitve, prevIndex,false); + this.curActiveSlice = target; + this.curActiveSliceIndex = i; + this.hoverSlice(target, i, true, e); + } else { + this.mouseLeave(); } } + mouseLeave(){ this.hoverSlice(this.curActiveSlice,this.curActiveSliceIndex,false); } - bind_tooltip() { - this.draw_area.addEventListener('mousemove',this.mouseMove); - this.draw_area.addEventListener('mouseleave',this.mouseLeave); - } - - show_summary() { - let x_values = this.formatted_labels && this.formatted_labels.length > 0 - ? this.formatted_labels : this.labels; - this.legend_totals.map((d, i) => { - const color = this.colors[i]; - - if(d) { - let stats = $.create('div', { - className: 'stats', - inside: this.stats_wrapper - }); - stats.innerHTML = ` - - ${x_values[i]}: - ${d} - `; - } - }); - } } // Playing around with dates @@ -2592,49 +2113,256 @@ function getDaysBetween(startDateStr, endDateStr) { return (treatAsUtc(endDateStr) - treatAsUtc(startDateStr)) / millisecondsPerDay; } -// mutates -function addDays(date, numberOfDays) { - date.setDate(date.getDate() + numberOfDays); +// mutates +function addDays(date, numberOfDays) { + date.setDate(date.getDate() + numberOfDays); +} + +function normalize(x) { + // Calculates mantissa and exponent of a number + // Returns normalized number and exponent + // https://stackoverflow.com/q/9383593/6495043 + + if(x===0) { + return [0, 0]; + } + if(isNaN(x)) { + return {mantissa: -6755399441055744, exponent: 972}; + } + var sig = x > 0 ? 1 : -1; + if(!isFinite(x)) { + return {mantissa: sig * 4503599627370496, exponent: 972}; + } + + x = Math.abs(x); + var exp = Math.floor(Math.log10(x)); + var man = x/Math.pow(10, exp); + + return [sig * man, exp]; +} + +function getChartRangeIntervals(max, min=0) { + let upperBound = Math.ceil(max); + let lowerBound = Math.floor(min); + let range = upperBound - lowerBound; + + let noOfParts = range; + let partSize = 1; + + // To avoid too many partitions + if(range > 5) { + if(range % 2 !== 0) { + upperBound++; + // Recalc range + range = upperBound - lowerBound; + } + noOfParts = range/2; + partSize = 2; + } + + // Special case: 1 and 2 + if(range <= 2) { + noOfParts = 4; + partSize = range/noOfParts; + } + + // Special case: 0 + if(range === 0) { + noOfParts = 5; + partSize = 1; + } + + let intervals = []; + for(var i = 0; i <= noOfParts; i++){ + intervals.push(lowerBound + partSize * i); + } + return intervals; +} + +function getChartIntervals(maxValue, minValue=0) { + let [normalMaxValue, exponent] = normalize(maxValue); + let normalMinValue = minValue ? minValue/Math.pow(10, exponent): 0; + + // Allow only 7 significant digits + normalMaxValue = normalMaxValue.toFixed(6); + + let intervals = getChartRangeIntervals(normalMaxValue, normalMinValue); + intervals = intervals.map(value => value * Math.pow(10, exponent)); + return intervals; +} + +function calcChartIntervals(values, withMinimum=false) { + //*** Where the magic happens *** + + // Calculates best-fit y intervals from given values + // and returns the interval array + + let maxValue = Math.max(...values); + let minValue = Math.min(...values); + + // Exponent to be used for pretty print + let exponent = 0, intervals = []; // eslint-disable-line no-unused-vars + + function getPositiveFirstIntervals(maxValue, absMinValue) { + let intervals = getChartIntervals(maxValue); + + let intervalSize = intervals[1] - intervals[0]; + + // Then unshift the negative values + let value = 0; + for(var i = 1; value < absMinValue; i++) { + value += intervalSize; + intervals.unshift((-1) * value); + } + return intervals; + } + + // CASE I: Both non-negative + + if(maxValue >= 0 && minValue >= 0) { + exponent = normalize(maxValue)[1]; + if(!withMinimum) { + intervals = getChartIntervals(maxValue); + } else { + intervals = getChartIntervals(maxValue, minValue); + } + } + + // CASE II: Only minValue negative + + else if(maxValue > 0 && minValue < 0) { + // `withMinimum` irrelevant in this case, + // We'll be handling both sides of zero separately + // (both starting from zero) + // Because ceil() and floor() behave differently + // in those two regions + + let absMinValue = Math.abs(minValue); + + if(maxValue >= absMinValue) { + exponent = normalize(maxValue)[1]; + intervals = getPositiveFirstIntervals(maxValue, absMinValue); + } else { + // Mirror: maxValue => absMinValue, then change sign + exponent = normalize(absMinValue)[1]; + let posIntervals = getPositiveFirstIntervals(absMinValue, maxValue); + intervals = posIntervals.map(d => d * (-1)); + } + + } + + // CASE III: Both non-positive + + else if(maxValue <= 0 && minValue <= 0) { + // Mirrored Case I: + // Work with positives, then reverse the sign and array + + let pseudoMaxValue = Math.abs(minValue); + let pseudoMinValue = Math.abs(maxValue); + + exponent = normalize(pseudoMaxValue)[1]; + if(!withMinimum) { + intervals = getChartIntervals(pseudoMaxValue); + } else { + intervals = getChartIntervals(pseudoMaxValue, pseudoMinValue); + } + + intervals = intervals.reverse().map(d => d * (-1)); + } + + return intervals; +} + +function getZeroIndex(yPts) { + let zeroIndex; + let interval = getIntervalSize(yPts); + if(yPts.indexOf(0) >= 0) { + // the range has a given zero + // zero-line on the chart + zeroIndex = yPts.indexOf(0); + } else if(yPts[0] > 0) { + // Minimum value is positive + // zero-line is off the chart: below + let min = yPts[0]; + zeroIndex = (-1) * min / interval; + } else { + // Maximum value is negative + // zero-line is off the chart: above + let max = yPts[yPts.length - 1]; + zeroIndex = (-1) * max / interval + (yPts.length - 1); + } + return zeroIndex; +} + + + +function getIntervalSize(orderedArray) { + return orderedArray[1] - orderedArray[0]; +} + +function getValueRange(orderedArray) { + return orderedArray[orderedArray.length-1] - orderedArray[0]; +} + +function scale(val, yAxis) { + return floatTwo(yAxis.zeroLine - val * yAxis.scaleMultiplier) +} + +function calcDistribution(values, distributionSize) { + // Assume non-negative values, + // implying distribution minimum at zero + + let dataMaxValue = Math.max(...values); + + let distributionStep = 1 / (distributionSize - 1); + let distribution = []; + + for(var i = 0; i < distributionSize; i++) { + let checkpoint = dataMaxValue * (distributionStep * i); + distribution.push(checkpoint); + } + + return distribution; } -// export function getMonthName() {} +function getMaxCheckpoint(value, distribution) { + return distribution.filter(d => d < value).length; +} class Heatmap extends BaseChart { - constructor({ - start = '', - domain = '', - subdomain = '', - data = {}, - discrete_domains = 0, - count_label = '', - legend_colors = [] - }) { - super(arguments[0]); + constructor(parent, options) { + super(parent, options); this.type = 'heatmap'; - this.domain = domain; - this.subdomain = subdomain; - this.data = data; - this.discrete_domains = discrete_domains; - this.count_label = count_label; + this.domain = options.domain || ''; + this.subdomain = options.subdomain || ''; + this.data = options.data || {}; + this.discreteDomains = options.discreteDomains === 0 ? 0 : 1; + this.countLabel = options.countLabel || ''; let today = new Date(); - this.start = start || addDays(today, 365); + this.start = options.start || addDays(today, 365); - legend_colors = legend_colors.slice(0, 5); - this.legend_colors = this.validate_colors(legend_colors) - ? legend_colors + let legendColors = (options.legendColors || []).slice(0, 5); + this.legendColors = this.validate_colors(legendColors) + ? legendColors : ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']; // Fixed 5-color theme, // More colors are difficult to parse visually this.distribution_size = 5; - this.translate_x = 0; + this.translateX = 0; this.setup(); } + setMargins() { + super.setMargins(); + this.leftMargin = 10; + this.translateY = 10; + } + validate_colors(colors) { if(colors.length < 5) return 0; @@ -2649,242 +2377,915 @@ class Heatmap extends BaseChart { return valid; } - setup_base_values() { + configure() { + super.configure(); this.today = new Date(); if(!this.start) { this.start = new Date(); this.start.setFullYear( this.start.getFullYear() - 1 ); } - this.first_week_start = new Date(this.start.toDateString()); - this.last_week_start = new Date(this.today.toDateString()); - if(this.first_week_start.getDay() !== 7) { - addDays(this.first_week_start, (-1) * this.first_week_start.getDay()); + this.firstWeekStart = new Date(this.start.toDateString()); + this.lastWeekStart = new Date(this.today.toDateString()); + if(this.firstWeekStart.getDay() !== 7) { + addDays(this.firstWeekStart, (-1) * this.firstWeekStart.getDay()); } - if(this.last_week_start.getDay() !== 7) { - addDays(this.last_week_start, (-1) * this.last_week_start.getDay()); + if(this.lastWeekStart.getDay() !== 7) { + addDays(this.lastWeekStart, (-1) * this.lastWeekStart.getDay()); } - this.no_of_cols = getWeeksBetween(this.first_week_start + '', this.last_week_start + '') + 1; + this.no_of_cols = getWeeksBetween(this.firstWeekStart + '', this.lastWeekStart + '') + 1; } - set_width() { - this.base_width = (this.no_of_cols + 3) * 12 ; + calcWidth() { + this.baseWidth = (this.no_of_cols + 3) * 12 ; - if(this.discrete_domains) { - this.base_width += (12 * 12); + if(this.discreteDomains) { + this.baseWidth += (12 * 12); } } - setup_components() { - this.domain_label_group = this.makeDrawAreaComponent( + makeChartArea() { + super.makeChartArea(); + this.domainLabelGroup = makeSVGGroup(this.drawArea, 'domain-label-group chart-label'); - this.data_groups = this.makeDrawAreaComponent( + this.dataGroups = makeSVGGroup(this.drawArea, 'data-groups', `translate(0, 20)` ); + + this.container.querySelector('.title').style.display = 'None'; + this.container.querySelector('.sub-title').style.display = 'None'; + this.container.querySelector('.graph-stats-container').style.display = 'None'; + this.chartWrapper.style.marginTop = '0px'; + this.chartWrapper.style.paddingTop = '0px'; } - setup_values() { - this.domain_label_group.textContent = ''; - this.data_groups.textContent = ''; + calc() { - let data_values = Object.keys(this.data).map(key => this.data[key]); - this.distribution = calcDistribution(data_values, this.distribution_size); + let dataValues = Object.keys(this.data).map(key => this.data[key]); + this.distribution = calcDistribution(dataValues, this.distribution_size); - this.month_names = ["January", "February", "March", "April", "May", "June", + this.monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; + } - this.render_all_weeks_and_store_x_values(this.no_of_cols); + render() { + this.renderAllWeeksAndStoreXValues(this.no_of_cols); } - render_all_weeks_and_store_x_values(no_of_weeks) { - let current_week_sunday = new Date(this.first_week_start); - this.week_col = 0; - this.current_month = current_week_sunday.getMonth(); + renderAllWeeksAndStoreXValues(no_of_weeks) { + // renderAllWeeksAndStoreXValues + this.domainLabelGroup.textContent = ''; + this.dataGroups.textContent = ''; + + let currentWeekSunday = new Date(this.firstWeekStart); + this.weekCol = 0; + this.currentMonth = currentWeekSunday.getMonth(); - this.months = [this.current_month + '']; - this.month_weeks = {}, this.month_start_points = []; - this.month_weeks[this.current_month] = 0; - this.month_start_points.push(13); + this.months = [this.currentMonth + '']; + this.monthWeeks = {}, this.monthStartPoints = []; + this.monthWeeks[this.currentMonth] = 0; + this.monthStartPoints.push(13); for(var i = 0; i < no_of_weeks; i++) { - let data_group, month_change = 0; - let day = new Date(current_week_sunday); - - [data_group, month_change] = this.get_week_squares_group(day, this.week_col); - this.data_groups.appendChild(data_group); - this.week_col += 1 + parseInt(this.discrete_domains && month_change); - this.month_weeks[this.current_month]++; - if(month_change) { - this.current_month = (this.current_month + 1) % 12; - this.months.push(this.current_month + ''); - this.month_weeks[this.current_month] = 1; + let dataGroup, monthChange = 0; + let day = new Date(currentWeekSunday); + + [dataGroup, monthChange] = this.get_week_squares_group(day, this.weekCol); + this.dataGroups.appendChild(dataGroup); + this.weekCol += 1 + parseInt(this.discreteDomains && monthChange); + this.monthWeeks[this.currentMonth]++; + if(monthChange) { + this.currentMonth = (this.currentMonth + 1) % 12; + this.months.push(this.currentMonth + ''); + this.monthWeeks[this.currentMonth] = 1; } - addDays(current_week_sunday, 7); + addDays(currentWeekSunday, 7); } this.render_month_labels(); } - get_week_squares_group(current_date, index) { - const no_of_weekdays = 7; - const square_side = 10; - const cell_padding = 2; + get_week_squares_group(currentDate, index) { + const noOfWeekdays = 7; + const squareSide = 10; + const cellPadding = 2; const step = 1; - const today_time = this.today.getTime(); + const todayTime = this.today.getTime(); - let month_change = 0; - let week_col_change = 0; + let monthChange = 0; + let weekColChange = 0; - let data_group = makeSVGGroup(this.data_groups, 'data-group'); + let dataGroup = makeSVGGroup(this.dataGroups, 'data-group'); - for(var y = 0, i = 0; i < no_of_weekdays; i += step, y += (square_side + cell_padding)) { - let data_value = 0; - let color_index = 0; + for(var y = 0, i = 0; i < noOfWeekdays; i += step, y += (squareSide + cellPadding)) { + let dataValue = 0; + let colorIndex = 0; - let current_timestamp = current_date.getTime()/1000; - let timestamp = Math.floor(current_timestamp - (current_timestamp % 86400)).toFixed(1); + let currentTimestamp = currentDate.getTime()/1000; + let timestamp = Math.floor(currentTimestamp - (currentTimestamp % 86400)).toFixed(1); if(this.data[timestamp]) { - data_value = this.data[timestamp]; + dataValue = this.data[timestamp]; } if(this.data[Math.round(timestamp)]) { - data_value = this.data[Math.round(timestamp)]; + dataValue = this.data[Math.round(timestamp)]; } - if(data_value) { - color_index = getMaxCheckpoint(data_value, this.distribution); + if(dataValue) { + colorIndex = getMaxCheckpoint(dataValue, this.distribution); } - let x = 13 + (index + week_col_change) * 12; + let x = 13 + (index + weekColChange) * 12; let dataAttr = { - 'data-date': getDdMmYyyy(current_date), - 'data-value': data_value, - 'data-day': current_date.getDay() + 'data-date': getDdMmYyyy(currentDate), + 'data-value': dataValue, + 'data-day': currentDate.getDay() }; - let heatSquare = makeHeatSquare('day', x, y, square_side, - this.legend_colors[color_index], dataAttr); - data_group.appendChild(heatSquare); + let heatSquare = makeHeatSquare('day', x, y, squareSide, + this.legendColors[colorIndex], dataAttr); - let next_date = new Date(current_date); - addDays(next_date, 1); - if(next_date.getTime() > today_time) break; + dataGroup.appendChild(heatSquare); + let nextDate = new Date(currentDate); + addDays(nextDate, 1); + if(nextDate.getTime() > todayTime) break; - if(next_date.getMonth() - current_date.getMonth()) { - month_change = 1; - if(this.discrete_domains) { - week_col_change = 1; + + if(nextDate.getMonth() - currentDate.getMonth()) { + monthChange = 1; + if(this.discreteDomains) { + weekColChange = 1; } - this.month_start_points.push(13 + (index + week_col_change) * 12); + this.monthStartPoints.push(13 + (index + weekColChange) * 12); } - current_date = next_date; + currentDate = nextDate; } - return [data_group, month_change]; + return [dataGroup, monthChange]; } render_month_labels() { // this.first_month_label = 1; - // if (this.first_week_start.getDate() > 8) { + // if (this.firstWeekStart.getDate() > 8) { // this.first_month_label = 0; // } // this.last_month_label = 1; // let first_month = this.months.shift(); - // let first_month_start = this.month_start_points.shift(); + // let first_month_start = this.monthStartPoints.shift(); // render first month if // let last_month = this.months.pop(); - // let last_month_start = this.month_start_points.pop(); + // let last_month_start = this.monthStartPoints.pop(); // render last month if this.months.shift(); - this.month_start_points.shift(); + this.monthStartPoints.shift(); this.months.pop(); - this.month_start_points.pop(); + this.monthStartPoints.pop(); - this.month_start_points.map((start, i) => { - let month_name = this.month_names[this.months[i]].substring(0, 3); + this.monthStartPoints.map((start, i) => { + let month_name = this.monthNames[this.months[i]].substring(0, 3); let text = makeText('y-value-text', start+12, 10, month_name); - this.domain_label_group.appendChild(text); - }); - } - - make_graph_components() { - Array.prototype.slice.call( - this.container.querySelectorAll('.graph-stats-container, .sub-title, .title') - ).map(d => { - d.style.display = 'None'; + this.domainLabelGroup.appendChild(text); }); - this.chart_wrapper.style.marginTop = '0px'; - this.chart_wrapper.style.paddingTop = '0px'; } - bind_tooltip() { + bindTooltip() { Array.prototype.slice.call( document.querySelectorAll(".data-group .day") ).map(el => { el.addEventListener('mouseenter', (e) => { let count = e.target.getAttribute('data-value'); - let date_parts = e.target.getAttribute('data-date').split('-'); + let dateParts = e.target.getAttribute('data-date').split('-'); - let month = this.month_names[parseInt(date_parts[1])-1].substring(0, 3); + let month = this.monthNames[parseInt(dateParts[1])-1].substring(0, 3); - let g_off = this.chart_wrapper.getBoundingClientRect(), p_off = e.target.getBoundingClientRect(); + let gOff = this.chartWrapper.getBoundingClientRect(), pOff = e.target.getBoundingClientRect(); let width = parseInt(e.target.getAttribute('width')); - let x = p_off.left - g_off.left + (width+2)/2; - let y = p_off.top - g_off.top - (width+2)/2; - let value = count + ' ' + this.count_label; - let name = ' on ' + month + ' ' + date_parts[0] + ', ' + date_parts[2]; + let x = pOff.left - gOff.left + (width+2)/2; + let y = pOff.top - gOff.top - (width+2)/2; + let value = count + ' ' + this.countLabel; + let name = ' on ' + month + ' ' + dateParts[0] + ', ' + dateParts[2]; - this.tip.set_values(x, y, name, value, [], 1); - this.tip.show_tip(); + this.tip.setValues(x, y, {name: name, value: value, valueFirst: 1}, []); + this.tip.showTip(); }); }); } update(data) { - this.data = data; - this.setup_values(); - this.bind_tooltip(); + super.update(data); + this.bindTooltip(); + } +} + +function dataPrep(data, type) { + data.labels = data.labels || []; + + let datasetLength = data.labels.length; + + // Datasets + let datasets = data.datasets; + let zeroArray = new Array(datasetLength).fill(0); + if(!datasets) { + // default + datasets = [{ + values: zeroArray + }]; + } + + datasets.map((d, i)=> { + // Set values + if(!d.values) { + d.values = zeroArray; + } else { + // Check for non values + let vals = d.values; + vals = vals.map(val => (!isNaN(val) ? val : 0)); + + // Trim or extend + if(vals.length > datasetLength) { + vals = vals.slice(0, datasetLength); + } else { + vals = fillArray(vals, datasetLength - vals.length, 0); + } + } + + // Set labels + // + + // Set type + if(!d.chartType ) { + if(!AXIS_DATASET_CHART_TYPES.includes(type)) type === DEFAULT_AXIS_CHART_TYPE; + d.chartType = type; + } + + }); + + // Markers + + // Regions + // data.yRegions = data.yRegions || []; + if(data.yRegions) { + data.yRegions.map(d => { + if(d.end < d.start) { + [d.start, d.end] = [d.end, d.start]; + } + }); + } + + return data; +} + +function zeroDataPrep(realData) { + let datasetLength = realData.labels.length; + let zeroArray = new Array(datasetLength).fill(0); + + let zeroData = { + labels: realData.labels.slice(0, -1), + datasets: realData.datasets.map(d => { + return { + name: '', + values: zeroArray.slice(0, -1), + chartType: d.chartType + } + }), + }; + + if(realData.yMarkers) { + zeroData.yMarkers = [ + { + value: 0, + label: '' + } + ]; + } + + if(realData.yRegions) { + zeroData.yRegions = [ + { + start: 0, + end: 0, + label: '' + } + ]; } + + return zeroData; +} + +function getShortenedLabels(chartWidth, labels=[], isSeries=true) { + let allowedSpace = chartWidth / labels.length; + let allowedLetters = allowedSpace / DEFAULT_CHAR_WIDTH; + + let calcLabels = labels.map((label, i) => { + label += ""; + if(label.length > allowedLetters) { + + if(!isSeries) { + if(allowedLetters-3 > 0) { + label = label.slice(0, allowedLetters-3) + " ..."; + } else { + label = label.slice(0, allowedLetters) + '..'; + } + } else { + let multiple = Math.ceil(label.length/allowedLetters); + if(i % multiple !== 0) { + label = ""; + } + } + } + return label; + }); + + return calcLabels; } -// if ("development" !== 'production') { -// // Enable LiveReload -// document.write( -// '
-

Frappé Charts

+

Frappe Charts

GitHub-inspired simple and modern charts for the web

with zero dependencies.

@@ -44,68 +46,64 @@
-
- - Create a chart -
-

Install

-
  npm install frappe-charts
-

And include it in your project

-
  import Chart from "frappe-charts/dist/frappe-charts.min.esm"
-

... or include it directly in your HTML

-
  <script src="https://unpkg.com/frappe-charts@0.0.8/dist/frappe-charts.min.iife.js"></script>
-

Make a new Chart

+
Create a chart
  <!--HTML-->
   <div id="chart"></div>
  // Javascript
-  let data = {
-    labels: ["12am-3am", "3am-6am", "6am-9am", "9am-12pm",
+  let chart = new Chart( "#chart", { // or DOM element
+    data: {
+      labels: ["12am-3am", "3am-6am", "6am-9am", "9am-12pm",
       "12pm-3pm", "3pm-6pm", "6pm-9pm", "9pm-12am"],
 
-    datasets: [
-      {
-        title: "Some Data",
-        values: [25, 40, 30, 35, 8, 52, 17, -4]
-      },
-      {
-        title: "Another Set",
-        values: [25, 50, -10, 15, 18, 32, 27, 14]
-      },
-      {
-        title: "Yet Another",
-        values: [15, 20, -3, -15, 58, 12, -17, 37]
-      }
-    ]
-  };
-
-  let chart = new Chart({
-    parent: "#chart", // or a DOM element
-    title: "My Awesome Chart",
-    data: data,
-    type: 'bar', // or 'line', 'scatter', 'pie', 'percentage'
-    height: 250,
+      datasets: [
+        {
+          label: "Some Data", type: 'bar',
+          values: [25, 40, 30, 35, 8, 52, 17, -4]
+        },
+        {
+          label: "Another Set", type: 'bar',
+          values: [25, 50, -10, 15, 18, 32, 27, 14]
+        },
+        {
+          label: "Yet Another", type: 'line',
+          values: [15, 20, -3, -15, 58, 12, -17, 37]
+        }
+      ],
 
-    colors: ['#7cd6fd', 'violet', 'blue'],
-    // hex-codes or these preset colors;
-    // defaults (in order):
-    // ['light-blue', 'blue', 'violet', 'red',
-    // 'orange', 'yellow', 'green', 'light-green',
-    // 'purple', 'magenta', 'grey', 'dark-grey']
+      yMarkers: [{ label: "Marker", value: 70 }],
+      yRegions: [{ label: "Region", start: -10, end: 50 }]
+    },
 
-    format_tooltip_x: d => (d + '').toUpperCase(),
-    format_tooltip_y: d => d + ' pts'
+    title: "My Awesome Chart",
+    type: 'axis-mixed', // or 'bar', 'line', 'pie', 'percentage'
+    height: 250,
+    colors: ['purple', '#ffa3ef', 'red']
   });
-
-
- - - + + + +
+
+
-

+

@@ -114,42 +112,12 @@
Update Values
-
  // Update entire datasets
-  chart.update_values(
-    [
-      {values: new_dataset_1_values},
-      {values: new_dataset_2_values}
-    ],
-    new_labels
-  );
-
-  // Add a new data point
-  chart.add_data_point(
-    [new_value_1, new_value_2],
-    new_label,
-    index // defaults to last index
-  );
-
-  // Remove a data point
-  chart.remove_data_point(index);
-
  ...
-    // Include specific Y values in input data to be displayed as lines
-    // (before passing data to a new chart):
-
-    data.specific_values = [
-      {
-        title: "Altitude",
-        line_type: "dashed", // or "solid"
-        value: 38
-      }
-    ]
-  ...
@@ -158,25 +126,20 @@
Plot Trends
-
  ...
-    x_axis_mode: 'tick',  // for short label ticks
-                          // or 'span' for long spanning vertical axis lines
-    y_axis_mode: 'span',  // for long horizontal lines, or 'tick'
-    is_series: 1,         // to allow for skipping of X values
-  ...
- + + - +
-
  ...
-    type: 'line',   // Line Chart specific properties:
+              
             
@@ -204,8 +167,7 @@
  ...
-    type: 'bar',     // Bar Chart specific properties:
-    is_navigable: 1, // Navigate across bars; default 0
+    isNavigable: 1, // Navigate across data points; default 0
   ...
 
   chart.parent.addEventListener('data-select', (e) => {
@@ -214,22 +176,6 @@
             
           
 
-          
-
-
- Simple Aggregations -
-
-
- - -
-
  chart.show_sums();  // and `hide_sums()`
-
-  chart.show_averages();  // and `hide_averages()`
-
-
-
@@ -242,23 +188,23 @@
- - + +
  let heatmap = new Chart({
     parent: "#heatmap",
     type: 'heatmap',
     height: 115,
-    data: heatmap_data,  // object with date/timestamp-value pairs
+    data: heatmapData,  // object with date/timestamp-value pairs
 
-    discrete_domains: 1,  // default: 0
+    discreteDomains: 1  // default: 0
 
-    start: start_date,
+    start: startDate,
                   // A Date object;
                   // default: today's date in past year
                   //          for an annual heatmap
 
-    legend_colors: ['#ebedf0', '#fdf436', '#ffc700', '#ff9100', '#06001c'],
+    legendColors: ['#ebedf0', '#fdf436', '#ffc700', '#ff9100', '#06001c'],
                   // Set of five incremental colors,
                   // beginning with a low-saturation color for zero data;
                   // default: ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']
@@ -267,13 +213,97 @@
             
+
+
+
Available options:
+

+  ...
+  {
+    data: {
+      labels: [],
+      datasets: [],
+      yRegions: [],
+      yMarkers: []
+    }
+    title: '',
+    colors: [],
+    height: 200,
+
+    tooltipOptions: {
+      formatTooltipX: d => (d + '').toUpperCase(),
+      formatTooltipY: d => d + ' pts',
+    }
+
+    // Axis charts
+    isNavigable: 1,        // default: 0
+    valuesOverPoints: 1,   // default: 0
+    barOptions: {
+      stacked: 1           // default: 0
+    }
+
+    lineOptions: {
+      dotSize: 6,          // default: 4
+      hideLine: 0,         // default: 0
+      hideDots: 1,         // default: 0
+      heatline: 1,         // default: 0
+      regionFill: 1        // default: 0
+    }
+
+    axisOptions: {
+      yAxisMode: 'span',   // Axis lines, default
+      xAxisMode: 'tick',   // No axis lines, only short ticks
+      xIsSeries: 1         // Allow skipping x values for space
+                           // default: 0
+    },
+
+    // Pie/Percentage charts
+
+    maxLegendPoints: 6,    // default: 20
+    maxSlices: 10,         // default: 20
+
+    // Heatmap
+
+    discreteDomains: 1,    // default: 1
+    start: startDate,      // Date object
+    legendColors: []
+  }
+  ...
+
+    // Updating values
+    chart.update(data);
+
+    // Axis charts:
+    chart.addDataPoint(label, valueFromEachDataset, index)
+    chart.removeDataPoint(index)
+    chart.updateDataset(datasetValues, index)
+
+  
+
+
+ +
+
+
Install
+

Install via npm

+
  npm install frappe-charts
+

And include it in your project

+
  import Chart from "frappe-charts/dist/frappe-charts.min.esm"
+

... or include it directly in your HTML

+
  <script src="https://unpkg.com/frappe-charts@0.0.8/dist/frappe-charts.min.iife.js"></script>
+ +
+
+ +

View on GitHub

-

+

+ Star +

License: MIT

@@ -285,7 +315,7 @@
- +

Project maintained by Frappe. Used in ERPNext. diff --git a/docs/old_index.html b/docs/old_index.html new file mode 100644 index 0000000..b77f19d --- /dev/null +++ b/docs/old_index.html @@ -0,0 +1,312 @@ + + + + + Frappe Charts + + + + + + + + + + + + + + + + + + +

+
+
+

Frappe Charts

+

GitHub-inspired simple and modern charts for the web

+

with zero dependencies.

+ +
+ +
+
+

Click or use arrow keys to navigate data points

+
+
+
+
+
+ +
+
+ +
+
+
+ + Create a chart +
+

Install

+
  npm install frappe-charts
+

And include it in your project

+
  import Chart from "frappe-charts/dist/frappe-charts.min.esm"
+

... or include it directly in your HTML

+
  <script src="https://unpkg.com/frappe-charts@0.0.8/dist/frappe-charts.min.iife.js"></script>
+

Make a new Chart

+
  <!--HTML-->
+  <div id="chart"></div>
+
  // Javascript
+  let data = {
+    labels: ["12am-3am", "3am-6am", "6am-9am", "9am-12pm",
+      "12pm-3pm", "3pm-6pm", "6pm-9pm", "9pm-12am"],
+
+    datasets: [
+      {
+        label: "Some Data",
+        values: [25, 40, 30, 35, 8, 52, 17, -4]
+      },
+      {
+        label: "Another Set",
+        values: [25, 50, -10, 15, 18, 32, 27, 14]
+      },
+      {
+        label: "Yet Another",
+        values: [15, 20, -3, -15, 58, 12, -17, 37]
+      }
+    ]
+  };
+
+  let chart = new Chart({
+    parent: "#chart", // or a DOM element
+    title: "My Awesome Chart",
+    data: data,
+    type: 'bar', // or 'line', 'scatter', 'pie', 'percentage'
+    height: 250,
+
+    colors: ['#7cd6fd', 'violet', 'blue'],
+    // hex-codes or these preset colors;
+    // defaults (in order):
+    // ['light-blue', 'blue', 'violet', 'red',
+    // 'orange', 'yellow', 'green', 'light-green',
+    // 'purple', 'magenta', 'grey', 'dark-grey']
+
+    format_tooltip_x: d => (d + '').toUpperCase(),
+    format_tooltip_y: d => d + ' pts'
+  });
+
+
+ + + + +
+

+ Why Percentage? +

+
+
+ +
+
+
+ Update Values +
+
  // Update entire datasets
+  chart.updateData(
+    [
+      {values: new_dataset_1_values},
+      {values: new_dataset_2_values}
+    ],
+    new_labels
+  );
+
+  // Add a new data point
+  chart.add_data_point(
+    [new_value_1, new_value_2],
+    new_label,
+    index // defaults to last index
+  );
+
+  // Remove a data point
+  chart.remove_data_point(index);
+
+
+ + + +
+
  ...
+    // Include specific Y values in input data to be displayed as lines
+    // (before passing data to a new chart):
+
+    data.specific_values = [
+      {
+        label: "Altitude",
+        line_type: "dashed", // or "solid"
+        value: 38
+      }
+    ]
+  ...
+
+
+ +
+
+
+ Plot Trends +
+
  ...
+    xAxisMode: 'tick',  // for short label ticks
+                          // or 'span' for long spanning vertical axis lines
+    yAxisMode: 'span',  // for long horizontal lines, or 'tick'
+    isSeries: 1,         // to allow for skipping of X values
+  ...
+ +
+ + + + +
+
  ...
+    type: 'line',   // Line Chart specific properties:
+
+    hideDots: 1,   // Hide data points on the line; default 0
+    heatline: 1,    // Show a value-wise line gradient; default 0
+    regionFill: 1, // Fill the area under the graph; default 0
+  ...
+
+
+ +
+
+
+ Listen to state change +
+
+
+
+
+
+
+
+ +
+
+
Europa
+

Semi-major-axis: 671034 km

+

Mass: 4800000 x 10^16 kg

+

Diameter: 3121.6 km

+
+
+
+
+
  ...
+    type: 'bar',     // Bar Chart specific properties:
+    isNavigable: 1, // Navigate across bars; default 0
+  ...
+
+  chart.parent.addEventListener('data-select', (e) => {
+    update_moon_data(e.index); // e contains index and value of current datapoint
+  });
+
+
+ +
+
+
+ Simple Aggregations +
+
+
+ + +
+
  chart.show_sums();  // and `hide_sums()`
+
+  chart.show_averages();  // and `hide_averages()`
+
+
+ +
+
+
+ And a Month-wise Heatmap +
+
+
+ + +
+
+ + +
+
  let heatmap = new Chart({
+    parent: "#heatmap",
+    type: 'heatmap',
+    height: 115,
+    data: heatmap_data,  // object with date/timestamp-value pairs
+
+    discrete_domains: 1  // default: 0
+
+    start: start_date,
+                  // A Date object;
+                  // default: today's date in past year
+                  //          for an annual heatmap
+
+    legend_colors: ['#ebedf0', '#fdf436', '#ffc700', '#ff9100', '#06001c'],
+                  // Set of five incremental colors,
+                  // beginning with a low-saturation color for zero data;
+                  // default: ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']
+
+  });
+
+
+ +
+
+ +
+ +

View on GitHub

+

+

License: MIT

+
+
+
+ +
+
+ +
+ +
+ +

+ Project maintained by Frappe. + Used in ERPNext. + Read the blog post. +

+

+ Data from the American Meteor Society, + SILSO and + NASA Open APIs +

+
+ + + + + + + + + diff --git a/package.json b/package.json index 43352ab..3e02b61 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,8 @@ "rollup-plugin-uglify": "^2.0.1", "rollup-plugin-uglify-es": "0.0.1", "rollup-watch": "^4.3.1" + }, + "dependencies": { + "eslint": "^4.18.2" } } diff --git a/rollup.config.js b/rollup.config.js index 732949f..b4a7678 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -15,17 +15,19 @@ import pkg from './package.json'; export default [ { - input: 'src/js/charts.js', + input: 'src/js/chart.js', + sourcemap: true, output: [ { - file: pkg.main, - format: 'cjs', + file: 'docs/assets/js/frappe-charts.min.js', + format: 'iife', }, { - file: pkg.module, - format: 'es', + file: pkg.browser, + format: 'iife', } ], + name: 'Chart', plugins: [ postcss({ preprocessor: (content, id) => new Promise((resolve, reject) => { @@ -33,7 +35,6 @@ export default [ resolve({ code: result.css.toString() }) }), extensions: [ '.scss' ], - // extract: 'dist/frappe-charts.min.css', plugins: [ nested(), cssnext({ warnForDuplicates: false }), @@ -56,10 +57,14 @@ export default [ ] }, { - input: 'src/js/charts.js', + input: 'src/js/chart.js', output: [ { - file: pkg.src, + file: pkg.main, + format: 'cjs', + }, + { + file: pkg.module, format: 'es', } ], @@ -70,7 +75,6 @@ export default [ resolve({ code: result.css.toString() }) }), extensions: [ '.scss' ], - extract: 'dist/frappe-charts.min.css', plugins: [ nested(), cssnext({ warnForDuplicates: false }), @@ -82,25 +86,24 @@ export default [ 'src/scss/**', ] }), + babel({ + exclude: 'node_modules/**', + }), replace({ exclude: 'node_modules/**', ENV: JSON.stringify(process.env.NODE_ENV || 'development'), - }) + }), + uglify() ], }, { - input: 'src/js/charts.js', + input: 'src/js/chart.js', output: [ { - file: 'docs/assets/js/frappe-charts.min.js', - format: 'iife', - }, - { - file: pkg.browser, - format: 'iife', + file: pkg.src, + format: 'es', } ], - name: 'Chart', plugins: [ postcss({ preprocessor: (content, id) => new Promise((resolve, reject) => { @@ -108,6 +111,7 @@ export default [ resolve({ code: result.css.toString() }) }), extensions: [ '.scss' ], + extract: 'dist/frappe-charts.min.css', plugins: [ nested(), cssnext({ warnForDuplicates: false }), @@ -119,14 +123,10 @@ export default [ 'src/scss/**', ] }), - babel({ - exclude: 'node_modules/**', - }), replace({ exclude: 'node_modules/**', ENV: JSON.stringify(process.env.NODE_ENV || 'development'), - }), - uglify() + }) ], } ]; diff --git a/src/js/chart.js b/src/js/chart.js new file mode 100644 index 0000000..f377f39 --- /dev/null +++ b/src/js/chart.js @@ -0,0 +1,40 @@ +import '../scss/charts.scss'; + +// import MultiAxisChart from './charts/MultiAxisChart'; +import PercentageChart from './charts/PercentageChart'; +import PieChart from './charts/PieChart'; +import Heatmap from './charts/Heatmap'; +import AxisChart from './charts/AxisChart'; + +const chartTypes = { + // multiaxis: MultiAxisChart, + percentage: PercentageChart, + heatmap: Heatmap, + pie: PieChart +}; + +function getChartByType(chartType = 'line', parent, options) { + if(chartType === 'line') { + options.type = 'line'; + return new AxisChart(parent, options); + } else if (chartType === 'bar') { + options.type = 'bar'; + return new AxisChart(parent, options); + } else if (chartType === 'axis-mixed') { + options.type = 'line'; + return new AxisChart(parent, options); + } + + if (!chartTypes[chartType]) { + console.error("Undefined chart type: " + chartType); + return; + } + + return new chartTypes[chartType](parent, options); +} + +export default class Chart { + constructor(parent, options) { + return getChartByType(options.type, parent, options); + } +} diff --git a/src/js/charts.js b/src/js/charts.js deleted file mode 100644 index 5ff073e..0000000 --- a/src/js/charts.js +++ /dev/null @@ -1,39 +0,0 @@ -import '../scss/charts.scss'; - -import BarChart from './charts/BarChart'; -import LineChart from './charts/LineChart'; -import ScatterChart from './charts/ScatterChart'; -import PercentageChart from './charts/PercentageChart'; -import PieChart from './charts/PieChart'; -import Heatmap from './charts/Heatmap'; - -// if (ENV !== 'production') { -// // Enable LiveReload -// document.write( -// '