├── .editorconfig ├── .github └── workflows │ ├── nodejs.yml │ └── npmpublish.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── benchmark ├── first_paint │ ├── chartjs-master.html │ ├── timechart.html │ └── uplot.html ├── interaction │ ├── chartjs-master.html │ ├── timechart.html │ └── uplot.html └── utils.js ├── demo ├── basic.html ├── dark.html ├── demo.js ├── dynamic_data.html ├── index.html ├── large_data_range.html ├── line_style.html ├── not_aligned.html ├── plugins │ ├── assemble.html │ ├── events.html │ └── select.html └── reset.html ├── docs ├── authoring_plugins.md ├── first_paint.png ├── graph_first_paint.py ├── graph_interaction.py ├── interaction.png ├── performance.md ├── pp.py └── rendered │ ├── chartjs.png │ ├── timechart.png │ └── uplot.png ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── chartZoom │ ├── index.ts │ ├── mouse.ts │ ├── options.ts │ ├── touch.ts │ ├── utils.ts │ └── wheel.ts ├── core │ ├── canvasLayer.ts │ ├── contentBoxDetector.ts │ ├── dataPointsBuffer.ts │ ├── index.ts │ ├── nearestPoint.ts │ ├── renderModel.ts │ └── svgLayer.ts ├── index.ts ├── options.ts ├── plugins │ ├── chartZoom.ts │ ├── crosshair.ts │ ├── d3Axis.ts │ ├── index.ts │ ├── legend.ts │ ├── lineChart.ts │ ├── nearestPoint.ts │ ├── tooltip.ts │ └── webGLUtils.ts ├── plugins_extra │ ├── README.md │ ├── events.ts │ ├── index.ts │ └── selectZoom.ts └── utils.ts ├── test-d └── index.test-d.ts ├── test ├── chartZoom │ └── utils.test.ts ├── dataPointsBuffer.test.ts └── utils.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-gh-pages: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '18' 18 | cache: 'npm' 19 | - run: npm ci 20 | - run: npm test 21 | - name: git commit 22 | run: | 23 | git add -f ./dist 24 | git config user.email "github-action@huww98.cn" 25 | git config user.name "GitHub Action" 26 | git commit -m "Build" 27 | git push -f origin HEAD:gh-pages 28 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | cache: 'npm' 19 | - run: npm ci 20 | - run: npm test 21 | 22 | publish-npm: 23 | needs: build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: 18 30 | registry-url: https://registry.npmjs.org/ 31 | cache: 'npm' 32 | - run: npm ci 33 | - run: | 34 | TAG=$(node </**" 17 | ] 18 | }, 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 胡玮文 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Chart 2 | 3 | [![npm version](https://img.shields.io/npm/v/timechart.svg)](https://www.npmjs.com/package/timechart) 4 | [![GitHub Pages](https://github.com/huww98/TimeChart/workflows/GitHub%20Pages/badge.svg)](https://huww98.github.io/TimeChart/) 5 | 6 | An chart library specialized for large-scale time-series data, built on WebGL. 7 | 8 | Flexable. Realtime monitor. High performance interaction. 9 | 10 | [Live Demo](https://huww98.github.io/TimeChart/demo/) 11 | 12 | ## Performance 13 | 14 | Taking advantage of the newest WebGL technology, we can directly talk to GPU, pushing the limit of the performance of rendering chart in browser. This library can display almost unlimited data points, and handle user interactions (pan / zoom) at 60 fps. 15 | 16 | We compare the performance of this library and some other popular libraries. See [Performance](https://huww98.github.io/TimeChart/docs/performance) 17 | 18 | ## Usage 19 | 20 | ### Installation 21 | 22 | * Use npm 23 | 24 | ```shell 25 | npm install timechart 26 | ``` 27 | 28 | * Use HTML script tag 29 | 30 | This library depends on D3 to draw axes and something else. It needs to be included seperatedly. 31 | 32 | ```HTML 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ``` 44 | 45 | ### Basic 46 | 47 | Display a basic line chart with axes. 48 | 49 | ```HTML 50 |
51 | ``` 52 | ```JavaScript 53 | const el = document.getElementById('chart'); 54 | const data = []; 55 | for (let x = 0; x < 100; x++) { 56 | data.push({x, y: Math.random()}); 57 | } 58 | const chart = new TimeChart(el, { 59 | series: [{ data }], 60 | }); 61 | ``` 62 | [Live](https://huww98.github.io/TimeChart/demo/basic.html) 63 | 64 | ### Assemble Your Own Chart 65 | 66 | New in v1. 67 | 68 | TimeChart comes with a modular design. Almost all functions are implemented as plugins. 69 | You can pick the plugins you need, so that you don't pay for functions you don't use. 70 | 71 | Offical plugins: 72 | * lineChart: draw the line chart with WebGL, the biggest selling point of this library. 73 | * d3Axis: intergret with [d3-axis](https://github.com/d3/d3-axis) to draw the axes. 74 | * legend: show a legend at top-right. 75 | * crosshair: show crosshair under the mouse. 76 | * nearestPoint: highlight the data points in each series that is nearest to the mouse. 77 | * chartZoom: respond to mouse, keyboard, touch event to zoom/pan the chart. See also [the interaction method](#interaction) 78 | 79 | As an example, to assemble your own chart with all offical plugins added: 80 | ```JavaScript 81 | import TimeChart from 'timechart/core'; 82 | import { lineChart } from 'timechart/plugins/lineChart'; 83 | import { d3Axis } from 'timechart/plugins/d3Axis'; 84 | import { legend } from 'timechart/plugins/legend'; 85 | import { crosshair } from 'timechart/plugins/crosshair'; 86 | import { nearestPoint } from 'timechart/plugins/nearestPoint'; 87 | import { TimeChartZoomPlugin } from 'timechart/plugins/chartZoom'; 88 | 89 | const el = document.getElementById('chart'); 90 | const chart = new TimeChart(el, { 91 | data: {...}, 92 | plugins: { 93 | lineChart, 94 | d3Axis, 95 | legend, 96 | crosshair, 97 | nearestPoint, 98 | zoom: new TimeChartZoomPlugin({...}), 99 | tooltip: new TimeChartTooltipPlugin({...}), 100 | } 101 | }); 102 | ``` 103 | 104 | This is almost equivalent to just `import TimeChart from 'timechart';`, except: 105 | * The [zoom options](#zoom-options) are now passed directly to `TimeChartZoomPlugin`. 106 | * To change the zoom options dynamically, use `chart.plugins.zoom.options` instead of original `chart.options.zoom`. 107 | 108 | You can also write your own plugins. Read [the guide](docs/authoring_plugins). 109 | 110 | For users who use HTML script tag to import TimeChart, use this instead: 111 | 112 | ```HTML 113 | 114 | 115 | 125 | ``` 126 | [Demo](https://huww98.github.io/TimeChart/demo/plugins/assemble.html) 127 | 128 | ### Dynamic Data 129 | 130 | To add/remove data dynamically, just change the data array with conventional array prototype methods, then call `chart.update()`. 131 | 132 | Some restrictions to the data manipulations: 133 | * The prototype of data array will be overridden. The length of this array can only be modified with the following overrode array prototype method: `push`, `pop`, `shift`, `unshift`, `splice`. 134 | The behavior is undefined if the length of the array is changed by any other means. 135 | * Once you call `update`, the data will be synchronized to GPU. Then these data cannot be modified, and can only be deleted from both ends. 136 | Illegal modification by the overrode `splice` prototype method will lead to an exception. Other Illegal modifications will lead to undefined behavior. 137 | * Any data that has not been synchronized to GPU can be modified at will. 138 | ```JavaScript 139 | const data = [...]; // Assume it contains 10 data points 140 | const chart = new TimeChart(el, { 141 | series: [{ data }], 142 | }); 143 | data.push({x, y}, {x, y}, {x, y}); // OK 144 | data.splice(-2, 1); // OK, data not synced yet 145 | chart.update(); 146 | 147 | data.splice(-2, 1); // RangeError 148 | data.splice(-2, 2); // OK, delete the last two data points 149 | data.pop(); // OK, delete the last data point 150 | data.splice(0, 2); // OK, delete the first two data points 151 | chart.update(); // See data deleted 152 | 153 | Array.prototype.pop.call(data) // Wrong. Only the overridden methods should be used 154 | data.length = 3; // Wrong. Changes cannot be tracked 155 | data[3] = {x, y}; // Wrong unless data[3] is already in array and not synced to GPU 156 | ``` 157 | * The x value of each data point must be monotonically increasing. 158 | * Due to the limitation of single-precision floating-point numbers, if the absolute value of x is large (e.g. `Date.now()`), you may need to use `baseTime` option (see below) to make the chart render properly. 159 | ```JavaScript 160 | let startTime = Date.now(); // Set the start time e.g. 1626186924936 161 | 162 | let bar = []; // holds the series data 163 | 164 | // build the chart 165 | const chart = new TimeChart(el, { 166 | series: [{ 167 | name: 'foo', 168 | data: bar 169 | }], 170 | baseTime: startTime, 171 | }); 172 | 173 | // update data 174 | bar.push({x: 1, y: 10}); // 1ms after start time 175 | bar.push({x: 43, y: 6.04}); // 43ms after start time 176 | bar.push({x: 89, y: 3.95}); // 89ms after start time 177 | 178 | // update chart 179 | chart.update(); 180 | ``` 181 | 182 | ### Global Options 183 | 184 | Specify these options in top level option object. e.g. to specify `lineWidth`: 185 | ```JavaScript 186 | const chart = new TimeChart(el, { 187 | series: [{ data }], 188 | lineWidth: 10, 189 | }); 190 | ``` 191 | 192 | * lineWidth (number): default line width for every data series. 193 | 194 | default: 1 195 | 196 | * backgroundColor (CSS color specifier or [d3-color](https://github.com/d3/d3-color) instance) 197 | 198 | default: 'transparent' 199 | 200 | * color (CSS color specifier or [d3-color](https://github.com/d3/d3-color) instance): line color 201 | 202 | default: `color` CSS property value at initialization. 203 | 204 | * paddingTop / paddingRight / paddingLeft / paddingBottom (number): Padding to add to chart area in CSS pixel. Also reserve space for axes. 205 | 206 | default: 10 / 10 / 45 / 20 207 | 208 | * renderPaddingTop / renderPaddingRight / renderPaddingLeft / renderPaddingBottom (number): Like the padding* counterpart, but for WebGL rendering canvas. 209 | 210 | default: 0 211 | 212 | * xRange / yRange ({min: number, max: number} or 'auto'): The range of x / y axes. Also use this to control pan / zoom programmatically. Specify `'auto'` to calculate these range from data automatically. Data points outside these range will be drawn in padding area, to display as much data as possible to user. 213 | 214 | default: 'auto' 215 | 216 | * realTime (boolean): If true, move xRange to newest data point at every frame. 217 | 218 | default: false 219 | 220 | * baseTime (number): Milliseconds since `new Date(0)`. Every x in data are relative to this. Set this option and keep the absolute value of x small for higher floating point precision. 221 | 222 | default: 0 223 | 224 | * xScaleType (() => Scale): A factory method that returns an object conforming d3-scale interface. Can be used to customize the appearance of x-axis. 225 | [`scaleTime`](https://github.com/d3/d3-scale#time-scales), 226 | [`scaleUtc`](https://github.com/d3/d3-scale#scaleUtc), 227 | [`scaleLinear`](https://github.com/d3/d3-scale#linear-scales) 228 | from d3-scale are known to work. 229 | 230 | default: d3.scaleTime 231 | 232 | * debugWebGL (boolean): If true, detect any error in WebGL calls. Most WebGL calls are asynchronized, and detecting error will force synchronization, which may slows down the program. Mainly used in development of this library. 233 | 234 | default: false 235 | 236 | * legend (boolean): If true, show the legend. 237 | 238 | default: true 239 | 240 | ### Series Options 241 | 242 | Specify these options in series option object. e.g. to specify `lineWidth`: 243 | ```JavaScript 244 | const chart = new TimeChart(el, { 245 | series: [{ 246 | data, 247 | lineWidth: 10, 248 | }], 249 | }); 250 | ``` 251 | 252 | * data ({x: number, y: number}[]): Array of data points to be drawn. `x` is the time elapsed in millisecond since `baseTime` 253 | 254 | * lineWidth (number or undefined): If undefined, use global option. 255 | 256 | default: undefined 257 | 258 | * lineType (number): Take one of the following: 259 | * LineType.Line: straight lines connecting data points 260 | * LineType.Step: step function, only horizontal and vertical lines 261 | * LineType.NativeLine: like LineType.Line, but use native WebGL line drawing capability. 262 | This is faster than LineType.Line, but the line width is fixed at 1 pixel on most devices. 263 | * LineType.NativePoint: draw points at each data point using native WebGL point drawing capability. 264 | lineWidth is reused to specify point size in this case. 265 | 266 | default: LineType.Line 267 | 268 | * stepLocation (number): Only effective if `lineType === LineType.Step`. Where to draw the vertical line. Specified as a ratio of the distance between two adjacent data points. Usually in the range of [0, 1]. 269 | 270 | default: 0.5 271 | 272 | * name (string): The name of the series. Will be shown in legend and tooltips. 273 | 274 | default: '' 275 | 276 | * color (CSS color specifier or [d3-color](https://github.com/d3/d3-color) instance or undefined): line color. If undefined, use global option. 277 | 278 | default: undefined 279 | 280 | * visible (boolean): Whether this series is visible 281 | 282 | default: true 283 | 284 | ### Zoom Options 285 | 286 | These options enable the builtin touch / mouse / trackpad [interaction](#interaction) support. The x, y axis can be enabled separately. 287 | 288 | Specify these options in zoom option object. e.g. to specify `autoRange`: 289 | ```JavaScript 290 | const chart = new TimeChart(el, { 291 | series: [{ data }], 292 | zoom: { 293 | x: { 294 | autoRange: true, 295 | }, 296 | y: { 297 | autoRange: true, 298 | } 299 | } 300 | }); 301 | ``` 302 | 303 | New in v1. If you are [using the plugins](#assemble_your_own_chart), pass these options to the `TimeChartZoomPlugin` plugin. 304 | ```JavaScript 305 | import TimeChart from 'timechart/core'; 306 | import { TimeChartZoomPlugin } from 'timechart/plugins/chartZoom'; 307 | const chart = new TimeChart(el, { 308 | series: [{ data }], 309 | plugins: { 310 | zoom: new TimeChartZoomPlugin({x: {autoRange: true}}) 311 | }, 312 | }); 313 | ``` 314 | Then old `chart.options.chart` is not available. Use `chart.plugins.zoom.options` instead. 315 | 316 | 317 | * panMouseButtons (number): allowed mouth buttons to trigger panning. see [MouseEvent.buttons](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons) 318 | 319 | default: 1 | 2 | 4 320 | 321 | * touchMinPoints (number): minimum number of touch points needed for the chart to respond to the gesture. Useful if you want to reserve some gesture for other plugins (e.g. selectZoom). 322 | 323 | default: 1 324 | 325 | * autoRange (boolean): Per axis. Determine maxDomain, minDomain automatically. 326 | 327 | default: false 328 | 329 | * maxDomain / minDomain (number): Per axis. The limit of xRange / yRange 330 | 331 | default: Infinity / -Infinity 332 | 333 | * maxDomainExtent / minDomainExtent (number): Per axis. The limit of `max - min` in xRange / yRange 334 | 335 | default: Infinity / 0 336 | 337 | ### Tooltip Options 338 | 339 | ```JavaScript 340 | const chart = new TimeChart({ 341 | ..., 342 | tooltip: { enabled: true } 343 | }) 344 | ``` 345 | Or 346 | ```JavaScript 347 | import TimeChart from 'timechart/core'; 348 | import { TimeChartTooltipPlugin } from 'timechart/plugins/tooltip'; 349 | const chart = new TimeChart(el, { 350 | ..., 351 | plugins: { 352 | tooltip: new TimeChartTooltipPlugin({ enabled: true, xLabel: 'Time' }) 353 | }, 354 | }); 355 | ``` 356 | 357 | * enabled (boolean): Whether to enable the tooltip on hover 358 | 359 | default: false 360 | 361 | * xLabel (string): Label for the X axis in the tooltip 362 | 363 | default: "X" 364 | 365 | * xFormatter ((number) => string): Function to format the X axis value in the tooltip 366 | 367 | default: x => x.toLocaleString() 368 | 369 | ### Methods 370 | 371 | * `chart.update()`: Request update after some options have been changed. You can call this as many times as needed. The actual update will only happen once per frame. 372 | 373 | * `chart.dispose()`: Dispose all the resources used by this chart instance. 374 | Note: We use shadow root to protect the chart from unintended style conflict. However, there is no easy way to remove the shadow root after dispose. 375 | But you can reuse the same HTML element to create another TimeChart. [Example](https://huww98.github.io/TimeChart/demo/reset.html) 376 | 377 | * `chart.onResize()`: Calculate size after layout changes. 378 | This method is automatically called when window size changed. 379 | However, if there are some layout changes that TimeChart is unaware of, you need to call this method manually. 380 | 381 | ## Interaction 382 | 383 | With touch screen: 384 | * 1 finger to pan 385 | * 2 or more finger to pan and zoom 386 | 387 | With mouse: 388 | * Left button drag to pan 389 | * wheel scroll translate X axis 390 | * Alt + wheel scroll to translate Y axis 391 | * Ctrl + wheel scroll to zoom X axis 392 | * Ctrl + Alt + wheel scroll to zoom Y axis 393 | * Hold Shift key to speed up translate or zoom 5 times 394 | 395 | With trackpad: 396 | * Pan X or Y direction to translate X axis 397 | * Alt + Pan X/Y direction to translate X/Y axis 398 | * Pinch to zoom X axis 399 | * Alt + pinch to zoom Y axis 400 | * Hold Shift key to speed up translate or zoom 5 times 401 | 402 | ## Styling 403 | 404 | The chart is in a shadow root so that most CSS in the main document can not affect it. But we do provide some styling interface. 405 | 406 | For example, we can support dark theme easily: 407 | 408 | ```HTML 409 |
410 | ``` 411 | ```CSS 412 | .dark-theme { 413 | color: white; 414 | background: black; 415 | --background-overlay: black; 416 | } 417 | ``` 418 | 419 | [Live](https://huww98.github.io/TimeChart/demo/dark.html) 420 | 421 | The `--background-overlay` CSS property is used in some non-transparent element on top on the chart. 422 | 423 | The background of the chart is transparent by default. 424 | So it's easy to change the background by setting the background of parent element. 425 | 426 | All foreground elements will change color to match the `color` CSS property. 427 | However, chart is drawn in canvas and cannot respond to CSS property changes. 428 | You need to change the color manually if you want to change the `color` after initialiation. 429 | 430 | ## Development 431 | 432 | * run `npm install` to install dependencies 433 | * run `npm start` to automatically build changes 434 | * run `npm run demo` then open http://127.0.0.1:8080/demo/index.html to test changes 435 | * run `npm test` to run automatic tests 436 | -------------------------------------------------------------------------------- /benchmark/first_paint/chartjs-master.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Benchmark Chart.JS master first paint 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /benchmark/first_paint/timechart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Benchmark TimeChart first paint 8 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /benchmark/first_paint/uplot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Benchmark μPlot first paint 8 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /benchmark/interaction/chartjs-master.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Benchmark Chart.JS master interaction 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /benchmark/interaction/timechart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Benchmark TimeChart interaction 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /benchmark/interaction/uplot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Benchmark μPlot interaction 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /benchmark/utils.js: -------------------------------------------------------------------------------- 1 | function f(x) { 2 | return Math.random() - 0.5 + Math.sin(x * 0.00002) * 40 + Math.sin(x * 0.001) * 5 + Math.sin(x * 0.1) * 2; 3 | } 4 | 5 | function getData(max) { 6 | const data = []; 7 | for (let x = 0; x < max; x++) { 8 | data.push({ x: x * 1000, y: f(x) }); 9 | } 10 | return data; 11 | } 12 | 13 | function getUPlotData(max) { 14 | const data = [[], []]; 15 | for (let x = 0; x < max; x++) { 16 | data[0].push(x); 17 | data[1].push(f(x)); 18 | } 19 | return data; 20 | } 21 | 22 | function simulateInteraction(startXRange, endXRange, frames, cb) { 23 | let lastTime = 0; 24 | let currentXRange = startXRange; 25 | let warmUpFrames = 30; 26 | const factor = (endXRange / startXRange) ** (1 / frames); 27 | const pref = { 28 | xRange: [], 29 | time: [], 30 | } 31 | function nextFrame(time) { 32 | if (warmUpFrames > 0) { 33 | warmUpFrames--; 34 | currentXRange *= factor; 35 | cb(currentXRange); 36 | requestAnimationFrame(nextFrame); 37 | } else if (warmUpFrames == 0) { 38 | warmUpFrames--; 39 | lastTime = time; 40 | currentXRange = startXRange 41 | cb(currentXRange); 42 | console.time('interaction'); 43 | requestAnimationFrame(nextFrame); 44 | } else if (frames > 0) { 45 | frames--; 46 | currentXRange *= factor; 47 | cb(currentXRange); 48 | pref.time.push(time - lastTime); 49 | pref.xRange.push(currentXRange); 50 | lastTime = time; 51 | requestAnimationFrame(nextFrame); 52 | } else { 53 | console.timeEnd('interaction'); 54 | console.log(JSON.stringify(pref)); 55 | } 56 | } 57 | 58 | setTimeout(() => { 59 | requestAnimationFrame(nextFrame) 60 | }, 2000); 61 | } 62 | -------------------------------------------------------------------------------- /demo/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Basic) 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /demo/dark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Dark Theme) 8 | 15 | 16 | 17 |

TimeChart Demo

18 |

Dark Theme

19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | main(); 2 | 3 | function main() { 4 | const el = document.getElementById('chart'); 5 | const dataSin = []; 6 | const dataCos = []; 7 | const baseTime = Date.now() - performance.now() 8 | const chart = new TimeChart(el, { 9 | // debugWebGL: true, 10 | // forceWebGL1: true, 11 | baseTime, 12 | series: [ 13 | { 14 | name: 'Sin', 15 | data: dataSin, 16 | }, 17 | { 18 | name: 'Cos', 19 | data: dataCos, 20 | lineWidth: 2, 21 | color: 'red', 22 | }, 23 | ], 24 | xRange: { min: 0, max: 20 * 1000 }, 25 | realTime: true, 26 | zoom: { 27 | x: { 28 | autoRange: true, 29 | minDomainExtent: 50, 30 | }, 31 | y: { 32 | autoRange: true, 33 | minDomainExtent: 1, 34 | } 35 | }, 36 | tooltip: { 37 | enabled: true, 38 | xFormatter: (x) => new Date(x + baseTime).toLocaleString([], {hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3}), 39 | }, 40 | }); 41 | const pointCountEl = document.getElementById('point-count'); 42 | 43 | let x = performance.now() - 20*1000; 44 | function update() { 45 | const time = performance.now(); 46 | for (; x < time; x += 1) { 47 | // const y = Math.random() * 500 + 100; 48 | const y_sin = Math.sin(x * 0.002) * 320; 49 | dataSin.push({ x, y: y_sin }); 50 | 51 | const y_cos = Math.cos(x * 0.002) * 200; 52 | dataCos.push({ x, y: y_cos }); 53 | } 54 | pointCountEl.innerText = dataSin.length; 55 | chart.update(); 56 | } 57 | 58 | const ev = setInterval(update, 5); 59 | document.getElementById('stop-btn').addEventListener('click', function () { 60 | clearInterval(ev); 61 | }); 62 | document.getElementById('follow-btn').addEventListener('click', function () { 63 | chart.options.realTime = true; 64 | }); 65 | document.getElementById('legend-btn').addEventListener('click', function () { 66 | chart.options.legend = !chart.options.legend; 67 | chart.update(); 68 | }); 69 | document.getElementById('tooltip-btn').addEventListener('click', function () { 70 | chart.options.tooltip.enabled = !chart.options.tooltip.enabled; 71 | }); 72 | 73 | paddingDirs = ['Top', 'Right', 'Bottom', 'Left']; 74 | for (const d of paddingDirs) { 75 | const i = document.getElementById('padding-' + d.toLowerCase()); 76 | const propName = 'padding' + d 77 | i.textContent = chart.options[propName]; 78 | } 79 | for (const d of paddingDirs) { 80 | /** @type {HTMLInputElement} */ 81 | const i = document.getElementById('render-padding-' + d.toLowerCase()); 82 | const propName = 'renderPadding' + d 83 | i.value = chart.options[propName]; 84 | i.addEventListener('change', () => { 85 | chart.options[propName] = parseFloat(i.value); 86 | chart.update(); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /demo/dynamic_data.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Dynamic Data) 8 | 9 | 10 |

Data points can be added or removed dynamically.

11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | TimeChart Demo 14 | 31 | 32 | 33 |

TimeChart Demo

34 |

1000 points / second adding to chart.

35 |

0 Points

36 |
37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /demo/large_data_range.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Large data range) 8 | 9 | 10 |

Test if we have precision issue with very large X

11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /demo/line_style.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Line Style) 8 | 9 | 10 |

All line style can be dynamically changed.

11 |
12 |
13 | 17 | 26 | 30 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /demo/not_aligned.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Not aligned) 8 | 9 | 10 |

X values from different series are not nessesarily aligned.

11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /demo/plugins/assemble.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Assemble Demo 8 | 9 | 10 |

Assemble Demo

11 |

This chart only contains very basic function. And have reduced dependency on D3

12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /demo/plugins/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Events Plugin Demo 8 | 9 | 10 |

Events Plugin Demo

11 |
12 | 13 |

Add New Event

14 |
15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /demo/plugins/select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Select Zoom Demo 8 | 9 | 10 |

Select & Zoom with Mouse

11 |

Drag with your mouse primary button. Use middle mouse button to pan.

12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /demo/reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TimeChart Demo (Reset) 8 | 9 | 10 |

11 | Demostrate how to dispose a chart, and reuse the same HTML element to create another TimeChart. 12 |

13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /docs/authoring_plugins.md: -------------------------------------------------------------------------------- 1 | # Authoring Plugins 2 | 3 | TimeChart comes with a simple but flexible plugin system to help you customize the chart to whatever you want. 4 | 5 | A plugin is a JavaScript object with a function named `apply`. 6 | This function takes a single argument, the current TimeChart instance. 7 | 8 | And the return value of this function will be added to `chart.plugins` object. You can use this interact with code outside the plugin. 9 | ```JavaScript 10 | import TimeChart from 'timechart'; 11 | const demoPlugin = { 12 | apply(chart) { 13 | // Do whatever you want 14 | return 'demo'; 15 | } 16 | }; 17 | const chart = new TimeChart(el, { 18 | plugins: { demoPlugin }, 19 | }); 20 | chart.plugins.demoPlugin === 'demo'; // true 21 | ``` 22 | 23 | The same plugin instance may be applied to multiple chart instance. 24 | 25 | Inside the `apply` function. The plugin can use many advanced APIs documented below. 26 | 27 | ## Event 28 | 29 | TimeChart implemented a simple event mechanism to communicate with plugins. For example, to subscribe to an event: 30 | 31 | ```JavaScript 32 | chart.model.updated.on(() => console.log('chart updated')); 33 | chart.model.resized.on((w, h) => console.log(`chart resized to ${w}x${h}`)); 34 | ``` 35 | 36 | The following document use TypeScript notation to denote the event signature. For example: `Event<(width: number, height: number) => void>` means this is an event, the listener of it will get two arguments, each of type number; and the listener should not return anything. 37 | 38 | ## APIs 39 | 40 | * chart.model.updated (`Event<() => void>`): Triggered when the chart should render a new frame, e.g. new data arrived, viewport changed. This will be triggered at most once per frame 41 | 42 | * chart.model.resized (`Event<(width: number, height: number) => void>`): Triggered when the browser window resized, or `chart.onResize()` is invoked. the new dimention of the chart is passed in. 43 | 44 | * chart.model.disposing (`Event<() => void>`): Triggered when `chart.dispose()` is invoked. Plugins should remove any event listener added to DOM, remove any element directly attached to `chart.el`; 45 | 46 | * chart.model.xScale, chart.model.yScale ([`d3.scaleLinear`](https://github.com/d3/d3-scale#scaleLinear)): used to translate between data points and coordinate used in HTML/SVG. 47 | 48 | * chart.model.pxPoint() (`({x: number, y: number}) => {x: number, y: number}`): a helper to get the coordinate used in HTML/SVG from data point. 49 | 50 | * chart.model.xRange, chart.model.yRange (`{min: number, max: number}`): The range of data points. 51 | 52 | * chart.canvasLayer.gl: The [WebGL rendering context](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext). [WebGL 2](https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext) if avaliable. 53 | 54 | * chart.svgLayer.svgNode: Top level [SVG node](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg). 55 | 56 | * chart.contentBoxDetector.node: an empty HTML element that is positioned only on non-padding area. plugins may want to add event listeners to it. 57 | 58 | * chart.nearestPoint.points (`Map`): The points in each series that is nearest to the mouse. In HTML coordinate. 59 | 60 | * chart.nearestPoint.updated (`Event<() => void>`): Like `chart.model.updated`, but only triggered when there are updates to `chart.nearestPoint.points`. 61 | 62 | ## Pass Options to Plugin 63 | 64 | Sometimes you may need to pass some extra options or data to plugins. The recommended way to do this is declare your plugin as a class and pass them in from constructor: 65 | 66 | ```JavaScript 67 | import TimeChart from 'timechart'; 68 | class DemoPlugin { 69 | constructor(options) { 70 | this.options = options; 71 | } 72 | apply(chart) { 73 | // Do whatever you want with this.options 74 | } 75 | }; 76 | const chart = new TimeChart(el, { 77 | plugins: { demoPlugin: new DemoPlugin({...}) }, 78 | }); 79 | ``` 80 | -------------------------------------------------------------------------------- /docs/first_paint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huww98/TimeChart/6d1d26c70f03a71e321a27c9bb49d2896693910b/docs/first_paint.png -------------------------------------------------------------------------------- /docs/graph_first_paint.py: -------------------------------------------------------------------------------- 1 | # start, prepare data, data ready, finish, FMP 2 | FP_DATA = { 3 | 'Chart.JS': [ 4 | [32.4, 45.1, 297.6, 1042.4, 1054.8], 5 | [33.6, 43.4, 285.1, 1041.6, 1064.5], 6 | [30.9, 40.9, 292.7, 1036.2, 1056.8], 7 | ], 8 | 'TimeChart': [ 9 | [29.9, 57.2, 255.2, 288.3, 853.2], 10 | [36.2, 43.9, 236.9, 271.5, 837.4], 11 | [34.9, 42.7, 252.4, 285.7, 871.9], 12 | ], 13 | 'μPlot': [ 14 | [29.6, 31.4, 189.8, 285.6, 335.3], 15 | [30.6, 32.8, 197.3, 305.5, 350.0], 16 | [37.9, 40.7, 209.7, 311.7, 351.7], 17 | ], 18 | } 19 | 20 | import matplotlib.pyplot as plt 21 | import numpy as np 22 | 23 | y = [] 24 | prepare_data = [] 25 | data_ready = [] 26 | finish = [] 27 | fmp = [] 28 | for lib in FP_DATA: 29 | data = np.array(FP_DATA[lib]) 30 | data = data[:, 1:] - data[:, 0:1] 31 | data = data.mean(axis=0) 32 | y.append(lib) 33 | prepare_data.append(data[0]) 34 | data_ready.append(data[1]) 35 | finish.append(data[2]) 36 | fmp.append(data[3]) 37 | 38 | plt.title('First Paint Time') 39 | plt.xlabel('ms') 40 | plt.barh(y, fmp, label='paint') 41 | plt.barh(y, finish, label='scripting') 42 | plt.barh(y, data_ready, label='prepare data') 43 | plt.barh(y, prepare_data, label='load script') 44 | plt.legend() 45 | plt.tight_layout() 46 | plt.savefig('docs/first_paint.png') 47 | -------------------------------------------------------------------------------- /docs/interaction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huww98/TimeChart/6d1d26c70f03a71e321a27c9bb49d2896693910b/docs/interaction.png -------------------------------------------------------------------------------- /docs/performance.md: -------------------------------------------------------------------------------- 1 | # Performance Tests 2 | 3 | Enviroment: All tests run on my laptop 4 | * CPU: Intel i7-6700HQ 5 | * Memory: 16GB 6 | * Browser: Chrome 80 7 | 8 | Tested libraries: 9 | * timechart: this repo, based on WebGL. 10 | * [μPlot](https://github.com/leeoniya/uPlot): An exceptionally fast, tiny time series & line chart 11 | * [Chart.JS](https://github.com/chartjs/Chart.js): Simple yet flexible JavaScript charting for designers & developers. In the tests, the unreleased version V3 is used. 12 | 13 | All test code is available in `benchmark` folder in this repo. 14 | 15 | ## Interaction 16 | 17 | This tests the FPS when user is interacting with chart (pan / zoom). 18 | 19 | This is the most exciting feature of TimeChart. All data is loaded into GPU memory. Almost all calculation required by pan or zoom is done by GPU, really fast. 20 | 21 | This test simulates user zooming in the chart 1000 frames. 22 | 23 | Method: 24 | * Open test page in Chrome in-private window (effectively disable all extensions) 25 | * Wait for the animation to finish, copy output from console. 26 | 27 | Result: 28 | 29 | ![Interaction result](./interaction.png) 30 | 31 | Using Intel integrated GPU, TimeChart performs slightly better than μPlot. Chart.JS is much slower, and it spent time on not rendered points. Using NVIDIA GPU, TimeChart is able to render 20x more data points than μPlot at 60 FPS. On mobile deivce (HUAWEI Mate 20 Android phone), TimeChart also performs better. 32 | 33 | Note that TimeChart renders every data points and results in smoother image. See [Render Comparation](#render-comparation) 34 | 35 | ## First Paint Test 36 | 37 | This test measures how long it will take for an chart to be ready since page start loading. 38 | 39 | 1M generated data points are provided to each library. 40 | 41 | Method: 42 | * Open test page in an Chrome in-private window (effectively disable all extensions), see it renders correctly. 43 | * In chrome dev-tools performance tab, disable JavaScript samples. 44 | * Click "Start profiling and reload page". 45 | * Verify all network requests hit memory cache. 46 | * Record 4 manual timestamp from "event log" and the timestamp of "First Meaningful Paint". 47 | * Repeat 3 times for each test. 48 | 49 | Result: 50 | 51 | ![First paint result](./first_paint.png) 52 | 53 | μPlot is really fast. TimeChart rendering is async, so the scripting time is very short. TimeChart is not very good at this, WebGL initialization takes more time. 54 | 55 | ## Render Comparation 56 | 57 | μPlot and Chart.JS downsamples data on the fly, while TimeChart renders every points. Taking advantages of this and GPU anti-aliasing, TimeChart renders smoother images. The following are images rendered in canvas. 58 | 59 | TimeChart: 60 | 61 | 62 | 63 | Chart.JS: 64 | 65 | 66 | 67 | μPlot: 68 | 69 | 70 | -------------------------------------------------------------------------------- /docs/pp.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | while True: 4 | out = [] 5 | for idx, line in enumerate(sys.stdin): 6 | if idx % 4 == 0: 7 | out.append(line[:-4]) 8 | if idx == 15: 9 | break 10 | print(f'[{", ".join(out)}, ],') 11 | -------------------------------------------------------------------------------- /docs/rendered/chartjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huww98/TimeChart/6d1d26c70f03a71e321a27c9bb49d2896693910b/docs/rendered/chartjs.png -------------------------------------------------------------------------------- /docs/rendered/timechart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huww98/TimeChart/6d1d26c70f03a71e321a27c9bb49d2896693910b/docs/rendered/timechart.png -------------------------------------------------------------------------------- /docs/rendered/uplot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huww98/TimeChart/6d1d26c70f03a71e321a27c9bb49d2896693910b/docs/rendered/uplot.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | tsconfig = require('./tsconfig.json').compilerOptions; 2 | tsconfig.types.push('jest') 3 | 4 | module.exports = { 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | transform: { 8 | '^.+\\.tsx?$': ['ts-jest', { tsconfig }], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timechart", 3 | "version": "1.0.0-beta.10", 4 | "description": "An chart library specialized for large-scale time-series data, built on WebGL.", 5 | "repository": { 6 | "url": "https://github.com/huww98/TimeChart.git", 7 | "type": "git" 8 | }, 9 | "main": "dist/timechart.umd.js", 10 | "module": "dist/lib/index.js", 11 | "types": "dist/lib/index.d.ts", 12 | "typesVersions": { 13 | "*": { 14 | "dist/*": [ 15 | "dist/*" 16 | ], 17 | "*": [ 18 | "dist/lib/*", 19 | "dist/lib/*/index" 20 | ] 21 | } 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "exports": { 27 | ".": "./dist/lib/index.js", 28 | "./*": "./dist/lib/*" 29 | }, 30 | "scripts": { 31 | "demo": "http-server -c1 -o /demo/index.html", 32 | "build": "tsc && rollup -c", 33 | "start": "rollup -c -w", 34 | "test": "tsd && jest", 35 | "prepare": "npm run build" 36 | }, 37 | "author": "huww98 ", 38 | "license": "MIT", 39 | "dependencies": { 40 | "d3-axis": "^3.0.0", 41 | "d3-color": "^3.0.1", 42 | "d3-scale": "^4.0.2", 43 | "d3-selection": "^3.0.0", 44 | "gl-matrix": "^3.3.0", 45 | "tslib": "^2.4.0" 46 | }, 47 | "devDependencies": { 48 | "@rollup/plugin-commonjs": "^25.0.1", 49 | "@rollup/plugin-node-resolve": "^15.1.0", 50 | "@rollup/plugin-terser": "^0.4.3", 51 | "@rollup/plugin-typescript": "^11.1.1", 52 | "@types/d3-axis": "^3.0.1", 53 | "@types/d3-color": "^3.0.2", 54 | "@types/d3-scale": "^4.0.1", 55 | "@types/d3-selection": "^3.0.1", 56 | "@types/jest": "^29.5.2", 57 | "http-server": "^14.0.0", 58 | "jest": "^29.5.0", 59 | "rollup": "^3.25.0", 60 | "ts-jest": "^29.1.0", 61 | "tsd": "^0.28.1", 62 | "typescript": "^5.1.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import typescript from '@rollup/plugin-typescript' 4 | import terser from "@rollup/plugin-terser"; 5 | 6 | import pkg from './package.json' assert {type: 'json'}; 7 | 8 | const ts = typescript({ compilerOptions: {outDir: 'dist', declaration: false}}) 9 | 10 | const config = { 11 | input: `src/index.ts`, 12 | output: [ 13 | { 14 | file: pkg.main, 15 | globals(id) { 16 | return id.startsWith('d3-') ? 'd3' : id; 17 | }, 18 | name: 'TimeChart', 19 | format: 'umd', 20 | sourcemap: true 21 | }, 22 | { 23 | file: 'dist/timechart.min.js', 24 | globals(id) { 25 | return id.startsWith('d3-') ? 'd3' : id; 26 | }, 27 | name: 'TimeChart', 28 | format: 'iife', 29 | plugins: [terser()], 30 | sourcemap: true 31 | }, 32 | { file: 'dist/timechart.module.js', format: 'es', sourcemap: true }, 33 | ], 34 | external: (id) => id.startsWith('d3-'), 35 | watch: { 36 | include: 'src/**', 37 | }, 38 | plugins: [ 39 | ts, 40 | commonjs(), 41 | resolve(), 42 | ], 43 | } 44 | 45 | const configPluginsExtra = { 46 | input: `src/plugins_extra/index.ts`, 47 | output: [ 48 | { 49 | file: 'dist/timechart.plugins_extra.js', 50 | globals(id) { 51 | return id.startsWith('d3-') ? 'd3' : id; 52 | }, 53 | name: 'TimeChart.plugins_extra', 54 | format: 'umd', 55 | sourcemap: true 56 | }, 57 | { 58 | file: 'dist/timechart.plugins_extra.min.js', 59 | globals(id) { 60 | return id.startsWith('d3-') ? 'd3' : id; 61 | }, 62 | name: 'TimeChart.plugins_extra', 63 | format: 'iife', 64 | plugins: [terser()], 65 | sourcemap: true 66 | }, 67 | ], 68 | external: (id) => id.startsWith('d3-'), 69 | watch: { 70 | include: 'src/plugins_extra/**', 71 | }, 72 | plugins: [ 73 | ts, 74 | commonjs(), 75 | resolve(), 76 | ], 77 | } 78 | 79 | export default [ 80 | config, 81 | configPluginsExtra, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/chartZoom/index.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from '../utils'; 2 | import { ChartZoomMouse } from './mouse'; 3 | import { CapableElement, ChartZoomOptions, ResolvedOptions } from "./options"; 4 | import { ChartZoomTouch } from './touch'; 5 | import { ChartZoomWheel } from './wheel'; 6 | 7 | export const defaultAxisOptions = { 8 | minDomain: -Infinity, 9 | maxDomain: Infinity, 10 | minDomainExtent: 0, 11 | maxDomainExtent: Infinity, 12 | } as const; 13 | 14 | export const defaultOptions = { 15 | panMouseButtons: 1 | 2 | 4, 16 | touchMinPoints: 1, 17 | } as const; 18 | 19 | export class ChartZoom { 20 | options: ResolvedOptions; 21 | private touch: ChartZoomTouch; 22 | private mouse: ChartZoomMouse; 23 | private wheel: ChartZoomWheel; 24 | private scaleUpdated = new EventDispatcher(); 25 | 26 | constructor(el: CapableElement, options?: ChartZoomOptions) { 27 | options = options ?? {}; 28 | this.options = new Proxy(options, { 29 | get(target, prop) { 30 | if (prop === 'x' || prop === 'y') { 31 | const op = target[prop]; 32 | if (!op) 33 | return op; 34 | return new Proxy(op, { 35 | get(target, prop) { 36 | return (target as any)[prop] ?? (defaultAxisOptions as any)[prop]; 37 | } 38 | }) 39 | } 40 | if (prop === 'eventElement') { 41 | return target[prop] ?? el; 42 | } 43 | return (target as any)[prop] ?? (defaultOptions as any)[prop]; 44 | } 45 | }) as ResolvedOptions; 46 | 47 | this.touch = new ChartZoomTouch(el, this.options); 48 | this.mouse = new ChartZoomMouse(el, this.options); 49 | this.wheel = new ChartZoomWheel(el, this.options); 50 | 51 | const cb = () => this.scaleUpdated.dispatch(); 52 | this.touch.scaleUpdated.on(cb); 53 | this.mouse.scaleUpdated.on(cb); 54 | this.wheel.scaleUpdated.on(cb); 55 | } 56 | 57 | onScaleUpdated(callback: () => void) { 58 | this.scaleUpdated.on(callback); 59 | } 60 | 61 | /** Call this when scale updated outside */ 62 | update() { 63 | this.touch.update(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/chartZoom/mouse.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from '../utils'; 2 | import { CapableElement, DIRECTION, dirOptions, Point, ResolvedOptions } from './options'; 3 | import { applyNewDomain, scaleK } from './utils'; 4 | 5 | export class ChartZoomMouse { 6 | public scaleUpdated = new EventDispatcher(); 7 | private previousPoint: Point | null = null; 8 | 9 | constructor(private el: CapableElement, private options: ResolvedOptions) { 10 | const eventEl = options.eventElement; 11 | eventEl.style.userSelect = 'none'; 12 | eventEl.addEventListener('pointerdown', ev => this.onMouseDown(ev)); 13 | eventEl.addEventListener('pointerup', ev => this.onMouseUp(ev)); 14 | eventEl.addEventListener('pointermove', ev => this.onMouseMove(ev)); 15 | } 16 | 17 | private point(ev: MouseEvent) { 18 | const boundingRect = this.el.getBoundingClientRect(); 19 | return { 20 | [DIRECTION.X]: ev.clientX - boundingRect.left, 21 | [DIRECTION.Y]: ev.clientY - boundingRect.top, 22 | }; 23 | } 24 | 25 | private onMouseMove(event: PointerEvent) { 26 | if (this.previousPoint === null) { 27 | return; 28 | } 29 | const p = this.point(event); 30 | let changed = false; 31 | for (const { dir, op } of dirOptions(this.options)) { 32 | const offset = p[dir] - this.previousPoint[dir]; 33 | const k = scaleK(op.scale); 34 | const domain = op.scale.domain(); 35 | const newDomain = domain.map(d => d - k * offset); 36 | if (applyNewDomain(op, newDomain)) { 37 | changed = true; 38 | } 39 | } 40 | this.previousPoint = p; 41 | if (changed) { 42 | this.scaleUpdated.dispatch(); 43 | } 44 | } 45 | 46 | private onMouseDown(event: PointerEvent) { 47 | if (event.pointerType !== 'mouse') 48 | return; 49 | if ((event.buttons & this.options.panMouseButtons) === 0) 50 | return; 51 | const eventEl = this.options.eventElement; 52 | eventEl.setPointerCapture(event.pointerId); 53 | this.previousPoint = this.point(event); 54 | eventEl.style.cursor = 'grabbing'; 55 | } 56 | 57 | private onMouseUp(event: PointerEvent) { 58 | if (this.previousPoint === null) { 59 | return; 60 | } 61 | const eventEl = this.options.eventElement; 62 | this.previousPoint = null 63 | eventEl.releasePointerCapture(event.pointerId); 64 | eventEl.style.cursor = ''; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/chartZoom/options.ts: -------------------------------------------------------------------------------- 1 | import { ScaleLinear } from "d3-scale"; 2 | 3 | export enum DIRECTION { 4 | UNKNOWN, X, Y, 5 | } 6 | 7 | export interface Point { 8 | [DIRECTION.X]: number; 9 | [DIRECTION.Y]: number; 10 | } 11 | 12 | export interface AxisOptions { 13 | scale: ScaleLinear; 14 | minDomain?: number; 15 | maxDomain?: number; 16 | minDomainExtent?: number; 17 | maxDomainExtent?: number; 18 | } 19 | 20 | export interface ResolvedAxisOptions { 21 | scale: ScaleLinear; 22 | minDomain: number; 23 | maxDomain: number; 24 | minDomainExtent: number; 25 | maxDomainExtent: number; 26 | } 27 | 28 | export interface ResolvedOptions { 29 | x?: ResolvedAxisOptions; 30 | y?: ResolvedAxisOptions; 31 | panMouseButtons: number; 32 | touchMinPoints: number; 33 | eventElement: CapableElement; 34 | } 35 | 36 | export interface ChartZoomOptions { 37 | x?: AxisOptions; 38 | y?: AxisOptions; 39 | panMouseButtons?: number; 40 | touchMinPoints?: number; 41 | eventElement?: CapableElement; 42 | } 43 | 44 | export interface CapableElement extends Element, ElementCSSInlineStyle { 45 | addEventListener(type: K, listener: (this: CapableElement, ev: GlobalEventHandlersEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; 46 | }; 47 | 48 | export function dirOptions(options: ResolvedOptions) { 49 | return [ 50 | { dir: DIRECTION.X, op: options.x }, 51 | { dir: DIRECTION.Y, op: options.y }, 52 | ].filter(i => i.op !== undefined) as {dir: DIRECTION.X | DIRECTION.Y, op: ResolvedAxisOptions}[]; 53 | } 54 | -------------------------------------------------------------------------------- /src/chartZoom/touch.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from '../utils'; 2 | import { CapableElement, DIRECTION, dirOptions, Point, ResolvedAxisOptions, ResolvedOptions } from './options'; 3 | import { applyNewDomain, linearRegression, scaleK, variance } from './utils'; 4 | 5 | export class ChartZoomTouch { 6 | public scaleUpdated = new EventDispatcher(); 7 | 8 | private majorDirection = DIRECTION.UNKNOWN; 9 | private previousPoints = new Map(); 10 | private enabled = { 11 | [DIRECTION.X]: false, 12 | [DIRECTION.Y]: false, 13 | }; 14 | 15 | constructor(private el: CapableElement, private options: ResolvedOptions) { 16 | const eventEl = options.eventElement; 17 | eventEl.addEventListener('touchstart', e => this.onTouchStart(e), { passive: true }); 18 | eventEl.addEventListener('touchend', e => this.onTouchEnd(e), { passive: true }); 19 | eventEl.addEventListener('touchcancel', e => this.onTouchEnd(e), { passive: true }); 20 | eventEl.addEventListener('touchmove', e => this.onTouchMove(e), { passive: true }); 21 | 22 | this.update(); 23 | } 24 | 25 | update() { 26 | this.syncEnabled(); 27 | this.syncTouchAction(); 28 | } 29 | 30 | private syncEnabled() { 31 | for (const { dir, op } of dirOptions(this.options)) { 32 | if (!op) { 33 | this.enabled[dir] = false; 34 | } else { 35 | const domain = op.scale.domain().sort(); 36 | this.enabled[dir] = op.minDomain < domain[0] && domain[1] < op.maxDomain; 37 | } 38 | } 39 | } 40 | 41 | private syncTouchAction() { 42 | const actions = []; 43 | if (!this.enabled[DIRECTION.X]) { 44 | actions.push('pan-x'); 45 | } 46 | if (!this.enabled[DIRECTION.Y]) { 47 | actions.push('pan-y'); 48 | } 49 | if (actions.length === 0) { 50 | actions.push('none'); 51 | } 52 | this.el.style.touchAction = actions.join(' ') 53 | } 54 | 55 | private calcKB(dir: DIRECTION, op: ResolvedAxisOptions, data: { current: number; domain: number}[]) { 56 | if (dir === this.majorDirection && data.length >= 2) { 57 | const domain = op.scale.domain(); 58 | const extent = domain[1] - domain[0]; 59 | if (variance(data.map(d => d.domain)) > 1e-4 * extent * extent) { 60 | return linearRegression(data.map(t => ({ x: t.current, y: t.domain }))); 61 | } 62 | } 63 | // Pan only 64 | const k = scaleK(op.scale); 65 | const b = data.map(t => t.domain - k * t.current).reduce((a, b) => a + b) / data.length; 66 | return { k, b }; 67 | } 68 | 69 | private touchPoints(touches: TouchList) { 70 | if (touches.length < this.options.touchMinPoints) { 71 | this.previousPoints.clear(); 72 | return; 73 | } 74 | const boundingBox = this.el.getBoundingClientRect(); 75 | const ts = new Map([...touches].map(t => [t.identifier, { 76 | [DIRECTION.X]: t.clientX - boundingBox.left, 77 | [DIRECTION.Y]: t.clientY - boundingBox.top, 78 | }])); 79 | let changed = false 80 | for (const {dir, op} of dirOptions(this.options)) { 81 | const scale = op.scale; 82 | const temp = [...ts.entries()].map(([id, p]) => ({ current: p[dir], previousPoint: this.previousPoints.get(id) })) 83 | .filter(t => t.previousPoint !== undefined) 84 | .map(({ current, previousPoint }) => ({ current, domain: scale.invert(previousPoint![dir]) })); 85 | if (temp.length === 0) { 86 | continue; 87 | } 88 | const { k, b } = this.calcKB(dir, op, temp); 89 | const domain = scale.range().map(r => b + k * r); 90 | if (applyNewDomain(op, domain)) { 91 | changed = true; 92 | } 93 | } 94 | this.previousPoints = ts; 95 | 96 | if (changed) { 97 | this.scaleUpdated.dispatch(); 98 | } 99 | return changed; 100 | } 101 | 102 | private dirOptions(dir: DIRECTION.X | DIRECTION.Y) { 103 | return { 104 | [DIRECTION.X]: this.options.x, 105 | [DIRECTION.Y]: this.options.y, 106 | }[dir]; 107 | } 108 | 109 | private onTouchStart(event: TouchEvent) { 110 | if (this.majorDirection === DIRECTION.UNKNOWN && event.touches.length >= 2) { 111 | const ts = [...event.touches]; 112 | function vari(data: number[]) { 113 | const mean = data.reduce((a, b) => a + b) / data.length; 114 | return data.map(d => (d - mean) ** 2).reduce((a, b) => a + b); 115 | } 116 | const varX = vari(ts.map(t => t.clientX)); 117 | const varY = vari(ts.map(t => t.clientY)); 118 | this.majorDirection = varX > varY ? DIRECTION.X : DIRECTION.Y; 119 | if (this.dirOptions(this.majorDirection) === undefined) { 120 | this.majorDirection = DIRECTION.UNKNOWN; 121 | } 122 | } 123 | this.touchPoints(event.touches); 124 | } 125 | 126 | private onTouchEnd(event: TouchEvent) { 127 | if (event.touches.length === 0) { 128 | this.majorDirection = DIRECTION.UNKNOWN; 129 | } 130 | this.touchPoints(event.touches); 131 | } 132 | 133 | private onTouchMove(event: TouchEvent) { 134 | this.touchPoints(event.touches); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/chartZoom/utils.ts: -------------------------------------------------------------------------------- 1 | import { ScaleLinear } from 'd3-scale'; 2 | import { ResolvedAxisOptions } from './options'; 3 | 4 | export function zip(...rows: [T1[], T2[]]) { 5 | return [...rows[0]].map((_, c) => rows.map(row => row[c])) as [T1, T2][]; 6 | } 7 | 8 | /** 9 | * least squares 10 | * 11 | * beta^T = [b, k] 12 | * X = [[1, x_1], 13 | * [1, x_2], 14 | * [1, x_3], ...] 15 | * Y^T = [y_1, y_2, y_3, ...] 16 | * beta = (X^T X)^(-1) X^T Y 17 | * @returns `{k, b}` 18 | */ 19 | export function linearRegression(data: { x: number, y: number }[]) { 20 | let sumX = 0; 21 | let sumY = 0; 22 | let sumXY = 0; 23 | let sumXX = 0; 24 | const len = data.length; 25 | 26 | for (const p of data) { 27 | sumX += p.x; 28 | sumY += p.y; 29 | sumXY += p.x * p.y; 30 | sumXX += p.x * p.x; 31 | } 32 | const det = (len * sumXX) - (sumX * sumX); 33 | const k = det === 0 ? 0 : ((len * sumXY) - (sumX * sumY)) / det; 34 | const b = (sumY - k * sumX) / len; 35 | return { k, b }; 36 | } 37 | 38 | export function scaleK(scale: ScaleLinear) { 39 | const domain = scale.domain(); 40 | const range = scale.range(); 41 | return (domain[1] - domain[0]) / (range[1] - range[0]); 42 | } 43 | 44 | /** 45 | * @returns If domain changed 46 | */ 47 | export function applyNewDomain(op: ResolvedAxisOptions, domain: number[]) { 48 | const inExtent = domain[1] - domain[0]; 49 | 50 | const previousDomain = op.scale.domain(); 51 | if ((previousDomain[1] - previousDomain[0]) * inExtent <= 0) { 52 | // forbidden reverse direction. 53 | return false; 54 | } 55 | 56 | const extent = Math.min(op.maxDomainExtent, op.maxDomain - op.minDomain, Math.max(op.minDomainExtent, inExtent)); 57 | const deltaE = (extent - inExtent) / 2; 58 | domain[0] -= deltaE; 59 | domain[1] += deltaE; 60 | 61 | const deltaO = Math.min(Math.max(op.minDomain - domain[0], 0), op.maxDomain - domain[1]); 62 | domain[0] += deltaO; 63 | domain[1] += deltaO; 64 | 65 | const eps = extent * 1e-6; 66 | op.scale.domain(domain); 67 | if (zip(domain, previousDomain).some(([d, pd]) => Math.abs(d - pd) > eps)) { 68 | return true; 69 | } 70 | return false; 71 | } 72 | 73 | export function variance(data: number[]) { 74 | const mean = data.reduce((a, b) => a + b)/ data.length; 75 | return data.map(d => (d - mean) ** 2).reduce((a, b) => a + b) / data.length; 76 | } 77 | 78 | export function clamp(value: number, min: number, max: number) { 79 | if (value > max) { 80 | return max; 81 | } else if (value < min) { 82 | return min; 83 | } 84 | return value; 85 | } 86 | -------------------------------------------------------------------------------- /src/chartZoom/wheel.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from '../utils'; 2 | import { CapableElement, DIRECTION, dirOptions, ResolvedOptions } from "./options"; 3 | import { applyNewDomain, clamp, scaleK } from './utils'; 4 | 5 | export class ChartZoomWheel { 6 | public scaleUpdated = new EventDispatcher(); 7 | 8 | constructor(private el: CapableElement, private options: ResolvedOptions) { 9 | const eventEl = options.eventElement; 10 | eventEl.addEventListener('wheel', ev => this.onWheel(ev)); 11 | } 12 | 13 | private onWheel(event: WheelEvent) { 14 | event.preventDefault(); 15 | 16 | let deltaX = event.deltaX; 17 | let deltaY = event.deltaY; 18 | switch (event.deltaMode) { 19 | case 1: // line 20 | deltaX *= 30; 21 | deltaY *= 30; 22 | break; 23 | case 2: // page 24 | deltaX *= 400; 25 | deltaY *= 400; 26 | break; 27 | } 28 | const transform = { 29 | [DIRECTION.X]: { 30 | translate: 0, 31 | zoom: 0, 32 | }, 33 | [DIRECTION.Y]: { 34 | translate: 0, 35 | zoom: 0, 36 | } 37 | }; 38 | if (event.ctrlKey || event.metaKey) { // zoom 39 | if (event.altKey) { 40 | transform[DIRECTION.X].zoom = deltaX; 41 | transform[DIRECTION.Y].zoom = deltaY; 42 | } else { 43 | transform[DIRECTION.X].zoom = (deltaX + deltaY); 44 | } 45 | } else { // translate 46 | if (event.altKey) { 47 | transform[DIRECTION.X].translate = deltaX; 48 | transform[DIRECTION.Y].translate = deltaY; 49 | } else { 50 | transform[DIRECTION.X].translate = (deltaX + deltaY); 51 | } 52 | } 53 | const boundingRect = this.el.getBoundingClientRect(); 54 | const origin = { 55 | [DIRECTION.X]: event.clientX - boundingRect.left, 56 | [DIRECTION.Y]: event.clientY - boundingRect.top, 57 | } 58 | 59 | let changed = false; 60 | for (const { dir, op } of dirOptions(this.options)) { 61 | const domain = op.scale.domain(); 62 | const k = scaleK(op.scale); 63 | const trans = transform[dir]; 64 | const transOrigin = op.scale.invert(origin[dir]); 65 | trans.translate *= k; 66 | trans.zoom *= 0.002; 67 | if (event.shiftKey) { 68 | trans.translate *= 5; 69 | trans.zoom *= 5; 70 | } 71 | 72 | const extent = domain[1] - domain[0]; 73 | const translateCap = 0.4 * extent; 74 | trans.translate = clamp(trans.translate, -translateCap, translateCap); 75 | 76 | const zoomCap = 0.5; 77 | trans.zoom = clamp(trans.zoom, -zoomCap, zoomCap); 78 | 79 | const newDomain = domain.map(d => d + trans.translate + (d - transOrigin) * trans.zoom); 80 | if (applyNewDomain(op, newDomain)) { 81 | changed = true; 82 | } 83 | } 84 | 85 | if (changed) { 86 | this.scaleUpdated.dispatch(); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/core/canvasLayer.ts: -------------------------------------------------------------------------------- 1 | import { resolveColorRGBA, ResolvedCoreOptions } from '../options'; 2 | import { RenderModel } from './renderModel'; 3 | 4 | function getContext(canvas: HTMLCanvasElement) { 5 | const ctx = canvas.getContext('webgl2'); 6 | if (!ctx) { 7 | throw new Error('Unable to initialize WebGL2. Your browser or machine may not support it.'); 8 | } 9 | return ctx; 10 | } 11 | 12 | export class CanvasLayer { 13 | canvas: HTMLCanvasElement 14 | gl: WebGL2RenderingContext; 15 | 16 | constructor(el: HTMLElement, private options: ResolvedCoreOptions, model: RenderModel) { 17 | const canvas = document.createElement('canvas'); 18 | const style = canvas.style; 19 | style.position = 'absolute'; 20 | style.width = style.height = '100%'; 21 | style.left = style.right = style.top = style.bottom = '0'; 22 | el.shadowRoot!.appendChild(canvas); 23 | 24 | this.gl = getContext(canvas); 25 | 26 | const bgColor = resolveColorRGBA(options.backgroundColor); 27 | this.gl.clearColor(...bgColor); 28 | 29 | this.canvas = canvas; 30 | 31 | model.updated.on(() => { 32 | this.clear(); 33 | this.syncViewport(); 34 | }); 35 | model.resized.on((w, h) => this.onResize(w, h)); 36 | model.disposing.on(() => { 37 | el.shadowRoot!.removeChild(canvas); 38 | canvas.width = 0; 39 | canvas.height = 0; 40 | const lossContext = this.gl.getExtension('WEBGL_lose_context'); 41 | if (lossContext) { 42 | lossContext.loseContext(); 43 | } 44 | }) 45 | } 46 | 47 | syncViewport() { 48 | const o = this.options; 49 | const r = o.pixelRatio; 50 | this.gl.viewport( 51 | o.renderPaddingLeft * r, 52 | o.renderPaddingBottom * r, 53 | (this.canvas.width - (o.renderPaddingLeft + o.renderPaddingRight) * r), 54 | (this.canvas.height - (o.renderPaddingTop + o.renderPaddingBottom) * r), 55 | ); 56 | } 57 | 58 | onResize(width: number, height: number) { 59 | const canvas = this.canvas; 60 | const scale = this.options.pixelRatio; 61 | canvas.width = width * scale; 62 | canvas.height = height * scale; 63 | this.syncViewport(); 64 | } 65 | 66 | clear() { 67 | const gl = this.gl; 68 | gl.clear(gl.COLOR_BUFFER_BIT); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/contentBoxDetector.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedCoreOptions } from '../options'; 2 | import { RenderModel } from './renderModel'; 3 | 4 | export class ContentBoxDetector { 5 | node: HTMLElement; 6 | constructor(el: HTMLElement, model: RenderModel, options: ResolvedCoreOptions) { 7 | this.node = document.createElement('div'); 8 | this.node.style.position = 'absolute'; 9 | this.node.style.left = `${options.paddingLeft}px`; 10 | this.node.style.right = `${options.paddingRight}px`; 11 | this.node.style.top = `${options.paddingTop}px`; 12 | this.node.style.bottom = `${options.paddingBottom}px`; 13 | el.shadowRoot!.appendChild(this.node); 14 | 15 | model.disposing.on(() => { 16 | el.shadowRoot!.removeChild(this.node); 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/dataPointsBuffer.ts: -------------------------------------------------------------------------------- 1 | import { DataPoint } from "./renderModel"; 2 | 3 | export class DataPointsBuffer extends Array { 4 | pushed_back = 0; 5 | pushed_front = 0; 6 | poped_back = 0; 7 | poped_front = 0; 8 | 9 | constructor(arrayLength: number); 10 | constructor(...items: T[]); 11 | constructor() { 12 | super(...arguments); 13 | this.pushed_back = this.length; 14 | } 15 | 16 | _synced() { 17 | this.pushed_back = this.poped_back = this.pushed_front = this.poped_front = 0; 18 | } 19 | 20 | static _from_array(arr: Array | DataPointsBuffer): DataPointsBuffer { 21 | if (arr instanceof DataPointsBuffer) 22 | return arr; 23 | const b = Object.setPrototypeOf(arr, DataPointsBuffer.prototype) as DataPointsBuffer 24 | b.poped_back = b.pushed_front = b.poped_front = 0; 25 | b.pushed_back = b.length; 26 | return b; 27 | } 28 | 29 | override push(...items: T[]): number { 30 | this.pushed_back += items.length; 31 | return super.push(...items); 32 | } 33 | 34 | override pop(): T | undefined { 35 | const len = this.length; 36 | const r = super.pop(); 37 | if (r === undefined) 38 | return r; 39 | 40 | if (this.pushed_back > 0) 41 | this.pushed_back--; 42 | else if (len - this.pushed_front > 0) 43 | this.poped_back++; 44 | else 45 | this.pushed_front--; 46 | return r; 47 | } 48 | 49 | override unshift(...items: T[]): number { 50 | this.pushed_front += items.length; 51 | return super.unshift(...items); 52 | } 53 | 54 | override shift(): T | undefined { 55 | const len = this.length; 56 | const r = super.shift(); 57 | if (r === undefined) 58 | return r; 59 | 60 | if (this.pushed_front > 0) 61 | this.pushed_front--; 62 | else if (len - this.pushed_back > 0) 63 | this.poped_front++; 64 | else 65 | this.pushed_back--; 66 | return r; 67 | } 68 | 69 | private updateDelete(start: number, deleteCount: number, len: number) { 70 | if (deleteCount === 0) 71 | return; 72 | 73 | const d = (c: number) => { 74 | deleteCount -= c; 75 | len -= c; 76 | return deleteCount === 0; 77 | } 78 | 79 | if (start < this.pushed_front) { 80 | const c = Math.min(deleteCount, this.pushed_front - start); 81 | this.pushed_front -= c; 82 | if (d(c)) 83 | return; 84 | } 85 | 86 | if (start === this.pushed_front) { 87 | const c = Math.min(deleteCount, len - this.pushed_front - this.pushed_back); 88 | this.poped_front += c 89 | if (d(c)) 90 | return; 91 | } 92 | 93 | if (start > this.pushed_front && start < len - this.pushed_back) { 94 | if (start + deleteCount < len - this.pushed_back) 95 | throw new RangeError("DataPoints that already synced to GPU cannot be delete in the middle"); 96 | const c = Math.min(deleteCount, len - start - this.pushed_back); 97 | this.poped_back += c; 98 | if (d(c)) 99 | return; 100 | } 101 | 102 | const c = Math.min(deleteCount, len - start); 103 | this.pushed_back -= c; 104 | if (d(c)) 105 | return; 106 | 107 | throw new Error('BUG'); 108 | } 109 | 110 | private updateInsert(start: number, insertCount: number, len: number) { 111 | if (start <= this.pushed_front) { 112 | this.pushed_front += insertCount; 113 | } else if (start >= len - this.pushed_back) { 114 | this.pushed_back += insertCount; 115 | } else { 116 | throw new RangeError("DataPoints cannot be inserted in the middle of the range that is already synced to GPU"); 117 | } 118 | } 119 | 120 | override splice(start: number, deleteCount?: number, ...items: T[]): T[] { 121 | if (start === -Infinity) 122 | start = 0 123 | else if (start < 0) 124 | start = Math.max(this.length + start, 0); 125 | 126 | if (deleteCount === undefined) 127 | deleteCount = this.length - start; 128 | else 129 | deleteCount = Math.min(Math.max(deleteCount, 0), this.length - start); 130 | 131 | this.updateDelete(start, deleteCount, this.length); 132 | this.updateInsert(start, items.length, this.length - deleteCount); 133 | 134 | const expectedLen = this.length - deleteCount + items.length; 135 | const r = super.splice(start, deleteCount, ...items); 136 | if (this.length !== expectedLen) 137 | throw new Error(`BUG! length after splice not expected. ${this.length} vs ${expectedLen}`); 138 | return r; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | import { LineType, NoPlugin, ResolvedCoreOptions, TimeChartOptions, TimeChartOptionsBase, TimeChartPlugins, TimeChartSeriesOptions } from '../options'; 2 | import { rgb } from 'd3-color'; 3 | import { scaleTime } from 'd3-scale'; 4 | import { TimeChartPlugin } from '../plugins'; 5 | import { CanvasLayer } from './canvasLayer'; 6 | import { ContentBoxDetector } from "./contentBoxDetector"; 7 | import { NearestPointModel } from './nearestPoint'; 8 | import { DataPoint, RenderModel } from './renderModel'; 9 | import { SVGLayer } from './svgLayer'; 10 | import { DataPointsBuffer } from './dataPointsBuffer'; 11 | 12 | 13 | const defaultOptions = { 14 | pixelRatio: window.devicePixelRatio, 15 | lineWidth: 1, 16 | backgroundColor: rgb(0, 0, 0, 0), 17 | paddingTop: 10, 18 | paddingRight: 10, 19 | paddingLeft: 45, 20 | paddingBottom: 20, 21 | renderPaddingTop: 0, 22 | renderPaddingRight: 0, 23 | renderPaddingLeft: 0, 24 | renderPaddingBottom: 0, 25 | xRange: 'auto', 26 | yRange: 'auto', 27 | realTime: false, 28 | baseTime: 0, 29 | xScaleType: scaleTime, 30 | debugWebGL: false, 31 | forceWebGL1: false, 32 | legend: true, 33 | } as const; 34 | 35 | const defaultSeriesOptions = { 36 | name: '', 37 | color: null, 38 | visible: true, 39 | lineType: LineType.Line, 40 | stepLocation: 1., 41 | } as const; 42 | 43 | type TPluginStates = { [P in keyof TPlugins]: TPlugins[P] extends TimeChartPlugin ? TState : never }; 44 | 45 | function completeSeriesOptions(s: Partial): TimeChartSeriesOptions { 46 | s.data = s.data ? DataPointsBuffer._from_array(s.data) : new DataPointsBuffer(); 47 | Object.setPrototypeOf(s, defaultSeriesOptions); 48 | return s as TimeChartSeriesOptions; 49 | } 50 | 51 | function completeOptions(el: Element, options?: TimeChartOptionsBase): ResolvedCoreOptions { 52 | const dynamicDefaults = { 53 | series: [] as TimeChartSeriesOptions[], 54 | color: getComputedStyle(el).getPropertyValue('color'), 55 | } 56 | const o = Object.assign({}, dynamicDefaults, options); 57 | o.series = o.series.map(s => completeSeriesOptions(s)); 58 | Object.setPrototypeOf(o, defaultOptions); 59 | return o as ResolvedCoreOptions; 60 | } 61 | 62 | export default class TimeChart { 63 | protected readonly _options: ResolvedCoreOptions; 64 | get options() { return this._options; } 65 | 66 | readonly model: RenderModel; 67 | readonly canvasLayer: CanvasLayer; 68 | readonly svgLayer: SVGLayer; 69 | readonly contentBoxDetector: ContentBoxDetector; 70 | readonly nearestPoint: NearestPointModel; 71 | readonly plugins: TPluginStates; 72 | disposed = false; 73 | 74 | constructor(public el: HTMLElement, options?: TimeChartOptions) { 75 | const coreOptions = completeOptions(el, options); 76 | 77 | this.model = new RenderModel(coreOptions); 78 | const shadowRoot = el.shadowRoot ?? el.attachShadow({ mode: 'open' }); 79 | const style = document.createElement('style'); 80 | style.innerText = ` 81 | :host { 82 | contain: size layout paint style; 83 | position: relative; 84 | }` 85 | shadowRoot.appendChild(style); 86 | 87 | this.canvasLayer = new CanvasLayer(el, coreOptions, this.model); 88 | this.svgLayer = new SVGLayer(el, this.model); 89 | this.contentBoxDetector = new ContentBoxDetector(el, this.model, coreOptions); 90 | this.nearestPoint = new NearestPointModel(this.canvasLayer, this.model, coreOptions, this.contentBoxDetector); 91 | this._options = coreOptions 92 | 93 | this.plugins = Object.fromEntries( 94 | Object.entries(options?.plugins ?? {}).map(([name, p]) => [name, p.apply(this)]) 95 | ) as TPluginStates; 96 | 97 | this.onResize(); 98 | 99 | const resizeHandler = () => this.onResize() 100 | window.addEventListener('resize', resizeHandler); 101 | this.model.disposing.on(() => { 102 | window.removeEventListener('resize', resizeHandler); 103 | shadowRoot.removeChild(style); 104 | }) 105 | } 106 | 107 | onResize() { 108 | this.model.resize(this.el.clientWidth, this.el.clientHeight); 109 | } 110 | 111 | update() { 112 | if (this.disposed) { 113 | throw new Error('Cannot update after dispose.'); 114 | } 115 | 116 | // fix dynamic added series 117 | for (let i = 0; i < this.options.series.length; i++) { 118 | const s = this.options.series[i]; 119 | if (!defaultSeriesOptions.isPrototypeOf(s)) { 120 | this.options.series[i] = completeSeriesOptions(s); 121 | } 122 | } 123 | 124 | this.model.requestRedraw(); 125 | } 126 | 127 | dispose() { 128 | this.model.dispose(); 129 | this.disposed = true; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/core/nearestPoint.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedCoreOptions, TimeChartSeriesOptions } from '../options'; 2 | import { domainSearch, EventDispatcher } from '../utils'; 3 | import { CanvasLayer } from './canvasLayer'; 4 | import { ContentBoxDetector } from "./contentBoxDetector"; 5 | import { DataPoint, RenderModel } from './renderModel'; 6 | 7 | export class NearestPointModel { 8 | dataPoints = new Map(); 9 | lastPointerPos: null | {x: number, y: number} = null; 10 | 11 | updated = new EventDispatcher(); 12 | 13 | constructor( 14 | private canvas: CanvasLayer, 15 | private model: RenderModel, 16 | private options: ResolvedCoreOptions, 17 | detector: ContentBoxDetector 18 | ) { 19 | detector.node.addEventListener('mousemove', ev => { 20 | const rect = canvas.canvas.getBoundingClientRect(); 21 | this.lastPointerPos = { 22 | x: ev.clientX - rect.left, 23 | y: ev.clientY - rect.top, 24 | }; 25 | this.adjustPoints(); 26 | }); 27 | detector.node.addEventListener('mouseleave', ev => { 28 | this.lastPointerPos = null; 29 | this.adjustPoints(); 30 | }); 31 | 32 | model.updated.on(() => this.adjustPoints()); 33 | } 34 | 35 | adjustPoints() { 36 | if (this.lastPointerPos === null) { 37 | this.dataPoints.clear(); 38 | } else { 39 | const domain = this.model.xScale.invert(this.lastPointerPos.x); 40 | for (const s of this.options.series) { 41 | if (s.data.length == 0 || !s.visible) { 42 | this.dataPoints.delete(s); 43 | continue; 44 | } 45 | const pos = domainSearch(s.data, 0, s.data.length, domain, d => d.x); 46 | const near: DataPoint[] = []; 47 | if (pos > 0) { 48 | near.push(s.data[pos - 1]); 49 | } 50 | if (pos < s.data.length) { 51 | near.push(s.data[pos]); 52 | } 53 | const sortKey = (a: typeof near[0]) => Math.abs(a.x - domain); 54 | near.sort((a, b) => sortKey(a) - sortKey(b)); 55 | const pxPoint = this.model.pxPoint(near[0]); 56 | const width = this.canvas.canvas.clientWidth; 57 | const height = this.canvas.canvas.clientHeight; 58 | 59 | if (pxPoint.x <= width && pxPoint.x >= 0 && 60 | pxPoint.y <= height && pxPoint.y >= 0) { 61 | this.dataPoints.set(s, near[0]); 62 | } else { 63 | this.dataPoints.delete(s); 64 | } 65 | } 66 | } 67 | this.updated.dispatch(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/core/renderModel.ts: -------------------------------------------------------------------------------- 1 | import { scaleLinear } from "d3-scale"; 2 | import { ResolvedCoreOptions, TimeChartSeriesOptions } from '../options'; 3 | import { EventDispatcher } from '../utils'; 4 | 5 | export interface DataPoint { 6 | x: number; 7 | y: number; 8 | } 9 | 10 | export interface MinMax { min: number; max: number; } 11 | 12 | function calcMinMaxY(arr: DataPoint[], start: number, end: number): MinMax { 13 | let max = -Infinity; 14 | let min = Infinity; 15 | for (let i = start; i < end; i++) { 16 | const v = arr[i].y; 17 | if (v > max) max = v; 18 | if (v < min) min = v; 19 | } 20 | return { max, min }; 21 | } 22 | 23 | function unionMinMax(...items: MinMax[]) { 24 | return { 25 | min: Math.min(...items.map(i => i.min)), 26 | max: Math.max(...items.map(i => i.max)), 27 | }; 28 | } 29 | 30 | export class RenderModel { 31 | xScale = scaleLinear(); 32 | yScale = scaleLinear(); 33 | xRange: MinMax | null = null; 34 | yRange: MinMax | null = null; 35 | 36 | constructor(private options: ResolvedCoreOptions) { 37 | if (options.xRange !== 'auto' && options.xRange) { 38 | this.xScale.domain([options.xRange.min, options.xRange.max]) 39 | } 40 | if (options.yRange !== 'auto' && options.yRange) { 41 | this.yScale.domain([options.yRange.min, options.yRange.max]) 42 | } 43 | } 44 | 45 | resized = new EventDispatcher<(width: number, height: number) => void>(); 46 | resize(width: number, height: number) { 47 | const op = this.options; 48 | this.xScale.range([op.paddingLeft, width - op.paddingRight]); 49 | this.yScale.range([height - op.paddingBottom, op.paddingTop]); 50 | 51 | this.resized.dispatch(width, height) 52 | this.requestRedraw() 53 | } 54 | 55 | updated = new EventDispatcher(); 56 | disposing = new EventDispatcher(); 57 | readonly abortController = new AbortController(); 58 | 59 | dispose() { 60 | if (!this.abortController.signal.aborted) { 61 | this.abortController.abort(); 62 | this.disposing.dispatch(); 63 | } 64 | } 65 | 66 | update() { 67 | this.updateModel(); 68 | this.updated.dispatch(); 69 | for (const s of this.options.series) { 70 | s.data._synced(); 71 | } 72 | } 73 | 74 | updateModel() { 75 | const series = this.options.series.filter(s => s.data.length > 0); 76 | if (series.length === 0) { 77 | return; 78 | } 79 | 80 | const o = this.options; 81 | 82 | { 83 | const maxDomain = Math.max(...series.map(s => s.data[s.data.length - 1].x)); 84 | const minDomain = Math.min(...series.map(s => s.data[0].x)); 85 | this.xRange = { max: maxDomain, min: minDomain }; 86 | if (this.options.realTime || o.xRange === 'auto') { 87 | if (this.options.realTime) { 88 | const currentDomain = this.xScale.domain(); 89 | const range = currentDomain[1] - currentDomain[0]; 90 | this.xScale.domain([maxDomain - range, maxDomain]); 91 | } else { // Auto 92 | this.xScale.domain([minDomain, maxDomain]); 93 | } 94 | } else if (o.xRange) { 95 | this.xScale.domain([o.xRange.min, o.xRange.max]) 96 | } 97 | } 98 | { 99 | const minMaxY = series.flatMap(s => { 100 | return [ 101 | calcMinMaxY(s.data, 0, s.data.pushed_front), 102 | calcMinMaxY(s.data, s.data.length - s.data.pushed_back, s.data.length), 103 | ]; 104 | }) 105 | if (this.yRange) { 106 | minMaxY.push(this.yRange); 107 | } 108 | this.yRange = unionMinMax(...minMaxY); 109 | if (o.yRange === 'auto') { 110 | this.yScale.domain([this.yRange.min, this.yRange.max]).nice(); 111 | } else if (o.yRange) { 112 | this.yScale.domain([o.yRange.min, o.yRange.max]) 113 | } 114 | } 115 | } 116 | 117 | private redrawRequested = false; 118 | requestRedraw() { 119 | if (this.redrawRequested) { 120 | return; 121 | } 122 | this.redrawRequested = true; 123 | const signal = this.abortController.signal; 124 | requestAnimationFrame((time) => { 125 | this.redrawRequested = false; 126 | if (!signal.aborted) { 127 | this.update(); 128 | } 129 | }); 130 | } 131 | 132 | pxPoint(dataPoint: DataPoint) { 133 | return { 134 | x: this.xScale(dataPoint.x)!, 135 | y: this.yScale(dataPoint.y)!, 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/core/svgLayer.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedCoreOptions } from '../options'; 2 | import { RenderModel } from './renderModel'; 3 | 4 | export class SVGLayer { 5 | svgNode: SVGSVGElement; 6 | 7 | constructor(el: HTMLElement, model: RenderModel) { 8 | this.svgNode = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 9 | const style = this.svgNode.style; 10 | style.position = 'absolute'; 11 | style.width = style.height = '100%'; 12 | style.left = style.right = style.top = style.bottom = '0'; 13 | el.shadowRoot!.appendChild(this.svgNode); 14 | 15 | model.disposing.on(() => { 16 | el.shadowRoot!.removeChild(this.svgNode); 17 | }) 18 | } 19 | } 20 | 21 | export function makeContentBox(model: RenderModel, options: ResolvedCoreOptions) { 22 | const contentSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 23 | contentSvg.classList.add('content-box') 24 | contentSvg.x.baseVal.value = options.paddingLeft 25 | contentSvg.y.baseVal.value = options.paddingRight 26 | 27 | model.resized.on((width, height) => { 28 | contentSvg.width.baseVal.value = width - options.paddingRight - options.paddingLeft; 29 | contentSvg.height.baseVal.value = height - options.paddingTop - options.paddingBottom; 30 | }) 31 | return contentSvg; 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import core from './core'; 2 | import { LineType, NoPlugin, ResolvedOptions, TimeChartOptions, TimeChartPlugins } from './options'; 3 | import { TimeChartZoomPlugin } from './plugins/chartZoom'; 4 | import { crosshair } from './plugins/crosshair'; 5 | import { d3Axis } from './plugins/d3Axis'; 6 | import { legend } from './plugins/legend'; 7 | import { lineChart } from './plugins/lineChart'; 8 | import { nearestPoint } from './plugins/nearestPoint'; 9 | import { TimeChartTooltipPlugin } from './plugins/tooltip'; 10 | 11 | type TDefaultPlugins = { 12 | lineChart: typeof lineChart, 13 | d3Axis: typeof d3Axis, 14 | crosshair: typeof crosshair, 15 | nearestPoint: typeof nearestPoint, 16 | legend: typeof legend, 17 | zoom: TimeChartZoomPlugin, 18 | tooltip: TimeChartTooltipPlugin, 19 | } 20 | 21 | function addDefaultPlugins(options?: TimeChartOptions): TimeChartOptions { 22 | const o = options ?? {plugins: undefined, zoom: undefined, tooltip: undefined}; 23 | return { 24 | ...options, 25 | plugins: { 26 | lineChart, 27 | d3Axis, 28 | crosshair, 29 | nearestPoint, 30 | legend, 31 | zoom: new TimeChartZoomPlugin(o.zoom), 32 | tooltip: new TimeChartTooltipPlugin(o.tooltip), 33 | ...(o.plugins ?? {}) as TPlugins, 34 | } 35 | } as TimeChartOptions; 36 | } 37 | 38 | export default class TimeChart extends core { 39 | // For users who use script tag 40 | static core = core; 41 | static plugins = { 42 | lineChart, 43 | d3Axis, 44 | crosshair, 45 | nearestPoint, 46 | legend, 47 | TimeChartZoomPlugin, 48 | TimeChartTooltipPlugin, 49 | } 50 | static LineType = LineType; 51 | 52 | get options(): ResolvedOptions { return this._options as ResolvedOptions; } 53 | 54 | constructor(public el: HTMLElement, options?: TimeChartOptions) { 55 | super(el, addDefaultPlugins(options)); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { ColorCommonInstance, ColorSpaceObject, rgb } from 'd3-color'; 2 | import { DataPointsBuffer } from './core/dataPointsBuffer'; 3 | import { DataPoint } from './core/renderModel'; 4 | import { TimeChartPlugin } from './plugins'; 5 | import * as zoomOptions from './chartZoom/options'; 6 | 7 | type ColorSpecifier = ColorSpaceObject | ColorCommonInstance | string 8 | 9 | export interface AxisZoomOptions extends zoomOptions.AxisOptions { 10 | autoRange?: boolean; 11 | } 12 | 13 | export interface ResolvedAxisZoomOptions extends zoomOptions.ResolvedAxisOptions { 14 | autoRange: boolean; 15 | } 16 | 17 | export interface ZoomOptions { 18 | x?: AxisZoomOptions; 19 | y?: AxisZoomOptions; 20 | } 21 | 22 | export interface ResolvedZoomOptions { 23 | x?: ResolvedAxisZoomOptions; 24 | y?: ResolvedAxisZoomOptions; 25 | } 26 | 27 | interface ScaleBase { 28 | (x: number | {valueOf(): number}): number; 29 | domain(): number[] | Date[]; 30 | range(): number[]; 31 | copy(): this; 32 | domain(domain: Array): this; 33 | range(range: ReadonlyArray): this; 34 | } 35 | 36 | export interface TooltipOptions { 37 | enabled: boolean; 38 | xLabel: string; 39 | xFormatter: (x: number) => string; 40 | } 41 | 42 | interface TimeChartRenderOptions { 43 | pixelRatio: number; 44 | lineWidth: number; 45 | backgroundColor: ColorSpecifier; 46 | color: ColorSpecifier; 47 | 48 | paddingLeft: number; 49 | paddingRight: number; 50 | paddingTop: number; 51 | paddingBottom: number; 52 | 53 | renderPaddingLeft: number; 54 | renderPaddingRight: number; 55 | renderPaddingTop: number; 56 | renderPaddingBottom: number; 57 | 58 | legend: boolean; 59 | tooltip: Partial; 60 | 61 | xRange: { min: number | Date, max: number | Date } | 'auto' | null; 62 | yRange: { min: number, max: number } | 'auto' | null; 63 | realTime: boolean; 64 | 65 | /** Milliseconds since `new Date(0)`. Every x in data are relative to this. 66 | * 67 | * Set this option and keep the absolute value of x small for higher floating point precision. 68 | **/ 69 | baseTime: number; 70 | xScaleType: () => ScaleBase; 71 | 72 | debugWebGL: boolean; 73 | } 74 | 75 | export type TimeChartPlugins = Readonly>; 76 | export type NoPlugin = Readonly>; 77 | 78 | export type TimeChartOptions = 79 | TimeChartOptionsBase & 80 | (NoPlugin extends TPlugins ? {plugins?: Record} : {plugins: TPlugins}); 81 | 82 | export interface TimeChartOptionsBase extends Partial { 83 | series?: Partial[]; 84 | zoom?: ZoomOptions; 85 | } 86 | 87 | export interface ResolvedCoreOptions extends TimeChartRenderOptions { 88 | series: TimeChartSeriesOptions[]; 89 | } 90 | 91 | export interface ResolvedOptions extends ResolvedCoreOptions { 92 | zoom: ResolvedZoomOptions; 93 | } 94 | 95 | export enum LineType { 96 | Line, 97 | Step, 98 | NativeLine, 99 | NativePoint, 100 | }; 101 | 102 | export interface TimeChartSeriesOptions { 103 | data: DataPointsBuffer; 104 | lineWidth?: number; 105 | name: string; 106 | color?: ColorSpecifier; 107 | visible: boolean; 108 | lineType: LineType; 109 | stepLocation: number; 110 | } 111 | 112 | export function resolveColorRGBA(color: ColorSpecifier): [number, number, number, number] { 113 | const rgbColor = typeof color === 'string' ? rgb(color) : rgb(color); 114 | return [rgbColor.r / 255, rgbColor.g / 255, rgbColor.b / 255, rgbColor.opacity]; 115 | } 116 | -------------------------------------------------------------------------------- /src/plugins/chartZoom.ts: -------------------------------------------------------------------------------- 1 | import { ChartZoom } from "../chartZoom"; 2 | import core from "../core"; 3 | import { MinMax } from "../core/renderModel"; 4 | import { ResolvedZoomOptions, TimeChartPlugins, ZoomOptions } from "../options"; 5 | import { TimeChartPlugin } from "."; 6 | import { ScaleLinear } from "d3-scale"; 7 | 8 | export class TimeChartZoom { 9 | constructor(chart: core, public options: ResolvedZoomOptions) { 10 | this.registerZoom(chart) 11 | } 12 | 13 | private applyAutoRange(o: {scale: ScaleLinear, autoRange: boolean, minDomain?: number, maxDomain?: number} | undefined, dataRange: MinMax | null) { 14 | if (!o) 15 | return; 16 | if (!o.autoRange) { 17 | delete o.minDomain; 18 | delete o.maxDomain; 19 | return; 20 | } 21 | let [min, max] = o.scale.domain(); 22 | if (dataRange) { 23 | min = Math.min(min, dataRange.min); 24 | max = Math.max(max, dataRange.max); 25 | } 26 | o.minDomain = min; 27 | o.maxDomain = max; 28 | } 29 | 30 | private registerZoom(chart: core) { 31 | const o = this.options; 32 | const z = new ChartZoom(chart.el, o); 33 | chart.model.updated.on(() => { 34 | this.applyAutoRange(o.x, chart.model.xRange); 35 | this.applyAutoRange(o.y, chart.model.yRange); 36 | z.update(); 37 | }); 38 | z.onScaleUpdated(() => { 39 | chart.options.xRange = null; 40 | chart.options.yRange = null; 41 | chart.options.realTime = false; 42 | chart.update(); 43 | }); 44 | } 45 | } 46 | 47 | const defaults = { 48 | autoRange: true, 49 | } as const; 50 | 51 | export class TimeChartZoomPlugin implements TimeChartPlugin { 52 | constructor(private options?: ZoomOptions) { 53 | } 54 | 55 | private resolveOptions(chart: core): ResolvedZoomOptions { 56 | const o = this.options ?? {}; 57 | return new Proxy(o, { 58 | get: (target, prop) => { 59 | switch (prop) { 60 | case 'x': 61 | case 'y': 62 | const op = target[prop]; 63 | if (!op) 64 | return op; 65 | return new Proxy(op, { 66 | get: (target, prop2) => { 67 | if (prop2 === 'scale') { 68 | switch (prop) { 69 | case 'x': 70 | return chart.model.xScale; 71 | case 'y': 72 | return chart.model.yScale; 73 | } 74 | } 75 | return (target as any)[prop2] ?? (defaults as any)[prop2]; 76 | } 77 | }) 78 | case 'eventElement': 79 | return chart.contentBoxDetector.node; 80 | default: 81 | return (target as any)[prop]; 82 | } 83 | } 84 | }) as ResolvedZoomOptions; 85 | } 86 | 87 | apply(chart: core) { 88 | return new TimeChartZoom(chart, this.resolveOptions(chart)); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/plugins/crosshair.ts: -------------------------------------------------------------------------------- 1 | import { makeContentBox } from '../core/svgLayer'; 2 | import { TimeChartPlugin } from '.'; 3 | 4 | export const crosshair: TimeChartPlugin = { 5 | apply(chart) { 6 | const contentBox = makeContentBox(chart.model, chart.options); 7 | const initTrans = contentBox.createSVGTransform(); 8 | initTrans.setTranslate(0, 0); 9 | 10 | const style = document.createElementNS("http://www.w3.org/2000/svg", "style"); 11 | style.textContent = ` 12 | .timechart-crosshair { 13 | stroke: currentColor; 14 | stroke-width: 1; 15 | stroke-dasharray: 2 1; 16 | visibility: hidden; 17 | }`; 18 | const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 19 | hLine.transform.baseVal.initialize(initTrans); 20 | hLine.x2.baseVal.newValueSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PERCENTAGE, 100); 21 | const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 22 | vLine.transform.baseVal.initialize(initTrans); 23 | vLine.y2.baseVal.newValueSpecifiedUnits(SVGLength.SVG_LENGTHTYPE_PERCENTAGE, 100); 24 | 25 | const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 26 | g.classList.add('timechart-crosshair'); 27 | for (const e of [style, hLine, vLine]) { 28 | g.appendChild(e); 29 | } 30 | 31 | const detector = chart.contentBoxDetector; 32 | detector.node.addEventListener('mousemove', ev => { 33 | const contentRect = contentBox.getBoundingClientRect(); 34 | hLine.transform.baseVal.getItem(0).setTranslate(0, ev.clientY - contentRect.y); 35 | vLine.transform.baseVal.getItem(0).setTranslate(ev.clientX - contentRect.x, 0); 36 | }); 37 | detector.node.addEventListener('mouseenter', ev => g.style.visibility = 'visible'); 38 | detector.node.addEventListener('mouseleave', ev => g.style.visibility = 'hidden'); 39 | 40 | contentBox.appendChild(g); 41 | chart.svgLayer.svgNode.appendChild(contentBox); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/plugins/d3Axis.ts: -------------------------------------------------------------------------------- 1 | import { axisBottom, axisLeft } from 'd3-axis'; 2 | import { select } from "d3-selection"; 3 | import { TimeChartPlugin } from "."; 4 | 5 | export const d3Axis: TimeChartPlugin = { 6 | apply(chart) { 7 | const d3Svg = select(chart.svgLayer.svgNode) 8 | const xg = d3Svg.append('g'); 9 | const yg = d3Svg.append('g'); 10 | 11 | const xAxis = axisBottom(chart.model.xScale); 12 | const yAxis = axisLeft(chart.model.yScale); 13 | 14 | function update() { 15 | const xs = chart.model.xScale; 16 | const xts = chart.options.xScaleType() 17 | .domain(xs.domain().map(d => d + chart.options.baseTime)) 18 | .range(xs.range()); 19 | xAxis.scale(xts); 20 | xg.call(xAxis); 21 | 22 | yAxis.scale(chart.model.yScale); 23 | yg.call(yAxis); 24 | } 25 | 26 | chart.model.updated.on(update); 27 | 28 | chart.model.resized.on((w, h) => { 29 | const op = chart.options; 30 | xg.attr('transform', `translate(0, ${h - op.paddingBottom})`); 31 | yg.attr('transform', `translate(${op.paddingLeft}, 0)`); 32 | 33 | update() 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import core from '../core'; 2 | import { TimeChartPlugins } from '../options'; 3 | 4 | export interface TimeChartPlugin { 5 | apply(chart: core): TState; 6 | } 7 | -------------------------------------------------------------------------------- /src/plugins/legend.ts: -------------------------------------------------------------------------------- 1 | import { RenderModel } from "../core/renderModel"; 2 | import { ResolvedCoreOptions, TimeChartSeriesOptions } from "../options"; 3 | import { TimeChartPlugin } from "."; 4 | 5 | export class Legend { 6 | legend: HTMLElement; 7 | items = new Map(); 8 | itemContainer: Node; 9 | 10 | constructor(private el: HTMLElement, private model: RenderModel, private options: ResolvedCoreOptions) { 11 | this.legend = document.createElement('chart-legend'); 12 | const ls = this.legend.style; 13 | ls.position = 'absolute'; 14 | ls.right = `${options.paddingRight}px`; 15 | ls.top = `${options.paddingTop}px`; 16 | 17 | const legendRoot = this.legend.attachShadow({ mode: 'open' }); 18 | 19 | const style = document.createElement('style'); 20 | style.textContent = ` 21 | :host { 22 | background: var(--background-overlay, white); 23 | border: 1px solid hsl(0, 0%, 80%); 24 | border-radius: 3px; 25 | padding: 5px 10px; 26 | } 27 | .item { 28 | display: flex; 29 | flex-flow: row nowrap; 30 | align-items: center; 31 | user-select: none; 32 | } 33 | .item:not(.visible) { 34 | color: gray; 35 | text-decoration: line-through; 36 | } 37 | .item .example { 38 | width: 50px; 39 | margin-right: 10px; 40 | max-height: 1em; 41 | }`; 42 | legendRoot.appendChild(style); 43 | 44 | this.itemContainer = legendRoot; 45 | this.update(); 46 | 47 | const shadowRoot = el.shadowRoot! 48 | shadowRoot.appendChild(this.legend); 49 | model.updated.on(() => this.update()); 50 | 51 | model.disposing.on(() => { 52 | shadowRoot.removeChild(this.legend); 53 | }) 54 | } 55 | 56 | update() { 57 | this.legend.style.display = this.options.legend ? "" : "none"; 58 | if (!this.options.legend) 59 | return; 60 | 61 | for (const s of this.options.series) { 62 | if (!this.items.has(s)) { 63 | const item = document.createElement('div'); 64 | item.className = 'item'; 65 | const example = document.createElement('div'); 66 | example.className = 'example'; 67 | item.appendChild(example); 68 | const name = document.createElement('label'); 69 | name.textContent = s.name; 70 | item.appendChild(name); 71 | this.itemContainer.appendChild(item); 72 | 73 | item.addEventListener('click', (ev) => { 74 | s.visible = !s.visible; 75 | this.model.update(); 76 | }) 77 | 78 | this.items.set(s, {item, example}); 79 | } 80 | const item = this.items.get(s)!; 81 | item.item.classList.toggle('visible', s.visible); 82 | item.example.style.height = `${s.lineWidth ?? this.options.lineWidth}px`; 83 | item.example.style.backgroundColor = (s.color ?? this.options.color).toString(); 84 | } 85 | } 86 | } 87 | 88 | export const legend: TimeChartPlugin = { 89 | apply(chart) { 90 | return new Legend(chart.el, chart.model, chart.options); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/plugins/lineChart.ts: -------------------------------------------------------------------------------- 1 | import { DataPoint, RenderModel } from "../core/renderModel"; 2 | import { resolveColorRGBA, ResolvedCoreOptions, TimeChartSeriesOptions, LineType } from '../options'; 3 | import { domainSearch } from '../utils'; 4 | import { vec2 } from 'gl-matrix'; 5 | import { TimeChartPlugin } from '.'; 6 | import { LinkedWebGLProgram, throwIfFalsy } from './webGLUtils'; 7 | import { DataPointsBuffer } from "../core/dataPointsBuffer"; 8 | 9 | 10 | const BUFFER_TEXTURE_WIDTH = 256; 11 | const BUFFER_TEXTURE_HEIGHT = 2048; 12 | const BUFFER_POINT_CAPACITY = BUFFER_TEXTURE_WIDTH * BUFFER_TEXTURE_HEIGHT; 13 | const BUFFER_INTERVAL_CAPACITY = BUFFER_POINT_CAPACITY - 2; 14 | 15 | class ShaderUniformData { 16 | data; 17 | ubo; 18 | 19 | constructor(private gl: WebGL2RenderingContext, size: number) { 20 | this.data = new ArrayBuffer(size); 21 | this.ubo = throwIfFalsy(gl.createBuffer()); 22 | gl.bindBuffer(gl.UNIFORM_BUFFER, this.ubo); 23 | gl.bufferData(gl.UNIFORM_BUFFER, this.data, gl.DYNAMIC_DRAW); 24 | } 25 | get modelScale() { 26 | return new Float32Array(this.data, 0, 2); 27 | } 28 | get modelTranslate() { 29 | return new Float32Array(this.data, 2 * 4, 2); 30 | } 31 | get projectionScale() { 32 | return new Float32Array(this.data, 4 * 4, 2); 33 | } 34 | 35 | upload(index = 0) { 36 | this.gl.bindBufferBase(this.gl.UNIFORM_BUFFER, index, this.ubo); 37 | this.gl.bufferSubData(this.gl.UNIFORM_BUFFER, 0, this.data); 38 | } 39 | } 40 | 41 | const VS_HEADER = `#version 300 es 42 | layout (std140) uniform proj { 43 | vec2 modelScale; 44 | vec2 modelTranslate; 45 | vec2 projectionScale; 46 | }; 47 | uniform highp sampler2D uDataPoints; 48 | uniform int uLineType; 49 | uniform float uStepLocation; 50 | 51 | const int TEX_WIDTH = ${BUFFER_TEXTURE_WIDTH}; 52 | const int TEX_HEIGHT = ${BUFFER_TEXTURE_HEIGHT}; 53 | 54 | vec2 dataPoint(int index) { 55 | int x = index % TEX_WIDTH; 56 | int y = index / TEX_WIDTH; 57 | return texelFetch(uDataPoints, ivec2(x, y), 0).xy; 58 | } 59 | ` 60 | 61 | const LINE_FS_SOURCE = `#version 300 es 62 | precision lowp float; 63 | uniform vec4 uColor; 64 | out vec4 outColor; 65 | void main() { 66 | outColor = uColor; 67 | }`; 68 | 69 | class NativeLineProgram extends LinkedWebGLProgram { 70 | locations; 71 | static VS_SOURCE = `${VS_HEADER} 72 | uniform float uPointSize; 73 | 74 | void main() { 75 | vec2 pos2d = projectionScale * modelScale * (dataPoint(gl_VertexID) + modelTranslate); 76 | gl_Position = vec4(pos2d, 0, 1); 77 | gl_PointSize = uPointSize; 78 | } 79 | ` 80 | 81 | constructor(gl: WebGL2RenderingContext, debug: boolean) { 82 | super(gl, NativeLineProgram.VS_SOURCE, LINE_FS_SOURCE, debug); 83 | this.link(); 84 | 85 | this.locations = { 86 | uDataPoints: this.getUniformLocation('uDataPoints'), 87 | uPointSize: this.getUniformLocation('uPointSize'), 88 | uColor: this.getUniformLocation('uColor'), 89 | } 90 | 91 | this.use(); 92 | gl.uniform1i(this.locations.uDataPoints, 0); 93 | const projIdx = gl.getUniformBlockIndex(this.program, 'proj'); 94 | gl.uniformBlockBinding(this.program, projIdx, 0); 95 | } 96 | } 97 | 98 | class LineProgram extends LinkedWebGLProgram { 99 | static VS_SOURCE = `${VS_HEADER} 100 | uniform float uLineWidth; 101 | 102 | void main() { 103 | int side = gl_VertexID & 1; 104 | int di = (gl_VertexID >> 1) & 1; 105 | int index = gl_VertexID >> 2; 106 | 107 | vec2 dp[2] = vec2[2](dataPoint(index), dataPoint(index + 1)); 108 | 109 | vec2 base; 110 | vec2 off; 111 | if (uLineType == ${LineType.Line}) { 112 | base = dp[di]; 113 | vec2 dir = dp[1] - dp[0]; 114 | dir = normalize(modelScale * dir); 115 | off = vec2(-dir.y, dir.x) * uLineWidth; 116 | } else if (uLineType == ${LineType.Step}) { 117 | base = vec2(dp[0].x * (1. - uStepLocation) + dp[1].x * uStepLocation, dp[di].y); 118 | float up = sign(dp[0].y - dp[1].y); 119 | off = vec2(uLineWidth * up, uLineWidth); 120 | } 121 | 122 | if (side == 1) 123 | off = -off; 124 | vec2 cssPose = modelScale * (base + modelTranslate); 125 | vec2 pos2d = projectionScale * (cssPose + off); 126 | gl_Position = vec4(pos2d, 0, 1); 127 | }`; 128 | 129 | locations; 130 | constructor(gl: WebGL2RenderingContext, debug: boolean) { 131 | super(gl, LineProgram.VS_SOURCE, LINE_FS_SOURCE, debug); 132 | this.link(); 133 | 134 | this.locations = { 135 | uDataPoints: this.getUniformLocation('uDataPoints'), 136 | uLineType: this.getUniformLocation('uLineType'), 137 | uStepLocation: this.getUniformLocation('uStepLocation'), 138 | uLineWidth: this.getUniformLocation('uLineWidth'), 139 | uColor: this.getUniformLocation('uColor'), 140 | } 141 | 142 | this.use(); 143 | gl.uniform1i(this.locations.uDataPoints, 0); 144 | const projIdx = gl.getUniformBlockIndex(this.program, 'proj'); 145 | gl.uniformBlockBinding(this.program, projIdx, 0); 146 | } 147 | } 148 | 149 | class SeriesSegmentVertexArray { 150 | dataBuffer; 151 | 152 | constructor( 153 | private gl: WebGL2RenderingContext, 154 | private dataPoints: DataPointsBuffer, 155 | ) { 156 | this.dataBuffer = throwIfFalsy(gl.createTexture()); 157 | gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer); 158 | gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RG32F, BUFFER_TEXTURE_WIDTH, BUFFER_TEXTURE_HEIGHT); 159 | gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, BUFFER_TEXTURE_WIDTH, BUFFER_TEXTURE_HEIGHT, gl.RG, gl.FLOAT, new Float32Array(BUFFER_TEXTURE_WIDTH * BUFFER_TEXTURE_HEIGHT * 2)); 160 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 161 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 162 | } 163 | 164 | delete() { 165 | this.gl.deleteTexture(this.dataBuffer); 166 | } 167 | 168 | syncPoints(start: number, n: number, bufferPos: number) { 169 | const dps = this.dataPoints; 170 | let rowStart = Math.floor(bufferPos / BUFFER_TEXTURE_WIDTH); 171 | let rowEnd = Math.ceil((bufferPos + n) / BUFFER_TEXTURE_WIDTH); 172 | // Ensure we have some padding at both ends of data. 173 | if (rowStart > 0 && start === 0 && bufferPos === rowStart * BUFFER_TEXTURE_WIDTH) 174 | rowStart--; 175 | if (rowEnd < BUFFER_TEXTURE_HEIGHT && start + n === dps.length && bufferPos + n === rowEnd * BUFFER_TEXTURE_WIDTH) 176 | rowEnd++; 177 | 178 | const buffer = new Float32Array((rowEnd - rowStart) * BUFFER_TEXTURE_WIDTH * 2); 179 | for (let r = rowStart; r < rowEnd; r++) { 180 | for (let c = 0; c < BUFFER_TEXTURE_WIDTH; c++) { 181 | const p = r * BUFFER_TEXTURE_WIDTH + c; 182 | const i = Math.max(Math.min(start + p - bufferPos, dps.length - 1), 0); 183 | const dp = dps[i]; 184 | const bufferIdx = ((r - rowStart) * BUFFER_TEXTURE_WIDTH + c) * 2; 185 | buffer[bufferIdx] = dp.x; 186 | buffer[bufferIdx + 1] = dp.y; 187 | } 188 | } 189 | const gl = this.gl; 190 | gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer); 191 | gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, rowStart, BUFFER_TEXTURE_WIDTH, rowEnd - rowStart, gl.RG, gl.FLOAT, buffer); 192 | } 193 | 194 | /** 195 | * @param renderInterval [start, end) interval of data points, start from 0 196 | */ 197 | draw(renderInterval: { start: number, end: number }, type: LineType) { 198 | const first = Math.max(0, renderInterval.start); 199 | const last = Math.min(BUFFER_INTERVAL_CAPACITY, renderInterval.end) 200 | const count = last - first 201 | 202 | const gl = this.gl; 203 | gl.activeTexture(gl.TEXTURE0); 204 | gl.bindTexture(gl.TEXTURE_2D, this.dataBuffer); 205 | if (type === LineType.Line) { 206 | gl.drawArrays(gl.TRIANGLE_STRIP, first * 4, count * 4 + (last !== renderInterval.end ? 2 : 0)); 207 | } else if (type === LineType.Step) { 208 | let firstP = first * 4; 209 | let countP = count * 4 + 2; 210 | if (first === renderInterval.start) { 211 | firstP -= 2; 212 | countP += 2; 213 | } 214 | gl.drawArrays(gl.TRIANGLE_STRIP, firstP, countP); 215 | } else if (type === LineType.NativeLine) { 216 | gl.drawArrays(gl.LINE_STRIP, first, count + 1); 217 | } else if (type === LineType.NativePoint) { 218 | gl.drawArrays(gl.POINTS, first, count + 1); 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * An array of `SeriesSegmentVertexArray` to represent a series 225 | */ 226 | class SeriesVertexArray { 227 | private segments = [] as SeriesSegmentVertexArray[]; 228 | // each segment has at least 2 points 229 | private validStart = 0; // start position of the first segment. (0, BUFFER_INTERVAL_CAPACITY] 230 | private validEnd = 0; // end position of the last segment. [2, BUFFER_POINT_CAPACITY) 231 | 232 | constructor( 233 | private gl: WebGL2RenderingContext, 234 | private series: TimeChartSeriesOptions, 235 | ) { 236 | } 237 | 238 | private popFront() { 239 | if (this.series.data.poped_front === 0) 240 | return; 241 | 242 | this.validStart += this.series.data.poped_front; 243 | 244 | while (this.validStart > BUFFER_INTERVAL_CAPACITY) { 245 | const activeArray = this.segments[0]; 246 | activeArray.delete(); 247 | this.segments.shift(); 248 | this.validStart -= BUFFER_INTERVAL_CAPACITY; 249 | } 250 | 251 | this.segments[0].syncPoints(0, 0, this.validStart); 252 | } 253 | private popBack() { 254 | if (this.series.data.poped_back === 0) 255 | return; 256 | 257 | this.validEnd -= this.series.data.poped_back; 258 | 259 | while (this.validEnd < BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY) { 260 | const activeArray = this.segments[this.segments.length - 1]; 261 | activeArray.delete(); 262 | this.segments.pop(); 263 | this.validEnd += BUFFER_INTERVAL_CAPACITY; 264 | } 265 | 266 | this.segments[this.segments.length - 1].syncPoints(this.series.data.length, 0, this.validEnd); 267 | } 268 | 269 | private newArray() { 270 | return new SeriesSegmentVertexArray(this.gl, this.series.data); 271 | } 272 | private pushFront() { 273 | let numDPtoAdd = this.series.data.pushed_front; 274 | if (numDPtoAdd === 0) 275 | return; 276 | 277 | const newArray = () => { 278 | this.segments.unshift(this.newArray()); 279 | this.validStart = BUFFER_POINT_CAPACITY; 280 | } 281 | 282 | if (this.segments.length === 0) { 283 | newArray(); 284 | this.validEnd = this.validStart = BUFFER_POINT_CAPACITY - 1; 285 | } 286 | 287 | while (true) { 288 | const activeArray = this.segments[0]; 289 | const n = Math.min(this.validStart, numDPtoAdd); 290 | activeArray.syncPoints(numDPtoAdd - n, n, this.validStart - n); 291 | numDPtoAdd -= this.validStart - (BUFFER_POINT_CAPACITY - BUFFER_INTERVAL_CAPACITY); 292 | this.validStart -= n; 293 | if (this.validStart > 0) 294 | break; 295 | newArray(); 296 | } 297 | } 298 | 299 | private pushBack() { 300 | let numDPtoAdd = this.series.data.pushed_back; 301 | if (numDPtoAdd === 0) 302 | return 303 | 304 | const newArray = () => { 305 | this.segments.push(this.newArray()); 306 | this.validEnd = 0; 307 | } 308 | 309 | if (this.segments.length === 0) { 310 | newArray(); 311 | this.validEnd = this.validStart = 1; 312 | } 313 | 314 | while (true) { 315 | const activeArray = this.segments[this.segments.length - 1]; 316 | const n = Math.min(BUFFER_POINT_CAPACITY - this.validEnd, numDPtoAdd); 317 | activeArray.syncPoints(this.series.data.length - numDPtoAdd, n, this.validEnd); 318 | // Note that each segment overlaps with the previous one. 319 | // numDPtoAdd can increase here, indicating the overlapping part should be synced again to the next segment 320 | numDPtoAdd -= BUFFER_INTERVAL_CAPACITY - this.validEnd; 321 | this.validEnd += n; 322 | // Fully fill the previous segment before creating a new one 323 | if (this.validEnd < BUFFER_POINT_CAPACITY) 324 | break; 325 | newArray(); 326 | } 327 | } 328 | 329 | deinit() { 330 | for (const s of this.segments) 331 | s.delete(); 332 | this.segments = []; 333 | } 334 | 335 | syncBuffer() { 336 | const d = this.series.data; 337 | if (d.length - d.pushed_back - d.pushed_front < 2) { 338 | this.deinit(); 339 | d.poped_front = d.poped_back = 0; 340 | } 341 | if (this.segments.length === 0) { 342 | if (d.length >= 2) { 343 | if (d.pushed_back > d.pushed_front) { 344 | d.pushed_back = d.length; 345 | this.pushBack(); 346 | } else { 347 | d.pushed_front = d.length; 348 | this.pushFront(); 349 | } 350 | } 351 | return; 352 | } 353 | this.popFront(); 354 | this.popBack(); 355 | this.pushFront(); 356 | this.pushBack(); 357 | } 358 | 359 | draw(renderDomain: { min: number, max: number }) { 360 | const data = this.series.data; 361 | if (this.segments.length === 0 || data[0].x > renderDomain.max || data[data.length - 1].x < renderDomain.min) 362 | return; 363 | 364 | const key = (d: DataPoint) => d.x 365 | const firstDP = domainSearch(data, 1, data.length, renderDomain.min, key) - 1; 366 | const lastDP = domainSearch(data, firstDP, data.length - 1, renderDomain.max, key) 367 | const startInterval = firstDP + this.validStart; 368 | const endInterval = lastDP + this.validStart; 369 | const startArray = Math.floor(startInterval / BUFFER_INTERVAL_CAPACITY); 370 | const endArray = Math.ceil(endInterval / BUFFER_INTERVAL_CAPACITY); 371 | 372 | for (let i = startArray; i < endArray; i++) { 373 | const arrOffset = i * BUFFER_INTERVAL_CAPACITY 374 | this.segments[i].draw({ 375 | start: startInterval - arrOffset, 376 | end: endInterval - arrOffset, 377 | }, this.series.lineType); 378 | } 379 | } 380 | } 381 | 382 | export class LineChartRenderer { 383 | private lineProgram = new LineProgram(this.gl, this.options.debugWebGL); 384 | private nativeLineProgram = new NativeLineProgram(this.gl, this.options.debugWebGL); 385 | private uniformBuffer; 386 | private arrays = new Map(); 387 | private height = 0; 388 | private width = 0; 389 | private renderHeight = 0; 390 | private renderWidth = 0; 391 | 392 | constructor( 393 | private model: RenderModel, 394 | private gl: WebGL2RenderingContext, 395 | private options: ResolvedCoreOptions, 396 | ) { 397 | const uboSize = gl.getActiveUniformBlockParameter(this.lineProgram.program, 0, gl.UNIFORM_BLOCK_DATA_SIZE); 398 | this.uniformBuffer = new ShaderUniformData(this.gl, uboSize); 399 | 400 | model.updated.on(() => this.drawFrame()); 401 | model.resized.on((w, h) => this.onResize(w, h)); 402 | } 403 | 404 | syncBuffer() { 405 | for (const s of this.options.series) { 406 | let a = this.arrays.get(s); 407 | if (!a) { 408 | a = new SeriesVertexArray(this.gl, s); 409 | this.arrays.set(s, a); 410 | } 411 | a.syncBuffer(); 412 | } 413 | } 414 | 415 | syncViewport() { 416 | this.renderWidth = this.width - this.options.renderPaddingLeft - this.options.renderPaddingRight; 417 | this.renderHeight = this.height - this.options.renderPaddingTop - this.options.renderPaddingBottom; 418 | 419 | const scale = vec2.fromValues(this.renderWidth, this.renderHeight) 420 | vec2.divide(scale, [2., 2.], scale) 421 | this.uniformBuffer.projectionScale.set(scale); 422 | } 423 | 424 | onResize(width: number, height: number) { 425 | this.height = height; 426 | this.width = width; 427 | } 428 | 429 | drawFrame() { 430 | this.syncBuffer(); 431 | this.syncDomain(); 432 | this.uniformBuffer.upload(); 433 | const gl = this.gl; 434 | for (const [ds, arr] of this.arrays) { 435 | if (!ds.visible) { 436 | continue; 437 | } 438 | 439 | const prog = ds.lineType === LineType.NativeLine || ds.lineType === LineType.NativePoint ? this.nativeLineProgram : this.lineProgram; 440 | prog.use(); 441 | const color = resolveColorRGBA(ds.color ?? this.options.color); 442 | gl.uniform4fv(prog.locations.uColor, color); 443 | 444 | const lineWidth = ds.lineWidth ?? this.options.lineWidth; 445 | if (prog instanceof LineProgram) { 446 | gl.uniform1i(prog.locations.uLineType, ds.lineType); 447 | gl.uniform1f(prog.locations.uLineWidth, lineWidth / 2); 448 | if (ds.lineType === LineType.Step) 449 | gl.uniform1f(prog.locations.uStepLocation, ds.stepLocation); 450 | } else { 451 | if (ds.lineType === LineType.NativeLine) 452 | gl.lineWidth(lineWidth * this.options.pixelRatio); // Not working on most platforms 453 | else if (ds.lineType === LineType.NativePoint) 454 | gl.uniform1f(prog.locations.uPointSize, lineWidth * this.options.pixelRatio); 455 | } 456 | 457 | const renderDomain = { 458 | min: this.model.xScale.invert(this.options.renderPaddingLeft - lineWidth / 2), 459 | max: this.model.xScale.invert(this.width - this.options.renderPaddingRight + lineWidth / 2), 460 | }; 461 | arr.draw(renderDomain); 462 | } 463 | if (this.options.debugWebGL) { 464 | const err = gl.getError(); 465 | if (err != gl.NO_ERROR) { 466 | throw new Error(`WebGL error ${err}`); 467 | } 468 | } 469 | } 470 | 471 | syncDomain() { 472 | this.syncViewport(); 473 | const m = this.model; 474 | 475 | // for any x, 476 | // (x - domain[0]) / (domain[1] - domain[0]) * (range[1] - range[0]) + range[0] - W / 2 - padding = s * (x + t) 477 | // => s = (range[1] - range[0]) / (domain[1] - domain[0]) 478 | // t = (range[0] - W / 2 - padding) / s - domain[0] 479 | 480 | // Not using vec2 for precision 481 | const xDomain = m.xScale.domain(); 482 | const xRange = m.xScale.range(); 483 | const yDomain = m.yScale.domain(); 484 | const yRange = m.yScale.range(); 485 | const s = [ 486 | (xRange[1] - xRange[0]) / (xDomain[1] - xDomain[0]), 487 | (yRange[0] - yRange[1]) / (yDomain[1] - yDomain[0]), 488 | ]; 489 | const t = [ 490 | (xRange[0] - this.renderWidth / 2 - this.options.renderPaddingLeft) / s[0] - xDomain[0], 491 | -(yRange[0] - this.renderHeight / 2 - this.options.renderPaddingTop) / s[1] - yDomain[0], 492 | ]; 493 | 494 | this.uniformBuffer.modelScale.set(s); 495 | this.uniformBuffer.modelTranslate.set(t); 496 | } 497 | } 498 | 499 | export const lineChart: TimeChartPlugin = { 500 | apply(chart) { 501 | return new LineChartRenderer(chart.model, chart.canvasLayer.gl, chart.options); 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /src/plugins/nearestPoint.ts: -------------------------------------------------------------------------------- 1 | import { NearestPointModel } from "../core/nearestPoint"; 2 | import { SVGLayer } from "../core/svgLayer"; 3 | import { ResolvedCoreOptions, TimeChartSeriesOptions } from "../options"; 4 | import { TimeChartPlugin } from "."; 5 | import { RenderModel } from "../core/renderModel"; 6 | 7 | export class NearestPoint { 8 | private intersectPoints = new Map(); 9 | private container: SVGGElement; 10 | 11 | constructor( 12 | private svg: SVGLayer, 13 | private options: ResolvedCoreOptions, 14 | private model: RenderModel, 15 | private pModel: NearestPointModel 16 | ) { 17 | const initTrans = svg.svgNode.createSVGTransform(); 18 | initTrans.setTranslate(0, 0); 19 | 20 | const style = document.createElementNS('http://www.w3.org/2000/svg', 'style'); 21 | style.textContent = ` 22 | .timechart-crosshair-intersect { 23 | fill: var(--background-overlay, white); 24 | visibility: hidden; 25 | } 26 | .timechart-crosshair-intersect circle { 27 | r: 3px; 28 | }`; 29 | const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 30 | g.classList.add('timechart-crosshair-intersect'); 31 | g.appendChild(style); 32 | 33 | this.container = g; 34 | this.adjustIntersectPoints(); 35 | 36 | svg.svgNode.appendChild(g); 37 | 38 | pModel.updated.on(() => this.adjustIntersectPoints()); 39 | } 40 | 41 | adjustIntersectPoints() { 42 | const initTrans = this.svg.svgNode.createSVGTransform(); 43 | initTrans.setTranslate(0, 0); 44 | for (const s of this.options.series) { 45 | if (!this.intersectPoints.has(s)) { 46 | const intersect = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 47 | intersect.transform.baseVal.initialize(initTrans); 48 | this.container.appendChild(intersect); 49 | this.intersectPoints.set(s, intersect); 50 | } 51 | const intersect = this.intersectPoints.get(s)!; 52 | intersect.style.stroke = (s.color ?? this.options.color).toString(); 53 | intersect.style.strokeWidth = `${s.lineWidth ?? this.options.lineWidth}px`; 54 | const point = this.pModel.dataPoints.get(s); 55 | if (!point) { 56 | intersect.style.visibility = 'hidden'; 57 | } else { 58 | intersect.style.visibility = 'visible'; 59 | const p = this.model.pxPoint(point); 60 | intersect.transform.baseVal.getItem(0).setTranslate(p.x, p.y); 61 | } 62 | } 63 | } 64 | } 65 | 66 | export const nearestPoint: TimeChartPlugin = { 67 | apply(chart) { 68 | return new NearestPoint(chart.svgLayer, chart.options, chart.model, chart.nearestPoint); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/plugins/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { TimeChartSeriesOptions, TooltipOptions } from "../options"; 2 | import { TimeChartPlugin } from "."; 3 | import core from "../core"; 4 | 5 | type ItemElements = { item: HTMLElement; example: HTMLElement; name: HTMLElement, value: HTMLElement } 6 | 7 | export class Tooltip { 8 | tooltip: HTMLElement; 9 | 10 | xItem: ItemElements; 11 | items = new Map(); 12 | itemContainer: HTMLElement; 13 | 14 | chartOptions; 15 | 16 | constructor(chart: core, public readonly options: TooltipOptions) { 17 | this.chartOptions = chart.options; 18 | 19 | const mouseOffset = 12; 20 | 21 | this.tooltip = document.createElement('chart-tooltip'); 22 | 23 | const ls = this.tooltip.style; 24 | ls.position = 'absolute'; 25 | ls.visibility = "hidden" 26 | 27 | const legendRoot = this.tooltip.attachShadow({ mode: 'open' }); 28 | 29 | const style = document.createElement('style'); 30 | style.textContent = ` 31 | :host { 32 | background: var(--background-overlay, white); 33 | border: 1px solid hsl(0, 0%, 80%); 34 | border-radius: 3px; 35 | padding: 2px 2px; 36 | } 37 | .item { 38 | user-select: none; 39 | } 40 | .out-of-range.item { 41 | display: none; 42 | } 43 | td { 44 | padding: 0px 5px; 45 | } 46 | .name { 47 | margin-right: 10px; 48 | max-width: 100px; 49 | text-overflow: ellipsis; 50 | overflow: hidden; 51 | white-space: nowrap; 52 | } 53 | .example { 54 | width: 6px; 55 | height: 6px; 56 | } 57 | .value { 58 | text-overflow: ellipsis; 59 | overflow: hidden; 60 | white-space: nowrap; 61 | min-width: 100px; 62 | max-width: 100px; 63 | text-align: right; 64 | } 65 | .x-not-aligned .value { 66 | opacity: 0.4; 67 | } 68 | `; 69 | legendRoot.appendChild(style); 70 | 71 | 72 | const table = document.createElement("table"); 73 | 74 | this.xItem = this.createItemElements(this.options.xLabel); 75 | table.appendChild(this.xItem.item); 76 | 77 | legendRoot.appendChild(table); 78 | 79 | this.itemContainer = table; 80 | this.update(); 81 | chart.el.shadowRoot!.appendChild(this.tooltip); 82 | 83 | chart.model.updated.on(() => this.update()); 84 | 85 | chart.model.disposing.on(() => { 86 | chart.el.shadowRoot!.removeChild(this.tooltip); 87 | }) 88 | 89 | chart.nearestPoint.updated.on(() => { 90 | if (!options.enabled || chart.nearestPoint.dataPoints.size == 0) { 91 | ls.visibility = "hidden"; 92 | return; 93 | } 94 | 95 | ls.visibility = "visible"; 96 | 97 | const p = chart.nearestPoint.lastPointerPos!; 98 | const tooltipRect = this.tooltip.getBoundingClientRect(); 99 | let left = p.x - tooltipRect.width - mouseOffset; 100 | let top = p.y - tooltipRect.height - mouseOffset; 101 | 102 | if (left < 0) 103 | left = p.x + mouseOffset; 104 | 105 | if (top < 0) 106 | top = p.y + mouseOffset; 107 | 108 | ls.left = left + "px"; 109 | ls.top = top + "px"; 110 | 111 | // display X for the data point that is the closest to the pointer 112 | let minPointerDistance = Number.POSITIVE_INFINITY; 113 | let displayingX: number | null = null; 114 | for (const [s, d] of chart.nearestPoint.dataPoints) { 115 | const px = chart.model.pxPoint(d); 116 | const dx = px.x - p.x; 117 | const dy = px.y - p.y; 118 | const dis = Math.sqrt(dx * dx + dy * dy); 119 | if (dis < minPointerDistance) { 120 | minPointerDistance = dis; 121 | displayingX = d.x; 122 | } 123 | } 124 | 125 | const xFormatter = options.xFormatter; 126 | this.xItem.value.textContent = xFormatter(displayingX!); 127 | 128 | for (const s of chart.options.series) { 129 | if (!s.visible) 130 | continue; 131 | 132 | let point = chart.nearestPoint.dataPoints.get(s); 133 | let item = this.items.get(s); 134 | if (item) { 135 | item.item.classList.toggle('out-of-range', !point); 136 | if (point) { 137 | item.value.textContent = point.y.toLocaleString(); 138 | item.item.classList.toggle('x-not-aligned', point.x !== displayingX); 139 | } 140 | } 141 | } 142 | }); 143 | } 144 | 145 | private createItemElements(label: string): ItemElements { 146 | const item = document.createElement('tr'); 147 | item.className = 'item'; 148 | const exampleTd = document.createElement('td'); 149 | const example = document.createElement('div'); 150 | example.className = 'example'; 151 | exampleTd.appendChild(example) 152 | item.appendChild(exampleTd); 153 | const name = document.createElement('td'); 154 | name.className = "name"; 155 | name.textContent = label; 156 | item.appendChild(name); 157 | const value = document.createElement('td'); 158 | value.className = "value"; 159 | item.appendChild(value); 160 | 161 | return { item, example, name, value }; 162 | } 163 | 164 | update() { 165 | for (const s of this.chartOptions.series) { 166 | if (!this.items.has(s)) { 167 | const itemElements = this.createItemElements(s.name); 168 | this.itemContainer.appendChild(itemElements.item); 169 | this.items.set(s, itemElements); 170 | } 171 | 172 | const item = this.items.get(s)!; 173 | item.example.style.backgroundColor = (s.color ?? this.chartOptions.color).toString(); 174 | item.item.style.display = s.visible ? "" : "none"; 175 | } 176 | } 177 | } 178 | 179 | const defaultOptions: TooltipOptions = { 180 | enabled: false, 181 | xLabel: "X", 182 | xFormatter: x => x.toLocaleString(), 183 | }; 184 | 185 | export class TimeChartTooltipPlugin implements TimeChartPlugin { 186 | options: TooltipOptions; 187 | constructor(options?: Partial) { 188 | if (!options) 189 | options = {}; 190 | if (!defaultOptions.isPrototypeOf(options)) 191 | Object.setPrototypeOf(options, defaultOptions); 192 | this.options = options as TooltipOptions; 193 | } 194 | 195 | apply(chart: core) { 196 | return new Tooltip(chart, this.options); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/plugins/webGLUtils.ts: -------------------------------------------------------------------------------- 1 | export class LinkedWebGLProgram { 2 | program: WebGLProgram; 3 | 4 | constructor( 5 | private gl: WebGLRenderingContext, 6 | vertexSource: string, fragmentSource: string, 7 | public readonly debug: boolean 8 | ) { 9 | const program = throwIfFalsy(gl.createProgram()); 10 | gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, debug))); 11 | gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, debug))); 12 | this.program = program 13 | } 14 | 15 | link() { 16 | const gl = this.gl; 17 | const program = this.program; 18 | gl.linkProgram(program); 19 | if (this.debug) { 20 | const success = gl.getProgramParameter(program, gl.LINK_STATUS); 21 | if (!success) { 22 | const message = gl.getProgramInfoLog(program) ?? 'Unknown Error.'; 23 | gl.deleteProgram(program); 24 | throw new Error(message); 25 | } 26 | } 27 | } 28 | 29 | getUniformLocation(name: string) { 30 | return throwIfFalsy(this.gl.getUniformLocation(this.program, name)); 31 | } 32 | 33 | public use() { 34 | this.gl.useProgram(this.program); 35 | } 36 | } 37 | 38 | export function createShader(gl: WebGLRenderingContext, type: number, source: string, debug: boolean): WebGLShader { 39 | const shader = throwIfFalsy(gl.createShader(type)); 40 | gl.shaderSource(shader, source); 41 | gl.compileShader(shader); 42 | if (debug) { 43 | const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); 44 | if (!success) { 45 | const message = gl.getShaderInfoLog(shader) ?? 'Unknown Error.'; 46 | gl.deleteShader(shader); 47 | throw new Error(message); 48 | } 49 | } 50 | return shader 51 | } 52 | 53 | export function throwIfFalsy(value: T | undefined | null): T { 54 | if (!value) { 55 | throw new Error('value must not be falsy'); 56 | } 57 | return value; 58 | } 59 | -------------------------------------------------------------------------------- /src/plugins_extra/README.md: -------------------------------------------------------------------------------- 1 | # Extra Plugins 2 | 3 | This plugins are not enabled by default. Enable them as you need. 4 | 5 | ## Installation 6 | 7 | These are included in the npm packages. 8 | 9 | To use HTML script tag: 10 | 11 | ```HTML 12 | 13 | 14 | 15 | ``` 16 | 17 | Then access the plugins from `TimeChart.plugins_extra`. 18 | 19 | ## Plugins 20 | 21 | ### Events 22 | 23 | Display events at a point in time with vertical lines. 24 | [Demo](https://huww98.github.io/TimeChart/demo/plugins/events.html) 25 | 26 | ### Select Zoom 27 | 28 | Zoom the chart by selecting a rectangle area. 29 | [Demo](https://huww98.github.io/TimeChart/demo/plugins/select.html) 30 | -------------------------------------------------------------------------------- /src/plugins_extra/events.ts: -------------------------------------------------------------------------------- 1 | import core from '../core'; 2 | import { TimeChartPlugin } from '../plugins'; 3 | import { select } from 'd3-selection'; 4 | 5 | export interface Event { 6 | name: string; 7 | x: number; 8 | } 9 | 10 | export class EventsPlugin implements TimeChartPlugin { 11 | readonly data: Event[] 12 | constructor(data?: Event[]) { 13 | this.data = data ?? []; 14 | } 15 | 16 | apply(chart: core) { 17 | const d3Svg = select(chart.svgLayer.svgNode) 18 | const box = d3Svg.append('svg'); 19 | box.append('style').text(` 20 | .timechart-event-line { 21 | stroke: currentColor; 22 | stroke-width: 1; 23 | stroke-dasharray: 2 1; 24 | opacity: 0.7; 25 | }`); 26 | chart.model.resized.on((w, h) => { 27 | box.attr('height', h - chart.options.paddingBottom); 28 | }) 29 | 30 | chart.model.updated.on(() => { 31 | const eventEl = box.selectAll('g') 32 | .data(this.data); 33 | 34 | eventEl.exit().remove(); 35 | 36 | const newEventEl = eventEl.enter().append('g'); 37 | newEventEl.append('line') 38 | .attr('y2', '100%') 39 | .attr('class', 'timechart-event-line'); 40 | newEventEl.append('text') 41 | .attr('x', 5) 42 | .attr('y', chart.options.paddingTop) 43 | .attr('dy', '0.8em'); 44 | 45 | const allEventEl = eventEl.merge(newEventEl) 46 | allEventEl.attr('transform', d => `translate(${chart.model.xScale(d.x)}, 0)`) 47 | allEventEl.select('text') 48 | .text(d => d.name) 49 | }); 50 | 51 | return this; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/plugins_extra/index.ts: -------------------------------------------------------------------------------- 1 | export { EventsPlugin } from './events'; 2 | export { SelectZoomPlugin } from './selectZoom'; 3 | -------------------------------------------------------------------------------- /src/plugins_extra/selectZoom.ts: -------------------------------------------------------------------------------- 1 | import core from "../core"; 2 | import { TimeChartPlugin } from "../plugins"; 3 | 4 | export interface SelectZoomOptions { 5 | mouseButtons: number; 6 | enableX: boolean; 7 | enableY: boolean; 8 | cancelOnSecondPointer: boolean; 9 | } 10 | 11 | const defaultOptions = { 12 | mouseButtons: 1, 13 | enableX: true, 14 | enableY: true, 15 | cancelOnSecondPointer: false, 16 | } as const; 17 | 18 | interface Point { x : number; y : number; }; 19 | 20 | export class SelectZoom { 21 | private visual: SVGRectElement; 22 | constructor(private readonly chart: core, public readonly options: SelectZoomOptions) { 23 | const el = chart.contentBoxDetector.node; 24 | el.tabIndex = -1; 25 | el.addEventListener('pointerdown', ev => this.onMouseDown(ev), { signal: chart.model.abortController.signal }); 26 | el.addEventListener('pointerup', ev => this.onMouseUp(ev), { signal: chart.model.abortController.signal }); 27 | el.addEventListener('pointermove', ev => this.onMouseMove(ev), { signal: chart.model.abortController.signal }); 28 | el.addEventListener('pointercancel', ev => this.onPointerCancel(ev), { signal: chart.model.abortController.signal }); 29 | el.addEventListener('keydown', ev => this.onKeyDown(ev), { signal: chart.model.abortController.signal }); 30 | 31 | const style = document.createElementNS("http://www.w3.org/2000/svg", "style"); 32 | style.textContent = ` 33 | .timechart-selection { 34 | stroke: currentColor; 35 | stroke-width: 1; 36 | fill: gray; 37 | opacity: 0.5; 38 | visibility: hidden; 39 | }` 40 | chart.svgLayer.svgNode.appendChild(style); 41 | 42 | this.visual = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 43 | this.visual.classList.add('timechart-selection'); 44 | chart.svgLayer.svgNode.appendChild(this.visual); 45 | } 46 | 47 | onKeyDown(ev: KeyboardEvent) { 48 | if (ev.code === 'Escape') 49 | this.reset(); 50 | } 51 | 52 | private start: {p: Point, id: number} | null = null; 53 | 54 | private reset() { 55 | if (this.start === null) 56 | return; 57 | const el = this.chart.contentBoxDetector.node; 58 | el.releasePointerCapture(this.start.id); 59 | this.visual.style.visibility = 'hidden'; 60 | this.start = null; 61 | } 62 | 63 | private getPoint(ev: PointerEvent): Point { 64 | const boundingRect = this.chart.svgLayer.svgNode.getBoundingClientRect(); 65 | return { 66 | x: ev.clientX - boundingRect.left, 67 | y: ev.clientY - boundingRect.top, 68 | }; 69 | } 70 | 71 | onMouseDown(ev: PointerEvent) { 72 | if (this.start !== null) { 73 | if (this.options.cancelOnSecondPointer) 74 | this.reset(); 75 | return; 76 | } 77 | if (ev.pointerType === 'mouse' && (ev.buttons & this.options.mouseButtons) === 0) 78 | return; 79 | const el = this.chart.contentBoxDetector.node; 80 | this.start = { 81 | p: this.getPoint(ev), 82 | id: ev.pointerId, 83 | }; 84 | el.setPointerCapture(ev.pointerId); 85 | 86 | this.visual.x.baseVal.value = this.start.p.x; 87 | this.visual.y.baseVal.value = this.start.p.y; 88 | this.visual.width.baseVal.value = 0; 89 | this.visual.height.baseVal.value = 0; 90 | this.visual.style.visibility = 'visible'; 91 | } 92 | 93 | onMouseMove(ev: PointerEvent) { 94 | if (ev.pointerId !== this.start?.id) 95 | return; 96 | const p = this.getPoint(ev); 97 | 98 | if (this.options.enableX) { 99 | const x = Math.min(this.start.p.x, p.x); 100 | const w = Math.abs(this.start.p.x - p.x); 101 | this.visual.x.baseVal.value = x; 102 | this.visual.width.baseVal.value = w; 103 | } else { 104 | this.visual.setAttribute('x', '0'); 105 | this.visual.setAttribute('width', '100%'); 106 | } 107 | 108 | if (this.options.enableY) { 109 | const y = Math.min(this.start.p.y, p.y); 110 | const h = Math.abs(this.start.p.y - p.y); 111 | this.visual.y.baseVal.value = y; 112 | this.visual.height.baseVal.value = h; 113 | } else { 114 | this.visual.setAttribute('y', '0'); 115 | this.visual.setAttribute('height', '100%'); 116 | } 117 | } 118 | 119 | onMouseUp(ev: PointerEvent) { 120 | if (ev.pointerId !== this.start?.id) 121 | return; 122 | 123 | const p = this.getPoint(ev); 124 | 125 | let changed = false; 126 | if (this.options.enableX) { 127 | const x1 = Math.min(this.start.p.x, p.x); 128 | const x2 = Math.max(this.start.p.x, p.x); 129 | if (x2 - x1 > 0) { 130 | const newDomain = [ 131 | this.chart.model.xScale.invert(x1), 132 | this.chart.model.xScale.invert(x2), 133 | ]; 134 | this.chart.model.xScale.domain(newDomain); 135 | this.chart.options.xRange = null; 136 | changed = true; 137 | } 138 | } 139 | if (this.options.enableY) { 140 | const y1 = Math.max(this.start.p.y, p.y); 141 | const y2 = Math.min(this.start.p.y, p.y); 142 | if (y1 - y2 > 0) { 143 | const newDomain = [ 144 | this.chart.model.yScale.invert(y1), 145 | this.chart.model.yScale.invert(y2), 146 | ]; 147 | this.chart.model.yScale.domain(newDomain); 148 | this.chart.options.yRange = null; 149 | changed = true; 150 | } 151 | } 152 | if (changed) 153 | this.chart.model.requestRedraw(); 154 | 155 | this.reset(); 156 | } 157 | 158 | onPointerCancel(ev: PointerEvent) { 159 | if (ev.pointerId === this.start?.id) 160 | this.reset(); 161 | } 162 | } 163 | 164 | export class SelectZoomPlugin implements TimeChartPlugin { 165 | readonly options: SelectZoomOptions; 166 | constructor(options?: Partial) { 167 | if (!options) 168 | options = {}; 169 | if (!defaultOptions.isPrototypeOf(options)) 170 | Object.setPrototypeOf(options, defaultOptions); 171 | this.options = options as SelectZoomOptions; 172 | } 173 | apply(chart: core) { 174 | return new SelectZoom(chart, this.options); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** lower bound */ 2 | export function domainSearch(data: ArrayLike, start: number, end: number, value: number, key: (v: T) => number) { 3 | if (start >= end) { 4 | return start; 5 | } 6 | 7 | if (value <= key(data[start])) { 8 | return start; 9 | } 10 | if (value > key(data[end - 1])) { 11 | return end; 12 | } 13 | 14 | end -= 1; 15 | while (start + 1 < end) { 16 | const minDomain = key(data[start]); 17 | const maxDomain = key(data[end]); 18 | const ratio = maxDomain <= minDomain ? 0 : (value - minDomain) / (maxDomain - minDomain); 19 | let expectedIndex = Math.ceil(start + ratio * (end - start)); 20 | if (expectedIndex === end) 21 | expectedIndex--; 22 | else if (expectedIndex === start) 23 | expectedIndex++; 24 | const domain = key(data[expectedIndex]); 25 | 26 | if (domain < value) { 27 | start = expectedIndex; 28 | } else { 29 | end = expectedIndex; 30 | } 31 | } 32 | return end; 33 | } 34 | 35 | type CbParameters) => void> = T extends (...args: infer P) => void ? P : never; 36 | 37 | export class EventDispatcher) => void = (() => void)> { 38 | private callbacks: Array = [] 39 | on(callback: TCb) { 40 | this.callbacks.push(callback); 41 | } 42 | dispatch(...args: CbParameters) { 43 | for (const cb of this.callbacks) { 44 | cb(...args); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test-d/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType, expectError} from 'tsd'; 2 | import core from '../src/core'; 3 | import TimeChart from '../src/index'; 4 | import { TimeChartPlugin } from '../src/plugins'; 5 | import { crosshair } from '../src/plugins/crosshair'; 6 | import { Legend, legend } from '../src/plugins/legend'; 7 | 8 | const el = {} as HTMLElement; 9 | const chart = new core(el, { 10 | baseTime: 1, 11 | plugins: { 12 | legend, 13 | crosshair, 14 | }, 15 | }) 16 | 17 | expectType(chart.plugins.crosshair); 18 | expectType(chart.plugins.legend) 19 | expectError(chart.plugins.a) 20 | 21 | const chart2 = new core(el, { 22 | baseTime: 1, 23 | // @ts-expect-error 24 | plugins: [], 25 | }) 26 | 27 | const chart3 = new TimeChart(el, { 28 | plugins: { 29 | test: {} as TimeChartPlugin 30 | } 31 | }) 32 | expectType(chart3.plugins.test) 33 | expectType(chart3.plugins.legend) 34 | -------------------------------------------------------------------------------- /test/chartZoom/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { zip, linearRegression } from "../../src/chartZoom/utils"; 2 | 3 | describe('zip test', () => { 4 | it('basic', () => { 5 | expect(zip([1, 2, 3], [4, 5, 6])).toEqual([[1, 4], [2, 5], [3, 6]]); 6 | }); 7 | }) 8 | 9 | describe('linear regression test', () => { 10 | it('1pt', () => { 11 | expect(linearRegression([{ x: 1, y: 2 }])).toEqual({ k: 0, b: 2 }); 12 | }) 13 | it('2pt', () => { 14 | expect(linearRegression([{ x: 1, y: 2 }, { x: 2, y: 3 }])).toEqual({ k: 1, b: 1 }); 15 | }); 16 | it('3pt colinear', () => { 17 | expect(linearRegression([{ x: 1, y: 2 }, { x: 2, y: 3 }, { x: 3, y: 4 }])).toEqual({ k: 1, b: 1 }); 18 | }); 19 | it('3pt', () => { 20 | expect(linearRegression([{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: -1 }])).toEqual({ k: -0.5, b: 0.5 }); 21 | }); 22 | }) 23 | -------------------------------------------------------------------------------- /test/dataPointsBuffer.test.ts: -------------------------------------------------------------------------------- 1 | import { DataPointsBuffer } from '../src/core/dataPointsBuffer'; 2 | 3 | function syncedBuffer(...items: number[]) { 4 | const b = new DataPointsBuffer(...items); 5 | b._synced(); 6 | return b; 7 | } 8 | 9 | describe('DataPointsBuffer', () => { 10 | test('push back', () => { 11 | const b = syncedBuffer(); 12 | b.push(1, 2); 13 | expect(b.pushed_back).toBe(2); 14 | }); 15 | describe('pop back', () => { 16 | test('synced', () => { 17 | const b = syncedBuffer(0, 1, 2); 18 | expect(b.pop()).toBe(2); 19 | expect(b.poped_back).toBe(1); 20 | }); 21 | test('just pushed', () => { 22 | const b = syncedBuffer(); 23 | b.push(2, 3); 24 | expect(b.pushed_back).toBe(2); 25 | expect(b.pop()).toBe(3); 26 | expect(b.poped_back).toBe(0); 27 | expect(b.pushed_back).toBe(1); 28 | }); 29 | test('empty', () => { 30 | const b = syncedBuffer(); 31 | expect(b.pop()).toBe(undefined); 32 | expect(b.poped_back).toBe(0); 33 | }); 34 | test('through', () => { 35 | const b = syncedBuffer(0, 1); 36 | b.unshift(-6, -5, -4) 37 | expect(b.pushed_front).toBe(3); 38 | b.pop() 39 | b.pop() 40 | b.pop() 41 | expect(b.poped_back).toBe(2); 42 | expect(b.pushed_front).toBe(2); 43 | }); 44 | }); 45 | test('push front', () => { 46 | const b = syncedBuffer(); 47 | b.unshift(1, 2); 48 | expect(b.pushed_front).toBe(2); 49 | }); 50 | describe('pop front', () => { 51 | test('synced', () => { 52 | const b = syncedBuffer(0, 1, 2); 53 | expect(b.shift()).toBe(0); 54 | expect(b.poped_front).toBe(1); 55 | }); 56 | test('just pushed', () => { 57 | const b = syncedBuffer(); 58 | b.unshift(2, 3); 59 | expect(b.pushed_front).toBe(2); 60 | expect(b.shift()).toBe(2); 61 | expect(b.poped_front).toBe(0); 62 | expect(b.pushed_front).toBe(1); 63 | }); 64 | test('empty', () => { 65 | const b = syncedBuffer(); 66 | expect(b.shift()).toBe(undefined); 67 | expect(b.poped_front).toBe(0); 68 | }); 69 | test('through', () => { 70 | const b = syncedBuffer(0, 1); 71 | b.push(4, 5, 6) 72 | expect(b.pushed_back).toBe(3); 73 | b.shift() 74 | b.shift() 75 | b.shift() 76 | expect(b.poped_front).toBe(2); 77 | expect(b.pushed_back).toBe(2); 78 | }); 79 | }); 80 | describe('splice', () => { 81 | test('delete all', () => { 82 | const b = syncedBuffer(4, 5, 6); 83 | b.shift(); 84 | b.unshift(1); 85 | b.pop(); 86 | b.push(8); 87 | b.splice(0); 88 | expect(b.pushed_front).toBe(0); 89 | expect(b.pushed_back).toBe(0); 90 | expect(b.poped_front + b.poped_back).toBe(3); 91 | }); 92 | test('pop front', () => { 93 | const b = syncedBuffer(4, 5, 6); 94 | b.splice(0, 2); 95 | expect(b.poped_front).toBe(2); 96 | }); 97 | test('pop back', () => { 98 | const b = syncedBuffer(4, 5, 6); 99 | b.splice(1, 2); 100 | expect(b.poped_back).toBe(2); 101 | }); 102 | test('pop middle', () => { 103 | const b = syncedBuffer(4, 5, 6); 104 | expect(() => { b.splice(1, 1) }).toThrow(RangeError); 105 | }); 106 | test('pop pushed front', () => { 107 | const b = syncedBuffer(4, 5, 6); 108 | b.unshift(0, 1, 2) 109 | b.splice(1, 1); 110 | expect(b.poped_front).toBe(0); 111 | expect(b.pushed_front).toBe(2); 112 | }); 113 | test('pop pushed back', () => { 114 | const b = syncedBuffer(4, 5, 6); 115 | b.push(0, 1, 2) 116 | b.splice(4, 1); 117 | expect(b.poped_back).toBe(0); 118 | expect(b.pushed_back).toBe(2); 119 | }); 120 | test('insert front', () => { 121 | const b = syncedBuffer(4, 5, 6); 122 | b.splice(0, 0, 1, 2); 123 | expect(b.pushed_front).toBe(2); 124 | }); 125 | test('insert back', () => { 126 | const b = syncedBuffer(4, 5, 6); 127 | b.splice(3, 0, 8, 9); 128 | expect(b.pushed_back).toBe(2); 129 | }); 130 | test('insert middle', () => { 131 | const b = syncedBuffer(4, 5, 6); 132 | expect(() => { b.splice(1, 0, 8, 9) }).toThrow(RangeError); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { domainSearch } from "../src/utils"; 2 | 3 | describe('DomainSearch test', () => { 4 | const key = (x: number) => x 5 | it('exact match', () => { 6 | const data = [0, 1, 2, 3, 4, 5]; 7 | expect(domainSearch(data, 0, data.length, 2, key)).toEqual(2); 8 | }); 9 | it('insert point', () => { 10 | const data = [0, 1, 2, 3, 4, 5]; 11 | expect(domainSearch(data, 0, data.length, 2.4, key)).toEqual(3); 12 | expect(domainSearch(data, 0, data.length, 2.5, key)).toEqual(3); 13 | expect(domainSearch(data, 0, data.length, 2.6, key)).toEqual(3); 14 | }); 15 | it('out lower bound', () => { 16 | const data = [0, 1, 2, 3, 4, 5]; 17 | expect(domainSearch(data, 0, data.length, -1, key)).toEqual(0); 18 | }); 19 | it('out upper bound', () => { 20 | const data = [0, 1, 2, 3, 4, 5]; 21 | expect(domainSearch(data, 0, data.length, 10, key)).toEqual(data.length); 22 | }); 23 | it('duplicated', () => { 24 | const data = [0, 1, 2, 2, 4, 5]; 25 | expect(domainSearch(data, 0, data.length, 3, key)).toEqual(4); 26 | }); 27 | it('duplicated lower bound', () => { 28 | const data = [0, 1, 2, 2, 4, 5]; 29 | for (let end = 2; end <= 6; end++) { 30 | expect(domainSearch(data, 0, end, 2, key)).toEqual(2); 31 | } 32 | }); 33 | it('uneven', () => { 34 | const data = [0]; 35 | for (let i = 0; i < 100; i++) { 36 | data.push(100); 37 | } 38 | expect(domainSearch(data, 0, data.length, 99, key)).toEqual(1); 39 | }); 40 | it('empty', () => { 41 | expect(domainSearch([], 0, 0, 0, key)).toEqual(0); 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "ES2016", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "importHelpers": true, 7 | "module": "ES2020", 8 | "sourceMap": true, 9 | "types": [], 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "declaration": true, 15 | "outDir": "dist/lib" 16 | }, 17 | "include": [ 18 | "src" 19 | ] 20 | } 21 | --------------------------------------------------------------------------------