, 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 |
--------------------------------------------------------------------------------