├── .gitignore ├── .editorconfig ├── bower.json ├── examples ├── example1.html ├── server-load.html ├── responsive.html └── server-load.js ├── package.json ├── LICENSE.txt ├── docs ├── example-final.html ├── index.html └── tutorial.html ├── README.md ├── smoothie.d.ts └── smoothie.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.d.ts] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smoothie", 3 | "main": "smoothie.js", 4 | "homepage": "http://smoothiecharts.org", 5 | "authors": [ 6 | { "name": "Joe Walnes", "homepage": "https://joewalnes.com" }, 7 | { "name": "Drew Noakes", "homepage": "https://drewnoakes.com" } 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:joewalnes/smoothie.git" 12 | }, 13 | "description": "Smooooooth JavaScript charts for realtime streaming data", 14 | "moduleType": [ 15 | "amd" 16 | ], 17 | "keywords": [ 18 | "charts", 19 | "charting", 20 | "canvas" 21 | ], 22 | "license": "MIT", 23 | "ignore": [ 24 | "**/.*", 25 | "node_modules", 26 | "bower_components", 27 | "docs", 28 | "examples", 29 | "builder" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /examples/example1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smoothie", 3 | "version": "1.36.1", 4 | "description": "Smoothie Charts: smooooooth JavaScript charts for realtime streaming data", 5 | "main": "./smoothie.js", 6 | "types": "./smoothie.d.ts", 7 | "directories": { 8 | "doc": "docs", 9 | "example": "examples", 10 | "builder": "builder" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/joewalnes/smoothie.git" 18 | }, 19 | "keywords": [ 20 | "charts", 21 | "charting", 22 | "realtime", 23 | "stock-ticker", 24 | "time series", 25 | "time-series", 26 | "responsive" 27 | ], 28 | "bugs": "https://github.com/joewalnes/smoothie/issues", 29 | "author": { 30 | "name": "Joe Walnes", 31 | "url": "https://joewalnes.com/" 32 | }, 33 | "contributors": [ 34 | { 35 | "name": "Drew Noakes", 36 | "url": "https://drewnoakes.com/" 37 | } 38 | ], 39 | "license": "MIT" 40 | } 41 | -------------------------------------------------------------------------------- /examples/server-load.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 21 | 22 | 23 | 24 | 25 |

host1.example.com

26 | 27 | 28 |

host2.example.com

29 | 30 | 31 |

host3.example.com

32 | 33 | 34 |

host4.example.com

35 | 36 | 37 | This is fake data. 38 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | ----------- 3 | 4 | Copyright (c) 2010-2013, Joe Walnes 5 | 2013-2018, Drew Noakes 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /docs/example-final.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Smoothie Chart Example 5 | 6 | 7 | 8 | 9 |

Smoothie Example

10 | 11 | 12 | 13 | 29 | 30 |

Return to tutorial

31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/responsive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 28 | 29 | 30 | 31 |

Non-responsive chart

32 | 33 | 34 |

Responsive chart

35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/server-load.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | initHost('host1'); 3 | initHost('host2'); 4 | initHost('host3'); 5 | initHost('host4'); 6 | } 7 | 8 | var seriesOptions = [ 9 | { strokeStyle: 'rgba(255, 0, 0, 1)', fillStyle: 'rgba(255, 0, 0, 0.1)', lineWidth: 3 }, 10 | { strokeStyle: 'rgba(0, 255, 0, 1)', fillStyle: 'rgba(0, 255, 0, 0.1)', lineWidth: 3 }, 11 | { strokeStyle: 'rgba(0, 0, 255, 1)', fillStyle: 'rgba(0, 0, 255, 0.1)', lineWidth: 3 }, 12 | { strokeStyle: 'rgba(255, 255, 0, 1)', fillStyle: 'rgba(255, 255, 0, 0.1)', lineWidth: 3 } 13 | ]; 14 | 15 | function initHost(hostId) { 16 | 17 | // Initialize an empty TimeSeries for each CPU. 18 | var cpuDataSets = [new TimeSeries(), new TimeSeries(), new TimeSeries(), new TimeSeries()]; 19 | 20 | var now = Date.now(); 21 | for (var t = now - 1000 * 50; t <= now; t += 1000) { 22 | addRandomValueToDataSets(t, cpuDataSets); 23 | } 24 | // Every second, simulate a new set of readings being taken from each CPU. 25 | setInterval(function() { 26 | addRandomValueToDataSets(Date.now(), cpuDataSets); 27 | }, 1000); 28 | 29 | // Build the timeline 30 | var timeline = new SmoothieChart({ fps: 30, millisPerPixel: 20, grid: { strokeStyle: '#555555', lineWidth: 1, millisPerLine: 1000, verticalSections: 4}, tooltip: true}); 31 | for (var i = 0; i < cpuDataSets.length; i++) { 32 | timeline.addTimeSeries(cpuDataSets[i], seriesOptions[i]); 33 | } 34 | timeline.streamTo(document.getElementById(hostId + 'Cpu'), 1000); 35 | } 36 | 37 | function addRandomValueToDataSets(time, dataSets) { 38 | for (var i = 0; i < dataSets.length; i++) { 39 | dataSets[i].append(time, Math.random()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Smoothie Charts 5 | 6 | 9 | 10 | 11 |

Smoothie Charts

12 | 13 | 14 | 15 |

Smoothie Charts is a really small charting library designed for live streaming data. I build it to reduce 16 | the headaches I was getting from watching charts jerkily updating every second. What you're looking at now is pretty much all 17 | it does. If you like that, then read on.

18 | 19 |

Getting started

20 | 27 | 28 |

For help, use the Smoothie Charts Google Group.

29 | 30 | 31 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/smoothie.svg)](https://www.npmjs.com/package/smoothie) 2 | 3 | *Smoothie Charts* is a really small charting library designed for _live 4 | streaming data_. I built it to reduce the headaches I was getting from 5 | watching charts jerkily updating every second. 6 | 7 | See [http://smoothiecharts.org](http://smoothiecharts.org) 8 | 9 | --- 10 | 11 | ### Getting Started 12 | 13 | * [Hello world example](http://smoothiecharts.org/examples/example1.html) 14 | * [Another example (server CPU usage)](http://smoothiecharts.org/examples/server-load.html) 15 | * [Another example (responsive layout)](http://smoothiecharts.org/examples/responsive.html) 16 | * [Tutorial](http://smoothiecharts.org/tutorial.html) 17 | * [Interactive builder](http://smoothiecharts.org/builder/) 18 | * Just the JavaScript: [smoothie.js](http://github.com/joewalnes/smoothie/raw/master/smoothie.js) 19 | * Full distribution (docs and examples): [zip](http://github.com/joewalnes/smoothie/zipball/master) or [tgz](http://github.com/joewalnes/smoothie/tarball/master) 20 | * Repository: `git clone git@github.com:joewalnes/smoothie.git` 21 | * Bower: `bower install smoothie` 22 | * NPM: `npm install smoothie` 23 | * Yarn: `yarn add smoothie` 24 | * [Introducing Smoothie Charts](http://joewalnes.com/2010/08/10/introducing-smoothie-charts/) (blog entry) 25 | 26 | --- 27 | 28 | ### Example 29 | 30 | Given a ``: 31 | 32 | ```html 33 | 34 | ``` 35 | 36 | Create a time series and chart with code resembling: 37 | 38 | ```js 39 | // Create a time series 40 | var series = new TimeSeries(); 41 | 42 | // Find the canvas 43 | var canvas = document.getElementById('chart'); 44 | 45 | // Create the chart 46 | var chart = new SmoothieChart(); 47 | chart.addTimeSeries(series, { strokeStyle: 'rgba(0, 255, 0, 1)' }); 48 | chart.streamTo(canvas, 500); 49 | ``` 50 | 51 | Then, add data to your time series and it will be displayed on the chart: 52 | 53 | ```js 54 | // Randomly add a data point every 500ms 55 | setInterval(function() { 56 | series.append(Date.now(), Math.random() * 10000); 57 | }, 500); 58 | ``` 59 | 60 | --- 61 | 62 | ### Questions 63 | 64 | For help, use the [Smoothie Charts Google Group](http://groups.google.com/group/smoothie-charts). 65 | 66 | --- 67 | 68 | [License](http://smoothiecharts.org/LICENSE.txt) (MIT) 69 | 70 | - [Joe Walnes](https://joewalnes.com/) 71 | - [Drew Noakes](https://drewnoakes.com/) 72 | 73 | -------------------------------------------------------------------------------- /docs/tutorial.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Smoothie Chart 5 | 18 | 19 | 20 | 21 |

Smoothie Chart Tutorial

22 | 23 |

Smoothie is a simple library for displaying smooth live time lines.

24 | 25 |

1. Include smoothie.js

26 |

Copy smoothie.js to your project and include it:

27 |
<script type="text/javascript" src="smoothie.js"></script>
28 | 29 |

2. Create a <canvas>

30 |
<canvas id="mycanvas" width="400" height="100"></canvas>
31 |
32 | 33 |

3. Stream a SmoothieChart to the Canvas

34 |

Use the default settings for now. These can be tweaked later.

35 |
 36 | var smoothie = new SmoothieChart();
 37 | smoothie.streamTo(document.getElementById("mycanvas"));
38 | 39 |
40 | 46 | 47 |

4. Add some data

48 |

Each line requires a TimeSeries object.

49 |

For this example, we'' create 2 TimeSeries objects and append random data to them every second.

50 |
 51 | // Data
 52 | var line1 = new TimeSeries();
 53 | var line2 = new TimeSeries();
 54 | 
 55 | // Add a random value to each line every second
 56 | setInterval(function() {
 57 |   line1.append(Date.now(), Math.random());
 58 |   line2.append(Date.now(), Math.random());
 59 | }, 1000);
 60 | 
 61 | // Add to SmoothieChart
 62 | smoothie.addTimeSeries(line1);
 63 | smoothie.addTimeSeries(line2);
64 | 65 |
66 | 81 | 82 |

In reality, you'd probably want to get data from a WebSocket, rather than random generator.

83 | 84 |

Important notes:

85 | 90 | 91 | 92 |

5. Add some delay

93 |

The above chart shows an issue... we cannot plot a line until the next data point is known. To get around this, 94 | we add a delay to the chart, so upcoming values are known before we need to plot the line.

95 |

This makes the chart look like a continual stream rather than very jumpy on the right hand side.

96 |

To add the delay, we modify the SmoothieChart.streamTo() call to include the delay.

97 |
 smoothie.streamTo(document.getElementById("mycanvas"), 1000 /*delay*/); 
98 |
99 | 114 |

That's much easier on the eye.

115 | 116 |

6. Splash of color

117 |

The API provides a few hooks for tweaking the style of the lines and the grid.

118 |
119 | var smoothie = new SmoothieChart({
120 |   grid: { strokeStyle:'rgb(125, 0, 0)', fillStyle:'rgb(60, 0, 0)',
121 |           lineWidth: 1, millisPerLine: 250, verticalSections: 6, },
122 |   labels: { fillStyle:'rgb(60, 0, 0)' }
123 | });
124 | smoothie.addTimeSeries(line1,
125 |   { strokeStyle:'rgb(0, 255, 0)', fillStyle:'rgba(0, 255, 0, 0.4)', lineWidth:3 });
126 | smoothie.addTimeSeries(line2,
127 |   { strokeStyle:'rgb(255, 0, 255)', fillStyle:'rgba(255, 0, 255, 0.3)', lineWidth:3 });
128 |
129 | 147 | 148 |

That's it.

149 | 150 |

View the finished example.

151 | 152 |

For help, use the Smoothie Charts Google Group.

153 | 154 | 155 | -------------------------------------------------------------------------------- /smoothie.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Smoothie Charts 1.35 2 | // Project: https://github.com/joewalnes/smoothie 3 | // Definitions by: Drew Noakes 4 | // Mike H. Hawley 5 | 6 | export interface ITimeSeriesOptions { 7 | resetBounds?: boolean; 8 | resetBoundsInterval?: number; 9 | } 10 | 11 | export interface ITimeSeriesPresentationOptions { 12 | strokeStyle?: string; 13 | fillStyle?: string; 14 | lineWidth?: number; 15 | /** 16 | * Controls how lines are drawn between data points. 17 | * Default value is controlled by SmoothieChart's interpolation option. 18 | */ 19 | interpolation?: "linear" | "step" | "bezier"; 20 | tooltipLabel?: string; 21 | /** 22 | * Determines how far on the Y axis the fill region spans. Truthy value (default) - to the 23 | * bottom of the canvas, falsy value - to 0. 24 | */ 25 | fillToBottom?: boolean; 26 | } 27 | 28 | export declare class TimeSeries { 29 | /** 30 | * Initialises a new TimeSeries with optional data options. 31 | * 32 | * Options are of the form (defaults shown): 33 | * 34 | *
 35 |      * {
 36 |      *   resetBounds: true,        // enables/disables automatic scaling of the y-axis
 37 |      *   resetBoundsInterval: 3000 // the period between scaling calculations, in millis
 38 |      * }
 39 |      * 
40 | * 41 | * Presentation options for TimeSeries are specified as an argument to SmoothieChart.addTimeSeries. 42 | */ 43 | constructor(options?: ITimeSeriesOptions); 44 | 45 | /** 46 | * Adjust or inspect the lower y-axis for this TimeSeries object. 47 | */ 48 | minValue: number; 49 | 50 | /** 51 | * Adjust or inspect the upper y-axis for this TimeSeries object. 52 | */ 53 | maxValue: number; 54 | 55 | /** 56 | * Hide this TimeSeries object in the chart. 57 | */ 58 | disabled: boolean; 59 | 60 | /** 61 | * Clears all data and state from this TimeSeries object. 62 | */ 63 | clear(): void; 64 | 65 | /** 66 | * Recalculate the min/max values for this TimeSeries object. 67 | * 68 | * This causes the graph to scale itself in the y-axis. 69 | */ 70 | resetBounds(): void; 71 | 72 | /** 73 | * Adds a new data point to the TimeSeries, preserving chronological order. 74 | * 75 | * @param timestamp the position, in time, of this data point 76 | * @param value the value of this data point 77 | * @param sumRepeatedTimeStampValues if timestamp has an exact match in the series, this flag controls 78 | * whether it is replaced, or the values summed (defaults to false.) 79 | */ 80 | append(timestamp: number, value: number, sumRepeatedTimeStampValues?: boolean): void; 81 | 82 | dropOldData(oldestValidTime: number, maxDataSetLength: number): void; 83 | } 84 | 85 | export interface IGridOptions { 86 | /** The background colour of the chart. */ 87 | fillStyle?: string; 88 | /** The pixel width of grid lines. */ 89 | lineWidth?: number; 90 | /** Colour of grid lines. */ 91 | strokeStyle?: string; 92 | /** Distance between vertical grid lines. */ 93 | millisPerLine?: number; 94 | /** Number of vertical sections marked out by horizontal grid lines. */ 95 | verticalSections?: number; 96 | /** Whether the grid lines trace the border of the chart or not. */ 97 | borderVisible?: boolean; 98 | } 99 | 100 | export interface ILabelOptions { 101 | /** Enables/disables labels showing the min/max values. */ 102 | disabled?: boolean; 103 | /** Colour for text of labels. */ 104 | fillStyle?: string; 105 | fontSize?: number; 106 | fontFamily?: string; 107 | precision?: number; 108 | /** Shows intermediate labels between min and max values along y axis. */ 109 | showIntermediateLabels?: boolean; 110 | intermediateLabelSameAxis?: boolean; 111 | } 112 | 113 | export interface ITitleOptions { 114 | /** The text to display on the left side of the chart. Defaults to "". */ 115 | text?: string; 116 | /** Colour for text. */ 117 | fillStyle?: string; 118 | fontSize?: number; 119 | fontFamily?: string; 120 | /** The vertical position of the text. Defaults to "middle". */ 121 | verticalAlign?: "top" | "middle" | "bottom"; 122 | } 123 | 124 | export interface IRange { min: number; max: number } 125 | 126 | export interface IHorizontalLine { 127 | value?: number; 128 | color?: string; 129 | lineWidth?: number; 130 | } 131 | 132 | export interface IChartOptions { 133 | /** Specify to clamp the lower y-axis to a given value. */ 134 | minValue?: number; 135 | /** Specify to clamp the upper y-axis to a given value. */ 136 | maxValue?: number; 137 | /** Allows proportional padding to be added above the chart. For 10% padding, specify 1.1. */ 138 | minValueScale?: number; 139 | /** Allows proportional padding to be added below the chart. For 10% padding, specify 1.1. */ 140 | maxValueScale?: number; 141 | yRangeFunction?: (range: IRange) => IRange; 142 | /** Controls the rate at which y-value zoom animation occurs. */ 143 | scaleSmoothing?: number; 144 | /** Sets the speed at which the chart pans by. */ 145 | millisPerPixel?: number; 146 | /** Whether to render at different DPI depending upon the device. Enabled by default. */ 147 | enableDpiScaling?: boolean; 148 | /** Callback function that formats the min y value label */ 149 | yMinFormatter?: (min: number, precision: number) => string; 150 | /** Callback function that formats the max y value label */ 151 | yMaxFormatter?: (max: number, precision: number) => string; 152 | /** Callback function that formats the intermediate y value labels */ 153 | yIntermediateFormatter?: (intermediate: number, precision: number) => string; 154 | maxDataSetLength?: number; 155 | /** Default value for time series presentation options' interpolation. Defaults to "bezier". */ 156 | interpolation?: ITimeSeriesPresentationOptions['interpolation']; 157 | /** Optional function to format time stamps for bottom of chart. You may use SmoothieChart.timeFormatter, or your own/ */ 158 | timestampFormatter?: (date: Date) => string; 159 | horizontalLines?: IHorizontalLine[]; 160 | 161 | grid?: IGridOptions; 162 | 163 | labels?: ILabelOptions; 164 | 165 | title?: ITitleOptions; 166 | 167 | tooltip?: boolean; 168 | tooltipLine?: { lineWidth: number, strokeStyle: string }; 169 | tooltipFormatter?: (timestamp: number, data: {series: TimeSeries, index: number, value: number}[]) => string; 170 | 171 | /** Whether to use time of latest data as current time. */ 172 | nonRealtimeData?: boolean; 173 | 174 | /** 175 | * Displays not the latest data, but data from the given percentile. 176 | * Useful when trying to see old data saved by setting a high value for maxDataSetLength. 177 | * Should be a value between 0 and 1. 178 | */ 179 | displayDataFromPercentile?: number; 180 | 181 | /** Allows the chart to stretch according to its containers and layout settings. Default is false, for backwards compatibility. */ 182 | responsive?: boolean; 183 | 184 | /** The maximum frame rate the chart will render at, in FPS. Default is 0, meaning no limit. */ 185 | limitFPS?: number; 186 | } 187 | 188 | /** 189 | * Initialises a new SmoothieChart. 190 | * 191 | * Options are optional and may be sparsely populated. Just specify the values you 192 | * need and the rest will be given sensible defaults. 193 | */ 194 | export declare class SmoothieChart { 195 | constructor(chartOptions?: IChartOptions); 196 | 197 | /** 198 | * Change or inspect presentation options. 199 | */ 200 | options: IChartOptions; 201 | 202 | /** 203 | * Adds a TimeSeries to this chart, with optional presentation options. 204 | */ 205 | addTimeSeries(series: TimeSeries, seriesOptions?: ITimeSeriesPresentationOptions): void; 206 | 207 | /** 208 | * Removes the specified TimeSeries from the chart. 209 | */ 210 | removeTimeSeries(series: TimeSeries): void; 211 | 212 | /** 213 | * Gets render options for the specified TimeSeries. 214 | * 215 | * As you may use a single TimeSeries in multiple charts with different formatting in each usage, 216 | * these settings are stored in the chart. 217 | */ 218 | getTimeSeriesOptions(timeSeries: TimeSeries): ITimeSeriesPresentationOptions; 219 | 220 | /** 221 | * Brings the specified TimeSeries to the top of the chart. It will be rendered last. 222 | */ 223 | bringToFront(timeSeries: TimeSeries): void; 224 | 225 | /** 226 | * Instructs the SmoothieChart to start rendering to the provided canvas, with specified delay. 227 | * 228 | * @param canvas the target canvas element 229 | * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series 230 | * from appearing on screen, with new values flashing into view, at the expense of some latency. 231 | */ 232 | streamTo(canvas: HTMLCanvasElement, delayMillis?: number): void; 233 | 234 | /** 235 | * Starts the animation of this chart. Called by streamTo. 236 | */ 237 | start(): void; 238 | 239 | /** 240 | * Stops the animation of this chart. 241 | */ 242 | stop(): void; 243 | 244 | updateValueRange(): void; 245 | 246 | render(canvas?: HTMLCanvasElement, time?: number): void; 247 | 248 | static timeFormatter(date: Date): string; 249 | } 250 | -------------------------------------------------------------------------------- /smoothie.js: -------------------------------------------------------------------------------- 1 | ;(function(exports) { 2 | 3 | /** 4 | * @license 5 | * MIT License: 6 | * 7 | * Copyright (c) 2010-2013, Joe Walnes 8 | * 2013-2018, Drew Noakes 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a copy 11 | * of this software and associated documentation files (the "Software"), to deal 12 | * in the Software without restriction, including without limitation the rights 13 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | * copies of the Software, and to permit persons to whom the Software is 15 | * furnished to do so, subject to the following conditions: 16 | * 17 | * The above copyright notice and this permission notice shall be included in 18 | * all copies or substantial portions of the Software. 19 | * 20 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | * THE SOFTWARE. 27 | */ 28 | 29 | /** 30 | * Smoothie Charts - http://smoothiecharts.org/ 31 | * (c) 2010-2013, Joe Walnes 32 | * 2013-2018, Drew Noakes 33 | * 34 | * v1.0: Main charting library, by Joe Walnes 35 | * v1.1: Auto scaling of axis, by Neil Dunn 36 | * v1.2: fps (frames per second) option, by Mathias Petterson 37 | * v1.3: Fix for divide by zero, by Paul Nikitochkin 38 | * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds 39 | * v1.5: Set default frames per second to 50... smoother. 40 | * .start(), .stop() methods for conserving CPU, by Dmitry Vyal 41 | * options.interpolation = 'bezier' or 'line', by Dmitry Vyal 42 | * options.maxValue to fix scale, by Dmitry Vyal 43 | * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla 44 | * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin 45 | * Smooth rescaling, by Kostas Michalopoulos 46 | * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni 47 | * v1.9: Display timestamps along the bottom, by Nick and Stev-io 48 | * (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D) 49 | * Refactored by Krishna Narni, to support timestamp formatting function 50 | * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh 51 | * v1.11: options.grid.sharpLines option added, by @drewnoakes 52 | * Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes 53 | * v1.12: Support for horizontalLines added, by @drewnoakes 54 | * Support for yRangeFunction callback added, by @drewnoakes 55 | * v1.13: Fixed typo (#32), by @alnikitich 56 | * v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano 57 | * Fixed diagonal line on chart at start/end of data stream, by @drewnoakes 58 | * v1.15: Support for npm package (#18), by @dominictarr 59 | * Fixed broken removeTimeSeries function (#24) by @davidgaleano 60 | * Minor performance and tidying, by @drewnoakes 61 | * v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes 62 | * TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12) 63 | * Documentation and some local variable renaming for clarity, by @drewnoakes 64 | * v1.17: Allow control over font size (#10), by @drewnoakes 65 | * Timestamp text won't overlap, by @drewnoakes 66 | * v1.18: Allow control of max/min label precision, by @drewnoakes 67 | * Added 'borderVisible' chart option, by @drewnoakes 68 | * Allow drawing series with fill but no stroke (line), by @drewnoakes 69 | * v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai 70 | * v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes 71 | * v1.21: Add 'step' interpolation mode, by @drewnoakes 72 | * v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic 73 | * v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes 74 | * v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf 75 | * v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92 76 | * Draw time labels on top of series, by @comolosabia 77 | * Add TimeSeries.clear function, by @drewnoakes 78 | * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic 79 | * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush 80 | * v1.28: Add 'minValueScale' option, by @megawac 81 | * Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn 82 | * v1.29: Support responsive sizing, by @drewnoakes 83 | * v1.29.1: Include types in package, and make property optional, by @TrentHouliston 84 | * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime 85 | * v1.31: Support tooltips, by @Sly1024 and @drewnoakes 86 | * v1.32: Support frame rate limit, by @dpuyosa 87 | * v1.33: Use Date static method instead of instance, by @nnnoel 88 | * Fix bug with tooltips when multiple charts on a page, by @jpmbiz70 89 | * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91) 90 | * Add nonRealtimeData option, by @annazhelt (#92, #93) 91 | * Add showIntermediateLabels option, by @annazhelt (#94) 92 | * Add displayDataFromPercentile option, by @annazhelt (#95) 93 | * Fix bug when hiding tooltip element, by @ralphwetzel (#96) 94 | * Support intermediate y-axis labels, by @beikeland (#99) 95 | * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101) 96 | * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions. 97 | * If tooltipLabel is present, tooltipLabel displays inside tooltip 98 | * next to value, by @jackdesert (#102) 99 | * Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik 100 | * Add title option, by @mesca 101 | * Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale 102 | * Allow setting interpolation per time series, by @WofWca (#123) 103 | * Fix chart constantly jumping in 1-2 pixel steps, by @WofWca (#131) 104 | * Fix a memory leak appearing when some `timeSeries.disabled === true`, by @WofWca (#132) 105 | * Fix: make all lines sharp, remove the `grid.sharpLines` option by @WofWca (#134) 106 | * Improve performance, by @WofWca (#135) 107 | * Fix `this.delay` not being respected with `nonRealtimeData: true`, by @WofWca (#137) 108 | * Fix series fill & stroke being inconsistent for last data time < render time, by @WofWca (#138) 109 | * v1.36.1: Fix a potential XSS when `tooltipLabel` or `strokeStyle` are controlled by users, by @WofWca 110 | * v1.36.2: fix: 1px lines jumping 1px left and right at rational `millisPerPixel`, by @WofWca 111 | * perf: improve `render()` performane a bit, by @WofWca 112 | * v1.37: Add `fillToBottom` option to fill timeSeries to 0 instead of to the bottom of the canvas, by @socketpair & @WofWca (#140) 113 | */ 114 | 115 | // Date.now polyfill 116 | Date.now = Date.now || function() { return new Date().getTime(); }; 117 | 118 | var Util = { 119 | extend: function() { 120 | arguments[0] = arguments[0] || {}; 121 | for (var i = 1; i < arguments.length; i++) 122 | { 123 | for (var key in arguments[i]) 124 | { 125 | if (arguments[i].hasOwnProperty(key)) 126 | { 127 | if (typeof(arguments[i][key]) === 'object') { 128 | if (arguments[i][key] instanceof Array) { 129 | arguments[0][key] = arguments[i][key]; 130 | } else { 131 | arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]); 132 | } 133 | } else { 134 | arguments[0][key] = arguments[i][key]; 135 | } 136 | } 137 | } 138 | } 139 | return arguments[0]; 140 | }, 141 | binarySearch: function(data, value) { 142 | var low = 0, 143 | high = data.length; 144 | while (low < high) { 145 | var mid = (low + high) >> 1; 146 | if (value < data[mid][0]) 147 | high = mid; 148 | else 149 | low = mid + 1; 150 | } 151 | return low; 152 | }, 153 | // So lines (especially vertical and horizontal) look a) consistent along their length and b) sharp. 154 | pixelSnap: function(position, lineWidth) { 155 | if (lineWidth % 2 === 0) { 156 | // Closest pixel edge. 157 | return Math.round(position); 158 | } else { 159 | // Closest pixel center. 160 | return Math.floor(position) + 0.5; 161 | } 162 | }, 163 | }; 164 | 165 | /** 166 | * Initialises a new TimeSeries with optional data options. 167 | * 168 | * Options are of the form (defaults shown): 169 | * 170 | *
 171 |    * {
 172 |    *   resetBounds: true,        // enables/disables automatic scaling of the y-axis
 173 |    *   resetBoundsInterval: 3000 // the period between scaling calculations, in millis
 174 |    * }
 175 |    * 
176 | * 177 | * Presentation options for TimeSeries are specified as an argument to SmoothieChart.addTimeSeries. 178 | * 179 | * @constructor 180 | */ 181 | function TimeSeries(options) { 182 | this.options = Util.extend({}, TimeSeries.defaultOptions, options); 183 | this.disabled = false; 184 | this.clear(); 185 | } 186 | 187 | TimeSeries.defaultOptions = { 188 | resetBoundsInterval: 3000, 189 | resetBounds: true 190 | }; 191 | 192 | /** 193 | * Clears all data and state from this TimeSeries object. 194 | */ 195 | TimeSeries.prototype.clear = function() { 196 | this.data = []; 197 | this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries. 198 | this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries. 199 | }; 200 | 201 | /** 202 | * Recalculate the min/max values for this TimeSeries object. 203 | * 204 | * This causes the graph to scale itself in the y-axis. 205 | */ 206 | TimeSeries.prototype.resetBounds = function() { 207 | if (this.data.length) { 208 | // Walk through all data points, finding the min/max value 209 | this.maxValue = this.data[0][1]; 210 | this.minValue = this.data[0][1]; 211 | for (var i = 1; i < this.data.length; i++) { 212 | var value = this.data[i][1]; 213 | if (value > this.maxValue) { 214 | this.maxValue = value; 215 | } 216 | if (value < this.minValue) { 217 | this.minValue = value; 218 | } 219 | } 220 | } else { 221 | // No data exists, so set min/max to NaN 222 | this.maxValue = Number.NaN; 223 | this.minValue = Number.NaN; 224 | } 225 | }; 226 | 227 | /** 228 | * Adds a new data point to the TimeSeries, preserving chronological order. 229 | * 230 | * @param timestamp the position, in time, of this data point 231 | * @param value the value of this data point 232 | * @param sumRepeatedTimeStampValues if timestamp has an exact match in the series, this flag controls 233 | * whether it is replaced, or the values summed (defaults to false.) 234 | */ 235 | TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) { 236 | // Reject NaN 237 | if (isNaN(timestamp) || isNaN(value)){ 238 | return 239 | } 240 | 241 | var lastI = this.data.length - 1; 242 | if (lastI >= 0) { 243 | // Rewind until we find the place for the new data 244 | var i = lastI; 245 | while (true) { 246 | var iThData = this.data[i]; 247 | if (timestamp >= iThData[0]) { 248 | if (timestamp === iThData[0]) { 249 | // Update existing values in the array 250 | if (sumRepeatedTimeStampValues) { 251 | // Sum this value into the existing 'bucket' 252 | iThData[1] += value; 253 | value = iThData[1]; 254 | } else { 255 | // Replace the previous value 256 | iThData[1] = value; 257 | } 258 | } else { 259 | // Splice into the correct position to keep timestamps in order 260 | this.data.splice(i + 1, 0, [timestamp, value]); 261 | } 262 | 263 | break; 264 | } 265 | 266 | i--; 267 | if (i < 0) { 268 | // This new item is the oldest data 269 | this.data.splice(0, 0, [timestamp, value]); 270 | 271 | break; 272 | } 273 | } 274 | } else { 275 | // It's the first element 276 | this.data.push([timestamp, value]); 277 | } 278 | 279 | this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value); 280 | this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value); 281 | }; 282 | 283 | TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) { 284 | // We must always keep one expired data point as we need this to draw the 285 | // line that comes into the chart from the left, but any points prior to that can be removed. 286 | var removeCount = 0; 287 | while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) { 288 | removeCount++; 289 | } 290 | if (removeCount !== 0) { 291 | this.data.splice(0, removeCount); 292 | } 293 | }; 294 | 295 | /** 296 | * Initialises a new SmoothieChart. 297 | * 298 | * Options are optional, and should be of the form below. Just specify the values you 299 | * need and the rest will be given sensible defaults as shown: 300 | * 301 | *
 302 |    * {
 303 |    *   minValue: undefined,                      // specify to clamp the lower y-axis to a given value
 304 |    *   maxValue: undefined,                      // specify to clamp the upper y-axis to a given value
 305 |    *   maxValueScale: 1,                         // allows proportional padding to be added above the chart. for 10% padding, specify 1.1.
 306 |    *   minValueScale: 1,                         // allows proportional padding to be added below the chart. for 10% padding, specify 1.1.
 307 |    *   yRangeFunction: undefined,                // function({min: , max: }) { return {min: , max: }; }
 308 |    *   scaleSmoothing: 0.125,                    // controls the rate at which y-value zoom animation occurs
 309 |    *   millisPerPixel: 20,                       // sets the speed at which the chart pans by
 310 |    *   enableDpiScaling: true,                   // support rendering at different DPI depending on the device
 311 |    *   yMinFormatter: function(min, precision) { // callback function that formats the min y value label
 312 |    *     return parseFloat(min).toFixed(precision);
 313 |    *   },
 314 |    *   yMaxFormatter: function(max, precision) { // callback function that formats the max y value label
 315 |    *     return parseFloat(max).toFixed(precision);
 316 |    *   },
 317 |    *   yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels
 318 |    *     return parseFloat(intermediate).toFixed(precision);
 319 |    *   },
 320 |    *   maxDataSetLength: 2,
 321 |    *   interpolation: 'bezier'                   // one of 'bezier', 'linear', or 'step'
 322 |    *   timestampFormatter: null,                 // optional function to format time stamps for bottom of chart
 323 |    *                                             // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; }
 324 |    *   scrollBackwards: false,                   // reverse the scroll direction of the chart
 325 |    *   horizontalLines: [],                      // [ { value: 0, color: '#ffffff', lineWidth: 1 } ]
 326 |    *   grid:
 327 |    *   {
 328 |    *     fillStyle: '#000000',                   // the background colour of the chart
 329 |    *     lineWidth: 1,                           // the pixel width of grid lines
 330 |    *     strokeStyle: '#777777',                 // colour of grid lines
 331 |    *     millisPerLine: 1000,                    // distance between vertical grid lines
 332 |    *     verticalSections: 2,                    // number of vertical sections marked out by horizontal grid lines
 333 |    *     borderVisible: true                     // whether the grid lines trace the border of the chart or not
 334 |    *   },
 335 |    *   labels
 336 |    *   {
 337 |    *     disabled: false,                        // enables/disables labels showing the min/max values
 338 |    *     fillStyle: '#ffffff',                   // colour for text of labels,
 339 |    *     fontSize: 15,
 340 |    *     fontFamily: 'sans-serif',
 341 |    *     precision: 2,
 342 |    *     showIntermediateLabels: false,          // shows intermediate labels between min and max values along y axis
 343 |    *     intermediateLabelSameAxis: true,
 344 |    *   },
 345 |    *   title
 346 |    *   {
 347 |    *     text: '',                               // the text to display on the left side of the chart
 348 |    *     fillStyle: '#ffffff',                   // colour for text
 349 |    *     fontSize: 15,
 350 |    *     fontFamily: 'sans-serif',
 351 |    *     verticalAlign: 'middle'                 // one of 'top', 'middle', or 'bottom'
 352 |    *   },
 353 |    *   tooltip: false                            // show tooltip when mouse is over the chart
 354 |    *   tooltipLine: {                            // properties for a vertical line at the cursor position
 355 |    *     lineWidth: 1,
 356 |    *     strokeStyle: '#BBBBBB'
 357 |    *   },
 358 |    *   tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text
 359 |    *   nonRealtimeData: false,                   // use time of latest data as current time
 360 |    *   displayDataFromPercentile: 1,             // display not latest data, but data from the given percentile
 361 |    *                                             // useful when trying to see old data saved by setting a high value for maxDataSetLength
 362 |    *                                             // should be a value between 0 and 1
 363 |    *   responsive: false,                        // whether the chart should adapt to the size of the canvas
 364 |    *   limitFPS: 0                               // maximum frame rate the chart will render at, in FPS (zero means no limit)
 365 |    * }
 366 |    * 
367 | * 368 | * @constructor 369 | */ 370 | function SmoothieChart(options) { 371 | this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options); 372 | this.seriesSet = []; 373 | this.currentValueRange = 1; 374 | this.currentVisMinValue = 0; 375 | this.lastRenderTimeMillis = 0; 376 | this.lastChartTimestamp = 0; 377 | 378 | this.mousemove = this.mousemove.bind(this); 379 | this.mouseout = this.mouseout.bind(this); 380 | } 381 | 382 | /** Formats the HTML string content of the tooltip. */ 383 | SmoothieChart.tooltipFormatter = function (timestamp, data) { 384 | var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter, 385 | // A dummy element to hold children. Maybe there's a better way. 386 | elements = document.createElement('div'), 387 | label; 388 | elements.appendChild(document.createTextNode( 389 | timestampFormatter(new Date(timestamp)) 390 | )); 391 | 392 | for (var i = 0; i < data.length; ++i) { 393 | label = data[i].series.options.tooltipLabel || '' 394 | if (label !== ''){ 395 | label = label + ' '; 396 | } 397 | var dataEl = document.createElement('span'); 398 | dataEl.style.color = data[i].series.options.strokeStyle; 399 | dataEl.appendChild(document.createTextNode( 400 | label + this.options.yMaxFormatter(data[i].value, this.options.labels.precision) 401 | )); 402 | elements.appendChild(document.createElement('br')); 403 | elements.appendChild(dataEl); 404 | } 405 | 406 | return elements.innerHTML; 407 | }; 408 | 409 | SmoothieChart.defaultChartOptions = { 410 | millisPerPixel: 20, 411 | enableDpiScaling: true, 412 | yMinFormatter: function(min, precision) { 413 | return parseFloat(min).toFixed(precision); 414 | }, 415 | yMaxFormatter: function(max, precision) { 416 | return parseFloat(max).toFixed(precision); 417 | }, 418 | yIntermediateFormatter: function(intermediate, precision) { 419 | return parseFloat(intermediate).toFixed(precision); 420 | }, 421 | maxValueScale: 1, 422 | minValueScale: 1, 423 | interpolation: 'bezier', 424 | scaleSmoothing: 0.125, 425 | maxDataSetLength: 2, 426 | scrollBackwards: false, 427 | displayDataFromPercentile: 1, 428 | grid: { 429 | fillStyle: '#000000', 430 | strokeStyle: '#777777', 431 | lineWidth: 2, 432 | millisPerLine: 1000, 433 | verticalSections: 2, 434 | borderVisible: true 435 | }, 436 | labels: { 437 | fillStyle: '#ffffff', 438 | disabled: false, 439 | fontSize: 10, 440 | fontFamily: 'monospace', 441 | precision: 2, 442 | showIntermediateLabels: false, 443 | intermediateLabelSameAxis: true, 444 | }, 445 | title: { 446 | text: '', 447 | fillStyle: '#ffffff', 448 | fontSize: 15, 449 | fontFamily: 'monospace', 450 | verticalAlign: 'middle' 451 | }, 452 | horizontalLines: [], 453 | tooltip: false, 454 | tooltipLine: { 455 | lineWidth: 1, 456 | strokeStyle: '#BBBBBB' 457 | }, 458 | tooltipFormatter: SmoothieChart.tooltipFormatter, 459 | nonRealtimeData: false, 460 | responsive: false, 461 | limitFPS: 0 462 | }; 463 | 464 | // Based on http://inspirit.github.com/jsfeat/js/compatibility.js 465 | SmoothieChart.AnimateCompatibility = (function() { 466 | var requestAnimationFrame = function(callback, element) { 467 | var requestAnimationFrame = 468 | window.requestAnimationFrame || 469 | window.webkitRequestAnimationFrame || 470 | window.mozRequestAnimationFrame || 471 | window.oRequestAnimationFrame || 472 | window.msRequestAnimationFrame || 473 | function(callback) { 474 | return window.setTimeout(function() { 475 | callback(Date.now()); 476 | }, 16); 477 | }; 478 | return requestAnimationFrame.call(window, callback, element); 479 | }, 480 | cancelAnimationFrame = function(id) { 481 | var cancelAnimationFrame = 482 | window.cancelAnimationFrame || 483 | function(id) { 484 | clearTimeout(id); 485 | }; 486 | return cancelAnimationFrame.call(window, id); 487 | }; 488 | 489 | return { 490 | requestAnimationFrame: requestAnimationFrame, 491 | cancelAnimationFrame: cancelAnimationFrame 492 | }; 493 | })(); 494 | 495 | SmoothieChart.defaultSeriesPresentationOptions = { 496 | lineWidth: 1, 497 | strokeStyle: '#ffffff', 498 | // Maybe default to false in the next breaking version. 499 | fillToBottom: true, 500 | }; 501 | 502 | /** 503 | * Adds a TimeSeries to this chart, with optional presentation options. 504 | * 505 | * Presentation options should be of the form (defaults shown): 506 | * 507 | *
 508 |    * {
 509 |    *   lineWidth: 1,
 510 |    *   strokeStyle: '#ffffff',
 511 |    *   fillStyle: undefined,
 512 |    *   interpolation: undefined;
 513 |    *   tooltipLabel: undefined,
 514 |    *   fillToBottom: true,
 515 |    * }
 516 |    * 
517 | */ 518 | SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) { 519 | this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)}); 520 | if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) { 521 | timeSeries.resetBoundsTimerId = setInterval( 522 | function() { 523 | timeSeries.resetBounds(); 524 | }, 525 | timeSeries.options.resetBoundsInterval 526 | ); 527 | } 528 | }; 529 | 530 | /** 531 | * Removes the specified TimeSeries from the chart. 532 | */ 533 | SmoothieChart.prototype.removeTimeSeries = function(timeSeries) { 534 | // Find the correct timeseries to remove, and remove it 535 | var numSeries = this.seriesSet.length; 536 | for (var i = 0; i < numSeries; i++) { 537 | if (this.seriesSet[i].timeSeries === timeSeries) { 538 | this.seriesSet.splice(i, 1); 539 | break; 540 | } 541 | } 542 | // If a timer was operating for that timeseries, remove it 543 | if (timeSeries.resetBoundsTimerId) { 544 | // Stop resetting the bounds, if we were 545 | clearInterval(timeSeries.resetBoundsTimerId); 546 | } 547 | }; 548 | 549 | /** 550 | * Gets render options for the specified TimeSeries. 551 | * 552 | * As you may use a single TimeSeries in multiple charts with different formatting in each usage, 553 | * these settings are stored in the chart. 554 | */ 555 | SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) { 556 | // Find the correct timeseries to remove, and remove it 557 | var numSeries = this.seriesSet.length; 558 | for (var i = 0; i < numSeries; i++) { 559 | if (this.seriesSet[i].timeSeries === timeSeries) { 560 | return this.seriesSet[i].options; 561 | } 562 | } 563 | }; 564 | 565 | /** 566 | * Brings the specified TimeSeries to the top of the chart. It will be rendered last. 567 | */ 568 | SmoothieChart.prototype.bringToFront = function(timeSeries) { 569 | // Find the correct timeseries to remove, and remove it 570 | var numSeries = this.seriesSet.length; 571 | for (var i = 0; i < numSeries; i++) { 572 | if (this.seriesSet[i].timeSeries === timeSeries) { 573 | var set = this.seriesSet.splice(i, 1); 574 | this.seriesSet.push(set[0]); 575 | break; 576 | } 577 | } 578 | }; 579 | 580 | /** 581 | * Instructs the SmoothieChart to start rendering to the provided canvas, with specified delay. 582 | * 583 | * @param canvas the target canvas element 584 | * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series 585 | * from appearing on screen, with new values flashing into view, at the expense of some latency. 586 | */ 587 | SmoothieChart.prototype.streamTo = function(canvas, delayMillis) { 588 | this.canvas = canvas; 589 | 590 | this.clientWidth = parseInt(this.canvas.getAttribute('width')); 591 | this.clientHeight = parseInt(this.canvas.getAttribute('height')); 592 | 593 | this.delay = delayMillis; 594 | this.start(); 595 | }; 596 | 597 | SmoothieChart.prototype.getTooltipEl = function () { 598 | // Create the tool tip element lazily 599 | if (!this.tooltipEl) { 600 | this.tooltipEl = document.createElement('div'); 601 | this.tooltipEl.className = 'smoothie-chart-tooltip'; 602 | this.tooltipEl.style.pointerEvents = 'none'; 603 | this.tooltipEl.style.position = 'absolute'; 604 | this.tooltipEl.style.display = 'none'; 605 | document.body.appendChild(this.tooltipEl); 606 | } 607 | return this.tooltipEl; 608 | }; 609 | 610 | SmoothieChart.prototype.updateTooltip = function () { 611 | if(!this.options.tooltip){ 612 | return; 613 | } 614 | var el = this.getTooltipEl(); 615 | 616 | if (!this.mouseover || !this.options.tooltip) { 617 | el.style.display = 'none'; 618 | return; 619 | } 620 | 621 | var time = this.lastChartTimestamp; 622 | 623 | // x pixel to time 624 | var t = this.options.scrollBackwards 625 | ? time - this.mouseX * this.options.millisPerPixel 626 | : time - (this.clientWidth - this.mouseX) * this.options.millisPerPixel; 627 | 628 | var data = []; 629 | 630 | // For each data set... 631 | for (var d = 0; d < this.seriesSet.length; d++) { 632 | var timeSeries = this.seriesSet[d].timeSeries; 633 | if (timeSeries.disabled) { 634 | continue; 635 | } 636 | 637 | // find datapoint closest to time 't' 638 | var closeIdx = Util.binarySearch(timeSeries.data, t); 639 | if (closeIdx > 0 && closeIdx < timeSeries.data.length) { 640 | data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] }); 641 | } 642 | } 643 | 644 | if (data.length) { 645 | // TODO make `tooltipFormatter` return element(s) instead of an HTML string so it's harder for users 646 | // to introduce an XSS. This would be a breaking change. 647 | el.innerHTML = this.options.tooltipFormatter.call(this, t, data); 648 | el.style.display = 'block'; 649 | } else { 650 | el.style.display = 'none'; 651 | } 652 | }; 653 | 654 | SmoothieChart.prototype.mousemove = function (evt) { 655 | this.mouseover = true; 656 | this.mouseX = evt.offsetX; 657 | this.mouseY = evt.offsetY; 658 | this.mousePageX = evt.pageX; 659 | this.mousePageY = evt.pageY; 660 | if(!this.options.tooltip){ 661 | return; 662 | } 663 | var el = this.getTooltipEl(); 664 | el.style.top = Math.round(this.mousePageY) + 'px'; 665 | el.style.left = Math.round(this.mousePageX) + 'px'; 666 | this.updateTooltip(); 667 | }; 668 | 669 | SmoothieChart.prototype.mouseout = function () { 670 | this.mouseover = false; 671 | this.mouseX = this.mouseY = -1; 672 | if (this.tooltipEl) 673 | this.tooltipEl.style.display = 'none'; 674 | }; 675 | 676 | /** 677 | * Make sure the canvas has the optimal resolution for the device's pixel ratio. 678 | */ 679 | SmoothieChart.prototype.resize = function () { 680 | var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio, 681 | width, height; 682 | if (this.options.responsive) { 683 | // Newer behaviour: Use the canvas's size in the layout, and set the internal 684 | // resolution according to that size and the device pixel ratio (eg: high DPI) 685 | width = this.canvas.offsetWidth; 686 | height = this.canvas.offsetHeight; 687 | 688 | if (width !== this.lastWidth) { 689 | this.lastWidth = width; 690 | this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); 691 | this.canvas.getContext('2d').scale(dpr, dpr); 692 | } 693 | if (height !== this.lastHeight) { 694 | this.lastHeight = height; 695 | this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); 696 | this.canvas.getContext('2d').scale(dpr, dpr); 697 | } 698 | 699 | this.clientWidth = width; 700 | this.clientHeight = height; 701 | } else { 702 | width = parseInt(this.canvas.getAttribute('width')); 703 | height = parseInt(this.canvas.getAttribute('height')); 704 | 705 | if (dpr !== 1) { 706 | // Older behaviour: use the canvas's inner dimensions and scale the element's size 707 | // according to that size and the device pixel ratio (eg: high DPI) 708 | 709 | if (Math.floor(this.clientWidth * dpr) !== width) { 710 | this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); 711 | this.canvas.style.width = width + 'px'; 712 | this.clientWidth = width; 713 | this.canvas.getContext('2d').scale(dpr, dpr); 714 | } 715 | 716 | if (Math.floor(this.clientHeight * dpr) !== height) { 717 | this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); 718 | this.canvas.style.height = height + 'px'; 719 | this.clientHeight = height; 720 | this.canvas.getContext('2d').scale(dpr, dpr); 721 | } 722 | } else { 723 | this.clientWidth = width; 724 | this.clientHeight = height; 725 | } 726 | } 727 | }; 728 | 729 | /** 730 | * Starts the animation of this chart. 731 | */ 732 | SmoothieChart.prototype.start = function() { 733 | if (this.frame) { 734 | // We're already running, so just return 735 | return; 736 | } 737 | 738 | this.canvas.addEventListener('mousemove', this.mousemove); 739 | this.canvas.addEventListener('mouseout', this.mouseout); 740 | 741 | // Renders a frame, and queues the next frame for later rendering 742 | var animate = function() { 743 | this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() { 744 | if(this.options.nonRealtimeData){ 745 | var dateZero = new Date(0); 746 | // find the data point with the latest timestamp 747 | var maxTimeStamp = this.seriesSet.reduce(function(max, series){ 748 | var dataSet = series.timeSeries.data; 749 | var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1; 750 | indexToCheck = indexToCheck >= 0 ? indexToCheck : 0; 751 | indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1; 752 | if(dataSet && dataSet.length > 0) 753 | { 754 | // timestamp corresponds to element 0 of the data point 755 | var lastDataTimeStamp = dataSet[indexToCheck][0]; 756 | max = max > lastDataTimeStamp ? max : lastDataTimeStamp; 757 | } 758 | return max; 759 | }.bind(this), dateZero); 760 | // use the max timestamp as current time 761 | this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null); 762 | } else { 763 | this.render(); 764 | } 765 | animate(); 766 | }.bind(this)); 767 | }.bind(this); 768 | 769 | animate(); 770 | }; 771 | 772 | /** 773 | * Stops the animation of this chart. 774 | */ 775 | SmoothieChart.prototype.stop = function() { 776 | if (this.frame) { 777 | SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame); 778 | delete this.frame; 779 | this.canvas.removeEventListener('mousemove', this.mousemove); 780 | this.canvas.removeEventListener('mouseout', this.mouseout); 781 | } 782 | }; 783 | 784 | SmoothieChart.prototype.updateValueRange = function() { 785 | // Calculate the current scale of the chart, from all time series. 786 | var chartOptions = this.options, 787 | chartMaxValue = Number.NaN, 788 | chartMinValue = Number.NaN; 789 | 790 | for (var d = 0; d < this.seriesSet.length; d++) { 791 | // TODO(ndunn): We could calculate / track these values as they stream in. 792 | var timeSeries = this.seriesSet[d].timeSeries; 793 | if (timeSeries.disabled) { 794 | continue; 795 | } 796 | 797 | if (!isNaN(timeSeries.maxValue)) { 798 | chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue; 799 | } 800 | 801 | if (!isNaN(timeSeries.minValue)) { 802 | chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue; 803 | } 804 | } 805 | 806 | // Scale the chartMaxValue to add padding at the top if required 807 | if (chartOptions.maxValue != null) { 808 | chartMaxValue = chartOptions.maxValue; 809 | } else { 810 | chartMaxValue *= chartOptions.maxValueScale; 811 | } 812 | 813 | // Set the minimum if we've specified one 814 | if (chartOptions.minValue != null) { 815 | chartMinValue = chartOptions.minValue; 816 | } else { 817 | chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue); 818 | } 819 | 820 | // If a custom range function is set, call it 821 | if (this.options.yRangeFunction) { 822 | var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue}); 823 | chartMinValue = range.min; 824 | chartMaxValue = range.max; 825 | } 826 | 827 | if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) { 828 | var targetValueRange = chartMaxValue - chartMinValue; 829 | var valueRangeDiff = (targetValueRange - this.currentValueRange); 830 | var minValueDiff = (chartMinValue - this.currentVisMinValue); 831 | this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1; 832 | this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff; 833 | this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff; 834 | } 835 | 836 | this.valueRange = { min: chartMinValue, max: chartMaxValue }; 837 | }; 838 | 839 | SmoothieChart.prototype.render = function(canvas, time) { 840 | var chartOptions = this.options, 841 | nowMillis = Date.now(); 842 | 843 | // Respect any frame rate limit. 844 | if (chartOptions.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/chartOptions.limitFPS)) 845 | return; 846 | 847 | time = (time || nowMillis) - (this.delay || 0); 848 | 849 | // Round time down to pixel granularity, so that pixel sample values remain the same, 850 | // just shifted 1px to the left, so motion appears smoother. 851 | time -= time % chartOptions.millisPerPixel; 852 | 853 | if (!this.isAnimatingScale) { 854 | // We're not animating. We can use the last render time and the scroll speed to work out whether 855 | // we actually need to paint anything yet. If not, we can return immediately. 856 | var sameTime = this.lastChartTimestamp === time; 857 | if (sameTime) { 858 | // Render at least every 1/6th of a second. The canvas may be resized, which there is 859 | // no reliable way to detect. 860 | var needToRenderInCaseCanvasResized = nowMillis - this.lastRenderTimeMillis > 1000/6; 861 | if (!needToRenderInCaseCanvasResized) { 862 | return; 863 | } 864 | } 865 | } 866 | 867 | this.lastRenderTimeMillis = nowMillis; 868 | this.lastChartTimestamp = time; 869 | 870 | this.resize(); 871 | 872 | canvas = canvas || this.canvas; 873 | var context = canvas.getContext('2d'), 874 | // Using `this.clientWidth` instead of `canvas.clientWidth` because the latter is slow. 875 | dimensions = { top: 0, left: 0, width: this.clientWidth, height: this.clientHeight }, 876 | // Calculate the threshold time for the oldest data points. 877 | oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel), 878 | valueToYPosition = function(value, lineWidth) { 879 | var offset = value - this.currentVisMinValue, 880 | unsnapped = this.currentValueRange === 0 881 | ? dimensions.height 882 | : dimensions.height * (1 - offset / this.currentValueRange); 883 | return Util.pixelSnap(unsnapped, lineWidth); 884 | }.bind(this), 885 | timeToXPosition = function(t, lineWidth) { 886 | // Why not write it as `(time - t) / chartOptions.millisPerPixel`: 887 | // If a datapoint's `t` is very close or is at the center of a pixel, that expression, 888 | // due to floating point error, may take value whose `% 1` sometimes is very close to 889 | // 0 and sometimes is close to 1, depending on the value of render time (`time`), 890 | // which would make `pixelSnap` snap it sometimes to the right and sometimes to the left, 891 | // which would look like it's jumping. 892 | // You can try the default examples, with `millisPerPixel = 100 / 3` and 893 | // `grid.lineWidth = 1`. The grid would jump. 894 | // Writing it this way seems to avoid such inconsistency because in the above example 895 | // `offset` is (almost?) always a whole number. 896 | // TODO Maybe there's a more elegant (and reliable?) way. 897 | var offset = time / chartOptions.millisPerPixel - t / chartOptions.millisPerPixel; 898 | var unsnapped = chartOptions.scrollBackwards 899 | ? offset 900 | : dimensions.width - offset; 901 | return Util.pixelSnap(unsnapped, lineWidth); 902 | }; 903 | 904 | this.updateValueRange(); 905 | 906 | context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily; 907 | 908 | // Save the state of the canvas context, any transformations applied in this method 909 | // will get removed from the stack at the end of this method when .restore() is called. 910 | context.save(); 911 | 912 | // Move the origin. 913 | context.translate(dimensions.left, dimensions.top); 914 | 915 | // Create a clipped rectangle - anything we draw will be constrained to this rectangle. 916 | // This prevents the occasional pixels from curves near the edges overrunning and creating 917 | // screen cheese (that phrase should need no explanation). 918 | context.beginPath(); 919 | context.rect(0, 0, dimensions.width, dimensions.height); 920 | context.clip(); 921 | 922 | // Clear the working area. 923 | context.save(); 924 | context.fillStyle = chartOptions.grid.fillStyle; 925 | context.clearRect(0, 0, dimensions.width, dimensions.height); 926 | context.fillRect(0, 0, dimensions.width, dimensions.height); 927 | context.restore(); 928 | 929 | // Grid lines... 930 | context.save(); 931 | context.lineWidth = chartOptions.grid.lineWidth; 932 | context.strokeStyle = chartOptions.grid.strokeStyle; 933 | // Vertical (time) dividers. 934 | if (chartOptions.grid.millisPerLine > 0) { 935 | context.beginPath(); 936 | for (var t = time - (time % chartOptions.grid.millisPerLine); 937 | t >= oldestValidTime; 938 | t -= chartOptions.grid.millisPerLine) { 939 | var gx = timeToXPosition(t, chartOptions.grid.lineWidth); 940 | context.moveTo(gx, 0); 941 | context.lineTo(gx, dimensions.height); 942 | } 943 | context.stroke(); 944 | } 945 | 946 | // Horizontal (value) dividers. 947 | for (var v = 1; v < chartOptions.grid.verticalSections; v++) { 948 | var gy = Util.pixelSnap(v * dimensions.height / chartOptions.grid.verticalSections, chartOptions.grid.lineWidth); 949 | context.beginPath(); 950 | context.moveTo(0, gy); 951 | context.lineTo(dimensions.width, gy); 952 | context.stroke(); 953 | } 954 | // Bounding rectangle. 955 | if (chartOptions.grid.borderVisible) { 956 | context.strokeRect(0, 0, dimensions.width, dimensions.height); 957 | } 958 | context.restore(); 959 | 960 | // Draw any horizontal lines... 961 | if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) { 962 | for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) { 963 | var line = chartOptions.horizontalLines[hl], 964 | lineWidth = line.lineWidth || 1, 965 | hly = valueToYPosition(line.value, lineWidth); 966 | context.strokeStyle = line.color || '#ffffff'; 967 | context.lineWidth = lineWidth; 968 | context.beginPath(); 969 | context.moveTo(0, hly); 970 | context.lineTo(dimensions.width, hly); 971 | context.stroke(); 972 | } 973 | } 974 | 975 | // For each data set... 976 | for (var d = 0; d < this.seriesSet.length; d++) { 977 | var timeSeries = this.seriesSet[d].timeSeries, 978 | dataSet = timeSeries.data; 979 | 980 | // Delete old data that's moved off the left of the chart. 981 | timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength); 982 | if (dataSet.length <= 1 || timeSeries.disabled) { 983 | continue; 984 | } 985 | context.save(); 986 | 987 | var seriesOptions = this.seriesSet[d].options, 988 | // Keep in mind that `context.lineWidth = 0` doesn't actually set it to `0`. 989 | drawStroke = seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none', 990 | lineWidthMaybeZero = drawStroke ? seriesOptions.lineWidth : 0; 991 | 992 | // Draw the line... 993 | context.beginPath(); 994 | // Retain lastX, lastY for calculating the control points of bezier curves. 995 | var firstX = timeToXPosition(dataSet[0][0], lineWidthMaybeZero), 996 | firstY = valueToYPosition(dataSet[0][1], lineWidthMaybeZero), 997 | lastX = firstX, 998 | lastY = firstY, 999 | draw; 1000 | context.moveTo(firstX, firstY); 1001 | switch (seriesOptions.interpolation || chartOptions.interpolation) { 1002 | case "linear": 1003 | case "line": { 1004 | draw = function(x, y, lastX, lastY) { 1005 | context.lineTo(x,y); 1006 | } 1007 | break; 1008 | } 1009 | case "bezier": 1010 | default: { 1011 | // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves 1012 | // 1013 | // Assuming A was the last point in the line plotted and B is the new point, 1014 | // we draw a curve with control points P and Q as below. 1015 | // 1016 | // A---P 1017 | // | 1018 | // | 1019 | // | 1020 | // Q---B 1021 | // 1022 | // Importantly, A and P are at the same y coordinate, as are B and Q. This is 1023 | // so adjacent curves appear to flow as one. 1024 | // 1025 | draw = function(x, y, lastX, lastY) { 1026 | context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop 1027 | Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) 1028 | Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) 1029 | x, y); // endPoint (B) 1030 | } 1031 | break; 1032 | } 1033 | case "step": { 1034 | draw = function(x, y, lastX, lastY) { 1035 | context.lineTo(x,lastY); 1036 | context.lineTo(x,y); 1037 | } 1038 | break; 1039 | } 1040 | } 1041 | 1042 | for (var i = 1; i < dataSet.length; i++) { 1043 | var iThData = dataSet[i], 1044 | x = timeToXPosition(iThData[0], lineWidthMaybeZero), 1045 | y = valueToYPosition(iThData[1], lineWidthMaybeZero); 1046 | draw(x, y, lastX, lastY); 1047 | lastX = x; lastY = y; 1048 | } 1049 | 1050 | if (drawStroke) { 1051 | context.lineWidth = seriesOptions.lineWidth; 1052 | context.strokeStyle = seriesOptions.strokeStyle; 1053 | context.stroke(); 1054 | } 1055 | 1056 | if (seriesOptions.fillStyle) { 1057 | // Close up the fill region. 1058 | var fillEndY = seriesOptions.fillToBottom 1059 | ? dimensions.height + lineWidthMaybeZero + 1 1060 | : valueToYPosition(0, 0); 1061 | context.lineTo(lastX, fillEndY); 1062 | context.lineTo(firstX, fillEndY); 1063 | 1064 | context.fillStyle = seriesOptions.fillStyle; 1065 | context.fill(); 1066 | } 1067 | 1068 | context.restore(); 1069 | } 1070 | 1071 | if (chartOptions.tooltip && this.mouseX >= 0) { 1072 | // Draw vertical bar to show tooltip position 1073 | context.lineWidth = chartOptions.tooltipLine.lineWidth; 1074 | context.strokeStyle = chartOptions.tooltipLine.strokeStyle; 1075 | context.beginPath(); 1076 | context.moveTo(this.mouseX, 0); 1077 | context.lineTo(this.mouseX, dimensions.height); 1078 | context.stroke(); 1079 | } 1080 | this.updateTooltip(); 1081 | 1082 | var labelsOptions = chartOptions.labels; 1083 | // Draw the axis values on the chart. 1084 | if (!labelsOptions.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { 1085 | var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, labelsOptions.precision), 1086 | minValueString = chartOptions.yMinFormatter(this.valueRange.min, labelsOptions.precision), 1087 | maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2, 1088 | minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2; 1089 | context.fillStyle = labelsOptions.fillStyle; 1090 | context.fillText(maxValueString, maxLabelPos, labelsOptions.fontSize); 1091 | context.fillText(minValueString, minLabelPos, dimensions.height - 2); 1092 | } 1093 | 1094 | // Display intermediate y axis labels along y-axis to the left of the chart 1095 | if ( labelsOptions.showIntermediateLabels 1096 | && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max) 1097 | && chartOptions.grid.verticalSections > 0) { 1098 | // show a label above every vertical section divider 1099 | var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections; 1100 | var stepPixels = dimensions.height / chartOptions.grid.verticalSections; 1101 | for (var v = 1; v < chartOptions.grid.verticalSections; v++) { 1102 | var gy = dimensions.height - Math.round(v * stepPixels), 1103 | yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), labelsOptions.precision), 1104 | //left of right axis? 1105 | intermediateLabelPos = 1106 | labelsOptions.intermediateLabelSameAxis 1107 | ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2) 1108 | : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0); 1109 | 1110 | context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth); 1111 | } 1112 | } 1113 | 1114 | // Display timestamps along x-axis at the bottom of the chart. 1115 | if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) { 1116 | var textUntilX = chartOptions.scrollBackwards 1117 | ? context.measureText(minValueString).width 1118 | : dimensions.width - context.measureText(minValueString).width + 4; 1119 | for (var t = time - (time % chartOptions.grid.millisPerLine); 1120 | t >= oldestValidTime; 1121 | t -= chartOptions.grid.millisPerLine) { 1122 | var gx = timeToXPosition(t, 0); 1123 | // Only draw the timestamp if it won't overlap with the previously drawn one. 1124 | if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) { 1125 | // Formats the timestamp based on user specified formatting function 1126 | // SmoothieChart.timeFormatter function above is one such formatting option 1127 | var tx = new Date(t), 1128 | ts = chartOptions.timestampFormatter(tx), 1129 | tsWidth = context.measureText(ts).width; 1130 | 1131 | textUntilX = chartOptions.scrollBackwards 1132 | ? gx + tsWidth + 2 1133 | : gx - tsWidth - 2; 1134 | 1135 | context.fillStyle = chartOptions.labels.fillStyle; 1136 | if(chartOptions.scrollBackwards) { 1137 | context.fillText(ts, gx, dimensions.height - 2); 1138 | } else { 1139 | context.fillText(ts, gx - tsWidth, dimensions.height - 2); 1140 | } 1141 | } 1142 | } 1143 | } 1144 | 1145 | // Display title. 1146 | if (chartOptions.title.text !== '') { 1147 | context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily; 1148 | var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2; 1149 | if (chartOptions.title.verticalAlign == 'bottom') { 1150 | context.textBaseline = 'bottom'; 1151 | var titleYPos = dimensions.height; 1152 | } else if (chartOptions.title.verticalAlign == 'middle') { 1153 | context.textBaseline = 'middle'; 1154 | var titleYPos = dimensions.height / 2; 1155 | } else { 1156 | context.textBaseline = 'top'; 1157 | var titleYPos = 0; 1158 | } 1159 | context.fillStyle = chartOptions.title.fillStyle; 1160 | context.fillText(chartOptions.title.text, titleXPos, titleYPos); 1161 | } 1162 | 1163 | context.restore(); // See .save() above. 1164 | }; 1165 | 1166 | // Sample timestamp formatting function 1167 | SmoothieChart.timeFormatter = function(date) { 1168 | function pad2(number) { return (number < 10 ? '0' : '') + number } 1169 | return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()); 1170 | }; 1171 | 1172 | exports.TimeSeries = TimeSeries; 1173 | exports.SmoothieChart = SmoothieChart; 1174 | 1175 | })(typeof exports === 'undefined' ? this : exports); 1176 | 1177 | --------------------------------------------------------------------------------