├── .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 | [](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 |
--------------------------------------------------------------------------------