From ed0192104cc12db5d9c1144d86b94227e66eaa1a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 17 Apr 2019 10:51:50 -0700 Subject: [PATCH] refactor: Merge PanelChrome and DataPanel, do query execution in PanelQueryRunner (#16632) Moves query execution logic to PanelQueryRunner and structures PanelChrome so it subscribes to the query results rather than necessarily controlling their execution. --- packages/grafana-ui/src/types/panel.ts | 5 +- .../dashboard/dashgrid/DashboardPanel.tsx | 26 +- .../features/dashboard/dashgrid/DataPanel.tsx | 253 --------------- .../dashboard/dashgrid/PanelChrome.test.tsx | 4 +- .../dashboard/dashgrid/PanelChrome.tsx | 295 ++++++++++-------- .../PanelQueryRunner.test.ts} | 27 +- .../dashboard/state/PanelQueryRunner.ts | 208 ++++++++++++ 7 files changed, 401 insertions(+), 417 deletions(-) delete mode 100644 public/app/features/dashboard/dashgrid/DataPanel.tsx rename public/app/features/dashboard/{dashgrid/DataPanel.test.tsx => state/PanelQueryRunner.test.ts} (66%) create mode 100644 public/app/features/dashboard/state/PanelQueryRunner.ts diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 81cc653ee43..da19c0040e3 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,7 +1,7 @@ import { ComponentClass } from 'react'; import { LoadingState, SeriesData } from './data'; import { TimeRange } from './time'; -import { ScopedVars, DataRequestInfo, DataQueryError } from './datasource'; +import { ScopedVars, DataRequestInfo, DataQueryError, LegacyResponseData } from './datasource'; export type InterpolateFunction = (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; @@ -10,6 +10,9 @@ export interface PanelData { series: SeriesData[]; request?: DataRequestInfo; error?: DataQueryError; + + // Data format expected by Angular panels + legacy?: LegacyResponseData[]; } export interface PanelProps { diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index 8f03bef954b..dcb78a8cdb2 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -19,6 +19,7 @@ import { PanelResizer } from './PanelResizer'; import { PanelModel, DashboardModel } from '../state'; import { PanelPlugin } from 'app/types'; import { AngularPanelPlugin, ReactPanelPlugin } from '@grafana/ui/src/types/panel'; +import { AutoSizer } from 'react-virtualized'; export interface Props { panel: PanelModel; @@ -153,13 +154,24 @@ export class DashboardPanel extends PureComponent { const { plugin } = this.state; return ( - + + {({ width, height }) => { + if (width === 0) { + return null; + } + return ( + + ); + }} + ); } diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx deleted file mode 100644 index a68a2aea9a0..00000000000 --- a/public/app/features/dashboard/dashgrid/DataPanel.tsx +++ /dev/null @@ -1,253 +0,0 @@ -// Library -import React, { Component } from 'react'; - -// Services -import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -// Utils -import kbn from 'app/core/utils/kbn'; -// Types -import { - DataQueryError, - LoadingState, - SeriesData, - TimeRange, - ScopedVars, - toSeriesData, - guessFieldTypes, - DataQuery, - PanelData, - DataRequestInfo, -} from '@grafana/ui'; - -interface RenderProps { - data: PanelData; -} - -export interface Props { - datasource: string | null; - queries: DataQuery[]; - panelId: number; - dashboardId?: number; - isVisible?: boolean; - timeRange?: TimeRange; - widthPixels: number; - refreshCounter: number; - minInterval?: string; - maxDataPoints?: number; - scopedVars?: ScopedVars; - children: (r: RenderProps) => JSX.Element; - onDataResponse?: (data?: SeriesData[]) => void; - onError: (message: string, error: DataQueryError) => void; -} - -export interface State { - isFirstLoad: boolean; - data: PanelData; -} - -/** - * All panels will be passed tables that have our best guess at colum type set - * - * This is also used by PanelChrome for snapshot support - */ -export function getProcessedSeriesData(results?: any[]): SeriesData[] { - if (!results) { - return []; - } - - const series: SeriesData[] = []; - for (const r of results) { - if (r) { - series.push(guessFieldTypes(toSeriesData(r))); - } - } - return series; -} - -export class DataPanel extends Component { - static defaultProps = { - isVisible: true, - dashboardId: 1, - }; - - dataSourceSrv: DatasourceSrv = getDatasourceSrv(); - isUnmounted = false; - - constructor(props: Props) { - super(props); - - this.state = { - isFirstLoad: true, - data: { - state: LoadingState.NotStarted, - series: [], - }, - }; - } - - componentDidMount() { - this.issueQueries(); - } - - componentWillUnmount() { - this.isUnmounted = true; - } - - async componentDidUpdate(prevProps: Props) { - if (!this.hasPropsChanged(prevProps)) { - return; - } - - this.issueQueries(); - } - - hasPropsChanged(prevProps: Props) { - return this.props.refreshCounter !== prevProps.refreshCounter; - } - - private issueQueries = async () => { - const { - isVisible, - queries, - datasource, - panelId, - dashboardId, - timeRange, - widthPixels, - maxDataPoints, - scopedVars, - onDataResponse, - onError, - } = this.props; - - if (!isVisible) { - return; - } - - if (!queries.length) { - this.setState({ - data: { - state: LoadingState.Done, - series: [], - }, - }); - return; - } - - this.setState({ - data: { - ...this.state.data, - loading: LoadingState.Loading, - }, - }); - - try { - const ds = await this.dataSourceSrv.get(datasource, scopedVars); - - const minInterval = this.props.minInterval || ds.interval; - const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval); - - // make shallow copy of scoped vars, - // and add built in variables interval and interval_ms - const scopedVarsWithInterval = Object.assign({}, scopedVars, { - __interval: { text: intervalRes.interval, value: intervalRes.interval }, - __interval_ms: { text: intervalRes.intervalMs.toString(), value: intervalRes.intervalMs }, - }); - - const request: DataRequestInfo = { - timezone: 'browser', - panelId: panelId, - dashboardId: dashboardId, - range: timeRange, - rangeRaw: timeRange.raw, - interval: intervalRes.interval, - intervalMs: intervalRes.intervalMs, - targets: queries, - maxDataPoints: maxDataPoints || widthPixels, - scopedVars: scopedVarsWithInterval, - cacheTimeout: null, - startTime: Date.now(), - }; - - const resp = await ds.query(request); - request.endTime = Date.now(); - - if (this.isUnmounted) { - return; - } - - // Make sure the data is SeriesData[] - const series = getProcessedSeriesData(resp.data); - if (onDataResponse) { - onDataResponse(series); - } - - this.setState({ - isFirstLoad: false, - data: { - state: LoadingState.Done, - series, - request, - }, - }); - } catch (err) { - console.log('DataPanel error', err); - - let message = 'Query error'; - - if (err.message) { - message = err.message; - } else if (err.data && err.data.message) { - message = err.data.message; - } else if (err.data && err.data.error) { - message = err.data.error; - } else if (err.status) { - message = `Query error: ${err.status} ${err.statusText}`; - } - - onError(message, err); - - this.setState({ - isFirstLoad: false, - data: { - ...this.state.data, - loading: LoadingState.Error, - }, - }); - } - }; - - render() { - const { queries } = this.props; - const { isFirstLoad, data } = this.state; - const { state } = data; - - // do not render component until we have first data - if (isFirstLoad && (state === LoadingState.Loading || state === LoadingState.NotStarted)) { - return this.renderLoadingState(); - } - - if (!queries.length) { - return ( -
-

Add a query to get some data!

-
- ); - } - - return ( - <> - {state === LoadingState.Loading && this.renderLoadingState()} - {this.props.children({ data })} - - ); - } - - private renderLoadingState(): JSX.Element { - return ( -
- -
- ); - } -} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx index 52bbd494f9a..32ce937b91e 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -11,10 +11,8 @@ describe('PanelChrome', () => { bbb: { value: 'BBB', text: 'upperB' }, }, }, - dashboard: {}, - plugin: {}, isFullscreen: false, - }); + } as any); }); it('Should replace a panel variable', () => { diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 67cd8be2a1d..9a725264c10 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -1,14 +1,12 @@ // Libraries import React, { PureComponent } from 'react'; -import { AutoSizer } from 'react-virtualized'; // Services import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; // Components import { PanelHeader } from './PanelHeader/PanelHeader'; -import { DataPanel } from './DataPanel'; -import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary'; +import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary'; // Utils import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; @@ -19,12 +17,13 @@ import config from 'app/core/config'; // Types import { DashboardModel, PanelModel } from '../state'; import { PanelPlugin } from 'app/types'; -import { TimeRange, LoadingState, DataQueryError, SeriesData, toLegacyResponseData, PanelData } from '@grafana/ui'; +import { TimeRange, LoadingState, PanelData, toLegacyResponseData } from '@grafana/ui'; import { ScopedVars } from '@grafana/ui'; import templateSrv from 'app/features/templating/template_srv'; -import { getProcessedSeriesData } from './DataPanel'; +import { PanelQueryRunner, getProcessedSeriesData } from '../state/PanelQueryRunner'; +import { Unsubscribable } from 'rxjs'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; @@ -34,53 +33,152 @@ export interface Props { plugin: PanelPlugin; isFullscreen: boolean; isEditing: boolean; + width: number; + height: number; } export interface State { - refreshCounter: number; + isFirstLoad: boolean; renderCounter: number; timeInfo?: string; timeRange?: TimeRange; errorMessage: string | null; + + // Current state of all events + data: PanelData; } export class PanelChrome extends PureComponent { timeSrv: TimeSrv = getTimeSrv(); + queryRunner = new PanelQueryRunner(); + querySubscription: Unsubscribable; - constructor(props) { + constructor(props: Props) { super(props); - this.state = { - refreshCounter: 0, + isFirstLoad: true, renderCounter: 0, errorMessage: null, + data: { + state: LoadingState.NotStarted, + series: [], + }, }; + + // Listen for changes to the query results + this.querySubscription = this.queryRunner.subscribe(this.panelDataObserver); } componentDidMount() { - this.props.panel.events.on('refresh', this.onRefresh); - this.props.panel.events.on('render', this.onRender); - this.props.dashboard.panelInitialized(this.props.panel); + const { panel, dashboard } = this.props; + panel.events.on('refresh', this.onRefresh); + panel.events.on('render', this.onRender); + dashboard.panelInitialized(this.props.panel); + + // Move snapshot data into the query response + if (this.hasPanelSnapshot) { + this.setState({ + data: { + state: LoadingState.Done, + series: getProcessedSeriesData(panel.snapshotData), + }, + isFirstLoad: false, + }); + } else if (!this.wantsQueryExecution) { + this.setState({ isFirstLoad: false }); + } } componentWillUnmount() { this.props.panel.events.off('refresh', this.onRefresh); } + // Updates the response with information from the stream + panelDataObserver = { + next: (data: PanelData) => { + if (data.state === LoadingState.Error) { + const { error } = data; + if (error) { + let message = 'Query error'; + if (error.message) { + message = error.message; + } else if (error.data && error.data.message) { + message = error.data.message; + } else if (error.data && error.data.error) { + message = error.data.error; + } else if (error.status) { + message = `Query error: ${error.status} ${error.statusText}`; + } + + if (this.state.errorMessage !== message) { + this.setState({ errorMessage: message }); + } + // this event is used by old query editors + this.props.panel.events.emit('data-error', error); + } + } else { + this.clearErrorState(); + } + + // Save the query response into the panel + if (data.state === LoadingState.Done && this.props.dashboard.snapshot) { + this.props.panel.snapshotData = data.series; + } + + this.setState({ data, isFirstLoad: false }); + + // Notify query editors that the results have changed + if (this.props.isEditing) { + const events = this.props.panel.events; + let legacy = data.legacy; + if (!legacy) { + legacy = data.series.map(v => toLegacyResponseData(v)); + } + + // Angular query editors expect TimeSeries|TableData + events.emit('data-received', legacy); + + // Notify react query editors + events.emit('series-data-received', data); + } + }, + }; + onRefresh = () => { console.log('onRefresh'); if (!this.isVisible) { return; } - const { panel } = this.props; + const { panel, width } = this.props; const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange()); this.setState({ - refreshCounter: this.state.refreshCounter + 1, timeRange: timeData.timeRange, timeInfo: timeData.timeInfo, }); + + // Issue Query + if (this.wantsQueryExecution && !this.hasPanelSnapshot) { + if (width < 0) { + console.log('No width yet... wait till we know'); + return; + } + + this.queryRunner.run({ + datasource: panel.datasource, + queries: panel.targets, + panelId: panel.id, + dashboardId: this.props.dashboard.id, + timezone: this.props.dashboard.timezone, + timeRange: timeData.timeRange, + widthPixels: width, + minInterval: undefined, // Currently not passed in DataPanel? + maxDataPoints: panel.maxDataPoints, + scopedVars: panel.scopedVars, + cacheTimeout: panel.cacheTimeout, + }); + } }; onRender = () => { @@ -97,35 +195,6 @@ export class PanelChrome extends PureComponent { return templateSrv.replace(value, vars, format); }; - onDataResponse = (data?: SeriesData[]) => { - if (this.props.dashboard.isSnapshot()) { - this.props.panel.snapshotData = data; - } - // clear error state (if any) - this.clearErrorState(); - - if (this.props.isEditing) { - const events = this.props.panel.events; - if (!data) { - data = []; - } - - // Angular query editors expect TimeSeries|TableData - events.emit('data-received', data.map(v => toLegacyResponseData(v))); - - // Notify react query editors - events.emit('series-data-received', data); - } - }; - - onDataError = (message: string, error: DataQueryError) => { - if (this.state.errorMessage !== message) { - this.setState({ errorMessage: message }); - } - // this event is used by old query editors - this.props.panel.events.emit('data-error', error); - }; - onPanelError = (message: string) => { if (this.state.errorMessage !== message) { this.setState({ errorMessage: message }); @@ -147,112 +216,82 @@ export class PanelChrome extends PureComponent { return panel.snapshotData && panel.snapshotData.length; } - get needsQueryExecution() { - return this.hasPanelSnapshot || this.props.plugin.dataFormats.length > 0; + get wantsQueryExecution() { + return this.props.plugin.dataFormats.length > 0; } - get getDataForPanel() { - return { - state: LoadingState.Done, - series: this.hasPanelSnapshot ? getProcessedSeriesData(this.props.panel.snapshotData) : [], - }; - } - - renderPanelPlugin(data: PanelData, width: number, height: number): JSX.Element { + renderPanel(width: number, height: number): JSX.Element { const { panel, plugin } = this.props; - const { timeRange, renderCounter } = this.state; + const { timeRange, renderCounter, data, isFirstLoad } = this.state; const PanelComponent = plugin.reactPlugin.panel; // This is only done to increase a counter that is used by backend // image rendering (phantomjs/headless chrome) to know when to capture image - if (data.state === LoadingState.Done) { + const loading = data.state; + if (loading === LoadingState.Done) { profiler.renderingCompleted(panel.id); } + // do not render component until we have first data + if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) { + return this.renderLoadingState(); + } + return ( -
- + <> + {loading === LoadingState.Loading && this.renderLoadingState()} +
+ +
+ + ); + } + + private renderLoadingState(): JSX.Element { + return ( +
+
); } - renderPanelBody = (width: number, height: number): JSX.Element => { - const { panel } = this.props; - const { refreshCounter, timeRange } = this.state; - const { datasource, targets } = panel; - return ( - <> - {this.needsQueryExecution ? ( - - {({ data }) => { - return this.renderPanelPlugin(data, width, height); - }} - - ) : ( - this.renderPanelPlugin(this.getDataForPanel, width, height) - )} - - ); - }; - render() { - const { dashboard, panel, isFullscreen } = this.props; + const { dashboard, panel, isFullscreen, width, height } = this.props; const { errorMessage, timeInfo } = this.state; const { transparent } = panel; const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`; return ( - - {({ width, height }) => { - if (width === 0) { - return null; - } - - return ( -
- - - {({ error, errorInfo }) => { - if (errorInfo) { - this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR); - return null; - } - return this.renderPanelBody(width, height); - }} - -
- ); - }} -
+
+ + + {({ error, errorInfo }) => { + if (errorInfo) { + this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR); + return null; + } + return this.renderPanel(width, height); + }} + +
); } } diff --git a/public/app/features/dashboard/dashgrid/DataPanel.test.tsx b/public/app/features/dashboard/state/PanelQueryRunner.test.ts similarity index 66% rename from public/app/features/dashboard/dashgrid/DataPanel.test.tsx rename to public/app/features/dashboard/state/PanelQueryRunner.test.ts index 35000a957f6..511f9978b93 100644 --- a/public/app/features/dashboard/dashgrid/DataPanel.test.tsx +++ b/public/app/features/dashboard/state/PanelQueryRunner.test.ts @@ -1,29 +1,6 @@ -// Library -import React from 'react'; - -import { DataPanel, getProcessedSeriesData } from './DataPanel'; - -describe('DataPanel', () => { - let dataPanel: DataPanel; - - beforeEach(() => { - dataPanel = new DataPanel({ - queries: [], - panelId: 1, - widthPixels: 100, - refreshCounter: 1, - datasource: 'xxx', - children: r => { - return
hello
; - }, - onError: (message, error) => {}, - }); - }); - - it('starts with unloaded state', () => { - expect(dataPanel.state.isFirstLoad).toBe(true); - }); +import { getProcessedSeriesData } from './PanelQueryRunner'; +describe('QueryRunner', () => { it('converts timeseries to table skipping nulls', () => { const input1 = { target: 'Field Name', diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/dashboard/state/PanelQueryRunner.ts new file mode 100644 index 00000000000..16fbc7b0f67 --- /dev/null +++ b/public/app/features/dashboard/state/PanelQueryRunner.ts @@ -0,0 +1,208 @@ +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { Subject, Unsubscribable, PartialObserver } from 'rxjs'; +import { + guessFieldTypes, + toSeriesData, + PanelData, + LoadingState, + DataQuery, + TimeRange, + ScopedVars, + DataRequestInfo, + SeriesData, + DataQueryError, + toLegacyResponseData, + isSeriesData, + DataSourceApi, +} from '@grafana/ui'; + +import cloneDeep from 'lodash/cloneDeep'; + +import kbn from 'app/core/utils/kbn'; + +export interface QueryRunnerOptions { + ds?: DataSourceApi; // if they already have the datasource, don't look it up + datasource: string | null; + queries: DataQuery[]; + panelId: number; + dashboardId?: number; + timezone?: string; + timeRange?: TimeRange; + widthPixels: number; + minInterval?: string; + maxDataPoints?: number; + scopedVars?: ScopedVars; + cacheTimeout?: string; + delayStateNotification?: number; // default 100ms. +} + +export enum PanelQueryRunnerFormat { + series = 'series', + legacy = 'legacy', +} + +export class PanelQueryRunner { + private subject?: Subject; + + private sendSeries = false; + private sendLegacy = false; + + private data = { + state: LoadingState.NotStarted, + series: [], + } as PanelData; + + /** + * Listen for updates to the PanelData. If a query has already run for this panel, + * the results will be immediatly passed to the observer + */ + subscribe(observer: PartialObserver, format = PanelQueryRunnerFormat.series): Unsubscribable { + if (!this.subject) { + this.subject = new Subject(); // Delay creating a subject until someone is listening + } + + if (format === PanelQueryRunnerFormat.legacy) { + this.sendLegacy = true; + } else { + this.sendSeries = true; + } + + // Send the last result + if (this.data.state !== LoadingState.NotStarted) { + // TODO: make sure it has legacy if necessary + observer.next(this.data); + } + + return this.subject.subscribe(observer); + } + + async run(options: QueryRunnerOptions): Promise { + if (!this.subject) { + this.subject = new Subject(); + } + + const { + queries, + timezone, + datasource, + panelId, + dashboardId, + timeRange, + cacheTimeout, + widthPixels, + maxDataPoints, + scopedVars, + delayStateNotification, + } = options; + + const request: DataRequestInfo = { + timezone, + panelId, + dashboardId, + range: timeRange, + rangeRaw: timeRange.raw, + interval: '', + intervalMs: 0, + targets: cloneDeep(queries), + maxDataPoints: maxDataPoints || widthPixels, + scopedVars: scopedVars || {}, + cacheTimeout, + startTime: Date.now(), + }; + + if (!queries) { + this.data = { + state: LoadingState.Done, + series: [], // Clear the data + legacy: [], + request, + }; + this.subject.next(this.data); + return this.data; + } + + try { + const ds = options.ds ? options.ds : await getDatasourceSrv().get(datasource, request.scopedVars); + + const minInterval = options.minInterval || ds.interval; + const norm = kbn.calculateInterval(timeRange, widthPixels, minInterval); + + // make shallow copy of scoped vars, + // and add built in variables interval and interval_ms + request.scopedVars = Object.assign({}, request.scopedVars, { + __interval: { text: norm.interval, value: norm.interval }, + __interval_ms: { text: norm.intervalMs, value: norm.intervalMs }, + }); + request.interval = norm.interval; + request.intervalMs = norm.intervalMs; + + // Send a loading status event on slower queries + setTimeout(() => { + if (!request.endTime) { + this.data = { + ...this.data, + state: LoadingState.Loading, + request, + }; + this.subject.next(this.data); + } + }, delayStateNotification || 100); + + const resp = await ds.query(request); + request.endTime = Date.now(); + + // Make sure the response is in a supported format + const series = this.sendSeries ? getProcessedSeriesData(resp.data) : []; + const legacy = this.sendLegacy + ? resp.data.map(v => { + if (isSeriesData(v)) { + return toLegacyResponseData(v); + } + return v; + }) + : undefined; + + // The Result + this.data = { + state: LoadingState.Done, + series, + legacy, + request, + }; + this.subject.next(this.data); + return this.data; + } catch (err) { + const error = err as DataQueryError; + if (!error.message) { + err.message = 'Query Error'; + } + + this.data = { + ...this.data, // ?? Should we keep existing data, or clear it ??? + state: LoadingState.Error, + error: error, + }; + this.subject.next(this.data); + return this.data; + } + } +} + +/** + * All panels will be passed tables that have our best guess at colum type set + * + * This is also used by PanelChrome for snapshot support + */ +export function getProcessedSeriesData(results?: any[]): SeriesData[] { + if (!results) { + return []; + } + + const series: SeriesData[] = []; + for (const r of results) { + if (r) { + series.push(guessFieldTypes(toSeriesData(r))); + } + } + return series; +}