diff --git a/.babelrc b/.babelrc index 2e0b3c8..ead898b 100644 --- a/.babelrc +++ b/.babelrc @@ -6,5 +6,9 @@ } }] ], - "plugins": ["external-helpers"] + "env": { + "test": { + "presets": ["env"] + } + } } diff --git a/.gitignore b/.gitignore index bb4a1f2..1127623 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,63 @@ -# cache -node_modules +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# misc -.DS_Store -yarn.lock +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a32df86 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: node_js + +node_js: + - "6" + - "8" + +before_install: + - make install + +script: + - make test + +after_success: + - make coveralls \ No newline at end of file diff --git a/Makefile b/Makefile index e69de29..ee21656 100644 --- a/Makefile +++ b/Makefile @@ -0,0 +1,45 @@ +-include .env + +BASEDIR = $(realpath .) + +SRCDIR = $(BASEDIR)/src +DISTDIR = $(BASEDIR)/dist +DOCSDIR = $(BASEDIR)/docs + +PROJECT = frappe-charts + +NODEMOD = $(BASEDIR)/node_modules +NODEBIN = $(NODEMOD)/.bin + +build: clean install + $(NODEBIN)/rollup \ + --config $(BASEDIR)/rollup.config.js \ + --watch=$(watch) + +clean: + rm -rf \ + $(BASEDIR)/.nyc_output \ + $(BASEDIR)/.yarn-error.log + + clear + +install.dep: +ifeq ($(shell command -v yarn),) + @echo "Installing yarn..." + npm install -g yarn +endif + +install: install.dep + yarn --cwd $(BASEDIR) + +test: clean + $(NODEBIN)/cross-env \ + NODE_ENV=test \ + $(NODEBIN)/nyc \ + $(NODEBIN)/mocha \ + --require $(NODEMOD)/babel-register \ + --recursive \ + $(SRCDIR)/js/**/test/*.test.js + +coveralls: + $(NODEBIN)/nyc report --reporter text-lcov | $(NODEBIN)/coveralls diff --git a/README.md b/README.md index 32959eb..5945fdd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@
GitHub-inspired modern, intuitive and responsive charts with zero dependencies
@@ -10,9 +12,15 @@@@ -42,9 +50,9 @@ * ...or include within your HTML ```html - + - + ``` #### Usage diff --git a/dist/frappe-charts.esm.js b/dist/frappe-charts.esm.js index 1a8f1bd..a4e278c 100644 --- a/dist/frappe-charts.esm.js +++ b/dist/frappe-charts.esm.js @@ -82,6 +82,97 @@ function fire(target, type, properties) { return target.dispatchEvent(evt); } +// https://css-tricks.com/snippets/javascript/loop-queryselectorall-matches/ + +const BASE_MEASURES = { + margins: { + top: 10, + bottom: 10, + left: 20, + right: 20 + }, + paddings: { + top: 20, + bottom: 40, + left: 30, + right: 10 + }, + + baseHeight: 240, + titleHeight: 20, + legendHeight: 30, + + titleFontSize: 12, +}; + +function getTopOffset(m) { + return m.titleHeight + m.margins.top + m.paddings.top; +} + +function getLeftOffset(m) { + return m.margins.left + m.paddings.left; +} + +function getExtraHeight(m) { + let totalExtraHeight = m.margins.top + m.margins.bottom + + m.paddings.top + m.paddings.bottom + + m.titleHeight + m.legendHeight; + return totalExtraHeight; +} + +function getExtraWidth(m) { + let totalExtraWidth = m.margins.left + m.margins.right + + m.paddings.left + m.paddings.right; + + return totalExtraWidth; +} + +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 AXIS_LEGEND_BAR_SIZE = 100; + +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 PERCENTAGE_BAR_DEFAULT_HEIGHT = 20; +const PERCENTAGE_BAR_DEFAULT_DEPTH = 2; + +// Fixed 5-color theme, +// More colors are difficult to parse visually +const HEATMAP_DISTRIBUTION_SIZE = 5; + +const HEATMAP_SQUARE_SIZE = 10; +const HEATMAP_GUTTER_SIZE = 2; + +const DEFAULT_CHAR_WIDTH = 7; + +const TOOLTIP_POINTER_TRIANGLE_HEIGHT = 5; + +const DEFAULT_CHART_COLORS = ['light-blue', 'blue', 'violet', 'red', 'orange', + 'yellow', 'green', 'light-green', 'purple', 'magenta', 'light-grey', 'dark-grey']; +const HEATMAP_COLORS_GREEN = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']; + + + +const DEFAULT_COLORS = { + bar: DEFAULT_CHART_COLORS, + line: DEFAULT_CHART_COLORS, + pie: DEFAULT_CHART_COLORS, + percentage: DEFAULT_CHART_COLORS, + heatmap: HEATMAP_COLORS_GREEN +}; + +// Universal constants +const ANGLE_RATIO = Math.PI / 180; +const FULL_ANGLE = 360; + class SvgTip { constructor({ parent = null, @@ -110,7 +201,6 @@ class SvgTip { refresh() { this.fill(); this.calcPosition(); - // this.showTip(); } makeTooltip() { @@ -146,12 +236,13 @@ class SvgTip { this.listValues.map((set, i) => { const color = this.colors[i] || 'black'; + let value = set.formatted === 0 || set.formatted ? set.formatted : set.value; let li = $.create('li', { styles: { 'border-top': `3px solid ${color}` }, - innerHTML: `${ set.value === 0 || set.value ? set.value : '' } + innerHTML: `${ value === 0 || value ? value : '' } ${set.title ? set.title : '' }` }); @@ -162,7 +253,8 @@ class SvgTip { calcPosition() { let width = this.container.offsetWidth; - this.top = this.y - this.container.offsetHeight; + this.top = this.y - this.container.offsetHeight + - TOOLTIP_POINTER_TRIANGLE_HEIGHT; this.left = this.x - width/2; let maxLeft = this.parent.offsetWidth - width; @@ -206,30 +298,10 @@ class SvgTip { } } -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; - +/** + * Returns the value of a number upto 2 decimal places. + * @param {Number} d Any number + */ function floatTwo(d) { return parseFloat(d.toFixed(2)); } @@ -274,10 +346,13 @@ function getStringWidth(string, charWidth) { +// https://stackoverflow.com/a/29325222 + + function getPositionByAngle(angle, radius) { return { - x:Math.sin(angle * ANGLE_RATIO) * radius, - y:Math.cos(angle * ANGLE_RATIO) * radius, + x: Math.sin(angle * ANGLE_RATIO) * radius, + y: Math.cos(angle * ANGLE_RATIO) * radius, }; } @@ -306,10 +381,57 @@ function equilizeNoOfElements(array1, array2, return [array1, array2]; } +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' +}; + +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 AXIS_TICK_LENGTH = 6; const LABEL_MARGIN = 4; const FONT_SIZE = 10; const BASE_LINE_COLOR = '#dadada'; +const FONT_FILL = '#555b51'; function $$1(expr, con) { return typeof expr === "string"? (con || document).querySelector(expr) : expr || null; @@ -383,12 +505,13 @@ function makeSVGDefs(svgContainer) { }); } -function makeSVGGroup(parent, className, transform='') { - return createSVG('g', { +function makeSVGGroup(className, transform='', parent=undefined) { + let args = { className: className, - inside: parent, transform: transform - }); + }; + if(parent) args.inside = parent; + return createSVG('g', args); } @@ -429,7 +552,29 @@ function makeGradient(svgDefElem, color, lighter = false) { return gradientId; } -function makeHeatSquare(className, x, y, size, fill='none', data={}) { +function percentageBar(x, y, width, height, + depth=PERCENTAGE_BAR_DEFAULT_DEPTH, fill='none') { + + let args = { + className: 'percentage-bar', + x: x, + y: y, + width: width, + height: height, + fill: fill, + styles: { + 'stroke': lightenDarkenColor(fill, -25), + // Diabolically good: https://stackoverflow.com/a/9000859 + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray + 'stroke-dasharray': `0, ${height + width}, ${width}, ${height}`, + 'stroke-width': depth + }, + }; + + return createSVG("rect", args); +} + +function heatSquare(className, x, y, size, fill='none', data={}) { let args = { className: className, x: x, @@ -446,13 +591,77 @@ function makeHeatSquare(className, x, y, size, fill='none', data={}) { return createSVG("rect", args); } -function makeText(className, x, y, content) { +function legendBar(x, y, size, fill='none', label) { + let args = { + className: 'legend-bar', + x: 0, + y: 0, + width: size, + height: '2px', + fill: fill + }; + let text = createSVG('text', { + className: 'legend-dataset-text', + x: 0, + y: 0, + dy: (FONT_SIZE * 2) + 'px', + 'font-size': (FONT_SIZE * 1.2) + 'px', + 'text-anchor': 'start', + fill: FONT_FILL, + innerHTML: label + }); + + let group = createSVG('g', { + transform: `translate(${x}, ${y})` + }); + group.appendChild(createSVG("rect", args)); + group.appendChild(text); + + return group; +} + +function legendDot(x, y, size, fill='none', label) { + let args = { + className: 'legend-dot', + cx: 0, + cy: 0, + r: size, + fill: fill + }; + let text = createSVG('text', { + className: 'legend-dataset-text', + x: 0, + y: 0, + dx: (FONT_SIZE) + 'px', + dy: (FONT_SIZE/3) + 'px', + 'font-size': (FONT_SIZE * 1.2) + 'px', + 'text-anchor': 'start', + fill: FONT_FILL, + innerHTML: label + }); + + let group = createSVG('g', { + transform: `translate(${x}, ${y})` + }); + group.appendChild(createSVG("circle", args)); + group.appendChild(text); + + return group; +} + +function makeText(className, x, y, content, options = {}) { + let fontSize = options.fontSize || FONT_SIZE; + let dy = options.dy !== undefined ? options.dy : (fontSize / 2); + let fill = options.fill || FONT_FILL; + let textAnchor = options.textAnchor || 'start'; return createSVG('text', { className: className, x: x, y: y, - dy: (FONT_SIZE / 2) + 'px', - 'font-size': FONT_SIZE + 'px', + dy: dy + 'px', + 'font-size': fontSize + 'px', + fill: fill, + 'text-anchor': textAnchor, innerHTML: content }); } @@ -592,9 +801,13 @@ function xLine(x, label, height, options={}) { } function yMarker(y, label, width, options={}) { + if(!options.labelPos) options.labelPos = 'right'; + let x = options.labelPos === 'left' ? LABEL_MARGIN + : width - getStringWidth(label, 5) - LABEL_MARGIN; + let labelSvg = createSVG('text', { className: 'chart-label', - x: width - getStringWidth(label, 5) - LABEL_MARGIN, + x: x, y: 0, dy: (FONT_SIZE / -2) + 'px', 'font-size': FONT_SIZE + 'px', @@ -613,7 +826,7 @@ function yMarker(y, label, width, options={}) { return line; } -function yRegion(y1, y2, width, label) { +function yRegion(y1, y2, width, label, options={}) { // return a group let height = y1 - y2; @@ -631,9 +844,13 @@ function yRegion(y1, y2, width, label) { height: height }); + if(!options.labelPos) options.labelPos = 'right'; + let x = options.labelPos === 'left' ? LABEL_MARGIN + : width - getStringWidth(label+"", 4.5) - LABEL_MARGIN; + let labelSvg = createSVG('text', { className: 'chart-label', - x: width - getStringWidth(label+"", 4.5) - LABEL_MARGIN, + x: x, y: 0, dy: (FONT_SIZE / -2) + 'px', 'font-size': FONT_SIZE + 'px', @@ -655,6 +872,11 @@ function datasetBar(x, yTop, width, color, label='', index=0, offset=0, meta={}) let [height, y] = getBarHeightAndYAttr(yTop, meta.zeroLine); y -= offset; + if(height === 0) { + height = meta.minHeight; + y -= meta.minHeight; + } + let rect = createSVG('rect', { className: `bar mini`, style: `fill: ${color}`, @@ -662,7 +884,7 @@ function datasetBar(x, yTop, width, color, label='', index=0, offset=0, meta={}) x: x, y: y, width: width, - height: height || meta.minHeight // TODO: correct y for positive min height + height: height }); label += ""; @@ -750,7 +972,6 @@ function getPaths(xList, yList, color, options={}, meta={}) { 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})`); } @@ -788,6 +1009,25 @@ let makeOverlay = { overlay.setAttribute('fill', fill); overlay.style.opacity = '0.6'; + if(transformValue) { + overlay.setAttribute('transform', transformValue); + } + return overlay; + }, + + 'heat_square': (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'; + if(transformValue) { overlay.setAttribute('transform', transformValue); } @@ -830,103 +1070,27 @@ let updateOverlay = { if(transformValue) { overlay.setAttribute('transform', transformValue); } - } + }, + + 'heat_square': (unit, overlay) => { + let transformValue; + if(unit.nodeName !== 'circle') { + transformValue = unit.getAttribute('transform'); + unit = unit.childNodes[0]; + } + let attributes = ['cx', 'cy']; + Object.values(unit.attributes) + .filter(attr => attributes.includes(attr.name) && attr.specified) + .map(attr => { + overlay.setAttribute(attr.name, attr.nodeValue); + }); + + if(transformValue) { + overlay.setAttribute('transform', transformValue); + } + }, }; -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', 'light-grey', 'dark-grey']; - -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: [] -}; - -// 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: [] -}; - -function getDifferentChart(type, current_type, parent, args) { - if(type === current_type) return; - - if(!ALL_CHART_TYPES.includes(type)) { - console.error(`'${type}' is not a valid chart type.`); - } - - if(!COMPATIBLE_CHARTS[current_type].includes(type)) { - console.error(`'${current_type}' chart cannot be converted to a '${type}' chart.`); - } - - // whether the new chart can use the existing colors - const useColor = COLOR_COMPATIBLE_CHARTS[current_type].includes(type); - - // Okay, this is anticlimactic - // this function will need to actually be 'changeChartType(type)' - // that will update only the required elements, but for now ... - - args.type = type; - args.colors = useColor ? args.colors : undefined; - - return new Chart(parent, args); -} - const UNIT_ANIM_DUR = 350; const PATH_ANIM_DUR = 350; const MARKER_LINE_ANIM_DUR = UNIT_ANIM_DUR; @@ -1145,29 +1309,76 @@ function runSMILAnimation(parent, svgElement, elementsToAnimate) { }, REPLACE_ALL_NEW_DUR); } +const CSSTEXT = ".chart-container{position:relative;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Roboto','Oxygen','Ubuntu','Cantarell','Fira Sans','Droid Sans','Helvetica Neue',sans-serif}.chart-container .axis,.chart-container .chart-label{fill:#555b51}.chart-container .axis line,.chart-container .chart-label line{stroke:#dadada}.chart-container .dataset-units circle{stroke:#fff;stroke-width:2}.chart-container .dataset-units path{fill:none;stroke-opacity:1;stroke-width:2px}.chart-container .dataset-path{stroke-width:2px}.chart-container .path-group path{fill:none;stroke-opacity:1;stroke-width:2px}.chart-container line.dashed{stroke-dasharray:5,3}.chart-container .axis-line .specific-value{text-anchor:start}.chart-container .axis-line .y-line{text-anchor:end}.chart-container .axis-line .x-line{text-anchor:middle}.chart-container .legend-dataset-text{fill:#6c7680;font-weight:600}.graph-svg-tip{position:absolute;z-index:99999;padding:10px;font-size:12px;color:#959da5;text-align:center;background:rgba(0,0,0,.8);border-radius:3px}.graph-svg-tip ul{padding-left:0;display:flex}.graph-svg-tip ol{padding-left:0;display:flex}.graph-svg-tip ul.data-point-list li{min-width:90px;flex:1;font-weight:600}.graph-svg-tip strong{color:#dfe2e5;font-weight:600}.graph-svg-tip .svg-pointer{position:absolute;height:5px;margin:0 0 0 -5px;content:' ';border:5px solid transparent;border-top-color:rgba(0,0,0,.8)}.graph-svg-tip.comparison{padding:0;text-align:left;pointer-events:none}.graph-svg-tip.comparison .title{display:block;padding:10px;margin:0;font-weight:600;line-height:1;pointer-events:none}.graph-svg-tip.comparison ul{margin:0;white-space:nowrap;list-style:none}.graph-svg-tip.comparison li{display:inline-block;padding:5px 10px}"; + +function downloadFile(filename, data) { + var a = document.createElement('a'); + a.style = "display: none"; + var blob = new Blob(data, {type: "image/svg+xml; charset=utf-8"}); + var url = window.URL.createObjectURL(blob); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(function(){ + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }, 300); +} + +function prepareForExport(svg) { + let clone = svg.cloneNode(true); + clone.classList.add('chart-container'); + clone.setAttribute('xmlns', "http://www.w3.org/2000/svg"); + clone.setAttribute('xmlns:xlink', "http://www.w3.org/1999/xlink"); + let styleEl = $.create('style', { + 'innerHTML': CSSTEXT + }); + clone.insertBefore(styleEl, clone.firstChild); + + let container = $.create('div'); + container.appendChild(clone); + + return container.innerHTML; +} + +let BOUND_DRAW_FN; + class BaseChart { constructor(parent, options) { - this.rawChartArgs = options; - this.parent = typeof parent === 'string' ? document.querySelector(parent) : parent; + 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.rawChartArgs = options; + 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.colors = this.validateColors(options.colors, this.type); + this.config = { showTooltip: 1, // calculate - showLegend: options.showLegend || 1, + showLegend: 1, // calculate isNavigable: options.isNavigable || 0, animate: 1 }; + + this.measures = JSON.parse(JSON.stringify(BASE_MEASURES)); + let m = this.measures; + this.setMeasures(options); + if(!this.title.length) { m.titleHeight = 0; } + if(!this.config.showLegend) m.legendHeight = 0; + this.argHeight = options.height || m.baseHeight; + this.state = {}; this.options = {}; @@ -1180,84 +1391,81 @@ class BaseChart { this.configure(options); } - configure(args) { - this.setColors(args); - this.setMargins(); - - // Bind window events - window.addEventListener('resize', () => this.draw(true)); - window.addEventListener('orientationchange', () => this.draw(true)); + prepareData(data) { + return data; } - setColors() { - let args = this.rawChartArgs; - - // Needs structure as per only labels/datasets, from config - const list = args.type === 'percentage' || args.type === 'pie' - ? args.data.labels - : args.data.datasets; - - if(!args.colors || (list && args.colors.length < list.length)) { - this.colors = DEFAULT_COLORS; - } else { - this.colors = args.colors; - } - - this.colors = this.colors.map(color => getColor(color)); + prepareFirstData(data) { + return data; } - setMargins() { + validateColors(colors, type) { + const validColors = []; + colors = (colors || []).concat(DEFAULT_COLORS[type]); + colors.forEach((string) => { + const color = getColor(string); + if(!isValidColor(color)) { + console.warn('"' + string + '" is not a valid color.'); + } else { + validColors.push(color); + } + }); + return validColors; + } + + setMeasures() { + // Override measures, including those for title and legend + // set config for legend and title + } + + configure() { let height = this.argHeight; this.baseHeight = height; - this.height = height - VERT_SPACE_OUTSIDE_BASE_CHART; - this.translateY = TRANSLATE_Y_BASE_CHART; + this.height = height - getExtraHeight(this.measures); - // Horizontal margins - this.leftMargin = LEFT_MARGIN_BASE_CHART; - this.rightMargin = RIGHT_MARGIN_BASE_CHART; + // Bind window events + BOUND_DRAW_FN = this.boundDrawFn.bind(this); + window.addEventListener('resize', BOUND_DRAW_FN); + window.addEventListener('orientationchange', this.boundDrawFn.bind(this)); } - validate() { - return true; + boundDrawFn() { + this.draw(true); } + unbindWindowEvents() { + window.removeEventListener('resize', BOUND_DRAW_FN); + window.removeEventListener('orientationchange', this.boundDrawFn.bind(this)); + } + + // Has to be called manually setup() { - if(this.validate()) { - this._setup(); - } - } - - _setup() { this.makeContainer(); + this.updateWidth(); this.makeTooltip(); this.draw(false, true); } - setupComponents() { - this.components = new Map(); - } - makeContainer() { - this.container = $.create('div', { - className: 'chart-container', - innerHTML: `
GitHub-inspired simple and modern charts for the web
+GitHub-inspired simple and modern SVG charts for the web
with zero dependencies.
-Click or use arrow keys to navigate data points
-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@1.0.0/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 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
- }
- ]
- ...
- ...
- 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
- ...
-
- 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
- });
- chart.show_sums(); // and `hide_sums()`
-
- chart.show_averages(); // and `hide_averages()`
- 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']
-
- });
- - Project maintained by Frappe. - Used in ERPNext. - Read the blog post. -
-- Data from the American Meteor Society, - SILSO and - NASA Open APIs -
-