From 2fcc04c9c159269d021f52689bcfa3134696b1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Zag=C3=B3rski?= Date: Fri, 29 Sep 2023 02:20:01 +0200 Subject: [PATCH] fix filtering when changing data --- .../widgets/TimeSeriesWidgetUI.test.js | 43 +------- .../TimeSeriesWidgetUI/TimeSeriesWidgetUI.js | 104 +++++++++++++----- .../components/TimeSeriesControls.js | 42 +++---- .../hooks/TimeSeriesContext.js | 43 ++------ .../hooks/useTimeSeriesInteractivity.js | 49 +++------ .../TimeSeriesWidgetUI/utils/utilities.js | 37 +++++++ .../widgetsUI/TimeSeriesWidgetUI.stories.js | 2 +- .../src/widgets/TimeSeriesWidget.js | 90 ++++++++++++--- 8 files changed, 236 insertions(+), 174 deletions(-) diff --git a/packages/react-ui/__tests__/widgets/TimeSeriesWidgetUI.test.js b/packages/react-ui/__tests__/widgets/TimeSeriesWidgetUI.test.js index 6ea4c6a9e..936054ddb 100644 --- a/packages/react-ui/__tests__/widgets/TimeSeriesWidgetUI.test.js +++ b/packages/react-ui/__tests__/widgets/TimeSeriesWidgetUI.test.js @@ -82,7 +82,7 @@ describe('TimeSeriesWidgetUI', () => { expect(onTimelineUpdate).toBeCalled(); }); - test('renders with initial timeline position', () => { + test('renders with initial timeline position', async () => { const onTimelineUpdate = jest.fn(); render( @@ -96,7 +96,7 @@ describe('TimeSeriesWidgetUI', () => { expect(screen.queryByTestId('pause-icon')).not.toBeInTheDocument(); expect(screen.queryByTestId('stop')).toBeInTheDocument(); expect(screen.queryByTestId('play-icon')).toBeInTheDocument(); - expect(onTimelineUpdate).toHaveBeenCalledWith(2); + await waitFor(() => expect(onTimelineUpdate).toHaveBeenCalledWith(2)); }); test('plays when play button is fired', () => { @@ -129,45 +129,6 @@ describe('TimeSeriesWidgetUI', () => { expect(onPause).toBeCalled(); }); - test('updates data cause reset component', () => { - const NEW_DATA = [ - { name: 1514761200000, value: 310 }, - { name: 1515366000000, value: 406 }, - { name: 1515970800000, value: 387 }, - { name: 1516575600000, value: 471 } - ]; - - const onTimelineUpdate = jest.fn(); - const onStop = jest.fn(); - - const { rerender } = render( - - ); - - rerender( - - ); - - expect(screen.queryByTestId('pause-icon')).not.toBeInTheDocument(); - expect(screen.queryByTestId('stop')).toBeInTheDocument(); - expect(screen.queryByTestId('play-icon')).toBeInTheDocument(); - expect(onTimelineUpdate).toHaveBeenCalledWith(0); - // Wait a second, because onStop is called with a certain delay - setTimeout(() => expect(onStop).toBeCalled()); - jest.runOnlyPendingTimers(); - }); - test('updates internal state from outside correctly', () => { const { rerender } = render(); expect(screen.queryByTestId('pause-icon')).toBeInTheDocument(); diff --git a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI.js b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI.js index 224ea1da1..9c89792aa 100644 --- a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI.js +++ b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI.js @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; import { Box, Link, useTheme } from '@mui/material'; import PropTypes from 'prop-types'; @@ -15,6 +15,7 @@ import { commonPalette } from '../../theme/sections/palette'; import { TimeSeriesControls } from './components/TimeSeriesControls'; import TimeSeriesLayout from './components/TimeSeriesLayout'; import ChartLegend from '../ChartLegend'; +import { findNearestPointInData, getDate } from './utils/utilities'; function TimeSeriesWidgetUI({ data, @@ -30,9 +31,9 @@ function TimeSeriesWidgetUI({ fitHeight, showControls, animation, - timelinePosition, onTimelineUpdate, timeWindow, + timelinePosition, onTimeWindowUpdate, selectedCategories, onSelectedCategoriesChange, @@ -45,6 +46,35 @@ function TimeSeriesWidgetUI({ palette, showLegend }) { + let prevEmittedTimeWindow = useRef(); + const handleTimeWindowUpdate = useCallback( + (timeWindow) => { + if (timeWindow.length === 2) { + if (prevEmittedTimeWindow.current?.length === 1) { + onTimelineUpdate?.(undefined); + } + const sorted = timeWindow + .sort((timeA, timeB) => (timeA < timeB ? -1 : 1)) + .map(getDate); + onTimeWindowUpdate?.(sorted); + } + + if (timeWindow.length === 1) { + if (prevEmittedTimeWindow.current?.length === 2) { + onTimeWindowUpdate?.([]); + } + const position = findNearestPointInData(timeWindow[0], data); + onTimelineUpdate?.(position); + } + + prevEmittedTimeWindow.current = timeWindow; + + // Only executed when timeWindow changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [onTimeWindowUpdate, onTimelineUpdate, data] + ); + const content = isLoading ? ( ); @@ -81,10 +112,8 @@ function TimeSeriesWidgetUI({ isPaused={isPaused} onPause={onPause} onStop={onStop} - timelinePosition={timelinePosition} - onTimelineUpdate={onTimelineUpdate} timeWindow={timeWindow} - onTimeWindowUpdate={onTimeWindowUpdate} + onTimeWindowUpdate={handleTimeWindowUpdate} > {content} @@ -134,8 +163,6 @@ TimeSeriesWidgetUI.defaultProps = { animation: true, isPlaying: false, isPaused: false, - timelinePosition: 0, - timeWindow: [], showControls: true, isLoading: false, palette: Object.values(commonPalette.qualitative.bold) @@ -162,13 +189,41 @@ function TimeSeriesWidgetUIContent({ palette, selectedCategories, onSelectedCategoriesChange, - showLegend + showLegend, + timelinePosition }) { const theme = useTheme(); const fallbackColor = theme.palette.secondary.main; - const { isPlaying, isPaused, timeWindow, timelinePosition, stop } = - useTimeSeriesContext(); + const { isPlaying, isPaused, timeWindow, stop, setTimeWindow } = useTimeSeriesContext(); + + useEffect(() => { + if (timelinePosition !== undefined) { + if (timelinePosition < 0 || timelinePosition >= data.length) return; + + const timeAtSelectedPosition = data[timelinePosition].name; + setTimeWindow([timeAtSelectedPosition]); + } else if (data.length > 0 && timeWindow.length === 0) { + setTimeWindow([data[0].name]); + } + // ignore timeWindow, as we're only expecting to change when external data changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timelinePosition, data]); + + useEffect(() => { + const start = data[0].name; + const end = data[data.length - 1].name; + if ( + timeWindow[0] < start || + timeWindow[1] > end || + timeWindow[1] < start || + timeWindow[1] > end + ) { + setTimeWindow([]); + } + // only run on data updates to cross-check that time-window isn't out-of bounds + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); const series = useMemo(() => { const colorMapping = {}; @@ -207,7 +262,7 @@ function TimeSeriesWidgetUIContent({ } // If timeWindow is activated - if (timeWindow.length) { + if (timeWindow.length === 2) { const [start, end] = timeWindow.map((time) => new Date(time)); return formatTimeRange({ start, end, stepSize }); } @@ -220,12 +275,11 @@ function TimeSeriesWidgetUIContent({ return formatTimeRange({ start, end, stepSize }); } - // If animation is active - if (timelinePosition >= 0 && data[timelinePosition]) { - const currentDate = new Date(data[timelinePosition].name); - return formatBucketRange({ date: currentDate, stepSize, stepMultiplier }); + if (timeWindow.length === 1) { + const date = new Date(timeWindow[0]); + return formatBucketRange({ date, stepSize, stepMultiplier }); } - }, [data, timeWindow, isPlaying, isPaused, timelinePosition, stepSize, stepMultiplier]); + }, [data, timeWindow, isPlaying, isPaused, stepSize, stepMultiplier]); const showClearButton = useMemo(() => { return ( @@ -233,8 +287,9 @@ function TimeSeriesWidgetUIContent({ ); }, [isPaused, isPlaying, selectedCategories?.length, timeWindow.length]); - const handleStop = () => { + const handleClear = () => { stop(); + setTimeWindow([]); onSelectedCategoriesChange?.([]); }; @@ -262,18 +317,17 @@ function TimeSeriesWidgetUIContent({ const header = ( <> - {!!currentDate && ( - - - {currentDate} - - - )} + + + {currentDate || '-'} + + + {showClearButton && ( Clear diff --git a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/components/TimeSeriesControls.js b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/components/TimeSeriesControls.js index 92da23767..98a2272c5 100644 --- a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/components/TimeSeriesControls.js +++ b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/components/TimeSeriesControls.js @@ -4,6 +4,7 @@ import { Box, Menu, IconButton, MenuItem, SvgIcon } from '@mui/material'; import { GroupDateTypes } from '@carto/react-core'; import Typography from '../../../components/atoms/Typography'; import { useTimeSeriesContext } from '../hooks/TimeSeriesContext'; +import { countDistinctTimePoints, findNearestPointInData } from '../utils/utilities'; // TimeWindow step is the amount of time (in seconds) that pass in every iteration during the animation. // It depends on step size for a better animation speed adjustment. @@ -24,16 +25,8 @@ export function TimeSeriesControls({ data, stepSize }) { const [speed, setSpeed] = useState(1); const animationRef = useRef({ animationFrameId: null, timeoutId: null }); - const { - isPlaying, - isPaused, - timeWindow, - timelinePosition, - setTimelinePosition, - setTimeWindow, - stop, - togglePlay - } = useTimeSeriesContext(); + const { isPlaying, isPaused, timeWindow, setTimeWindow, stop, togglePlay } = + useTimeSeriesContext(); // If data changes, stop animation. useDidMountEffect is used to avoid // being executed in the initial rendering because that cause @@ -92,13 +85,13 @@ export function TimeSeriesControls({ data, stepSize }) { // Running timeline useEffect(() => { - if (isPlaying && !timeWindow.length && data.length) { + if (isPlaying && timeWindow.length === 1 && data.length) { animateTimeline({ speed, - timelinePosition, + timelinePosition: timeWindow[0], data, drawFrame: (newTimelinePosition) => { - setTimelinePosition(newTimelinePosition); + setTimeWindow([newTimelinePosition]); }, onEnd: () => { setTimeout(handleStop, 250); @@ -107,9 +100,11 @@ export function TimeSeriesControls({ data, stepSize }) { }); return () => stopAnimation(); + } else if (isPlaying && timeWindow.length === 0) { + setTimeWindow([data[0].name]); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, isPlaying, speed, timeWindow.length, handleStop, setTimelinePosition]); + }, [data, isPlaying, speed, timeWindow.length, handleStop]); const handleOpenSpeedMenu = (e) => { if (e?.currentTarget) { @@ -282,11 +277,13 @@ function animateTimeline({ onEnd, animationRef }) { - let currentTimeline = timelinePosition; + let currentDataIndex = findNearestPointInData(timelinePosition, data) || 0; + let currentTime = data[currentDataIndex].name; + const numberOfTimePoints = countDistinctTimePoints(data); const fpsToUse = Math.max( - Math.round(Math.sqrt(data.length) / 2), // FPS based on data length + Math.round(Math.sqrt(numberOfTimePoints) / 2), // FPS based on number of unique time points MIN_FPS // Min FPS ) * speed; @@ -297,11 +294,18 @@ function animateTimeline({ }; const animate = () => { - currentTimeline = Math.min(data.length, currentTimeline + 1); - if (currentTimeline === data.length) { + // // search for next time different than previous one + for (; currentDataIndex < data.length; currentDataIndex++) { + const itemTime = data[currentDataIndex].name; + if (itemTime !== currentTime) { + currentTime = itemTime; + break; + } + } + if (currentDataIndex === data.length) { onEnd(); } else { - drawFrame(currentTimeline); + drawFrame(currentTime); fireAnimation(); } }; diff --git a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/hooks/TimeSeriesContext.js b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/hooks/TimeSeriesContext.js index ac920842f..7ad8b553f 100644 --- a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/hooks/TimeSeriesContext.js +++ b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/hooks/TimeSeriesContext.js @@ -15,9 +15,6 @@ export const TimeSeriesContext = createContext({ setIsPaused: (value) => {}, onPause: () => {}, onStop: () => {}, - timelinePosition: 0, - setTimelinePosition: (value) => {}, - onTimelineUpdate: (value) => {}, timeWindow: [], setTimeWindow: (value) => {}, onTimeWindowUpdate: (value) => {}, @@ -36,33 +33,19 @@ export function TimeSeriesProvider({ onPlay, onPause, onStop, - timelinePosition, - onTimelineUpdate, timeWindow, onTimeWindowUpdate }) { const [_isPlaying, setIsPlaying] = useState(isPlaying); const [_isPaused, setIsPaused] = useState(isPaused); - const [_timelinePosition, setTimelinePosition] = useState(0); const [_timeWindow, setTimeWindow] = useState([]); useEffect(() => { - if (_timeWindow.length === 2 && onTimeWindowUpdate) { - onTimeWindowUpdate( - _timeWindow.sort((timeA, timeB) => (timeA < timeB ? -1 : 1)).map(getDate) - ); - } - // Only executed when timeWindow changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [_timeWindow]); - - useEffect(() => { - if (!_timeWindow.length && (_isPlaying || _isPaused) && onTimelineUpdate) { - onTimelineUpdate(_timelinePosition); - } - // Only executed when timelinePosition changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [_timelinePosition, _isPlaying, _isPaused, _timeWindow]); + const sorted = _timeWindow + .sort((timeA, timeB) => (timeA < timeB ? -1 : 1)) + .map(getDate); + onTimeWindowUpdate?.(sorted); + }, [_timeWindow, onTimeWindowUpdate]); const togglePlay = useCallback(() => { if (_isPlaying) { @@ -79,7 +62,6 @@ export function TimeSeriesProvider({ const stopWrapper = useCallback(() => { setIsPlaying(false); setIsPaused(false); - setTimelinePosition(0); setTimeWindow([]); }, []); @@ -109,25 +91,19 @@ export function TimeSeriesProvider({ }, [isPaused]); useEffect(() => { - if (timelinePosition !== _timelinePosition) { - setTimelinePosition(timelinePosition); - } - // Only when the state out of the context changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timelinePosition]); + if (!timeWindow) return; - useEffect(() => { const timeWindowTimestamp = timeWindow.map(getTimestamp); const _timeWindowTimestamp = _timeWindow.map(getTimestamp); if ( - timeWindowTimestamp[0] !== _timeWindowTimestamp[0] && + timeWindowTimestamp[0] !== _timeWindowTimestamp[0] || timeWindowTimestamp[1] !== _timeWindowTimestamp[1] ) { setTimeWindow(timeWindowTimestamp); } // Only when the state out of the context changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [...timeWindow]); + }, timeWindow); return ( - findTimelinePositionByX(item, idx, data, x) - ); - setTimelinePosition(Math.max(0, position)); + const position = findNearestPointInData(x, data); + setTimeWindow(position !== undefined ? [data[position].name] : []); } }, - [data, echartsInstance, setTimelinePosition] + [data, echartsInstance, setTimeWindow] ); // Echarts events @@ -196,10 +187,15 @@ export default function useTimeSeriesInteractivity({ // markLine in echarts const timelineOptions = useMemo(() => { - const xAxis = data?.[Math.max(0, timelinePosition)]?.name; + if (timeWindow.length !== 1) return undefined; + + const timestamp = timeWindow[0]; + const nearestPosition = findNearestPointInData(timestamp, data); + if (nearestPosition === undefined) return undefined; + + const xAxis = data[nearestPosition]?.name; return ( // Cannot have markLine and markArea at the same time - !timeWindow.length && xAxis !== undefined && { symbol: ['none', 'none'], animationDuration: 100, @@ -230,7 +226,7 @@ export default function useTimeSeriesInteractivity({ : [] } ); - }, [isPaused, isPlaying, data, theme, timelinePosition, timeWindow]); + }, [data, isPaused, isPlaying, theme, timeWindow]); // markArea in echarts const timeWindowOptions = useMemo( @@ -259,20 +255,3 @@ function addEventWithCleanUp(zr, eventKey, event) { }; } } - -function findTimelinePositionByX(item, idx, data, x) { - const currentDate = item.name; - const upperCloseDate = data[idx + 1]?.name; - const lowerCloseDate = data[idx - 1]?.name; - const upperDiff = Math.abs(currentDate - upperCloseDate); - const lowerDiff = Math.abs(currentDate - lowerCloseDate); - const lowerBound = currentDate - lowerDiff * 0.5, - upperBound = currentDate + upperDiff * 0.5; - if (isFinite(lowerBound) && isFinite(upperBound)) { - return x >= lowerBound && x <= upperBound; - } else if (isFinite(lowerBound)) { - return x >= lowerBound; - } else if (isFinite(upperBound)) { - return x <= upperBound; - } -} diff --git a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/utils/utilities.js b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/utils/utilities.js index 5854b49a0..9cfdbda58 100644 --- a/packages/react-ui/src/widgets/TimeSeriesWidgetUI/utils/utilities.js +++ b/packages/react-ui/src/widgets/TimeSeriesWidgetUI/utils/utilities.js @@ -14,3 +14,40 @@ export function getDate(date) { if (date.getTime) return date; return new Date(date); } + +export function findNearestPointInData(timestamp, data) { + for (let idx = 0; idx < data.length; idx++) { + const currentDate = data[idx].name; + const upperCloseDate = idx < data.length ? data[idx + 1]?.name : currentDate; + const lowerCloseDate = idx > 0 ? data[idx - 1]?.name : currentDate; + const upperDiff = Math.abs(currentDate - upperCloseDate); + const lowerDiff = Math.abs(currentDate - lowerCloseDate); + const lowerBound = currentDate - lowerDiff * 0.5, + upperBound = currentDate + upperDiff * 0.5; + + if (isFinite(lowerBound) && isFinite(upperBound)) { + if (timestamp >= lowerBound && timestamp <= upperBound) { + return idx; + } + } else if (isFinite(lowerBound)) { + if (timestamp >= lowerBound) { + return idx; + } + } else if (isFinite(upperBound)) { + if (timestamp <= upperBound) { + return idx; + } + } + } +} + +export function countDistinctTimePoints(data) { + let lastTime = undefined; + let result = 0; + for (const { name: time } of data) { + if (time === lastTime) continue; + lastTime = time; + result++; + } + return result; +} diff --git a/packages/react-ui/storybook/stories/widgetsUI/TimeSeriesWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/TimeSeriesWidgetUI.stories.js index 51942a6b8..7cc0771f9 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/TimeSeriesWidgetUI.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/TimeSeriesWidgetUI.stories.js @@ -158,7 +158,7 @@ const options = { 'Event emitted when timeline is updated. TimeSeriesWidget is responsible of applying the filter.' }, timeWindow: { - description: `Array of two UNIX timestamp (ms) that indicates the start and end of a frame to filter data. Example: [${data[0].name}, ${data[5].name}]. + description: `Array of UNIX timestamp (ms) that indicates selected time. One entry mean one bucket selected, two entries mean start and end of a frame to filter data. Example: [${data[0].name}, ${data[5].name}]. [Internal state] This prop is used to managed state outside of the component.`, control: { type: 'array', expanded: true } }, diff --git a/packages/react-widgets/src/widgets/TimeSeriesWidget.js b/packages/react-widgets/src/widgets/TimeSeriesWidget.js index 3716aa0be..2b6dbc723 100644 --- a/packages/react-widgets/src/widgets/TimeSeriesWidget.js +++ b/packages/react-widgets/src/widgets/TimeSeriesWidget.js @@ -166,15 +166,8 @@ function TimeSeriesWidget({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [stepSize]); - const { - data: result = [], - isLoading, - warning, - remoteCalculation - } = useWidgetFetch(getTimeSeries, { - id, - dataSource, - params: { + const widgetParams = useMemo( + () => ({ series, column, joinOperation, @@ -185,7 +178,29 @@ function TimeSeriesWidget({ splitByCategory, splitByCategoryLimit, splitByCategoryValues - }, + }), + [ + series, + column, + joinOperation, + selectedStepSize, + stepMultiplier, + operation, + operationColumn, + splitByCategory, + splitByCategoryLimit, + splitByCategoryValues + ] + ); + const { + data: result = [], + isLoading, + warning, + remoteCalculation + } = useWidgetFetch(getTimeSeries, { + id, + dataSource, + params: widgetParams, global, onError, onStateChange, @@ -196,6 +211,24 @@ function TimeSeriesWidget({ ? { data: result, categories: undefined } : { data: result.rows, categories: result.categories }; + // clean filter + useEffect(() => { + return () => { + removeFilter({ + id: dataSource, + column, + owner: id + }); + if (widgetParams.splitByCategory) { + removeFilter({ + id: dataSource, + column: widgetParams.splitByCategory, + owner: id + }); + } + }; + }, [column, dataSource, id, widgetParams]); + const minTime = useMemo( () => data.reduce((acc, { name }) => (name < acc ? name : acc), Number.MAX_VALUE), [data] @@ -203,7 +236,9 @@ function TimeSeriesWidget({ const handleTimeWindowUpdate = useCallback( (timeWindow) => { - if (!isLoading) { + if (isLoading) return; + + if (timeWindow.length === 2) { dispatch( addFilter({ id: dataSource, @@ -214,17 +249,29 @@ function TimeSeriesWidget({ owner: id }) ); - - if (onTimeWindowUpdate) onTimeWindowUpdate(timeWindow); + } else { + dispatch( + removeFilter({ + id: dataSource, + column, + owner: id + }) + ); } + + if (onTimeWindowUpdate) onTimeWindowUpdate(timeWindow); }, [isLoading, dispatch, dataSource, column, minTime, id, onTimeWindowUpdate] ); const handleTimelineUpdate = useCallback( (timelinePosition) => { - if (!isLoading) { - const { name: moment } = data[timelinePosition]; + if (isLoading) return; + + const moment = + timelinePosition !== undefined ? data[timelinePosition]?.name : undefined; + + if (moment) { dispatch( addFilter({ id: dataSource, @@ -237,9 +284,17 @@ function TimeSeriesWidget({ owner: id }) ); - - if (onTimelineUpdate) onTimelineUpdate(new Date(moment)); + } else { + dispatch( + removeFilter({ + id: dataSource, + column, + owner: id + }) + ); } + + if (onTimelineUpdate) onTimelineUpdate(moment ? new Date(moment) : undefined); }, [ isLoading, @@ -454,7 +509,6 @@ TimeSeriesWidget.defaultProps = { animation: true, isPlaying: false, isPaused: false, - // timelinePosition: 0, timeWindow: [], showControls: true, chartType: TIME_SERIES_CHART_TYPES.LINE