Repository: frappe/charts Branch: master Commit: 7b15424c3af8 Files: 39 Total size: 143.9 KB Directory structure: gitextract_lpjitjve/ ├── .babelrc ├── .eslintrc.json ├── .github/ │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ └── npm-publish.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── package.json ├── rollup.config.js └── src/ ├── css/ │ ├── charts.scss │ └── chartsCss.js └── js/ ├── chart.js ├── charts/ │ ├── AggregationChart.js │ ├── AxisChart.js │ ├── BaseChart.js │ ├── DonutChart.js │ ├── Heatmap.js │ ├── PercentageChart.js │ └── PieChart.js ├── index.js ├── objects/ │ ├── ChartComponents.js │ └── SvgTip.js └── utils/ ├── animate.js ├── animation.js ├── axis-chart-utils.js ├── colors.js ├── constants.js ├── date-utils.js ├── dom.js ├── draw-utils.js ├── draw.js ├── export.js ├── helpers.js ├── intervals.js └── test/ ├── colors.test.js └── helpers.test.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ [ "@babel/preset-env", { "targets": { "browsers": ["last 2 versions", "safari >= 7"] }, "modules": false } ] ] } ================================================ FILE: .eslintrc.json ================================================ { "env": { "browser": true, "es6": true }, "extends": "eslint:recommended", "parserOptions": { "sourceType": "module" }, "rules": { "indent": ["error", "tab"], "linebreak-style": ["error", "unix"], "semi": ["error", "always"], "no-console": [ "error", { "allow": ["warn", "error"] } ] }, "globals": { "ENV": true } } ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ #### Expected Behaviour #### Actual Behaviour #### Steps to Reproduce: * NOTE: Add a GIF/Screenshot if required. Frappé Charts version: Codepen / Codesandbox: ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ###### Explanation About What Code Achieves: - Explanation ###### Screenshots/GIFs: - Screenshot ###### Steps To Test: - Steps ###### TODOs: - None ================================================ FILE: .github/workflows/npm-publish.yml ================================================ # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages name: Node.js Package on: release: types: [created] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 - run: npm ci - run: npm build publish-npm: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # 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 # npm build output dist docs docs/assets/ .DS_Store ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "6" - "8" before_install: - make install script: - make test after_success: - make coveralls ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Prateeksha Singh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ -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 ================================================ FILE: README.md ================================================
charts-logo # Frappe Charts **GitHub-inspired modern, intuitive and responsive charts with zero dependencies**

[Explore Demos](https://frappe.io/charts) - [Edit at CodeSandbox](https://codesandbox.io/s/frappe-charts-demo-viqud) - [Documentation](https://frappe.io/charts/docs)
## Frappe Charts Frappe Charts is a simple charting library with a focus on a simple API. The design is inspired by various charts you see on GitHub. ### Motivation ERPNext needed a simple sales history graph for its user company master to help users track sales. While using c3.js for reports, the library didn’t align well with our product’s classic design. Existing JS libraries were either too complex or rigid in their structure and behavior. To address this, I decided to create a library for translating value pairs into relative shapes or positions, focusing on simplicity. ### Key Features - **Variety of chart types**: Frappe Charts supports various chart types, including Axis Charts, Area and Trends, Bar, Line, Pie, Percentage, Mixed Axis, and Heatmap. - **Annotations and tooltips**: Charts can be annotated with x and y markers, regions, and tooltips for enhanced data context and clarity. - **Dynamic data handling**: Add, remove, or update individual data points in place, or refresh the entire dataset to reflect changes. - **Customizable configurations**: Flexible options like colors, animations, and custom titles allow for a highly personalized chart experience. ## Usage ```sh npm install frappe-charts ``` Import in your project: ```js import { Chart } from 'frappe-charts' // or esm import import { Chart } from 'frappe-charts/dist/frappe-charts.esm.js' // import css import 'frappe-charts/dist/frappe-charts.min.css' ``` Or directly include script in your HTML ```html ``` ```js const data = { labels: ["12am-3am", "3am-6pm", "6am-9am", "9am-12am", "12pm-3pm", "3pm-6pm", "6pm-9pm", "9am-12am" ], datasets: [ { name: "Some Data", chartType: "bar", values: [25, 40, 30, 35, 8, 52, 17, -4] }, { name: "Another Set", chartType: "line", values: [25, 50, -10, 15, 18, 32, 27, 14] } ] } const chart = new frappe.Chart("#chart", { // or a DOM element, // new Chart() in case of ES6 module with above usage title: "My Awesome Chart", data: data, type: 'axis-mixed', // or 'bar', 'line', 'scatter', 'pie', 'percentage' height: 250, colors: ['#7cd6fd', '#743ee2'] }) ``` ## Contributing 1. Clone this repo. 2. `cd` into project directory 3. `npm install` 4. `npm i npm-run-all -D` (*optional --> might be required for some developers*) 5. `npm run dev` ## Links - [Read the blog](https://medium.com/@pratu16x7/so-we-decided-to-create-our-own-charts-a95cb5032c97)

Frappe Technologies
================================================ FILE: package.json ================================================ { "name": "frappe-charts", "version": "v1.6.3", "type": "module", "main": "dist/frappe-charts.esm.js", "module": "dist/frappe-charts.esm.js", "browser": "dist/frappe-charts.umd.js", "common": "dist/frappe-charts.cjs.js", "unnpkg": "dist/frappe-charts.umd.js", "description": "https://frappe.github.io/charts", "directories": { "doc": "docs" }, "files": [ "src", "dist" ], "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "watch": "rollup -c --watch", "dev": "npm-run-all --parallel watch", "build": "rollup -c" }, "repository": { "type": "git", "url": "git+https://github.com/frappe/charts.git" }, "keywords": [ "js", "charts" ], "author": "Prateeksha Singh", "license": "MIT", "bugs": { "url": "https://github.com/frappe/charts/issues" }, "homepage": "https://github.com/frappe/charts#readme", "devDependencies": { "@babel/core": "^7.10.5", "@babel/preset-env": "^7.10.4", "node-sass": "^8.0.0", "rollup": "^2.21.0", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-bundle-size": "^1.0.3", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-eslint": "^7.0.0", "rollup-plugin-postcss": "^3.1.3", "rollup-plugin-scss": "^2.5.0", "rollup-plugin-terser": "^6.1.0" } } ================================================ FILE: rollup.config.js ================================================ import pkg from "./package.json"; import commonjs from "rollup-plugin-commonjs"; import babel from "rollup-plugin-babel"; import postcss from "rollup-plugin-postcss"; import scss from "rollup-plugin-scss"; import bundleSize from "rollup-plugin-bundle-size"; import { terser } from "rollup-plugin-terser"; export default [ // browser-friendly UMD build { input: "src/js/index.js", output: { sourcemap: true, name: "frappe", file: pkg.browser, format: "umd", }, plugins: [ commonjs(), babel({ exclude: ["node_modules/**"], }), terser(), scss({ output: "dist/frappe-charts.min.css" }), bundleSize(), ], }, // CommonJS (for Node) and ES module (for bundlers) build. { input: "src/js/chart.js", output: [ { file: pkg.common, format: "cjs", sourcemap: true }, { file: pkg.module, format: "es", sourcemap: true }, ], plugins: [ babel({ exclude: ["node_modules/**"], }), terser(), postcss(), bundleSize(), ], }, ]; ================================================ FILE: src/css/charts.scss ================================================ :root { --charts-label-color: #313b44; --charts-axis-line-color: #f4f5f6; --charts-tooltip-title: var(--charts-label-color); --charts-tooltip-label: var(--charts-label-color); --charts-tooltip-value: #192734; --charts-tooltip-bg: #ffffff; --charts-stroke-width: 2px; --charts-dataset-circle-stroke: #ffffff; --charts-dataset-circle-stroke-width: var(--charts-stroke-width); --charts-legend-label: var(--charts-label-color); --charts-legend-value: var(--charts-label-color); } .chart-container { position: relative; /* for absolutely positioned tooltip */ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; .axis, .chart-label { fill: var(--charts-label-color); line { stroke: var(--charts-axis-line-color); } } .dataset-units { circle { stroke: var(--charts-dataset-circle-stroke); stroke-width: var(--charts-dataset-circle-stroke-width); } path { fill: none; stroke-opacity: 1; stroke-width: var(--charts-stroke-width); } } .dataset-path { stroke-width: var(--charts-stroke-width); } .path-group { path { fill: none; stroke-opacity: 1; stroke-width: var(--charts-stroke-width); } } line.dashed { stroke-dasharray: 5, 3; } .axis-line { .specific-value { text-anchor: start; } .y-line { text-anchor: end; } .x-line { text-anchor: middle; } } .legend-dataset-label { fill: var(--charts-legend-label); font-weight: 600; } .legend-dataset-value { fill: var(--charts-legend-value); } } .graph-svg-tip { position: absolute; z-index: 99999; padding: 10px; font-size: 12px; text-align: center; background: var(--charts-tooltip-bg); box-shadow: 0px 1px 4px rgba(17, 43, 66, 0.1), 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 40px 30px -30px rgba(17, 43, 66, 0.1); border-radius: 6px; ul { padding-left: 0; display: flex; } ol { padding-left: 0; display: flex; } ul.data-point-list { li { min-width: 90px; font-weight: 600; } } .svg-pointer { position: absolute; height: 12px; width: 12px; border-radius: 2px; background: var(--charts-tooltip-bg); transform: rotate(45deg); margin-top: -7px; margin-left: -6px; } &.comparison { text-align: left; padding: 0px; pointer-events: none; .title { display: block; padding: 16px; margin: 0; color: var(--charts-tooltip-title); font-weight: 600; line-height: 1; pointer-events: none; text-transform: uppercase; strong { color: var(--charts-tooltip-value); } } ul { margin: 0; white-space: nowrap; list-style: none; &.tooltip-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; } } li { display: inline-block; display: flex; flex-direction: row; font-weight: 600; line-height: 1; padding: 5px 15px 15px 15px; .tooltip-legend { height: 12px; width: 12px; margin-right: 8px; border-radius: 2px; } .tooltip-label { margin-top: 4px; font-size: 11px; max-width: 100px; color: var(--fr-tooltip-label); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tooltip-value { color: var(--charts-tooltip-value); } } } } ================================================ FILE: src/css/chartsCss.js ================================================ export 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;}.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}"; ================================================ FILE: src/js/chart.js ================================================ import "../css/charts.scss"; import PercentageChart from "./charts/PercentageChart"; import PieChart from "./charts/PieChart"; import Heatmap from "./charts/Heatmap"; import AxisChart from "./charts/AxisChart"; import DonutChart from "./charts/DonutChart"; const chartTypes = { bar: AxisChart, line: AxisChart, percentage: PercentageChart, heatmap: Heatmap, pie: PieChart, donut: DonutChart, }; function getChartByType(chartType = "line", parent, options) { 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); } class Chart { constructor(parent, options) { return getChartByType(options.type, parent, options); } } export { Chart, PercentageChart, PieChart, Heatmap, AxisChart }; ================================================ FILE: src/js/charts/AggregationChart.js ================================================ import BaseChart from "./BaseChart"; import { truncateString } from "../utils/draw-utils"; import { legendDot } from "../utils/draw"; import { round } from "../utils/helpers"; import { getExtraWidth } from "../utils/constants"; export default class AggregationChart extends BaseChart { constructor(parent, args) { super(parent, args); } configure(args) { super.configure(args); this.config.formatTooltipY = (args.tooltipOptions || {}).formatTooltipY; this.config.maxSlices = args.maxSlices || 20; this.config.maxLegendPoints = args.maxLegendPoints || 20; this.config.legendRowHeight = 60; } calc() { let s = this.state; let maxSlices = this.config.maxSlices; s.sliceTotals = []; 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 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]; }); totals = allTotals.slice(0, maxSlices - 1); let remaining = allTotals.slice(maxSlices - 1); let sumOfRemaining = 0; remaining.map((d) => { sumOfRemaining += d[0]; }); totals.push([sumOfRemaining, "Rest"]); this.colors[maxSlices - 1] = "grey"; } s.labels = []; totals.map((d) => { s.sliceTotals.push(round(d[0])); s.labels.push(d[1]); }); s.grandTotal = s.sliceTotals.reduce((a, b) => a + b, 0); this.center = { x: this.width / 2, y: this.height / 2, }; } renderLegend() { let s = this.state; this.legendArea.textContent = ""; this.legendTotals = s.sliceTotals.slice(0, this.config.maxLegendPoints); super.renderLegend(this.legendTotals); } makeLegend(data, index, x_pos, y_pos) { let formatted = this.config.formatTooltipY ? this.config.formatTooltipY(data) : data; return legendDot( x_pos, y_pos, 12, // size 3, // dot radius this.colors[index], // fill this.state.labels[index], // label formatted, // value null, // base_font_size this.config.truncateLegends // truncate_legends ); } } ================================================ FILE: src/js/charts/AxisChart.js ================================================ import BaseChart from "./BaseChart"; import { dataPrep, zeroDataPrep, getShortenedLabels, } from "../utils/axis-chart-utils"; import { getComponent } from "../objects/ChartComponents"; import { getOffset, fire } from "../utils/dom"; import { calcChartIntervals, getIntervalSize, getValueRange, getZeroIndex, scale, getClosestInArray, } from "../utils/intervals"; import { floatTwo } from "../utils/helpers"; import { makeOverlay, updateOverlay, legendDot } from "../utils/draw"; import { getTopOffset, getLeftOffset, MIN_BAR_PERCENT_HEIGHT, BAR_CHART_SPACE_RATIO, LINE_CHART_DOT_SIZE, LEGEND_ITEM_WIDTH, } from "../utils/constants"; export default class AxisChart extends BaseChart { constructor(parent, args) { super(parent, args); this.barOptions = args.barOptions || {}; this.lineOptions = args.lineOptions || {}; this.type = args.type || "line"; this.init = 1; this.setup(); } setMeasures() { if (this.data.datasets.length <= 1) { this.config.showLegend = 0; this.measures.paddings.bottom = 30; } } configure(options) { super.configure(options); const { axisOptions = {} } = options; const { xAxis, yAxis } = axisOptions || {}; options.tooltipOptions = options.tooltipOptions || {}; this.config.xAxisMode = xAxis ? xAxis.xAxisMode : axisOptions.xAxisMode || "span"; // this will pass an array // lets determine if we need two yAxis based on if there is length // to the yAxis array if (yAxis && yAxis.length) { this.config.yAxisConfig = yAxis.map((item) => { return { yAxisMode: item.yAxisMode, id: item.id, position: item.position, title: item.title, }; }); } else { this.config.yAxisMode = yAxis ? yAxis.yAxisMode : axisOptions.yAxisMode || "span"; // if we have yAxis config settings lets populate a yAxis config array. if (yAxis && yAxis.id && yAxis.position) { this.config.yAxisConfig = [yAxis]; } } this.config.xIsSeries = axisOptions.xIsSeries || 0; this.config.shortenYAxisNumbers = axisOptions.shortenYAxisNumbers || 0; this.config.formatTooltipX = options.tooltipOptions.formatTooltipX; this.config.formatTooltipY = options.tooltipOptions.formatTooltipY; this.config.valuesOverPoints = options.valuesOverPoints; this.config.legendRowHeight = 30; } prepareData(data = this.data, config = this.config) { return dataPrep(data, this.type, config.continuous); } prepareFirstData(data = this.data) { return zeroDataPrep(data); } calc(onlyWidthChange = false) { this.calcXPositions(); if (!onlyWidthChange) { this.calcYAxisParameters( this.getAllYValues(), this.type === "line" ); } this.makeDataByIndex(); } calcXPositions() { let s = this.state; let labels = this.data.labels; s.datasetLength = labels.length; s.unitWidth = this.width / s.datasetLength; // Default, as per bar, and mixed. Only line will be a special case s.xOffset = s.unitWidth / 2; // // For a pure Line Chart // s.unitWidth = this.width/(s.datasetLength - 1); // s.xOffset = 0; s.xAxis = { labels: labels, positions: labels.map((d, i) => floatTwo(s.xOffset + i * s.unitWidth) ), }; } calcYAxisParameters(dataValues, withMinimum = "false") { let yPts, scaleMultiplier, intervalHeight, zeroLine, positions, yAxisConfigObject, yAxisAlignment, yKeys; yKeys = []; yAxisConfigObject = this.config.yAxisMode || {}; yAxisAlignment = yAxisConfigObject.position ? yAxisConfigObject.position : "left"; // if we have an object we have multiple yAxisParameters. if (dataValues instanceof Array) { yPts = calcChartIntervals(dataValues, withMinimum, this.config.overrideCeiling, this.config.overrideFloor); scaleMultiplier = this.height / getValueRange(yPts); intervalHeight = getIntervalSize(yPts) * scaleMultiplier; zeroLine = this.height - getZeroIndex(yPts) * intervalHeight; this.state.yAxis = { labels: yPts, positions: yPts.map((d) => zeroLine - d * scaleMultiplier), title: yAxisConfigObject.title || null, pos: yAxisAlignment, scaleMultiplier: scaleMultiplier, zeroLine: zeroLine, }; } else { this.state.yAxis = []; for (let key in dataValues) { const dataValue = dataValues[key]; yAxisConfigObject = this.config.yAxisConfig.find((item) => key === item.id) || []; yAxisAlignment = yAxisConfigObject.position ? yAxisConfigObject.position : "left"; yPts = calcChartIntervals(dataValue, withMinimum, this.config.overrideCeiling, this.config.overrideFloor); scaleMultiplier = this.height / getValueRange(yPts); intervalHeight = getIntervalSize(yPts) * scaleMultiplier; zeroLine = this.height - getZeroIndex(yPts) * intervalHeight; positions = yPts.map((d) => zeroLine - d * scaleMultiplier); yKeys.push(key); if (this.state.yAxis.length > 1) { const yPtsArray = []; const firstArr = this.state.yAxis[0]; // we need to calculate the scaleMultiplier. // now that we have an accurate scaleMultiplier we can // we need to loop through original positions. scaleMultiplier = this.height / getValueRange(yPts); firstArr.positions.forEach((pos) => { yPtsArray.push(Math.ceil(pos / scaleMultiplier)); }); yPts = yPtsArray.reverse(); zeroLine = this.height - getZeroIndex(yPts) * intervalHeight; positions = firstArr.positions; } this.state.yAxis.push({ axisID: key || "left-axis", labels: yPts, title: yAxisConfigObject.title, pos: yAxisAlignment, scaleMultiplier, zeroLine, positions, }); } // the labels are not aligned in length between the two yAxis objects, // we need to run some new calculations. if ( this.state.yAxis[1] && this.state.yAxis[0].labels.length !== this.state.yAxis[1].labels.length ) { const newYptsArr = []; // find the shorter array const shortest = this.state.yAxis.reduce( (p, c) => { return p.length > c.labels.length ? c : p; }, { length: Infinity } ); // return the longest const longest = this.state.yAxis.reduce( (p, c) => { return p.length < c.labels.length ? p : c; }, { length: Infinity } ); // we now need to populate the shortest obj with the new scale multiplier // with the positions of the longest obj. longest.positions.forEach((pos) => { // calculate a new yPts newYptsArr.push(Math.ceil(pos / shortest.scaleMultiplier)); }); shortest.labels = newYptsArr.reverse(); shortest.positions = longest.positions; } } // Dependent if above changes this.calcDatasetPoints(); this.calcYExtremes(); this.calcYRegions(); } calcDatasetPoints() { let s = this.state; let scaleAll = (values, id) => { return values.map((val) => { let { yAxis } = s; if (yAxis instanceof Array) { yAxis = yAxis.length > 1 ? yAxis.find((axis) => id === axis.axisID) : s.yAxis[0]; } return scale(val, yAxis); }); }; s.barChartIndex = 1; s.datasets = this.data.datasets.map((d, i) => { let values = d.values; let cumulativeYs = d.cumulativeYs || []; return { name: d.name && d.name.replace(/<|>|&/g, (char) => char == "&" ? "&" : char == "<" ? "<" : ">" ), index: i, barIndex: d.chartType === "bar" ? s.barChartIndex++ : s.barChartIndex, chartType: d.chartType, values: values, yPositions: scaleAll(values, d.axisID), id: d.axisID, cumulativeYs: cumulativeYs, cumulativeYPos: scaleAll(cumulativeYs, d.axisID), }; }); } calcYExtremes() { let s = this.state; if (this.barOptions.stacked) { s.yExtremes = s.datasets[s.datasets.length - 1].cumulativeYPos; return; } s.yExtremes = new Array(s.datasetLength).fill(9999); s.datasets.map((d) => { d.yPositions.map((pos, j) => { if (pos < s.yExtremes[j]) { s.yExtremes[j] = pos; } }); }); } calcYRegions() { let s = this.state; if (this.data.yMarkers) { this.state.yMarkers = this.data.yMarkers.map((d) => { d.position = scale(d.value, s.yAxis); if (!d.options) d.options = {}; // if(!d.label.includes(':')) { // d.label += ': ' + d.value; // } return d; }); } if (this.data.yRegions) { this.state.yRegions = this.data.yRegions.map((d) => { d.startPos = scale(d.start, s.yAxis); d.endPos = scale(d.end, s.yAxis); if (!d.options) d.options = {}; return d; }); } } getAllYValues() { let key = "values"; let multiAxis = this.config.yAxisConfig ? true : false; let allValueLists = multiAxis ? {} : []; let groupBy = (arr, property) => { return arr.reduce((acc, cur) => { acc[cur[property]] = [...(acc[cur[property]] || []), cur]; return acc; }, {}); }; let generateCumulative = (arr) => { let cumulative = new Array(this.state.datasetLength).fill(0); arr.forEach((d, i) => { let values = arr[i].values; d[key] = cumulative = cumulative.map((c, i) => { return c + values[i]; }); }); }; if (this.barOptions.stacked) { key = "cumulativeYs"; // we need to filter out the different yAxis ID's here. if (multiAxis) { const groupedDataSets = groupBy(this.data.datasets, "axisID"); // const dataSetsByAxis = this.data.dd for (var axisID in groupedDataSets) { generateCumulative(groupedDataSets[axisID]); } } else { generateCumulative(this.data.datasets); } } // this is the trouble maker, we don't want to merge all // datasets since we are trying to run two yAxis. if (multiAxis) { this.data.datasets.forEach((d) => { // if the array exists already just push more data into it. // otherwise create a new array into the object. allValueLists[d.axisID || key] ? allValueLists[d.axisID || key].push(...d[key]) : (allValueLists[d.axisID || key] = [...d[key]]); }); } else { allValueLists = this.data.datasets.map((d) => { return d[key]; }); } if (this.data.yMarkers && !multiAxis) { allValueLists.push(this.data.yMarkers.map((d) => d.value)); } if (this.data.yRegions && !multiAxis) { this.data.yRegions.map((d) => { allValueLists.push([d.end, d.start]); }); } return multiAxis ? allValueLists : [].concat(...allValueLists); //return [].concat(...allValueLists); master } setupComponents() { let componentConfigs = [ [ "xAxis", { mode: this.config.xAxisMode, height: this.height, // pos: 'right' }, function () { let s = this.state; s.xAxis.calcLabels = getShortenedLabels( this.width, s.xAxis.labels, this.config.xIsSeries ); return s.xAxis; }.bind(this), ], [ "yRegions", { width: this.width, pos: "right", }, function () { return this.state.yRegions; }.bind(this), ], ]; // if we have multiple yAxisConfigs we need to update the yAxisDefault // components to multiple yAxis components. if (this.config.yAxisConfig && this.config.yAxisConfig.length) { this.config.yAxisConfig.forEach((yAxis) => { componentConfigs.push([ "yAxis", { mode: yAxis.yAxisMode || "span", width: this.width, height: this.baseHeight, shortenNumbers: this.config.shortenYAxisNumbers, pos: yAxis.position || "left", }, function () { return this.state.yAxis; }.bind(this), ]); }); } else { componentConfigs.push([ "yAxis", { mode: this.config.yAxisMode, width: this.width, height: this.baseHeight, shortenNumbers: this.config.shortenYAxisNumbers, }, function () { return this.state.yAxis; }.bind(this), ]); } let barDatasets = this.state.datasets.filter( (d) => d.chartType === "bar" ); let lineDatasets = this.state.datasets.filter( (d) => d.chartType === "line" ); let barsConfigs = barDatasets.map((d) => { let index = d.index; let barIndex = d.barIndex || index; return [ "barGraph" + "-" + d.index, { index: index, color: this.colors[index], stacked: this.barOptions.stacked, // same for all datasets valuesOverPoints: this.config.valuesOverPoints, minHeight: this.height * MIN_BAR_PERCENT_HEIGHT, }, function () { let s = this.state; let { yAxis } = s; let d = s.datasets[index]; let { id = "left-axis" } = d; let stacked = this.barOptions.stacked; let spaceRatio = this.barOptions.spaceRatio || BAR_CHART_SPACE_RATIO; let barsWidth = s.unitWidth * (1 - spaceRatio); let barWidth = barsWidth / (stacked ? 1 : barDatasets.length); // if there are multiple yAxis we need to return the yAxis with the // proper ID. if (yAxis instanceof Array) { // if the person only configured one yAxis in the array return the first. yAxis = yAxis.length > 1 ? yAxis.find((axis) => id === axis.axisID) : s.yAxis[0]; } let xPositions = s.xAxis.positions.map( (x) => x - barsWidth / 2 ); if (!stacked) { xPositions = xPositions.map( (p) => p + barWidth * barIndex - barWidth ); } let labels = new Array(s.datasetLength).fill(""); if (this.config.valuesOverPoints) { if (stacked && d.index === s.datasets.length - 1) { labels = d.cumulativeYs; } else { labels = d.values; } } let offsets = new Array(s.datasetLength).fill(0); if (stacked) { offsets = d.yPositions.map( (y, j) => y - d.cumulativeYPos[j] ); } return { xPositions: xPositions, yPositions: d.yPositions, offsets: offsets, // values: d.values, labels: labels, zeroLine: yAxis.zeroLine, barsWidth: barsWidth, barWidth: barWidth, }; }.bind(this), ]; }); let lineConfigs = lineDatasets.map((d) => { let index = d.index; return [ "lineGraph" + "-" + d.index, { index: index, color: this.colors[index], svgDefs: this.svgDefs, heatline: this.lineOptions.heatline, regionFill: this.lineOptions.regionFill, spline: this.lineOptions.spline, hideDots: this.lineOptions.hideDots, hideLine: this.lineOptions.hideLine, // same for all datasets valuesOverPoints: this.config.valuesOverPoints, }, function () { let s = this.state; let d = s.datasets[index]; // if we have more than one yindex lets map the values const yAxis = s.yAxis.length ? s.yAxis.find((axis) => d.id === axis.axisID) || s.yAxis[0] : s.yAxis; let minLine = yAxis.positions[0] < yAxis.zeroLine ? yAxis.positions[0] : yAxis.zeroLine; return { xPositions: s.xAxis.positions, yPositions: d.yPositions, values: d.values, zeroLine: minLine, radius: this.lineOptions.dotSize || LINE_CHART_DOT_SIZE, }; }.bind(this), ]; }); let markerConfigs = [ [ "yMarkers", { width: this.width, pos: "right", }, function () { return this.state.yMarkers; }.bind(this), ], ]; componentConfigs = componentConfigs.concat( barsConfigs, lineConfigs, markerConfigs ); let optionals = ["yMarkers", "yRegions"]; this.dataUnitComponents = []; this.components = new Map( componentConfigs .filter( (args) => !optionals.includes(args[0]) || this.state[args[0]] ) .map((args) => { let component = getComponent(...args); if ( args[0].includes("lineGraph") || args[0].includes("barGraph") ) { this.dataUnitComponents.push(component); } return [args[0], component]; }) ); } makeDataByIndex() { this.dataByIndex = {}; let s = this.state; let formatX = this.config.formatTooltipX; let formatY = this.config.formatTooltipY; let titles = s.xAxis.labels; titles.map((label, index) => { let values = this.state.datasets.map((set, i) => { let value = set.values[index]; return { title: set.name, value: value, yPos: set.yPositions[index], color: this.colors[i], formatted: formatY ? formatY(value) : value, }; }); this.dataByIndex[index] = { label: label, formattedLabel: formatX ? formatX(label) : label, xPos: s.xAxis.positions[index], values: values, yExtreme: s.yExtremes[index], }; }); } bindTooltip() { // NOTE: could be in tooltip itself, as it is a given functionality for its parent this.container.addEventListener("mousemove", (e) => { let m = this.measures; let o = getOffset(this.container); let relX = e.pageX - o.left - getLeftOffset(m); let relY = e.pageY - o.top; if ( relY < this.height + getTopOffset(m) && relY > getTopOffset(m) ) { this.mapTooltipXPosition(relX); } else { this.tip.hideTip(); } }); } mapTooltipXPosition(relX) { let s = this.state; if (!s.yExtremes) return; let index = getClosestInArray(relX, s.xAxis.positions, true); if (index >= 0) { let dbi = this.dataByIndex[index]; this.tip.setValues( dbi.xPos + this.tip.offset.x, dbi.yExtreme + this.tip.offset.y, { name: dbi.formattedLabel, value: "" }, dbi.values, index ); this.tip.showTip(); } } renderLegend() { let s = this.data; if (s.datasets.length > 1) { super.renderLegend(s.datasets); } } // Legacy /* renderLegend() { let s = this.data; if (s.datasets.length > 1) { this.legendArea.textContent = ""; console.log(s.datasets); s.datasets.map((d, i) => { let barWidth = LEGEND_ITEM_WIDTH; // let rightEndPoint = this.baseWidth - this.measures.margins.left - this.measures.margins.right; // let multiplier = s.datasets.length - i; let rect = legendBar( // rightEndPoint - multiplier * barWidth, // To right align barWidth * i, "0", barWidth, this.colors[i], d.name, this.config.truncateLegends ); this.legendArea.appendChild(rect); }); } } */ makeLegend(data, index, x_pos, y_pos) { return legendDot( x_pos, y_pos + 5, // Extra offset 12, // size 3, // dot radius this.colors[index], // fill data.name, //label null, // value 8.75, // base_font_size this.config.truncateLegends // truncate legends ); } // Overlay makeOverlay() { if (this.init) { this.init = 0; return; } if (this.overlayGuides) { this.overlayGuides.forEach((g) => { let o = g.overlay; o.parentNode.removeChild(o); }); } this.overlayGuides = this.dataUnitComponents.map((c) => { return { type: c.unitType, overlay: undefined, units: c.units, }; }); if (this.state.currentIndex === undefined) { this.state.currentIndex = this.state.datasetLength - 1; } // Render overlays this.overlayGuides.map((d) => { let currentUnit = d.units[this.state.currentIndex]; d.overlay = makeOverlay[d.type](currentUnit); this.drawArea.appendChild(d.overlay); }); } updateOverlayGuides() { if (this.overlayGuides) { this.overlayGuides.forEach((g) => { let o = g.overlay; o.parentNode.removeChild(o); }); } } bindOverlay() { this.parent.addEventListener("data-select", () => { this.updateOverlay(); }); } bindUnits() { this.dataUnitComponents.map((c) => { c.units.map((unit) => { unit.addEventListener("click", () => { let index = unit.getAttribute("data-point-index"); this.setCurrentDataPoint(index); }); }); }); // Note: Doesn't work as tooltip is absolutely positioned this.tip.container.addEventListener("click", () => { let index = this.tip.container.getAttribute("data-point-index"); this.setCurrentDataPoint(index); }); } updateOverlay() { this.overlayGuides.map((d) => { let currentUnit = d.units[this.state.currentIndex]; updateOverlay[d.type](currentUnit, d.overlay); }); } onLeftArrow() { this.setCurrentDataPoint(this.state.currentIndex - 1); } onRightArrow() { this.setCurrentDataPoint(this.state.currentIndex + 1); } getDataPoint(index = this.state.currentIndex) { let s = this.state; let data_point = { index: index, label: s.xAxis.labels[index], values: s.datasets.map((d) => d.values[index]), }; return data_point; } setCurrentDataPoint(index) { let s = this.state; index = parseInt(index); if (index < 0) index = 0; if (index >= s.xAxis.labels.length) index = s.xAxis.labels.length - 1; if (index === s.currentIndex) return; s.currentIndex = index; fire(this.parent, "data-select", this.getDataPoint()); } // API addDataPoint(label, datasetValues, index = this.state.datasetLength) { super.addDataPoint(label, datasetValues, index); this.data.labels.splice(index, 0, label); this.data.datasets.map((d, i) => { d.values.splice(index, 0, datasetValues[i]); }); this.update(this.data); } removeDataPoint(index = this.state.datasetLength - 1) { if (this.data.labels.length <= 1) { return; } super.removeDataPoint(index); this.data.labels.splice(index, 1); this.data.datasets.map((d) => { d.values.splice(index, 1); }); this.update(this.data); } updateDataset(datasetValues, index = 0) { this.data.datasets[index].values = datasetValues; this.update(this.data); } // addDataset(dataset, index) {} // removeDataset(index = 0) {} updateDatasets(datasets) { this.data.datasets.map((d, i) => { if (datasets[i]) { d.values = datasets[i]; } }); this.update(this.data); } // updateDataPoint(dataPoint, index = 0) {} // addDataPoint(dataPoint, index = 0) {} // removeDataPoint(index = 0) {} } ================================================ FILE: src/js/charts/BaseChart.js ================================================ import SvgTip from "../objects/SvgTip"; import { $, isElementInViewport, getElementContentWidth, isHidden, } from "../utils/dom"; import { makeSVGContainer, makeSVGDefs, makeSVGGroup, makeText, } from "../utils/draw"; import { LEGEND_ITEM_WIDTH } from "../utils/constants"; import { BASE_MEASURES, getExtraHeight, getExtraWidth, getTopOffset, getLeftOffset, INIT_CHART_UPDATE_TIMEOUT, CHART_POST_ANIMATE_TIMEOUT, DEFAULT_COLORS, } from "../utils/constants"; import { getColor, isValidColor } from "../utils/colors"; import { runSMILAnimation } from "../utils/animation"; import { downloadFile, prepareForExport } from "../utils/export"; import { deepClone } from "../utils/helpers"; export default class BaseChart { constructor(parent, options) { // deepclone options to avoid making changes to orignal object options = deepClone(options); 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.type = options.type || ""; this.colors = this.validateColors(options.colors, this.type); this.config = { showTooltip: 1, // calculate showLegend: typeof options.showLegend !== "undefined" ? options.showLegend : 1, isNavigable: options.isNavigable || 0, animate: 0, overrideCeiling: options.overrideCeiling || false, overrideFloor: options.overrideFloor || false, truncateLegends: typeof options.truncateLegends !== "undefined" ? options.truncateLegends : 1, continuous: typeof options.continuous !== "undefined" ? options.continuous : 1, }; this.measures = JSON.parse(JSON.stringify(BASE_MEASURES)); let m = this.measures; this.realData = this.prepareData(options.data, this.config); this.data = this.prepareFirstData(this.realData); 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 = {}; this.initTimeout = INIT_CHART_UPDATE_TIMEOUT; if (this.config.isNavigable) { this.overlays = []; } this.configure(options); } prepareData(data) { return data; } prepareFirstData(data) { return data; } 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 - getExtraHeight(this.measures); // Bind window events this.boundDrawFn = () => this.draw(true); // Look into improving responsiveness //if (ResizeObserver) { // this.resizeObserver = new ResizeObserver(this.boundDrawFn); // this.resizeObserver.observe(this.parent); //} window.addEventListener("resize", this.boundDrawFn); window.addEventListener("orientationchange", this.boundDrawFn); } destroy() { //if (this.resizeObserver) this.resizeObserver.disconnect(); window.removeEventListener("resize", this.boundDrawFn); window.removeEventListener("orientationchange", this.boundDrawFn); } // Has to be called manually setup() { this.makeContainer(); this.updateWidth(); this.makeTooltip(); this.draw(false, true); } makeContainer() { // Chart needs a dedicated parent element this.parent.innerHTML = ""; let args = { inside: this.parent, className: "chart-container", }; if (this.independentWidth) { args.styles = { width: this.independentWidth + "px" }; } this.container = $.create("div", args); } makeTooltip() { this.tip = new SvgTip({ parent: this.container, colors: this.colors, }); this.bindTooltip(); } bindTooltip() {} draw(onlyWidthChange = false, init = false) { if (onlyWidthChange && isHidden(this.parent)) { // Don't update anything if the chart is hidden return; } this.updateWidth(); this.calc(onlyWidthChange); this.makeChartArea(); this.setupComponents(); this.components.forEach((c) => c.setup(this.drawArea)); // this.components.forEach(c => c.make()); this.render(this.components, false); if (init) { this.data = this.realData; this.update(this.data, true); // Not needed anymore since animate defaults to 0 and might potentially be refactored or deprecated /* setTimeout(() => { this.update(this.data, true); }, this.initTimeout); */ } if (this.config.showLegend) { this.renderLegend(); } this.setupNavigation(init); } calc() {} // builds state updateWidth() { this.baseWidth = getElementContentWidth(this.parent); this.width = this.baseWidth - getExtraWidth(this.measures); } makeChartArea() { if (this.svg) { this.container.removeChild(this.svg); } let m = this.measures; this.svg = makeSVGContainer( this.container, "frappe-chart chart", this.baseWidth, this.baseHeight ); this.svgDefs = makeSVGDefs(this.svg); if (this.title.length) { this.titleEL = makeText( "title", m.margins.left, m.margins.top, this.title, { fontSize: m.titleFontSize, fill: "#666666", dy: m.titleFontSize, } ); } let top = getTopOffset(m); this.drawArea = makeSVGGroup( this.type + "-chart chart-draw-area", `translate(${getLeftOffset(m)}, ${top})` ); if (this.config.showLegend) { top += this.height + m.paddings.bottom; this.legendArea = makeSVGGroup( "chart-legend", `translate(${getLeftOffset(m)}, ${top})` ); } if (this.title.length) { this.svg.appendChild(this.titleEL); } this.svg.appendChild(this.drawArea); if (this.config.showLegend) { this.svg.appendChild(this.legendArea); } this.updateTipOffset(getLeftOffset(m), getTopOffset(m)); } updateTipOffset(x, y) { this.tip.offset = { x: x, y: y, }; } setupComponents() { this.components = new Map(); } update(data, drawing = false, config) { if (!data) console.error("No data to update."); if (!drawing) data = deepClone(data); this.data = this.prepareData(data, config); this.calc(); // builds state this.render(this.components, this.config.animate); } 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.container, this.svg, elementsToAnimate); setTimeout(() => { components.forEach((c) => c.make()); this.updateNav(); }, CHART_POST_ANIMATE_TIMEOUT); } else { components.forEach((c) => c.make()); this.updateNav(); } } updateNav() { if (this.config.isNavigable) { this.makeOverlay(); this.bindUnits(); } } renderLegend(dataset) { this.legendArea.textContent = ""; let count = 0; let y = 0; dataset.map((data, index) => { let divisor = Math.floor(this.width / LEGEND_ITEM_WIDTH); if (count > divisor) { count = 0; y += this.config.legendRowHeight; } let x = LEGEND_ITEM_WIDTH * count; let dot = this.makeLegend(data, index, x, y); this.legendArea.appendChild(dot); count++; }); } makeLegend() {} setupNavigation(init = false) { if (!this.config.isNavigable) return; 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), }; document.addEventListener("keydown", (e) => { if (isElementInViewport(this.container)) { e = e || window.event; if (this.keyActions[e.keyCode]) { this.keyActions[e.keyCode](); } } }); } } makeOverlay() {} updateOverlay() {} bindOverlay() {} bindUnits() {} onLeftArrow() {} onRightArrow() {} onUpArrow() {} onDownArrow() {} onEnterKey() {} addDataPoint() {} removeDataPoint() {} getDataPoint() {} setCurrentDataPoint() {} updateDataset() {} export() { let chartSvg = prepareForExport(this.svg); downloadFile(this.title || "Chart", [chartSvg]); } } ================================================ FILE: src/js/charts/DonutChart.js ================================================ import AggregationChart from "./AggregationChart"; import { getComponent } from "../objects/ChartComponents"; import { getOffset } from "../utils/dom"; import { getPositionByAngle } from "../utils/helpers"; import { makeArcStrokePathStr, makeStrokeCircleStr } from "../utils/draw"; import { lightenDarkenColor } from "../utils/colors"; import { transform } from "../utils/animation"; import { FULL_ANGLE } from "../utils/constants"; export default class DonutChart extends AggregationChart { constructor(parent, args) { super(parent, args); this.type = "donut"; this.initTimeout = 0; this.init = 1; this.setup(); } configure(args) { super.configure(args); this.mouseMove = this.mouseMove.bind(this); this.mouseLeave = this.mouseLeave.bind(this); this.hoverRadio = args.hoverRadio || 0.1; this.config.startAngle = args.startAngle || 0; this.clockWise = args.clockWise || false; this.strokeWidth = args.strokeWidth || 30; } calc() { super.calc(); let s = this.state; this.radius = this.height > this.width ? this.center.x - this.strokeWidth / 2 : this.center.y - this.strokeWidth / 2; 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 / s.grandTotal) * FULL_ANGLE; const largeArc = originDiffAngle > 180 ? 1 : 0; const diffAngle = clockWise ? -originDiffAngle : originDiffAngle; const endAngle = (curAngle = curAngle + diffAngle); const startPosition = getPositionByAngle(startAngle, radius); const endPosition = getPositionByAngle(endAngle, radius); const prevProperty = this.init && prevSlicesProperties[i]; let curStart, curEnd; if (this.init) { curStart = prevProperty ? prevProperty.startPosition : startPosition; curEnd = prevProperty ? prevProperty.endPosition : startPosition; } else { curStart = startPosition; curEnd = endPosition; } const curPath = originDiffAngle === 360 ? makeStrokeCircleStr( curStart, curEnd, this.center, this.radius, this.clockWise, largeArc ) : makeArcStrokePathStr( curStart, curEnd, this.center, this.radius, this.clockWise, largeArc ); s.sliceStrings.push(curPath); s.slicesProperties.push({ startPosition, endPosition, value: total, total: s.grandTotal, startAngle, endAngle, angle: diffAngle, }); }); this.init = 0; } setupComponents() { let s = this.state; let componentConfigs = [ [ "donutSlices", {}, function () { return { sliceStrings: s.sliceStrings, colors: this.colors, strokeWidth: this.strokeWidth, }; }.bind(this), ], ]; this.components = new Map( componentConfigs.map((args) => { let component = getComponent(...args); return [args[0], component]; }) ); } calTranslateByAngle(property) { const { radius, hoverRadio } = this; 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.state.slicesProperties[i])); path.style.stroke = 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.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.hideTip(); path.style.stroke = color; } } bindTooltip() { this.container.addEventListener("mousemove", this.mouseMove); this.container.addEventListener("mouseleave", this.mouseLeave); } mouseMove(e) { const target = e.target; let slices = this.components.get("donutSlices").store; let prevIndex = this.curActiveSliceIndex; let prevAcitve = this.curActiveSlice; 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); } } ================================================ FILE: src/js/charts/Heatmap.js ================================================ import BaseChart from "./BaseChart"; import { getComponent } from "../objects/ChartComponents"; import { makeText, heatSquare } from "../utils/draw"; import { DAY_NAMES_SHORT, toMidnightUTC, addDays, areInSameMonth, getLastDateInMonth, setDayToSunday, getYyyyMmDd, getWeeksBetween, getMonthName, clone, NO_OF_MILLIS, NO_OF_YEAR_MONTHS, NO_OF_DAYS_IN_WEEK, } from "../utils/date-utils"; import { calcDistribution, getMaxCheckpoint } from "../utils/intervals"; import { getExtraHeight, getExtraWidth, HEATMAP_DISTRIBUTION_SIZE, HEATMAP_SQUARE_SIZE, HEATMAP_GUTTER_SIZE, } from "../utils/constants"; const COL_WIDTH = HEATMAP_SQUARE_SIZE + HEATMAP_GUTTER_SIZE; const ROW_HEIGHT = COL_WIDTH; // const DAY_INCR = 1; export default class Heatmap extends BaseChart { constructor(parent, options) { super(parent, options); this.type = "heatmap"; this.countLabel = options.countLabel || ""; let validStarts = ["Sunday", "Monday"]; let startSubDomain = validStarts.includes(options.startSubDomain) ? options.startSubDomain : "Sunday"; this.startSubDomainIndex = validStarts.indexOf(startSubDomain); this.setup(); } setMeasures(options) { let m = this.measures; this.discreteDomains = options.discreteDomains === 0 ? 0 : 1; m.paddings.top = ROW_HEIGHT * 3; m.paddings.bottom = 0; m.legendHeight = ROW_HEIGHT * 2; m.baseHeight = ROW_HEIGHT * NO_OF_DAYS_IN_WEEK + getExtraHeight(m); let d = this.data; let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0; this.independentWidth = (getWeeksBetween(d.start, d.end) + spacing) * COL_WIDTH + getExtraWidth(m); } updateWidth() { let spacing = this.discreteDomains ? NO_OF_YEAR_MONTHS : 0; let noOfWeeks = this.state.noOfWeeks ? this.state.noOfWeeks : 52; this.baseWidth = (noOfWeeks + spacing) * COL_WIDTH + getExtraWidth(this.measures); } prepareData(data = this.data) { if (data.start && data.end && data.start > data.end) { throw new Error("Start date cannot be greater than end date."); } if (!data.start) { data.start = new Date(); data.start.setFullYear(data.start.getFullYear() - 1); } data.start = toMidnightUTC(data.start); if (!data.end) { data.end = new Date(); } data.end = toMidnightUTC(data.end); data.dataPoints = data.dataPoints || {}; if (parseInt(Object.keys(data.dataPoints)[0]) > 100000) { let points = {}; Object.keys(data.dataPoints).forEach((timestampSec) => { let date = new Date(timestampSec * NO_OF_MILLIS); points[getYyyyMmDd(date)] = data.dataPoints[timestampSec]; }); data.dataPoints = points; } return data; } calc() { let s = this.state; s.start = clone(this.data.start); s.end = clone(this.data.end); s.firstWeekStart = clone(s.start); s.noOfWeeks = getWeeksBetween(s.start, s.end); s.distribution = calcDistribution( Object.values(this.data.dataPoints), HEATMAP_DISTRIBUTION_SIZE ); s.domainConfigs = this.getDomains(); } setupComponents() { let s = this.state; let lessCol = this.discreteDomains ? 0 : 1; let componentConfigs = s.domainConfigs.map((config, i) => [ "heatDomain", { index: config.index, colWidth: COL_WIDTH, rowHeight: ROW_HEIGHT, squareSize: HEATMAP_SQUARE_SIZE, radius: this.rawChartArgs.radius || 0, xTranslate: s.domainConfigs .filter((config, j) => j < i) .map((config) => config.cols.length - lessCol) .reduce((a, b) => a + b, 0) * COL_WIDTH, }, function () { return s.domainConfigs[i]; }.bind(this), ]); this.components = new Map( componentConfigs.map((args, i) => { let component = getComponent(...args); return [args[0] + "-" + i, component]; }) ); let y = 0; DAY_NAMES_SHORT.forEach((dayName, i) => { if ([1, 3, 5].includes(i)) { let dayText = makeText("subdomain-name", -COL_WIDTH / 2, y, dayName, { fontSize: HEATMAP_SQUARE_SIZE, dy: 8, textAnchor: "end", }); this.drawArea.appendChild(dayText); } y += ROW_HEIGHT; }); } update(data) { if (!data) { console.error("No data to update."); } this.data = this.prepareData(data); this.draw(); this.bindTooltip(); } bindTooltip() { this.container.addEventListener("mousemove", (e) => { this.components.forEach((comp) => { let daySquares = comp.store; let daySquare = e.target; if (daySquares.includes(daySquare)) { let count = daySquare.getAttribute("data-value"); let dateParts = daySquare.getAttribute("data-date").split("-"); let month = getMonthName(parseInt(dateParts[1]) - 1, true); let gOff = this.container.getBoundingClientRect(), pOff = daySquare.getBoundingClientRect(); let width = parseInt(e.target.getAttribute("width")); let x = pOff.left - gOff.left + width / 2; let y = pOff.top - gOff.top; let value = count + " " + this.countLabel; let name = " on " + month + " " + dateParts[0] + ", " + dateParts[2]; this.tip.setValues( x, y, { name: name, value: value, valueFirst: 1 }, [] ); this.tip.showTip(); } }); }); } renderLegend() { this.legendArea.textContent = ""; let x = 0; let y = ROW_HEIGHT; let radius = this.rawChartArgs.radius || 0; let lessText = makeText("subdomain-name", x, y, "Less", { fontSize: HEATMAP_SQUARE_SIZE + 1, dy: 9, }); x = COL_WIDTH * 2 + COL_WIDTH / 2; this.legendArea.appendChild(lessText); this.colors.slice(0, HEATMAP_DISTRIBUTION_SIZE).map((color, i) => { const square = heatSquare( "heatmap-legend-unit", x + (COL_WIDTH + 3) * i, y, HEATMAP_SQUARE_SIZE, radius, color ); this.legendArea.appendChild(square); }); let moreTextX = x + HEATMAP_DISTRIBUTION_SIZE * (COL_WIDTH + 3) + COL_WIDTH / 4; let moreText = makeText("subdomain-name", moreTextX, y, "More", { fontSize: HEATMAP_SQUARE_SIZE + 1, dy: 9, }); this.legendArea.appendChild(moreText); } getDomains() { let s = this.state; const [startMonth, startYear] = [s.start.getMonth(), s.start.getFullYear()]; const [endMonth, endYear] = [s.end.getMonth(), s.end.getFullYear()]; const noOfMonths = endMonth - startMonth + 1 + (endYear - startYear) * 12; let domainConfigs = []; let startOfMonth = clone(s.start); for (var i = 0; i < noOfMonths; i++) { let endDate = s.end; if (!areInSameMonth(startOfMonth, s.end)) { let [month, year] = [ startOfMonth.getMonth(), startOfMonth.getFullYear(), ]; endDate = getLastDateInMonth(month, year); } domainConfigs.push(this.getDomainConfig(startOfMonth, endDate)); addDays(endDate, 1); startOfMonth = endDate; } return domainConfigs; } getDomainConfig(startDate, endDate = "") { let [month, year] = [startDate.getMonth(), startDate.getFullYear()]; let startOfWeek = setDayToSunday(startDate); // TODO: Monday as well endDate = endDate ? clone(endDate) : toMidnightUTC(getLastDateInMonth(month, year)); let domainConfig = { index: month, cols: [], }; addDays(endDate, 1); let noOfMonthWeeks = getWeeksBetween(startOfWeek, endDate); let cols = [], col; for (var i = 0; i < noOfMonthWeeks; i++) { col = this.getCol(startOfWeek, month); cols.push(col); startOfWeek = toMidnightUTC( new Date(col[NO_OF_DAYS_IN_WEEK - 1].yyyyMmDd) ); addDays(startOfWeek, 1); } if (col[NO_OF_DAYS_IN_WEEK - 1].dataValue !== undefined) { addDays(startOfWeek, 1); cols.push(this.getCol(startOfWeek, month, true)); } domainConfig.cols = cols; return domainConfig; } getCol(startDate, month, empty = false) { let s = this.state; // startDate is the start of week let currentDate = clone(startDate); let col = []; for (var i = 0; i < NO_OF_DAYS_IN_WEEK; i++, addDays(currentDate, 1)) { let config = {}; // Non-generic adjustment for entire heatmap, needs state let currentDateWithinData = currentDate >= s.start && currentDate <= s.end; if (empty || currentDate.getMonth() !== month || !currentDateWithinData) { config.yyyyMmDd = getYyyyMmDd(currentDate); } else { config = this.getSubDomainConfig(currentDate); } col.push(config); } return col; } getSubDomainConfig(date) { let yyyyMmDd = getYyyyMmDd(date); let dataValue = this.data.dataPoints[yyyyMmDd]; let config = { yyyyMmDd: yyyyMmDd, dataValue: dataValue || 0, fill: this.colors[getMaxCheckpoint(dataValue, this.state.distribution)], }; return config; } } ================================================ FILE: src/js/charts/PercentageChart.js ================================================ import AggregationChart from "./AggregationChart"; import { getOffset } from "../utils/dom"; import { getComponent } from "../objects/ChartComponents"; import { PERCENTAGE_BAR_DEFAULT_HEIGHT } from "../utils/constants"; export default class PercentageChart extends AggregationChart { constructor(parent, args) { super(parent, args); this.type = "percentage"; this.setup(); } setMeasures(options) { let m = this.measures; this.barOptions = options.barOptions || {}; let b = this.barOptions; b.height = b.height || PERCENTAGE_BAR_DEFAULT_HEIGHT; m.paddings.right = 30; m.legendHeight = 60; m.baseHeight = (b.height + b.depth * 0.5) * 8; } setupComponents() { let s = this.state; let componentConfigs = [ [ "percentageBars", { barHeight: this.barOptions.height, }, function () { return { xPositions: s.xPositions, widths: s.widths, colors: this.colors, }; }.bind(this), ], ]; this.components = new Map( componentConfigs.map((args) => { let component = getComponent(...args); return [args[0], component]; }) ); } calc() { super.calc(); let s = this.state; s.xPositions = []; s.widths = []; let xPos = 0; s.sliceTotals.map((value) => { let width = (this.width * value) / s.grandTotal; s.widths.push(width); s.xPositions.push(xPos); xPos += width; }); } makeDataByIndex() {} bindTooltip() { let s = this.state; this.container.addEventListener("mousemove", (e) => { let bars = this.components.get("percentageBars").store; let bar = e.target; if (bars.includes(bar)) { let i = bars.indexOf(bar); let gOff = getOffset(this.container), pOff = getOffset(bar); let x = pOff.left - gOff.left + parseInt(bar.getAttribute("width")) / 2; let y = pOff.top - gOff.top; let title = (this.formattedLabels && this.formattedLabels.length > 0 ? this.formattedLabels[i] : this.state.labels[i]) + ": "; let fraction = s.sliceTotals[i] / s.grandTotal; this.tip.setValues(x, y, { name: title, value: (fraction * 100).toFixed(1) + "%", }); this.tip.showTip(); } }); } } ================================================ FILE: src/js/charts/PieChart.js ================================================ import AggregationChart from "./AggregationChart"; import { getComponent } from "../objects/ChartComponents"; import { getOffset, fire } from "../utils/dom"; import { getPositionByAngle } from "../utils/helpers"; import { makeArcPathStr, makeCircleStr } from "../utils/draw"; import { lightenDarkenColor } from "../utils/colors"; import { transform } from "../utils/animation"; import { FULL_ANGLE } from "../utils/constants"; export default class PieChart extends AggregationChart { constructor(parent, args) { super(parent, args); this.type = "pie"; this.initTimeout = 0; this.init = 1; this.setup(); } configure(args) { super.configure(args); this.mouseMove = this.mouseMove.bind(this); this.mouseLeave = this.mouseLeave.bind(this); this.hoverRadio = args.hoverRadio || 0.1; this.config.startAngle = args.startAngle || 0; this.clockWise = args.clockWise || false; } calc() { super.calc(); let s = this.state; this.radius = this.height > this.width ? this.center.x : this.center.y; 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 / s.grandTotal) * FULL_ANGLE; const largeArc = originDiffAngle > 180 ? 1 : 0; const diffAngle = clockWise ? -originDiffAngle : originDiffAngle; const endAngle = (curAngle = curAngle + diffAngle); const startPosition = getPositionByAngle(startAngle, radius); const endPosition = getPositionByAngle(endAngle, radius); const prevProperty = this.init && prevSlicesProperties[i]; let curStart, curEnd; if (this.init) { curStart = prevProperty ? prevProperty.startPosition : startPosition; curEnd = prevProperty ? prevProperty.endPosition : startPosition; } else { curStart = startPosition; curEnd = endPosition; } const curPath = originDiffAngle === 360 ? makeCircleStr( curStart, curEnd, this.center, this.radius, clockWise, largeArc ) : makeArcPathStr( curStart, curEnd, this.center, this.radius, clockWise, largeArc ); s.sliceStrings.push(curPath); s.slicesProperties.push({ startPosition, endPosition, value: total, total: s.grandTotal, startAngle, endAngle, angle: diffAngle, }); }); this.init = 0; } setupComponents() { let s = this.state; let componentConfigs = [ [ "pieSlices", {}, function () { return { sliceStrings: s.sliceStrings, colors: this.colors, }; }.bind(this), ], ]; this.components = new Map( componentConfigs.map((args) => { let component = getComponent(...args); return [args[0], component]; }) ); } calTranslateByAngle(property) { const { radius, hoverRadio } = this; 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.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.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.hideTip(); path.style.fill = color; } } bindTooltip() { this.container.addEventListener("mousemove", this.mouseMove); this.container.addEventListener("mouseleave", this.mouseLeave); } getDataPoint(index = this.state.currentIndex) { let s = this.state; let data_point = { index: index, label: s.labels[index], values: s.sliceTotals[index], }; return data_point; } setCurrentDataPoint(index) { let s = this.state; index = parseInt(index); if (index < 0) index = 0; if (index >= s.labels.length) index = s.labels.length - 1; if (index === s.currentIndex) return; s.currentIndex = index; fire(this.parent, "data-select", this.getDataPoint()); } bindUnits() { const units = this.components.get("pieSlices").store; if (!units) return; units.forEach((unit, index) => { unit.addEventListener("click", () => { this.setCurrentDataPoint(index); }); }); } mouseMove(e) { const target = e.target; let slices = this.components.get("pieSlices").store; let prevIndex = this.curActiveSliceIndex; let prevAcitve = this.curActiveSlice; 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); } } ================================================ FILE: src/js/index.js ================================================ import * as Charts from "./chart"; let frappe = {}; frappe.NAME = "Frappe Charts"; frappe.VERSION = "1.6.2"; frappe = Object.assign({}, frappe, Charts); export default frappe; ================================================ FILE: src/js/objects/ChartComponents.js ================================================ import { makeSVGGroup } from "../utils/draw"; import { makeText, makePath, xLine, yLine, generateAxisLabel, yMarker, yRegion, datasetBar, datasetDot, percentageBar, getPaths, heatSquare, } from "../utils/draw"; import { equilizeNoOfElements } from "../utils/draw-utils"; import { translateHoriLine, translateVertLine, animateRegion, animateBar, animateDot, animatePath, animatePathStr, } from "../utils/animate"; import { getMonthName } from "../utils/date-utils"; class ChartComponent { constructor({ layerClass = "", layerTransform = "", constants, getData, makeElements, animateElements, }) { this.layerTransform = layerTransform; this.constants = constants; this.makeElements = makeElements; this.getData = getData; this.animateElements = animateElements; this.store = []; this.labels = []; this.layerClass = layerClass; this.layerClass = typeof this.layerClass === "function" ? this.layerClass() : this.layerClass; this.refresh(); } refresh(data) { this.data = data || this.getData(); } setup(parent) { this.layer = makeSVGGroup(this.layerClass, this.layerTransform, parent); } make() { this.render(this.data); this.oldData = this.data; } render(data) { this.store = this.makeElements(data); this.layer.textContent = ""; this.store.forEach((element) => { element.length ? element.forEach((el) => { this.layer.appendChild(el); }) : this.layer.appendChild(element); }); this.labels.forEach((element) => { this.layer.appendChild(element); }); } update(animate = true) { this.refresh(); let animateElements = []; if (animate) { animateElements = this.animateElements(this.data) || []; } return animateElements; } } let componentConfigs = { donutSlices: { layerClass: "donut-slices", makeElements(data) { return data.sliceStrings.map((s, i) => { let slice = makePath( s, "donut-path", data.colors[i], "none", data.strokeWidth ); slice.style.transition = "transform .3s;"; return slice; }); }, animateElements(newData) { return this.store.map((slice, i) => animatePathStr(slice, newData.sliceStrings[i]) ); }, }, 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; }); }, animateElements(newData) { return this.store.map((slice, i) => animatePathStr(slice, newData.sliceStrings[i]) ); }, }, percentageBars: { layerClass: "percentage-bars", makeElements(data) { const numberOfPoints = data.xPositions.length; return data.xPositions.map((x, i) => { let y = 0; let isLast = i == numberOfPoints - 1; let isFirst = i == 0; let bar = percentageBar( x, y, data.widths[i], this.constants.barHeight, isFirst, isLast, data.colors[i] ); return bar; }); }, animateElements(newData) { if (newData) return []; }, }, yAxis: { layerClass: "y axis", makeElements(data) { let elements = []; // will loop through each yaxis dataset if it exists if (data.length) { data.forEach((item, i) => { item.positions.map((position, i) => { elements.push( yLine( position, item.labels[i], this.constants.width, { mode: this.constants.mode, pos: item.pos || this.constants.pos, shortenNumbers: this.constants.shortenNumbers, title: item.title, } ) ); }); // we need to make yAxis titles if they are defined if (item.title) { elements.push( generateAxisLabel({ title: item.title, position: item.pos, height: this.constants.height || data.zeroLine, width: this.constants.width, }) ); } }); return elements; } data.positions.forEach((position, i) => { elements.push( yLine(position, data.labels[i], this.constants.width, { mode: this.constants.mode, pos: data.pos || this.constants.pos, shortenNumbers: this.constants.shortenNumbers, }) ); }); if (data.title) { elements.push( generateAxisLabel({ title: data.title, position: data.pos, height: this.constants.height || data.zeroLine, width: this.constants.width, }) ); } return elements; }, animateElements(newData) { const animateMultipleElements = (oldData, newData) => { let newPos = newData.positions; let newLabels = newData.labels; let oldPos = oldData.positions; let oldLabels = oldData.labels; [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); [oldLabels, newLabels] = equilizeNoOfElements( oldLabels, newLabels ); this.render({ positions: oldPos, labels: newLabels, }); return this.store.map((line, i) => { return translateHoriLine(line, newPos[i], oldPos[i]); }); }; // we will need to animate both axis if we have more than one. // so check if the oldData is an array of values. if (this.oldData instanceof Array) { return this.oldData.forEach((old, i) => { animateMultipleElements(old, newData[i]); }); } let newPos = newData.positions; let newLabels = newData.labels; let oldPos = this.oldData.positions; let oldLabels = this.oldData.labels; [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); this.render({ positions: oldPos, labels: newLabels, }); return this.store.map((line, i) => { return translateHoriLine(line, newPos[i], oldPos[i]); }); }, }, 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, }) ); }, animateElements(newData) { let newPos = newData.positions; let newLabels = newData.calcLabels; let oldPos = this.oldData.positions; let oldLabels = this.oldData.calcLabels; [oldPos, newPos] = equilizeNoOfElements(oldPos, newPos); [oldLabels, newLabels] = equilizeNoOfElements(oldLabels, newLabels); this.render({ positions: oldPos, calcLabels: newLabels, }); return this.store.map((line, i) => { return translateVertLine(line, newPos[i], oldPos[i]); }); }, }, yMarkers: { layerClass: "y-markers", makeElements(data) { return data.map((m) => yMarker(m.position, m.label, this.constants.width, { labelPos: m.options.labelPos, stroke: m.options.stroke, mode: "span", lineType: m.options.lineType, }) ); }, animateElements(newData) { [this.oldData, newData] = equilizeNoOfElements( this.oldData, newData ); let newPos = newData.map((d) => d.position); let newLabels = newData.map((d) => d.label); let newOptions = newData.map((d) => d.options); let oldPos = this.oldData.map((d) => d.position); this.render( oldPos.map((pos, i) => { return { position: oldPos[i], label: newLabels[i], options: newOptions[i], }; }) ); return this.store.map((line, i) => { return translateHoriLine(line, newPos[i], oldPos[i]); }); }, }, yRegions: { layerClass: "y-regions", makeElements(data) { return data.map((r) => yRegion(r.startPos, r.endPos, this.constants.width, r.label, { labelPos: r.options.labelPos, }) ); }, 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 newOptions = newData.map((d) => d.options); 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], options: newOptions[i], }; }) ); let animateElements = []; this.store.map((rectGroup, i) => { animateElements = animateElements.concat( animateRegion(rectGroup, newStarts[i], newPos[i], oldPos[i]) ); }); return animateElements; }, }, heatDomain: { layerClass: function () { return "heat-domain domain-" + this.constants.index; }, makeElements(data) { let { index, colWidth, rowHeight, squareSize, radius, xTranslate } = this.constants; let monthNameHeight = -12; let x = xTranslate, y = 0; this.serializedSubDomains = []; data.cols.map((week, weekNo) => { if (weekNo === 1) { this.labels.push( makeText( "domain-name", x, monthNameHeight, getMonthName(index, true).toUpperCase(), { fontSize: 9, } ) ); } week.map((day, i) => { if (day.fill) { let data = { "data-date": day.yyyyMmDd, "data-value": day.dataValue, "data-day": i, }; let square = heatSquare( "day", x, y, squareSize, radius, day.fill, data ); this.serializedSubDomains.push(square); } y += rowHeight; }); y = 0; x += colWidth; }); return this.serializedSubDomains; }, animateElements(newData) { if (newData) return []; }, }, 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 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, }); let animateElements = []; this.store.map((bar, i) => { animateElements = animateElements.concat( animateBar( bar, newXPos[i], newYPos[i], newData.barWidth, newOffsets[i], { zeroLine: newData.zeroLine } ) ); }); 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, spline: c.spline, }, { svgDefs: c.svgDefs, zeroLine: data.zeroLine, } ); } 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 ); }); } 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, }); let animateElements = []; if (Object.keys(this.paths).length) { animateElements = animateElements.concat( animatePath( this.paths, newXPos, newYPos, newData.zeroLine, this.constants.spline ) ); } if (this.units.length) { this.units.map((dot, i) => { animateElements = animateElements.concat( animateDot(dot, newXPos[i], newYPos[i]) ); }); } return animateElements; }, }, }; export 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); } ================================================ FILE: src/js/objects/SvgTip.js ================================================ import { $ } from "../utils/dom"; import { TOOLTIP_POINTER_TRIANGLE_HEIGHT } from "../utils/constants"; export default 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(); } makeTooltip() { this.container = $.create("div", { inside: this.parent, className: "graph-svg-tip comparison", innerHTML: `
`, }); this.hideTip(); this.title = this.container.querySelector(".title"); this.list = this.container.querySelector(".data-point-list"); 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}`; } if (this.listValues.length > 4) { this.list.classList.add("tooltip-grid"); } else { this.list.classList.remove("tooltip-grid"); } this.title.innerHTML = title; this.dataPointList.innerHTML = ""; 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", { innerHTML: `
${value === 0 || value ? value : ""}
${set.title ? set.title : ""}
`, }); this.dataPointList.appendChild(li); }); } calcPosition() { let width = this.container.offsetWidth; this.top = this.y - this.container.offsetHeight - TOOLTIP_POINTER_TRIANGLE_HEIGHT; 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"; } } ================================================ FILE: src/js/utils/animate.js ================================================ import { getBarHeightAndYAttr, getSplineCurvePointsStr } from "./draw-utils"; export const UNIT_ANIM_DUR = 350; export const PATH_ANIM_DUR = 350; export const MARKER_LINE_ANIM_DUR = UNIT_ANIM_DUR; export const REPLACE_ALL_NEW_DUR = 250; export const STD_EASING = "easein"; export 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 }, ]; } export function translateVertLine(xLine, newX, oldX) { return translate(xLine, [oldX, 0], [newX, 0], MARKER_LINE_ANIM_DUR); } export function translateHoriLine(yLine, newY, oldY) { return translate(yLine, [0, oldY], [0, newY], MARKER_LINE_ANIM_DUR); } export 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]; } export function animateBar(bar, x, yTop, width, offset = 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); } export 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); } export function animatePath(paths, newXList, newYList, zeroLine, spline) { let pathComponents = []; let pointsStr = newYList.map((y, i) => newXList[i] + "," + y).join("L"); if (spline) pointsStr = getSplineCurvePointsStr(newXList, newYList); const animPath = [ paths.path, { d: "M" + pointsStr }, 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 + pointsStr + regEndPt }, PATH_ANIM_DUR, STD_EASING, ]; pathComponents.push(animRegion); } return pathComponents; } export function animatePathStr(oldPath, pathStr) { return [oldPath, { d: pathStr }, UNIT_ANIM_DUR, STD_EASING]; } ================================================ FILE: src/js/utils/animation.js ================================================ // Leveraging SMIL Animations import { REPLACE_ALL_NEW_DUR } from "./animate"; 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", }; function animateSVGElement( element, props, dur, easingType = "linear", type = undefined, oldValues = {} ) { let animElement = element.cloneNode(true); let newElement = element.cloneNode(true); 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 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]; } export 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 animateSVG(svgContainer, elements) { let newElements = []; let animElements = []; elements.map((element) => { let unit = element[0]; let parent = unit.parentNode; let animElement, newElement; element[0] = unit; [animElement, newElement] = animateSVGElement(...element); newElements.push(newElement); animElements.push([animElement, parent]); if (parent) { parent.replaceChild(animElement, unit); } }); let animSvg = svgContainer.cloneNode(true); animElements.map((animElement, i) => { if (animElement[1]) { animElement[1].replaceChild(newElements[i], animElement[0]); elements[i][0] = newElements[i]; } }); return animSvg; } export function runSMILAnimation(parent, svgElement, elementsToAnimate) { if (elementsToAnimate.length === 0) return; let animSvgElement = animateSVG(svgElement, elementsToAnimate); if (svgElement.parentNode == parent) { parent.removeChild(svgElement); parent.appendChild(animSvgElement); } // Replace the new svgElement (data has already been replaced) setTimeout(() => { if (animSvgElement.parentNode == parent) { parent.removeChild(animSvgElement); parent.appendChild(svgElement); } }, REPLACE_ALL_NEW_DUR); } ================================================ FILE: src/js/utils/axis-chart-utils.js ================================================ import { fillArray } from "../utils/helpers"; import { DEFAULT_AXIS_CHART_TYPE, AXIS_DATASET_CHART_TYPES, DEFAULT_CHAR_WIDTH, SERIES_LABEL_SPACE_RATIO, } from "../utils/constants"; export function dataPrep(data, type, config) { 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) => { // 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); } if (config) { vals = fillArray(vals, datasetLength - vals.length, null); } else { vals = fillArray(vals, datasetLength - vals.length, 0); } d.values = vals; } // 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; } export 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) => { const { axisID } = d; return { axisID, 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; } export function getShortenedLabels(chartWidth, labels = [], isSeries = true) { let allowedSpace = (chartWidth / labels.length) * SERIES_LABEL_SPACE_RATIO; if (allowedSpace <= 0) allowedSpace = 1; let allowedLetters = allowedSpace / DEFAULT_CHAR_WIDTH; let seriesMultiple; if (isSeries) { // Find the maximum label length for spacing calculations let maxLabelLength = Math.max(...labels.map((label) => label.length)); seriesMultiple = Math.ceil(maxLabelLength / allowedLetters); } 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 { if (i % seriesMultiple !== 0 && i !== labels.length - 1) { label = ""; } } } return label; }); return calcLabels; } ================================================ FILE: src/js/utils/colors.js ================================================ const PRESET_COLOR_MAP = { pink: "#F683AE", blue: "#318AD8", green: "#48BB74", grey: "#A6B1B9", red: "#F56B6B", yellow: "#FACF7A", purple: "#44427B", teal: "#5FD8C4", cyan: "#15CCEF", orange: "#F8814F", "light-pink": "#FED7E5", "light-blue": "#BFDDF7", "light-green": "#48BB74", "light-grey": "#F4F5F6", "light-red": "#F6DFDF", "light-yellow": "#FEE9BF", "light-purple": "#E8E8F7", "light-teal": "#D3FDF6", "light-cyan": "#DDF8FD", "light-orange": "#FECDB8", }; function limitColor(r) { if (r > 255) return 255; else if (r < 0) return 0; return r; } export 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); } export function isValidColor(string) { // https://stackoverflow.com/a/32685393 let HEX_RE = /(^\s*)(#)((?:[A-Fa-f0-9]{3}){1,2})$/i; let RGB_RE = /(^\s*)(rgb|hsl)(a?)[(]\s*([\d.]+\s*%?)\s*,\s*([\d.]+\s*%?)\s*,\s*([\d.]+\s*%?)\s*(?:,\s*([\d.]+)\s*)?[)]$/i; return HEX_RE.test(string) || RGB_RE.test(string); } export const getColor = (color) => { // When RGB color, convert to hexadecimal (alpha value is omitted) if (/rgb[a]{0,1}\([\d, ]+\)/gim.test(color)) { return /\D+(\d*)\D+(\d*)\D+(\d*)/gim .exec(color) .map((x, i) => (i !== 0 ? Number(x).toString(16) : "#")) .reduce((c, ch) => `${c}${ch}`); } return PRESET_COLOR_MAP[color] || color; }; ================================================ FILE: src/js/utils/constants.js ================================================ export const ALL_CHART_TYPES = [ "line", "scatter", "bar", "percentage", "heatmap", "pie", ]; export const COMPATIBLE_CHARTS = { bar: ["line", "scatter", "percentage", "pie"], line: ["scatter", "bar", "percentage", "pie"], pie: ["line", "scatter", "percentage", "bar"], percentage: ["bar", "line", "scatter", "pie"], heatmap: [], }; export const DATA_COLOR_DIVISIONS = { bar: "datasets", line: "datasets", pie: "labels", percentage: "labels", heatmap: HEATMAP_DISTRIBUTION_SIZE, }; export 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, }; export function getTopOffset(m) { return m.titleHeight + m.margins.top + m.paddings.top; } export function getLeftOffset(m) { return m.margins.left + m.paddings.left; } export function getExtraHeight(m) { let totalExtraHeight = m.margins.top + m.margins.bottom + m.paddings.top + m.paddings.bottom + m.titleHeight + m.legendHeight; return totalExtraHeight; } export function getExtraWidth(m) { let totalExtraWidth = m.margins.left + m.margins.right + m.paddings.left + m.paddings.right; return totalExtraWidth; } export const INIT_CHART_UPDATE_TIMEOUT = 700; export const CHART_POST_ANIMATE_TIMEOUT = 400; export const DEFAULT_AXIS_CHART_TYPE = "line"; export const AXIS_DATASET_CHART_TYPES = ["line", "bar"]; export const LEGEND_ITEM_WIDTH = 150; export const SERIES_LABEL_SPACE_RATIO = 0.6; export const BAR_CHART_SPACE_RATIO = 0.5; export const MIN_BAR_PERCENT_HEIGHT = 0.0; export const LINE_CHART_DOT_SIZE = 4; export const DOT_OVERLAY_SIZE_INCR = 4; export const PERCENTAGE_BAR_DEFAULT_HEIGHT = 16; // Fixed 5-color theme, // More colors are difficult to parse visually export const HEATMAP_DISTRIBUTION_SIZE = 5; export const HEATMAP_SQUARE_SIZE = 10; export const HEATMAP_GUTTER_SIZE = 2; export const DEFAULT_CHAR_WIDTH = 7; export const TOOLTIP_POINTER_TRIANGLE_HEIGHT = 7.48; const DEFAULT_CHART_COLORS = [ "pink", "blue", "green", "grey", "red", "yellow", "purple", "teal", "cyan", "orange", ]; const HEATMAP_COLORS_GREEN = [ "#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127", ]; export const HEATMAP_COLORS_BLUE = [ "#ebedf0", "#c0ddf9", "#73b3f3", "#3886e1", "#17459e", ]; export const HEATMAP_COLORS_YELLOW = [ "#ebedf0", "#fdf436", "#ffc700", "#ff9100", "#06001c", ]; export const DEFAULT_COLORS = { bar: DEFAULT_CHART_COLORS, line: DEFAULT_CHART_COLORS, pie: DEFAULT_CHART_COLORS, percentage: DEFAULT_CHART_COLORS, heatmap: HEATMAP_COLORS_GREEN, donut: DEFAULT_CHART_COLORS, }; // Universal constants export const ANGLE_RATIO = Math.PI / 180; export const FULL_ANGLE = 360; ================================================ FILE: src/js/utils/date-utils.js ================================================ // Playing around with dates export const NO_OF_YEAR_MONTHS = 12; export const NO_OF_DAYS_IN_WEEK = 7; export const DAYS_IN_YEAR = 375; export const NO_OF_MILLIS = 1000; export const SEC_IN_DAY = 86400; export const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; export const MONTH_NAMES_SHORT = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; export const DAY_NAMES_SHORT = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", ]; export const DAY_NAMES = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", ]; // https://stackoverflow.com/a/11252167/6495043 function treatAsUtc(date) { let result = new Date(date); result.setMinutes(result.getMinutes() - result.getTimezoneOffset()); return result; } export function toMidnightUTC(date) { let result = new Date(date); result.setUTCHours(0, result.getTimezoneOffset(), 0, 0); return result; } export function getYyyyMmDd(date) { let dd = date.getDate(); let mm = date.getMonth() + 1; // getMonth() is zero-based return [ date.getFullYear(), (mm > 9 ? "" : "0") + mm, (dd > 9 ? "" : "0") + dd, ].join("-"); } export function clone(date) { return new Date(date.getTime()); } export function timestampSec(date) { return date.getTime() / NO_OF_MILLIS; } export function timestampToMidnight(timestamp, roundAhead = false) { let midnightTs = Math.floor(timestamp - (timestamp % SEC_IN_DAY)); if (roundAhead) { return midnightTs + SEC_IN_DAY; } return midnightTs; } // export function getMonthsBetween(startDate, endDate) {} export function getWeeksBetween(startDate, endDate) { let weekStartDate = setDayToSunday(startDate); return Math.ceil(getDaysBetween(weekStartDate, endDate) / NO_OF_DAYS_IN_WEEK); } export function getDaysBetween(startDate, endDate) { let millisecondsPerDay = SEC_IN_DAY * NO_OF_MILLIS; return (treatAsUtc(endDate) - treatAsUtc(startDate)) / millisecondsPerDay; } export function areInSameMonth(startDate, endDate) { return ( startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear() ); } export function getMonthName(i, short = false) { let monthName = MONTH_NAMES[i]; return short ? monthName.slice(0, 3) : monthName; } export function getLastDateInMonth(month, year) { return new Date(year, month + 1, 0); // 0: last day in previous month } // mutates export function setDayToSunday(date) { let newDate = clone(date); const day = newDate.getDay(); if (day !== 0) { addDays(newDate, -1 * day); } return newDate; } // mutates export function addDays(date, numberOfDays) { date.setDate(date.getDate() + numberOfDays); } ================================================ FILE: src/js/utils/dom.js ================================================ export function $(expr, con) { return typeof expr === "string" ? (con || document).querySelector(expr) : expr || null; } export function findNodeIndex(node) { var i = 0; while (node.previousSibling) { node = node.previousSibling; i++; } return i; } $.create = (tag, o) => { var element = document.createElement(tag); for (var i in o) { var val = o[i]; if (i === "inside") { $(val).appendChild(element); } else if (i === "around") { var ref = $(val); ref.parentNode.insertBefore(element, ref); element.appendChild(ref); } else if (i === "styles") { if (typeof val === "object") { Object.keys(val).map((prop) => { element.style[prop] = val[prop]; }); } } else if (i in element) { element[i] = val; } else { element.setAttribute(i, val); } } return element; }; export function getOffset(element) { let rect = element.getBoundingClientRect(); return { // https://stackoverflow.com/a/7436602/6495043 // rect.top varies with scroll, so we add whatever has been // scrolled to it to get absolute distance from actual page top top: rect.top + (document.documentElement.scrollTop || document.body.scrollTop), left: rect.left + (document.documentElement.scrollLeft || document.body.scrollLeft), }; } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent // an element's offsetParent property will return null whenever it, or any of its parents, // is hidden via the display style property. export function isHidden(el) { return el.offsetParent === null; } export function isElementInViewport(el) { // Although straightforward: https://stackoverflow.com/a/7557433/6495043 var rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) /*or $(window).height() */ && rect.right <= (window.innerWidth || document.documentElement.clientWidth) /*or $(window).width() */ ); } export function getElementContentWidth(element) { var styles = window.getComputedStyle(element); var padding = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); return element.clientWidth - padding; } export function bind(element, o) { if (element) { for (var event in o) { var callback = o[event]; event.split(/\s+/).forEach(function (event) { element.addEventListener(event, callback); }); } } } export function unbind(element, o) { if (element) { for (var event in o) { var callback = o[event]; event.split(/\s+/).forEach(function (event) { element.removeEventListener(event, callback); }); } } } export function fire(target, type, properties) { var evt = document.createEvent("HTMLEvents"); evt.initEvent(type, true, true); for (var j in properties) { evt[j] = properties[j]; } return target.dispatchEvent(evt); } // https://css-tricks.com/snippets/javascript/loop-queryselectorall-matches/ export function forEachNode(nodeList, callback, scope) { if (!nodeList) return; for (var i = 0; i < nodeList.length; i++) { callback.call(scope, nodeList[i], i); } } export function activate( $parent, $child, commonClass, activeClass = "active", index = -1 ) { let $children = $parent.querySelectorAll(`.${commonClass}.${activeClass}`); forEachNode($children, (node, i) => { if (index >= 0 && i <= index) return; node.classList.remove(activeClass); }); $child.classList.add(activeClass); } ================================================ FILE: src/js/utils/draw-utils.js ================================================ import { fillArray } from "./helpers"; export function getBarHeightAndYAttr(yTop, zeroLine) { let height, y; if (yTop <= zeroLine) { height = zeroLine - yTop; y = yTop; } else { height = yTop - zeroLine; y = zeroLine; } return [height, y]; } export 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]; } export function truncateString(txt, len) { if (!txt) { return; } if (txt.length > len) { return txt.slice(0, len - 3) + "..."; } else { return txt; } } export function shortenLargeNumber(label) { let number; if (typeof label === "number") number = label; else if (typeof label === "string") { number = Number(label); if (Number.isNaN(number)) return label; } // Using absolute since log wont work for negative numbers let p = Math.floor(Math.log10(Math.abs(number))); if (p <= 2) return number; // Return as is for a 3 digit number of less let l = Math.floor(p / 3); let shortened = Math.pow(10, p - l * 3) * +(number / Math.pow(10, p)).toFixed(1); // Correct for floating point error upto 2 decimal places return Math.round(shortened * 100) / 100 + " " + ["", "K", "M", "B", "T"][l]; } // cubic bezier curve calculation (from example by François Romain) export function getSplineCurvePointsStr(xList, yList) { let points = []; for (let i = 0; i < xList.length; i++) { points.push([xList[i], yList[i]]); } let smoothing = 0.2; let line = (pointA, pointB) => { let lengthX = pointB[0] - pointA[0]; let lengthY = pointB[1] - pointA[1]; return { length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)), angle: Math.atan2(lengthY, lengthX), }; }; let controlPoint = (current, previous, next, reverse) => { let p = previous || current; let n = next || current; let o = line(p, n); let angle = o.angle + (reverse ? Math.PI : 0); let length = o.length * smoothing; let x = current[0] + Math.cos(angle) * length; let y = current[1] + Math.sin(angle) * length; return [x, y]; }; let bezierCommand = (point, i, a) => { let cps = controlPoint(a[i - 1], a[i - 2], point); let cpe = controlPoint(point, a[i - 1], a[i + 1], true); return `C ${cps[0]},${cps[1]} ${cpe[0]},${cpe[1]} ${point[0]},${point[1]}`; }; let pointStr = (points, command) => { return points.reduce( (acc, point, i, a) => i === 0 ? `${point[0]},${point[1]}` : `${acc} ${command(point, i, a)}`, "" ); }; return pointStr(points, bezierCommand); } ================================================ FILE: src/js/utils/draw.js ================================================ import { getBarHeightAndYAttr, truncateString, shortenLargeNumber, getSplineCurvePointsStr, } from "./draw-utils"; import { getStringWidth, isValidNumber, round } from "./helpers"; import { DOT_OVERLAY_SIZE_INCR, } from "./constants"; export const AXIS_TICK_LENGTH = 6; const LABEL_MARGIN = 4; const LABEL_WIDTH = 25; const TOTAL_PADDING = 120; const LABEL_MAX_CHARS = 18; export const FONT_SIZE = 10; const BASE_LINE_COLOR = "#E2E6E9"; function $(expr, con) { return typeof expr === "string" ? (con || document).querySelector(expr) : expr || null; } export function createSVG(tag, o) { var element = document.createElementNS("http://www.w3.org/2000/svg", tag); for (var i in o) { var val = o[i]; if (i === "inside") { $(val).appendChild(element); } else if (i === "around") { var ref = $(val); ref.parentNode.insertBefore(element, ref); element.appendChild(ref); } else if (i === "styles") { if (typeof val === "object") { Object.keys(val).map((prop) => { element.style[prop] = val[prop]; }); } } else { if (i === "className") { i = "class"; } if (i === "innerHTML") { element["textContent"] = val; } else { element.setAttribute(i, val); } } } return element; } function renderVerticalGradient(svgDefElem, gradientId) { return createSVG("linearGradient", { inside: svgDefElem, id: gradientId, x1: 0, x2: 0, y1: 0, y2: 1, }); } function setGradientStop(gradElem, offset, color, opacity) { return createSVG("stop", { inside: gradElem, style: `stop-color: ${color}`, offset: offset, "stop-opacity": opacity, }); } export function makeSVGContainer(parent, className, width, height) { return createSVG("svg", { className: className, inside: parent, width: width, height: height, }); } export function makeSVGDefs(svgContainer) { return createSVG("defs", { inside: svgContainer, }); } export function makeSVGGroup(className, transform = "", parent = undefined) { let args = { className: className, transform: transform, }; if (parent) args.inside = parent; return createSVG("g", args); } export function wrapInSVGGroup(elements, className = "") { let g = createSVG("g", { className: className, }); elements.forEach((e) => g.appendChild(e)); return g; } export function makePath( pathStr, className = "", stroke = "none", fill = "none", strokeWidth = 2 ) { return createSVG("path", { className: className, d: pathStr, styles: { stroke: stroke, fill: fill, "stroke-width": strokeWidth, }, }); } export function makeArcPathStr( startPosition, endPosition, center, radius, clockWise = 1, largeArc = 0 ) { 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 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${arcEndY} z`; } export function makeCircleStr( startPosition, endPosition, center, radius, clockWise = 1, largeArc = 0 ) { let [arcStartX, arcStartY] = [ center.x + startPosition.x, center.y + startPosition.y, ]; let [arcEndX, midArc, arcEndY] = [ center.x + endPosition.x, center.y * 2, center.y + endPosition.y, ]; return `M${center.x} ${center.y} L${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${midArc} z L${arcStartX} ${midArc} A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${arcEndY} z`; } export function makeArcStrokePathStr( startPosition, endPosition, center, radius, clockWise = 1, largeArc = 0 ) { let [arcStartX, arcStartY] = [ center.x + startPosition.x, center.y + startPosition.y, ]; let [arcEndX, arcEndY] = [ center.x + endPosition.x, center.y + endPosition.y, ]; return `M${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${arcEndY}`; } export function makeStrokeCircleStr( startPosition, endPosition, center, radius, clockWise = 1, largeArc = 0 ) { let [arcStartX, arcStartY] = [ center.x + startPosition.x, center.y + startPosition.y, ]; let [arcEndX, midArc, arcEndY] = [ center.x + endPosition.x, radius * 2 + arcStartY, center.y + startPosition.y, ]; return `M${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${midArc} M${arcStartX} ${midArc} A ${radius} ${radius} 0 ${largeArc} ${clockWise ? 1 : 0} ${arcEndX} ${arcEndY}`; } export function makeGradient(svgDefElem, color, lighter = false) { let gradientId = "path-fill-gradient" + "-" + color + "-" + (lighter ? "lighter" : "default"); let gradientDef = renderVerticalGradient(svgDefElem, gradientId); let opacities = [1, 0.6, 0.2]; if (lighter) { opacities = [0.4, 0.2, 0]; } setGradientStop(gradientDef, "0%", color, opacities[0]); setGradientStop(gradientDef, "50%", color, opacities[1]); setGradientStop(gradientDef, "100%", color, opacities[2]); return gradientId; } export function rightRoundedBar(x, width, height) { // https://medium.com/@dennismphil/one-side-rounded-rectangle-using-svg-fb31cf318d90 let radius = height / 2; let xOffset = width - radius; return `M${x},0 h${xOffset} q${radius},0 ${radius},${radius} q0,${radius} -${radius},${radius} h-${xOffset} v${height}z`; } export function leftRoundedBar(x, width, height) { let radius = height / 2; let xOffset = width - radius; return `M${ x + radius },0 h${xOffset} v${height} h-${xOffset} q-${radius}, 0 -${radius},-${radius} q0,-${radius} ${radius},-${radius}z`; } export function percentageBar( x, y, width, height, isFirst, isLast, fill = "none" ) { if (isLast) { let pathStr = rightRoundedBar(x, width, height); return makePath(pathStr, "percentage-bar", null, fill); } if (isFirst) { let pathStr = leftRoundedBar(x, width, height); return makePath(pathStr, "percentage-bar", null, fill); } let args = { className: "percentage-bar", x: x, y: y, width: width, height: height, fill: fill, }; return createSVG("rect", args); } export function heatSquare( className, x, y, size, radius, fill = "none", data = {} ) { let args = { className: className, x: x, y: y, width: size, height: size, rx: radius, fill: fill, }; Object.keys(data).map((key) => { args[key] = data[key]; }); return createSVG("rect", args); } export function legendDot( x, y, size, radius, fill = "none", label, value, font_size = null, truncate = false ) { label = truncate ? truncateString(label, LABEL_MAX_CHARS) : label; if (!font_size) font_size = FONT_SIZE; let args = { className: "legend-dot", x: 0, y: 4 - size, height: size, width: size, rx: radius, fill: fill, }; let textLabel = createSVG("text", { className: "legend-dataset-label", x: size, y: 0, dx: font_size + "px", dy: font_size / 3 + "px", "font-size": font_size * 1.6 + "px", "text-anchor": "start", innerHTML: label, }); let textValue = null; if (value) { textValue = createSVG("text", { className: "legend-dataset-value", x: size, y: FONT_SIZE + 10, dx: FONT_SIZE + "px", dy: FONT_SIZE / 3 + "px", "font-size": FONT_SIZE * 1.2 + "px", "text-anchor": "start", innerHTML: value, }); } let group = createSVG("g", { transform: `translate(${x}, ${y})`, }); group.appendChild(createSVG("rect", args)); group.appendChild(textLabel); if (value && textValue) { group.appendChild(textValue); } return group; } export 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 || "var(--charts-label-color)"; let fill = options.fill || "var(--charts-label-color)"; let textAnchor = options.textAnchor || "start"; return createSVG("text", { className: className, x: x, y: y, dy: dy + "px", "font-size": fontSize + "px", fill: fill, "text-anchor": textAnchor, innerHTML: content, }); } 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: y1, y2: y2, styles: { stroke: options.stroke, }, }); let text = createSVG("text", { x: 0, 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 line = createSVG("g", { transform: `translate(${x}, 0)`, }); line.appendChild(l); line.appendChild(text); return line; } function makeHoriLine(y, label, x1, x2, options = {}) { if (!options.stroke) options.stroke = BASE_LINE_COLOR; if (!options.lineType) options.lineType = ""; if (!options.alignment) options.alignment = "left"; if (options.shortenNumbers) label = shortenLargeNumber(label); let className = "line-horizontal " + options.className + (options.lineType === "dashed" ? "dashed" : ""); const textXPos = options.alignment === "left" ? options.title ? x1 - LABEL_MARGIN + LABEL_WIDTH : x1 - LABEL_MARGIN : options.title ? x2 + LABEL_MARGIN * 4 - LABEL_WIDTH : x2 + LABEL_MARGIN * 4; const lineX1Post = options.title ? x1 + LABEL_WIDTH : x1; const lineX2Post = options.title ? x2 - LABEL_WIDTH : x2; let l = createSVG("line", { className: className, x1: lineX1Post, x2: lineX2Post, y1: 0, y2: 0, styles: { stroke: options.stroke, }, }); let text = createSVG("text", { x: textXPos, y: 0, dy: FONT_SIZE / 2 - 2 + "px", "font-size": FONT_SIZE + "px", "text-anchor": x1 < x2 ? "end" : "start", innerHTML: label + "", }); let line = createSVG("g", { transform: `translate(0, ${y})`, "stroke-opacity": 1, }); if (text === 0 || text === "0") { line.style.stroke = "rgba(27, 31, 35, 0.6)"; } line.appendChild(l); line.appendChild(text); return line; } export function generateAxisLabel(options) { if (!options.title) return; const y = options.position === "left" ? (options.height - TOTAL_PADDING) / 2 + getStringWidth(options.title, 5) / 2 : (options.height - TOTAL_PADDING) / 2 - getStringWidth(options.title, 5) / 2; const x = options.position === "left" ? 0 : options.width; const y2 = options.position === "left" ? FONT_SIZE - LABEL_WIDTH : FONT_SIZE + LABEL_WIDTH * -1; const rotation = options.position === "right" ? `rotate(90)` : `rotate(270)`; const labelSvg = createSVG("text", { className: "chart-label", x: 0, // getStringWidth(options.title, 5) / 2, y: 0, // y, dy: `${y2}px`, "font-size": `${FONT_SIZE}px`, "text-anchor": "start", innerHTML: `${options.title} `, }); let wrapper = createSVG("g", { x: 0, y: 0, transformBox: "fill-box", transform: `translate(${x}, ${y}) ${rotation}`, className: `test-${options.position}`, }); wrapper.appendChild(labelSvg); return wrapper; } export function yLine(y, label, width, options = {}) { if (!isValidNumber(y)) y = 0; 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 = ""; let x1 = -1 * AXIS_TICK_LENGTH; let x2 = options.mode === "span" ? width + AXIS_TICK_LENGTH : 0; if (options.mode === "tick" && options.pos === "right") { x1 = width + AXIS_TICK_LENGTH; x2 = width; } let offset = options.pos === "left" ? -1 * options.offset : options.offset; // pr_366 //x1 += offset; //x2 += offset; x1 += options.offset; x2 += options.offset; if (typeof label === "number") label = round(label); return makeHoriLine(y, label, x1, x2, { stroke: options.stroke, className: options.className, lineType: options.lineType, alignment: options.pos, title: options.title, shortenNumbers: options.shortenNumbers, }); } export function xLine(x, label, height, options = {}) { if (!isValidNumber(x)) x = 0; 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, }); } export function yMarker(y, label, width, options = {}) { if (!isValidNumber(y)) y = 0; if (!options.labelPos) options.labelPos = "right"; if (!options.lineType) options.lineType = "dashed"; let x = options.labelPos === "left" ? LABEL_MARGIN : width - getStringWidth(label, 5) - LABEL_MARGIN; let labelSvg = createSVG("text", { className: "chart-label", x: x, y: 0, dy: FONT_SIZE / -2 + "px", "font-size": FONT_SIZE + "px", "text-anchor": "start", innerHTML: label + "", }); let line = makeHoriLine(y, "", 0, width, { stroke: options.stroke || BASE_LINE_COLOR, className: options.className || "", lineType: options.lineType, }); line.appendChild(labelSvg); return line; } export function yRegion(y1, y2, width, label, options = {}) { // return a group let height = y1 - y2; let rect = createSVG("rect", { className: `bar mini`, // remove class styles: { fill: options.fill || `rgba(228, 234, 239, 0.49)`, stroke: options.stroke || BASE_LINE_COLOR, "stroke-dasharray": `${width}, ${height}`, }, // 'data-point-index': index, x: 0, y: 0, width: width, 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: x, y: 0, dy: FONT_SIZE / -2 + "px", "font-size": FONT_SIZE + "px", "text-anchor": "start", innerHTML: label + "", }); let region = createSVG("g", { transform: `translate(0, ${y2})`, }); region.appendChild(rect); region.appendChild(labelSvg); return region; } export 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; } // Preprocess numbers to avoid svg building errors if (!isValidNumber(x)) x = 0; if (!isValidNumber(y)) y = 0; if (!isValidNumber(height, true)) height = 0; if (!isValidNumber(width, true)) width = 0; // x y h w // M{x},{y+r} // q0,-{r} {r},-{r} // q{r},0 {r},{r} // v{h-r} // h-{w}z // let radius = width/2; // let pathStr = `M${x},${y+radius} q0,-${radius} ${radius},-${radius} q${radius},0 ${radius},${radius} v${height-radius} h-${width}z` // let rect = createSVG('path', { // className: 'bar mini', // d: pathStr, // styles: { fill: color }, // x: x, // y: y, // 'data-point-index': index, // }); let rect = createSVG("rect", { className: `bar mini`, style: `fill: ${color}`, "data-point-index": index, x: x, y: y, width: width, height: height, }); label += ""; 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, }); let group = createSVG("g", { "data-point-index": index, transform: `translate(${x}, ${y})`, }); group.appendChild(rect); group.appendChild(text); return group; } } export function datasetDot(x, y, radius, color, label = "", index = 0) { let dot = createSVG("circle", { style: `fill: ${color}`, "data-point-index": index, cx: x, cy: y, r: radius, }); label += ""; 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, }); let group = createSVG("g", { "data-point-index": index, transform: `translate(${x}, ${y})`, }); group.appendChild(dot); group.appendChild(text); return group; } } export function getPaths(xList, yList, color, options = {}, meta = {}) { let pointsList = yList.map((y, i) => xList[i] + "," + y); let pointsStr = pointsList.join("L"); // Spline if (options.spline) pointsStr = getSplineCurvePointsStr(xList, yList); let path = makePath("M" + pointsStr, "line-graph-path", color); // HeatLine if (options.heatline) { let gradient_id = makeGradient(meta.svgDefs, color); path.style.stroke = `url(#${gradient_id})`; } let paths = { path: path, }; // Region if (options.regionFill) { let gradient_id_region = makeGradient(meta.svgDefs, color, true); 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})` ); } return paths; } export let makeOverlay = { bar: (unit) => { let transformValue; if (unit.nodeName !== "rect") { transformValue = unit.getAttribute("transform"); unit = unit.childNodes[0]; } let overlay = unit.cloneNode(); overlay.style.fill = "#000000"; overlay.style.opacity = "0.4"; if (transformValue) { overlay.setAttribute("transform", transformValue); } return overlay; }, 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"; 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); } return overlay; }, }; export 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 (transformValue) { overlay.setAttribute("transform", transformValue); } }, dot: (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); } }, 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); } }, }; ================================================ FILE: src/js/utils/export.js ================================================ import { $ } from "../utils/dom"; import { CSSTEXT } from "../../css/chartsCss"; export 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); } export 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; } ================================================ FILE: src/js/utils/helpers.js ================================================ import { ANGLE_RATIO } from "./constants"; /** * Returns the value of a number upto 2 decimal places. * @param {Number} d Any number */ export 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 */ export 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. */ export function shuffle(array) { // Awesomeness: https://bost.ocks.org/mike/shuffle/ // https://stackoverflow.com/a/2450976/6495043 // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array?noredirect=1&lq=1 for (let i = array.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } /** * 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? */ export function fillArray(array, count, element, start = false) { if (element == undefined) { 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 */ export function getStringWidth(string, charWidth) { return (string + "").length * charWidth; } export function bindChange(obj, getFn, setFn) { return new Proxy(obj, { set: function (target, prop, value) { setFn(); return Reflect.set(target, prop, value); }, get: function (target, prop) { getFn(); return Reflect.get(target, prop); }, }); } // https://stackoverflow.com/a/29325222 export function getRandomBias(min, max, bias, influence) { const range = max - min; const biasValue = range * bias + min; var rnd = Math.random() * range + min, // random in range mix = Math.random() * influence; // random mixer return rnd * (1 - mix) + biasValue * mix; // mix full range and bias } export function getPositionByAngle(angle, radius) { return { x: Math.sin(angle * ANGLE_RATIO) * radius, y: Math.cos(angle * ANGLE_RATIO) * radius, }; } /** * Check if a number is valid for svg attributes * @param {object} candidate Candidate to test * @param {Boolean} nonNegative flag to treat negative number as invalid */ export function isValidNumber(candidate, nonNegative = false) { if (Number.isNaN(candidate)) return false; else if (candidate === undefined) return false; else if (!Number.isFinite(candidate)) return false; else if (nonNegative && candidate < 0) return false; else return true; } /** * Round a number to the closes precision, max max precision 4 * @param {Number} d Any Number */ export function round(d) { // https://floating-point-gui.de/ // https://www.jacklmoore.com/notes/rounding-in-javascript/ return Number(Math.round(d + "e4") + "e-4"); } /** * Creates a deep clone of an object * @param {Object} candidate Any Object */ export function deepClone(candidate) { let cloned, value, key; if (candidate instanceof Date) { return new Date(candidate.getTime()); } if (typeof candidate !== "object" || candidate === null) { return candidate; } cloned = Array.isArray(candidate) ? [] : {}; for (key in candidate) { value = candidate[key]; cloned[key] = deepClone(value); } return cloned; } ================================================ FILE: src/js/utils/intervals.js ================================================ import { floatTwo } from "./helpers"; 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) => { // For negative exponents we want to divide by 10^-exponent to avoid // floating point arithmetic bugs. For instance, in javascript // 6 * 10^-1 == 0.6000000000000001, we instead want 6 / 10^1 == 0.6 if (exponent < 0) { return value / Math.pow(10, -exponent); } return value * Math.pow(10, exponent); }); return intervals; } export function calcChartIntervals(values, withMinimum = true, overrideCeiling=false, overrideFloor=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); if (overrideCeiling) { maxValue = overrideCeiling } if (overrideFloor) { minValue = overrideFloor } // 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.reverse().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.sort((a, b) => a - b); } export 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; } export function getRealIntervals(max, noOfIntervals, min = 0, asc = 1) { let range = max - min; let part = (range * 1.0) / noOfIntervals; let intervals = []; for (var i = 0; i <= noOfIntervals; i++) { intervals.push(min + part * i); } return asc ? intervals : intervals.reverse(); } export function getIntervalSize(orderedArray) { return orderedArray[1] - orderedArray[0]; } export function getValueRange(orderedArray) { return orderedArray[orderedArray.length - 1] - orderedArray[0]; } export function scale(val, yAxis) { return floatTwo(yAxis.zeroLine - val * yAxis.scaleMultiplier); } export function isInRange(val, min, max) { return val > min && val < max; } export function isInRange2D(coord, minCoord, maxCoord) { return ( isInRange(coord[0], minCoord[0], maxCoord[0]) && isInRange(coord[1], minCoord[1], maxCoord[1]) ); } export function getClosestInArray(goal, arr, index = false) { let closest = arr.reduce(function (prev, curr) { return Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev; }, []); return index ? arr.indexOf(closest) : closest; } export 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 getMaxCheckpoint(value, distribution) { return distribution.filter((d) => d < value).length; } ================================================ FILE: src/js/utils/test/colors.test.js ================================================ const assert = require("assert"); const colors = require("../colors"); describe("utils.colors", () => { it("should return #aaabac for RGB()", () => { assert.equal(colors.getColor("rgb(170, 171, 172)"), "#aaabac"); }); it("should return #ff5858 for the named color red", () => { assert.equal(colors.getColor("red"), "#ff5858d"); }); it("should return #1a5c29 for the hex color #1a5c29", () => { assert.equal(colors.getColor("#1a5c29"), "#1a5c29"); }); }); ================================================ FILE: src/js/utils/test/helpers.test.js ================================================ const assert = require("assert"); const helpers = require("../helpers"); describe("utils.helpers", () => { it("should return a value fixed upto 2 decimals", () => { assert.equal(helpers.floatTwo(1.234), 1.23); assert.equal(helpers.floatTwo(1.456), 1.46); assert.equal(helpers.floatTwo(1), 1.0); }); });