├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── API.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── circle.yml ├── example.png ├── examples ├── BasicInteractiveChart.tsx ├── ComplicatedChart.tsx ├── ConnectedInteractiveChart.tsx ├── ControlledInteractiveChart.tsx ├── StaticChart.tsx ├── example-styles.less ├── index-template.html ├── index.tsx └── test-data.ts ├── hooks ├── install.sh └── pre-commit ├── package.json ├── src ├── connected │ ├── ChartProvider.tsx │ ├── axes │ │ ├── ConnectedXAxis.tsx │ │ ├── ConnectedYAxis.tsx │ │ └── index.ts │ ├── export-only │ │ ├── exportableActions.ts │ │ ├── exportableSelectors.ts │ │ └── exportableState.ts │ ├── flux │ │ ├── atomicActions.ts │ │ ├── compoundActions.ts │ │ ├── reducer.ts │ │ ├── reducerUtils.ts │ │ └── storeFactory.ts │ ├── index.ts │ ├── interfaces.ts │ ├── layers │ │ ├── ConnectedHoverLineLayer.tsx │ │ ├── ConnectedInteractionCaptureLayer.tsx │ │ ├── ConnectedResizeSentinelLayer.tsx │ │ ├── ConnectedSelectionBrushLayer.tsx │ │ ├── ConnectedSpanLayer.tsx │ │ ├── connectedDataLayers.tsx │ │ ├── index.ts │ │ └── wrapDataLayerWithConnect.tsx │ ├── loaderUtils.ts │ └── model │ │ ├── constants.ts │ │ ├── selectors.ts │ │ └── state.ts ├── core │ ├── MouseCapture.tsx │ ├── Stack.tsx │ ├── axes │ │ ├── XAxis.tsx │ │ ├── YAxis.tsx │ │ └── index.ts │ ├── componentUtils.tsx │ ├── decorators │ │ ├── NonReactRender.ts │ │ ├── PixelRatioContext.ts │ │ ├── PixelRatioContextProvider.ts │ │ └── index.ts │ ├── index.ts │ ├── interfaces.ts │ ├── intervalUtils.ts │ ├── layers │ │ ├── BarLayer.tsx │ │ ├── BucketedLineLayer.tsx │ │ ├── InteractionCaptureLayer.tsx │ │ ├── LineLayer.tsx │ │ ├── PointLayer.tsx │ │ ├── PollingResizingCanvasLayer.tsx │ │ ├── SpanLayer.tsx │ │ ├── VerticalLineLayer.tsx │ │ └── index.ts │ ├── propTypes.ts │ └── renderUtils.ts ├── index.ts └── test-util │ ├── CanvasContextSpy.ts │ └── index.ts ├── styles └── index.less ├── test ├── CanvasContextSpy-test.ts ├── atomicActions-test.ts ├── compoundActions-test.ts ├── intervalUtils-test.ts ├── layers │ ├── BarLayer-test.ts │ ├── BucketedLineLayer-test.ts │ ├── LineLayer-test.ts │ ├── PointLayer-test.ts │ ├── SpanLayer-test.ts │ ├── VerticalLineLayer-test.ts │ └── layerTestUtils.ts ├── loaderUtils-test.ts ├── mocha.opts ├── reducerUtils-test.ts ├── renderUtils-test.ts └── selectors-test.ts ├── tsconfig-base.json ├── tsconfig-build.json ├── tsconfig-webpack.json ├── tsconfig.json ├── tslint.json ├── webpack.config.hot.js ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | examples/build/ 3 | .DS_Store 4 | *.sublime-workspace 5 | *.log 6 | lib/ 7 | react-layered-chart.css* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitkeep 2 | examples/ 3 | hooks/ 4 | styles/ 5 | test/ 6 | *.sublime-project 7 | *.sublime-workspace 8 | circle.yml 9 | tsconfig*.json 10 | tsd.json 11 | webpack.config.*js 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Palantir Technologies 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-layered-chart 2 | 3 | A high-performance canvas-based time series visualization in Typescript + React. 4 | 5 | ![Example Image](./example.png) 6 | 7 | [![CircleCI](https://circleci.com/gh/palantir/react-layered-chart.svg?style=shield&circle-token=b88166d5bf8a2beba96fbfc3bd2e62cad17f204b)](https://circleci.com/gh/palantir/react-layered-chart) 8 | [![npm](https://img.shields.io/npm/v/react-layered-chart.svg?maxAge=2592000?style=flat-square)](https://www.npmjs.com/package/react-layered-chart) 9 | 10 | ## Table of Contents 11 | 12 | * [Installation](#installation) 13 | * [Making Basic, Static Charts](#making-basic-static-charts) 14 | * [Interactive Charts](#interactive-charts) 15 | * [Adding Custom Behavior](#adding-custom-behavior) 16 | * [Caveats/Limitations](#caveatslimitations) 17 | * [Common Issues](#common-issues) 18 | * [Developing](#developing) 19 | 20 | The [API reference can be found at API.md](API.md). 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install --save react-layered-chart 26 | ``` 27 | 28 | Be sure to include the styles from `react-layered-chart/react-layered-chart.css`. This file is also specified on the `style` key in `package.json` for any toolchains that support it. 29 | 30 | ### Typings 31 | 32 | If you're using Typescript, you must provide typings for a global `Promise`, as this type appears in the API of react-layered-chart. If you're targeting ES6, the standard library typings should suffice. Otherwise, you can install something like [`es6-promise`](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/es6-promise) or typings for your library of choice, such as [Bluebird](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/bluebird). 33 | 34 | ## Making Basic, Static Charts 35 | 36 | > See the [section on developing](#developing) to set up a page you can play around with yourself. Check the [caveats](#caveatslimitations) and [common issues](#common-issues) if you run into problems. 37 | 38 | The core functionality of react-layered-chart is a set of "layer" components inside a `Stack` component. The simplest possible chart looks something like this: 39 | 40 | ```tsx 41 | const MY_DATA = [ ... ]; 42 | 43 | 44 | 49 | 50 | ``` 51 | 52 | ([View code that implements a chart like this.](/examples/StaticChart.tsx)) 53 | 54 | Where the `data` prop is an array of objects appropriate for the particular layer (see the [implementations of the included layers](https://github.com/palantir/react-layered-chart/tree/master/src/core/layers) for details). 55 | 56 | The `xDomain` and `yDomain` props, which are common to many layers, describe which subset of the data should be rendered. Many layers also include a `yScale` for customizing the scale on the Y domain (e.g. for displaying logarithmic plots). 57 | 58 | Including multiple layers will cause them to be stacked in the Z direction, so you can overlay multiple charts. For instance, if you want a line chart that also emphasizes each data point with a dot, you could do something like the following: 59 | 60 | ```tsx 61 | 62 | 63 | 64 | 65 | ``` 66 | 67 | Charts made in this manner are static. See the [interactive section](#interactive-charts) for how to make interactive charts. 68 | 69 | ## Interactive Charts 70 | 71 | > See the [section on developing](#developing) to set up a page you can play around with yourself. Check the [caveats](#caveatslimitations) and [common issues](#common-issues) if you run into problems. 72 | 73 | react-layered-chart also includes a bunch of somewhat opinionated, stateful components that help you make interactive charts and load new data as necessary. These components require that each of the series you're rendering can be uniquely identified with a string, referred to as the "series ID". 74 | 75 | The `ChartProvider` component is a wrapper around a [react-redux `Provider`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#provider-store) that also exposes a [controlled-input-like](https://facebook.github.io/react/docs/forms.html#controlled-components) interface. A simple chart that includes user interaction might look like this: 76 | 77 | ```tsx 78 | // This stateless function receives a bunch of parameters to load data. It's called 79 | // any time the X domain changes or the data otherwise becomes potentially stale. 80 | function myDataLoader(...) { 81 | return ...; 82 | } 83 | 84 | 89 | 90 | 91 | 92 | 93 | 94 | ``` 95 | 96 | ([View code that implements a chart like this.](/examples/BasicInteractiveChart.tsx)) 97 | 98 | In this example, the X and Y domains are controlled by internal state and need not be explicitly passed. The `ConnectedInteractionCaptureLayer` captures mouse events and dispatches actions internally to make the chart respond to user input. 99 | 100 | ## Adding Custom Behavior 101 | 102 | > See the [section on developing](#developing) to set up a page you can play around with yourself. Check the [caveats](#caveatslimitations) and [common issues](#common-issues) if you run into problems. 103 | 104 | react-layered-chart provides two methods of customizing behavior: 105 | 106 | - Providing props to the `ChartProvider` in the manner of [controlled components](https://facebook.github.io/react/docs/forms.html#controlled-components). [View example.](/examples/ControlledInteractiveChart.tsx) 107 | - Hooking into the internal [Redux](https://github.com/reactjs/redux) store using react-redux's [`connect`](https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options) plus the [selectors](https://github.com/reactjs/reselect) and action creators provided by react-layered-chart. [View example.](/examples/ConnectedInteractiveChart.tsx) 108 | 109 | Your custom component doesn't even have to be a type of chart -- for example, if you want to add a textual legend that updates on hover, you could do this by adding a component within a `ChartProvider`. 110 | 111 | If you'd like to add animation to your custom view, I recommend [react-motion](https://github.com/chenglou/react-motion). It composes well and handles interrupted animations elegantly. 112 | 113 | ## Caveats/Limitations 114 | 115 | ### Physical chart size 116 | 117 | `ChartProvider` needs to know how large it is on the page in order to scale and request data at an appropriate resolution. By default, it injects a hidden `ConnectedResizeSentinelLayer` to poll for the width of the container. 118 | 119 | If you adjust the margins/padding or change the layout to be horizontally-aligned, you may need to set `ChartProvider`'s `includeResizeSentinel` to `false` and supply your own `ConnectedResizeSentinelLayer` in a place where it can determine the correct width. You only need one per `ChartProvider`, as all charts are assumed to be the same width. 120 | 121 | The included CSS provides some easily-overridden defaults to prevent some issues around zero-size charts. Ensure you have [included the stylesheet](#installation) and set a meaningful width/height for your chart. 122 | 123 | ### Usage with react-redux 124 | 125 | `ChartProvider` is implemented under the hood with [Redux](https://github.com/reactjs/redux) and [react-redux](https://github.com/reactjs/react-redux)'s `Provider`. If you are using react-redux elsewhere, watch out for nested `Provider`s: you cannot access the outer provider from a child of a `ChartProvider` component! 126 | 127 | `ChartProvider` has a `debugStoreHooks` prop if you want to attach tooling to the underlying store for debugging purposes. 128 | 129 | ### Tracking transient state across (un)mount cycles 130 | 131 | Each `ChartProvider` component is highly stateful: both in the data that's automatically loaded for you and in the state of the UI as the user interacts. If you want to preserve the transient state across renders but the nature of your application causes React to unmount it frequently, you can provide the optional `chartId` to `ChartProvider`. `chartId` is an arbitrary, *globally-unique* string for this particular chart that will allow react-layered-chart to restore state from the the last time a chart with this ID was mounted. 132 | 133 | A common example of undesirable state loss is in a tabbed application: charts in one tab will be unmounted and removed from the DOM as you switch, but to provide good UX, you'll want to make sure they're still loaded and panned over to the same place when the user tabs back. 134 | 135 | ## Common Issues 136 | 137 | Please [file an issue on Github](https://github.com/palantir/react-layered-chart/issues) for any issues that aren't covered here. 138 | 139 | #### The chart is invisible, zero-height, unstoppably increasing in height; or, my loader is receiving `0` for the chart width parameter. 140 | 141 | This likely happens because you've either forgotten to [include react-layered-chart's stylesheet](#installation) (which sets some default sizes to prevent some of these issues) or you haven't provided any CSS rules to size the chart horizontally. See the [caveat about physical chart size](#physical-chart-size) for an explanation of why this happens. 142 | 143 | #### When `ChartProvider` unmounts, I lose all my loaded state. 144 | 145 | Specify a globally-unique `chartId` string prop to `ChartProvider` so on next mount it can retrieve its old state. See the [caveat on (un)mounting](#tracking-transient-state-across-unmount-cycles) for more details. 146 | 147 | #### Loads are consistently and frequently triggered even when nothing is changing. 148 | 149 | This generally means you are providing a functionally-equal but reference-unequal value for `ChartProvider`'s `loadData`, such as by using an inline function definition. `loadData` should be a stateless function, and should almost never change reference. If you want to provide extra information to `loadData`, use `ChartProvider`'s `loadDataContext` prop instead of `bind`ing arguments to a function (which creates a new function object). All series are reloaded whenever `loadDataContext` changes shallowly, but if you use deep-checkable types you can tweak `loadData` to prevent extraneous network requests. 150 | 151 | ## Developing 152 | 153 | ``` 154 | npm install 155 | npm start 156 | ``` 157 | 158 | Then visit [localhost:8085](http://localhost:8085/) to see the example page. This runs `webpack-dev-server` on port 8085, including auto-recompilation and hot code injection. 159 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 7.9.0 4 | 5 | dependencies: 6 | pre: 7 | - npm install yarn@0.20.3 -g 8 | - yarn config set cache-folder /home/ubuntu/.cache/yarn 9 | 10 | override: 11 | - yarn install 12 | 13 | cache_directories: 14 | - /home/ubuntu/.cache/yarn 15 | - node_modules 16 | 17 | test: 18 | override: 19 | - npm test 20 | - npm run prepublish 21 | 22 | deployment: 23 | npm-publish: 24 | tag: /v[0-9]+(\.[0-9]+){2}(-.*[0-9]+)?/ 25 | owner: palantir 26 | commands: 27 | - touch .npmrc 28 | - printf "\n$NPM_REGISTRY_CREDS\n" >> .npmrc 29 | - npm publish 30 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seansfkelley/react-layered-chart/c4c12d4c3bceadc0e75814777390d2954398a1e5/example.png -------------------------------------------------------------------------------- /examples/BasicInteractiveChart.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This chart implements the basic interactions using built-in layers and state 3 | management. It's pannable and zoomable. 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { 8 | ChartProvider, 9 | ConnectedInteractionCaptureLayer, 10 | ConnectedLineLayer, 11 | DEFAULT_SHOULD_PAN, 12 | DEFAULT_SHOULD_ZOOM, 13 | DataLoader, 14 | Stack 15 | } from '../src'; 16 | import { SIMPLE_LINE_DATA, SIMPLE_LINE_X_DOMAIN, SIMPLE_LINE_Y_DOMAIN } from './test-data'; 17 | 18 | // All series need to have an ID. 19 | const SERIES_ID = 'foo'; 20 | 21 | // Set up a test data loader that will just return this static data. 22 | const SIMPLE_LINE_DATA_LOADER: DataLoader = () => ({ 23 | [SERIES_ID]: new Promise(resolve => { 24 | resolve({ 25 | data: SIMPLE_LINE_DATA, 26 | yDomain: SIMPLE_LINE_Y_DOMAIN 27 | }); 28 | }) 29 | }); 30 | 31 | const CHART = ( 32 | 45 | 46 | {/* Render the test data as a simple line chart. */} 47 | 48 | {/* Capture any mouse interactions and automatically trigger changes on the chart. */} 49 | 50 | 51 | 52 | ); 53 | 54 | export default CHART; 55 | -------------------------------------------------------------------------------- /examples/ComplicatedChart.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This chart implements a bunch of various features from around the library to 3 | demonstrate a more complex chart. 4 | */ 5 | 6 | import * as _ from 'lodash'; 7 | import * as React from 'react'; 8 | import * as createLogger from 'redux-logger'; 9 | 10 | import { 11 | SIMPLE_LINE_DATA, 12 | SIMPLE_LINE_X_DOMAIN, 13 | SIMPLE_LINE_Y_DOMAIN, 14 | BAR_DATA, 15 | BAR_Y_DOMAIN, 16 | BUCKETED_LINE_DATA, 17 | BUCKETED_LINE_Y_DOMAIN 18 | } from './test-data'; 19 | 20 | import { 21 | ChartProvider, 22 | Stack, 23 | DataLoader, 24 | ConnectedLineLayer, 25 | ConnectedInteractionCaptureLayer, 26 | ConnectedHoverLineLayer, 27 | ConnectedBarLayer, 28 | ConnectedSelectionBrushLayer, 29 | ConnectedBucketedLineLayer, 30 | ConnectedXAxis, 31 | ConnectedYAxis, 32 | DebugStoreHooks, 33 | getActionTypeName, 34 | DEFAULT_SHOULD_PAN, 35 | DEFAULT_SHOULD_ZOOM, 36 | DEFAULT_SHOULD_HOVER, 37 | DEFAULT_SHOULD_BRUSH 38 | } from '../src'; 39 | 40 | // All series need to have an ID. 41 | const SIMPLE_LINE_SERIES_ID = 'foo'; 42 | const BAR_SERIES_ID = 'bar'; 43 | const BUCKETED_LINE_SERIES_ID = 'baz'; 44 | 45 | const COLOR_1 = '#e77'; 46 | const COLOR_2 = '#7e7'; 47 | const COLOR_3 = '#77e'; 48 | 49 | const SIMPLE_LINE_EXTENT = SIMPLE_LINE_X_DOMAIN.max - SIMPLE_LINE_X_DOMAIN.min; 50 | 51 | function shiftXValues(data: T[], shift: number): T[] { 52 | return data.map(datum => _.defaults({ 53 | minXValue: datum.minXValue + shift, 54 | maxXValue: datum.maxXValue + shift 55 | }, datum)); 56 | } 57 | 58 | // Set up a test data loader that will just return this static data. 59 | const DATA_LOADER: DataLoader = () => ({ 60 | [SIMPLE_LINE_SERIES_ID]: new Promise(resolve => resolve({ 61 | data: SIMPLE_LINE_DATA, 62 | yDomain: SIMPLE_LINE_Y_DOMAIN, 63 | })), 64 | [BAR_SERIES_ID]: new Promise(resolve => resolve({ 65 | data: shiftXValues(BAR_DATA, -SIMPLE_LINE_EXTENT * 1.1), 66 | yDomain: BAR_Y_DOMAIN 67 | })), 68 | [BUCKETED_LINE_SERIES_ID]: new Promise(resolve => resolve({ 69 | data: shiftXValues(BUCKETED_LINE_DATA, SIMPLE_LINE_EXTENT * 1.1), 70 | yDomain: BUCKETED_LINE_Y_DOMAIN 71 | })) 72 | }); 73 | 74 | const DEBUG_HOOKS: DebugStoreHooks = { 75 | middlewares: [ 76 | createLogger({ 77 | actionTransformer: (action) => _.defaults({ 78 | type: getActionTypeName(action.type) || action.type 79 | }, action), 80 | collapsed: true 81 | }) 82 | ], 83 | enhancers: [ 84 | (window as any).devToolsExtension ? (window as any).devToolsExtension() : _.identity 85 | ] 86 | }; 87 | 88 | const CHART = ( 89 | 103 | {/* This stack has all the main views. */} 104 | 105 | {/* Render the test data in a few different ways. */} 106 | 107 | 108 | 109 | {/* Capture any mouse interactions and automatically trigger changes on the chart. */} 110 | 116 | {/* Show a reference line for hover as the mouse moves around. */} 117 | 118 | {/* Show a mostly-transparent box indicating the user's selection. */} 119 | 120 | {/* Show one Y axis per series, overlaid on the left side of the chart. */} 121 |
122 | 123 | 124 | 125 |
126 |
127 | {/* Show some X axes. This Stack puts the X axes in their own section. */} 128 | 129 |
130 | 131 | 132 |
133 |
134 |
135 | ); 136 | 137 | export default CHART; 138 | -------------------------------------------------------------------------------- /examples/ConnectedInteractiveChart.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This example implements a chart with both a custom view and some custom behavior. 3 | A button is added underneath the chart that displays the current X domain, and 4 | if you click it, it will reset the domain to where it started. 5 | */ 6 | 7 | import * as React from 'react'; 8 | import { connect } from 'react-redux'; 9 | import { Dispatch, bindActionCreators } from 'redux'; 10 | import { 11 | ChartProvider, 12 | ChartProviderState, 13 | ConnectedInteractionCaptureLayer, 14 | ConnectedLineLayer, 15 | DataLoader, 16 | Interval, 17 | Stack, 18 | selectXDomain, 19 | setXDomain, 20 | DEFAULT_SHOULD_PAN, 21 | DEFAULT_SHOULD_ZOOM 22 | } from '../src'; 23 | import { SIMPLE_LINE_DATA, SIMPLE_LINE_X_DOMAIN, SIMPLE_LINE_Y_DOMAIN } from './test-data'; 24 | 25 | // All series need to have an ID. 26 | const SERIES_ID = 'foo'; 27 | 28 | // Set up a test data loader that will just return this static data. 29 | const SIMPLE_LINE_DATA_LOADER: DataLoader = () => ({ 30 | [SERIES_ID]: new Promise(resolve => { 31 | resolve({ 32 | data: SIMPLE_LINE_DATA, 33 | yDomain: SIMPLE_LINE_Y_DOMAIN 34 | }); 35 | }) 36 | }); 37 | 38 | // Props we expect to receive from our our parent component. 39 | interface OwnProps { 40 | resetXDomain: Interval; 41 | } 42 | 43 | // Props auto-injected from the internally managed state. 44 | interface ConnectedProps { 45 | currentXDomain: Interval; 46 | } 47 | 48 | // Actions we want to be able to fire. 49 | interface DispatchProps { 50 | setXDomain: typeof setXDomain; 51 | } 52 | 53 | class SnapToXDomainButton extends React.Component { 54 | render() { 55 | // Implement a simple text-based
that reacts to clicks. 56 | return ( 57 |
this.props.setXDomain(this.props.resetXDomain)}> 58 | Current: {this.props.currentXDomain.min} to {this.props.currentXDomain.max}. Click me to reset! 59 |
60 | ); 61 | } 62 | } 63 | 64 | // See react-redux docs for more. 65 | function mapStateToProps(state: ChartProviderState): ConnectedProps { 66 | return { 67 | currentXDomain: selectXDomain(state) 68 | }; 69 | } 70 | 71 | // See react-redux docs for more. 72 | function mapDispatchToProps(dispatch: Dispatch): DispatchProps { 73 | return bindActionCreators({ setXDomain }, dispatch); 74 | } 75 | 76 | // In Typescript, this cast is sometimes necessary. 77 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/8787 78 | const ConnectedSnapToXDomainButton = connect(mapStateToProps, mapDispatchToProps)(SnapToXDomainButton) as React.ComponentClass; 79 | 80 | // Wrap the button into a chart for demo purposes. 81 | const CHART = ( 82 | // See BasicInteractiveChart.tsx for comments on things that are not commented here. 83 | 93 | 94 | 95 | 96 | 97 | {/* Give the layer its own Stack so it appears visually separate from the main views. */} 98 | 99 | 100 | 101 | 102 | ); 103 | 104 | export default CHART; 105 | -------------------------------------------------------------------------------- /examples/ControlledInteractiveChart.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This example implements a chart with custom behavior. It doesn't let you pan 3 | a week beyond the data, on either end. 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { 8 | ChartProvider, 9 | ConnectedInteractionCaptureLayer, 10 | ConnectedLineLayer, 11 | DataLoader, 12 | Interval, 13 | Stack, 14 | enforceIntervalBounds, 15 | DEFAULT_SHOULD_PAN 16 | } from '../src'; 17 | import { SIMPLE_LINE_DATA, SIMPLE_LINE_X_DOMAIN, SIMPLE_LINE_Y_DOMAIN } from './test-data'; 18 | 19 | const ONE_WEEK_MS = 1000 * 60 * 60 * 24 * 7; 20 | 21 | // The bounds within which the view should always be contained. 22 | const CHART_BOUNDS = { 23 | min: SIMPLE_LINE_X_DOMAIN.min - ONE_WEEK_MS, 24 | max: SIMPLE_LINE_X_DOMAIN.max + ONE_WEEK_MS 25 | }; 26 | 27 | // All series need to have an ID. 28 | const SERIES_ID = 'foo'; 29 | 30 | // Set up a test data loader that will just return this static data. 31 | const SIMPLE_LINE_DATA_LOADER: DataLoader = () => ({ 32 | [SERIES_ID]: new Promise(resolve => { 33 | resolve({ 34 | data: SIMPLE_LINE_DATA, 35 | yDomain: SIMPLE_LINE_Y_DOMAIN 36 | }); 37 | }) 38 | }); 39 | 40 | // For simplicity in this example, the controlled domain is kept on component 41 | // state, but you should use whatever means is appropriate for your environment. 42 | interface State { 43 | xDomain: Interval; 44 | } 45 | 46 | class ControlledInteractiveChart extends React.Component<{}, State> { 47 | // Declare some sane default. 48 | state: State = { 49 | xDomain: SIMPLE_LINE_X_DOMAIN 50 | }; 51 | 52 | render() { 53 | return ( 54 | // See BasicInteractiveChart.tsx for comments on things that are not commented here. 55 | 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | } 73 | 74 | private _onXDomainChange(xDomain: Interval) { 75 | // Use the provided utility to munge the X domain to something acceptable. 76 | this.setState({ 77 | xDomain: enforceIntervalBounds(xDomain, CHART_BOUNDS) 78 | }); 79 | }; 80 | } 81 | 82 | const CHART = ; 83 | 84 | export default CHART; 85 | -------------------------------------------------------------------------------- /examples/StaticChart.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | This chart implements one of the simplest possible static charts. It is not 3 | interactive. 4 | */ 5 | 6 | import * as React from 'react'; 7 | 8 | // Import our static data. 9 | import { SIMPLE_LINE_DATA, SIMPLE_LINE_X_DOMAIN, SIMPLE_LINE_Y_DOMAIN } from './test-data'; 10 | import { 11 | Stack, 12 | LineLayer 13 | } from '../src'; 14 | 15 | const CHART = ( 16 | // Everything has to be in a Stack. 17 | 21 | {/* Render a static layer with our static data and view parameters. */} 22 | 27 | 28 | ); 29 | 30 | export default CHART; 31 | -------------------------------------------------------------------------------- /examples/example-styles.less: -------------------------------------------------------------------------------- 1 | .container { 2 | font-family: sans-serif; 3 | 4 | .example-chart { 5 | border: 1px solid #eee; 6 | height: 100px; 7 | width: auto; 8 | 9 | &.complicated { 10 | height: 140px; 11 | } 12 | } 13 | 14 | .explanation { 15 | font-family: sans-serif; 16 | padding: 8px; 17 | } 18 | 19 | .lc-stack.x-axis-stack { 20 | flex: initial; 21 | height: 40px; 22 | } 23 | 24 | .left-align-layer { 25 | display: flex; 26 | flex-wrap: nowrap; 27 | right: auto; 28 | } 29 | 30 | .vertical-split-layer { 31 | display: flex; 32 | flex-direction: column; 33 | 34 | > * { 35 | flex: 1; 36 | } 37 | } 38 | 39 | .lc-stack.snap-to-x-domain-button-stack { 40 | flex: initial; 41 | height: 20px; 42 | 43 | .snap-to-x-domain-button { 44 | display: flex; 45 | justify-content: center; 46 | align-items: center; 47 | 48 | &:hover { 49 | background-color: #eee; 50 | cursor: pointer; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/index-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-layered-chart 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import STATIC_CHART from './StaticChart'; 5 | import BASIC_INTERACTIVE_CHART from './BasicInteractiveChart'; 6 | import CONTROLLED_INTERACTIVE_CHART from './ControlledInteractiveChart'; 7 | import CONNECTED_INTERACTIVE_CHART from './ConnectedInteractiveChart'; 8 | import COMPLICATED_CHART from './ComplicatedChart'; 9 | 10 | import '../styles/index.less'; 11 | import './example-styles.less'; 12 | 13 | const APP_ELEMENT = document.getElementById('app'); 14 | 15 | const TEST_COMPONENT = ( 16 |
17 |
This is a basic, static chart. It is not interactive.
18 | {STATIC_CHART} 19 |
This is a basic interactive chart. Drag to pan and scroll to zoom.
20 | {BASIC_INTERACTIVE_CHART} 21 |
This chart is pannable, but with limits.
22 | {CONTROLLED_INTERACTIVE_CHART} 23 |
This chart is pannable and zoomable, and can be reset by clicking the button.
24 | {CONNECTED_INTERACTIVE_CHART} 25 |
This chart implements a bunch of features from around the library all in one.
26 |
Drag to pan, zoom to scroll and shift-drag to draw a selection.
27 | {COMPLICATED_CHART} 28 |
29 | ); 30 | 31 | // This setTimeout fixes a demo-only issue where Webpack's delivery of the CSS via JS injection comes too late and the 32 | // polling canvas layers render for a split second too small. It's not a library bug, just a side effect of the way that 33 | // Webpack's style-loader injects styles interacting poorly with react-layered-chart checking how large a is.` 34 | setTimeout(() => { 35 | ReactDOM.render(TEST_COMPONENT, APP_ELEMENT); 36 | }, 50); 37 | -------------------------------------------------------------------------------- /hooks/install.sh: -------------------------------------------------------------------------------- 1 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 2 | 3 | ln -s "$DIR/pre-commit" "$DIR/../.git/hooks" 4 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm test -- --reporter landing 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-layered-chart", 3 | "version": "2.0.0-rc.5", 4 | "description": "A high-performance canvas-based time series visualization in React.", 5 | "main": "lib/index.js", 6 | "typings": "lib/index.d.ts", 7 | "style": "react-layered-chart.css", 8 | "author": "Palantir Technologies", 9 | "license": "Apache-2.0", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/palantir/react-layered-chart.git" 13 | }, 14 | "scripts": { 15 | "start": "webpack-dev-server --config webpack.config.hot.js --inline --content-base examples/build/ --host 0.0.0.0 --port 8085 --hot true", 16 | "test": "mocha 'test/**/*.ts' && tslint --project tsconfig.json", 17 | "prepublish": "tsc --project tsconfig-build.json && lessc --source-map styles/index.less react-layered-chart.css" 18 | }, 19 | "devDependencies": { 20 | "@types/assertion-error": "^1.0.30", 21 | "@types/chai": "^3.4.34", 22 | "@types/classnames": "0.0.32", 23 | "@types/d3-scale": "^1.0.5", 24 | "@types/es6-promise": "0.0.32", 25 | "@types/immutability-helper": "^2.0.15", 26 | "@types/lodash": "^4.14.48", 27 | "@types/mocha": "^2.2.37", 28 | "@types/moment": "^2.13.0", 29 | "@types/react": "^15.0.16", 30 | "@types/react-dom": "^0.14.23", 31 | "@types/react-motion": "^0.0.21", 32 | "@types/react-redux": "^4.4.37", 33 | "@types/redux-debounced": "^0.2.16", 34 | "@types/redux-logger": "^2.6.32", 35 | "@types/reselect": "^2.0.27", 36 | "@types/sinon": "^1.16.34", 37 | "chai": "^3.5.0", 38 | "css-loader": "^0.23.1", 39 | "html-webpack-plugin": "^2.17.0", 40 | "less": "^2.7.1", 41 | "less-loader": "^2.2.3", 42 | "mocha": "^2.3.4", 43 | "mocha-multi": "^0.7.2", 44 | "mocha-osx-reporter": "^0.1.2", 45 | "moment": "^2.13.0", 46 | "react": "^15.4.2", 47 | "react-dom": "^15.4.2", 48 | "react-hot-loader": "^1.3.0", 49 | "redux-logger": "^2.6.1", 50 | "sinon": "^1.17.4", 51 | "source-map-loader": "^0.1.5", 52 | "style-loader": "^0.13.1", 53 | "ts-loader": "^1.3.3", 54 | "ts-node": "^2.0.0", 55 | "tslint": "^4.3.1", 56 | "typescript": "^2.1.4", 57 | "webpack": "^1.12.11", 58 | "webpack-dev-server": "^1.14.1", 59 | "webpack-notifier": "^1.3.0" 60 | }, 61 | "dependencies": { 62 | "classnames": "^2.2.1", 63 | "d3-scale": "^0.9.1", 64 | "immutability-helper": "^2.0.0", 65 | "lodash": "^4.0.0", 66 | "react-motion": "^0.4.3", 67 | "react-redux": "^4.4.5", 68 | "redux": "^3.5.2", 69 | "redux-debounced": "^0.2.0", 70 | "redux-thunk": "^2.1.1", 71 | "reselect": "^2.5.1" 72 | }, 73 | "peerDependencies": { 74 | "react": "^15.4.2", 75 | "react-dom": "^15.4.2" 76 | }, 77 | "keywords": [ 78 | "timeline", 79 | "time", 80 | "series", 81 | "chart", 82 | "canvas", 83 | "react", 84 | "component" 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /src/connected/ChartProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | import * as classNames from 'classnames'; 4 | import { Store } from 'redux'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import { Interval, Stack, propTypes, PixelRatioContextProvider } from '../core'; 8 | import storeFactory from './flux/storeFactory'; 9 | import { ChartId, SeriesId, TBySeriesId, DataLoader, DebugStoreHooks } from './interfaces'; 10 | import { DefaultChartState, ChartState } from './model/state'; 11 | import ConnectedResizeSentinelLayer from './layers/ConnectedResizeSentinelLayer'; 12 | import { 13 | setYDomains, 14 | setOverrideYDomains, 15 | setOverrideSelection, 16 | setOverrideHover, 17 | setDataLoaderDebounceTimeout 18 | } from './flux/atomicActions'; 19 | import { 20 | setXDomainAndLoad, 21 | setOverrideXDomainAndLoad, 22 | setSeriesIdsAndLoad, 23 | setDataLoaderAndLoad, 24 | setDataLoaderContextAndLoad 25 | } from './flux/compoundActions'; 26 | 27 | export interface Props { 28 | seriesIds: SeriesId[]; 29 | loadData: DataLoader; 30 | 31 | className?: string; 32 | pixelRatio?: number; 33 | chartId?: ChartId; 34 | defaultState?: DefaultChartState; 35 | onLoadStateChange?: (isLoading: TBySeriesId) => void; 36 | onError?: (errors: TBySeriesId) => void; 37 | includeResizeSentinel?: boolean; 38 | loadDataContext?: any; 39 | loadDataDebounceTimeout?: number; 40 | debugStoreHooks?: DebugStoreHooks; 41 | 42 | // Controlled props go here. 43 | xDomain?: Interval; 44 | onXDomainChange?: (xDomain: Interval) => void; 45 | yDomains?: TBySeriesId; 46 | onYDomainsChange?: (yDomains: TBySeriesId) => void; 47 | selection?: Interval | 'none'; 48 | onSelectionChange?: (selection: Interval) => void; 49 | hover?: number | 'none'; 50 | onHoverChange?: (hover: number) => void; 51 | } 52 | 53 | @PixelRatioContextProvider 54 | export default class ChartProvider extends React.PureComponent { 55 | static propTypes: React.ValidationMap = { 56 | seriesIds: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, 57 | loadData: React.PropTypes.func.isRequired, 58 | 59 | className: React.PropTypes.string, 60 | pixelRatio: React.PropTypes.number, 61 | chartId: React.PropTypes.string, 62 | defaultState: propTypes.defaultChartState, 63 | onLoadStateChange: React.PropTypes.func, 64 | onError: React.PropTypes.func, 65 | includeResizeSentinel: React.PropTypes.bool, 66 | // loadDataContext doesn't need a prop type, as it's `any?` and therefore we can make no assertions about it. 67 | loadDataDebounceTimeout: React.PropTypes.number, 68 | 69 | xDomain: propTypes.interval, 70 | onXDomainChange: React.PropTypes.func, 71 | yDomains: React.PropTypes.objectOf(propTypes.interval), 72 | onYDomainsChange: React.PropTypes.func, 73 | selection: propTypes.controlledInterval, 74 | onSelectionChange: React.PropTypes.func, 75 | hover: propTypes.controlledHover, 76 | onHoverChange: React.PropTypes.func, 77 | }; 78 | 79 | private _store: Store; 80 | private _lastState: ChartState; 81 | private _unsubscribeCallback: Function; 82 | 83 | static defaultProps: Partial = { 84 | includeResizeSentinel: true 85 | }; 86 | 87 | componentWillMount() { 88 | this._store = storeFactory(this.props.chartId, this.props.debugStoreHooks); 89 | this._onStoreChange(this.props); 90 | } 91 | 92 | componentWillReceiveProps(nextProps: Props) { 93 | if (nextProps.chartId !== this.props.chartId) { 94 | this._unsubscribeCallback(); 95 | this._store = storeFactory(this.props.chartId, this.props.debugStoreHooks); 96 | this._onStoreChange(nextProps); 97 | } else { 98 | this._onPropsChange(nextProps); 99 | } 100 | } 101 | 102 | componentWillUnmount() { 103 | this._unsubscribeCallback(); 104 | } 105 | 106 | private _onStoreChange(props: Props) { 107 | this._lastState = this._store.getState(); 108 | 109 | this._store.dispatch(setSeriesIdsAndLoad(props.seriesIds)); 110 | this._store.dispatch(setDataLoaderAndLoad(props.loadData)); 111 | this._store.dispatch(setDataLoaderContextAndLoad(props.loadDataContext)); 112 | if (_.isNumber(props.loadDataDebounceTimeout)) { 113 | this._store.dispatch(setDataLoaderDebounceTimeout(props.loadDataDebounceTimeout)); 114 | } 115 | // These should perhaps be set on the store as explicit "default" fields rather than auto-dispatched on load. 116 | if (props.xDomain) { 117 | this._store.dispatch(setOverrideXDomainAndLoad(props.xDomain)); 118 | } else if (props.defaultState && props.defaultState.xDomain) { 119 | this._store.dispatch(setXDomainAndLoad(props.defaultState.xDomain)); 120 | } 121 | if (props.yDomains) { 122 | this._store.dispatch(setOverrideYDomains(props.yDomains)); 123 | } else if (props.defaultState && props.defaultState.yDomains) { 124 | this._store.dispatch(setYDomains(props.defaultState.yDomains)); 125 | } 126 | if (props.hover != null) { 127 | this._store.dispatch(setOverrideHover(props.hover)); 128 | } 129 | if (props.selection) { 130 | this._store.dispatch(setOverrideSelection(props.selection)); 131 | } 132 | 133 | this._maybeFireAllCallbacks(); 134 | this._unsubscribeCallback = this._store.subscribe(this._maybeFireAllCallbacks.bind(this)); 135 | } 136 | 137 | private _maybeFireAllCallbacks() { 138 | const state: ChartState = this._store.getState(); 139 | this._maybeFireCallback(state.uiState.xDomain, this._lastState.uiState.xDomain, this.props.onXDomainChange); 140 | this._maybeFireCallback(state.uiState.selection, this._lastState.uiState.selection, this.props.onSelectionChange); 141 | this._maybeFireCallback(state.uiState.hover, this._lastState.uiState.hover, this.props.onHoverChange); 142 | this._maybeFireCallback(state.errorBySeriesId, this._lastState.errorBySeriesId, this.props.onError); 143 | 144 | if (this.props.onLoadStateChange) { 145 | if (state.loadVersionBySeriesId !== this._lastState.loadVersionBySeriesId) { 146 | this.props.onLoadStateChange(_.mapValues(state.loadVersionBySeriesId, v => !!v)); 147 | } 148 | } 149 | 150 | if (this.props.onYDomainsChange) { 151 | if (state.uiState.yDomainBySeriesId !== this._lastState.uiState.yDomainBySeriesId || 152 | state.loadedDataBySeriesId !== this._lastState.loadedDataBySeriesId) { 153 | const internalYDomains = _.assign( 154 | _.mapValues(state.loadedDataBySeriesId, loadedData => loadedData.yDomain), 155 | state.uiState.yDomainBySeriesId 156 | ); 157 | this.props.onYDomainsChange(internalYDomains); 158 | } 159 | } 160 | 161 | this._lastState = state; 162 | } 163 | 164 | private _maybeFireCallback(currentState: T, lastState: T, callback?: (value: T) => void) { 165 | if (callback && currentState !== lastState) { 166 | callback(currentState); 167 | } 168 | } 169 | 170 | private _onPropsChange(nextProps: Props) { 171 | this._maybeDispatchChangedProp(this.props.seriesIds, nextProps.seriesIds, setSeriesIdsAndLoad); 172 | this._maybeDispatchChangedProp(this.props.loadData, nextProps.loadData, setDataLoaderAndLoad); 173 | this._maybeDispatchChangedProp(this.props.loadDataContext, nextProps.loadDataContext, setDataLoaderContextAndLoad); 174 | this._maybeDispatchChangedProp(this.props.loadDataDebounceTimeout, nextProps.loadDataDebounceTimeout, setDataLoaderDebounceTimeout); 175 | this._maybeDispatchChangedProp(this.props.xDomain, nextProps.xDomain, setOverrideXDomainAndLoad); 176 | this._maybeDispatchChangedProp(this.props.yDomains, nextProps.yDomains, setOverrideYDomains); 177 | this._maybeDispatchChangedProp(this.props.hover, nextProps.hover, setOverrideHover); 178 | this._maybeDispatchChangedProp(this.props.selection, nextProps.selection, setOverrideSelection); 179 | } 180 | 181 | private _maybeDispatchChangedProp(prop: T, nextProp: T, actionCreator: (payload: T) => any) { 182 | if (prop !== nextProp) { 183 | this._store.dispatch(actionCreator(nextProp)); 184 | } 185 | } 186 | 187 | render() { 188 | return ( 189 | 190 |
191 | {this.props.includeResizeSentinel 192 | ? 193 | 194 | 195 | : null} 196 | {this.props.children} 197 |
198 |
199 | ); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/connected/axes/ConnectedXAxis.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, AxisSpec, XAxis } from '../../core'; 5 | import { ChartState } from '../model/state'; 6 | import { selectXDomain } from '../model/selectors'; 7 | 8 | export interface OwnProps extends AxisSpec {} 9 | 10 | export interface ConnectedProps { 11 | xDomain: Interval; 12 | } 13 | 14 | function mapStateToProps(state: ChartState): ConnectedProps { 15 | return { 16 | xDomain: selectXDomain(state) 17 | }; 18 | } 19 | 20 | export default connect(mapStateToProps)(XAxis) as React.ComponentClass; 21 | -------------------------------------------------------------------------------- /src/connected/axes/ConnectedYAxis.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, AxisSpec, YAxis } from '../../core'; 5 | import { SeriesId } from '../interfaces'; 6 | import { ChartState } from '../model/state'; 7 | import { selectYDomains } from '../model/selectors'; 8 | 9 | export interface OwnProps extends AxisSpec { 10 | seriesId: SeriesId; 11 | } 12 | 13 | export interface ConnectedProps { 14 | yDomain: Interval; 15 | } 16 | 17 | function mapStateToProps(state: ChartState, ownProps: OwnProps): ConnectedProps { 18 | return { 19 | yDomain: selectYDomains(state)[ownProps.seriesId] 20 | }; 21 | } 22 | 23 | export default connect(mapStateToProps)(YAxis) as React.ComponentClass; 24 | -------------------------------------------------------------------------------- /src/connected/axes/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as ConnectedYAxis, 3 | OwnProps as ConnectedYAxisProps 4 | } from './ConnectedYAxis'; 5 | 6 | export { 7 | default as ConnectedXAxis, 8 | OwnProps as ConnectedXAxisProps 9 | } from './ConnectedXAxis'; 10 | -------------------------------------------------------------------------------- /src/connected/export-only/exportableActions.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from '../../core'; 2 | import { ActionType } from '../flux/atomicActions'; 3 | 4 | import { 5 | setYDomains as internalSetYDomains, 6 | setHover as internalSetHover, 7 | setSelection as internalSetSelection 8 | } from '../flux/atomicActions'; 9 | 10 | import { 11 | setXDomainAndLoad as internalSetXDomain 12 | } from '../flux/compoundActions'; 13 | 14 | let _unusedToMakeTscQuiet: Interval = null as any; _unusedToMakeTscQuiet = _unusedToMakeTscQuiet; 15 | 16 | function _castToAnyOutput(actionCreator: (payload: T) => any): (payload: T) => any { 17 | return actionCreator; 18 | } 19 | 20 | export const setYDomains = _castToAnyOutput(internalSetYDomains); 21 | export const setHover = _castToAnyOutput(internalSetHover); 22 | export const setSelection = _castToAnyOutput(internalSetSelection); 23 | export const setXDomain = _castToAnyOutput(internalSetXDomain); 24 | 25 | export function getActionTypeName(actionValue: any): string | undefined { 26 | return ActionType[actionValue] || undefined; 27 | } 28 | -------------------------------------------------------------------------------- /src/connected/export-only/exportableSelectors.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { createSelector } from 'reselect'; 3 | import { Interval } from '../../core'; 4 | import { 5 | selectData as internalSelectData, 6 | selectHover as internalSelectHover, 7 | selectSelection as internalSelectSelection, 8 | selectXDomain as internalSelectXDomain, 9 | selectYDomains as internalSelectYDomains 10 | } from '../model/selectors'; 11 | import { ChartState } from '../model/state'; 12 | import { SeriesId, TBySeriesId } from '../interfaces'; 13 | import { ChartProviderState } from './exportableState'; 14 | 15 | let _unusedToMakeTscQuiet: Interval = null as any; _unusedToMakeTscQuiet = _unusedToMakeTscQuiet; 16 | 17 | function _castToOpaqueInput(selector: (state: ChartState) => T): (state: ChartProviderState) => T { 18 | return selector as any; 19 | } 20 | 21 | export const selectXDomain = _castToOpaqueInput(internalSelectXDomain); 22 | export const selectYDomains = _castToOpaqueInput(internalSelectYDomains); 23 | export const selectHover = _castToOpaqueInput(internalSelectHover); 24 | export const selectSelection = _castToOpaqueInput(internalSelectSelection); 25 | export const selectData = _castToOpaqueInput(internalSelectData); 26 | 27 | export const selectIsLoading = _castToOpaqueInput((state: ChartState) => _.mapValues(state.loadVersionBySeriesId, v => !!v) as TBySeriesId); 28 | export const selectErrors = _castToOpaqueInput((state: ChartState) => state.errorBySeriesId); 29 | export const selectChartPixelWidth = _castToOpaqueInput((state: ChartState) => state.physicalChartWidth); 30 | 31 | // We inherit the name of "iterator" from Lodash. I would prefer this to be called a "selector", but obviously that 32 | // may be confusing in this context. 33 | export type NumericalValueIterator = (seriesId: SeriesId, datum: any) => number; 34 | 35 | // This is cause sortedIndexBy prefers to have the same shape for the array items and the searched thing. We don't 36 | // know what that shape is, so we have a sentinel + accompanying function to figure out when it's asking for the hover value. 37 | function HOVER_VALUE_SENTINEL() {} 38 | 39 | export function createSelectDataForHover(xValueSelector: NumericalValueIterator) { 40 | return _castToOpaqueInput>(createSelector( 41 | internalSelectData, 42 | internalSelectHover, 43 | (dataBySeriesId: TBySeriesId, hover?: number) => { 44 | if (_.isNil(hover)) { 45 | return _.mapValues(dataBySeriesId, _.constant(undefined)); 46 | } else { 47 | const xIterator = (seriesId: SeriesId, datum: any) => { 48 | return datum === HOVER_VALUE_SENTINEL ? hover : xValueSelector(seriesId, datum); 49 | }; 50 | return _.mapValues(dataBySeriesId, (data: any[], seriesId: SeriesId) => { 51 | // -1 because sortedIndexBy returns the first index that would be /after/ the input value, but we're trying to 52 | // get whichever value comes before. Note that this may return undefined, but that's specifically allowed: 53 | // there may not be an appropriate hover value for this series. 54 | return data[ _.sortedIndexBy(data, HOVER_VALUE_SENTINEL, xIterator.bind(null, seriesId)) - 1 ]; 55 | }); 56 | } 57 | } 58 | )); 59 | } 60 | -------------------------------------------------------------------------------- /src/connected/export-only/exportableState.ts: -------------------------------------------------------------------------------- 1 | export interface ChartProviderState { 2 | __chartProviderState: boolean; 3 | } 4 | 5 | export { DefaultChartState } from '../model/state'; 6 | -------------------------------------------------------------------------------- /src/connected/flux/atomicActions.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from '../../core/interfaces'; 2 | import { SeriesId, TBySeriesId, DataLoader, LoadedSeriesData } from '../interfaces'; 3 | 4 | export enum ActionType { 5 | DATA_REQUESTED = 1, 6 | DATA_RETURNED, 7 | DATA_ERRORED, 8 | SET_SERIES_IDS, 9 | SET_DATA_LOADER, 10 | SET_DATA_LOADER_DEBOUNCE_TIMEOUT, 11 | SET_DATA_LOADER_CONTEXT, 12 | SET_X_DOMAIN, 13 | SET_OVERRIDE_X_DOMAIN, 14 | SET_Y_DOMAINS, 15 | SET_OVERRIDE_Y_DOMAINS, 16 | SET_HOVER, 17 | SET_OVERRIDE_HOVER, 18 | SET_SELECTION, 19 | SET_OVERRIDE_SELECTION, 20 | SET_CHART_PHYSICAL_WIDTH 21 | } 22 | 23 | export interface Action

{ 24 | type: ActionType; 25 | payload: P; 26 | } 27 | 28 | function createActionCreator

(type: ActionType) { 29 | return function(payload: P): Action

{ 30 | return { type, payload }; 31 | }; 32 | } 33 | 34 | export const setSeriesIds = createActionCreator(ActionType.SET_SERIES_IDS); 35 | export const setDataLoader = createActionCreator(ActionType.SET_DATA_LOADER); 36 | export const setDataLoaderDebounceTimeout = createActionCreator(ActionType.SET_DATA_LOADER_DEBOUNCE_TIMEOUT); 37 | export const setDataLoaderContext = createActionCreator(ActionType.SET_DATA_LOADER_CONTEXT); 38 | export const setChartPhysicalWidth = createActionCreator(ActionType.SET_CHART_PHYSICAL_WIDTH); 39 | 40 | export const setXDomain = createActionCreator(ActionType.SET_X_DOMAIN); 41 | export const setOverrideXDomain = createActionCreator(ActionType.SET_OVERRIDE_X_DOMAIN); 42 | export const setYDomains = createActionCreator>(ActionType.SET_Y_DOMAINS); 43 | export const setOverrideYDomains = createActionCreator | undefined>(ActionType.SET_OVERRIDE_Y_DOMAINS); 44 | export const setHover = createActionCreator(ActionType.SET_HOVER); 45 | export const setOverrideHover = createActionCreator(ActionType.SET_OVERRIDE_HOVER); 46 | export const setSelection = createActionCreator(ActionType.SET_SELECTION); 47 | export const setOverrideSelection = createActionCreator(ActionType.SET_OVERRIDE_SELECTION); 48 | 49 | export const dataRequested = createActionCreator(ActionType.DATA_REQUESTED); 50 | export const dataReturned = createActionCreator>(ActionType.DATA_RETURNED); 51 | export const dataErrored = createActionCreator>(ActionType.DATA_ERRORED); 52 | -------------------------------------------------------------------------------- /src/connected/flux/compoundActions.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { ThunkAction } from 'redux-thunk'; 3 | 4 | import { Interval } from '../../core'; 5 | import { ChartState } from '../model/state'; 6 | import { SeriesId, TBySeriesId, DataLoader, LoadedSeriesData } from '../interfaces'; 7 | import { selectXDomain } from '../model/selectors'; 8 | 9 | import { 10 | setXDomain, 11 | setOverrideXDomain, 12 | setChartPhysicalWidth, 13 | setSeriesIds, 14 | setDataLoader, 15 | setDataLoaderContext, 16 | dataRequested, 17 | dataReturned, 18 | dataErrored 19 | } from './atomicActions'; 20 | 21 | export function setXDomainAndLoad(payload: Interval): ThunkAction { 22 | return (dispatch, getState) => { 23 | const state = getState(); 24 | 25 | if (!_.isEqual(payload, state.uiState.xDomain)) { 26 | dispatch(setXDomain(payload)); 27 | if (!state.uiStateConsumerOverrides.xDomain) { 28 | dispatch(_requestDataLoad()); 29 | } 30 | } 31 | }; 32 | } 33 | 34 | export function setOverrideXDomainAndLoad(payload?: Interval): ThunkAction { 35 | return (dispatch, getState) => { 36 | const state = getState(); 37 | 38 | if (!_.isEqual(payload, state.uiStateConsumerOverrides.xDomain)) { 39 | dispatch(setOverrideXDomain(payload)); 40 | dispatch(_requestDataLoad()); 41 | } 42 | }; 43 | } 44 | 45 | export function setChartPhysicalWidthAndLoad(payload: number): ThunkAction { 46 | return (dispatch, getState) => { 47 | const state = getState(); 48 | 49 | if (payload !== state.physicalChartWidth) { 50 | dispatch(setChartPhysicalWidth(payload)); 51 | dispatch(_requestDataLoad()); 52 | } 53 | }; 54 | } 55 | 56 | export function setSeriesIdsAndLoad(payload: SeriesId[]): ThunkAction { 57 | return (dispatch, getState) => { 58 | const state = getState(); 59 | const orderedSeriesIds = _.sortBy(payload); 60 | 61 | if (!_.isEqual(orderedSeriesIds, state.seriesIds)) { 62 | const newSeriesIds: SeriesId[] = _.difference(orderedSeriesIds, state.seriesIds); 63 | dispatch(setSeriesIds(orderedSeriesIds)); 64 | dispatch(_requestDataLoad(newSeriesIds)); 65 | } 66 | }; 67 | } 68 | 69 | export function setDataLoaderAndLoad(payload: DataLoader): ThunkAction { 70 | return (dispatch, getState) => { 71 | const state = getState(); 72 | 73 | if (state.dataLoader !== payload) { 74 | dispatch(setDataLoader(payload)); 75 | dispatch(_requestDataLoad()); 76 | } 77 | }; 78 | } 79 | 80 | export function setDataLoaderContextAndLoad(payload?: any): ThunkAction { 81 | return (dispatch, getState) => { 82 | const state = getState(); 83 | 84 | if (payload !== state.loaderContext) { 85 | dispatch(setDataLoaderContext(payload)); 86 | dispatch(_requestDataLoad()); 87 | } 88 | }; 89 | } 90 | 91 | // Exported for testing. 92 | export function _requestDataLoad(seriesIds?: SeriesId[]): ThunkAction { 93 | return (dispatch, getState) => { 94 | const existingSeriesIds: SeriesId[] = getState().seriesIds; 95 | const seriesIdsToLoad = seriesIds 96 | ? _.intersection(seriesIds, existingSeriesIds) 97 | : existingSeriesIds; 98 | 99 | dispatch(dataRequested(seriesIdsToLoad)); 100 | dispatch(_performDataLoad()); 101 | }; 102 | } 103 | 104 | // Exported for testing. 105 | export function _makeKeyedDataBatcher(onBatch: (batchData: TBySeriesId) => void, timeout: number): (partialData: TBySeriesId) => void { 106 | let keyedBatchAccumulator: TBySeriesId = {}; 107 | 108 | const throttledBatchCallback = _.throttle(() => { 109 | // Save it off first in case the batch triggers any more additions. 110 | const batchData = keyedBatchAccumulator; 111 | keyedBatchAccumulator = {}; 112 | onBatch(batchData); 113 | }, timeout, { leading: false, trailing: true }); 114 | 115 | return function(keyedData: TBySeriesId) { 116 | _.assign(keyedBatchAccumulator, keyedData); 117 | throttledBatchCallback(); 118 | }; 119 | } 120 | 121 | // Exported for testing. 122 | export function _performDataLoad(batchingTimeout: number = 200): ThunkAction, ChartState, void> & { meta?: any } { 123 | return (dispatch, getState) => { 124 | let { debounceTimeout } = getState(); 125 | 126 | // redux-debounced checks falsy-ness, so 0 will behave as if there is no debouncing! 127 | debounceTimeout = debounceTimeout === 0 ? 1 : debounceTimeout; 128 | 129 | const adjustedBatchingTimeout = Math.min(batchingTimeout, debounceTimeout); 130 | 131 | const thunk: ThunkAction, ChartState, void> & { meta?: any } = (dispatch, getState) => { 132 | const preLoadChartState = getState(); 133 | const dataLoader = preLoadChartState.dataLoader; 134 | const loaderContext = preLoadChartState.loaderContext; 135 | 136 | const seriesIdsToLoad = _.keys(_.pickBy(preLoadChartState.loadVersionBySeriesId)); 137 | 138 | const loadPromiseBySeriesId = dataLoader( 139 | seriesIdsToLoad, 140 | selectXDomain(preLoadChartState), 141 | preLoadChartState.physicalChartWidth, 142 | preLoadChartState.loadedDataBySeriesId, 143 | loaderContext 144 | ); 145 | 146 | const batchedDataReturned = _makeKeyedDataBatcher((payload: TBySeriesId) => { 147 | dispatch(dataReturned(payload)); 148 | }, adjustedBatchingTimeout); 149 | 150 | const batchedDataErrored = _makeKeyedDataBatcher((payload: TBySeriesId) => { 151 | dispatch(dataErrored(payload)); 152 | }, adjustedBatchingTimeout); 153 | 154 | function isResultStillRelevant(postLoadChartState: ChartState, seriesId: SeriesId) { 155 | return preLoadChartState.loadVersionBySeriesId[ seriesId ] === postLoadChartState.loadVersionBySeriesId[ seriesId ]; 156 | } 157 | 158 | const dataPromises = _.map(loadPromiseBySeriesId, (dataPromise: Promise, seriesId: SeriesId) => 159 | dataPromise 160 | .then(loadedData => { 161 | if (isResultStillRelevant(getState(), seriesId)) { 162 | batchedDataReturned({ 163 | [seriesId]: loadedData 164 | }); 165 | } 166 | }) 167 | .catch(error => { 168 | if (isResultStillRelevant(getState(), seriesId)) { 169 | batchedDataErrored({ 170 | [seriesId]: error 171 | }); 172 | } 173 | }) 174 | ); 175 | 176 | return Promise.all(dataPromises); 177 | }; 178 | 179 | thunk.meta = { 180 | debounce: { 181 | time: debounceTimeout, 182 | key: 'data-load' 183 | } 184 | }; 185 | 186 | return dispatch(thunk); 187 | }; 188 | } 189 | -------------------------------------------------------------------------------- /src/connected/flux/reducer.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { newContext } from 'immutability-helper'; 3 | 4 | const update = newContext(); 5 | 6 | update.extend('$assign', (spec, object) => _.assign({}, object, spec)); 7 | 8 | import { ActionType, Action } from './atomicActions'; 9 | import { ChartState, DEFAULT_CHART_STATE, invalidLoader } from '../model/state'; 10 | import { DEFAULT_Y_DOMAIN } from '../model/constants'; 11 | import { objectWithKeys, replaceValuesWithConstant, objectWithKeysFromObject } from './reducerUtils'; 12 | 13 | export default function(state: ChartState, action: Action): ChartState { 14 | if (state === undefined) { 15 | return DEFAULT_CHART_STATE; 16 | } 17 | 18 | switch (action.type) { 19 | case ActionType.SET_SERIES_IDS: { 20 | const seriesIds = action.payload; 21 | return update(state, { 22 | seriesIds: { $set: seriesIds }, 23 | loadedDataBySeriesId: { $set: objectWithKeysFromObject(state.loadedDataBySeriesId, seriesIds, { data: [], yDomain: DEFAULT_Y_DOMAIN }) }, 24 | loadVersionBySeriesId: { $set: objectWithKeysFromObject(state.loadVersionBySeriesId, seriesIds, null) }, 25 | errorBySeriesId: { $set: objectWithKeysFromObject(state.errorBySeriesId, seriesIds, null) } 26 | }); 27 | } 28 | 29 | case ActionType.DATA_REQUESTED: 30 | return update(state, { 31 | loadVersionBySeriesId: { $assign: objectWithKeys(action.payload, _.uniqueId('load-version-')) } 32 | }); 33 | 34 | case ActionType.DATA_RETURNED: 35 | return update(state, { 36 | loadedDataBySeriesId: { $assign: action.payload }, 37 | loadVersionBySeriesId: { $assign: replaceValuesWithConstant(action.payload, null) }, 38 | errorBySeriesId: { $assign: replaceValuesWithConstant(action.payload, null) } 39 | }); 40 | 41 | case ActionType.DATA_ERRORED: 42 | // TODO: Should we clear the current data too? 43 | return update(state, { 44 | loadVersionBySeriesId: { $assign: replaceValuesWithConstant(action.payload, null) }, 45 | errorBySeriesId: { $assign: action.payload } 46 | }); 47 | 48 | case ActionType.SET_DATA_LOADER: 49 | return update(state, { 50 | dataLoader: { $set: action.payload || invalidLoader } 51 | }); 52 | 53 | case ActionType.SET_DATA_LOADER_CONTEXT: 54 | return update(state, { 55 | loaderContext: { $set: action.payload } 56 | }); 57 | 58 | case ActionType.SET_DATA_LOADER_DEBOUNCE_TIMEOUT: 59 | return update(state, { 60 | debounceTimeout: { $set: _.isNumber(action.payload) ? action.payload : state.debounceTimeout } 61 | }); 62 | 63 | case ActionType.SET_CHART_PHYSICAL_WIDTH: 64 | return update(state, { 65 | physicalChartWidth: { $set: action.payload } 66 | }); 67 | 68 | case ActionType.SET_X_DOMAIN: 69 | return update(state, { 70 | uiState: { 71 | xDomain: { $set: action.payload } 72 | } 73 | }); 74 | 75 | case ActionType.SET_OVERRIDE_X_DOMAIN: 76 | if (action.payload) { 77 | return update(state, { 78 | uiStateConsumerOverrides: { 79 | xDomain: { $set: action.payload } 80 | } 81 | }); 82 | } else { 83 | return update(state, { 84 | uiStateConsumerOverrides: { 85 | xDomain: { $set: null } 86 | } 87 | }); 88 | } 89 | 90 | case ActionType.SET_Y_DOMAINS: 91 | return update(state, { 92 | uiState: { 93 | yDomainBySeriesId: { $set: action.payload } 94 | } 95 | }); 96 | 97 | case ActionType.SET_OVERRIDE_Y_DOMAINS: 98 | if (action.payload) { 99 | return update(state, { 100 | uiStateConsumerOverrides: { 101 | yDomainBySeriesId: { $set: action.payload } 102 | } 103 | }); 104 | } else { 105 | return update(state, { 106 | uiStateConsumerOverrides: { 107 | yDomainBySeriesId: { $set: null } 108 | } 109 | }); 110 | } 111 | 112 | case ActionType.SET_HOVER: 113 | return update(state, { 114 | uiState: { 115 | hover: { $set: action.payload } 116 | } 117 | }); 118 | 119 | case ActionType.SET_OVERRIDE_HOVER: 120 | if (action.payload != null) { 121 | return update(state, { 122 | uiStateConsumerOverrides: { 123 | hover: { $set: action.payload } 124 | } 125 | }); 126 | } else { 127 | return update(state, { 128 | uiStateConsumerOverrides: { 129 | hover: { $set: null } 130 | } 131 | }); 132 | } 133 | 134 | case ActionType.SET_SELECTION: 135 | return update(state, { 136 | uiState: { 137 | selection: { $set: action.payload } 138 | } 139 | }); 140 | 141 | case ActionType.SET_OVERRIDE_SELECTION: 142 | if (action.payload) { 143 | return update(state, { 144 | uiStateConsumerOverrides: { 145 | selection: { $set: action.payload } 146 | } 147 | }); 148 | } else { 149 | return update(state, { 150 | uiStateConsumerOverrides: { 151 | selection: { $set: null } 152 | } 153 | }); 154 | } 155 | 156 | default: 157 | return state; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/connected/flux/reducerUtils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import { TBySeriesId } from '../interfaces'; 4 | 5 | export function objectWithKeys(keys: string[], value: T): { [key: string]: T } { 6 | const object: { [key: string]: T } = {}; 7 | keys.forEach(k => { object[k] = value; }); 8 | return object; 9 | } 10 | 11 | export function replaceValuesWithConstant(anyBySeriesId: TBySeriesId, value: T): TBySeriesId { 12 | return _.mapValues(anyBySeriesId, _.constant(value)); 13 | } 14 | 15 | export function objectWithKeysFromObject(anyBySeriesId: TBySeriesId, keys: string[], defaultValue: T): TBySeriesId { 16 | const object: { [key: string]: T } = {}; 17 | keys.forEach(k => { object[k] = anyBySeriesId[k] !== undefined ? anyBySeriesId[k] : defaultValue; }); 18 | return object; 19 | } 20 | -------------------------------------------------------------------------------- /src/connected/flux/storeFactory.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { applyMiddleware, createStore, Store, compose, Middleware } from 'redux'; 3 | import ThunkMiddleware from 'redux-thunk'; 4 | import createDebounced from 'redux-debounced'; 5 | 6 | import reducer from './reducer'; 7 | import { ChartId, DebugStoreHooks } from '../interfaces'; 8 | import { ChartState } from '../model/state'; 9 | 10 | // chartId is only used for memoization. 11 | function _createStore(_chartId?: ChartId, debugHooks?: DebugStoreHooks): Store { 12 | let middlewares: Middleware[] = [ 13 | createDebounced(), 14 | ThunkMiddleware 15 | ]; 16 | if (debugHooks && debugHooks.middlewares) { 17 | middlewares = middlewares.concat(debugHooks.middlewares as Middleware[]); 18 | } 19 | 20 | let enhancers = [ 21 | applyMiddleware(...middlewares) 22 | ]; 23 | if (debugHooks && debugHooks.enhancers) { 24 | enhancers = enhancers.concat(debugHooks.enhancers); 25 | } 26 | 27 | // hacking typings for `compose` because the redux typings have typings for when 28 | // the `compose` method has 1, 2, 3, and 3 + more arguments but no typings for 29 | // when the first argument is a spread 30 | return createStore(reducer, (compose as (...funcs: Function[]) => any)(...enhancers)); 31 | } 32 | 33 | const memoizedCreateStore = _.memoize(_createStore); 34 | 35 | export default function(chartId?: ChartId, debugHooks?: DebugStoreHooks) { 36 | if (chartId) { 37 | return memoizedCreateStore(chartId, debugHooks); 38 | } else { 39 | return _createStore(chartId, debugHooks); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/connected/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | export * from './layers'; 3 | export * from './axes'; 4 | export * from './export-only/exportableActions'; 5 | export * from './export-only/exportableSelectors'; 6 | export * from './export-only/exportableState'; 7 | export * from './loaderUtils'; 8 | export * from './model/constants'; 9 | export { default as ChartProvider, Props as ChartProviderProps } from './ChartProvider'; 10 | -------------------------------------------------------------------------------- /src/connected/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { GenericStoreEnhancer, Middleware } from 'redux'; 2 | import { Interval, SeriesData } from '../core'; 3 | 4 | export type SeriesId = string; 5 | 6 | export type ChartId = string; 7 | 8 | export type TBySeriesId = { [seriesId: string]: T }; 9 | 10 | export interface LoadedSeriesData { 11 | data: SeriesData; 12 | yDomain: Interval; 13 | } 14 | 15 | export type DataLoader = (seriesIds: SeriesId[], 16 | xDomain: Interval, 17 | chartPixelWidth: number, 18 | currentLoadedData: TBySeriesId, 19 | context?: any) => TBySeriesId>; 20 | 21 | export interface DebugStoreHooks { 22 | middlewares?: Middleware[]; 23 | enhancers?: GenericStoreEnhancer[]; 24 | }; 25 | -------------------------------------------------------------------------------- /src/connected/layers/ConnectedHoverLineLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, VerticalLineLayer } from '../../core'; 5 | import { ChartState } from '../model/state'; 6 | import { selectHover, selectXDomain } from '../model/selectors'; 7 | 8 | export interface OwnProps { 9 | color?: string; 10 | } 11 | 12 | export interface ConnectedProps { 13 | xValue?: number; 14 | xDomain: Interval; 15 | } 16 | 17 | function mapStateToProps(state: ChartState): ConnectedProps { 18 | return { 19 | xValue: selectHover(state), 20 | xDomain: selectXDomain(state) 21 | }; 22 | } 23 | 24 | export default connect(mapStateToProps)(VerticalLineLayer) as React.ComponentClass; 25 | -------------------------------------------------------------------------------- /src/connected/layers/ConnectedInteractionCaptureLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Dispatch, bindActionCreators } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { 6 | Interval, 7 | BooleanMouseEventHandler, 8 | panInterval, 9 | zoomInterval, 10 | InteractionCaptureLayer 11 | } from '../../core'; 12 | import { setSelection, setHover } from '../flux/atomicActions'; 13 | import { setXDomainAndLoad } from '../flux/compoundActions'; 14 | import { ChartState } from '../model/state'; 15 | import { selectXDomain } from '../model/selectors'; 16 | 17 | export interface OwnProps { 18 | shouldZoom?: BooleanMouseEventHandler; 19 | shouldPan?: BooleanMouseEventHandler; 20 | shouldHover?: BooleanMouseEventHandler; 21 | shouldBrush?: BooleanMouseEventHandler; 22 | zoomSpeed?: number; 23 | } 24 | 25 | export interface ConnectedProps { 26 | xDomain: Interval; 27 | } 28 | 29 | export interface DispatchProps { 30 | setXDomainAndLoad: typeof setXDomainAndLoad; 31 | setSelection: typeof setSelection; 32 | setHover: typeof setHover; 33 | } 34 | 35 | export class ConnectedInteractionCaptureLayer extends React.PureComponent { 36 | render() { 37 | return ( 38 | 50 | ); 51 | } 52 | 53 | private _zoom = (factor: number, anchorBias: number) => { 54 | this.props.setXDomainAndLoad(zoomInterval(this.props.xDomain, factor, anchorBias)); 55 | }; 56 | 57 | private _pan = (logicalUnits: number) => { 58 | this.props.setXDomainAndLoad(panInterval(this.props.xDomain, logicalUnits)); 59 | }; 60 | 61 | private _brush = (logicalUnitInterval?: Interval) => { 62 | this.props.setSelection(logicalUnitInterval); 63 | }; 64 | 65 | private _hover = (logicalPosition?: number) => { 66 | this.props.setHover(logicalPosition); 67 | }; 68 | } 69 | 70 | function mapStateToProps(state: ChartState): ConnectedProps { 71 | return { 72 | xDomain: selectXDomain(state) 73 | }; 74 | } 75 | 76 | function mapDispatchToProps(dispatch: Dispatch): DispatchProps { 77 | return bindActionCreators({ 78 | setXDomainAndLoad, 79 | setSelection, 80 | setHover 81 | }, dispatch); 82 | } 83 | 84 | export default connect(mapStateToProps, mapDispatchToProps)(ConnectedInteractionCaptureLayer) as React.ComponentClass; 85 | -------------------------------------------------------------------------------- /src/connected/layers/ConnectedResizeSentinelLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | import { Dispatch, bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | 6 | import { setChartPhysicalWidthAndLoad } from '../flux/compoundActions'; 7 | import { ChartState } from '../model/state'; 8 | 9 | interface DispatchProps { 10 | setChartPhysicalWidthAndLoad: typeof setChartPhysicalWidthAndLoad; 11 | } 12 | 13 | class ConnectedResizeSentinelLayer extends React.PureComponent { 14 | private __setSizeInterval: number; 15 | private __lastWidth: number; 16 | 17 | render() { 18 | return ( 19 |

20 | ); 21 | } 22 | 23 | componentDidMount() { 24 | this._maybeCallOnSizeChange(); 25 | this.__setSizeInterval = setInterval(this._maybeCallOnSizeChange, 1000); 26 | } 27 | 28 | componentWillUnmount() { 29 | clearInterval(this.__setSizeInterval); 30 | } 31 | 32 | componentWillReceiveProps(nextProps: DispatchProps) { 33 | if (this.props.setChartPhysicalWidthAndLoad !== nextProps.setChartPhysicalWidthAndLoad && _.isNumber(this.__lastWidth)) { 34 | this.props.setChartPhysicalWidthAndLoad(this.__lastWidth); 35 | } 36 | } 37 | 38 | private _maybeCallOnSizeChange = () => { 39 | const newWidth = (this.refs['element'] as HTMLElement).offsetWidth; 40 | if (this.__lastWidth !== newWidth) { 41 | this.__lastWidth = newWidth; 42 | this.props.setChartPhysicalWidthAndLoad(this.__lastWidth); 43 | } 44 | }; 45 | } 46 | 47 | function mapDispatchToProps(dispatch: Dispatch): DispatchProps { 48 | return bindActionCreators({ setChartPhysicalWidthAndLoad }, dispatch); 49 | } 50 | 51 | export default connect(undefined, mapDispatchToProps)(ConnectedResizeSentinelLayer) as React.ComponentClass; 52 | -------------------------------------------------------------------------------- /src/connected/layers/ConnectedSelectionBrushLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, Color, SpanLayer, SpanDatum } from '../../core'; 5 | import { ChartState } from '../model/state'; 6 | import { selectSelection, selectXDomain } from '../model/selectors'; 7 | 8 | export interface OwnProps { 9 | fillColor?: Color; 10 | borderColor?: Color; 11 | } 12 | 13 | export interface ConnectedProps { 14 | data: SpanDatum[]; 15 | xDomain: Interval; 16 | } 17 | 18 | function mapStateToProps(state: ChartState): ConnectedProps { 19 | const selection = selectSelection(state); 20 | return { 21 | data: selection ? [{ minXValue: selection.min, maxXValue: selection.max }] : [], 22 | xDomain: selectXDomain(state) 23 | }; 24 | } 25 | 26 | export default connect(mapStateToProps)(SpanLayer) as React.ComponentClass; 27 | -------------------------------------------------------------------------------- /src/connected/layers/ConnectedSpanLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, Color, SpanLayer, SpanDatum } from '../../core'; 5 | import { ChartState } from '../model/state'; 6 | import { selectData, selectXDomain } from '../model/selectors'; 7 | import { SeriesId } from '../interfaces'; 8 | 9 | export interface OwnProps { 10 | seriesId: SeriesId; 11 | fillColor?: Color; 12 | borderColor?: Color; 13 | } 14 | 15 | export interface ConnectedProps { 16 | data: SpanDatum[]; 17 | xDomain: Interval; 18 | } 19 | 20 | function mapStateToProps(state: ChartState, ownProps: OwnProps): ConnectedProps { 21 | if (state.seriesIds.indexOf(ownProps.seriesId) === -1) { 22 | throw new Error(`Cannot render data for missing series ID ${ownProps.seriesId}`); 23 | } 24 | 25 | return { 26 | data: selectData(state)[ownProps.seriesId], 27 | xDomain: selectXDomain(state) 28 | }; 29 | } 30 | 31 | export default connect(mapStateToProps)(SpanLayer) as React.ComponentClass; 32 | -------------------------------------------------------------------------------- /src/connected/layers/connectedDataLayers.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | ScaleFunction, 4 | BarLayer, 5 | BarLayerProps, 6 | BucketedLineLayer, 7 | BucketedLineLayerProps, 8 | PointLayer, 9 | PointLayerProps, 10 | LineLayer, 11 | LineLayerProps, 12 | JoinType 13 | } from '../../core'; 14 | 15 | import { wrapDataLayerWithConnect, SeriesIdProp } from './wrapDataLayerWithConnect'; 16 | 17 | 18 | // tslint:disable-next-line:class-name 19 | export interface _CommonConnectedBarLayerProps { 20 | color?: Color; 21 | } 22 | export type ConnectedBarLayerProps = _CommonConnectedBarLayerProps & SeriesIdProp; 23 | export const ConnectedBarLayer = wrapDataLayerWithConnect<_CommonConnectedBarLayerProps, BarLayerProps>(BarLayer); 24 | 25 | 26 | // tslint:disable-next-line:class-name 27 | export interface _CommonConnectedBucketedLineLayerProps { 28 | yScale?: ScaleFunction; 29 | color?: Color; 30 | joinType?: JoinType; 31 | } 32 | export type ConnectedBucketedLineLayerProps = _CommonConnectedBucketedLineLayerProps & SeriesIdProp; 33 | export const ConnectedBucketedLineLayer = wrapDataLayerWithConnect<_CommonConnectedBucketedLineLayerProps, BucketedLineLayerProps>(BucketedLineLayer); 34 | 35 | 36 | // tslint:disable-next-line:class-name 37 | export interface _CommonConnectedPointLayerProps { 38 | yScale?: ScaleFunction; 39 | color?: Color; 40 | radius?: number; 41 | innerRadius?: number; 42 | } 43 | export type ConnectedPointLayerProps = _CommonConnectedPointLayerProps & SeriesIdProp; 44 | export const ConnectedPointLayer = wrapDataLayerWithConnect<_CommonConnectedPointLayerProps, PointLayerProps>(PointLayer); 45 | 46 | 47 | // tslint:disable-next-line:class-name 48 | export interface _CommonConnectedLineLayerProps { 49 | yScale?: ScaleFunction; 50 | color?: Color; 51 | joinType?: JoinType; 52 | } 53 | export type ConnectedLineLayerProps = _CommonConnectedLineLayerProps & SeriesIdProp; 54 | export const ConnectedLineLayer = wrapDataLayerWithConnect<_CommonConnectedLineLayerProps, LineLayerProps>(LineLayer); 55 | -------------------------------------------------------------------------------- /src/connected/layers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connectedDataLayers'; 2 | 3 | export { 4 | default as ConnectedHoverLineLayer, 5 | OwnProps as ConnectedHoverLineLayerProps 6 | } from './ConnectedHoverLineLayer'; 7 | 8 | export { 9 | default as ConnectedInteractionCaptureLayer, 10 | OwnProps as ConnectedInteractionCaptureLayerProps 11 | } from './ConnectedInteractionCaptureLayer'; 12 | 13 | export { 14 | default as ConnectedResizeSentinelLayer 15 | } from './ConnectedResizeSentinelLayer'; 16 | 17 | export { 18 | default as ConnectedSelectionBrushLayer, 19 | OwnProps as ConnectedSelectionBrushLayerProps 20 | } from './ConnectedSelectionBrushLayer'; 21 | 22 | export { 23 | default as ConnectedSpanLayer, 24 | OwnProps as ConnectedSpanLayerProps 25 | } from './ConnectedSpanLayer'; 26 | -------------------------------------------------------------------------------- /src/connected/layers/wrapDataLayerWithConnect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Interval, SeriesData} from '../../core'; 5 | import { SeriesId } from '../interfaces'; 6 | import { ChartState } from '../model/state'; 7 | import { selectData, selectXDomain, selectYDomains } from '../model/selectors'; 8 | 9 | export interface SeriesIdProp { 10 | seriesId: SeriesId; 11 | } 12 | 13 | export interface WrappedDataLayerConnectedProps { 14 | data: SeriesData; 15 | xDomain: Interval; 16 | yDomain: Interval; 17 | } 18 | 19 | function mapStateToProps(state: ChartState, ownProps: SeriesIdProp): WrappedDataLayerConnectedProps { 20 | if (state.seriesIds.indexOf(ownProps.seriesId) === -1) { 21 | throw new Error(`Cannot render data for missing series ID ${ownProps.seriesId}`); 22 | } 23 | 24 | return { 25 | data: selectData(state)[ownProps.seriesId], 26 | xDomain: selectXDomain(state), 27 | yDomain: selectYDomains(state)[ownProps.seriesId] 28 | }; 29 | } 30 | 31 | export function wrapDataLayerWithConnect< 32 | OwnProps, 33 | OriginalProps extends OwnProps & WrappedDataLayerConnectedProps 34 | >(OriginalComponent: React.ComponentClass): React.ComponentClass { 35 | 36 | return connect(mapStateToProps)(OriginalComponent) as React.ComponentClass; 37 | } 38 | -------------------------------------------------------------------------------- /src/connected/loaderUtils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import { Interval } from '../core/interfaces'; 4 | import { SeriesId, DataLoader, TBySeriesId, LoadedSeriesData } from './interfaces'; 5 | 6 | export function chainLoaders(...loaders: DataLoader[]): DataLoader { 7 | 8 | const chainedLoader: DataLoader = (seriesIds: SeriesId[], 9 | xDomain: Interval, 10 | chartPixelWidth: number, 11 | currentLoadedData: TBySeriesId, 12 | context?: any): TBySeriesId> => { 13 | 14 | let accumulator: TBySeriesId> = {}; 15 | let seriesIdsToLoad: SeriesId[] = seriesIds; 16 | 17 | loaders.forEach(loader => { 18 | const loadedSeries: TBySeriesId> = loader( 19 | seriesIdsToLoad, 20 | xDomain, 21 | chartPixelWidth, 22 | currentLoadedData, 23 | context 24 | ); 25 | 26 | seriesIdsToLoad = seriesIdsToLoad.filter(id => !loadedSeries[ id ]); 27 | _.assign(accumulator, loadedSeries); 28 | }); 29 | 30 | const rejectedIds: TBySeriesId> = _.fromPairs>(seriesIdsToLoad.map(seriesId => [ 31 | seriesId, 32 | Promise.reject(new Error(`No loader specified that can handle series ID '${seriesId}'`)) 33 | ] as [ string, Promise ])); 34 | 35 | return _.assign({}, accumulator, rejectedIds); 36 | }; 37 | 38 | return chainedLoader; 39 | } 40 | -------------------------------------------------------------------------------- /src/connected/model/constants.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from '../../core'; 2 | 3 | export const DEFAULT_X_DOMAIN: Interval = { 4 | min: 0, 5 | max: 100 6 | }; 7 | 8 | export const DEFAULT_Y_DOMAIN: Interval = { 9 | min: 0, 10 | max: 100 11 | }; 12 | -------------------------------------------------------------------------------- /src/connected/model/selectors.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { createSelector } from 'reselect'; 3 | 4 | import { Interval, SeriesData } from '../../core'; 5 | import { TBySeriesId } from '../interfaces'; 6 | import { ChartState } from './state'; 7 | 8 | function createSubSelector(selectParentState: (state: ChartState) => S, fieldName: F): (state: ChartState) => S[F] { 9 | return createSelector( 10 | selectParentState, 11 | state => state[fieldName] 12 | ); 13 | } 14 | 15 | const selectLoadedSeriesData = (state: ChartState) => state.loadedDataBySeriesId; 16 | const selectUiStateInternal = (state: ChartState) => state.uiState; 17 | const selectUiStateOverride = (state: ChartState) => state.uiStateConsumerOverrides; 18 | 19 | export const selectLoadedYDomains = createSelector( 20 | selectLoadedSeriesData, 21 | (loadedSeriesData) => _.mapValues(loadedSeriesData, loadedSeriesData => loadedSeriesData.yDomain) as TBySeriesId 22 | ); 23 | 24 | export const selectData = createSelector( 25 | selectLoadedSeriesData, 26 | (loadedSeriesData) => _.mapValues(loadedSeriesData, loadedSeriesData => loadedSeriesData.data) as TBySeriesId 27 | ); 28 | 29 | export const selectXDomain = createSelector( 30 | createSubSelector(selectUiStateInternal, 'xDomain'), 31 | createSubSelector(selectUiStateOverride, 'xDomain'), 32 | (internal, override) => override || internal 33 | ); 34 | 35 | export const selectYDomains = createSelector( 36 | selectLoadedYDomains, 37 | createSubSelector(selectUiStateInternal, 'yDomainBySeriesId'), 38 | createSubSelector(selectUiStateOverride, 'yDomainBySeriesId'), 39 | (loaded, internal, override) => _.assign({}, loaded, internal, override) as TBySeriesId 40 | ); 41 | 42 | export const selectHover = createSelector( 43 | createSubSelector(selectUiStateInternal, 'hover'), 44 | createSubSelector(selectUiStateOverride, 'hover'), 45 | (internal, override) => { 46 | if (override != null) { 47 | return override === 'none' ? undefined : override; 48 | } else { 49 | return internal; 50 | } 51 | } 52 | ); 53 | 54 | export const selectSelection = createSelector( 55 | createSubSelector(selectUiStateInternal, 'selection'), 56 | createSubSelector(selectUiStateOverride, 'selection'), 57 | (internal, override) => { 58 | if (override != null) { 59 | return override === 'none' ? undefined : override; 60 | } else { 61 | return internal; 62 | } 63 | } 64 | ); 65 | -------------------------------------------------------------------------------- /src/connected/model/state.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from '../../core'; 2 | 3 | import { DEFAULT_X_DOMAIN } from './constants'; 4 | import { SeriesId, TBySeriesId, DataLoader, LoadedSeriesData } from '../interfaces'; 5 | 6 | export interface DefaultChartState { 7 | xDomain?: Interval; 8 | yDomains?: TBySeriesId; 9 | } 10 | 11 | export interface UiState { 12 | xDomain: Interval; 13 | yDomainBySeriesId: TBySeriesId; 14 | hover?: number; 15 | selection?: Interval; 16 | } 17 | 18 | export interface OverriddenUiState { 19 | xDomain?: Interval; 20 | yDomainBySeriesId?: TBySeriesId; 21 | hover?: number | 'none'; 22 | selection?: Interval | 'none'; 23 | } 24 | 25 | export interface ChartState { 26 | debounceTimeout: number; 27 | loaderContext?: any; 28 | physicalChartWidth: number; 29 | seriesIds: SeriesId[]; 30 | loadedDataBySeriesId: TBySeriesId; 31 | loadVersionBySeriesId: TBySeriesId; 32 | errorBySeriesId: TBySeriesId; 33 | dataLoader: DataLoader; 34 | uiState: UiState; 35 | uiStateConsumerOverrides: OverriddenUiState; 36 | } 37 | 38 | export const invalidLoader = (() => { 39 | throw new Error('No data loader specified.'); 40 | }) as any as DataLoader; 41 | 42 | export const DEFAULT_CHART_STATE: ChartState = { 43 | debounceTimeout: 1000, 44 | physicalChartWidth: 200, 45 | seriesIds: [], 46 | loadedDataBySeriesId: {}, 47 | loadVersionBySeriesId: {}, 48 | errorBySeriesId: {}, 49 | dataLoader: invalidLoader, 50 | uiState: { 51 | xDomain: DEFAULT_X_DOMAIN, 52 | yDomainBySeriesId: {}, 53 | }, 54 | uiStateConsumerOverrides: {} 55 | }; 56 | -------------------------------------------------------------------------------- /src/core/MouseCapture.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | import * as d3Scale from 'd3-scale'; 4 | 5 | const LEFT_MOUSE_BUTTON = 0; 6 | 7 | export interface Props { 8 | className?: string; 9 | zoomSpeed?: number | ((e: React.WheelEvent) => number); 10 | onZoom?: (factor: number, xPct: number, yPct: number, e: React.WheelEvent) => void; 11 | onDragStart?: (xPct: number, yPct: number, e: React.MouseEvent | MouseEvent) => void; 12 | onDrag?: (xPct: number, yPct: number, e: React.MouseEvent | MouseEvent) => void; 13 | onDragEnd?: (xPct: number, yPct: number, e: React.MouseEvent | MouseEvent) => void; 14 | onClick?: (xPct: number, yPct: number, e: React.MouseEvent) => void; 15 | onHover?: (xPct: number | undefined, yPct: number | undefined, e: React.MouseEvent) => void; 16 | children?: React.ReactNode; 17 | } 18 | 19 | export interface State { 20 | mouseDownClientX?: number; 21 | mouseDownClientY?: number; 22 | lastMouseMoveClientX?: number; 23 | lastMouseMoveClientY?: number; 24 | } 25 | 26 | export default class MouseCapture extends React.PureComponent { 27 | static propTypes: React.ValidationMap = { 28 | className: React.PropTypes.string, 29 | zoomSpeed: React.PropTypes.oneOfType([ 30 | React.PropTypes.number, 31 | React.PropTypes.func 32 | ]), 33 | onZoom: React.PropTypes.func, 34 | onDragStart: React.PropTypes.func, 35 | onDrag: React.PropTypes.func, 36 | onDragEnd: React.PropTypes.func, 37 | onHover: React.PropTypes.func, 38 | onClick: React.PropTypes.func, 39 | children: React.PropTypes.oneOfType([ 40 | React.PropTypes.element, 41 | React.PropTypes.arrayOf(React.PropTypes.element) 42 | ]) 43 | }; 44 | 45 | static defaultProps: Partial = { 46 | zoomSpeed: 0.05 47 | }; 48 | 49 | private element: HTMLDivElement; 50 | 51 | state: State = {}; 52 | 53 | componentWillUnmount() { 54 | this._removeWindowMouseEventHandlers(); 55 | } 56 | 57 | render() { 58 | return ( 59 |
{ this.element = element; }} 67 | > 68 | {this.props.children} 69 |
70 | ); 71 | } 72 | 73 | private _createPhysicalToLogicalScales() { 74 | const { left, right, top, bottom } = this.element.getBoundingClientRect(); 75 | return { 76 | xScale: d3Scale.scaleLinear() 77 | .domain([ left, right ]) 78 | .range([ 0, 1 ]), 79 | yScale: d3Scale.scaleLinear() 80 | .domain([ top, bottom ]) 81 | .range([ 0, 1 ]) 82 | }; 83 | } 84 | 85 | private _clearState() { 86 | this.setState({ 87 | mouseDownClientX: undefined, 88 | mouseDownClientY: undefined, 89 | lastMouseMoveClientX: undefined, 90 | lastMouseMoveClientY: undefined 91 | }); 92 | } 93 | 94 | private _maybeDispatchDragHandler( 95 | e: React.MouseEvent | MouseEvent, 96 | handler?: (xPct: number, yPct: number, e: React.MouseEvent | MouseEvent) => void 97 | ) { 98 | if (e.button === LEFT_MOUSE_BUTTON && handler && this.state.mouseDownClientX != null) { 99 | const { xScale, yScale } = this._createPhysicalToLogicalScales(); 100 | handler( 101 | xScale(e.clientX), 102 | yScale(e.clientY), 103 | e 104 | ); 105 | } 106 | } 107 | 108 | private _addWindowMouseEventHandlers = () => { 109 | window.addEventListener('mousemove', this._onMouseMoveInWindow); 110 | window.addEventListener('mouseup', this._onMouseUpInWindow); 111 | } 112 | 113 | private _removeWindowMouseEventHandlers = () => { 114 | window.removeEventListener('mousemove', this._onMouseMoveInWindow); 115 | window.removeEventListener('mouseup', this._onMouseUpInWindow); 116 | } 117 | 118 | private _onMouseDownInCaptureArea = (e: React.MouseEvent) => { 119 | if (e.button === LEFT_MOUSE_BUTTON) { 120 | this.setState({ 121 | mouseDownClientX: e.clientX, 122 | mouseDownClientY: e.clientY, 123 | lastMouseMoveClientX: e.clientX, 124 | lastMouseMoveClientY: e.clientY 125 | }); 126 | 127 | if (this.props.onDragStart) { 128 | const { xScale, yScale } = this._createPhysicalToLogicalScales(); 129 | this.props.onDragStart(xScale(e.clientX), yScale(e.clientY), e); 130 | } 131 | 132 | this._removeWindowMouseEventHandlers(); 133 | this._addWindowMouseEventHandlers(); 134 | } 135 | }; 136 | 137 | private _onMouseMoveInCaptureArea = (e: React.MouseEvent) => { 138 | if (this.props.onHover) { 139 | const { xScale, yScale } = this._createPhysicalToLogicalScales(); 140 | this.props.onHover(xScale(e.clientX), yScale(e.clientY), e); 141 | } 142 | }; 143 | 144 | private _onMouseUpInCaptureArea = (e: React.MouseEvent) => { 145 | if (e.button === LEFT_MOUSE_BUTTON && this.props.onClick && Math.abs(this.state.mouseDownClientX! - e.clientX) <= 2 && Math.abs(this.state.mouseDownClientY! - e.clientY) <= 2) { 146 | const { xScale, yScale } = this._createPhysicalToLogicalScales(); 147 | this.props.onClick(xScale(e.clientX), yScale(e.clientY), e); 148 | } 149 | } 150 | 151 | private _onMouseMoveInWindow = (e: MouseEvent) => { 152 | this._maybeDispatchDragHandler(e, this.props.onDrag); 153 | 154 | this.setState({ 155 | lastMouseMoveClientX: e.clientX, 156 | lastMouseMoveClientY: e.clientY 157 | }); 158 | } 159 | 160 | private _onMouseUpInWindow = (e: MouseEvent) => { 161 | this._maybeDispatchDragHandler(e, this.props.onDragEnd); 162 | this._removeWindowMouseEventHandlers(); 163 | this._clearState(); 164 | }; 165 | 166 | private _onMouseLeaveCaptureArea = (e: React.MouseEvent) => { 167 | if (this.props.onHover) { 168 | this.props.onHover(undefined, undefined, e); 169 | } 170 | }; 171 | 172 | private _onWheel = (e: React.WheelEvent) => { 173 | // In Chrome, shift + wheel results in horizontal scrolling and 174 | // deltaY == 0 while deltaX != 0, and deltaX should be used instead 175 | const delta = e.shiftKey ? e.deltaY || e.deltaX : e.deltaY; 176 | if (this.props.onZoom && delta) { 177 | const zoomSpeed = typeof this.props.zoomSpeed === 'function' 178 | ? this.props.zoomSpeed(e) 179 | : this.props.zoomSpeed; 180 | const zoomFactor = Math.exp(-delta * zoomSpeed!); 181 | const { xScale, yScale } = this._createPhysicalToLogicalScales(); 182 | this.props.onZoom(zoomFactor, xScale(e.clientX), yScale(e.clientY), e); 183 | } 184 | }; 185 | } 186 | -------------------------------------------------------------------------------- /src/core/Stack.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | import PixelRatioContext from './decorators/PixelRatioContext'; 5 | import PixelRatioContextProvider from './decorators/PixelRatioContextProvider'; 6 | 7 | export interface Props { 8 | className?: string; 9 | pixelRatio?: number; 10 | } 11 | 12 | @PixelRatioContext 13 | @PixelRatioContextProvider 14 | export default class Stack extends React.PureComponent { 15 | static propTypes: React.ValidationMap = { 16 | className: React.PropTypes.string, 17 | pixelRatio: React.PropTypes.number 18 | }; 19 | 20 | render() { 21 | return ( 22 |
23 | {this.props.children} 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/axes/XAxis.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import propTypes from '../propTypes'; 5 | import { computeTicks } from '../renderUtils'; 6 | import { Interval, AxisSpec } from '../interfaces'; 7 | 8 | export interface Props extends AxisSpec { 9 | xDomain: Interval; 10 | } 11 | 12 | export default class XAxis extends React.PureComponent { 13 | static propTypes: React.ValidationMap = { 14 | ...propTypes.axisSpecPartial, 15 | xDomain: propTypes.interval.isRequired 16 | }; 17 | 18 | static defaultProps: Partial = { 19 | scale: d3Scale.scaleTime 20 | }; 21 | 22 | render() { 23 | const xScale = this.props.scale!() 24 | .domain([ this.props.xDomain.min, this.props.xDomain.max ]) 25 | .range([ 0, 100 ]); 26 | 27 | const { ticks, format } = computeTicks(xScale, this.props.ticks, this.props.tickFormat); 28 | 29 | return ( 30 |
33 | {ticks.map((tick, i) => 34 |
35 | {format(tick)} 36 |
37 | )} 38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/axes/YAxis.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import propTypes from '../propTypes'; 5 | import { wrapWithAnimatedYDomain } from '../componentUtils'; 6 | import { computeTicks } from '../renderUtils'; 7 | import { Interval, AxisSpec } from '../interfaces'; 8 | 9 | export interface Props extends AxisSpec { 10 | yDomain: Interval; 11 | } 12 | 13 | class YAxis extends React.PureComponent { 14 | static propTypes: React.ValidationMap = { 15 | ...propTypes.axisSpecPartial, 16 | yDomain: propTypes.interval.isRequired 17 | }; 18 | 19 | static defaultProps: Partial = { 20 | scale: d3Scale.scaleLinear 21 | }; 22 | 23 | render() { 24 | const yScale = this.props.scale!() 25 | .domain([ this.props.yDomain.min, this.props.yDomain.max ]) 26 | .range([ 0, 100 ]); 27 | 28 | const { ticks, format } = computeTicks(yScale, this.props.ticks, this.props.tickFormat); 29 | 30 | return ( 31 |
35 | {ticks.map((tick, i) => 36 |
37 | {format(tick)} 38 | 39 |
40 | )} 41 |
42 | ); 43 | } 44 | } 45 | 46 | export default wrapWithAnimatedYDomain(YAxis); 47 | -------------------------------------------------------------------------------- /src/core/axes/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as YAxis, 3 | Props as YAxisProps 4 | } from './YAxis'; 5 | 6 | export { 7 | default as XAxis, 8 | Props as XAxisProps 9 | } from './XAxis'; 10 | -------------------------------------------------------------------------------- /src/core/componentUtils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Motion, spring } from 'react-motion'; 3 | 4 | import { Interval } from './interfaces'; 5 | 6 | function springifyInterval(interval: Interval) { 7 | return { 8 | min: spring(interval.min), 9 | max: spring(interval.max) 10 | }; 11 | } 12 | 13 | export interface YDomainProp { 14 | yDomain: Interval; 15 | } 16 | 17 | export function wrapWithAnimatedYDomain(Component: React.ComponentClass): React.ComponentClass { 18 | 19 | class AnimatedYDomainWrapper extends React.PureComponent { 20 | render() { 21 | return ( 22 | 23 | {(interpolatedYDomain: Interval) => } 24 | 25 | ); 26 | } 27 | } 28 | 29 | return AnimatedYDomainWrapper; 30 | } 31 | -------------------------------------------------------------------------------- /src/core/decorators/NonReactRender.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | export default function NonReactRender(component: React.ComponentClass) { 5 | const prototype = component.prototype as React.ComponentLifecycle; 6 | 7 | const oldDidMount = prototype.componentDidMount; 8 | prototype.componentDidMount = function() { 9 | if (!_.isFunction(this.nonReactRender)) { 10 | throw new Error(this.constructor.name + ' must implement a nonReactRender function to use the NonReactRender decorator'); 11 | } 12 | 13 | this.__boundNonReactRender = function() { 14 | this.__lastRafRequest = null; 15 | this.nonReactRender(); 16 | }.bind(this); 17 | 18 | this.__lastRafRequest = requestAnimationFrame(this.__boundNonReactRender); 19 | 20 | if (oldDidMount) { 21 | oldDidMount.call(this); 22 | } 23 | }; 24 | 25 | const oldDidUpdate = prototype.componentDidUpdate; 26 | prototype.componentDidUpdate = function() { 27 | if (!this.__lastRafRequest) { 28 | this.__lastRafRequest = requestAnimationFrame(this.__boundNonReactRender); 29 | } 30 | 31 | if (oldDidUpdate) { 32 | oldDidUpdate.call(this); 33 | } 34 | }; 35 | 36 | const oldWillUnmount = prototype.componentWillUnmount; 37 | prototype.componentWillUnmount = function() { 38 | cancelAnimationFrame(this.__lastRafRequest); 39 | 40 | if (oldWillUnmount) { 41 | oldWillUnmount.call(this); 42 | } 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/core/decorators/PixelRatioContext.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | export interface Context { 5 | pixelRatio: number; 6 | } 7 | 8 | export default function PixelRatioContext(component: React.ComponentClass) { 9 | component.contextTypes = _.defaults({ 10 | pixelRatio: React.PropTypes.number 11 | }, component.contextTypes); 12 | } 13 | -------------------------------------------------------------------------------- /src/core/decorators/PixelRatioContextProvider.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | export default function PixelRatioContextProvider(component: React.ComponentClass) { 5 | component.childContextTypes = _.defaults({ 6 | pixelRatio: React.PropTypes.number 7 | }, component.childContextTypes); 8 | 9 | const prototype = component.prototype as React.ComponentClass & React.ChildContextProvider; 10 | 11 | const oldGetChildContext = prototype.getChildContext; 12 | prototype.getChildContext = function() { 13 | const oldContext = oldGetChildContext ? oldGetChildContext.call(this) : {}; 14 | return _.defaults({ pixelRatio: this.props.pixelRatio || this.context.pixelRatio }, oldContext); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/core/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NonReactRender } from './NonReactRender'; 2 | export { default as PixelRatioContext, Context as PixelRatioContextType } from './PixelRatioContext'; 3 | export { default as PixelRatioContextProvider } from './PixelRatioContextProvider'; 4 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | export * from './renderUtils'; 3 | export * from './intervalUtils'; 4 | export * from './componentUtils'; 5 | export * from './decorators'; 6 | export * from './layers'; 7 | export * from './axes'; 8 | export { default as Stack, Props as StackProps } from './Stack'; 9 | export { default as MouseCapture, Props as MouseCaptureProps } from './MouseCapture'; 10 | export { default as propTypes } from './propTypes'; 11 | -------------------------------------------------------------------------------- /src/core/interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type Color = string; 4 | export type ScaleFunction = Function; // TODO: d3 scale function typings. 5 | export type SeriesData = any[]; 6 | 7 | export type Ticks = ((axisDomain: Interval) => number[] | number) | number[] | number; 8 | export type TickFormat = ((value: number) => string) | string; 9 | export type BooleanMouseEventHandler = (event: React.MouseEvent) => boolean; 10 | 11 | export interface Interval { 12 | min: number; 13 | max: number; 14 | } 15 | 16 | export interface PointDatum { 17 | xValue: number; 18 | yValue: number; 19 | } 20 | 21 | export interface SpanDatum { 22 | minXValue: number; 23 | maxXValue: number; 24 | } 25 | 26 | export interface BarDatum { 27 | minXValue: number; 28 | maxXValue: number; 29 | yValue: number; 30 | } 31 | 32 | export interface BucketDatum { 33 | minXValue: number; 34 | maxXValue: number; 35 | minYValue: number; 36 | maxYValue: number; 37 | firstYValue: number; 38 | lastYValue: number; 39 | } 40 | 41 | export interface AxisSpec { 42 | scale?: ScaleFunction; 43 | ticks?: Ticks; 44 | tickFormat?: TickFormat; 45 | color?: Color; 46 | } 47 | 48 | export enum JoinType { 49 | DIRECT, 50 | LEADING, 51 | TRAILING 52 | } 53 | -------------------------------------------------------------------------------- /src/core/intervalUtils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import { Interval } from '../core'; 5 | 6 | export function enforceIntervalBounds(interval: Interval, bounds?: Interval): Interval { 7 | if (!bounds) { 8 | return interval; 9 | } 10 | 11 | const extent = intervalExtent(interval); 12 | const boundsExtent = intervalExtent(bounds); 13 | if (extent > boundsExtent) { 14 | const halfExtentDiff = (extent - boundsExtent) / 2; 15 | return { 16 | min: bounds.min - halfExtentDiff, 17 | max: bounds.max + halfExtentDiff 18 | }; 19 | } else if (interval.min < bounds.min) { 20 | return { 21 | min: bounds.min, 22 | max: bounds.min + extent 23 | }; 24 | } else if (interval.max > bounds.max) { 25 | return { 26 | min: bounds.max - extent, 27 | max: bounds.max 28 | }; 29 | } else { 30 | return interval; 31 | } 32 | } 33 | 34 | export function enforceIntervalExtent(interval: Interval, minExtent?: number, maxExtent?: number): Interval { 35 | const extent = intervalExtent(interval); 36 | if (minExtent != null && extent < minExtent) { 37 | const halfExtentDiff = (minExtent - extent) / 2; 38 | return { 39 | min: interval.min - halfExtentDiff, 40 | max: interval.max + halfExtentDiff 41 | }; 42 | } else if (maxExtent != null && extent > maxExtent) { 43 | const halfExtentDiff = (extent - maxExtent) / 2; 44 | return { 45 | min: interval.min + halfExtentDiff, 46 | max: interval.max - halfExtentDiff 47 | }; 48 | } else { 49 | return interval; 50 | } 51 | } 52 | 53 | export function intervalExtent(interval: Interval): number { 54 | return interval.max - interval.min; 55 | } 56 | 57 | export function extendInterval(interval: Interval, factor: number): Interval { 58 | const extent = intervalExtent(interval); 59 | return { 60 | min: interval.min - extent * factor, 61 | max: interval.max + extent * factor 62 | }; 63 | } 64 | 65 | export function roundInterval(interval: Interval): Interval { 66 | return { 67 | min: Math.round(interval.min), 68 | max: Math.round(interval.max) 69 | }; 70 | } 71 | 72 | export function niceInterval(interval: Interval): Interval { 73 | const nicedInterval = d3Scale.scaleLinear().domain([ interval.min, interval.max ]).nice().domain(); 74 | return { 75 | min: nicedInterval[0], 76 | max: nicedInterval[1] 77 | }; 78 | } 79 | 80 | export function mergeIntervals(intervals: Interval[]): Interval | undefined; 81 | export function mergeIntervals(intervals: Interval[], defaultInterval: Interval): Interval; 82 | export function mergeIntervals(intervals: Interval[], defaultInterval: undefined): Interval | undefined; 83 | 84 | export function mergeIntervals(intervals: Interval[], defaultInterval?: Interval) { 85 | if (intervals.length === 0) { 86 | return defaultInterval || undefined; 87 | } else { 88 | return { 89 | min: _.min(_.map(intervals, 'min')), 90 | max: _.max(_.map(intervals, 'max')) 91 | }; 92 | } 93 | } 94 | 95 | export function intervalContains(maybeLargerInterval: Interval, maybeSmallerInterval: Interval) { 96 | return maybeLargerInterval.min <= maybeSmallerInterval.min && maybeLargerInterval.max >= maybeSmallerInterval.max; 97 | } 98 | 99 | export function panInterval(interval: Interval, delta: number): Interval { 100 | return { 101 | min: interval.min + delta, 102 | max: interval.max + delta 103 | }; 104 | } 105 | 106 | export function zoomInterval(interval: Interval, factor: number, anchorBias: number = 0.5): Interval { 107 | const currentExtent = intervalExtent(interval); 108 | const targetExtent = currentExtent / factor; 109 | const extentDelta = targetExtent - currentExtent; 110 | 111 | return { 112 | min: interval.min - extentDelta * anchorBias, 113 | max: interval.max + extentDelta * (1 - anchorBias) 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/core/layers/BarLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import NonReactRender from '../decorators/NonReactRender'; 5 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 6 | 7 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 8 | import { getIndexBoundsForSpanData } from '../renderUtils'; 9 | import { wrapWithAnimatedYDomain } from '../componentUtils'; 10 | import propTypes from '../propTypes'; 11 | import { Color, Interval, BarDatum } from '../interfaces'; 12 | 13 | export interface Props { 14 | data: BarDatum[]; 15 | xDomain: Interval; 16 | yDomain: Interval; 17 | color?: Color; 18 | } 19 | 20 | @NonReactRender 21 | @PixelRatioContext 22 | class BarLayer extends React.PureComponent { 23 | context: Context; 24 | 25 | static propTypes: React.ValidationMap = { 26 | data: React.PropTypes.arrayOf(propTypes.barDatum).isRequired, 27 | xDomain: propTypes.interval.isRequired, 28 | yDomain: propTypes.interval.isRequired, 29 | color: React.PropTypes.string 30 | }; 31 | 32 | static defaultProps: Partial = { 33 | color: 'rgba(0, 0, 0, 0.7)' 34 | }; 35 | 36 | render() { 37 | return ; 42 | } 43 | 44 | nonReactRender = () => { 45 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 46 | _renderCanvas(this.props, width, height, context); 47 | }; 48 | } 49 | 50 | // Export for testing. 51 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 52 | const { firstIndex, lastIndex } = getIndexBoundsForSpanData(props.data, props.xDomain, 'minXValue', 'maxXValue'); 53 | if (firstIndex === lastIndex) { 54 | return; 55 | } 56 | 57 | const xScale = d3Scale.scaleLinear() 58 | .domain([ props.xDomain.min, props.xDomain.max ]) 59 | .rangeRound([ 0, width ]); 60 | 61 | const yScale = d3Scale.scaleLinear() 62 | .domain([ props.yDomain.min, props.yDomain.max ]) 63 | .rangeRound([ 0, height ]); 64 | 65 | context.beginPath(); 66 | 67 | for (let i = firstIndex; i < lastIndex; ++i) { 68 | const left = xScale(props.data[i].minXValue); 69 | const right = xScale(props.data[i].maxXValue); 70 | const top = height - yScale(props.data[i].yValue); 71 | const bottom = height - yScale(0); 72 | 73 | context.rect(left, bottom, right - left, top - bottom); 74 | } 75 | 76 | context.fillStyle = props.color!; 77 | context.fill(); 78 | } 79 | 80 | export default wrapWithAnimatedYDomain(BarLayer); 81 | -------------------------------------------------------------------------------- /src/core/layers/BucketedLineLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import NonReactRender from '../decorators/NonReactRender'; 5 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 6 | 7 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 8 | import { getIndexBoundsForSpanData } from '../renderUtils'; 9 | import { wrapWithAnimatedYDomain } from '../componentUtils'; 10 | import propTypes from '../propTypes'; 11 | import { Interval, Color, ScaleFunction, BucketDatum, JoinType } from '../interfaces'; 12 | 13 | export interface Props { 14 | data: BucketDatum[]; 15 | xDomain: Interval; 16 | yDomain: Interval; 17 | yScale?: ScaleFunction; 18 | color?: Color; 19 | joinType?: JoinType; 20 | } 21 | 22 | @NonReactRender 23 | @PixelRatioContext 24 | class BucketedLineLayer extends React.PureComponent { 25 | context: Context; 26 | 27 | static propTypes: React.ValidationMap = { 28 | data: React.PropTypes.arrayOf(propTypes.bucketDatum).isRequired, 29 | xDomain: propTypes.interval.isRequired, 30 | yDomain: propTypes.interval.isRequired, 31 | yScale: React.PropTypes.func, 32 | color: React.PropTypes.string 33 | }; 34 | 35 | static defaultProps: Partial = { 36 | yScale: d3Scale.scaleLinear, 37 | color: '#444', 38 | joinType: JoinType.DIRECT 39 | }; 40 | 41 | render() { 42 | return ; 47 | } 48 | 49 | nonReactRender = () => { 50 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 51 | _renderCanvas(this.props, width, height, context); 52 | }; 53 | } 54 | 55 | function clamp(value: number, min: number, max: number) { 56 | return Math.min(Math.max(min, value), max); 57 | } 58 | 59 | // Export for testing. 60 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 61 | const { firstIndex, lastIndex } = getIndexBoundsForSpanData(props.data, props.xDomain, 'minXValue', 'maxXValue'); 62 | 63 | if (firstIndex === lastIndex) { 64 | return; 65 | } 66 | 67 | // Don't use rangeRound -- it causes flicker as you pan/zoom because it doesn't consistently round in one direction. 68 | const xScale = d3Scale.scaleLinear() 69 | .domain([ props.xDomain.min, props.xDomain.max ]) 70 | .range([ 0, width ]); 71 | 72 | const yScale = props.yScale!() 73 | .domain([ props.yDomain.min, props.yDomain.max ]) 74 | .range([ 0, height ]); 75 | 76 | const computedValuesForVisibleData = props.data 77 | .slice(firstIndex, lastIndex) 78 | .map(datum => { 79 | // TODO: Why is this ceiling'd? There must have been a reason... 80 | // I think this was to avoid jitter, but if you zoom really slowly when the rects 81 | // are small you can still see them jitter in their width... 82 | const minX = Math.ceil(xScale(datum.minXValue)); 83 | const maxX = Math.max(Math.floor(xScale(datum.maxXValue)), minX + 1); 84 | 85 | const minY = Math.floor(yScale(datum.minYValue)); 86 | const maxY = Math.max(Math.floor(yScale(datum.maxYValue)), minY + 1); 87 | 88 | return { 89 | minX, 90 | maxX, 91 | minY, 92 | maxY, 93 | firstY: clamp(Math.floor(yScale(datum.firstYValue)), minY, maxY - 1), 94 | lastY: clamp(Math.floor(yScale(datum.lastYValue)), minY, maxY - 1), 95 | width: maxX - minX, 96 | height: maxY - minY 97 | }; 98 | }); 99 | 100 | // Bars 101 | context.beginPath(); 102 | for (let i = 0; i < computedValuesForVisibleData.length; ++i) { 103 | const computedValues = computedValuesForVisibleData[i]; 104 | if (computedValues.width !== 1 || computedValues.height !== 1) { 105 | context.rect( 106 | computedValues.minX, 107 | height - computedValues.maxY, 108 | computedValues.width, 109 | computedValues.height 110 | ); 111 | } 112 | } 113 | context.fillStyle = props.color!; 114 | context.fill(); 115 | 116 | // Lines 117 | context.translate(0.5, -0.5); 118 | context.beginPath(); 119 | const firstComputedValues = computedValuesForVisibleData[0]; 120 | context.moveTo(firstComputedValues.maxX - 1, height - firstComputedValues.lastY); 121 | for (let i = 1; i < computedValuesForVisibleData.length; ++i) { 122 | const computedValues = computedValuesForVisibleData[i]; 123 | 124 | if (props.joinType === JoinType.LEADING) { 125 | context.lineTo(computedValuesForVisibleData[i - 1].maxX - 1, height - computedValues.firstY); 126 | } else if (props.joinType === JoinType.TRAILING) { 127 | context.lineTo(computedValues.minX, height - computedValuesForVisibleData[i - 1].lastY); 128 | } 129 | 130 | context.lineTo(computedValues.minX, height - computedValues.firstY); 131 | context.moveTo(computedValues.maxX - 1, height - computedValues.lastY); 132 | } 133 | context.strokeStyle = props.color!; 134 | context.stroke(); 135 | } 136 | 137 | export default wrapWithAnimatedYDomain(BucketedLineLayer); 138 | -------------------------------------------------------------------------------- /src/core/layers/InteractionCaptureLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import propTypes from '../propTypes'; 4 | import { Interval, BooleanMouseEventHandler } from '../interfaces'; 5 | import MouseCapture from '../MouseCapture'; 6 | 7 | const LEFT_MOUSE_BUTTON = 0; 8 | 9 | export const DEFAULT_SHOULD_ZOOM: BooleanMouseEventHandler = () => true; 10 | export const DEFAULT_SHOULD_PAN: BooleanMouseEventHandler = (event) => !event.shiftKey && event.button === LEFT_MOUSE_BUTTON; 11 | export const DEFAULT_SHOULD_BRUSH: BooleanMouseEventHandler = (event) => event.shiftKey && event.button === LEFT_MOUSE_BUTTON; 12 | export const DEFAULT_SHOULD_HOVER: BooleanMouseEventHandler = () => true; 13 | 14 | export interface Props { 15 | xDomain: Interval; 16 | shouldZoom?: BooleanMouseEventHandler; 17 | shouldPan?: BooleanMouseEventHandler; 18 | shouldBrush?: BooleanMouseEventHandler; 19 | shouldHover?: BooleanMouseEventHandler; 20 | onZoom?: (factor: number, anchorBias: number) => void; 21 | onPan?: (logicalUnits: number) => void; 22 | onBrush?: (logicalUnitInterval?: Interval) => void; 23 | onHover?: (logicalPosition?: number) => void; 24 | zoomSpeed?: number; 25 | } 26 | 27 | export interface State { 28 | isPanning: boolean; 29 | isBrushing: boolean; 30 | lastPanXPct?: number; 31 | startBrushXPct?: number; 32 | } 33 | 34 | export default class InteractionCaptureLayer extends React.PureComponent { 35 | static propTypes: React.ValidationMap = { 36 | shouldZoom: React.PropTypes.func, 37 | shouldPan: React.PropTypes.func, 38 | shouldBrush: React.PropTypes.func, 39 | shouldHover: React.PropTypes.func, 40 | onZoom: React.PropTypes.func, 41 | onPan: React.PropTypes.func, 42 | onBrush: React.PropTypes.func, 43 | onHover: React.PropTypes.func, 44 | xDomain: propTypes.interval.isRequired, 45 | zoomSpeed: React.PropTypes.number 46 | }; 47 | 48 | static defaultProps: Partial = { 49 | zoomSpeed: 0.05 50 | }; 51 | 52 | state: State = { 53 | isPanning: false, 54 | isBrushing: false 55 | }; 56 | 57 | render() { 58 | return ( 59 | 69 | ); 70 | } 71 | 72 | private _dispatchPanAndBrushEvents(xPct: number, _yPct: number, _e: React.MouseEvent) { 73 | if (this.props.onPan && this.state.isPanning) { 74 | this.props.onPan(this._xPctToDomain(this.state.lastPanXPct!) - this._xPctToDomain(xPct)); 75 | this.setState({ lastPanXPct: xPct }); 76 | } else if (this.props.onBrush && this.state.isBrushing) { 77 | const a = this._xPctToDomain(this.state.startBrushXPct!); 78 | const b = this._xPctToDomain(xPct); 79 | this.props.onBrush({ min: Math.min(a, b), max: Math.max(a, b) }); 80 | } 81 | } 82 | 83 | private _xPctToDomain(xPct: number) { 84 | return this.props.xDomain.min + (this.props.xDomain.max - this.props.xDomain.min) * xPct; 85 | } 86 | 87 | private _onZoom = (factor: number, xPct: number, _yPct: number, e: React.WheelEvent) => { 88 | if (this.props.onZoom && this.props.shouldZoom && this.props.shouldZoom(e)) { 89 | e.preventDefault(); 90 | this.props.onZoom(factor, xPct); 91 | } 92 | }; 93 | 94 | private _onDragStart = (xPct: number, _yPct: number, e: React.MouseEvent) => { 95 | if (this.props.onPan && this.props.shouldPan && this.props.shouldPan(e)) { 96 | this.setState({ isPanning: true, lastPanXPct: xPct }); 97 | } else if (this.props.onBrush && this.props.shouldBrush && this.props.shouldBrush(e)) { 98 | this.setState({ isBrushing: true, startBrushXPct: xPct }); 99 | } 100 | }; 101 | 102 | private _onDrag = (xPct: number, yPct: number, e: React.MouseEvent) => { 103 | this._dispatchPanAndBrushEvents(xPct, yPct, e); 104 | }; 105 | 106 | private _onDragEnd = (xPct: number, yPct: number, e: React.MouseEvent) => { 107 | this._dispatchPanAndBrushEvents(xPct, yPct, e); 108 | this.setState({ 109 | isPanning: false, 110 | isBrushing: false, 111 | lastPanXPct: undefined, 112 | startBrushXPct: undefined 113 | }); 114 | }; 115 | 116 | private _onClick = (_xPct: number, _yPct: number, e: React.MouseEvent) => { 117 | if (this.props.onBrush && this.props.shouldBrush && this.props.shouldBrush(e)) { 118 | this.props.onBrush(undefined); 119 | } 120 | }; 121 | 122 | private _onHover = (xPct: number, _yPct: number, e: React.MouseEvent) => { 123 | if (this.props.onHover && this.props.shouldHover && this.props.shouldHover(e)) { 124 | if (xPct != null) { 125 | this.props.onHover(this._xPctToDomain(xPct)); 126 | } else { 127 | this.props.onHover(undefined); 128 | } 129 | } 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/core/layers/LineLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import NonReactRender from '../decorators/NonReactRender'; 5 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 6 | 7 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 8 | import { getIndexBoundsForPointData } from '../renderUtils'; 9 | import { wrapWithAnimatedYDomain } from '../componentUtils'; 10 | import propTypes from '../propTypes'; 11 | import { Interval, PointDatum, ScaleFunction, Color, JoinType } from '../interfaces'; 12 | 13 | export interface Props { 14 | data: PointDatum[]; 15 | xDomain: Interval; 16 | yDomain: Interval; 17 | yScale?: ScaleFunction; 18 | color?: Color; 19 | joinType?: JoinType; 20 | } 21 | 22 | @NonReactRender 23 | @PixelRatioContext 24 | class LineLayer extends React.PureComponent { 25 | context: Context; 26 | 27 | static propTypes: React.ValidationMap = { 28 | data: React.PropTypes.arrayOf(propTypes.pointDatum).isRequired, 29 | xDomain: propTypes.interval.isRequired, 30 | yDomain: propTypes.interval.isRequired, 31 | yScale: React.PropTypes.func, 32 | color: React.PropTypes.string 33 | }; 34 | 35 | static defaultProps: Partial = { 36 | yScale: d3Scale.scaleLinear, 37 | color: 'rgba(0, 0, 0, 0.7)', 38 | joinType: JoinType.DIRECT 39 | }; 40 | 41 | render() { 42 | return ; 47 | } 48 | 49 | nonReactRender = () => { 50 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 51 | _renderCanvas(this.props, width, height, context); 52 | }; 53 | } 54 | 55 | // Export for testing. 56 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 57 | // Should we draw something if there is one data point? 58 | if (props.data.length < 2) { 59 | return; 60 | } 61 | 62 | const { firstIndex, lastIndex } = getIndexBoundsForPointData(props.data, props.xDomain, 'xValue'); 63 | if (firstIndex === lastIndex) { 64 | return; 65 | } 66 | 67 | const xScale = d3Scale.scaleLinear() 68 | .domain([ props.xDomain.min, props.xDomain.max ]) 69 | .rangeRound([ 0, width ]); 70 | 71 | const yScale = props.yScale!() 72 | .domain([ props.yDomain.min, props.yDomain.max ]) 73 | .rangeRound([ 0, height ]); 74 | 75 | context.translate(0.5, -0.5); 76 | context.beginPath(); 77 | 78 | context.moveTo(xScale(props.data[firstIndex].xValue), height - yScale(props.data[firstIndex].yValue)); 79 | for (let i = firstIndex + 1; i < lastIndex; ++i) { 80 | const xValue = xScale(props.data[i].xValue); 81 | const yValue = height - yScale(props.data[i].yValue); 82 | 83 | if (props.joinType === JoinType.LEADING) { 84 | context.lineTo(xScale(props.data[i - 1].xValue), yValue); 85 | } else if (props.joinType === JoinType.TRAILING) { 86 | context.lineTo(xValue, height - yScale(props.data[i - 1].yValue)); 87 | } 88 | 89 | context.lineTo(xValue, yValue); 90 | } 91 | 92 | context.strokeStyle = props.color!; 93 | context.stroke(); 94 | } 95 | 96 | export default wrapWithAnimatedYDomain(LineLayer); 97 | -------------------------------------------------------------------------------- /src/core/layers/PointLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import NonReactRender from '../decorators/NonReactRender'; 5 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 6 | 7 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 8 | import { getIndexBoundsForPointData } from '../renderUtils'; 9 | import { wrapWithAnimatedYDomain } from '../componentUtils'; 10 | import propTypes from '../propTypes'; 11 | import { Interval, PointDatum, ScaleFunction, Color } from '../interfaces'; 12 | 13 | const TWO_PI = Math.PI * 2; 14 | 15 | export interface Props { 16 | data: PointDatum[]; 17 | xDomain: Interval; 18 | yDomain: Interval; 19 | yScale?: ScaleFunction; 20 | color?: Color; 21 | radius?: number; 22 | innerRadius?: number; 23 | } 24 | 25 | @NonReactRender 26 | @PixelRatioContext 27 | class PointLayer extends React.PureComponent { 28 | context: Context; 29 | 30 | static propTypes: React.ValidationMap = { 31 | data: React.PropTypes.arrayOf(propTypes.pointDatum).isRequired, 32 | xDomain: propTypes.interval.isRequired, 33 | yDomain: propTypes.interval.isRequired, 34 | yScale: React.PropTypes.func, 35 | color: React.PropTypes.string, 36 | radius: React.PropTypes.number, 37 | innerRadius: React.PropTypes.number 38 | }; 39 | 40 | static defaultProps: Partial = { 41 | yScale: d3Scale.scaleLinear, 42 | color: 'rgba(0, 0, 0, 0.7)', 43 | radius: 3, 44 | innerRadius: 0 45 | }; 46 | 47 | render() { 48 | return ; 53 | } 54 | 55 | nonReactRender = () => { 56 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 57 | _renderCanvas(this.props, width, height, context); 58 | }; 59 | } 60 | 61 | // Export for testing. 62 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 63 | const { firstIndex, lastIndex } = getIndexBoundsForPointData(props.data, props.xDomain, 'xValue'); 64 | if (firstIndex === lastIndex) { 65 | return; 66 | } 67 | 68 | const xScale = d3Scale.scaleLinear() 69 | .domain([ props.xDomain.min, props.xDomain.max ]) 70 | .rangeRound([ 0, width ]); 71 | 72 | const yScale = props.yScale!() 73 | .domain([ props.yDomain.min, props.yDomain.max ]) 74 | .rangeRound([ 0, height ]); 75 | 76 | const isFilled = props.innerRadius === 0; 77 | 78 | const radius = isFilled ? props.radius! : (props.radius! - props.innerRadius!) / 2 + props.innerRadius!; 79 | 80 | context.lineWidth = props.radius! - props.innerRadius!; 81 | context.strokeStyle = props.color!; 82 | context.fillStyle = props.color!; 83 | 84 | if (isFilled) { 85 | context.beginPath(); 86 | } 87 | 88 | for (let i = firstIndex; i < lastIndex; ++i) { 89 | const x = xScale(props.data[i].xValue); 90 | const y = height - yScale(props.data[i].yValue); 91 | 92 | // `fill` can be batched, but `stroke` can't (it draws extraneous lines even with `moveTo`). 93 | // https://html.spec.whatwg.org/multipage/scripting.html#dom-context-2d-arc 94 | if (!isFilled) { 95 | context.beginPath(); 96 | context.arc(x, y, radius, 0, TWO_PI); 97 | context.stroke(); 98 | } else { 99 | context.moveTo(x, y); 100 | context.arc(x, y, radius, 0, TWO_PI); 101 | } 102 | } 103 | 104 | if (isFilled) { 105 | context.fill(); 106 | } 107 | } 108 | 109 | export default wrapWithAnimatedYDomain(PointLayer); 110 | -------------------------------------------------------------------------------- /src/core/layers/PollingResizingCanvasLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface Props { 4 | onSizeChange: () => void; 5 | pixelRatio?: number; 6 | } 7 | 8 | export interface State { 9 | width: number; 10 | height: number; 11 | } 12 | 13 | export default class PollingResizingCanvasLayer extends React.PureComponent { 14 | private __setSizeInterval: number; 15 | 16 | static propTypes: React.ValidationMap = { 17 | onSizeChange: React.PropTypes.func.isRequired, 18 | pixelRatio: React.PropTypes.number 19 | }; 20 | 21 | static defaultProps: Partial = { 22 | pixelRatio: 1 23 | }; 24 | 25 | state = { 26 | width: 0, 27 | height: 0 28 | }; 29 | 30 | render() { 31 | return ( 32 | 38 | ); 39 | } 40 | 41 | getCanvasElement() { 42 | return this.refs['canvas'] as HTMLCanvasElement; 43 | } 44 | 45 | getDimensions() { 46 | return { 47 | width: this.state.width, 48 | height: this.state.height 49 | }; 50 | } 51 | 52 | resetCanvas() { 53 | const canvas = this.getCanvasElement(); 54 | const { width, height } = this.state; 55 | const context = canvas.getContext('2d')!; 56 | 57 | context.setTransform(1, 0, 0, 1, 0, 0); // Same as resetTransform, but actually part of the spec. 58 | context.scale(this.props.pixelRatio!, this.props.pixelRatio!); 59 | context.clearRect(0, 0, width, height); 60 | 61 | return { width, height, context }; 62 | } 63 | 64 | componentDidUpdate() { 65 | this.props.onSizeChange(); 66 | } 67 | 68 | componentDidMount() { 69 | this._setSizeFromDom(); 70 | this.__setSizeInterval = setInterval(this._setSizeFromDom.bind(this), 1000); 71 | } 72 | 73 | componentWillUnmount() { 74 | clearInterval(this.__setSizeInterval); 75 | } 76 | 77 | private _setSizeFromDom() { 78 | const wrapper = this.refs['canvas'] as HTMLElement; 79 | this.setState({ 80 | width: wrapper.offsetWidth, 81 | height: wrapper.offsetHeight 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/core/layers/SpanLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | 4 | import NonReactRender from '../decorators/NonReactRender'; 5 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 6 | 7 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 8 | import { getIndexBoundsForSpanData } from '../renderUtils'; 9 | import propTypes from '../propTypes'; 10 | import { Interval, Color, SpanDatum } from '../interfaces'; 11 | 12 | export interface Props { 13 | data: SpanDatum[]; 14 | xDomain: Interval; 15 | fillColor?: Color; 16 | borderColor?: Color; 17 | } 18 | 19 | @NonReactRender 20 | @PixelRatioContext 21 | export default class SpanLayer extends React.PureComponent { 22 | context: Context; 23 | 24 | static propTypes: React.ValidationMap = { 25 | data: React.PropTypes.arrayOf(propTypes.spanDatum).isRequired, 26 | xDomain: propTypes.interval.isRequired, 27 | fillColor: React.PropTypes.string, 28 | borderColor: React.PropTypes.string 29 | }; 30 | 31 | static defaultProps: Partial = { 32 | fillColor: 'rgba(0, 0, 0, 0.1)' 33 | }; 34 | 35 | render() { 36 | return ; 41 | } 42 | 43 | nonReactRender = () => { 44 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 45 | _renderCanvas(this.props, width, height, context); 46 | } 47 | } 48 | 49 | // Export for testing. 50 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 51 | const { firstIndex, lastIndex } = getIndexBoundsForSpanData(props.data, props.xDomain, 'minXValue', 'maxXValue'); 52 | if (firstIndex === lastIndex) { 53 | return; 54 | } 55 | 56 | const xScale = d3Scale.scaleLinear() 57 | .domain([ props.xDomain.min, props.xDomain.max ]) 58 | .rangeRound([ 0, width ]); 59 | 60 | context.lineWidth = 1; 61 | context.strokeStyle = props.borderColor!; 62 | 63 | for (let i = firstIndex; i < lastIndex; ++i) { 64 | const left = xScale(props.data[i].minXValue); 65 | const right = xScale(props.data[i].maxXValue); 66 | const width = right - left; 67 | context.beginPath(); 68 | context.rect(left, -1, width <= 0 ? 1 : width, height + 2); 69 | 70 | if (props.fillColor) { 71 | context.fillStyle = props.fillColor; 72 | context.fill(); 73 | } 74 | 75 | if (props.borderColor) { 76 | context.stroke(); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/core/layers/VerticalLineLayer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as d3Scale from 'd3-scale'; 3 | import * as _ from 'lodash'; 4 | 5 | import NonReactRender from '../decorators/NonReactRender'; 6 | import PixelRatioContext, { Context } from '../decorators/PixelRatioContext'; 7 | 8 | import PollingResizingCanvasLayer from './PollingResizingCanvasLayer'; 9 | import propTypes from '../propTypes'; 10 | import { Interval, Color } from '../interfaces'; 11 | 12 | export interface Props { 13 | xDomain: Interval; 14 | xValue?: number; 15 | color?: Color; 16 | } 17 | 18 | @NonReactRender 19 | @PixelRatioContext 20 | export default class VerticalLineLayer extends React.PureComponent { 21 | context: Context; 22 | 23 | static propTypes: React.ValidationMap = { 24 | xValue: React.PropTypes.number, 25 | xDomain: propTypes.interval.isRequired, 26 | color: React.PropTypes.string 27 | }; 28 | 29 | static defaultProps: Partial = { 30 | color: 'rgba(0, 0, 0, 1)' 31 | }; 32 | 33 | render() { 34 | return ; 39 | } 40 | 41 | nonReactRender = () => { 42 | const { width, height, context } = (this.refs['canvasLayer'] as PollingResizingCanvasLayer).resetCanvas(); 43 | _renderCanvas(this.props, width, height, context); 44 | }; 45 | } 46 | 47 | // Export for testing. 48 | export function _renderCanvas(props: Props, width: number, height: number, context: CanvasRenderingContext2D) { 49 | if (!_.isFinite(props.xValue)) { 50 | return; 51 | } 52 | 53 | const xScale = d3Scale.scaleLinear() 54 | .domain([ props.xDomain.min, props.xDomain.max ]) 55 | .rangeRound([ 0, width ]); 56 | const xPos = xScale(props.xValue!); 57 | 58 | if (xPos >= 0 && xPos < width) { 59 | context.lineWidth = 1; 60 | context.strokeStyle = props.color!; 61 | context.translate(0.5, -0.5); 62 | context.beginPath(); 63 | context.moveTo(xPos, 0); 64 | context.lineTo(xPos, height); 65 | context.stroke(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/core/layers/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as PollingResizingCanvasLayer, 3 | Props as PollingResizingCanvasLayerProps 4 | } from './PollingResizingCanvasLayer'; 5 | 6 | export { 7 | default as BarLayer, 8 | Props as BarLayerProps 9 | } from './BarLayer'; 10 | 11 | export { 12 | default as BucketedLineLayer, 13 | Props as BucketedLineLayerProps 14 | } from './BucketedLineLayer'; 15 | 16 | export { 17 | default as VerticalLineLayer, 18 | Props as VerticalLineLayerProps 19 | } from './VerticalLineLayer'; 20 | 21 | export { 22 | default as InteractionCaptureLayer, 23 | Props as InteractionCaptureLayerProps, 24 | DEFAULT_SHOULD_ZOOM, 25 | DEFAULT_SHOULD_PAN, 26 | DEFAULT_SHOULD_BRUSH, 27 | DEFAULT_SHOULD_HOVER 28 | } from './InteractionCaptureLayer'; 29 | 30 | export { 31 | default as PointLayer, 32 | Props as PointLayerProps 33 | } from './PointLayer'; 34 | 35 | export { 36 | default as LineLayer, 37 | Props as LineLayerProps 38 | } from './LineLayer'; 39 | 40 | export { 41 | default as SpanLayer, 42 | Props as SpanLayerProps 43 | } from './SpanLayer'; 44 | -------------------------------------------------------------------------------- /src/core/propTypes.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const interval = React.PropTypes.shape({ 4 | min: React.PropTypes.number.isRequired, 5 | max: React.PropTypes.number.isRequired 6 | }); 7 | 8 | export const controlledInterval = React.PropTypes.oneOfType([ 9 | interval, 10 | React.PropTypes.oneOf(['none']) 11 | ]); 12 | 13 | export const controlledHover = React.PropTypes.oneOfType([ 14 | React.PropTypes.number, 15 | React.PropTypes.oneOf(['none']) 16 | ]); 17 | 18 | export const pointDatum = React.PropTypes.shape({ 19 | xValue: React.PropTypes.number.isRequired, 20 | yValue: React.PropTypes.number.isRequired 21 | }); 22 | 23 | export const barDatum = React.PropTypes.shape({ 24 | minXValue: React.PropTypes.number.isRequired, 25 | maxXValue: React.PropTypes.number.isRequired, 26 | yValue: React.PropTypes.number.isRequired 27 | }); 28 | 29 | export const bucketDatum = React.PropTypes.shape({ 30 | minXValue: React.PropTypes.number.isRequired, 31 | maxXValue: React.PropTypes.number.isRequired, 32 | minYValue: React.PropTypes.number.isRequired, 33 | maxYValue: React.PropTypes.number.isRequired, 34 | firstYValue: React.PropTypes.number.isRequired, 35 | lastYValue: React.PropTypes.number.isRequired 36 | }); 37 | 38 | export const spanDatum = React.PropTypes.shape({ 39 | minXValue: React.PropTypes.number.isRequired, 40 | maxXValue: React.PropTypes.number.isRequired 41 | }); 42 | 43 | export const ticks = React.PropTypes.oneOfType([ 44 | React.PropTypes.func, 45 | React.PropTypes.number, 46 | React.PropTypes.arrayOf(React.PropTypes.number) 47 | ]); 48 | 49 | export const tickFormat = React.PropTypes.oneOfType([ 50 | React.PropTypes.func, 51 | React.PropTypes.string 52 | ]); 53 | 54 | export const axisSpecPartial = { 55 | scale: React.PropTypes.func, 56 | ticks: ticks, 57 | tickFormat: tickFormat, 58 | color: React.PropTypes.string 59 | }; 60 | 61 | export const defaultChartState = React.PropTypes.shape({ 62 | xDomain: interval, 63 | yDomains: React.PropTypes.objectOf(interval) 64 | }); 65 | 66 | export default { 67 | interval, 68 | controlledInterval, 69 | controlledHover, 70 | pointDatum, 71 | barDatum, 72 | bucketDatum, 73 | spanDatum, 74 | ticks, 75 | tickFormat, 76 | axisSpecPartial, 77 | defaultChartState 78 | }; 79 | -------------------------------------------------------------------------------- /src/core/renderUtils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | 3 | import { Interval, Ticks, TickFormat } from './interfaces'; 4 | 5 | export interface IndexBounds { 6 | firstIndex: number; 7 | lastIndex: number; 8 | } 9 | 10 | export type ValueAccessor = string | ((value: T) => number); 11 | 12 | function adjustBounds(firstIndex: number, lastIndex: number, dataLength: number): IndexBounds { 13 | if (firstIndex === dataLength || lastIndex === 0) { 14 | // No data is visible! 15 | return { firstIndex, lastIndex }; 16 | } else { 17 | // We want to include the previous and next data points so that e.g. lines drawn across the canvas 18 | // boundary still have somewhere to go. 19 | return { 20 | firstIndex: Math.max(0, firstIndex - 1), 21 | lastIndex: Math.min(dataLength, lastIndex + 1) 22 | }; 23 | } 24 | } 25 | 26 | // This is cause sortedIndexBy prefers to have the same shape for the array items and the searched thing. We don't 27 | // know what that shape is, so we have a sentinel + accompanying function to figure out when it's asking for this value. 28 | type BoundSentinel = { __boundSentinelBrand: string }; 29 | const LOWER_BOUND_SENTINEL: BoundSentinel = (() => {}) as any; 30 | const UPPER_BOUND_SENTINEL: BoundSentinel = (() => {}) as any; 31 | 32 | // Assumption: data is sorted by `xValuePath` acending. 33 | export function getIndexBoundsForPointData(data: T[], xValueBounds: Interval, xValueAccessor: ValueAccessor): IndexBounds { 34 | let lowerBound; 35 | let upperBound; 36 | let accessor; 37 | 38 | if (_.isString(xValueAccessor)) { 39 | lowerBound = _.set({}, xValueAccessor, xValueBounds.min); 40 | upperBound = _.set({}, xValueAccessor, xValueBounds.max); 41 | accessor = xValueAccessor; 42 | } else { 43 | lowerBound = LOWER_BOUND_SENTINEL; 44 | upperBound = UPPER_BOUND_SENTINEL; 45 | accessor = (value: T | BoundSentinel) => { 46 | if (value === LOWER_BOUND_SENTINEL) { 47 | return xValueBounds.min; 48 | } else if (value === UPPER_BOUND_SENTINEL) { 49 | return xValueBounds.max; 50 | } else { 51 | return xValueAccessor(value as T); 52 | } 53 | }; 54 | } 55 | 56 | const firstIndex = _.sortedIndexBy(data, lowerBound, accessor); 57 | const lastIndex = _.sortedLastIndexBy(data, upperBound, accessor); 58 | 59 | return adjustBounds(firstIndex, lastIndex, data.length); 60 | } 61 | 62 | // Assumption: data is sorted by `minXValuePath` ascending. 63 | export function getIndexBoundsForSpanData(data: T[], xValueBounds: Interval, minXValueAccessor: ValueAccessor, maxXValueAccessor: ValueAccessor): IndexBounds { 64 | let upperBound; 65 | let upperBoundAccessor; 66 | 67 | // Note that this purposely mixes the min accessor/max value. Think about it. 68 | if (_.isString(minXValueAccessor)) { 69 | upperBound = _.set({}, minXValueAccessor, xValueBounds.max); 70 | upperBoundAccessor = minXValueAccessor; 71 | } else { 72 | upperBound = UPPER_BOUND_SENTINEL; 73 | upperBoundAccessor = (value: T | BoundSentinel) => { 74 | if (value === UPPER_BOUND_SENTINEL) { 75 | return xValueBounds.max; 76 | } else { 77 | return minXValueAccessor(value as T); 78 | } 79 | }; 80 | } 81 | 82 | const lowerBoundAccessor = _.isString(maxXValueAccessor) 83 | ? (value: T) => _.get(value, maxXValueAccessor) 84 | : maxXValueAccessor; 85 | 86 | // Also note that this is a loose bound -- there could be spans that start later and end earlier such that 87 | // they don't actually fit inside the bounds, but this still saves us work in the end. 88 | const lastIndex = _.sortedLastIndexBy(data, upperBound, upperBoundAccessor); 89 | let firstIndex; 90 | for (firstIndex = 0; firstIndex < lastIndex; ++firstIndex) { 91 | if (lowerBoundAccessor(data[firstIndex]) >= xValueBounds.min) { 92 | break; 93 | } 94 | } 95 | 96 | return adjustBounds(firstIndex, lastIndex, data.length); 97 | } 98 | 99 | const DEFAULT_TICK_AMOUNT = 5; 100 | 101 | export function computeTicks(scale: any, ticks?: Ticks, tickFormat?: TickFormat) { 102 | let outputTicks: number[]; 103 | if (ticks) { 104 | if (_.isFunction(ticks)) { 105 | const [ min, max ] = scale.domain(); 106 | const maybeOutputTicks = ticks({ min, max }); 107 | if (_.isNumber(maybeOutputTicks)) { 108 | outputTicks = scale.ticks(maybeOutputTicks); 109 | } else { 110 | outputTicks = maybeOutputTicks; 111 | } 112 | } else if (_.isArray(ticks)) { 113 | outputTicks = ticks; 114 | } else if (_.isNumber(ticks)) { 115 | outputTicks = scale.ticks(ticks); 116 | } else { 117 | throw new Error('ticks must be a function, array or number'); 118 | } 119 | } else { 120 | outputTicks = scale.ticks(DEFAULT_TICK_AMOUNT); 121 | } 122 | 123 | let format: Function; 124 | if (_.isFunction(tickFormat)) { 125 | format = tickFormat; 126 | } else { 127 | const tickCount = _.isNumber(ticks) ? ticks : DEFAULT_TICK_AMOUNT; 128 | format = scale.tickFormat(tickCount, tickFormat); 129 | } 130 | 131 | return { ticks: outputTicks, format }; 132 | } 133 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core'; 2 | export * from './connected'; 3 | export * from './test-util'; 4 | -------------------------------------------------------------------------------- /src/test-util/CanvasContextSpy.ts: -------------------------------------------------------------------------------- 1 | // From Typescript's lib.d.ts as of be2ca35b004f2079464fdca454c08a5019020260. 2 | const PROPERTY_NAMES = [ 3 | 'fillStyle', 4 | 'font', 5 | 'globalAlpha', 6 | 'globalCompositeOperation', 7 | 'lineCap', 8 | 'lineDashOffset', 9 | 'lineJoin', 10 | 'lineWidth', 11 | 'miterLimit', 12 | 'msFillRule', 13 | 'msImageSmoothingEnabled', 14 | 'shadowBlur', 15 | 'shadowColor', 16 | 'shadowOffsetX', 17 | 'shadowOffsetY', 18 | 'strokeStyle', 19 | 'textAlign', 20 | 'textBaseline', 21 | 'mozImageSmoothingEnabled', 22 | 'webkitImageSmoothingEnabled', 23 | 'oImageSmoothingEnabled' 24 | ]; 25 | 26 | const METHOD_NAMES = [ 27 | 'arc', 28 | 'arcTo', 29 | 'beginPath', 30 | 'bezierCurveTo', 31 | 'clearRect', 32 | 'clip', 33 | 'closePath', 34 | 'createImageData', 35 | 'createLinearGradient', 36 | 'createPattern', 37 | 'createRadialGradient', 38 | 'drawImage', 39 | 'ellipse', 40 | 'fill', 41 | 'fillRect', 42 | 'fillText', 43 | 'getImageData', 44 | 'getLineDash', 45 | 'isPointInPath', 46 | 'lineTo', 47 | 'measureText', 48 | 'moveTo', 49 | 'putImageData', 50 | 'quadraticCurveTo', 51 | 'rect', 52 | 'restore', 53 | 'rotate', 54 | 'save', 55 | 'scale', 56 | 'setLineDash', 57 | 'setTransform', 58 | 'stroke', 59 | 'strokeRect', 60 | 'strokeText', 61 | 'transform', 62 | 'translate' 63 | ]; 64 | 65 | export interface PropertySet { 66 | property: string; 67 | value: any; 68 | } 69 | 70 | export interface MethodCall { 71 | method: string; 72 | arguments: any[]; 73 | } 74 | 75 | export class CanvasContextSpyExtensions { 76 | public operations: (PropertySet | MethodCall)[] = []; 77 | public calls: MethodCall[] = []; 78 | public properties: PropertySet[] = []; 79 | 80 | public callsOmit(...methodNames: string[]) { 81 | return this.calls.filter(call => methodNames.indexOf(call.method) === -1); 82 | } 83 | 84 | public callsOnly(...methodNames: string[]) { 85 | return this.calls.filter(call => methodNames.indexOf(call.method) !== -1); 86 | } 87 | } 88 | 89 | PROPERTY_NAMES.forEach(property => { 90 | Object.defineProperty(CanvasContextSpyExtensions.prototype, property, { 91 | set: function(value: any) { 92 | const propertySet = { property, value }; 93 | this.properties.push(propertySet); 94 | this.operations.push(propertySet); 95 | } 96 | }); 97 | }); 98 | 99 | METHOD_NAMES.forEach(method => { 100 | (CanvasContextSpyExtensions.prototype as any)[method] = function() { 101 | const call = { method, arguments: Array.prototype.slice.apply(arguments) }; 102 | this.calls.push(call); 103 | this.operations.push(call); 104 | }; 105 | }); 106 | 107 | // I don't know why this roundabout type definition works and simpler definitions 108 | // don't, but it took me a while to get here so we're going to leave it, weird 109 | // though it is (and it requires an annoying `typeof` on definitions to work). 110 | type MergedCanvasContext = CanvasRenderingContext2D & CanvasContextSpyExtensions & { 111 | new(): MergedCanvasContext; 112 | }; 113 | 114 | export default CanvasContextSpyExtensions as any as MergedCanvasContext; 115 | -------------------------------------------------------------------------------- /src/test-util/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CanvasContextSpy, PropertySet, MethodCall } from './CanvasContextSpy'; 2 | -------------------------------------------------------------------------------- /styles/index.less: -------------------------------------------------------------------------------- 1 | .lc-stack { 2 | position: relative; 3 | overflow: hidden; 4 | // TODO: Autoprefixer. 5 | user-select: none; 6 | -webkit-user-select: none; 7 | 8 | > * { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | bottom: 0; 13 | right: 0; 14 | } 15 | } 16 | 17 | .lc-stack .interaction-capture-layer { 18 | z-index: 1; 19 | } 20 | 21 | .lc-stack .y-axis { 22 | height: 100%; 23 | border-right: 1px solid; 24 | font-size: 12px; 25 | background-color: rgba(255, 255, 255, 0.7); 26 | 27 | .tick { 28 | position: relative; 29 | display: flex; 30 | flex-wrap: nowrap; 31 | justify-content: flex-end; 32 | align-items: center; 33 | // This way, when they are positioned in javascript, the values are a function of the height of the container, only. 34 | height: 0; 35 | 36 | .label { 37 | margin: 0 4px 0 6px; 38 | } 39 | 40 | .mark { 41 | width: 4px; 42 | border-bottom: 1px solid; 43 | } 44 | } 45 | } 46 | 47 | .lc-stack .x-axis { 48 | display: flex; 49 | flex-wrap: nowrap; 50 | text-transform: uppercase; 51 | font-size: 12px; 52 | 53 | .tick { 54 | position: relative; 55 | width: 0; 56 | border-left: 1px solid; 57 | margin-left: -1px; // To make up for the border rule ^. 58 | white-space: nowrap; 59 | display: flex; 60 | align-items: center; 61 | 62 | .label { 63 | margin-left: 4px; 64 | } 65 | } 66 | } 67 | 68 | .lc-chart-provider { 69 | display: flex; 70 | flex-direction: column; 71 | width: 200px; 72 | height: 100px; 73 | 74 | > .lc-stack { 75 | flex: 1; 76 | } 77 | 78 | // Make this pretty specific so you don't accidentally make it giant. 79 | > .lc-stack.autoinjected-resize-sentinel-stack { 80 | flex: initial; 81 | height: 0; 82 | } 83 | } 84 | 85 | .lc-polling-resizing-canvas-layer { 86 | width: 100%; 87 | height: 100%; 88 | } 89 | -------------------------------------------------------------------------------- /test/CanvasContextSpy-test.ts: -------------------------------------------------------------------------------- 1 | import CanvasContextSpy from '../src/test-util/CanvasContextSpy'; 2 | import { expect } from 'chai'; 3 | 4 | describe('CanvasContextSpy', () => { 5 | let spy: typeof CanvasContextSpy; 6 | 7 | beforeEach(() => { 8 | spy = new CanvasContextSpy(); 9 | }); 10 | 11 | function doABunchOfStuff(spy: typeof CanvasContextSpy) { 12 | spy.fillStyle = '#000'; 13 | spy.scale(0, 0); 14 | spy.lineWidth = 1; 15 | spy.save(); 16 | } 17 | 18 | it('should support setting properties', () => { 19 | spy.fillStyle = '#000'; 20 | 21 | expect(spy.properties).to.deep.equal([ 22 | { property: 'fillStyle', value: '#000' } 23 | ]); 24 | }); 25 | 26 | it('should support calling methods', () => { 27 | spy.scale(0, 0); 28 | 29 | expect(spy.calls).to.deep.equal([ 30 | { method: 'scale', arguments: [ 0, 0 ] } 31 | ]); 32 | }); 33 | 34 | it('should provide property sets and method calls in the order they happen via \'operations\'', () => { 35 | doABunchOfStuff(spy); 36 | 37 | expect(spy.operations).to.deep.equal([ 38 | { property: 'fillStyle', value: '#000' }, 39 | { method: 'scale', arguments: [ 0, 0 ] }, 40 | { property: 'lineWidth', value: 1 }, 41 | { method: 'save', arguments: [] } 42 | ]); 43 | }); 44 | 45 | it('should track only property sets in the order they happen via \'properties\'', () => { 46 | doABunchOfStuff(spy); 47 | 48 | expect(spy.properties).to.deep.equal([ 49 | { property: 'fillStyle', value: '#000' }, 50 | { property: 'lineWidth', value: 1 } 51 | ]); 52 | }); 53 | 54 | it('should track only method calls in the order they happen via \'calls\'', () => { 55 | doABunchOfStuff(spy); 56 | 57 | expect(spy.calls).to.deep.equal([ 58 | { method: 'scale', arguments: [ 0, 0 ] }, 59 | { method: 'save', arguments: [] } 60 | ]); 61 | }); 62 | 63 | it('should exclude calls using callsOmit', () => { 64 | doABunchOfStuff(spy); 65 | 66 | expect(spy.callsOmit('scale')).to.deep.equal([ 67 | { method: 'save', arguments: [] } 68 | ]); 69 | }); 70 | 71 | it('should include calls using callsOnly', () => { 72 | doABunchOfStuff(spy); 73 | 74 | expect(spy.callsOnly('scale')).to.deep.equal([ 75 | { method: 'scale', arguments: [ 0, 0 ] } 76 | ]); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/atomicActions-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { expect } from 'chai'; 3 | 4 | import { TBySeriesId, LoadedSeriesData, DataLoader } from '../src/connected/interfaces'; 5 | import reducer from '../src/connected/flux/reducer'; 6 | import { objectWithKeys } from '../src/connected/flux/reducerUtils'; 7 | import { ChartState } from '../src/connected/model/state'; 8 | import { DEFAULT_Y_DOMAIN } from '../src/connected/model/constants'; 9 | 10 | import { 11 | Action, 12 | setSeriesIds, 13 | setDataLoader, 14 | setDataLoaderDebounceTimeout, 15 | setDataLoaderContext, 16 | setChartPhysicalWidth, 17 | setXDomain, 18 | setOverrideXDomain, 19 | setYDomains, 20 | setOverrideYDomains, 21 | setHover, 22 | setOverrideHover, 23 | setSelection, 24 | setOverrideSelection, 25 | dataRequested, 26 | dataReturned, 27 | dataErrored 28 | } from '../src/connected/flux/atomicActions'; 29 | 30 | function pickKeyedState(state: ChartState) { 31 | return { 32 | loadedDataBySeriesId: state.loadedDataBySeriesId, 33 | loadVersionBySeriesId: state.loadVersionBySeriesId, 34 | errorBySeriesId: state.errorBySeriesId 35 | }; 36 | } 37 | 38 | function serial(state: ChartState, ...actions: Action[]): ChartState { 39 | actions.forEach(action => state = reducer(state, action)); 40 | return state; 41 | } 42 | 43 | describe('(atomic actions)', () => { 44 | const SERIES_A = 'a'; 45 | const SERIES_B = 'b'; 46 | const ALL_SERIES_IDS = [SERIES_A, SERIES_B]; 47 | const DATA_A = [{ __a: true }]; 48 | const DATA_B = [{ __b: true }]; 49 | const INTERVAL_A = { min: 0, max: 10 }; 50 | const INTERVAL_B = { min: 100, max: 1000 }; 51 | const DUMMY_INTERVAL = { min: -1, max: 1 }; 52 | const ALL_SERIES_DATA: TBySeriesId = { 53 | [SERIES_A]: { 54 | data: DATA_A, 55 | yDomain: INTERVAL_A 56 | }, 57 | [SERIES_B]: { 58 | data: DATA_B, 59 | yDomain: INTERVAL_B 60 | } 61 | }; 62 | const ALL_INTERVALS = { 63 | [SERIES_A]: INTERVAL_A, 64 | [SERIES_B]: INTERVAL_B 65 | }; 66 | const ERROR = { __error: true }; 67 | 68 | let state: ChartState; 69 | 70 | beforeEach(() => { 71 | state = { 72 | debounceTimeout: 1000, 73 | physicalChartWidth: 0, 74 | seriesIds: [], 75 | loadedDataBySeriesId: {}, 76 | loadVersionBySeriesId: {}, 77 | errorBySeriesId: {}, 78 | dataLoader: function() {} as any as DataLoader, 79 | uiState: { 80 | xDomain: DUMMY_INTERVAL, 81 | yDomainBySeriesId: {} 82 | }, 83 | uiStateConsumerOverrides: {} 84 | }; 85 | }); 86 | 87 | describe('setSeriesIds', () => { 88 | it('should put defaults in for all fields that are keyed by series ID', () => { 89 | state = reducer(state, setSeriesIds(ALL_SERIES_IDS)); 90 | 91 | expect(state.seriesIds).to.deep.equal(ALL_SERIES_IDS); 92 | expect(pickKeyedState(state)).to.deep.equal({ 93 | loadedDataBySeriesId: objectWithKeys(ALL_SERIES_IDS, { data: [], yDomain: DEFAULT_Y_DOMAIN }), 94 | loadVersionBySeriesId: objectWithKeys(ALL_SERIES_IDS, null), 95 | errorBySeriesId: objectWithKeys(ALL_SERIES_IDS, null), 96 | }); 97 | }); 98 | 99 | it('should remove outdated keys from fields that are keyed by series ID', () => { 100 | const ONLY_SERIES_A = [SERIES_A]; 101 | state = serial(state, 102 | setSeriesIds(ALL_SERIES_IDS), 103 | setSeriesIds(ONLY_SERIES_A) 104 | ); 105 | 106 | expect(state.seriesIds).to.deep.equal(ONLY_SERIES_A); 107 | expect(pickKeyedState(state)).to.deep.equal({ 108 | loadedDataBySeriesId: objectWithKeys(ONLY_SERIES_A, { data: [], yDomain: DEFAULT_Y_DOMAIN }), 109 | loadVersionBySeriesId: objectWithKeys(ONLY_SERIES_A, null), 110 | errorBySeriesId: objectWithKeys(ONLY_SERIES_A, null) 111 | }); 112 | }); 113 | }); 114 | 115 | describe('setDataLoader', () => { 116 | const dataLoader: any = function() {}; 117 | 118 | it('should set the dataLoader field', () => { 119 | state = serial(state, 120 | setSeriesIds(ALL_SERIES_IDS), 121 | setDataLoader(dataLoader) 122 | ); 123 | 124 | expect(state.dataLoader).to.equal(dataLoader); 125 | }); 126 | 127 | it('should not unset any already-loaded data', () => { 128 | state = serial(state, 129 | setSeriesIds(ALL_SERIES_IDS), 130 | dataReturned(ALL_SERIES_DATA) 131 | ); 132 | 133 | expect(state.loadedDataBySeriesId).to.deep.equal(ALL_SERIES_DATA); 134 | 135 | state = reducer(state, setDataLoader(dataLoader)); 136 | 137 | expect(state.loadedDataBySeriesId).to.deep.equal(ALL_SERIES_DATA); 138 | }); 139 | }); 140 | 141 | describe('dataRequested', () => { 142 | it('should update the load versions for only the series specified', () => { 143 | state = serial(state, 144 | setSeriesIds(ALL_SERIES_IDS), 145 | dataRequested([ SERIES_A ]) 146 | ); 147 | 148 | expect(state.loadVersionBySeriesId).to.have.keys(ALL_SERIES_IDS); 149 | expect(state.loadVersionBySeriesId[SERIES_A]).to.not.be.null; 150 | expect(state.loadVersionBySeriesId[SERIES_B]).to.be.null; 151 | }); 152 | 153 | it('should not change anything other than the load versions', () => { 154 | state = reducer(state, setSeriesIds(ALL_SERIES_IDS)); 155 | 156 | const startingState = state; 157 | 158 | state = reducer(state, dataRequested(ALL_SERIES_IDS)); 159 | 160 | expect(_.omit(state, 'loadVersionBySeriesId')).to.deep.equal(_.omit(startingState, 'loadVersionBySeriesId')); 161 | }); 162 | }); 163 | 164 | describe('dataReturned', () => { 165 | it('should clear the load version when a load returns for a particular series', () => { 166 | state = serial(state, 167 | setSeriesIds(ALL_SERIES_IDS), 168 | dataRequested(ALL_SERIES_IDS), 169 | dataReturned({ 170 | [SERIES_A]: { 171 | data: DATA_A, 172 | yDomain: INTERVAL_A 173 | } 174 | }) 175 | ); 176 | 177 | expect(state.loadVersionBySeriesId).to.have.keys(ALL_SERIES_IDS); 178 | expect(state.loadVersionBySeriesId[SERIES_A]).to.be.null; 179 | expect(state.loadVersionBySeriesId[SERIES_B]).to.not.be.null; 180 | }); 181 | 182 | it('should clear the load version and set the data for all series when they return successfully simultaneously', () => { 183 | state = serial(state, 184 | setSeriesIds(ALL_SERIES_IDS), 185 | dataRequested(ALL_SERIES_IDS), 186 | dataReturned(ALL_SERIES_DATA) 187 | ); 188 | 189 | expect(pickKeyedState(state)).to.deep.equal({ 190 | loadedDataBySeriesId: ALL_SERIES_DATA, 191 | loadVersionBySeriesId: objectWithKeys(ALL_SERIES_IDS, null), 192 | errorBySeriesId: objectWithKeys(ALL_SERIES_IDS, null) 193 | }); 194 | }); 195 | 196 | it('should clear the loading state and set the data for a single series that returns successfully', () => { 197 | state = serial(state, 198 | setSeriesIds(ALL_SERIES_IDS), 199 | dataRequested(ALL_SERIES_IDS), 200 | dataReturned({ 201 | [SERIES_A]: { 202 | data: DATA_A, 203 | yDomain: INTERVAL_A 204 | } 205 | }) 206 | ); 207 | 208 | expect(state.loadedDataBySeriesId).to.deep.equal({ 209 | [SERIES_A]: { 210 | data: DATA_A, 211 | yDomain: INTERVAL_A 212 | }, 213 | [SERIES_B]: { 214 | data: [], 215 | yDomain: DEFAULT_Y_DOMAIN 216 | } 217 | }); 218 | 219 | expect(state.loadVersionBySeriesId[SERIES_A]).to.be.null; 220 | expect(state.loadVersionBySeriesId[SERIES_B]).to.not.be.null; 221 | }); 222 | }); 223 | 224 | describe('dataErrored', () => { 225 | it('should clear the loading state, set the error state, and not change the data for all series when they return in error simultaneously', () => { 226 | state = serial(state, 227 | setSeriesIds(ALL_SERIES_IDS), 228 | dataRequested(ALL_SERIES_IDS), 229 | dataReturned(ALL_SERIES_DATA), 230 | dataRequested(ALL_SERIES_IDS), 231 | dataErrored(objectWithKeys(ALL_SERIES_IDS, ERROR)) 232 | ); 233 | 234 | expect(pickKeyedState(state)).to.deep.equal({ 235 | loadedDataBySeriesId: ALL_SERIES_DATA, 236 | loadVersionBySeriesId: objectWithKeys(ALL_SERIES_IDS, null), 237 | errorBySeriesId: objectWithKeys(ALL_SERIES_IDS, ERROR) 238 | }); 239 | }); 240 | 241 | it('should clear the loading state, set the error state, and not change the data for a single series that returns in error', () => { 242 | state = serial(state, 243 | setSeriesIds(ALL_SERIES_IDS), 244 | dataRequested(ALL_SERIES_IDS), 245 | dataReturned(ALL_SERIES_DATA), 246 | dataRequested(ALL_SERIES_IDS), 247 | dataErrored({ 248 | [SERIES_A]: ERROR 249 | }) 250 | ); 251 | 252 | expect(state.loadedDataBySeriesId).to.deep.equal(ALL_SERIES_DATA); 253 | 254 | expect(state.loadVersionBySeriesId[SERIES_A]).to.be.null; 255 | expect(state.loadVersionBySeriesId[SERIES_B]).to.not.be.null; 256 | 257 | expect(state.errorBySeriesId).to.deep.equal({ 258 | [SERIES_A]: ERROR, 259 | [SERIES_B]: null 260 | }); 261 | }); 262 | }); 263 | 264 | describe('(pass-throughs)', () => { 265 | beforeEach(() => { 266 | state = { 267 | physicalChartWidth: 0, 268 | uiState: { 269 | xDomain: DUMMY_INTERVAL, 270 | yDomainBySeriesId: { 271 | [SERIES_A]: DUMMY_INTERVAL, 272 | [SERIES_B]: DUMMY_INTERVAL 273 | }, 274 | hover: 0, 275 | selection: DUMMY_INTERVAL 276 | }, 277 | uiStateConsumerOverrides: { 278 | xDomain: DUMMY_INTERVAL, 279 | yDomainBySeriesId: { 280 | [SERIES_A]: DUMMY_INTERVAL, 281 | [SERIES_B]: DUMMY_INTERVAL 282 | }, 283 | hover: 0, 284 | selection: DUMMY_INTERVAL 285 | } 286 | } as any as ChartState; 287 | }); 288 | 289 | interface PassThroughTestCase

{ 290 | name: string; 291 | actionCreator: (payload: P) => Action

; 292 | actionValue: P; 293 | valuePath: string; 294 | } 295 | 296 | const TEST_CASES: PassThroughTestCase[] = [{ 297 | name: 'setDataLoaderDebounceTimeout', 298 | actionCreator: setDataLoaderDebounceTimeout, 299 | actionValue: 1337, 300 | valuePath: 'debounceTimeout' 301 | }, { 302 | name: 'setDataLoaderContext', 303 | actionCreator: setDataLoaderContext, 304 | actionValue: { foo: 'bar' }, 305 | valuePath: 'loaderContext' 306 | }, { 307 | name: 'setChartPhysicalWidth', 308 | actionCreator: setChartPhysicalWidth, 309 | actionValue: 1337, 310 | valuePath: 'physicalChartWidth' 311 | }, { 312 | name: 'setXDomain', 313 | actionCreator: setXDomain, 314 | actionValue: INTERVAL_A, 315 | valuePath: 'uiState.xDomain' 316 | }, { 317 | name: 'setOverrideXDomain', 318 | actionCreator: setOverrideXDomain, 319 | actionValue: INTERVAL_A, 320 | valuePath: 'uiStateConsumerOverrides.xDomain' 321 | }, { 322 | name: 'setYDomains', 323 | actionCreator: setYDomains, 324 | actionValue: ALL_INTERVALS, 325 | valuePath: 'uiState.yDomainBySeriesId' 326 | }, { 327 | name: 'setOverrideYDomains', 328 | actionCreator: setOverrideYDomains, 329 | actionValue: ALL_INTERVALS, 330 | valuePath: 'uiStateConsumerOverrides.yDomainBySeriesId' 331 | }, { 332 | name: 'setHover', 333 | actionCreator: setHover, 334 | actionValue: 1337, 335 | valuePath: 'uiState.hover' 336 | }, { 337 | name: 'setOverrideHover', 338 | actionCreator: setOverrideHover, 339 | actionValue: 1337, 340 | valuePath: 'uiStateConsumerOverrides.hover' 341 | }, { 342 | name: 'setSelection', 343 | actionCreator: setSelection, 344 | actionValue: INTERVAL_A, 345 | valuePath: 'uiState.selection' 346 | }, { 347 | name: 'setOverrideSelection', 348 | actionCreator: setOverrideSelection, 349 | actionValue: INTERVAL_A, 350 | valuePath: 'uiStateConsumerOverrides.selection' 351 | }]; 352 | 353 | _.each(TEST_CASES, test => { 354 | const DUMMY_VALUE = function() {}; 355 | 356 | describe(test.name, () => { 357 | it(`should set only the ${test.valuePath} field`, () => { 358 | const previousState = state; 359 | 360 | state = reducer(state, test.actionCreator(test.actionValue)); 361 | 362 | expect(_.get(state, test.valuePath)).to.equal(test.actionValue); 363 | 364 | _.set(previousState, test.valuePath, DUMMY_VALUE); 365 | _.set(state, test.valuePath, DUMMY_VALUE); 366 | 367 | expect(state).to.deep.equal(previousState); 368 | }); 369 | }); 370 | }); 371 | }); 372 | }); 373 | -------------------------------------------------------------------------------- /test/intervalUtils-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | enforceIntervalBounds, 5 | enforceIntervalExtent, 6 | intervalExtent, 7 | extendInterval, 8 | roundInterval, 9 | mergeIntervals, 10 | intervalContains, 11 | panInterval, 12 | zoomInterval 13 | } from '../src/core/intervalUtils'; 14 | 15 | function interval(min: number, max: number) { 16 | return { min, max }; 17 | } 18 | 19 | describe('(interval utils)', () => { 20 | describe('enforceIntervalBounds', () => { 21 | const BOUNDS = interval(0, 10); 22 | 23 | const TEST_CASES = [ 24 | { 25 | description: 'should do nothing if the interval is within bounds', 26 | input: interval(1, 9), 27 | output: interval(1, 9) 28 | }, { 29 | description: 'should slide the interval forward without changing extent if the interval is entirely before the early bound', 30 | input: interval(-3, -1), 31 | output: interval(0, 2) 32 | }, { 33 | description: 'should slide the interval forward without changing extent if the interval starts before the early bound', 34 | input: interval(-1, 1), 35 | output: interval(0, 2) 36 | }, { 37 | description: 'should slide the interval back without changing extent if the interval ends after the later bound', 38 | input: interval(9, 11), 39 | output: interval(8, 10) 40 | }, { 41 | description: 'should slide the interval back without changing extent if the interval is entirely after the later bound', 42 | input: interval(11, 13), 43 | output: interval(8, 10) 44 | }, { 45 | description: 'should align the interval and bounds by their centers if the interval is longer than the bounds and extends on both ends', 46 | input: interval(-1, 13), 47 | output: interval(-2, 12) 48 | }, { 49 | description: 'should align the interval and bounds by their centers if the interval is longer than the bounds and starts before', 50 | input: interval(-10, 2), 51 | output: interval(-1, 11) 52 | }, { 53 | description: 'should align the interval and bounds by their centers if the interval is longer than the bounds and is entirely before', 54 | input: interval(-13, -1), 55 | output: interval(-1, 11) 56 | }, { 57 | description: 'should align the interval and bounds by their centers if the interval is longer than the bounds and ends after', 58 | input: interval(1, 13), 59 | output: interval(-1, 11) 60 | }, { 61 | description: 'should align the interval and bounds by their centers if the interval is longer than the bounds and is entirely after', 62 | input: interval(11, 23), 63 | output: interval(-1, 11) 64 | }, { 65 | description: 'should do nothing if the bounds are null', 66 | input: interval(0, 10), 67 | output: interval(0, 10) 68 | } 69 | ]; 70 | 71 | TEST_CASES.forEach(test => { 72 | it(test.description, () => { 73 | expect(enforceIntervalBounds(test.input, BOUNDS)).to.deep.equal(test.output); 74 | }); 75 | }); 76 | 77 | it('should return the input interval by reference if no changes occured', () => { 78 | const input = interval(1, 9); 79 | const output = enforceIntervalBounds(input, BOUNDS); 80 | expect(output).to.equal(input); 81 | }); 82 | 83 | it('should not mutate the input interval', () => { 84 | const input = interval(-1, 1); 85 | const output = enforceIntervalBounds(input, BOUNDS); 86 | expect(input).to.deep.equal(interval(-1, 1)); 87 | expect(output).to.not.deep.equal(interval(-1, 1)); 88 | }); 89 | }); 90 | 91 | describe('enforceIntervalExtent', () => { 92 | const TEST_CASES = [ 93 | { 94 | description: 'should increase the endpoints symmetrically to match the minimum extent when the interval is too short', 95 | input: interval(1, 2), 96 | min: 5, 97 | max: 10, 98 | output: interval(-1, 4) 99 | }, { 100 | description: 'should do nothing if the interval is between the two extents', 101 | input: interval(1, 7), 102 | min: 5, 103 | max: 10, 104 | output: interval(1, 7) 105 | }, { 106 | description: 'should decrease the endpoints symmetrically to match the maximum extend when the interval is too long', 107 | input: interval(1, 15), 108 | min: 5, 109 | max: 10, 110 | output: interval(3, 13) 111 | }, { 112 | description: 'should not enforce a minimum if the min extent is null', 113 | input: interval(1, 2), 114 | min: undefined, 115 | max: 10, 116 | output: interval(1, 2) 117 | }, { 118 | description: 'should not enforce a minimum if the min extent is 0', 119 | input: interval(1, 2), 120 | min: 0, 121 | max: 10, 122 | output: interval(1, 2) 123 | }, { 124 | description: 'should not enforce a minimum if the min extent is negative', 125 | input: interval(1, 2), 126 | min: -10, 127 | max: 10, 128 | output: interval(1, 2) 129 | }, { 130 | description: 'should not enforce a maximum if the max extent is null', 131 | input: interval(1, 2), 132 | min: 0, 133 | max: undefined, 134 | output: interval(1, 2) 135 | }, { 136 | description: 'should return the midpoint of the interval if the max extent is 0', 137 | input: interval(1, 2), 138 | min: undefined, 139 | max: 0, 140 | output: interval(1.5, 1.5) 141 | }, { 142 | description: 'should not enforce a maximum if the max extent is Infinity', 143 | input: interval(1, 2), 144 | min: undefined, 145 | max: Infinity, 146 | output: interval(1, 2) 147 | }, { 148 | description: 'should do nothing if both the min and max extends are null', 149 | input: interval(1, 2), 150 | min: undefined, 151 | max: undefined, 152 | output: interval(1, 2) 153 | } 154 | ]; 155 | 156 | TEST_CASES.forEach(test => { 157 | it(test.description, () => { 158 | expect(enforceIntervalExtent(test.input, test.min, test.max)).to.deep.equal(test.output); 159 | }); 160 | }); 161 | 162 | it('should return the input interval by reference if no changes occured', () => { 163 | const input = interval(0, 10); 164 | const output = enforceIntervalExtent(input, undefined, undefined); 165 | expect(output).to.equal(input); 166 | }); 167 | 168 | it('should not mutate in the input interval', () => { 169 | const input = interval(0, 10); 170 | const output = enforceIntervalExtent(input, 1, 5); 171 | expect(input).to.deep.equal(interval(0, 10)); 172 | expect(output).to.not.deep.equal(interval(0, 10)); 173 | }); 174 | }); 175 | 176 | describe('intervalExtent', () => { 177 | it('should return the length of the interval', () => { 178 | expect(intervalExtent({ min: 0, max: 10 })).to.equal(10); 179 | }); 180 | 181 | it('should return a negative length if the interval is backwards', () => { 182 | expect(intervalExtent({ min: 10, max: 0 })).to.equal(-10); 183 | }); 184 | }); 185 | 186 | describe('extendInterval', () => { 187 | it('should increase both endpoints symmetrically as a fraction of the extent', () => { 188 | expect(extendInterval(interval(0, 10), 0.5)).to.deep.equal(interval(-5, 15)); 189 | }); 190 | 191 | it('should not mutate in the input interval', () => { 192 | const input = interval(0, 10); 193 | const output = extendInterval(input, 0.5); 194 | expect(input).to.deep.equal(interval(0, 10)); 195 | expect(output).to.not.deep.equal(interval(0, 10)); 196 | }); 197 | }); 198 | 199 | describe('roundInterval', () => { 200 | it('should round each endpoint of the interval to the nearest integer', () => { 201 | expect(roundInterval(interval(1.1, 1.9))).to.deep.equal(interval(1, 2)); 202 | }); 203 | 204 | it('should not mutate in the input interval', () => { 205 | const input = interval(1.1, 1.9); 206 | const output = roundInterval(input); 207 | expect(input).to.deep.equal(interval(1.1, 1.9)); 208 | expect(output).to.not.deep.equal(interval(1.1, 1.9)); 209 | }); 210 | }); 211 | 212 | describe('mergeIntervals', () => { 213 | it('should return undefined when a zero-length array is given', () => { 214 | expect(mergeIntervals([])).to.be.undefined; 215 | }); 216 | 217 | it('should return the default interval when a zero-length array and default is given', () => { 218 | const i = interval(0, 5); 219 | expect(mergeIntervals([], i)).to.equal(i); 220 | }); 221 | 222 | it('should return a interval with min-of-mins and max-of-maxes', () => { 223 | expect(mergeIntervals([ 224 | interval(0, 2), 225 | interval(1, 3) 226 | ])).to.deep.equal(interval(0, 3)); 227 | }); 228 | 229 | it('should not mutate the input intervals', () => { 230 | const i1 = interval(0, 2); 231 | const i2 = interval(1, 3); 232 | mergeIntervals([i1, i2]); 233 | expect(i1).to.deep.equal(interval(0, 2)); 234 | expect(i2).to.deep.equal(interval(1, 3)); 235 | }); 236 | }); 237 | 238 | describe('panInterval', () => { 239 | it('should apply the delta value to both min and max', () => { 240 | expect(panInterval({ 241 | min: 0, 242 | max: 10 243 | }, 5)).to.deep.equal({ 244 | min: 5, 245 | max: 15 246 | }); 247 | }); 248 | 249 | it('should not mutate the input interval', () => { 250 | const input = interval(0, 10); 251 | const output = panInterval(input, 5); 252 | expect(input).to.deep.equal(interval(0, 10)); 253 | expect(output).to.not.deep.equal(interval(0, 10)); 254 | }); 255 | }); 256 | 257 | describe('zoomInterval', () => { 258 | it('should zoom out when given a value less than 1', () => { 259 | expect(zoomInterval({ 260 | min: -1, 261 | max: 1 262 | }, 1 / 4, 0.5)).to.deep.equal({ 263 | min: -4, 264 | max: 4 265 | }); 266 | }); 267 | 268 | it('should zoom in when given a value greater than 1', () => { 269 | expect(zoomInterval({ 270 | min: -1, 271 | max: 1 272 | }, 4, 0.5)).to.deep.equal({ 273 | min: -1 / 4, 274 | max: 1 / 4 275 | }); 276 | }); 277 | 278 | it('should default to zooming equally on both bounds', () => { 279 | expect(zoomInterval({ 280 | min: -1, 281 | max: 1 282 | }, 1 / 4)).to.deep.equal({ 283 | min: -4, 284 | max: 4 285 | }); 286 | }); 287 | 288 | it('should bias a zoom-in towards one end when given an anchor not equal to 1/2', () => { 289 | expect(zoomInterval({ 290 | min: -1, 291 | max: 1 292 | }, 4, 1)).to.deep.equal({ 293 | min: 1 / 2, 294 | max: 1 295 | }); 296 | }); 297 | 298 | it('should bias a zoom-out towards one end when given an anchor not equal to 1/2', () => { 299 | expect(zoomInterval({ 300 | min: -1, 301 | max: 1 302 | }, 1 / 4, 1)).to.deep.equal({ 303 | min: -7, 304 | max: 1 305 | }); 306 | }); 307 | 308 | it('should not mutate the input interval', () => { 309 | const input = interval(0, 10); 310 | const output = zoomInterval(input, 1 / 2, 2); 311 | expect(input).to.deep.equal(interval(0, 10)); 312 | expect(output).to.not.deep.equal(interval(0, 10)); 313 | }); 314 | }); 315 | 316 | describe('intervalContains', () => { 317 | const SMALL_RANGE = { min: 1, max: 2 }; 318 | const BIG_RANGE = { min: 0, max: 3 }; 319 | 320 | it('should return true if the first interval is strictly larger than the second interval', () => { 321 | expect(intervalContains(BIG_RANGE, SMALL_RANGE)).to.be.true; 322 | }); 323 | 324 | it('should return true if the first interval is equal to the second interval', () => { 325 | expect(intervalContains(BIG_RANGE, BIG_RANGE)).to.be.true; 326 | }); 327 | 328 | it('should return false if the first interval is strictly smaller than the second interval', () => { 329 | expect(intervalContains(SMALL_RANGE, BIG_RANGE)).to.be.false; 330 | }); 331 | }); 332 | }); 333 | -------------------------------------------------------------------------------- /test/layers/BarLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { expect } from 'chai'; 3 | 4 | import { bar, method } from './layerTestUtils'; 5 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 6 | import { BarDatum } from '../../src/core/interfaces'; 7 | import { _renderCanvas } from '../../src/core/layers/BarLayer'; 8 | 9 | describe('BarLayer', () => { 10 | let spy: typeof CanvasContextSpy; 11 | 12 | const DEFAULT_PROPS = { 13 | xDomain: { min: 0, max: 100 }, 14 | yDomain: { min: 0, max: 100 }, 15 | color: '#000' 16 | }; 17 | 18 | beforeEach(() => { 19 | spy = new CanvasContextSpy(); 20 | }); 21 | 22 | function renderWithSpy(spy: CanvasRenderingContext2D, data: BarDatum[]) { 23 | _renderCanvas(_.defaults({ data }, DEFAULT_PROPS), 100, 100, spy); 24 | } 25 | 26 | it('should render a bar with a positive value', () => { 27 | renderWithSpy(spy, [ 28 | bar(40, 60, 33) 29 | ]); 30 | 31 | expect(spy.calls).to.deep.equal([ 32 | method('beginPath', []), 33 | method('rect', [ 40, 100, 20, -33 ]), 34 | method('fill', []) 35 | ]); 36 | }); 37 | 38 | it('should render a bar with a negative value', () => { 39 | renderWithSpy(spy, [ 40 | bar(40, 60, -33) 41 | ]); 42 | 43 | expect(spy.calls).to.deep.equal([ 44 | method('beginPath', []), 45 | method('rect', [ 40, 100, 20, 33 ]), 46 | method('fill', []) 47 | ]); 48 | }); 49 | 50 | it('should round X and Y values to the nearest integer', () => { 51 | renderWithSpy(spy, [ 52 | bar(33.4, 55.6, 84.7) 53 | ]); 54 | 55 | expect(spy.calls).to.deep.equal([ 56 | method('beginPath', []), 57 | method('rect', [ 33, 100, 23, -85 ]), 58 | method('fill', []) 59 | ]); 60 | }); 61 | 62 | it('should fill once at the end', () => { 63 | renderWithSpy(spy, [ 64 | bar(20, 40, 10), 65 | bar(60, 80, 90) 66 | ]); 67 | 68 | expect(spy.calls).to.deep.equal([ 69 | method('beginPath', []), 70 | method('rect', [ 20, 100, 20, -10 ]), 71 | method('rect', [ 60, 100, 20, -90 ]), 72 | method('fill', []) 73 | ]); 74 | }); 75 | 76 | it('should attempt to render points even if their X or Y values are NaN or infinite', () => { 77 | renderWithSpy(spy, [ 78 | bar(NaN, 50, 50), 79 | bar(50, NaN, 50), 80 | bar(50, 50, NaN), 81 | bar(-Infinity, 50, 50), 82 | bar(50, Infinity, 50), 83 | bar(50, 50, Infinity) 84 | ]); 85 | 86 | expect(spy.calls).to.deep.equal([ 87 | method('beginPath', []), 88 | method('rect', [ NaN, 100, NaN, -50 ]), 89 | method('rect', [ 50, 100, NaN, -50 ]), 90 | method('rect', [ 50, 100, 0, NaN ]), 91 | method('rect', [ -Infinity, 100, Infinity, -50 ]), 92 | method('rect', [ 50, 100, Infinity, -50 ]), 93 | method('rect', [ 50, 100, 0, -Infinity ]), 94 | method('fill', []) 95 | ]); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/layers/BucketedLineLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as d3Scale from 'd3-scale'; 3 | import { expect } from 'chai'; 4 | 5 | import { bucket, method } from './layerTestUtils'; 6 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 7 | import { BucketDatum, JoinType } from '../../src/core/interfaces'; 8 | import { _renderCanvas } from '../../src/core/layers/BucketedLineLayer'; 9 | 10 | describe('BucketedLineLayer', () => { 11 | let spy: typeof CanvasContextSpy; 12 | 13 | const DEFAULT_PROPS = { 14 | xDomain: { min: 0, max: 100 }, 15 | yDomain: { min: 0, max: 100 }, 16 | yScale: d3Scale.scaleLinear, 17 | color: '#000' 18 | }; 19 | 20 | beforeEach(() => { 21 | spy = new CanvasContextSpy(); 22 | }); 23 | 24 | function renderWithSpy(spy: CanvasRenderingContext2D, data: BucketDatum[], joinType?: JoinType) { 25 | _renderCanvas(_.defaults({ data, joinType }, DEFAULT_PROPS), 100, 100, spy); 26 | } 27 | 28 | it('should render a single rect for a single bucket', () => { 29 | renderWithSpy(spy, [ 30 | bucket(10, 25, 35, 80, 0, 0) 31 | ]); 32 | 33 | expect(spy.callsOnly('rect')).to.deep.equal([ 34 | method('rect', [ 10, 20, 15, 45 ]) 35 | ]); 36 | }); 37 | 38 | it('should round min-X up and max-X down to the nearest integer', () => { 39 | renderWithSpy(spy, [ 40 | bucket(10.4, 40.6, 0, 100, 0, 0) 41 | ]); 42 | 43 | expect(spy.callsOnly('rect')).to.deep.equal([ 44 | method('rect', [ 11, 0, 29, 100 ]) 45 | ]); 46 | }); 47 | 48 | it('should round min-Y and max-Y values down to the nearest integer', () => { 49 | renderWithSpy(spy, [ 50 | bucket(0, 100, 40.4, 60.6, 0, 0) 51 | ]); 52 | 53 | expect(spy.callsOnly('rect')).to.deep.equal([ 54 | method('rect', [ 0, 40, 100, 20 ]) 55 | ]); 56 | }); 57 | 58 | it('should always draw a rect with width at least 1, even for tiny buckets', () => { 59 | renderWithSpy(spy, [ 60 | bucket(50, 50, 0, 100, 0, 100) 61 | ]); 62 | 63 | expect(spy.callsOnly('rect')).to.deep.equal([ 64 | method('rect', [ 50, 0, 1, 100 ]) 65 | ]); 66 | }); 67 | 68 | it('should always draw a rect with height at least 1, even for tiny buckets', () => { 69 | renderWithSpy(spy, [ 70 | bucket(0, 100, 50, 50, 50, 50) 71 | ]); 72 | 73 | expect(spy.callsOnly('rect')).to.deep.equal([ 74 | method('rect', [ 0, 49, 100, 1 ]) 75 | ]); 76 | }); 77 | 78 | it('should not draw a rect for a bucket of both width and height of 1', () => { 79 | renderWithSpy(spy, [ 80 | bucket(50, 50, 50, 50, 50, 50) 81 | ]); 82 | 83 | expect(spy.callsOnly('rect')).to.deep.equal([]); 84 | }); 85 | 86 | it('should draw lines between the last and first (respectively) Y values of adjacent rects', () => { 87 | renderWithSpy(spy, [ 88 | bucket( 0, 40, 0, 100, 0, 67), 89 | bucket(60, 100, 0, 100, 45, 0) 90 | ]); 91 | 92 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 93 | method('moveTo', [ 39, 33 ]), 94 | method('lineTo', [ 60, 55 ]), 95 | method('moveTo', [ 99, 100 ]) 96 | ]); 97 | }); 98 | 99 | it('should draw an extra segment, vertical-first, when JoinType is LEADING', () => { 100 | renderWithSpy(spy, [ 101 | bucket( 0, 40, 0, 100, 0, 67), 102 | bucket(60, 100, 0, 100, 45, 0) 103 | ], JoinType.LEADING); 104 | 105 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 106 | method('moveTo', [ 39, 33 ]), 107 | method('lineTo', [ 39, 55 ]), 108 | method('lineTo', [ 60, 55 ]), 109 | method('moveTo', [ 99, 100 ]) 110 | ]); 111 | }); 112 | 113 | it('should draw an extra segment, vertical-last, when JoinType is TRAILING', () => { 114 | renderWithSpy(spy, [ 115 | bucket( 0, 40, 0, 100, 0, 67), 116 | bucket(60, 100, 0, 100, 45, 0) 117 | ], JoinType.TRAILING); 118 | 119 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 120 | method('moveTo', [ 39, 33 ]), 121 | method('lineTo', [ 60, 33 ]), 122 | method('lineTo', [ 60, 55 ]), 123 | method('moveTo', [ 99, 100 ]) 124 | ]); 125 | }); 126 | 127 | it('should round first-Y and last-Y values down to the nearest integer', () => { 128 | renderWithSpy(spy, [ 129 | bucket( 0, 40, 0, 100, 0, 67.6), 130 | bucket(60, 100, 0, 100, 45.6, 0) 131 | ]); 132 | 133 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 134 | method('moveTo', [ 39, 33 ]), 135 | method('lineTo', [ 60, 55 ]), 136 | method('moveTo', [ 99, 100 ]) 137 | ]); 138 | }); 139 | 140 | it('should clamp first-Y and last-Y values to be between the min Y and max Y - 1', () => { 141 | renderWithSpy(spy, [ 142 | bucket( 0, 40, 0, 40, 0, 100), 143 | bucket(60, 100, 60, 100, 0, 100) 144 | ]); 145 | 146 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 147 | method('moveTo', [ 39, 61 ]), 148 | method('lineTo', [ 60, 40 ]), 149 | method('moveTo', [ 99, 1 ]) 150 | ]); 151 | }); 152 | 153 | xit('should not draw lines between rects when they overlap in Y and they are separated by 0 along X', () => { 154 | renderWithSpy(spy, [ 155 | bucket( 0, 50, 0, 60, 0, 60), 156 | bucket(50, 100, 40, 100, 40, 100) 157 | ]); 158 | 159 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 160 | method('moveTo', [ 50, 40 ]), 161 | method('moveTo', [ 100, 0 ]), 162 | ]); 163 | }); 164 | 165 | xit('should not draw lines between rects when they overlap in Y and they are separated by 1 along X', () => { 166 | renderWithSpy(spy, [ 167 | bucket( 0, 50, 0, 60, 0, 60), 168 | bucket(51, 100, 40, 100, 40, 100) 169 | ]); 170 | 171 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 172 | method('moveTo', [ 50, 40 ]), 173 | method('moveTo', [ 100, 0 ]) 174 | ]); 175 | }); 176 | 177 | xit('should draw lines between rects when they do not overlap in Y and they are separated by 0 along X', () => { 178 | renderWithSpy(spy, [ 179 | bucket( 0, 50, 0, 40, 0, 40), 180 | bucket(50, 100, 60, 100, 60, 100) 181 | ]); 182 | 183 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 184 | method('moveTo', [ 50, 60 ]), 185 | method('lineTo', [ 50, 40 ]), 186 | method('moveTo', [ 100, 0 ]) 187 | ]); 188 | }); 189 | 190 | xit('should draw lines between rects when they do not overlap in Y and they are separated by 1 along X', () => { 191 | renderWithSpy(spy, [ 192 | bucket( 0, 50, 0, 40, 0, 40), 193 | bucket(51, 100, 60, 100, 60, 100) 194 | ]); 195 | 196 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 197 | method('moveTo', [ 50, 60 ]), 198 | method('lineTo', [ 51, 40 ]), 199 | method('moveTo', [ 100, 0 ]) 200 | ]); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/layers/LineLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as d3Scale from 'd3-scale'; 3 | import { expect } from 'chai'; 4 | 5 | import { point, method } from './layerTestUtils'; 6 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 7 | import { PointDatum, JoinType } from '../../src/core/interfaces'; 8 | import { _renderCanvas } from '../../src/core/layers/LineLayer'; 9 | 10 | describe('LineLayer', () => { 11 | let spy: typeof CanvasContextSpy; 12 | 13 | const DEFAULT_PROPS = { 14 | xDomain: { min: 0, max: 100 }, 15 | yDomain: { min: 0, max: 100 }, 16 | yScale: d3Scale.scaleLinear, 17 | color: '#000' 18 | }; 19 | 20 | beforeEach(() => { 21 | spy = new CanvasContextSpy(); 22 | }); 23 | 24 | function renderWithSpy(spy: CanvasRenderingContext2D, data: PointDatum[], joinType?: JoinType) { 25 | _renderCanvas(_.defaults({ data, joinType }, DEFAULT_PROPS), 100, 100, spy); 26 | } 27 | 28 | it('should not render anything if there is only one data point', () => { 29 | renderWithSpy(spy, [ 30 | point(50, 50) 31 | ]); 32 | 33 | expect(spy.calls).to.deep.equal([]); 34 | }); 35 | 36 | it('should not render anything if all the data is entirely outside the X domain', () => { 37 | renderWithSpy(spy, [ 38 | point(-100, 0), 39 | point(-50, 0) 40 | ]); 41 | 42 | expect(spy.calls).to.deep.equal([]); 43 | }); 44 | 45 | it('should render all the data if all the data fits in the X domain', () => { 46 | renderWithSpy(spy, [ 47 | point(25, 33), 48 | point(75, 50) 49 | ]); 50 | 51 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 52 | method('moveTo', [ 25, 67 ]), 53 | method('lineTo', [ 75, 50 ]) 54 | ]); 55 | }); 56 | 57 | it('should render an extra segment, vertical-first, when JoinType is LEADING', () => { 58 | renderWithSpy(spy, [ 59 | point(25, 33), 60 | point(75, 50) 61 | ], JoinType.LEADING); 62 | 63 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 64 | method('moveTo', [ 25, 67 ]), 65 | method('lineTo', [ 25, 50 ]), 66 | method('lineTo', [ 75, 50 ]) 67 | ]); 68 | }); 69 | 70 | it('should render an extra segment, vertical-last, when JoinType is TRAILING', () => { 71 | renderWithSpy(spy, [ 72 | point(25, 33), 73 | point(75, 50) 74 | ], JoinType.TRAILING); 75 | 76 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 77 | method('moveTo', [ 25, 67 ]), 78 | method('lineTo', [ 75, 67 ]), 79 | method('lineTo', [ 75, 50 ]) 80 | ]); 81 | }); 82 | 83 | it('should render all visible data plus one on each end when the data spans more than the X domain', () => { 84 | renderWithSpy(spy, [ 85 | point(-10, 5), 86 | point(-5, 10), 87 | point(50, 15), 88 | point(105, 20), 89 | point(110, 2) 90 | ]); 91 | 92 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 93 | method('moveTo', [ -5, 90 ]), 94 | method('lineTo', [ 50, 85 ]), 95 | method('lineTo', [ 105, 80 ]) 96 | ]); 97 | }); 98 | 99 | it('should round X and Y values to the nearest integer', () => { 100 | renderWithSpy(spy, [ 101 | point(34.6, 22.1), 102 | point(55.4, 84.6) 103 | ]); 104 | 105 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 106 | method('moveTo', [ 35, 78 ]), 107 | method('lineTo', [ 55, 15 ]) 108 | ]); 109 | }); 110 | 111 | it('should attempt to render points even if their X or Y values are NaN or infinite', () => { 112 | renderWithSpy(spy, [ 113 | point(0, 50), 114 | point(50, Infinity), 115 | point(Infinity, 50), 116 | point(50, NaN), 117 | point(NaN, 50), 118 | point(100, 50) 119 | ]); 120 | 121 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 122 | method('moveTo', [ 0, 50 ]), 123 | method('lineTo', [ 50, -Infinity ]), 124 | method('lineTo', [ Infinity, 50 ]), 125 | method('lineTo', [ 50, NaN ]), 126 | method('lineTo', [ NaN, 50 ]), 127 | method('lineTo', [ 100, 50 ]) 128 | ]); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/layers/PointLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as d3Scale from 'd3-scale'; 3 | import { expect } from 'chai'; 4 | 5 | import { point, method, property } from './layerTestUtils'; 6 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 7 | import { PointDatum } from '../../src/core/interfaces'; 8 | import { _renderCanvas } from '../../src/core/layers/PointLayer'; 9 | 10 | const TWO_PI = Math.PI * 2; 11 | 12 | describe('PointLayer', () => { 13 | let spy: typeof CanvasContextSpy; 14 | 15 | const DEFAULT_PROPS = { 16 | xDomain: { min: 0, max: 100 }, 17 | yDomain: { min: 0, max: 100 }, 18 | yScale: d3Scale.scaleLinear, 19 | color: '#000' 20 | }; 21 | 22 | beforeEach(() => { 23 | spy = new CanvasContextSpy(); 24 | }); 25 | 26 | function renderWithSpy(spy: CanvasRenderingContext2D, data: PointDatum[], innerRadius: number = 0) { 27 | _renderCanvas(_.defaults({ data, innerRadius, radius: 5 }, DEFAULT_PROPS), 100, 100, spy); 28 | } 29 | 30 | it('should batch everything together with one fill when innerRadius = 0', () => { 31 | renderWithSpy(spy, [ 32 | point(25, 33), 33 | point(75, 67) 34 | ], 0); 35 | 36 | expect(spy.calls).to.deep.equal([ 37 | method('beginPath', []), 38 | method('moveTo', [ 25, 67 ]), 39 | method('arc', [ 25, 67, 5, 0, TWO_PI ]), 40 | method('moveTo', [ 75, 33 ]), 41 | method('arc', [ 75, 33, 5, 0, TWO_PI ]), 42 | method('fill', []) 43 | ]); 44 | }); 45 | 46 | it('should set lineWidth once stroke each point individually when innerRadius > 0', () => { 47 | renderWithSpy(spy, [ 48 | point(25, 33), 49 | point(75, 67) 50 | ], 3); 51 | 52 | expect(spy.properties.filter(({ property }) => property === 'lineWidth')).to.deep.equal([ 53 | property('lineWidth', 2) 54 | ]); 55 | 56 | expect(spy.calls).to.deep.equal([ 57 | method('beginPath', []), 58 | method('arc', [ 25, 67, 4, 0, TWO_PI ]), 59 | method('stroke', []), 60 | 61 | method('beginPath', []), 62 | method('arc', [ 75, 33, 4, 0, TWO_PI ]), 63 | method('stroke', []) 64 | ]); 65 | }); 66 | 67 | it('should render only the data that is in within the bounds of the X domain, +/- 1', () => { 68 | renderWithSpy(spy, [ 69 | point(-100, 0), 70 | point(-50, 16), 71 | point(25, 33), 72 | point(75, 67), 73 | point(150, 95), 74 | point(200, 100) 75 | ]); 76 | 77 | expect(spy.calls).to.deep.equal([ 78 | method('beginPath', []), 79 | method('moveTo', [ -50, 84 ]), 80 | method('arc', [ -50, 84, 5, 0, TWO_PI ]), 81 | method('moveTo', [ 25, 67 ]), 82 | method('arc', [ 25, 67, 5, 0, TWO_PI ]), 83 | method('moveTo', [ 75, 33 ]), 84 | method('arc', [ 75, 33, 5, 0, TWO_PI ]), 85 | method('moveTo', [ 150, 5 ]), 86 | method('arc', [ 150, 5, 5, 0, TWO_PI ]), 87 | method('fill', []) 88 | ]); 89 | }); 90 | 91 | it('should round X and Y values to the nearest integer', () => { 92 | renderWithSpy(spy, [ 93 | point(34.6, 22.1) 94 | ]); 95 | 96 | expect(spy.calls).to.deep.equal([ 97 | method('beginPath', []), 98 | method('moveTo', [ 35, 78 ]), 99 | method('arc', [ 35, 78, 5, 0, TWO_PI ]), 100 | method('fill', []) 101 | ]); 102 | }); 103 | 104 | it('should attempt to render points even if their X or Y values are NaN or infinite', () => { 105 | renderWithSpy(spy, [ 106 | point(50, Infinity), 107 | point(Infinity, 50), 108 | point(50, NaN), 109 | point(NaN, 50) 110 | ]); 111 | 112 | expect(spy.calls).to.deep.equal([ 113 | method('beginPath', []), 114 | method('moveTo', [ 50, -Infinity ]), 115 | method('arc', [ 50, -Infinity, 5, 0, TWO_PI ]), 116 | method('moveTo', [ Infinity, 50 ]), 117 | method('arc', [ Infinity, 50, 5, 0, TWO_PI ]), 118 | method('moveTo', [ 50, NaN ]), 119 | method('arc', [ 50, NaN, 5, 0, TWO_PI ]), 120 | method('moveTo', [ NaN, 50 ]), 121 | method('arc', [ NaN, 50, 5, 0, TWO_PI ]), 122 | method('fill', []) 123 | ]); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/layers/SpanLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { expect } from 'chai'; 3 | 4 | import { method, property, span } from './layerTestUtils'; 5 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 6 | import { SpanDatum } from '../../src/core/interfaces'; 7 | import { _renderCanvas } from '../../src/core/layers/SpanLayer'; 8 | 9 | describe('SpanLayer', () => { 10 | let spy: typeof CanvasContextSpy; 11 | 12 | const DEFAULT_PROPS = { 13 | xDomain: { min: 0, max: 100 }, 14 | fillColor: '#000', 15 | borderColor: '#fff' 16 | }; 17 | 18 | beforeEach(() => { 19 | spy = new CanvasContextSpy(); 20 | }); 21 | 22 | function renderWithSpy(spy: CanvasRenderingContext2D, data: SpanDatum[]) { 23 | _renderCanvas(_.defaults({ data }, DEFAULT_PROPS), 100, 100, spy); 24 | } 25 | 26 | it('should render a rect that hides its top and bottom borders just out of view', () => { 27 | renderWithSpy(spy, [ 28 | span(25, 75) 29 | ]); 30 | 31 | expect(spy.callsOnly('rect')).to.deep.equal([ 32 | method('rect', [ 25, -1, 50, 102 ]) 33 | ]); 34 | }); 35 | 36 | it('should render span using the top-level default colors', () => { 37 | renderWithSpy(spy, [ 38 | span(25, 75) 39 | ]); 40 | 41 | expect(spy.operations).to.deep.equal([ 42 | property('lineWidth', 1), 43 | property('strokeStyle', '#fff'), 44 | method('beginPath', []), 45 | method('rect', [ 25, -1, 50, 102 ]), 46 | property('fillStyle', '#000'), 47 | method('fill', []), 48 | method('stroke', []) 49 | ]); 50 | }); 51 | 52 | it('should stroke/fill each span individually', () => { 53 | renderWithSpy(spy, [ 54 | span(10, 20), 55 | span(80, 90) 56 | ]); 57 | 58 | expect(spy.callsOnly('rect', 'fill', 'stroke')).to.deep.equal([ 59 | method('rect', [ 10, -1, 10, 102 ]), 60 | method('fill', []), 61 | method('stroke', []), 62 | 63 | method('rect', [ 80, -1, 10, 102 ]), 64 | method('fill', []), 65 | method('stroke', []) 66 | ]); 67 | }); 68 | 69 | it('should round X values to the nearest integer', () => { 70 | renderWithSpy(spy, [ 71 | span(33.4, 84.6) 72 | ]); 73 | 74 | expect(spy.callsOnly('rect')).to.deep.equal([ 75 | method('rect', [ 33, -1, 52, 102 ]) 76 | ]); 77 | }); 78 | 79 | it('should attempt to render spans even if their X values are NaN or infinite', () => { 80 | renderWithSpy(spy, [ 81 | span(NaN, 50), 82 | span(50, NaN), 83 | span(-Infinity, 50), 84 | span(50, Infinity) 85 | ]); 86 | 87 | expect(spy.callsOnly('rect')).to.deep.equal([ 88 | method('rect', [ NaN, -1, NaN, 102 ]), 89 | method('rect', [ 50, -1, NaN, 102 ]), 90 | method('rect', [ -Infinity, -1, Infinity, 102 ]), 91 | method('rect', [ 50, -1, Infinity, 102 ]) 92 | ]); 93 | }); 94 | 95 | it('should render spans at least one pixel wide even if their X values are on the same pixel', () => { 96 | renderWithSpy(spy, [ 97 | span(10, 10), 98 | span(30.02, 30.05) 99 | ]); 100 | 101 | expect(spy.callsOnly('rect')).to.deep.equal([ 102 | method('rect', [ 10, -1, 1, 102 ]), 103 | method('rect', [ 30, -1, 1, 102 ]) 104 | ]); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/layers/VerticalLineLayer-test.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { expect } from 'chai'; 3 | 4 | import { method } from './layerTestUtils'; 5 | import CanvasContextSpy from '../../src/test-util/CanvasContextSpy'; 6 | import { _renderCanvas } from '../../src/core/layers/VerticalLineLayer'; 7 | 8 | describe('VerticalLineLayer', () => { 9 | let spy: typeof CanvasContextSpy; 10 | 11 | const DEFAULT_PROPS = { 12 | xDomain: { min: 0, max: 100 }, 13 | stroke: '#000' 14 | }; 15 | 16 | beforeEach(() => { 17 | spy = new CanvasContextSpy(); 18 | }); 19 | 20 | function renderWithSpy(spy: CanvasRenderingContext2D, xValue?: number) { 21 | _renderCanvas(_.defaults({ xValue }, DEFAULT_PROPS), 100, 100, spy); 22 | } 23 | 24 | it('should do nothing if no xValue is provided', () => { 25 | renderWithSpy(spy, undefined); 26 | 27 | expect(spy.operations).to.deep.equal([]); 28 | }); 29 | 30 | it('should do nothing if the xValue is NaN', () => { 31 | renderWithSpy(spy, NaN); 32 | 33 | expect(spy.operations).to.deep.equal([]); 34 | }); 35 | 36 | it('should do nothing if the xValue is infinite', () => { 37 | renderWithSpy(spy, Infinity); 38 | 39 | expect(spy.operations).to.deep.equal([]); 40 | }); 41 | 42 | it('should do nothing if the xValue is before the X domain', () => { 43 | renderWithSpy(spy, -100); 44 | 45 | expect(spy.operations).to.deep.equal([]); 46 | }); 47 | 48 | it('should do nothing if the xValue is after the X domain', () => { 49 | renderWithSpy(spy, 200); 50 | 51 | expect(spy.operations).to.deep.equal([]); 52 | }); 53 | 54 | it('should render a value line for a xValue in bounds', () => { 55 | renderWithSpy(spy, 50); 56 | 57 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 58 | method('moveTo', [ 50, 0 ]), 59 | method('lineTo', [ 50, 100 ]) 60 | ]); 61 | }); 62 | 63 | it('should round the xValue to the integer', () => { 64 | renderWithSpy(spy, 33.4); 65 | 66 | expect(spy.callsOnly('moveTo', 'lineTo')).to.deep.equal([ 67 | method('moveTo', [ 33, 0 ]), 68 | method('lineTo', [ 33, 100 ]) 69 | ]); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/layers/layerTestUtils.ts: -------------------------------------------------------------------------------- 1 | import { PointDatum, BarDatum, SpanDatum, BucketDatum } from '../../src/core/interfaces'; 2 | import { PropertySet, MethodCall } from '../../src/test-util/CanvasContextSpy'; 3 | 4 | export function point(xValue: number, yValue: number): PointDatum { 5 | return { xValue, yValue }; 6 | } 7 | 8 | export function bar(minXValue: number, maxXValue: number, yValue: number): BarDatum { 9 | return { minXValue, maxXValue, yValue }; 10 | } 11 | 12 | export function span(minXValue: number, maxXValue: number): SpanDatum { 13 | return { minXValue, maxXValue }; 14 | } 15 | 16 | export function bucket(minXValue: number, maxXValue: number, minYValue: number, maxYValue: number, firstYValue: number, lastYValue: number): BucketDatum { 17 | return { minXValue, maxXValue, minYValue, maxYValue, firstYValue, lastYValue }; 18 | } 19 | 20 | export function property(property: string, value: any): PropertySet { 21 | return { property, value }; 22 | } 23 | 24 | export function method(method: string, args: any[]): MethodCall { 25 | return { method, arguments: args }; 26 | } 27 | -------------------------------------------------------------------------------- /test/loaderUtils-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as sinon from 'sinon'; 3 | 4 | import { Interval } from '../src/core/interfaces'; 5 | import { TBySeriesId, LoadedSeriesData, DataLoader } from '../src/connected/interfaces'; 6 | import { chainLoaders } from '../src/connected/loaderUtils'; 7 | 8 | describe('(loader utils)', () => { 9 | describe('chainLoaders', () => { 10 | const SERIES_IDS = [ 'a', 'b' ]; 11 | const X_DOMAIN: Interval = { min: 0, max: 1 }; 12 | const CHART_PIXEL_WIDTH = 100; 13 | const CURRENT_LOADED_DATA: TBySeriesId = { 14 | a: { 15 | data: [ 1, 2, 3 ], 16 | yDomain: { min: 2, max: 3 } 17 | }, 18 | b: { 19 | data: [ 4, 5, 6 ], 20 | yDomain: { min: 4, max: 5 } 21 | } 22 | }; 23 | const CONTEXT = { foo: 'bar' }; 24 | 25 | let loaderStub1: sinon.SinonStub; 26 | let loaderStub2: sinon.SinonStub; 27 | let loader: DataLoader; 28 | 29 | beforeEach(() => { 30 | loaderStub1 = sinon.stub(); 31 | loaderStub2 = sinon.stub(); 32 | loader = chainLoaders(loaderStub1, loaderStub2); 33 | }); 34 | 35 | function callWithArgs(loader: DataLoader) { 36 | return loader( 37 | SERIES_IDS, 38 | X_DOMAIN, 39 | CHART_PIXEL_WIDTH, 40 | CURRENT_LOADED_DATA, 41 | CONTEXT 42 | ); 43 | } 44 | 45 | it('should forward all parameters as-is to each loader, except series IDs', () => { 46 | loaderStub1.onFirstCall().returns({}); 47 | loaderStub2.onFirstCall().returns({}); 48 | 49 | callWithArgs(loader); 50 | 51 | const args = [ 52 | SERIES_IDS, 53 | X_DOMAIN, 54 | CHART_PIXEL_WIDTH, 55 | CURRENT_LOADED_DATA, 56 | CONTEXT 57 | ]; 58 | 59 | expect(loaderStub1.calledOnce).to.be.true; 60 | expect(loaderStub1.firstCall.args).to.deep.equal(args); 61 | expect(loaderStub2.calledOnce).to.be.true; 62 | expect(loaderStub2.firstCall.args).to.deep.equal(args); 63 | }); 64 | 65 | it('should call the loaders in order', () => { 66 | const callOrder: number[] = []; 67 | 68 | loader = chainLoaders( 69 | () => { 70 | callOrder.push(0); 71 | return {}; 72 | }, 73 | () => { 74 | callOrder.push(1); 75 | return {}; 76 | } 77 | ); 78 | 79 | callWithArgs(loader); 80 | 81 | expect(callOrder).to.deep.equal([ 0, 1 ]); 82 | }); 83 | 84 | it('should pass through only series IDs that earlier loaders didn\'t handle', () => { 85 | loaderStub1.onFirstCall().returns({ 86 | a: Promise.resolve() 87 | }); 88 | loaderStub2.onFirstCall().returns({}); 89 | 90 | callWithArgs(loader); 91 | 92 | expect(loaderStub2.calledOnce).to.be.true; 93 | expect(loaderStub2.firstCall.args[0]).to.deep.equal([ 'b' ]); 94 | }); 95 | 96 | it('should automatically create rejected promises for any unhandled series IDs', () => { 97 | loaderStub1.onFirstCall().returns({}); 98 | loaderStub2.onFirstCall().returns({}); 99 | 100 | const { a, b } = callWithArgs(loader); 101 | 102 | return Promise.all([ 103 | a.then( 104 | () => { throw new Error('promise should have been rejected'); }, 105 | () => {} 106 | ) 107 | , 108 | b.then( 109 | () => { throw new Error('promise should have been rejected'); }, 110 | () => {} 111 | ) 112 | ]); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --compilers ts:ts-node/register,tsx:ts-node/register 3 | -------------------------------------------------------------------------------- /test/reducerUtils-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | objectWithKeys, 5 | replaceValuesWithConstant, 6 | objectWithKeysFromObject 7 | } from '../src/connected/flux/reducerUtils'; 8 | 9 | describe('(reducer utils)', () => { 10 | describe('objectWithKeys', () => { 11 | it('should yield an object with the specified keys', () => { 12 | expect(objectWithKeys(['a', 'b'], true)).to.deep.equal({ 13 | a: true, 14 | b: true 15 | }); 16 | }); 17 | 18 | // _.each early-aborts when you return false, which caused an issue earlier. 19 | it('should work as expected even when the value is false', () => { 20 | expect(objectWithKeys(['a', 'b'], false)).to.deep.equal({ 21 | a: false, 22 | b: false 23 | }); 24 | }); 25 | 26 | it('should not clone the default value for each key', () => { 27 | const { a, b } = objectWithKeys(['a', 'b'], {}); 28 | expect(a).to.equal(b); 29 | }); 30 | }); 31 | 32 | describe('replaceValuesWithConstant', () => { 33 | it('should replace all the values in the given object with the given value', () => { 34 | expect(replaceValuesWithConstant({ a: 1, b: 2 }, true)).to.deep.equal({ 35 | a: true, 36 | b: true 37 | }); 38 | }); 39 | 40 | // _.each early-aborts when you return false, which caused an issue earlier. 41 | it('should work as expected even when the value is false', () => { 42 | expect(replaceValuesWithConstant({ a: 1, b: 2 }, false)).to.deep.equal({ 43 | a: false, 44 | b: false 45 | }); 46 | }); 47 | 48 | it('should not mutate the input value', () => { 49 | const input = { a: 1 }; 50 | const output = replaceValuesWithConstant(input, true); 51 | expect(input).to.not.equal(output); 52 | expect(input).to.deep.equal({ a: 1 }); 53 | }); 54 | 55 | it('should not clone the default value for each key', () => { 56 | const { a, b } = replaceValuesWithConstant({ a: 1, b: 2 }, {}); 57 | expect(a).to.equal(b); 58 | }); 59 | }); 60 | 61 | describe('objectWithKeysFromObject', () => { 62 | it('should add any missing keys using the default value', () => { 63 | expect(objectWithKeysFromObject({}, ['a'], true)).to.deep.equal({ 64 | a: true 65 | }); 66 | }); 67 | 68 | it('should remove any extraneous keys', () => { 69 | expect(objectWithKeysFromObject({ a: 1 }, [], true)).to.deep.equal({}); 70 | }); 71 | 72 | it('should add and remove keys as necessary, preferring the value of existing keys', () => { 73 | expect(objectWithKeysFromObject({ a: 1, b: 2 }, ['b', 'c'], true)).to.deep.equal({ 74 | b: 2, 75 | c: true 76 | }); 77 | }); 78 | 79 | // _.each early-aborts when you return false, which caused an issue earlier. 80 | it('should work as expected even when the value is false', () => { 81 | expect(objectWithKeysFromObject({ a: false }, ['a'], true)).to.deep.equal({ 82 | a: false 83 | }); 84 | 85 | expect(objectWithKeysFromObject({ a: true }, ['a'], false)).to.deep.equal({ 86 | a: true 87 | }); 88 | 89 | expect(objectWithKeysFromObject({}, ['a'], false)).to.deep.equal({ 90 | a: false 91 | }); 92 | }); 93 | 94 | it('should not mutate the input value', () => { 95 | const input = { a: 1 }; 96 | const output = objectWithKeysFromObject(input, ['a'], true); 97 | expect(input).to.not.equal(output); 98 | expect(input).to.deep.equal({ a: 1 }); 99 | }); 100 | 101 | it('should not clone the default value for each key', () => { 102 | const { a, b } = objectWithKeysFromObject({}, ['a', 'b'], {}); 103 | expect(a).to.equal(b); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/selectors-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | createSelectDataForHover, 4 | selectIsLoading 5 | } from '../src/connected/export-only/exportableSelectors'; 6 | import { ChartProviderState } from '../src/connected/export-only/exportableState'; 7 | import { 8 | selectData, 9 | selectHover, 10 | selectLoadedYDomains, 11 | selectSelection, 12 | selectXDomain, 13 | selectYDomains 14 | } from '../src/connected/model/selectors'; 15 | import { ChartState } from '../src/connected/model/state'; 16 | import { Interval } from '../src/core/interfaces'; 17 | 18 | describe('(selectors)', () => { 19 | const SERIES_A = 'a'; 20 | const SERIES_B = 'b'; 21 | const SERIES_C = 'c'; 22 | const INTERVAL_A: Interval = { min: 0, max: 1 }; 23 | const INTERVAL_B: Interval = { min: 2, max: 3 }; 24 | const INTERVAL_C: Interval = { min: 4, max: 5 }; 25 | const DATA_A = [{ a: true }]; 26 | 27 | describe('selectXDomain', () => { 28 | it('should use the internal value if no override is set', () => { 29 | expect(selectXDomain({ 30 | uiState: { 31 | xDomain: INTERVAL_A 32 | }, 33 | uiStateConsumerOverrides: {} 34 | } as any as ChartState)).to.equal(INTERVAL_A); 35 | }); 36 | 37 | it('should use the override if set', () => { 38 | expect(selectXDomain({ 39 | uiState: { 40 | xDomain: INTERVAL_A 41 | }, 42 | uiStateConsumerOverrides: { 43 | xDomain: INTERVAL_B 44 | } 45 | } as any as ChartState)).to.equal(INTERVAL_B); 46 | }); 47 | }); 48 | 49 | describe('selectYDomains', () => { 50 | it('should use the loaded value if no action-set value or override are set', () => { 51 | expect(selectYDomains({ 52 | loadedDataBySeriesId: { 53 | [SERIES_A]: { 54 | data: [], 55 | yDomain: INTERVAL_A 56 | } 57 | }, 58 | uiState: {}, 59 | uiStateConsumerOverrides: {} 60 | } as any as ChartState)).to.deep.equal({ 61 | [SERIES_A]: INTERVAL_A 62 | }); 63 | }); 64 | 65 | it('should use the action-set value if no override is set', () => { 66 | expect(selectYDomains({ 67 | loadedDataBySeriesId: { 68 | [SERIES_A]: { 69 | data: [], 70 | yDomain: INTERVAL_B 71 | } 72 | }, 73 | uiState: { 74 | yDomainBySeriesId: { 75 | [SERIES_A]: INTERVAL_A 76 | } 77 | }, 78 | uiStateConsumerOverrides: {} 79 | } as any as ChartState)).to.deep.equal({ 80 | [SERIES_A]: INTERVAL_A 81 | }); 82 | }); 83 | 84 | it('should use the override if all three values are set', () => { 85 | expect(selectYDomains({ 86 | loadedDataBySeriesId: { 87 | [SERIES_A]: { 88 | data: [], 89 | yDomain: INTERVAL_B 90 | } 91 | }, 92 | uiState: { 93 | yDomainBySeriesId: { 94 | [SERIES_A]: INTERVAL_B 95 | } 96 | }, 97 | uiStateConsumerOverrides: { 98 | yDomainBySeriesId: { 99 | [SERIES_A]: INTERVAL_A 100 | } 101 | } 102 | } as any as ChartState)).to.deep.equal({ 103 | [SERIES_A]: INTERVAL_A 104 | }); 105 | }); 106 | 107 | it('should use the override if set', () => { 108 | expect(selectYDomains({ 109 | loadedDataBySeriesId: { 110 | [SERIES_A]: { 111 | data: [], 112 | yDomain: INTERVAL_B 113 | } 114 | }, 115 | uiState: {}, 116 | uiStateConsumerOverrides: { 117 | yDomainBySeriesId: { 118 | [SERIES_A]: INTERVAL_A 119 | } 120 | } 121 | } as any as ChartState)).to.deep.equal({ 122 | [SERIES_A]: INTERVAL_A 123 | }); 124 | }); 125 | 126 | it('should merge subets of domains from different settings, preferring override, then action-set, then loaded', () => { 127 | expect(selectYDomains({ 128 | loadedDataBySeriesId: { 129 | [SERIES_A]: { 130 | data: [], 131 | yDomain: INTERVAL_A 132 | }, 133 | [SERIES_B]: { 134 | data: [], 135 | yDomain: INTERVAL_A 136 | } 137 | }, 138 | uiState: { 139 | yDomainBySeriesId: { 140 | [SERIES_B]: INTERVAL_B, 141 | [SERIES_C]: INTERVAL_B 142 | } 143 | }, 144 | uiStateConsumerOverrides: { 145 | yDomainBySeriesId: { 146 | [SERIES_C]: INTERVAL_C 147 | } 148 | } 149 | } as any as ChartState)).to.deep.equal({ 150 | [SERIES_A]: INTERVAL_A, 151 | [SERIES_B]: INTERVAL_B, 152 | [SERIES_C]: INTERVAL_C 153 | }); 154 | }); 155 | }); 156 | 157 | describe('selectHover', () => { 158 | it('should use the internal value if no override is set', () => { 159 | expect(selectHover({ 160 | uiState: { 161 | hover: 5 162 | }, 163 | uiStateConsumerOverrides: {} 164 | } as any as ChartState)).to.equal(5); 165 | }); 166 | 167 | it('should use the override if set', () => { 168 | expect(selectHover({ 169 | uiState: { 170 | hover: 5 171 | }, 172 | uiStateConsumerOverrides: { 173 | hover: 10 174 | } 175 | } as any as ChartState)).to.equal(10); 176 | }); 177 | 178 | it('should use the override even if it\'s set to 0', () => { 179 | expect(selectHover({ 180 | uiState: { 181 | hover: 5 182 | }, 183 | uiStateConsumerOverrides: { 184 | hover: 0 185 | } 186 | } as any as ChartState)).to.equal(0); 187 | }); 188 | 189 | it('should not exist when the override is set to \'none\'', () => { 190 | expect(selectHover({ 191 | uiState: { 192 | hover: 5 193 | }, 194 | uiStateConsumerOverrides: { 195 | hover: 'none' 196 | } 197 | } as any as ChartState)).to.not.exist; 198 | }); 199 | }); 200 | 201 | describe('selectSelection', () => { 202 | it('should use the internal value if no override is set', () => { 203 | expect(selectSelection({ 204 | uiState: { 205 | selection: INTERVAL_A 206 | }, 207 | uiStateConsumerOverrides: {} 208 | } as any as ChartState)).to.equal(INTERVAL_A); 209 | }); 210 | 211 | it('should use the override if set', () => { 212 | expect(selectSelection({ 213 | uiState: { 214 | selection: INTERVAL_A 215 | }, 216 | uiStateConsumerOverrides: { 217 | selection: INTERVAL_B 218 | } 219 | } as any as ChartState)).to.equal(INTERVAL_B); 220 | }); 221 | 222 | it('should not exist when the override is set to \'none\'', () => { 223 | expect(selectHover({ 224 | uiState: { 225 | selection: INTERVAL_A 226 | }, 227 | uiStateConsumerOverrides: { 228 | selection: 'none' 229 | } 230 | } as any as ChartState)).to.not.exist; 231 | }); 232 | }); 233 | 234 | describe('selectLoadedYDomains', () => { 235 | it('should select only the loaded Y domains even if action-set values and overrides are set', () => { 236 | expect(selectLoadedYDomains({ 237 | loadedDataBySeriesId: { 238 | [SERIES_A]: { 239 | data: [], 240 | yDomain: INTERVAL_A 241 | } 242 | }, 243 | uiState: { 244 | yDomainBySeriesId: { 245 | [SERIES_A]: INTERVAL_B 246 | } 247 | }, 248 | uiStateConsumerOverrides: { 249 | yDomainBySeriesId: { 250 | [SERIES_A]: INTERVAL_B 251 | } 252 | } 253 | } as any as ChartState)).to.deep.equal({ 254 | [SERIES_A]: INTERVAL_A 255 | }); 256 | }); 257 | }); 258 | 259 | describe('selectData', () => { 260 | it('should select only the data arrays', () => { 261 | expect(selectData({ 262 | loadedDataBySeriesId: { 263 | [SERIES_A]: { 264 | data: DATA_A, 265 | yDomain: INTERVAL_A 266 | } 267 | } 268 | } as any as ChartState)).to.deep.equal({ 269 | [SERIES_A]: DATA_A 270 | }); 271 | }); 272 | }); 273 | 274 | describe('selectIsLoading', () => { 275 | it('should convert the raw map to be a map to booleans', () => { 276 | expect(selectIsLoading({ 277 | loadVersionBySeriesId: { 278 | [SERIES_A]: null, 279 | [SERIES_B]: 'foo' 280 | } 281 | } as any as ChartProviderState)).to.deep.equal({ 282 | [SERIES_A]: false, 283 | [SERIES_B]: true 284 | }); 285 | }); 286 | }); 287 | 288 | describe('createSelectDataForHover', () => { 289 | const xValueSelector = (_seriesId: string, datum: any) => datum.x; 290 | const selectDataForHover = createSelectDataForHover(xValueSelector); 291 | 292 | const DATUM_1 = { x: 5 }; 293 | const DATUM_2 = { x: 10 }; 294 | const DATA = [ DATUM_1, DATUM_2 ]; 295 | 296 | it('should return undefineds if hover is unset', () => { 297 | expect(selectDataForHover({ 298 | loadedDataBySeriesId: { 299 | [SERIES_A]: { 300 | data: [{ x: 0}], 301 | yDomain: INTERVAL_A 302 | } 303 | }, 304 | uiState: { 305 | hover: null 306 | }, 307 | uiStateConsumerOverrides: {} 308 | } as any as ChartProviderState)).to.deep.equal({ 309 | [SERIES_A]: undefined 310 | }); 311 | }); 312 | 313 | it('should return undefineds for empty series', () => { 314 | expect(selectDataForHover({ 315 | loadedDataBySeriesId: { 316 | [SERIES_A]: { 317 | data: [], 318 | yDomain: INTERVAL_A 319 | } 320 | }, 321 | uiState: { 322 | hover: 10 323 | }, 324 | uiStateConsumerOverrides: {} 325 | } as any as ChartProviderState)).to.deep.equal({ 326 | [SERIES_A]: undefined 327 | }); 328 | }); 329 | 330 | it('should return the datum immediately preceding the hover value', () => { 331 | expect(selectDataForHover({ 332 | loadedDataBySeriesId: { 333 | [SERIES_A]: { 334 | data: DATA, 335 | yDomain: INTERVAL_A 336 | } 337 | }, 338 | uiState: { 339 | hover: 10 340 | }, 341 | uiStateConsumerOverrides: {} 342 | } as any as ChartProviderState)[SERIES_A]).to.equal(DATUM_1); 343 | }); 344 | 345 | it('should return undefined for series whose earilest datum is after the hover', () => { 346 | expect(selectDataForHover({ 347 | loadedDataBySeriesId: { 348 | [SERIES_A]: { 349 | data: DATA, 350 | yDomain: INTERVAL_A 351 | } 352 | }, 353 | uiState: { 354 | hover: 0 355 | }, 356 | uiStateConsumerOverrides: {} 357 | } as any as ChartProviderState)).to.deep.equal({ 358 | [SERIES_A]: undefined 359 | }); 360 | }); 361 | 362 | it('should return the datum immediately preceding the hover value when hover is overridden', () => { 363 | expect(selectDataForHover({ 364 | loadedDataBySeriesId: { 365 | [SERIES_A]: { 366 | data: DATA, 367 | yDomain: INTERVAL_A 368 | } 369 | }, 370 | uiState: { 371 | hover: null 372 | }, 373 | uiStateConsumerOverrides: { 374 | hover: 10 375 | } 376 | } as any as ChartProviderState)[SERIES_A]).to.equal(DATUM_1); 377 | }); 378 | }); 379 | }); 380 | -------------------------------------------------------------------------------- /tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "jsx": "react", 7 | "experimentalDecorators": true, 8 | "strictNullChecks": true, 9 | "noImplicitReturns": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "pretty": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | "declaration": true 7 | }, 8 | "exclude": [ 9 | "node_modules", 10 | "lib", 11 | "test", 12 | "examples" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig-webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base", 3 | "exclude": [ 4 | "node_modules", 5 | "lib", 6 | "test" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base", 3 | "exclude": [ 4 | "node_modules", 5 | "lib" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsRules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-duplicate-variable": true, 13 | "no-eval": true, 14 | "no-trailing-whitespace": true, 15 | "no-unsafe-finally": true, 16 | "one-line": [ 17 | true, 18 | "check-open-brace", 19 | "check-whitespace" 20 | ], 21 | "quotemark": [ 22 | true, 23 | "single" 24 | ], 25 | "semicolon": [ 26 | true, 27 | "always" 28 | ], 29 | "triple-equals": [ 30 | true, 31 | "allow-null-check" 32 | ], 33 | "variable-name": [ 34 | true, 35 | "ban-keywords" 36 | ], 37 | "whitespace": [ 38 | true, 39 | "check-branch", 40 | "check-decl", 41 | "check-operator", 42 | "check-separator", 43 | "check-type" 44 | ] 45 | }, 46 | "rules": { 47 | "class-name": true, 48 | "comment-format": [ 49 | true, 50 | "check-space" 51 | ], 52 | "indent": [ 53 | true, 54 | "spaces" 55 | ], 56 | "curly": true, 57 | "no-eval": true, 58 | "no-internal-module": true, 59 | "no-trailing-whitespace": true, 60 | "no-unsafe-finally": true, 61 | "no-var-keyword": true, 62 | "one-line": [ 63 | true, 64 | "check-open-brace", 65 | "check-whitespace" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": [ 72 | true, 73 | "always", 74 | "ignore-interfaces", 75 | "ignore-bound-class-methods" 76 | ], 77 | "triple-equals": [ 78 | true, 79 | "allow-null-check" 80 | ], 81 | "typedef-whitespace": [ 82 | true, 83 | { 84 | "call-signature": "nospace", 85 | "index-signature": "nospace", 86 | "parameter": "nospace", 87 | "property-declaration": "nospace", 88 | "variable-declaration": "nospace" 89 | } 90 | ], 91 | "variable-name": [ 92 | true, 93 | "ban-keywords" 94 | ], 95 | "whitespace": [ 96 | true, 97 | "check-branch", 98 | "check-decl", 99 | "check-operator", 100 | "check-separator", 101 | "check-type" 102 | ] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /webpack.config.hot.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const webpack = require('webpack'); 3 | 4 | const config = require('./webpack.config.js'); 5 | 6 | if (!_.get(config, 'entry.index')) { 7 | throw new Error('root config seems to have changed and is missing an index entry'); 8 | } 9 | 10 | config.entry.index = _.flatten([ 11 | 'webpack/hot/only-dev-server', 12 | config.entry.index 13 | ]); 14 | 15 | config.module.loaders.forEach(loaderConf => { 16 | if (loaderConf.loader.slice(0, 2) === 'ts') { 17 | loaderConf.loader = 'react-hot!' + loaderConf.loader; 18 | } 19 | }); 20 | 21 | module.exports = config; 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const WebpackNotifierPlugin = require('webpack-notifier'); 5 | 6 | const VENDOR_LIBS = _.keys(require('./package.json').dependencies); 7 | 8 | module.exports = { 9 | entry: { 10 | index: './examples/index.tsx', 11 | vendor: VENDOR_LIBS 12 | }, 13 | output: { 14 | path: './examples/build', 15 | publicPath: '/', 16 | filename: '[name].js' 17 | }, 18 | module: { 19 | loaders: [ 20 | { test: /\.tsx?$/, loader: 'ts?configFileName=tsconfig-webpack.json' }, 21 | { test: /node_modules.*\.js$/, loader: 'source-map-loader' }, 22 | { test: /\.css$/, loader: 'style!css' }, 23 | { test: /\.less/, loader: 'style!css?sourceMap!less?sourceMap' } 24 | ] 25 | }, 26 | resolve: { 27 | extensions: ['', '.ts', '.tsx', '.js'] 28 | }, 29 | devtool: 'source-map', 30 | plugins: [ 31 | new webpack.optimize.CommonsChunkPlugin({ 32 | name: 'vendor' 33 | }), 34 | new HtmlWebpackPlugin({ 35 | template: './examples/index-template.html', 36 | filename: 'index.html', 37 | chunks: ['index', 'vendor'] 38 | }), 39 | new WebpackNotifierPlugin({ 40 | title: 'react-layered-chart' 41 | }) 42 | ] 43 | }; 44 | --------------------------------------------------------------------------------