├── .gitignore ├── .npmignore ├── .travis.yml ├── dist ├── chartjs_ext.js ├── chartjs_ext.js.map ├── index.js.map ├── index.d.ts ├── search_index.d.ts ├── data_culling.d.ts ├── index.js ├── utils.js.map ├── utils.d.ts ├── responsive_downsample_plugin.d.ts ├── lttb_data_mipmap.d.ts ├── search_index.js ├── data_mipmap.d.ts ├── data_culling.js ├── utils.js ├── chartjs_ext.d.ts ├── lttb_data_mipmap.js.map ├── responsive_downsample_plugin.js.map ├── data_mipmap.js.map ├── data_mipmap.js ├── lttb_data_mipmap.js ├── responsive_downsample_plugin.js ├── chartjs-plugin-responsive-downsample.bundle.min.js └── chartjs-plugin-responsive-downsample.bundle.js ├── test ├── mocha.opts ├── helper │ └── mock_time_scale.ts └── unit │ ├── lttb_data_mipmap.test.ts │ ├── data_culling.test.ts │ ├── utils.test.ts │ ├── data_mipmap.test.ts │ └── responsive_downsample_plugin.test.ts ├── tsconfig.json ├── src ├── index.ts ├── data_culling.ts ├── utils.ts ├── chartjs_ext.ts ├── data_mipmap.ts ├── lttb_data_mipmap.ts └── responsive_downsample_plugin.ts ├── LICENSE ├── README.md ├── package.json └── examples └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .nyc_output/ 3 | coverage/ 4 | node_modules/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | examples/ 3 | node_modules/ 4 | src/ 5 | test/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | before_script: 5 | - npm run build -------------------------------------------------------------------------------- /dist/chartjs_ext.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require source-map-support/register 3 | --full-trace 4 | --colors -------------------------------------------------------------------------------- /dist/chartjs_ext.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"chartjs_ext.js","sourceRoot":"","sources":["../src/chartjs_ext.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAAA,qCAAiC;AAEjC,+EAA+G;AAC/G,+EAA+G;AAAtG,oEAAA,0BAA0B,CAAA;AAQnC,gBAAK,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,yDAA0B,EAAE,CAAC,CAAC"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "noImplicitAny": true, 5 | "sourceMap": false, 6 | "declaration": true, 7 | "outDir": "dist", 8 | "module": "commonjs", 9 | "lib": [ 10 | "dom", 11 | "es5" 12 | ] 13 | }, 14 | "include": [ 15 | "src" 16 | ] 17 | } -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ResponsiveDownsamplePluginOptions } from './responsive_downsample_plugin'; 2 | export { ResponsiveDownsamplePlugin, ResponsiveDownsamplePluginOptions } from './responsive_downsample_plugin'; 3 | declare module "chart.js" { 4 | interface ChartOptions { 5 | /** 6 | * Options for responsive downsample plugin 7 | */ 8 | responsiveDownsample?: ResponsiveDownsamplePluginOptions; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /dist/search_index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { ChartPoint } from "chart.js"; 3 | export declare type IndexType = 'number' | 'date'; 4 | export declare class SearchIndex { 5 | private endIndex; 6 | private index; 7 | static toNumericValue(point: ChartPoint): number; 8 | constructor(data: ChartPoint[], bucketSize?: number); 9 | getSearchRange(data: ChartPoint): [number, number]; 10 | private createIndex(data); 11 | } 12 | -------------------------------------------------------------------------------- /dist/data_culling.d.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | import { Scale } from './chartjs_ext'; 3 | import moment_module = require('moment'); 4 | export declare type XValue = number | string | Date | moment_module.Moment; 5 | export declare type Range = [XValue, XValue]; 6 | export declare function rangeIsEqual(previousValue: Range, currentValue: Range): boolean; 7 | export declare function getScaleRange(scale: Scale): Range; 8 | export declare function cullData(data: ChartPoint[], range: Range): ChartPoint[]; 9 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var chartjs = require("chart.js"); 4 | var Chart = window && window.Chart ? window.Chart : chartjs.Chart; 5 | var responsive_downsample_plugin_1 = require("./responsive_downsample_plugin"); 6 | var responsive_downsample_plugin_2 = require("./responsive_downsample_plugin"); 7 | exports.ResponsiveDownsamplePlugin = responsive_downsample_plugin_2.ResponsiveDownsamplePlugin; 8 | Chart.pluginService.register(new responsive_downsample_plugin_1.ResponsiveDownsamplePlugin()); 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import chartjs = require('chart.js'); 2 | const Chart = window && (window as any).Chart ? (window as any).Chart : chartjs.Chart; 3 | 4 | import { ResponsiveDownsamplePlugin, ResponsiveDownsamplePluginOptions } from './responsive_downsample_plugin'; 5 | export { ResponsiveDownsamplePlugin, ResponsiveDownsamplePluginOptions } from './responsive_downsample_plugin'; 6 | 7 | 8 | // Extend chart.js options interface 9 | declare module "chart.js" { 10 | interface ChartOptions { 11 | /** 12 | * Options for responsive downsample plugin 13 | */ 14 | responsiveDownsample?: ResponsiveDownsamplePluginOptions; 15 | } 16 | } 17 | 18 | Chart.pluginService.register(new ResponsiveDownsamplePlugin()); 19 | -------------------------------------------------------------------------------- /dist/utils.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":";;AACA;;GAEG;AACH,eAAsB,KAAU;IAC5B,MAAM,CAAC,CAAC,OAAO,KAAK,KAAK,WAAW,CAAC,IAAI,KAAK,KAAK,IAAI,CAAC;AAC5D,CAAC;AAFD,sBAEC;AAED;;;;;GAKG;AACH,eAAsB,KAAa,EAAE,GAAW,EAAE,GAAW;IACzD,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;AAC/C,CAAC;AAFD,sBAEC;AAED;;;;;GAKG;AACH,sBAAgC,MAAS,EAAE,QAAoB;IAC3D,GAAG,CAAC,CAAC,IAAI,GAAG,IAAI,QAAQ,CAAC,CAAC,CAAC;QACvB,IAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QAE1B,EAAE,CAAC,CAAC,OAAO,KAAK,KAAK,WAAW,CAAC,CAAC,CAAC;YAC/B,MAAM,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;QAChC,CAAC;QAAC,IAAI,CAAC,EAAE,CAAC,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC;YACnC,MAAM,CAAC,GAAG,CAAC,GAAG,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;QACrD,CAAC;IACL,CAAC;IAED,MAAM,CAAC,MAAM,CAAC;AAClB,CAAC;AAZD,oCAYC"} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 3D Content Logistics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/helper/mock_time_scale.ts: -------------------------------------------------------------------------------- 1 | export class MockTimeScale { 2 | left: number; 3 | right: number; 4 | top: number; 5 | bottom: number; 6 | startDate: Date; 7 | endDate: Date; 8 | 9 | constructor() { 10 | this.left = 0; 11 | this.right = 100; 12 | this.top = 0; 13 | this.bottom = 32; 14 | this.startDate = new Date("2018-01-01T00:00:00.000Z"); 15 | this.endDate = new Date("2018-01-01T24:00:00.000Z"); 16 | } 17 | 18 | getPixelForValue(value: Date, index?: number, datasetIndex?: number): number { 19 | const interval = this.endDate.getTime() - this.startDate.getTime(); 20 | const width = this.right - this.left; 21 | const alpha = value.getTime() - this.startDate.getTime() / interval; 22 | 23 | return alpha * width + this.left; 24 | } 25 | 26 | 27 | getValueForPixel(pixel: number): Date { 28 | const width = this.right - this.left; 29 | const interval = this.endDate.getTime() - this.startDate.getTime(); 30 | const alpha = (pixel - this.left) / width; 31 | 32 | return new Date(alpha * interval + this.startDate.getTime()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /dist/utils.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if a value is null or undefined 3 | */ 4 | export declare function isNil(value: any): value is null | undefined; 5 | /** 6 | * Clamp a number to a range 7 | * @param value 8 | * @param min 9 | * @param max 10 | */ 11 | export declare function clamp(value: number, min: number, max: number): number; 12 | /** 13 | * Recursivly assign default values to an object if object is missing the keys. 14 | * @param object The destination object to assign default values to 15 | * @param defaults The default values for the object 16 | * @return The destination object 17 | */ 18 | export declare function defaultsDeep(object: T, defaults: Partial): T; 19 | /** 20 | * Finds the first element in an array for that the comaperator functions returns true 21 | * 22 | * @export 23 | * @template T Element type of the array 24 | * @param {Array} array An array 25 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 26 | * @returns {T} The found element or undefined 27 | */ 28 | export declare function findInArray(array: Array, compareFunction: (element: T) => boolean): T; 29 | /** 30 | * Finds the first index in an array for that the comaperator function for an element returns true 31 | * 32 | * @export 33 | * @template T 34 | * @param {Array} array An array of elements 35 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 36 | * @returns {number} Index of the matched element or -1 if no element was found 37 | */ 38 | export declare function findIndexInArray(array: Array, compareFunction: (element: T) => boolean): number; 39 | -------------------------------------------------------------------------------- /dist/responsive_downsample_plugin.d.ts: -------------------------------------------------------------------------------- 1 | import { IChartPlugin } from './chartjs_ext'; 2 | import * as data_culling from './data_culling'; 3 | export interface ResponsiveDownsamplePluginOptions { 4 | /** 5 | * Enable/disable plugin 6 | */ 7 | enabled?: boolean; 8 | /** 9 | * The aggregation algorithm to thin out data. Default: LTTB 10 | */ 11 | aggregationAlgorithm?: 'AVG' | 'LTTB'; 12 | /** 13 | * Desired mininmal distance between data points in pixels. Default: 1 pixel 14 | */ 15 | desiredDataPointDistance?: number; 16 | /** 17 | * Minimal number of data points. Limits 18 | */ 19 | minNumPoints?: number; 20 | /** 21 | * Cull data to displayed range of x scale 22 | */ 23 | cullData?: boolean; 24 | /** 25 | * Flag is set by plugin to trigger reload of data 26 | */ 27 | needsUpdate?: boolean; 28 | /** 29 | * Current target resolution(Set by plugin) 30 | */ 31 | targetResolution?: number; 32 | /** 33 | * Scale range of x axis 34 | */ 35 | scaleRange?: data_culling.Range; 36 | } 37 | /** 38 | * Chart js Plugin for downsampling data 39 | */ 40 | export declare class ResponsiveDownsamplePlugin implements IChartPlugin { 41 | static getPluginOptions(chart: any): ResponsiveDownsamplePluginOptions; 42 | static hasDataChanged(chart: Chart): boolean; 43 | static createDataMipMap(chart: Chart, options: ResponsiveDownsamplePluginOptions): void; 44 | static restoreOriginalData(chart: Chart): boolean; 45 | static getTargetResolution(chart: Chart, options: ResponsiveDownsamplePluginOptions): number; 46 | static updateMipMap(chart: Chart, options: ResponsiveDownsamplePluginOptions, rangeChanged: boolean): boolean; 47 | beforeInit(chart: Chart): void; 48 | beforeDatasetsUpdate(chart: Chart): void; 49 | beforeRender(chart: Chart): boolean; 50 | } 51 | -------------------------------------------------------------------------------- /dist/lttb_data_mipmap.d.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | import { DataMipmap } from './data_mipmap'; 3 | /** 4 | * A mipmap data structure that uses Largest-Triangle-Three-Buckets algorithm to downsample data 5 | */ 6 | export declare class LTTBDataMipmap extends DataMipmap { 7 | protected resolutions: number[]; 8 | getMipMapIndexForResolution(resolution: number): number; 9 | protected createMipMap(): void; 10 | /** 11 | * This method is adapted from: https://github.com/sveinn-steinarsson/flot-downsample 12 | * 13 | * The MIT License 14 | * Copyright (c) 2013 by Sveinn Steinarsson 15 | * Permission is hereby granted, free of charge, to any person obtaining a copy 16 | * of this software and associated documentation files (the "Software"), to deal 17 | * in the Software without restriction, including without limitation the rights 18 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | * copies of the Software, and to permit persons to whom the Software is 20 | * furnished to do so, subject to the following conditions: 21 | * The above copyright notice and this permission notice shall be included in 22 | * all copies or substantial portions of the Software. 23 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 29 | * THE SOFTWARE. 30 | */ 31 | protected downsampleToResolution(data: ChartPoint[], targetResolution: number, targetLength: number): ChartPoint[]; 32 | protected computeAverageResolution(data: ChartPoint[]): number; 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | chartjs-plugin-responsive-downsample 2 | ==================================== 3 | [![Build Status](https://travis-ci.com/3dcl/chartjs-plugin-responsive-downsample.svg?branch=master)](https://travis-ci.com/3dcl/chartjs-plugin-responsive-downsample) 4 | 5 | A chart.js plugin to dynamically downsample line chart data depending on the chart resolution. 6 | The plugin creates a mipmap-like data structure from line chart data and dynamically choses a downsampled version of the data depending on the chart resolution and x axis scale. 7 | 8 | Inspired by: [AlbinoDrought/chartjs-plugin-downsample](https://github.com/AlbinoDrought/chartjs-plugin-downsample) 9 | 10 | ### Installation 11 | 12 | ```bash 13 | $ npm install chartjs-plugin-responsive-downsample 14 | ``` 15 | 16 | ### Configuration 17 | ```javascript 18 | { 19 | options: { 20 | responsiveDownsample: { 21 | /** 22 | * Enable/disable plugin 23 | */ 24 | enabled?: boolean; 25 | /** 26 | * The aggregation algorithm to thin out data. Default: LTTB 27 | */ 28 | aggregationAlgorithm?: 'AVG' | 'LTTB'; 29 | /** 30 | * Desired mininmal distance between data points in pixels. Default: 1 pixel 31 | */ 32 | desiredDataPointDistance?: number; 33 | /** 34 | * Minimal number of data points. Limits 35 | */ 36 | minNumPoints?: number; 37 | /** 38 | * Cull data to displayed range of x scale 39 | */ 40 | cullData?: boolean; 41 | /** 42 | * Flag is set by plugin to trigger reload of data 43 | */ 44 | needsUpdate?: boolean; 45 | /** 46 | * Current target resolution(Set by plugin) 47 | */ 48 | targetResolution?: number; 49 | /** 50 | * Scale range of x axis 51 | */ 52 | scaleRange?: data_culling.Range; 53 | } 54 | } 55 | } 56 | 57 | ``` 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chartjs-plugin-responsive-downsample", 3 | "version": "1.1.3", 4 | "description": "A chart.js plugin to dynamically downsample line chart data depending on the chart resolution.", 5 | "files": [ 6 | "dist" 7 | ], 8 | "browser": "dist/index.js", 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "prepublish": "npm run build && npm run build-browser && npm run build-browser-min", 13 | "build": "tsc", 14 | "build-browser": "browserify src/index.ts -o dist/chartjs-plugin-responsive-downsample.bundle.js -p [ tsify ] --ignore chart.js --ignore moment", 15 | "build-browser-min": "browserify src/index.ts -o dist/chartjs-plugin-responsive-downsample.bundle.min.js -p [ tsify ] -t [ uglifyify -x .js -x .ts ] --ignore chart.js --ignore moment", 16 | "test": "nyc mocha test/unit/**.ts" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/3dcl/chartjs-plugin-responsive-downsample.git" 21 | }, 22 | "author": "Marcel Pursche", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/3dcl/chartjs-plugin-responsive-downsample/issues" 26 | }, 27 | "homepage": "https://github.com/3dcl/chartjs-plugin-responsive-downsample#readme", 28 | "dependencies": { 29 | "@types/chart.js": "^2.7.42", 30 | "chart.js": "^2.7.3", 31 | "typescript": "^2.9.2" 32 | }, 33 | "devDependencies": { 34 | "@types/chai": "^4.1.7", 35 | "@types/chai-datetime": "0.0.31", 36 | "@types/mocha": "^5.2.5", 37 | "browserify": "^16.2.3", 38 | "chai": "^4.2.0", 39 | "chai-datetime": "^1.5.0", 40 | "mocha": "^10.2.0", 41 | "nyc": "^15.1.0", 42 | "ts-node": "^5.0.1", 43 | "tsify": "^3.0.4", 44 | "uglifyify": "^5.0.2" 45 | }, 46 | "nyc": { 47 | "include": [ 48 | "src" 49 | ], 50 | "exclude": [], 51 | "extension": [ 52 | ".ts" 53 | ], 54 | "require": [ 55 | "ts-node/register" 56 | ], 57 | "reporter": [ 58 | "lcov" 59 | ], 60 | "report-dir": "coverage", 61 | "sourceMap": true, 62 | "instrument": true 63 | } 64 | } -------------------------------------------------------------------------------- /dist/search_index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var SearchIndex = /** @class */ (function () { 4 | function SearchIndex(data, bucketSize) { 5 | if (bucketSize === void 0) { bucketSize = 100; } 6 | this.index = []; 7 | this.endIndex = data.length - 1; 8 | this.createIndex(data); 9 | } 10 | SearchIndex.toNumericValue = function (point) { 11 | if (typeof point.x === 'string') { 12 | return new Date(point.x).getTime(); 13 | } 14 | else if (typeof point.x === 'number') { 15 | return point.x; 16 | } 17 | else if (point.x instanceof Date) { 18 | return point.x.getTime(); 19 | } 20 | }; 21 | SearchIndex.prototype.getSearchRange = function (data) { 22 | var startIndex = 0; 23 | var endIndex = this.endIndex; 24 | for (var i = 0; i < this.index.length; ++i) { 25 | var entry = this.index[i]; 26 | if (entry.x <= data.x) { 27 | startIndex = entry.index; 28 | } 29 | if (entry.x >= data.x) { 30 | endIndex = entry.index; 31 | break; 32 | } 33 | } 34 | return [startIndex, endIndex]; 35 | }; 36 | SearchIndex.prototype.createIndex = function (data) { 37 | var lastValue = SearchIndex.toNumericValue(data[0]); 38 | this.index.push({ 39 | x: data[0].x, 40 | index: 0 41 | }); 42 | for (var i = 1, end = data.length - 1; i < end; ++i) { 43 | var currentValue = SearchIndex.toNumericValue(data[i]); 44 | if (currentValue > (lastValue + 3600000)) { 45 | this.index.push({ 46 | x: data[i].x, 47 | index: i 48 | }); 49 | lastValue = currentValue; 50 | } 51 | } 52 | this.index.push({ 53 | x: data[data.length - 1].x, 54 | index: data.length - 1 55 | }); 56 | }; 57 | return SearchIndex; 58 | }()); 59 | exports.SearchIndex = SearchIndex; 60 | -------------------------------------------------------------------------------- /dist/data_mipmap.d.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | /** 3 | * A mipmap data structure for line chart data. Uses averages to downsample data. 4 | */ 5 | export declare class DataMipmap { 6 | protected minNumPoints: number; 7 | protected mipMaps: ChartPoint[][]; 8 | protected originalData: ChartPoint[]; 9 | protected resolution: number; 10 | /** 11 | * Create a data mipmap 12 | * @param data The orignal line chart data 13 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 14 | */ 15 | constructor(data: ChartPoint[], minNumPoints?: number); 16 | /** 17 | * Set the line chart data and update mipmap level. 18 | * @param data The orignal line chart data 19 | */ 20 | setData(data: ChartPoint[]): void; 21 | /** 22 | * Set the minimal number of points 23 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 24 | */ 25 | setMinNumPoints(minNumPoints: number): void; 26 | /** 27 | * Get the best fitting mipmap level for a certain scale resolution 28 | * @param resolution Desired resolution in ms per pixel 29 | */ 30 | getMipMapForResolution(resolution: number): ChartPoint[]; 31 | /** 32 | * Computes the index of the best fitting mipmap level for a certain scale resolution 33 | * @param resolution Desired resolution in ms per pixel 34 | */ 35 | getMipMapIndexForResolution(resolution: number): number; 36 | /** 37 | * Get a mipmap level by index 38 | * @param level The index of the mipmap level 39 | */ 40 | getMipMapLevel(level: number): ChartPoint[]; 41 | /** 42 | * Get all mipmap level 43 | */ 44 | getMipMaps(): ChartPoint[][]; 45 | /** 46 | * Get the number of available mipmap level 47 | */ 48 | getNumLevel(): number; 49 | protected computeResolution(data: ChartPoint[]): number; 50 | protected createMipMap(): void; 51 | protected downsampleToResolution(data: ChartPoint[], targetResolution: number, targetLength: number): ChartPoint[]; 52 | protected getAverage(aggregationValues: ChartPoint[]): ChartPoint; 53 | protected getTime(point: ChartPoint): number; 54 | } 55 | -------------------------------------------------------------------------------- /dist/data_culling.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var moment_module = require("moment"); 4 | var moment = (window && window.moment) ? window.moment : moment_module; 5 | var utils = require("./utils"); 6 | function getCompareValue(value) { 7 | if (typeof value === 'number') { 8 | return value; 9 | } 10 | else if (typeof value === 'string') { 11 | return (new Date(value)).getTime(); 12 | } 13 | else if (value instanceof Date) { 14 | return value.getTime(); 15 | } 16 | else { 17 | return moment(value).toDate().getTime(); 18 | } 19 | } 20 | function rangeIsEqual(previousValue, currentValue) { 21 | if (utils.isNil(previousValue) || 22 | utils.isNil(currentValue) || 23 | utils.isNil(previousValue[0]) || 24 | utils.isNil(previousValue[1]) || 25 | utils.isNil(currentValue[0]) || 26 | utils.isNil(currentValue[1])) { 27 | return false; 28 | } 29 | previousValue = [getCompareValue(previousValue[0]), getCompareValue(previousValue[1])]; 30 | currentValue = [getCompareValue(currentValue[0]), getCompareValue(currentValue[1])]; 31 | return previousValue[0] === currentValue[0] && previousValue[1] == currentValue[1]; 32 | } 33 | exports.rangeIsEqual = rangeIsEqual; 34 | function getScaleRange(scale) { 35 | if (utils.isNil(scale)) 36 | return [null, null]; 37 | var start = scale.getValueForPixel(scale.left); 38 | var end = scale.getValueForPixel(scale.right); 39 | return [start, end]; 40 | } 41 | exports.getScaleRange = getScaleRange; 42 | function cullData(data, range) { 43 | var startValue = getCompareValue(range[0]); 44 | var endValue = getCompareValue(range[1]); 45 | var startIndex = 0; 46 | var endIndex = data.length; 47 | for (var i = 1; i < data.length; ++i) { 48 | var point = data[i]; 49 | var compareValue = getCompareValue(point.x || point.t); 50 | if (compareValue <= startValue) { 51 | startIndex = i; 52 | } 53 | if (compareValue >= endValue) { 54 | endIndex = i + 1; 55 | break; 56 | } 57 | } 58 | return data.slice(startIndex, endIndex); 59 | } 60 | exports.cullData = cullData; 61 | -------------------------------------------------------------------------------- /src/data_culling.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | import { Scale } from './chartjs_ext'; 3 | import moment_module = require('moment'); 4 | const moment = (window && (window as any).moment) ? (window as any).moment : moment_module; 5 | import * as utils from './utils'; 6 | 7 | export type XValue = number | string | Date | moment_module.Moment; 8 | export type Range = [XValue, XValue]; 9 | 10 | function getCompareValue(value: XValue): number { 11 | if (typeof value === 'number') { 12 | return value; 13 | } else if (typeof value === 'string') { 14 | return (new Date(value)).getTime(); 15 | } else if (value instanceof Date) { 16 | return value.getTime(); 17 | } else { 18 | return moment(value).toDate().getTime(); 19 | } 20 | } 21 | 22 | export function rangeIsEqual(previousValue: Range, currentValue: Range): boolean { 23 | if (utils.isNil(previousValue) || 24 | utils.isNil(currentValue) || 25 | utils.isNil(previousValue[0]) || 26 | utils.isNil(previousValue[1]) || 27 | utils.isNil(currentValue[0]) || 28 | utils.isNil(currentValue[1]) 29 | ) { 30 | return false; 31 | } 32 | 33 | previousValue = [getCompareValue(previousValue[0]), getCompareValue(previousValue[1])]; 34 | currentValue = [getCompareValue(currentValue[0]), getCompareValue(currentValue[1])]; 35 | 36 | return previousValue[0] === currentValue[0] && previousValue[1] == currentValue[1]; 37 | } 38 | 39 | export function getScaleRange(scale: Scale): Range { 40 | if (utils.isNil(scale)) return [null, null]; 41 | 42 | const start = scale.getValueForPixel(scale.left); 43 | const end = scale.getValueForPixel(scale.right); 44 | 45 | return [start, end]; 46 | } 47 | 48 | export function cullData(data: ChartPoint[], range: Range): ChartPoint[] { 49 | const startValue = getCompareValue(range[0]); 50 | const endValue = getCompareValue(range[1]); 51 | let startIndex = 0; 52 | let endIndex = data.length; 53 | 54 | for (let i = 1; i < data.length; ++i) { 55 | const point = data[i]; 56 | const compareValue = getCompareValue(point.x || point.t); 57 | 58 | if (compareValue <= startValue) { 59 | startIndex = i; 60 | } 61 | 62 | if (compareValue >= endValue) { 63 | endIndex = i + 1; 64 | break; 65 | } 66 | } 67 | 68 | return data.slice(startIndex, endIndex); 69 | } 70 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Check if a value is null or undefined 4 | */ 5 | export function isNil(value: any): value is null | undefined { 6 | return (typeof value === "undefined") || value === null; 7 | } 8 | 9 | /** 10 | * Clamp a number to a range 11 | * @param value 12 | * @param min 13 | * @param max 14 | */ 15 | export function clamp(value: number, min: number, max: number): number { 16 | return Math.min(Math.max(value, min), max); 17 | } 18 | 19 | /** 20 | * Recursivly assign default values to an object if object is missing the keys. 21 | * @param object The destination object to assign default values to 22 | * @param defaults The default values for the object 23 | * @return The destination object 24 | */ 25 | export function defaultsDeep(object: T, defaults: Partial): T { 26 | for (let key in defaults) { 27 | const value = object[key]; 28 | 29 | if (typeof value === "undefined") { 30 | object[key] = defaults[key]; 31 | } else if (value !== null && typeof value === "object") { 32 | object[key] = defaultsDeep(value, defaults[key]); 33 | } 34 | } 35 | 36 | return object; 37 | } 38 | 39 | 40 | /** 41 | * Finds the first element in an array for that the comaperator functions returns true 42 | * 43 | * @export 44 | * @template T Element type of the array 45 | * @param {Array} array An array 46 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 47 | * @returns {T} The found element or undefined 48 | */ 49 | export function findInArray(array: Array, compareFunction: (element: T) => boolean) : T{ 50 | if(isNil(array)) return undefined; 51 | for (var i = 0; i < array.length; i++){ 52 | if(compareFunction(array[i]) === true) { 53 | return array[i]; 54 | } 55 | } 56 | return undefined; 57 | } 58 | 59 | 60 | /** 61 | * Finds the first index in an array for that the comaperator function for an element returns true 62 | * 63 | * @export 64 | * @template T 65 | * @param {Array} array An array of elements 66 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 67 | * @returns {number} Index of the matched element or -1 if no element was found 68 | */ 69 | export function findIndexInArray(array: Array, compareFunction: (element: T) => boolean) : number{ 70 | if(isNil(array)) return undefined; 71 | for (var i = 0; i < array.length; i++){ 72 | if(compareFunction(array[i]) === true) { 73 | return i; 74 | } 75 | } 76 | return -1; 77 | } -------------------------------------------------------------------------------- /dist/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | /** 4 | * Check if a value is null or undefined 5 | */ 6 | function isNil(value) { 7 | return (typeof value === "undefined") || value === null; 8 | } 9 | exports.isNil = isNil; 10 | /** 11 | * Clamp a number to a range 12 | * @param value 13 | * @param min 14 | * @param max 15 | */ 16 | function clamp(value, min, max) { 17 | return Math.min(Math.max(value, min), max); 18 | } 19 | exports.clamp = clamp; 20 | /** 21 | * Recursivly assign default values to an object if object is missing the keys. 22 | * @param object The destination object to assign default values to 23 | * @param defaults The default values for the object 24 | * @return The destination object 25 | */ 26 | function defaultsDeep(object, defaults) { 27 | for (var key in defaults) { 28 | var value = object[key]; 29 | if (typeof value === "undefined") { 30 | object[key] = defaults[key]; 31 | } 32 | else if (value !== null && typeof value === "object") { 33 | object[key] = defaultsDeep(value, defaults[key]); 34 | } 35 | } 36 | return object; 37 | } 38 | exports.defaultsDeep = defaultsDeep; 39 | /** 40 | * Finds the first element in an array for that the comaperator functions returns true 41 | * 42 | * @export 43 | * @template T Element type of the array 44 | * @param {Array} array An array 45 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 46 | * @returns {T} The found element or undefined 47 | */ 48 | function findInArray(array, compareFunction) { 49 | if (isNil(array)) 50 | return undefined; 51 | for (var i = 0; i < array.length; i++) { 52 | if (compareFunction(array[i]) === true) { 53 | return array[i]; 54 | } 55 | } 56 | return undefined; 57 | } 58 | exports.findInArray = findInArray; 59 | /** 60 | * Finds the first index in an array for that the comaperator function for an element returns true 61 | * 62 | * @export 63 | * @template T 64 | * @param {Array} array An array of elements 65 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 66 | * @returns {number} Index of the matched element or -1 if no element was found 67 | */ 68 | function findIndexInArray(array, compareFunction) { 69 | if (isNil(array)) 70 | return undefined; 71 | for (var i = 0; i < array.length; i++) { 72 | if (compareFunction(array[i]) === true) { 73 | return i; 74 | } 75 | } 76 | return -1; 77 | } 78 | exports.findIndexInArray = findIndexInArray; 79 | -------------------------------------------------------------------------------- /dist/chartjs_ext.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A chart scale 3 | */ 4 | export interface Scale { 5 | left: number; 6 | right: number; 7 | top: number; 8 | bottom: number; 9 | /** 10 | * Returns the location of the given data point. Value can either be an index or a numerical value 11 | * The coordinate (0, 0) is at the upper-left corner of the canvas 12 | * @param value 13 | * @param index 14 | * @param datasetIndex 15 | */ 16 | getPixelForValue(value: any, index?: number, datasetIndex?: number): number; 17 | /** 18 | * Used to get the data value from a given pixel. This is the inverse of getPixelForValue 19 | * The coordinate (0, 0) is at the upper-left corner of the canvas 20 | * @param pixel 21 | */ 22 | getValueForPixel(pixel: number): any; 23 | } 24 | /** 25 | * A time-based chart scale 26 | */ 27 | export interface TimeScale extends Scale { 28 | /** 29 | * Returns the location of the given data point. Value can either be an index or a numerical value 30 | * The coordinate (0, 0) is at the upper-left corner of the canvas 31 | * @param value 32 | * @param index 33 | * @param datasetIndex 34 | */ 35 | getPixelForValue(value: Date, index?: number, datasetIndex?: number): number; 36 | /** 37 | * Used to get the data value from a given pixel. This is the inverse of getPixelForValue 38 | * The coordinate (0, 0) is at the upper-left corner of the canvas 39 | * @param pixel 40 | */ 41 | getValueForPixel(pixel: number): Date; 42 | } 43 | /** 44 | * Interface for a chart.js plugin 45 | */ 46 | export interface IChartPlugin { 47 | beforeInit?: (chartInstance: Chart) => void; 48 | afterInit?: (chartInstance: Chart) => void; 49 | resize?: (chartInstance: Chart, newChartSize: [number, number]) => void; 50 | beforeUpdate?: (chartInstance: Chart) => void | boolean; 51 | afterScaleUpdate?: (charInstance: Chart) => void; 52 | afterLayout?: (charInstance: Chart) => void; 53 | beforeDatasetsUpdate?: (charInstance: Chart) => void | boolean; 54 | afterDatasetsUpdate?: (charInstance: Chart) => void; 55 | afterUpdate?: (charInstance: Chart) => void; 56 | beforeRender?: (charInstance: Chart) => void | boolean; 57 | beforeDraw?: (charInstance: Chart, easing: string) => void | boolean; 58 | afterDraw?: (charInstance: Chart, easing: string) => void; 59 | beforeDatasetsDraw?: (charInstance: Chart, easing: string) => void | boolean; 60 | afterDatasetsDraw?: (charInstance: Chart, easing: string) => void; 61 | destroy?: (charInstance: Chart) => void; 62 | beforeEvent?: (charInstance: Chart, event: any) => void | boolean; 63 | afterEvent?: (charInstance: Chart, event: any) => void; 64 | } 65 | -------------------------------------------------------------------------------- /src/chartjs_ext.ts: -------------------------------------------------------------------------------- 1 | import { Chart } from 'chart.js'; 2 | 3 | /** 4 | * A chart scale 5 | */ 6 | export interface Scale { 7 | left: number; 8 | right: number; 9 | top: number; 10 | bottom: number; 11 | 12 | /** 13 | * Returns the location of the given data point. Value can either be an index or a numerical value 14 | * The coordinate (0, 0) is at the upper-left corner of the canvas 15 | * @param value 16 | * @param index 17 | * @param datasetIndex 18 | */ 19 | getPixelForValue(value: any, index?: number, datasetIndex?: number): number; 20 | 21 | /** 22 | * Used to get the data value from a given pixel. This is the inverse of getPixelForValue 23 | * The coordinate (0, 0) is at the upper-left corner of the canvas 24 | * @param pixel 25 | */ 26 | getValueForPixel(pixel: number): any; 27 | } 28 | 29 | /** 30 | * A time-based chart scale 31 | */ 32 | export interface TimeScale extends Scale { 33 | /** 34 | * Returns the location of the given data point. Value can either be an index or a numerical value 35 | * The coordinate (0, 0) is at the upper-left corner of the canvas 36 | * @param value 37 | * @param index 38 | * @param datasetIndex 39 | */ 40 | getPixelForValue(value: Date, index?: number, datasetIndex?: number): number; 41 | 42 | /** 43 | * Used to get the data value from a given pixel. This is the inverse of getPixelForValue 44 | * The coordinate (0, 0) is at the upper-left corner of the canvas 45 | * @param pixel 46 | */ 47 | getValueForPixel(pixel: number): Date; 48 | } 49 | 50 | /** 51 | * Interface for a chart.js plugin 52 | */ 53 | export interface IChartPlugin { 54 | beforeInit?: (chartInstance: Chart) => void; 55 | afterInit?: (chartInstance: Chart) => void; 56 | 57 | resize?: (chartInstance: Chart, newChartSize: [number, number]) => void; 58 | 59 | beforeUpdate?: (chartInstance: Chart) => void | boolean; 60 | afterScaleUpdate?: (charInstance: Chart) => void; 61 | afterLayout?: (charInstance: Chart) => void; 62 | beforeDatasetsUpdate?: (charInstance: Chart) => void | boolean; 63 | afterDatasetsUpdate?: (charInstance: Chart) => void; 64 | afterUpdate?: (charInstance: Chart) => void; 65 | 66 | beforeRender?: (charInstance: Chart) => void | boolean; 67 | 68 | beforeDraw?: (charInstance: Chart, easing: string) => void | boolean; 69 | afterDraw?: (charInstance: Chart, easing: string) => void; 70 | 71 | // Before the datasets are drawn but after scales are drawn 72 | beforeDatasetsDraw?: (charInstance: Chart, easing: string) => void | boolean; 73 | afterDatasetsDraw?: (charInstance: Chart, easing: string) => void; 74 | 75 | destroy?: (charInstance: Chart) => void; 76 | 77 | // Called when an event occurs on the chart 78 | beforeEvent?: (charInstance: Chart, event: any) => void | boolean; 79 | afterEvent?: (charInstance: Chart, event: any) => void; 80 | } 81 | -------------------------------------------------------------------------------- /dist/lttb_data_mipmap.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"lttb_data_mipmap.js","sourceRoot":"","sources":["../src/lttb_data_mipmap.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /dist/responsive_downsample_plugin.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"responsive_downsample_plugin.js","sourceRoot":"","sources":["../src/responsive_downsample_plugin.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Responsive Downsampling 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | Displayed Points: 200000 14 |
15 | 16 |
17 |
18 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /dist/data_mipmap.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"data_mipmap.js","sourceRoot":"","sources":["../src/data_mipmap.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /test/unit/lttb_data_mipmap.test.ts: -------------------------------------------------------------------------------- 1 | global.window = {}; 2 | import mocha = require('mocha'); 3 | import chai = require('chai'); 4 | import chai_datatime = require('chai-datetime'); 5 | chai.use(chai_datatime); 6 | const expect = chai.expect; 7 | 8 | import { Chart, ChartPoint } from 'chart.js'; 9 | import { LTTBDataMipmap } from '../../src/lttb_data_mipmap'; 10 | 11 | describe('DataMipMap', function () { 12 | let testData: ChartPoint[]; 13 | let testDataUneven: ChartPoint[]; 14 | 15 | function checkMipMaps(mipmaps: ChartPoint[][]): void { 16 | let expectedLength = mipmaps[0].length; 17 | 18 | mipmaps.forEach((level) => { 19 | expect(level).to.have.length(expectedLength); 20 | expectedLength *= 0.5; 21 | }); 22 | } 23 | 24 | before(function () { 25 | const startTime = Date.parse("2018-01-01T12:00:00.000Z"); 26 | 27 | testData = []; 28 | for (let i = 0; i < 128; ++i) { 29 | // 1 data point per minute 30 | testData.push({ 31 | x: new Date(startTime + i * 60000).toISOString(), 32 | y: i 33 | }); 34 | } 35 | 36 | let lastTimestamp = startTime; 37 | testDataUneven = []; 38 | for (let i = 0; i < 128; ++i) { 39 | const randomOffset = Math.random() * 59 + 1; 40 | lastTimestamp = lastTimestamp + randomOffset * 1000; 41 | 42 | // data points with unequal distribution over time 43 | testDataUneven.push({ 44 | x: new Date(lastTimestamp).toISOString(), 45 | y: i 46 | }); 47 | } 48 | }); 49 | 50 | describe('constructor', function () { 51 | it('should downsample time based diagram data', function () { 52 | const mipMap = new LTTBDataMipmap(testData); 53 | const mipMapLevel = mipMap.getMipMaps(); 54 | 55 | expect(mipMapLevel).to.have.length(7); 56 | checkMipMaps(mipMapLevel); 57 | }); 58 | 59 | it('should downsample time based diagram data with uneven distribution', function () { 60 | const mipMap = new LTTBDataMipmap(testDataUneven); 61 | const mipMapLevel = mipMap.getMipMaps(); 62 | 63 | checkMipMaps(mipMapLevel); 64 | }); 65 | 66 | it('should downsample diagram data with mininmal number of points', function () { 67 | const mipMap = new LTTBDataMipmap(testData, 100); 68 | const mipMapLevel = mipMap.getMipMaps(); 69 | 70 | expect(mipMapLevel).to.have.length(2); 71 | checkMipMaps(mipMapLevel); 72 | }); 73 | 74 | it('should work with data where the x value is stored in t', function () { 75 | const testDataWithT = testData.map((point) => ({ t: point.x, y: point.y })); 76 | const mipMap = new LTTBDataMipmap(testDataWithT, 100); 77 | const mipMapLevel = mipMap.getMipMaps(); 78 | 79 | expect(mipMapLevel).to.have.length(2); 80 | checkMipMaps(mipMapLevel); 81 | }); 82 | 83 | it('should handle an empty dataset', function () { 84 | const mipMap = new LTTBDataMipmap([]); 85 | const mipMapLevel = mipMap.getMipMaps(); 86 | 87 | expect(mipMapLevel).to.have.length(1); 88 | }); 89 | }); 90 | 91 | describe('getMipMapForResolution', function () { 92 | it('should return first level if resolution is null', function () { 93 | const mipMap = new LTTBDataMipmap(testData); 94 | const mipMapLevel = mipMap.getMipMaps(); 95 | 96 | expect(mipMap.getMipMapForResolution(null)).to.equal(mipMapLevel[0]); 97 | }); 98 | 99 | it('should return a level best fitting for desired resolution', function () { 100 | const mipMap = new LTTBDataMipmap(testData); 101 | const mipMapLevel = mipMap.getMipMaps(); 102 | 103 | expect(mipMap.getMipMapForResolution(1)).to.equal(mipMapLevel[0]); 104 | expect(mipMap.getMipMapForResolution(60000)).to.equal(mipMapLevel[0]); 105 | expect(mipMap.getMipMapForResolution(100000)).to.equal(mipMapLevel[1]); 106 | expect(mipMap.getMipMapForResolution(120000)).to.equal(mipMapLevel[1]); 107 | expect(mipMap.getMipMapForResolution(480000)).to.equal(mipMapLevel[3]); 108 | expect(mipMap.getMipMapForResolution(10000000)).to.equal(mipMapLevel[6]); 109 | expect(mipMap.getMipMapForResolution(10000000000)).to.equal(mipMapLevel[6]); 110 | }); 111 | 112 | it('should return an empty level for empty dataset', function () { 113 | const mipMap = new LTTBDataMipmap([]); 114 | const mipMapLevel = mipMap.getMipMaps(); 115 | 116 | expect(mipMap.getMipMapForResolution(1)).to.equal(mipMapLevel[0]); 117 | expect(mipMap.getMipMapForResolution(60000)).to.equal(mipMapLevel[0]); 118 | expect(mipMap.getMipMapForResolution(100000)).to.equal(mipMapLevel[0]); 119 | expect(mipMap.getMipMapForResolution(120000)).to.equal(mipMapLevel[0]); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/data_mipmap.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | import * as utils from './utils'; 3 | 4 | /** 5 | * A mipmap data structure for line chart data. Uses averages to downsample data. 6 | */ 7 | export class DataMipmap { 8 | protected minNumPoints: number; 9 | protected mipMaps: ChartPoint[][]; 10 | protected originalData: ChartPoint[]; 11 | protected resolution: number; 12 | 13 | /** 14 | * Create a data mipmap 15 | * @param data The orignal line chart data 16 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 17 | */ 18 | constructor(data: ChartPoint[], minNumPoints: number = 2) { 19 | this.minNumPoints = minNumPoints; 20 | this.setData(data); 21 | } 22 | 23 | /** 24 | * Set the line chart data and update mipmap level. 25 | * @param data The orignal line chart data 26 | */ 27 | setData(data: ChartPoint[]): void { 28 | this.originalData = data || []; 29 | this.mipMaps = []; 30 | this.resolution = this.computeResolution(this.originalData); 31 | this.createMipMap(); 32 | } 33 | 34 | /** 35 | * Set the minimal number of points 36 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 37 | */ 38 | setMinNumPoints(minNumPoints: number): void { 39 | this.minNumPoints = minNumPoints; 40 | this.mipMaps = []; 41 | this.createMipMap(); 42 | } 43 | 44 | /** 45 | * Get the best fitting mipmap level for a certain scale resolution 46 | * @param resolution Desired resolution in ms per pixel 47 | */ 48 | getMipMapForResolution(resolution: number): ChartPoint[] { 49 | return this.getMipMapLevel(this.getMipMapIndexForResolution(resolution)); 50 | } 51 | 52 | /** 53 | * Computes the index of the best fitting mipmap level for a certain scale resolution 54 | * @param resolution Desired resolution in ms per pixel 55 | */ 56 | getMipMapIndexForResolution(resolution: number): number { 57 | if (utils.isNil(resolution)) { return 0; } 58 | 59 | const factor = resolution / this.resolution; 60 | let level = utils.clamp(Math.floor(Math.log(factor) / Math.log(2.0)), 0, this.mipMaps.length - 1); 61 | 62 | return level; 63 | } 64 | 65 | /** 66 | * Get a mipmap level by index 67 | * @param level The index of the mipmap level 68 | */ 69 | getMipMapLevel(level: number): ChartPoint[] { 70 | return this.mipMaps[level]; 71 | } 72 | 73 | /** 74 | * Get all mipmap level 75 | */ 76 | getMipMaps(): ChartPoint[][] { 77 | return this.mipMaps; 78 | } 79 | 80 | /** 81 | * Get the number of available mipmap level 82 | */ 83 | getNumLevel(): number { 84 | return this.mipMaps.length; 85 | } 86 | 87 | protected computeResolution(data: ChartPoint[]): number { 88 | let minTimeDistance = Infinity; 89 | 90 | for (let i = 0, end = this.originalData.length - 1; i < end; ++i) { 91 | const current = this.originalData[i]; 92 | const next = this.originalData[i + 1]; 93 | 94 | 95 | minTimeDistance = Math.min( 96 | Math.abs(this.getTime(current) - this.getTime(next)), 97 | minTimeDistance 98 | ); 99 | } 100 | 101 | return minTimeDistance; 102 | } 103 | 104 | protected createMipMap(): void { 105 | let targetResolution = this.resolution; 106 | let targetLength = this.originalData.length; 107 | this.mipMaps.push(this.originalData); 108 | let lastMipMap = this.originalData; 109 | 110 | while (lastMipMap.length > this.minNumPoints) { 111 | targetResolution = targetResolution * 2; 112 | targetLength = Math.floor(targetLength * 0.5); 113 | lastMipMap = this.downsampleToResolution(lastMipMap, targetResolution, targetLength); 114 | this.mipMaps.push(lastMipMap); 115 | } 116 | } 117 | 118 | protected downsampleToResolution( 119 | data: ChartPoint[], 120 | targetResolution: number, 121 | targetLength: number 122 | ): ChartPoint[] { 123 | const output: ChartPoint[] = []; 124 | let aggregationValues: ChartPoint[] = []; 125 | let firstPoint = data[0]; 126 | aggregationValues.push(firstPoint); 127 | 128 | for (let i = 1, end = data.length; i < end; ++i) { 129 | const currentPoint = data[i]; 130 | const timeDistance = Math.abs(this.getTime(firstPoint) - this.getTime(currentPoint)); 131 | 132 | if (timeDistance < targetResolution) { 133 | aggregationValues.push(currentPoint); 134 | } else { 135 | // create new sensor value in output 136 | const newPoint = this.getAverage(aggregationValues); 137 | output.push(newPoint); 138 | 139 | // reset aggregation data structure 140 | firstPoint = currentPoint; 141 | aggregationValues = [currentPoint]; 142 | } 143 | } 144 | 145 | // insert last point 146 | output.push(this.getAverage(aggregationValues)); 147 | 148 | return output; 149 | } 150 | 151 | protected getAverage(aggregationValues: ChartPoint[]): ChartPoint { 152 | const value = aggregationValues 153 | .map(point => point.y as number) 154 | .reduce((previous, current) => previous + current) 155 | / aggregationValues.length; 156 | 157 | return { 158 | x: aggregationValues[0].x || aggregationValues[0].t, 159 | y: value 160 | }; 161 | } 162 | 163 | protected getTime(point: ChartPoint): number { 164 | const x = point.x || point.t; 165 | 166 | if (typeof x === "number") { 167 | return x; 168 | } else if (typeof x === "string") { 169 | return new Date(x).getTime(); 170 | } else { 171 | return x.getTime(); 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /src/lttb_data_mipmap.ts: -------------------------------------------------------------------------------- 1 | import { ChartPoint } from 'chart.js'; 2 | import * as utils from './utils'; 3 | import { DataMipmap } from './data_mipmap'; 4 | 5 | /** 6 | * A mipmap data structure that uses Largest-Triangle-Three-Buckets algorithm to downsample data 7 | */ 8 | export class LTTBDataMipmap extends DataMipmap { 9 | protected resolutions: number[]; 10 | 11 | getMipMapIndexForResolution(resolution: number): number { 12 | if (utils.isNil(resolution)) { return 0; } 13 | 14 | let index = utils.findIndexInArray(this.resolutions, (levelResolution) => levelResolution >= resolution); 15 | if (index === -1) { 16 | // use smallest mipmap as fallback 17 | index = this.resolutions.length - 1; 18 | } 19 | 20 | return index; 21 | } 22 | 23 | protected createMipMap(): void { 24 | super.createMipMap(); 25 | this.resolutions = this.mipMaps.map((level) => this.computeAverageResolution(level)); 26 | } 27 | 28 | /** 29 | * This method is adapted from: https://github.com/sveinn-steinarsson/flot-downsample 30 | * 31 | * The MIT License 32 | * Copyright (c) 2013 by Sveinn Steinarsson 33 | * Permission is hereby granted, free of charge, to any person obtaining a copy 34 | * of this software and associated documentation files (the "Software"), to deal 35 | * in the Software without restriction, including without limitation the rights 36 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 37 | * copies of the Software, and to permit persons to whom the Software is 38 | * furnished to do so, subject to the following conditions: 39 | * The above copyright notice and this permission notice shall be included in 40 | * all copies or substantial portions of the Software. 41 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 47 | * THE SOFTWARE. 48 | */ 49 | protected downsampleToResolution( 50 | data: ChartPoint[], 51 | targetResolution: number, 52 | targetLength: number 53 | ) { 54 | const dataLength = data.length; 55 | if (targetLength >= dataLength || targetLength === 0) { 56 | return data; // data has target size 57 | } 58 | 59 | const output: ChartPoint[] = []; 60 | 61 | // bucket size, leave room for start and end data points 62 | const bucksetSize = (dataLength - 2) / (targetLength - 2); 63 | 64 | let a = 0; // initially a is the first point in the triangle 65 | let maxAreaPoint: ChartPoint; 66 | let maxArea: number; 67 | let area: number; 68 | let nextA: number; 69 | 70 | // always add the first point 71 | output.push(data[a]); 72 | 73 | for (let i = 0; i < targetLength - 2; ++i) { 74 | // Calculate point average for next bucket (containing c) 75 | let avgX = 0; 76 | let avgY = 0; 77 | let avgRangeStart = Math.floor((i + 1) * bucksetSize) + 1; 78 | let avgRangeEnd = Math.floor((i + 2) * bucksetSize) + 1; 79 | avgRangeEnd = avgRangeEnd < dataLength ? avgRangeEnd : dataLength; 80 | 81 | const avgRangeLength = avgRangeEnd - avgRangeStart; 82 | 83 | for (; avgRangeStart < avgRangeEnd; avgRangeStart++) { 84 | avgX += this.getTime(data[avgRangeStart]); 85 | avgY += data[avgRangeStart].y as number * 1; 86 | } 87 | avgX /= avgRangeLength; 88 | avgY /= avgRangeLength; 89 | 90 | // Get the range for this bucket 91 | let rangeOffs = Math.floor((i + 0) * bucksetSize) + 1; 92 | const rangeTo = Math.floor((i + 1) * bucksetSize) + 1; 93 | 94 | // Point a 95 | const pointA = data[a]; 96 | const pointAX = this.getTime(pointA); 97 | const pointAY = pointA.y as number * 1; 98 | 99 | maxArea = area = -1; 100 | 101 | for (; rangeOffs < rangeTo; rangeOffs++) { 102 | // Calculate triangle area over three buckets 103 | area = Math.abs((pointAX - avgX) * (data[rangeOffs].y as number - pointAY) - 104 | (pointAX - this.getTime(data[rangeOffs])) * (avgY - pointAY) 105 | ) * 0.5; 106 | 107 | if (area > maxArea) { 108 | maxArea = area; 109 | maxAreaPoint = data[rangeOffs]; 110 | nextA = rangeOffs; // Next a is this b 111 | } 112 | } 113 | 114 | output.push(maxAreaPoint); // Pick this point from the bucket 115 | a = nextA; // This a is the next a (chosen b) 116 | } 117 | 118 | output.push(data[dataLength - 1]); // Always add last 119 | 120 | return output; 121 | } 122 | 123 | protected computeAverageResolution(data: ChartPoint[]): number { 124 | let timeDistances = 0; 125 | 126 | for (let i = 0, end = this.originalData.length - 1; i < end; ++i) { 127 | const current = this.originalData[i]; 128 | const next = this.originalData[i + 1]; 129 | 130 | 131 | timeDistances += Math.abs(this.getTime(current) - this.getTime(next)); 132 | } 133 | 134 | return timeDistances / (data.length - 1); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /dist/data_mipmap.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var utils = require("./utils"); 4 | /** 5 | * A mipmap data structure for line chart data. Uses averages to downsample data. 6 | */ 7 | var DataMipmap = /** @class */ (function () { 8 | /** 9 | * Create a data mipmap 10 | * @param data The orignal line chart data 11 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 12 | */ 13 | function DataMipmap(data, minNumPoints) { 14 | if (minNumPoints === void 0) { minNumPoints = 2; } 15 | this.minNumPoints = minNumPoints; 16 | this.setData(data); 17 | } 18 | /** 19 | * Set the line chart data and update mipmap level. 20 | * @param data The orignal line chart data 21 | */ 22 | DataMipmap.prototype.setData = function (data) { 23 | this.originalData = data || []; 24 | this.mipMaps = []; 25 | this.resolution = this.computeResolution(this.originalData); 26 | this.createMipMap(); 27 | }; 28 | /** 29 | * Set the minimal number of points 30 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 31 | */ 32 | DataMipmap.prototype.setMinNumPoints = function (minNumPoints) { 33 | this.minNumPoints = minNumPoints; 34 | this.mipMaps = []; 35 | this.createMipMap(); 36 | }; 37 | /** 38 | * Get the best fitting mipmap level for a certain scale resolution 39 | * @param resolution Desired resolution in ms per pixel 40 | */ 41 | DataMipmap.prototype.getMipMapForResolution = function (resolution) { 42 | return this.getMipMapLevel(this.getMipMapIndexForResolution(resolution)); 43 | }; 44 | /** 45 | * Computes the index of the best fitting mipmap level for a certain scale resolution 46 | * @param resolution Desired resolution in ms per pixel 47 | */ 48 | DataMipmap.prototype.getMipMapIndexForResolution = function (resolution) { 49 | if (utils.isNil(resolution)) { 50 | return 0; 51 | } 52 | var factor = resolution / this.resolution; 53 | var level = utils.clamp(Math.floor(Math.log(factor) / Math.log(2.0)), 0, this.mipMaps.length - 1); 54 | return level; 55 | }; 56 | /** 57 | * Get a mipmap level by index 58 | * @param level The index of the mipmap level 59 | */ 60 | DataMipmap.prototype.getMipMapLevel = function (level) { 61 | return this.mipMaps[level]; 62 | }; 63 | /** 64 | * Get all mipmap level 65 | */ 66 | DataMipmap.prototype.getMipMaps = function () { 67 | return this.mipMaps; 68 | }; 69 | /** 70 | * Get the number of available mipmap level 71 | */ 72 | DataMipmap.prototype.getNumLevel = function () { 73 | return this.mipMaps.length; 74 | }; 75 | DataMipmap.prototype.computeResolution = function (data) { 76 | var minTimeDistance = Infinity; 77 | for (var i = 0, end = this.originalData.length - 1; i < end; ++i) { 78 | var current = this.originalData[i]; 79 | var next = this.originalData[i + 1]; 80 | minTimeDistance = Math.min(Math.abs(this.getTime(current) - this.getTime(next)), minTimeDistance); 81 | } 82 | return minTimeDistance; 83 | }; 84 | DataMipmap.prototype.createMipMap = function () { 85 | var targetResolution = this.resolution; 86 | var targetLength = this.originalData.length; 87 | this.mipMaps.push(this.originalData); 88 | var lastMipMap = this.originalData; 89 | while (lastMipMap.length > this.minNumPoints) { 90 | targetResolution = targetResolution * 2; 91 | targetLength = Math.floor(targetLength * 0.5); 92 | lastMipMap = this.downsampleToResolution(lastMipMap, targetResolution, targetLength); 93 | this.mipMaps.push(lastMipMap); 94 | } 95 | }; 96 | DataMipmap.prototype.downsampleToResolution = function (data, targetResolution, targetLength) { 97 | var output = []; 98 | var aggregationValues = []; 99 | var firstPoint = data[0]; 100 | aggregationValues.push(firstPoint); 101 | for (var i = 1, end = data.length; i < end; ++i) { 102 | var currentPoint = data[i]; 103 | var timeDistance = Math.abs(this.getTime(firstPoint) - this.getTime(currentPoint)); 104 | if (timeDistance < targetResolution) { 105 | aggregationValues.push(currentPoint); 106 | } 107 | else { 108 | // create new sensor value in output 109 | var newPoint = this.getAverage(aggregationValues); 110 | output.push(newPoint); 111 | // reset aggregation data structure 112 | firstPoint = currentPoint; 113 | aggregationValues = [currentPoint]; 114 | } 115 | } 116 | // insert last point 117 | output.push(this.getAverage(aggregationValues)); 118 | return output; 119 | }; 120 | DataMipmap.prototype.getAverage = function (aggregationValues) { 121 | var value = aggregationValues 122 | .map(function (point) { return point.y; }) 123 | .reduce(function (previous, current) { return previous + current; }) 124 | / aggregationValues.length; 125 | return { 126 | x: aggregationValues[0].x || aggregationValues[0].t, 127 | y: value 128 | }; 129 | }; 130 | DataMipmap.prototype.getTime = function (point) { 131 | var x = point.x || point.t; 132 | if (typeof x === "number") { 133 | return x; 134 | } 135 | else if (typeof x === "string") { 136 | return new Date(x).getTime(); 137 | } 138 | else { 139 | return x.getTime(); 140 | } 141 | }; 142 | return DataMipmap; 143 | }()); 144 | exports.DataMipmap = DataMipmap; 145 | -------------------------------------------------------------------------------- /test/unit/data_culling.test.ts: -------------------------------------------------------------------------------- 1 | global.window = {}; 2 | import mocha = require('mocha'); 3 | import chai = require('chai'); 4 | import chai_datatime = require('chai-datetime'); 5 | chai.use(chai_datatime); 6 | const expect = chai.expect; 7 | 8 | import moment = require('moment'); 9 | import { Chart, ChartPoint } from 'chart.js'; 10 | import * as data_culling from '../../src/data_culling'; 11 | import { MockTimeScale } from '../helper/mock_time_scale'; 12 | 13 | describe('ResponsiveDownsamplePlugin', function () { 14 | let mockTimeScale: MockTimeScale; 15 | let testData: ChartPoint[]; 16 | let dataRange: [Date, Date]; 17 | 18 | before(function () { 19 | mockTimeScale = new MockTimeScale(); 20 | const startTime = Date.parse("2018-01-01T12:00:00.000Z"); 21 | 22 | testData = []; 23 | for (let i = 0; i < 128; ++i) { 24 | // 1 data point per minute 25 | testData.push({ 26 | x: new Date(startTime + i * 60000).toISOString(), 27 | y: i 28 | }); 29 | } 30 | 31 | dataRange = [ 32 | new Date(testData[0].x as string), 33 | new Date(testData[testData.length - 1].x as string) 34 | ]; 35 | }); 36 | 37 | describe('rangeIsEqual', function () { 38 | it('should handle null values', function () { 39 | expect(data_culling.rangeIsEqual(null, null)).to.be.false; 40 | expect(data_culling.rangeIsEqual(null, [0, 1])).to.be.false; 41 | expect(data_culling.rangeIsEqual([0, 1], null)).to.be.false; 42 | expect(data_culling.rangeIsEqual([null, 1], [0, 1])).to.be.false; 43 | expect(data_culling.rangeIsEqual([0, null], [0, 1])).to.be.false; 44 | expect(data_culling.rangeIsEqual([0, 1], [null, 1])).to.be.false; 45 | expect(data_culling.rangeIsEqual([0, 1], [0, null])).to.be.false; 46 | }); 47 | 48 | it('should compare number ranges', function () { 49 | expect(data_culling.rangeIsEqual([0, 1], [0, 1])).to.be.true; 50 | expect(data_culling.rangeIsEqual([0, 1], [2, 1])).to.be.false; 51 | }); 52 | 53 | it('should compare date ranges in string format', function () { 54 | expect(data_culling.rangeIsEqual(['2018-01-01T12:00:00.000Z', '2018-01-02T12:00:00.000Z'], ['2018-01-01T12:00:00.000Z', '2018-01-02T12:00:00.000Z'])).to.be.true; 55 | expect(data_culling.rangeIsEqual(['2018-01-01T12:00:00.000Z', '2018-01-02T12:00:00.000Z'], ['2018-01-01T12:00:00.000Z', '2018-01-01T15:00:00.000Z'])).to.be.false; 56 | }); 57 | 58 | it('should compare date ranges', function () { 59 | const date1 = new Date('2018-01-01T12:00:00.000Z'); 60 | const date2 = new Date('2018-01-02T12:00:00.000Z'); 61 | const date3 = new Date('2018-01-01T15:00:00.000Z'); 62 | expect(data_culling.rangeIsEqual([date1, date2], [date1, date2])).to.be.true; 63 | expect(data_culling.rangeIsEqual([date1, date2], [date1, date3])).to.be.false; 64 | }); 65 | 66 | it('should compare date ranges using moment', function () { 67 | const date1 = moment('2018-01-01T12:00:00.000Z'); 68 | const date2 = moment('2018-01-02T12:00:00.000Z'); 69 | const date3 = moment('2018-01-01T15:00:00.000Z'); 70 | expect(data_culling.rangeIsEqual([date1, date2], [date1, date2])).to.be.true; 71 | expect(data_culling.rangeIsEqual([date1, date2], [date1, date3])).to.be.false; 72 | }); 73 | }) 74 | 75 | describe('getScaleRange', function () { 76 | it('handles missing scales', function () { 77 | expect(data_culling.getScaleRange(null)).to.deep.equal([null, null]); 78 | }); 79 | 80 | it('reads scale range', function () { 81 | expect(data_culling.getScaleRange(mockTimeScale)).to.deep.equal([mockTimeScale.startDate, mockTimeScale.endDate]); 82 | }); 83 | }); 84 | 85 | describe('cullData', function () { 86 | it('returns whole data array if date range is larger', function () { 87 | const culledData = data_culling.cullData(testData, ["2018-01-01T00:00:00.000Z", "2018-01-31T24:00:00.000Z"]); 88 | expect(culledData).to.deep.equal(testData); 89 | }); 90 | 91 | it('should cull data to a date range', function () { 92 | const startDate = new Date("2018-01-01T12:15:00.000Z"); 93 | const endDate = new Date("2018-01-01T12:20:00.000Z"); 94 | const culledData = data_culling.cullData(testData, [startDate, endDate]); 95 | 96 | expect(culledData).to.have.length(6); 97 | culledData.forEach((point) => { 98 | const date = new Date(point.x); 99 | expect(testData).to.include(point); 100 | expect(date >= startDate).to.be.equal(true, `Expected "${date}" to be after or equal to "${startDate}"`); 101 | expect(date <= endDate).to.be.equal(true, `Expected "${date}" to be before or equal to "${endDate}"`); 102 | }); 103 | }); 104 | 105 | it('should work with data where the x value is stored in t', function () { 106 | const testDataWithT = testData.map((point) => ({ t: point.x, y: point.y })); 107 | const startDate = new Date("2018-01-01T12:15:00.000Z"); 108 | const endDate = new Date("2018-01-01T12:20:00.000Z"); 109 | const culledData = data_culling.cullData(testDataWithT, [startDate, endDate]); 110 | 111 | expect(culledData).to.have.length(6); 112 | culledData.forEach((point) => { 113 | const date = new Date(point.t); 114 | expect(testDataWithT).to.include(point); 115 | expect(date >= startDate).to.be.equal(true, `Expected "${date}" to be after or equal to "${startDate}"`); 116 | expect(date <= endDate).to.be.equal(true, `Expected "${date}" to be before or equal to "${endDate}"`); 117 | }); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /test/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import mocha = require('mocha'); 2 | import chai = require('chai'); 3 | const expect = chai.expect; 4 | 5 | import * as util from '../../src/utils'; 6 | 7 | describe('utils', function () { 8 | describe('isNil', function () { 9 | it('should check for null and undefined', function () { 10 | expect(util.isNil(null)).to.be.true; 11 | expect(util.isNil(undefined)).to.be.true; 12 | expect(util.isNil(false)).to.be.false; 13 | expect(util.isNil(true)).to.be.false; 14 | expect(util.isNil(1234)).to.be.false; 15 | expect(util.isNil("Test")).to.be.false; 16 | expect(util.isNil({})).to.be.false; 17 | }); 18 | }); 19 | 20 | describe('clamp', function () { 21 | it('should clamp a value to a numeric range', function () { 22 | expect(util.clamp(42, 0, 100)).to.equal(42); 23 | expect(util.clamp(200, 0, 100)).to.equal(100); 24 | expect(util.clamp(-1, 0, 100)).to.equal(0); 25 | expect(util.clamp(0, 0, 100)).to.equal(0); 26 | expect(util.clamp(100, 0, 100)).to.equal(100); 27 | }); 28 | }); 29 | 30 | describe('findInArray', function () { 31 | it('should return undefined if array is null or undefined', function () { 32 | expect(util.findInArray(null, (number: any) => { return number === 3 })).to.be.undefined; 33 | expect(util.findInArray(undefined, (number: any) => { return (number % 3) === 0 })).to.be.undefined; 34 | }); 35 | 36 | it('should return the first matched element in an array', function () { 37 | var a = [1, 2, 3, 4, 5, 6]; 38 | expect(util.findInArray(a, (number) => { return number === 3 })).to.equal(3); 39 | expect(util.findInArray(a, (number) => { return (number % 3) === 0 })).to.equal(3); 40 | }); 41 | 42 | it('should return undefined if the element is not in the array', function () { 43 | var a = [1, 2, 3, 4, 5, 6]; 44 | expect(util.findInArray(a, (number) => { return number === 2345 })).to.equal(undefined); 45 | }); 46 | }); 47 | 48 | describe('findIndexInArray', function () { 49 | it('should return undefined if array is null or undefined', function () { 50 | expect(util.findIndexInArray(null, (number: any) => { return number === 3 })).to.be.undefined; 51 | expect(util.findIndexInArray(undefined, (number: any) => { return (number % 3) === 0 })).to.be.undefined; 52 | }); 53 | 54 | it('should return the first matched index in an array', function () { 55 | var a = [1, 2, 3, 4, 5, 6]; 56 | expect(util.findIndexInArray(a, (number) => { return number === 3 })).to.equal(2); 57 | expect(util.findIndexInArray(a, (number) => { return (number % 3) === 0 })).to.equal(2); 58 | }); 59 | 60 | it('should return undefined if the element is not in the array', function () { 61 | var a = [1, 2, 3, 4, 5, 6]; 62 | expect(util.findIndexInArray(a, (number) => { return number === 2345 })).to.equal(-1); 63 | }); 64 | 65 | }); 66 | 67 | describe('defaultsDeep', function () { 68 | it('should replace values of object without nesting', function () { 69 | let options: any = { 70 | enabled: true, 71 | test: 1234, 72 | testNull: null 73 | }; 74 | 75 | expect(util.defaultsDeep(options, { 76 | enabled: false, 77 | test: 1, 78 | testNull: "Not Null", 79 | additionalVariable: "A Test string" 80 | })).to.deep.equal({ 81 | enabled: true, 82 | test: 1234, 83 | testNull: null, 84 | additionalVariable: "A Test string" 85 | }); 86 | 87 | expect(options).to.deep.equal({ 88 | enabled: true, 89 | test: 1234, 90 | testNull: null, 91 | additionalVariable: "A Test string" 92 | }); 93 | }); 94 | 95 | it('should replace values of object with nesting', function () { 96 | const timestamp = new Date(); 97 | let options: any = { 98 | enabled: true, 99 | test: 1234, 100 | testNull: null, 101 | someValuesPresent: { 102 | test: timestamp 103 | } 104 | }; 105 | 106 | expect(util.defaultsDeep(options, { 107 | enabled: false, 108 | test: 1, 109 | testNull: "Not Null", 110 | additionalVariable: "A Test string", 111 | someValuesPresent: { 112 | test: new Date(), 113 | test2: 1234, 114 | test3: timestamp 115 | }, 116 | notPresent: { 117 | test: "Test" 118 | } 119 | })).to.deep.equal({ 120 | enabled: true, 121 | test: 1234, 122 | testNull: null, 123 | additionalVariable: "A Test string", 124 | someValuesPresent: { 125 | test: timestamp, 126 | test2: 1234, 127 | test3: timestamp 128 | }, 129 | notPresent: { 130 | test: "Test" 131 | } 132 | }); 133 | 134 | expect(options).to.deep.equal({ 135 | enabled: true, 136 | test: 1234, 137 | testNull: null, 138 | additionalVariable: "A Test string", 139 | someValuesPresent: { 140 | test: timestamp, 141 | test2: 1234, 142 | test3: timestamp 143 | }, 144 | notPresent: { 145 | test: "Test" 146 | } 147 | }); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /dist/lttb_data_mipmap.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __extends = (this && this.__extends) || (function () { 3 | var extendStatics = Object.setPrototypeOf || 4 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || 5 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; 6 | return function (d, b) { 7 | extendStatics(d, b); 8 | function __() { this.constructor = d; } 9 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 10 | }; 11 | })(); 12 | Object.defineProperty(exports, "__esModule", { value: true }); 13 | var utils = require("./utils"); 14 | var data_mipmap_1 = require("./data_mipmap"); 15 | /** 16 | * A mipmap data structure that uses Largest-Triangle-Three-Buckets algorithm to downsample data 17 | */ 18 | var LTTBDataMipmap = /** @class */ (function (_super) { 19 | __extends(LTTBDataMipmap, _super); 20 | function LTTBDataMipmap() { 21 | return _super !== null && _super.apply(this, arguments) || this; 22 | } 23 | LTTBDataMipmap.prototype.getMipMapIndexForResolution = function (resolution) { 24 | if (utils.isNil(resolution)) { 25 | return 0; 26 | } 27 | var index = utils.findIndexInArray(this.resolutions, function (levelResolution) { return levelResolution >= resolution; }); 28 | if (index === -1) { 29 | // use smallest mipmap as fallback 30 | index = this.resolutions.length - 1; 31 | } 32 | return index; 33 | }; 34 | LTTBDataMipmap.prototype.createMipMap = function () { 35 | var _this = this; 36 | _super.prototype.createMipMap.call(this); 37 | this.resolutions = this.mipMaps.map(function (level) { return _this.computeAverageResolution(level); }); 38 | }; 39 | /** 40 | * This method is adapted from: https://github.com/sveinn-steinarsson/flot-downsample 41 | * 42 | * The MIT License 43 | * Copyright (c) 2013 by Sveinn Steinarsson 44 | * Permission is hereby granted, free of charge, to any person obtaining a copy 45 | * of this software and associated documentation files (the "Software"), to deal 46 | * in the Software without restriction, including without limitation the rights 47 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 48 | * copies of the Software, and to permit persons to whom the Software is 49 | * furnished to do so, subject to the following conditions: 50 | * The above copyright notice and this permission notice shall be included in 51 | * all copies or substantial portions of the Software. 52 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 53 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 54 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 55 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 56 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 57 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 58 | * THE SOFTWARE. 59 | */ 60 | LTTBDataMipmap.prototype.downsampleToResolution = function (data, targetResolution, targetLength) { 61 | var dataLength = data.length; 62 | if (targetLength >= dataLength || targetLength === 0) { 63 | return data; // data has target size 64 | } 65 | var output = []; 66 | // bucket size, leave room for start and end data points 67 | var bucksetSize = (dataLength - 2) / (targetLength - 2); 68 | var a = 0; // initially a is the first point in the triangle 69 | var maxAreaPoint; 70 | var maxArea; 71 | var area; 72 | var nextA; 73 | // always add the first point 74 | output.push(data[a]); 75 | for (var i = 0; i < targetLength - 2; ++i) { 76 | // Calculate point average for next bucket (containing c) 77 | var avgX = 0; 78 | var avgY = 0; 79 | var avgRangeStart = Math.floor((i + 1) * bucksetSize) + 1; 80 | var avgRangeEnd = Math.floor((i + 2) * bucksetSize) + 1; 81 | avgRangeEnd = avgRangeEnd < dataLength ? avgRangeEnd : dataLength; 82 | var avgRangeLength = avgRangeEnd - avgRangeStart; 83 | for (; avgRangeStart < avgRangeEnd; avgRangeStart++) { 84 | avgX += this.getTime(data[avgRangeStart]); 85 | avgY += data[avgRangeStart].y * 1; 86 | } 87 | avgX /= avgRangeLength; 88 | avgY /= avgRangeLength; 89 | // Get the range for this bucket 90 | var rangeOffs = Math.floor((i + 0) * bucksetSize) + 1; 91 | var rangeTo = Math.floor((i + 1) * bucksetSize) + 1; 92 | // Point a 93 | var pointA = data[a]; 94 | var pointAX = this.getTime(pointA); 95 | var pointAY = pointA.y * 1; 96 | maxArea = area = -1; 97 | for (; rangeOffs < rangeTo; rangeOffs++) { 98 | // Calculate triangle area over three buckets 99 | area = Math.abs((pointAX - avgX) * (data[rangeOffs].y - pointAY) - 100 | (pointAX - this.getTime(data[rangeOffs])) * (avgY - pointAY)) * 0.5; 101 | if (area > maxArea) { 102 | maxArea = area; 103 | maxAreaPoint = data[rangeOffs]; 104 | nextA = rangeOffs; // Next a is this b 105 | } 106 | } 107 | output.push(maxAreaPoint); // Pick this point from the bucket 108 | a = nextA; // This a is the next a (chosen b) 109 | } 110 | output.push(data[dataLength - 1]); // Always add last 111 | return output; 112 | }; 113 | LTTBDataMipmap.prototype.computeAverageResolution = function (data) { 114 | var timeDistances = 0; 115 | for (var i = 0, end = this.originalData.length - 1; i < end; ++i) { 116 | var current = this.originalData[i]; 117 | var next = this.originalData[i + 1]; 118 | timeDistances += Math.abs(this.getTime(current) - this.getTime(next)); 119 | } 120 | return timeDistances / (data.length - 1); 121 | }; 122 | return LTTBDataMipmap; 123 | }(data_mipmap_1.DataMipmap)); 124 | exports.LTTBDataMipmap = LTTBDataMipmap; 125 | -------------------------------------------------------------------------------- /dist/responsive_downsample_plugin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var moment_module = require("moment"); 4 | var moment = (window && window.moment) ? window.moment : moment_module; 5 | var utils = require("./utils"); 6 | var data_mipmap_1 = require("./data_mipmap"); 7 | var lttb_data_mipmap_1 = require("./lttb_data_mipmap"); 8 | var data_culling = require("./data_culling"); 9 | /** 10 | * Chart js Plugin for downsampling data 11 | */ 12 | var ResponsiveDownsamplePlugin = /** @class */ (function () { 13 | function ResponsiveDownsamplePlugin() { 14 | } 15 | ResponsiveDownsamplePlugin.getPluginOptions = function (chart) { 16 | var options = chart.options.responsiveDownsample || {}; 17 | utils.defaultsDeep(options, { 18 | enabled: false, 19 | aggregationAlgorithm: 'LTTB', 20 | desiredDataPointDistance: 1, 21 | minNumPoints: 100, 22 | cullData: true 23 | }); 24 | if (options.enabled) { 25 | chart.options.responsiveDownsample = options; 26 | } 27 | return options; 28 | }; 29 | ResponsiveDownsamplePlugin.hasDataChanged = function (chart) { 30 | return !utils.isNil(utils.findInArray(chart.data.datasets, function (dataset) { 31 | return utils.isNil(dataset.mipMap); 32 | })); 33 | }; 34 | ResponsiveDownsamplePlugin.createDataMipMap = function (chart, options) { 35 | chart.data.datasets.forEach(function (dataset, i) { 36 | var data = !utils.isNil(dataset.originalData) 37 | ? dataset.originalData 38 | : dataset.data; 39 | var mipMap = (options.aggregationAlgorithm === 'LTTB') 40 | ? new lttb_data_mipmap_1.LTTBDataMipmap(data, options.minNumPoints) 41 | : new data_mipmap_1.DataMipmap(data, options.minNumPoints); 42 | dataset.originalData = data; 43 | dataset.mipMap = mipMap; 44 | dataset.data = mipMap.getMipMapLevel(mipMap.getNumLevel() - 1); // set last level for first render pass 45 | }); 46 | }; 47 | ResponsiveDownsamplePlugin.restoreOriginalData = function (chart) { 48 | var updated = false; 49 | chart.data.datasets.forEach(function (dataset) { 50 | if (!utils.isNil(dataset.originalData) && 51 | dataset.data !== dataset.originalData) { 52 | dataset.data = dataset.originalData; 53 | updated = true; 54 | } 55 | }); 56 | return updated; 57 | }; 58 | ResponsiveDownsamplePlugin.getTargetResolution = function (chart, options) { 59 | var xScale = chart.scales["x-axis-0"]; 60 | if (utils.isNil(xScale)) 61 | return null; 62 | var start = moment(xScale.getValueForPixel(xScale.left)); 63 | var end = moment(xScale.getValueForPixel(xScale.left + 1)); 64 | var targetResolution = end.diff(start); 65 | return targetResolution * options.desiredDataPointDistance; 66 | }; 67 | ResponsiveDownsamplePlugin.updateMipMap = function (chart, options, rangeChanged) { 68 | var updated = false; 69 | chart.data.datasets.forEach(function (dataset, i) { 70 | var mipMap = dataset.mipMap; 71 | if (utils.isNil(mipMap)) 72 | return; 73 | var mipMalLevel = mipMap.getMipMapIndexForResolution(options.targetResolution); 74 | if (mipMalLevel === dataset.currentMipMapLevel && !rangeChanged) { 75 | // skip update if mip map level and data range did not change 76 | return; 77 | } 78 | updated = true; 79 | dataset.currentMipMapLevel = mipMalLevel; 80 | var newData = mipMap.getMipMapLevel(mipMalLevel); 81 | if (options.cullData) { 82 | newData = data_culling.cullData(newData, options.scaleRange); 83 | } 84 | dataset.data = newData; 85 | }); 86 | return updated; 87 | }; 88 | ResponsiveDownsamplePlugin.prototype.beforeInit = function (chart) { 89 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 90 | if (!options.enabled) { 91 | return; 92 | } 93 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 94 | options.needsUpdate = true; 95 | }; 96 | ResponsiveDownsamplePlugin.prototype.beforeDatasetsUpdate = function (chart) { 97 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 98 | if (!options.enabled) { 99 | // restore original data and remove state from options 100 | options.needsUpdate = ResponsiveDownsamplePlugin.restoreOriginalData(chart); 101 | delete options.targetResolution; 102 | delete options.scaleRange; 103 | return; 104 | } 105 | // only update mip map if data set was reloaded externally 106 | if (ResponsiveDownsamplePlugin.hasDataChanged(chart)) { 107 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 108 | options.needsUpdate = true; 109 | } 110 | }; 111 | ResponsiveDownsamplePlugin.prototype.beforeRender = function (chart) { 112 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 113 | if (!options.enabled) { 114 | // update chart if data was restored from original data 115 | if (options.needsUpdate) { 116 | options.needsUpdate = false; 117 | chart.update(0); 118 | return false; 119 | } 120 | return; 121 | } 122 | var targetResolution = ResponsiveDownsamplePlugin.getTargetResolution(chart, options); 123 | var xScale = chart.scales["x-axis-0"]; 124 | var scaleRange = data_culling.getScaleRange(xScale); 125 | var rangeChanged = !data_culling.rangeIsEqual(options.scaleRange, scaleRange); 126 | if (options.needsUpdate || 127 | options.targetResolution !== targetResolution || 128 | rangeChanged) { 129 | options.targetResolution = targetResolution; 130 | options.scaleRange = scaleRange; 131 | options.needsUpdate = false; 132 | if (ResponsiveDownsamplePlugin.updateMipMap(chart, options, rangeChanged)) { 133 | // update chart and cancel current render 134 | chart.update(0); 135 | return false; 136 | } 137 | } 138 | }; 139 | return ResponsiveDownsamplePlugin; 140 | }()); 141 | exports.ResponsiveDownsamplePlugin = ResponsiveDownsamplePlugin; 142 | -------------------------------------------------------------------------------- /test/unit/data_mipmap.test.ts: -------------------------------------------------------------------------------- 1 | global.window = {}; 2 | import mocha = require('mocha'); 3 | import chai = require('chai'); 4 | import chai_datatime = require('chai-datetime'); 5 | chai.use(chai_datatime); 6 | const expect = chai.expect; 7 | 8 | import { Chart, ChartPoint } from 'chart.js'; 9 | import { DataMipmap } from '../../src/data_mipmap'; 10 | 11 | describe('DataMipMap', function () { 12 | let testData: ChartPoint[]; 13 | let testDataUneven: ChartPoint[]; 14 | 15 | function checkMipMaps(mipmaps: ChartPoint[][], startResolution: number): void { 16 | let resolution = startResolution; 17 | 18 | mipmaps.forEach((level) => { 19 | let lastPoint = level[0]; 20 | for (let i = 1; i < level.length; ++i) { 21 | expect(new Date(level[i].x || level[i].t as string).getTime() - new Date(lastPoint.x || lastPoint.t as string).getTime()) 22 | .to.be.gte(resolution); 23 | lastPoint = level[i]; 24 | } 25 | resolution *= 2; 26 | }); 27 | } 28 | 29 | before(function () { 30 | const startTime = Date.parse("2018-01-01T12:00:00.000Z"); 31 | 32 | testData = []; 33 | for (let i = 0; i < 128; ++i) { 34 | // 1 data point per minute 35 | testData.push({ 36 | x: new Date(startTime + i * 60000).toISOString(), 37 | y: i 38 | }); 39 | } 40 | 41 | let lastTimestamp = startTime; 42 | testDataUneven = []; 43 | for (let i = 0; i < 128; ++i) { 44 | const randomOffset = Math.floor(Math.random() * 59) + 1; 45 | lastTimestamp = lastTimestamp + randomOffset * 1000; 46 | 47 | // data points with unequal distribution over time 48 | testDataUneven.push({ 49 | x: new Date(lastTimestamp).toISOString(), 50 | y: i 51 | }); 52 | } 53 | }); 54 | 55 | describe('constructor', function () { 56 | it('should downsample time based diagram data', function () { 57 | const mipMap = new DataMipmap(testData); 58 | const mipMapLevel = mipMap.getMipMaps(); 59 | 60 | expect(mipMapLevel).to.have.length(7); 61 | checkMipMaps(mipMapLevel, 60000); 62 | }); 63 | 64 | it('should downsample time based diagram data with uneven distribution', function () { 65 | const mipMap = new DataMipmap(testDataUneven); 66 | const mipMapLevel = mipMap.getMipMaps(); 67 | 68 | checkMipMaps(mipMapLevel, 1000); 69 | }); 70 | 71 | it('should downsample diagram data with mininmal number of points', function () { 72 | const mipMap = new DataMipmap(testData, 100); 73 | const mipMapLevel = mipMap.getMipMaps(); 74 | 75 | expect(mipMapLevel).to.have.length(2); 76 | checkMipMaps(mipMapLevel, 60000); 77 | }); 78 | 79 | it('should work with data where the x value is stored in t', function () { 80 | const testDataWithT = testData.map((point) => ({ t: point.x, y: point.y })); 81 | const mipMap = new DataMipmap(testDataWithT, 100); 82 | const mipMapLevel = mipMap.getMipMaps(); 83 | 84 | expect(mipMapLevel).to.have.length(2); 85 | checkMipMaps(mipMapLevel, 60000); 86 | }); 87 | 88 | it('should handle an empty dataset', function () { 89 | const mipMap = new DataMipmap([]); 90 | const mipMapLevel = mipMap.getMipMaps(); 91 | 92 | expect(mipMapLevel).to.have.length(1); 93 | }); 94 | 95 | it('should handle null and undefined dataset', function () { 96 | let mipMap = new DataMipmap(null); 97 | expect(mipMap.getMipMaps()).to.have.length(1); 98 | 99 | mipMap = new DataMipmap(undefined); 100 | expect(mipMap.getMipMaps()).to.have.length(1); 101 | }); 102 | }); 103 | 104 | describe('setData', function () { 105 | it('should update mip map level', function () { 106 | const mipMap = new DataMipmap(testData); 107 | let mipMapLevel = mipMap.getMipMaps(); 108 | expect(mipMapLevel).to.have.length(7); 109 | 110 | mipMap.setData(testDataUneven); 111 | expect(mipMap.getMipMaps()).to.not.equal(mipMapLevel); 112 | 113 | mipMapLevel = mipMapLevel = mipMap.getMipMaps(); 114 | checkMipMaps(mipMapLevel, 1000); 115 | }); 116 | }); 117 | 118 | describe('setMinNumPoints', function () { 119 | it('should update min num points', function () { 120 | const mipMap = new DataMipmap(testData); 121 | let mipMapLevel = mipMap.getMipMaps(); 122 | expect(mipMapLevel).to.have.length(7); 123 | 124 | mipMap.setMinNumPoints(100); 125 | expect(mipMap.getMipMaps()).to.not.equal(mipMapLevel); 126 | mipMapLevel = mipMapLevel = mipMap.getMipMaps(); 127 | expect(mipMapLevel).to.have.length(2); 128 | checkMipMaps(mipMapLevel, 1000); 129 | }); 130 | }); 131 | 132 | describe('getMipMapForResolution', function () { 133 | it('should return first level if resolution is null', function () { 134 | const mipMap = new DataMipmap(testData); 135 | const mipMapLevel = mipMap.getMipMaps(); 136 | 137 | expect(mipMap.getMipMapForResolution(null)).to.equal(mipMapLevel[0]); 138 | }); 139 | 140 | it('should return a level best fitting for desired resolution', function () { 141 | const mipMap = new DataMipmap(testData); 142 | const mipMapLevel = mipMap.getMipMaps(); 143 | 144 | expect(mipMap.getMipMapForResolution(1)).to.equal(mipMapLevel[0]); 145 | expect(mipMap.getMipMapForResolution(60000)).to.equal(mipMapLevel[0]); 146 | expect(mipMap.getMipMapForResolution(100000)).to.equal(mipMapLevel[0]); 147 | expect(mipMap.getMipMapForResolution(120000)).to.equal(mipMapLevel[1]); 148 | expect(mipMap.getMipMapForResolution(480000)).to.equal(mipMapLevel[3]); 149 | expect(mipMap.getMipMapForResolution(10000000)).to.equal(mipMapLevel[6]); 150 | expect(mipMap.getMipMapForResolution(10000000000)).to.equal(mipMapLevel[6]); 151 | }); 152 | 153 | it('should return an empty level for empty dataset', function () { 154 | const mipMap = new DataMipmap([]); 155 | const mipMapLevel = mipMap.getMipMaps(); 156 | 157 | expect(mipMap.getMipMapForResolution(1)).to.equal(mipMapLevel[0]); 158 | expect(mipMap.getMipMapForResolution(60000)).to.equal(mipMapLevel[0]); 159 | expect(mipMap.getMipMapForResolution(100000)).to.equal(mipMapLevel[0]); 160 | expect(mipMap.getMipMapForResolution(120000)).to.equal(mipMapLevel[0]); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/responsive_downsample_plugin.ts: -------------------------------------------------------------------------------- 1 | import moment_module = require('moment'); 2 | const moment = (window && (window as any).moment) ? (window as any).moment : moment_module; 3 | import { Chart, ChartData, ChartDataSets, ChartPoint } from 'chart.js'; 4 | import { IChartPlugin, TimeScale } from './chartjs_ext'; 5 | import * as utils from './utils'; 6 | 7 | import { DataMipmap } from './data_mipmap'; 8 | import { LTTBDataMipmap } from './lttb_data_mipmap'; 9 | import * as data_culling from './data_culling'; 10 | 11 | interface MipMapDataSets extends ChartDataSets { 12 | originalData?: ChartPoint[]; 13 | currentMipMapLevel?: number; 14 | mipMap?: DataMipmap; 15 | } 16 | 17 | export interface ResponsiveDownsamplePluginOptions { 18 | /** 19 | * Enable/disable plugin 20 | */ 21 | enabled?: boolean; 22 | /** 23 | * The aggregation algorithm to thin out data. Default: LTTB 24 | */ 25 | aggregationAlgorithm?: 'AVG' | 'LTTB'; 26 | /** 27 | * Desired mininmal distance between data points in pixels. Default: 1 pixel 28 | */ 29 | desiredDataPointDistance?: number; 30 | /** 31 | * Minimal number of data points. Limits 32 | */ 33 | minNumPoints?: number; 34 | /** 35 | * Cull data to displayed range of x scale 36 | */ 37 | cullData?: boolean; 38 | /** 39 | * Flag is set by plugin to trigger reload of data 40 | */ 41 | needsUpdate?: boolean; 42 | /** 43 | * Current target resolution(Set by plugin) 44 | */ 45 | targetResolution?: number; 46 | /** 47 | * Scale range of x axis 48 | */ 49 | scaleRange?: data_culling.Range; 50 | } 51 | 52 | /** 53 | * Chart js Plugin for downsampling data 54 | */ 55 | export class ResponsiveDownsamplePlugin implements IChartPlugin { 56 | static getPluginOptions(chart: any): ResponsiveDownsamplePluginOptions { 57 | let options: ResponsiveDownsamplePluginOptions = chart.options.responsiveDownsample || {}; 58 | utils.defaultsDeep(options, { 59 | enabled: false, 60 | aggregationAlgorithm: 'LTTB', 61 | desiredDataPointDistance: 1, 62 | minNumPoints: 100, 63 | cullData: true 64 | }); 65 | 66 | if (options.enabled) { 67 | chart.options.responsiveDownsample = options; 68 | } 69 | 70 | return options; 71 | } 72 | 73 | static hasDataChanged(chart: Chart): boolean { 74 | return !utils.isNil( 75 | utils.findInArray( 76 | chart.data.datasets as MipMapDataSets[], 77 | (dataset) => { 78 | return utils.isNil(dataset.mipMap) 79 | } 80 | ) 81 | ); 82 | } 83 | 84 | static createDataMipMap(chart: Chart, options: ResponsiveDownsamplePluginOptions): void { 85 | chart.data.datasets.forEach((dataset: MipMapDataSets, i) => { 86 | const data = !utils.isNil(dataset.originalData) 87 | ? dataset.originalData 88 | : dataset.data as ChartPoint[]; 89 | 90 | const mipMap = (options.aggregationAlgorithm === 'LTTB') 91 | ? new LTTBDataMipmap(data, options.minNumPoints) 92 | : new DataMipmap(data, options.minNumPoints); 93 | 94 | dataset.originalData = data; 95 | dataset.mipMap = mipMap; 96 | dataset.data = mipMap.getMipMapLevel(mipMap.getNumLevel() - 1); // set last level for first render pass 97 | }); 98 | } 99 | 100 | static restoreOriginalData(chart: Chart): boolean { 101 | let updated = false; 102 | 103 | chart.data.datasets.forEach((dataset: MipMapDataSets) => { 104 | if ( 105 | !utils.isNil(dataset.originalData) && 106 | dataset.data !== dataset.originalData 107 | ) { 108 | dataset.data = dataset.originalData; 109 | updated = true; 110 | } 111 | }); 112 | 113 | return updated; 114 | } 115 | 116 | static getTargetResolution(chart: Chart, options: ResponsiveDownsamplePluginOptions): number { 117 | const xScale: TimeScale = (chart as any).scales["x-axis-0"]; 118 | 119 | if (utils.isNil(xScale)) return null; 120 | 121 | let start = moment(xScale.getValueForPixel(xScale.left) as any); 122 | let end = moment(xScale.getValueForPixel(xScale.left + 1) as any); 123 | const targetResolution = end.diff(start); 124 | 125 | return targetResolution * options.desiredDataPointDistance; 126 | } 127 | 128 | static updateMipMap(chart: Chart, options: ResponsiveDownsamplePluginOptions, rangeChanged: boolean): boolean { 129 | let updated = false; 130 | 131 | chart.data.datasets.forEach((dataset: MipMapDataSets, i) => { 132 | const mipMap = dataset.mipMap; 133 | if (utils.isNil(mipMap)) return; 134 | 135 | let mipMalLevel = mipMap.getMipMapIndexForResolution(options.targetResolution); 136 | if (mipMalLevel === dataset.currentMipMapLevel && !rangeChanged) { 137 | // skip update if mip map level and data range did not change 138 | return; 139 | } 140 | updated = true; 141 | dataset.currentMipMapLevel = mipMalLevel; 142 | 143 | let newData = mipMap.getMipMapLevel(mipMalLevel); 144 | if (options.cullData) { 145 | newData = data_culling.cullData(newData, options.scaleRange) 146 | } 147 | 148 | dataset.data = newData; 149 | }); 150 | 151 | return updated; 152 | } 153 | 154 | beforeInit(chart: Chart): void { 155 | const options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 156 | if (!options.enabled) { return; } 157 | 158 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 159 | options.needsUpdate = true; 160 | } 161 | 162 | beforeDatasetsUpdate(chart: Chart): void { 163 | const options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 164 | if (!options.enabled) { 165 | // restore original data and remove state from options 166 | options.needsUpdate = ResponsiveDownsamplePlugin.restoreOriginalData(chart); 167 | delete options.targetResolution; 168 | delete options.scaleRange; 169 | return; 170 | } 171 | 172 | // only update mip map if data set was reloaded externally 173 | if (ResponsiveDownsamplePlugin.hasDataChanged(chart)) { 174 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 175 | options.needsUpdate = true; 176 | } 177 | } 178 | 179 | beforeRender(chart: Chart): boolean { 180 | const options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 181 | if (!options.enabled) { 182 | // update chart if data was restored from original data 183 | if (options.needsUpdate) { 184 | options.needsUpdate = false; 185 | chart.update(0); 186 | 187 | return false; 188 | } 189 | return; 190 | } 191 | 192 | const targetResolution = ResponsiveDownsamplePlugin.getTargetResolution(chart, options); 193 | const xScale: TimeScale = (chart as any).scales["x-axis-0"]; 194 | const scaleRange = data_culling.getScaleRange(xScale); 195 | const rangeChanged = !data_culling.rangeIsEqual(options.scaleRange, scaleRange); 196 | 197 | if (options.needsUpdate || 198 | options.targetResolution !== targetResolution || 199 | rangeChanged 200 | ) { 201 | options.targetResolution = targetResolution; 202 | options.scaleRange = scaleRange; 203 | options.needsUpdate = false; 204 | 205 | if (ResponsiveDownsamplePlugin.updateMipMap(chart, options, rangeChanged)) { 206 | // update chart and cancel current render 207 | chart.update(0); 208 | 209 | return false; 210 | } 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /dist/chartjs-plugin-responsive-downsample.bundle.min.js: -------------------------------------------------------------------------------- 1 | !function r(o,s,u){function p(e,t){if(!s[e]){if(!o[e]){var i="function"==typeof require&&require;if(!t&&i)return i(e,!0);if(l)return l(e,!0);var n=new Error("Cannot find module '"+e+"'");throw n.code="MODULE_NOT_FOUND",n}var a=s[e]={exports:{}};o[e][0].call(a.exports,function(t){return p(o[e][1][t]||t)},a,a.exports,r,o,s,u)}return s[e].exports}for(var l="function"==typeof require&&require,t=0;tthis.minNumPoints;)t*=2,e=Math.floor(.5*e),i=this.downsampleToResolution(i,t,e),this.mipMaps.push(i)},r.prototype.downsampleToResolution=function(t,e,i){var n=[],a=[],r=t[0];a.push(r);for(var o=1,s=t.length;o { 16 | return new Promise((resolve, reject) => { 17 | setTimeout(resolve, duration); 18 | }); 19 | } 20 | 21 | let mockTimeScale: MockTimeScale; 22 | let testData: ChartPoint[]; 23 | let mockChart: Chart; 24 | beforeEach(function () { 25 | mockTimeScale = new MockTimeScale(); 26 | const startTime = Date.parse("2018-01-01T12:00:00.000Z"); 27 | 28 | testData = []; 29 | for (let i = 0; i < 128; ++i) { 30 | // 1 data point per minute 31 | testData.push({ 32 | x: new Date(startTime + i * 60000).toISOString(), 33 | y: i 34 | }); 35 | } 36 | 37 | mockChart = { 38 | options: { 39 | responsiveDownsample: { 40 | enabled: true 41 | } 42 | }, 43 | data: { 44 | datasets: [ 45 | { 46 | data: testData 47 | } 48 | ] 49 | }, 50 | scales: { 51 | "x-axis-0": mockTimeScale 52 | }, 53 | update: () => { } 54 | } as any; 55 | }); 56 | 57 | describe('getPluginOptions', function () { 58 | it('should be disabled if plugin options are not set', function () { 59 | const options = ResponsiveDownsamplePlugin.getPluginOptions({ 60 | options: {} 61 | }); 62 | 63 | expect(options.enabled).to.be.false; 64 | }); 65 | 66 | it('should use default options', function () { 67 | const options = ResponsiveDownsamplePlugin.getPluginOptions({ 68 | options: { 69 | responsiveDownsample: { 70 | enabled: true 71 | } 72 | } 73 | }); 74 | 75 | expect(options).to.deep.equal({ 76 | enabled: true, 77 | aggregationAlgorithm: 'LTTB', 78 | desiredDataPointDistance: 1, 79 | minNumPoints: 100, 80 | cullData: true 81 | }); 82 | }); 83 | 84 | it('should override default options', function () { 85 | const options = ResponsiveDownsamplePlugin.getPluginOptions({ 86 | options: { 87 | responsiveDownsample: { 88 | enabled: true, 89 | aggregationAlgorithm: 'AVG', 90 | desiredDataPointDistance: 5, 91 | minNumPoints: 2, 92 | cullData: false 93 | } 94 | } 95 | }); 96 | 97 | expect(options).to.deep.equal({ 98 | enabled: true, 99 | aggregationAlgorithm: 'AVG', 100 | desiredDataPointDistance: 5, 101 | minNumPoints: 2, 102 | cullData: false 103 | }); 104 | }); 105 | }); 106 | 107 | describe('hasDataChanged', function () { 108 | it('should return true if data set was not intialized', function () { 109 | expect(ResponsiveDownsamplePlugin.hasDataChanged(mockChart)).to.be.true; 110 | }); 111 | 112 | it('should return false if data set was initialized', function () { 113 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 114 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 115 | expect(ResponsiveDownsamplePlugin.hasDataChanged(mockChart)).to.be.false; 116 | }); 117 | 118 | it('should return true if a new data set was added', function () { 119 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 120 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 121 | 122 | mockChart.data.datasets.push({ 123 | data: [ 124 | { x: '2018-01-02T12:00:00.000Z', y: 400 }, 125 | { x: '2018-01-02T13:00:00.000Z', y: 300 }, 126 | { x: '2018-01-02T13:30:00.000Z', y: 200 }, 127 | { x: '2018-01-02T14:15:00.000Z', y: 100 } 128 | ] 129 | }); 130 | 131 | expect(ResponsiveDownsamplePlugin.hasDataChanged(mockChart)).to.be.true; 132 | }); 133 | 134 | it('should return true if data was replaced', function () { 135 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 136 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 137 | 138 | mockChart.data.datasets[0] = { 139 | data: [ 140 | { x: '2018-01-02T12:00:00.000Z', y: 400 }, 141 | { x: '2018-01-02T13:00:00.000Z', y: 300 }, 142 | { x: '2018-01-02T13:30:00.000Z', y: 200 }, 143 | { x: '2018-01-02T14:15:00.000Z', y: 100 } 144 | ] 145 | }; 146 | 147 | expect(ResponsiveDownsamplePlugin.hasDataChanged(mockChart)).to.be.true; 148 | }); 149 | }); 150 | 151 | describe('createDataMipMap', function () { 152 | it('should create mip map data', function () { 153 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 154 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 155 | 156 | expect(mockChart.data.datasets[0]).to.have.property('originalData', testData); 157 | expect(mockChart.data.datasets[0]) 158 | .to.have.property('mipMap') 159 | .that.is.instanceof(LTTBDataMipmap); 160 | 161 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 162 | expect(mipmap.getMipMaps()).to.have.length(2); 163 | }); 164 | 165 | it('should create mip map data using options', function () { 166 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 167 | options.aggregationAlgorithm = 'AVG'; 168 | options.minNumPoints = 2; 169 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 170 | 171 | expect(mockChart.data.datasets[0]).to.have.property('originalData', testData); 172 | expect(mockChart.data.datasets[0]) 173 | .to.have.property('mipMap') 174 | .that.is.instanceof(DataMipmap) 175 | .and.not.instanceof(LTTBDataMipmap); 176 | 177 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 178 | expect(mipmap.getMipMaps()).to.have.length(7); 179 | }); 180 | 181 | it('should handle empty dataset', function () { 182 | mockChart.data.datasets[0] = { 183 | data: [] 184 | }; 185 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 186 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 187 | 188 | expect(mockChart.data.datasets[0]).to.have.property('originalData').that.is.empty; 189 | expect(mockChart.data.datasets[0]) 190 | .to.have.property('mipMap') 191 | .that.is.instanceof(LTTBDataMipmap); 192 | 193 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 194 | expect(mipmap.getMipMaps()).to.have.length(1); 195 | expect(mipmap.getMipMaps()[0]).to.have.length(0); 196 | }); 197 | 198 | it('should handle undefined dataset', function () { 199 | mockChart.data.datasets[0] = { 200 | data: undefined 201 | }; 202 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 203 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 204 | 205 | expect(mockChart.data.datasets[0]).to.have.property('originalData').that.is.undefined; 206 | expect(mockChart.data.datasets[0]) 207 | .to.have.property('mipMap') 208 | .that.is.instanceof(LTTBDataMipmap); 209 | 210 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 211 | expect(mipmap.getMipMaps()).to.have.length(1); 212 | expect(mipmap.getMipMaps()[0]).to.have.length(0); 213 | }); 214 | }); 215 | 216 | describe('getTargetResolution', function () { 217 | it('should compute a target resolution depending on the time scale', function () { 218 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 219 | expect( 220 | ResponsiveDownsamplePlugin.getTargetResolution(mockChart, options) 221 | ).to.equal(864000); 222 | }); 223 | 224 | it('should consider desiredDataPointDistance option', function () { 225 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 226 | options.desiredDataPointDistance = 10; 227 | expect( 228 | ResponsiveDownsamplePlugin.getTargetResolution(mockChart, options) 229 | ).to.equal(8640000); 230 | }); 231 | 232 | it('should handle a missig scale', function () { 233 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 234 | delete mockChart['scales']['x-axis-0']; 235 | expect( 236 | ResponsiveDownsamplePlugin.getTargetResolution(mockChart, options) 237 | ).to.be.null; 238 | }); 239 | }); 240 | 241 | describe('updateMipMap', function () { 242 | it('should update mip map level', function () { 243 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 244 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 245 | options.targetResolution = 864000; 246 | options.scaleRange = [testData[0].x, testData[testData.length - 1].x]; 247 | ResponsiveDownsamplePlugin.updateMipMap(mockChart, options, false); 248 | 249 | return waitFor(101).then(() => { 250 | expect(mockChart.data.datasets[0].data).to.not.equal(mockChart.data.datasets[0]['originalData']); 251 | 252 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 253 | expect(mockChart.data.datasets[0].data).to.deep.equal(mipmap.getMipMapLevel(1)); 254 | }); 255 | }); 256 | 257 | it('should allow to disable data culling', function () { 258 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 259 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 260 | options.cullData = false; 261 | options.targetResolution = 864000; 262 | options.scaleRange = [testData[0].x, testData[testData.length - 1].x]; 263 | ResponsiveDownsamplePlugin.updateMipMap(mockChart, options, false); 264 | 265 | return waitFor(101).then(() => { 266 | expect(mockChart.data.datasets[0].data).to.not.equal(mockChart.data.datasets[0]['originalData']); 267 | 268 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 269 | expect(mockChart.data.datasets[0].data).to.deep.equal(mipmap.getMipMapLevel(1)); 270 | }); 271 | }); 272 | 273 | it('should skip update if mip map level and data range did no change', function () { 274 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 275 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 276 | options.cullData = false; 277 | options.targetResolution = 864000; 278 | options.scaleRange = [testData[0].x, testData[testData.length - 1].x]; 279 | mockChart.data.datasets[0]['currentMipMapLevel'] = 1; 280 | ResponsiveDownsamplePlugin.updateMipMap(mockChart, options, false); 281 | 282 | return waitFor(101).then(() => { 283 | expect(mockChart.data.datasets[0].data).to.not.equal(mockChart.data.datasets[0]['originalData']); 284 | 285 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 286 | expect(mockChart.data.datasets[0].data).to.deep.equal(mipmap.getMipMapLevel(1)); 287 | }); 288 | }); 289 | 290 | it('should skip update if mip map data structure is missing', function () { 291 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 292 | ResponsiveDownsamplePlugin.createDataMipMap(mockChart, options); 293 | options.cullData = false; 294 | options.targetResolution = 864000; 295 | options.scaleRange = [testData[0].x, testData[testData.length - 1].x]; 296 | delete mockChart.data.datasets[0]['mipMap']; 297 | ResponsiveDownsamplePlugin.updateMipMap(mockChart, options, false); 298 | 299 | return waitFor(101).then(() => { 300 | expect(mockChart.data.datasets[0].data).to.not.equal(mockChart.data.datasets[0]['originalData']); 301 | }); 302 | }); 303 | }); 304 | 305 | describe('beforeInit', function () { 306 | let plugin = new ResponsiveDownsamplePlugin(); 307 | 308 | it('should do nothing when disabled', function () { 309 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 310 | options.enabled = false; 311 | 312 | plugin.beforeInit(mockChart); 313 | expect(mockChart.data.datasets[0]).to.not.have.property('originalData'); 314 | expect(mockChart.data.datasets[0]).to.not.have.property('mipMap'); 315 | }); 316 | 317 | it('should initialize plugin data structures', function () { 318 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 319 | 320 | plugin.beforeInit(mockChart); 321 | expect(mockChart.data.datasets[0]).to.have.property('originalData'); 322 | expect(mockChart.data.datasets[0]).to.have.property('mipMap'); 323 | expect(options.needsUpdate).to.be.true; 324 | }); 325 | }); 326 | 327 | describe('beforeDatasetsUpdate', function () { 328 | let plugin: ResponsiveDownsamplePlugin; 329 | beforeEach(function () { 330 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 331 | plugin = new ResponsiveDownsamplePlugin(); 332 | plugin.beforeInit(mockChart); 333 | options.needsUpdate = false; 334 | }); 335 | 336 | it('should restore original data if plugin is disabled', function () { 337 | plugin.beforeDatasetsUpdate(mockChart); 338 | expect(mockChart.data.datasets[0]).to.have.property('originalData'); 339 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 340 | options.enabled = false; 341 | 342 | plugin.beforeDatasetsUpdate(mockChart); 343 | expect(options.needsUpdate).to.be.true; 344 | expect(mockChart.data.datasets[0]) 345 | .to.have.property('originalData') 346 | .that.is.equal(mockChart.data.datasets[0].data); 347 | 348 | // only restore data once to prevent an infinite update loop 349 | options.needsUpdate = false; 350 | options.targetResolution = 5.0; 351 | options.scaleRange = [0, 1]; 352 | plugin.beforeDatasetsUpdate(mockChart); 353 | expect(options.needsUpdate).to.be.false; 354 | expect(options).to.not.have.property('targetResolution'); 355 | expect(options).to.not.have.property('scaleRange'); 356 | }); 357 | 358 | it('should do nothing when data has not changed', function () { 359 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 360 | plugin.beforeDatasetsUpdate(mockChart); 361 | expect(options.needsUpdate).to.be.false; 362 | }); 363 | 364 | it('should update new data set', function () { 365 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 366 | mockChart.data.datasets.push({ 367 | data: [ 368 | { x: '2018-01-02T12:00:00.000Z', y: 400 }, 369 | { x: '2018-01-02T13:00:00.000Z', y: 300 }, 370 | { x: '2018-01-02T13:30:00.000Z', y: 200 }, 371 | { x: '2018-01-02T14:15:00.000Z', y: 100 } 372 | ] 373 | }); 374 | 375 | plugin.beforeDatasetsUpdate(mockChart); 376 | 377 | expect(mockChart.data.datasets[1]).to.have.property('originalData'); 378 | expect(mockChart.data.datasets[1]).to.have.property('mipMap'); 379 | expect(options.needsUpdate).to.be.true; 380 | }); 381 | }); 382 | 383 | describe('beforeRender', function () { 384 | let plugin: ResponsiveDownsamplePlugin; 385 | beforeEach(function () { 386 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 387 | plugin = new ResponsiveDownsamplePlugin(); 388 | plugin.beforeInit(mockChart); 389 | }); 390 | 391 | it('should update selected mipmap on initial draw', function () { 392 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 393 | expect(plugin.beforeRender(mockChart)).to.be.false; 394 | 395 | return waitFor(101).then(() => { 396 | expect(options.needsUpdate).to.be.false; 397 | expect(options.targetResolution).to.equal(864000); 398 | 399 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 400 | expect(mockChart.data.datasets[0]) 401 | .to.have.property('data') 402 | .that.deep.equals(mipmap.getMipMapLevel(1)); 403 | }); 404 | }); 405 | 406 | it('should update selected mipmap when time scale changes', function () { 407 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 408 | expect(plugin.beforeRender(mockChart)).to.be.false; 409 | 410 | return waitFor(101).then(() => { 411 | mockTimeScale.right = 10000; 412 | plugin.beforeRender(mockChart); 413 | 414 | return waitFor(101); 415 | }).then(() => { 416 | expect(options.needsUpdate).to.be.false; 417 | expect(options.targetResolution).to.equal(8640); 418 | 419 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 420 | expect(mockChart.data.datasets[0]) 421 | .to.have.property('data') 422 | .that.deep.equals(mipmap.getMipMapLevel(0)); 423 | }); 424 | }); 425 | 426 | it('should not update selected mipmap if resolution does not change', function () { 427 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 428 | expect(plugin.beforeRender(mockChart)).to.be.false; 429 | 430 | return waitFor(101).then(() => { 431 | expect(plugin.beforeRender(mockChart)).to.be.undefined; 432 | 433 | return waitFor(101); 434 | }).then(() => { 435 | expect(options.needsUpdate).to.be.false; 436 | expect(options.targetResolution).to.equal(864000); 437 | 438 | const mipmap: DataMipmap = mockChart.data.datasets[0]['mipMap']; 439 | expect(mockChart.data.datasets[0]) 440 | .to.have.property('data') 441 | .that.deep.equals(mipmap.getMipMapLevel(1)); 442 | }); 443 | }); 444 | 445 | it('should skip rendering and update chart if orignal data was restored', function () { 446 | const options = ResponsiveDownsamplePlugin.getPluginOptions(mockChart); 447 | plugin.beforeDatasetsUpdate(mockChart); 448 | options.enabled = false; 449 | options.needsUpdate = true; 450 | plugin.beforeDatasetsUpdate(mockChart); 451 | expect(plugin.beforeRender(mockChart)).to.be.false; 452 | 453 | return waitFor(101).then(() => { 454 | expect(options.needsUpdate).to.be.false; 455 | expect(mockChart.data.datasets[0]) 456 | .to.have.property('originalData') 457 | .that.is.equal(mockChart.data.datasets[0].data); 458 | 459 | expect(plugin.beforeRender(mockChart)).to.be.undefined; 460 | }); 461 | }); 462 | }); 463 | }); 464 | 465 | -------------------------------------------------------------------------------- /dist/chartjs-plugin-responsive-downsample.bundle.js: -------------------------------------------------------------------------------- 1 | (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= endValue) { 61 | endIndex = i + 1; 62 | break; 63 | } 64 | } 65 | return data.slice(startIndex, endIndex); 66 | } 67 | exports.cullData = cullData; 68 | 69 | },{"./utils":7,"moment":1}],3:[function(require,module,exports){ 70 | "use strict"; 71 | Object.defineProperty(exports, "__esModule", { value: true }); 72 | var utils = require("./utils"); 73 | /** 74 | * A mipmap data structure for line chart data. Uses averages to downsample data. 75 | */ 76 | var DataMipmap = /** @class */ (function () { 77 | /** 78 | * Create a data mipmap 79 | * @param data The orignal line chart data 80 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 81 | */ 82 | function DataMipmap(data, minNumPoints) { 83 | if (minNumPoints === void 0) { minNumPoints = 2; } 84 | this.minNumPoints = minNumPoints; 85 | this.setData(data); 86 | } 87 | /** 88 | * Set the line chart data and update mipmap level. 89 | * @param data The orignal line chart data 90 | */ 91 | DataMipmap.prototype.setData = function (data) { 92 | this.originalData = data || []; 93 | this.mipMaps = []; 94 | this.resolution = this.computeResolution(this.originalData); 95 | this.createMipMap(); 96 | }; 97 | /** 98 | * Set the minimal number of points 99 | * @param minNumPoints Minimal number of points on lowest mipmap level(limits number of levels) 100 | */ 101 | DataMipmap.prototype.setMinNumPoints = function (minNumPoints) { 102 | this.minNumPoints = minNumPoints; 103 | this.mipMaps = []; 104 | this.createMipMap(); 105 | }; 106 | /** 107 | * Get the best fitting mipmap level for a certain scale resolution 108 | * @param resolution Desired resolution in ms per pixel 109 | */ 110 | DataMipmap.prototype.getMipMapForResolution = function (resolution) { 111 | return this.getMipMapLevel(this.getMipMapIndexForResolution(resolution)); 112 | }; 113 | /** 114 | * Computes the index of the best fitting mipmap level for a certain scale resolution 115 | * @param resolution Desired resolution in ms per pixel 116 | */ 117 | DataMipmap.prototype.getMipMapIndexForResolution = function (resolution) { 118 | if (utils.isNil(resolution)) { 119 | return 0; 120 | } 121 | var factor = resolution / this.resolution; 122 | var level = utils.clamp(Math.floor(Math.log(factor) / Math.log(2.0)), 0, this.mipMaps.length - 1); 123 | return level; 124 | }; 125 | /** 126 | * Get a mipmap level by index 127 | * @param level The index of the mipmap level 128 | */ 129 | DataMipmap.prototype.getMipMapLevel = function (level) { 130 | return this.mipMaps[level]; 131 | }; 132 | /** 133 | * Get all mipmap level 134 | */ 135 | DataMipmap.prototype.getMipMaps = function () { 136 | return this.mipMaps; 137 | }; 138 | /** 139 | * Get the number of available mipmap level 140 | */ 141 | DataMipmap.prototype.getNumLevel = function () { 142 | return this.mipMaps.length; 143 | }; 144 | DataMipmap.prototype.computeResolution = function (data) { 145 | var minTimeDistance = Infinity; 146 | for (var i = 0, end = this.originalData.length - 1; i < end; ++i) { 147 | var current = this.originalData[i]; 148 | var next = this.originalData[i + 1]; 149 | minTimeDistance = Math.min(Math.abs(this.getTime(current) - this.getTime(next)), minTimeDistance); 150 | } 151 | return minTimeDistance; 152 | }; 153 | DataMipmap.prototype.createMipMap = function () { 154 | var targetResolution = this.resolution; 155 | var targetLength = this.originalData.length; 156 | this.mipMaps.push(this.originalData); 157 | var lastMipMap = this.originalData; 158 | while (lastMipMap.length > this.minNumPoints) { 159 | targetResolution = targetResolution * 2; 160 | targetLength = Math.floor(targetLength * 0.5); 161 | lastMipMap = this.downsampleToResolution(lastMipMap, targetResolution, targetLength); 162 | this.mipMaps.push(lastMipMap); 163 | } 164 | }; 165 | DataMipmap.prototype.downsampleToResolution = function (data, targetResolution, targetLength) { 166 | var output = []; 167 | var aggregationValues = []; 168 | var firstPoint = data[0]; 169 | aggregationValues.push(firstPoint); 170 | for (var i = 1, end = data.length; i < end; ++i) { 171 | var currentPoint = data[i]; 172 | var timeDistance = Math.abs(this.getTime(firstPoint) - this.getTime(currentPoint)); 173 | if (timeDistance < targetResolution) { 174 | aggregationValues.push(currentPoint); 175 | } 176 | else { 177 | // create new sensor value in output 178 | var newPoint = this.getAverage(aggregationValues); 179 | output.push(newPoint); 180 | // reset aggregation data structure 181 | firstPoint = currentPoint; 182 | aggregationValues = [currentPoint]; 183 | } 184 | } 185 | // insert last point 186 | output.push(this.getAverage(aggregationValues)); 187 | return output; 188 | }; 189 | DataMipmap.prototype.getAverage = function (aggregationValues) { 190 | var value = aggregationValues 191 | .map(function (point) { return point.y; }) 192 | .reduce(function (previous, current) { return previous + current; }) 193 | / aggregationValues.length; 194 | return { 195 | x: aggregationValues[0].x || aggregationValues[0].t, 196 | y: value 197 | }; 198 | }; 199 | DataMipmap.prototype.getTime = function (point) { 200 | var x = point.x || point.t; 201 | if (typeof x === "number") { 202 | return x; 203 | } 204 | else if (typeof x === "string") { 205 | return new Date(x).getTime(); 206 | } 207 | else { 208 | return x.getTime(); 209 | } 210 | }; 211 | return DataMipmap; 212 | }()); 213 | exports.DataMipmap = DataMipmap; 214 | 215 | },{"./utils":7}],4:[function(require,module,exports){ 216 | "use strict"; 217 | Object.defineProperty(exports, "__esModule", { value: true }); 218 | var chartjs = require("chart.js"); 219 | var Chart = window && window.Chart ? window.Chart : chartjs.Chart; 220 | var responsive_downsample_plugin_1 = require("./responsive_downsample_plugin"); 221 | var responsive_downsample_plugin_2 = require("./responsive_downsample_plugin"); 222 | exports.ResponsiveDownsamplePlugin = responsive_downsample_plugin_2.ResponsiveDownsamplePlugin; 223 | Chart.pluginService.register(new responsive_downsample_plugin_1.ResponsiveDownsamplePlugin()); 224 | 225 | },{"./responsive_downsample_plugin":6,"chart.js":1}],5:[function(require,module,exports){ 226 | "use strict"; 227 | var __extends = (this && this.__extends) || (function () { 228 | var extendStatics = Object.setPrototypeOf || 229 | ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || 230 | function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; 231 | return function (d, b) { 232 | extendStatics(d, b); 233 | function __() { this.constructor = d; } 234 | d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 235 | }; 236 | })(); 237 | Object.defineProperty(exports, "__esModule", { value: true }); 238 | var utils = require("./utils"); 239 | var data_mipmap_1 = require("./data_mipmap"); 240 | /** 241 | * A mipmap data structure that uses Largest-Triangle-Three-Buckets algorithm to downsample data 242 | */ 243 | var LTTBDataMipmap = /** @class */ (function (_super) { 244 | __extends(LTTBDataMipmap, _super); 245 | function LTTBDataMipmap() { 246 | return _super !== null && _super.apply(this, arguments) || this; 247 | } 248 | LTTBDataMipmap.prototype.getMipMapIndexForResolution = function (resolution) { 249 | if (utils.isNil(resolution)) { 250 | return 0; 251 | } 252 | var index = utils.findIndexInArray(this.resolutions, function (levelResolution) { return levelResolution >= resolution; }); 253 | if (index === -1) { 254 | // use smallest mipmap as fallback 255 | index = this.resolutions.length - 1; 256 | } 257 | return index; 258 | }; 259 | LTTBDataMipmap.prototype.createMipMap = function () { 260 | var _this = this; 261 | _super.prototype.createMipMap.call(this); 262 | this.resolutions = this.mipMaps.map(function (level) { return _this.computeAverageResolution(level); }); 263 | }; 264 | /** 265 | * This method is adapted from: https://github.com/sveinn-steinarsson/flot-downsample 266 | * 267 | * The MIT License 268 | * Copyright (c) 2013 by Sveinn Steinarsson 269 | * Permission is hereby granted, free of charge, to any person obtaining a copy 270 | * of this software and associated documentation files (the "Software"), to deal 271 | * in the Software without restriction, including without limitation the rights 272 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 273 | * copies of the Software, and to permit persons to whom the Software is 274 | * furnished to do so, subject to the following conditions: 275 | * The above copyright notice and this permission notice shall be included in 276 | * all copies or substantial portions of the Software. 277 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 278 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 279 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 280 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 281 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 282 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 283 | * THE SOFTWARE. 284 | */ 285 | LTTBDataMipmap.prototype.downsampleToResolution = function (data, targetResolution, targetLength) { 286 | var dataLength = data.length; 287 | if (targetLength >= dataLength || targetLength === 0) { 288 | return data; // data has target size 289 | } 290 | var output = []; 291 | // bucket size, leave room for start and end data points 292 | var bucksetSize = (dataLength - 2) / (targetLength - 2); 293 | var a = 0; // initially a is the first point in the triangle 294 | var maxAreaPoint; 295 | var maxArea; 296 | var area; 297 | var nextA; 298 | // always add the first point 299 | output.push(data[a]); 300 | for (var i = 0; i < targetLength - 2; ++i) { 301 | // Calculate point average for next bucket (containing c) 302 | var avgX = 0; 303 | var avgY = 0; 304 | var avgRangeStart = Math.floor((i + 1) * bucksetSize) + 1; 305 | var avgRangeEnd = Math.floor((i + 2) * bucksetSize) + 1; 306 | avgRangeEnd = avgRangeEnd < dataLength ? avgRangeEnd : dataLength; 307 | var avgRangeLength = avgRangeEnd - avgRangeStart; 308 | for (; avgRangeStart < avgRangeEnd; avgRangeStart++) { 309 | avgX += this.getTime(data[avgRangeStart]); 310 | avgY += data[avgRangeStart].y * 1; 311 | } 312 | avgX /= avgRangeLength; 313 | avgY /= avgRangeLength; 314 | // Get the range for this bucket 315 | var rangeOffs = Math.floor((i + 0) * bucksetSize) + 1; 316 | var rangeTo = Math.floor((i + 1) * bucksetSize) + 1; 317 | // Point a 318 | var pointA = data[a]; 319 | var pointAX = this.getTime(pointA); 320 | var pointAY = pointA.y * 1; 321 | maxArea = area = -1; 322 | for (; rangeOffs < rangeTo; rangeOffs++) { 323 | // Calculate triangle area over three buckets 324 | area = Math.abs((pointAX - avgX) * (data[rangeOffs].y - pointAY) - 325 | (pointAX - this.getTime(data[rangeOffs])) * (avgY - pointAY)) * 0.5; 326 | if (area > maxArea) { 327 | maxArea = area; 328 | maxAreaPoint = data[rangeOffs]; 329 | nextA = rangeOffs; // Next a is this b 330 | } 331 | } 332 | output.push(maxAreaPoint); // Pick this point from the bucket 333 | a = nextA; // This a is the next a (chosen b) 334 | } 335 | output.push(data[dataLength - 1]); // Always add last 336 | return output; 337 | }; 338 | LTTBDataMipmap.prototype.computeAverageResolution = function (data) { 339 | var timeDistances = 0; 340 | for (var i = 0, end = this.originalData.length - 1; i < end; ++i) { 341 | var current = this.originalData[i]; 342 | var next = this.originalData[i + 1]; 343 | timeDistances += Math.abs(this.getTime(current) - this.getTime(next)); 344 | } 345 | return timeDistances / (data.length - 1); 346 | }; 347 | return LTTBDataMipmap; 348 | }(data_mipmap_1.DataMipmap)); 349 | exports.LTTBDataMipmap = LTTBDataMipmap; 350 | 351 | },{"./data_mipmap":3,"./utils":7}],6:[function(require,module,exports){ 352 | "use strict"; 353 | Object.defineProperty(exports, "__esModule", { value: true }); 354 | var moment_module = require("moment"); 355 | var moment = (window && window.moment) ? window.moment : moment_module; 356 | var utils = require("./utils"); 357 | var data_mipmap_1 = require("./data_mipmap"); 358 | var lttb_data_mipmap_1 = require("./lttb_data_mipmap"); 359 | var data_culling = require("./data_culling"); 360 | /** 361 | * Chart js Plugin for downsampling data 362 | */ 363 | var ResponsiveDownsamplePlugin = /** @class */ (function () { 364 | function ResponsiveDownsamplePlugin() { 365 | } 366 | ResponsiveDownsamplePlugin.getPluginOptions = function (chart) { 367 | var options = chart.options.responsiveDownsample || {}; 368 | utils.defaultsDeep(options, { 369 | enabled: false, 370 | aggregationAlgorithm: 'LTTB', 371 | desiredDataPointDistance: 1, 372 | minNumPoints: 100, 373 | cullData: true 374 | }); 375 | if (options.enabled) { 376 | chart.options.responsiveDownsample = options; 377 | } 378 | return options; 379 | }; 380 | ResponsiveDownsamplePlugin.hasDataChanged = function (chart) { 381 | return !utils.isNil(utils.findInArray(chart.data.datasets, function (dataset) { 382 | return utils.isNil(dataset.mipMap); 383 | })); 384 | }; 385 | ResponsiveDownsamplePlugin.createDataMipMap = function (chart, options) { 386 | chart.data.datasets.forEach(function (dataset, i) { 387 | var data = !utils.isNil(dataset.originalData) 388 | ? dataset.originalData 389 | : dataset.data; 390 | var mipMap = (options.aggregationAlgorithm === 'LTTB') 391 | ? new lttb_data_mipmap_1.LTTBDataMipmap(data, options.minNumPoints) 392 | : new data_mipmap_1.DataMipmap(data, options.minNumPoints); 393 | dataset.originalData = data; 394 | dataset.mipMap = mipMap; 395 | dataset.data = mipMap.getMipMapLevel(mipMap.getNumLevel() - 1); // set last level for first render pass 396 | }); 397 | }; 398 | ResponsiveDownsamplePlugin.restoreOriginalData = function (chart) { 399 | var updated = false; 400 | chart.data.datasets.forEach(function (dataset) { 401 | if (!utils.isNil(dataset.originalData) && 402 | dataset.data !== dataset.originalData) { 403 | dataset.data = dataset.originalData; 404 | updated = true; 405 | } 406 | }); 407 | return updated; 408 | }; 409 | ResponsiveDownsamplePlugin.getTargetResolution = function (chart, options) { 410 | var xScale = chart.scales["x-axis-0"]; 411 | if (utils.isNil(xScale)) 412 | return null; 413 | // start and end are already moment object in chartjs 414 | var start = xScale.getValueForPixel(xScale.left); 415 | var end = xScale.getValueForPixel(xScale.left + 1); 416 | var targetResolution = end.diff(start); 417 | return targetResolution * options.desiredDataPointDistance; 418 | }; 419 | ResponsiveDownsamplePlugin.updateMipMap = function (chart, options, rangeChanged) { 420 | var updated = false; 421 | chart.data.datasets.forEach(function (dataset, i) { 422 | var mipMap = dataset.mipMap; 423 | if (utils.isNil(mipMap)) 424 | return; 425 | var mipMalLevel = mipMap.getMipMapIndexForResolution(options.targetResolution); 426 | if (mipMalLevel === dataset.currentMipMapLevel && !rangeChanged) { 427 | // skip update if mip map level and data range did not change 428 | return; 429 | } 430 | updated = true; 431 | dataset.currentMipMapLevel = mipMalLevel; 432 | var newData = mipMap.getMipMapLevel(mipMalLevel); 433 | if (options.cullData) { 434 | newData = data_culling.cullData(newData, options.scaleRange); 435 | } 436 | dataset.data = newData; 437 | }); 438 | return updated; 439 | }; 440 | ResponsiveDownsamplePlugin.prototype.beforeInit = function (chart) { 441 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 442 | if (!options.enabled) { 443 | return; 444 | } 445 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 446 | options.needsUpdate = true; 447 | }; 448 | ResponsiveDownsamplePlugin.prototype.beforeDatasetsUpdate = function (chart) { 449 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 450 | if (!options.enabled) { 451 | // restore original data and remove state from options 452 | options.needsUpdate = ResponsiveDownsamplePlugin.restoreOriginalData(chart); 453 | delete options.targetResolution; 454 | delete options.scaleRange; 455 | return; 456 | } 457 | // only update mip map if data set was reloaded externally 458 | if (ResponsiveDownsamplePlugin.hasDataChanged(chart)) { 459 | ResponsiveDownsamplePlugin.createDataMipMap(chart, options); 460 | options.needsUpdate = true; 461 | } 462 | }; 463 | ResponsiveDownsamplePlugin.prototype.beforeRender = function (chart) { 464 | var options = ResponsiveDownsamplePlugin.getPluginOptions(chart); 465 | if (!options.enabled) { 466 | // update chart if data was restored from original data 467 | if (options.needsUpdate) { 468 | options.needsUpdate = false; 469 | chart.update(0); 470 | return false; 471 | } 472 | return; 473 | } 474 | var targetResolution = ResponsiveDownsamplePlugin.getTargetResolution(chart, options); 475 | var xScale = chart.scales["x-axis-0"]; 476 | var scaleRange = data_culling.getScaleRange(xScale); 477 | var rangeChanged = !data_culling.rangeIsEqual(options.scaleRange, scaleRange); 478 | if (options.needsUpdate || 479 | options.targetResolution !== targetResolution || 480 | rangeChanged) { 481 | options.targetResolution = targetResolution; 482 | options.scaleRange = scaleRange; 483 | options.needsUpdate = false; 484 | if (ResponsiveDownsamplePlugin.updateMipMap(chart, options, rangeChanged)) { 485 | // update chart and cancel current render 486 | chart.update(0); 487 | return false; 488 | } 489 | } 490 | }; 491 | return ResponsiveDownsamplePlugin; 492 | }()); 493 | exports.ResponsiveDownsamplePlugin = ResponsiveDownsamplePlugin; 494 | 495 | },{"./data_culling":2,"./data_mipmap":3,"./lttb_data_mipmap":5,"./utils":7,"moment":1}],7:[function(require,module,exports){ 496 | "use strict"; 497 | Object.defineProperty(exports, "__esModule", { value: true }); 498 | /** 499 | * Check if a value is null or undefined 500 | */ 501 | function isNil(value) { 502 | return (typeof value === "undefined") || value === null; 503 | } 504 | exports.isNil = isNil; 505 | /** 506 | * Clamp a number to a range 507 | * @param value 508 | * @param min 509 | * @param max 510 | */ 511 | function clamp(value, min, max) { 512 | return Math.min(Math.max(value, min), max); 513 | } 514 | exports.clamp = clamp; 515 | /** 516 | * Recursivly assign default values to an object if object is missing the keys. 517 | * @param object The destination object to assign default values to 518 | * @param defaults The default values for the object 519 | * @return The destination object 520 | */ 521 | function defaultsDeep(object, defaults) { 522 | for (var key in defaults) { 523 | var value = object[key]; 524 | if (typeof value === "undefined") { 525 | object[key] = defaults[key]; 526 | } 527 | else if (value !== null && typeof value === "object") { 528 | object[key] = defaultsDeep(value, defaults[key]); 529 | } 530 | } 531 | return object; 532 | } 533 | exports.defaultsDeep = defaultsDeep; 534 | /** 535 | * Finds the first element in an array for that the comaperator functions returns true 536 | * 537 | * @export 538 | * @template T Element type of the array 539 | * @param {Array} array An array 540 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 541 | * @returns {T} The found element or undefined 542 | */ 543 | function findInArray(array, compareFunction) { 544 | if (isNil(array)) 545 | return undefined; 546 | for (var i = 0; i < array.length; i++) { 547 | if (compareFunction(array[i]) === true) { 548 | return array[i]; 549 | } 550 | } 551 | return undefined; 552 | } 553 | exports.findInArray = findInArray; 554 | /** 555 | * Finds the first index in an array for that the comaperator function for an element returns true 556 | * 557 | * @export 558 | * @template T 559 | * @param {Array} array An array of elements 560 | * @param {(element: T) => boolean} compareFunction Comperator function returning true for the element seeked 561 | * @returns {number} Index of the matched element or -1 if no element was found 562 | */ 563 | function findIndexInArray(array, compareFunction) { 564 | if (isNil(array)) 565 | return undefined; 566 | for (var i = 0; i < array.length; i++) { 567 | if (compareFunction(array[i]) === true) { 568 | return i; 569 | } 570 | } 571 | return -1; 572 | } 573 | exports.findIndexInArray = findIndexInArray; 574 | 575 | },{}]},{},[4]); 576 | --------------------------------------------------------------------------------