From 78563f7f81ec2d075206145d42f9ed5e79562865 Mon Sep 17 00:00:00 2001 From: Jt Miclat Date: Wed, 17 Mar 2021 18:16:42 +0800 Subject: [PATCH] Add ScatterPlotWidget --- .../__tests__/operations/scatterPlot.test.js | 18 +++ packages/react-core/src/index.js | 3 +- .../react-core/src/operations/scatterPlot.js | 13 ++ .../widgets/ScatterPlotWidget.test.js | 34 +++++ packages/react-ui/src/index.js | 2 + .../src/widgets/ScatterPlotWidgetUI.js | 104 +++++++++++++++ .../widgets/ScatterPlotWidget.stories.js | 96 ++++++++++++++ .../widgetsUI/ScatterPlotWidgetUI.stories.js | 59 +++++++++ .../__tests__/models/ScatterModel.test.js | 117 +++++++++++++++++ packages/react-widgets/src/index.js | 9 +- .../src/models/ScatterPlotModel.js | 62 +++++++++ packages/react-widgets/src/models/index.js | 1 + .../src/widgets/ScatterPlotWidget.js | 123 ++++++++++++++++++ packages/react-workers/src/workerMethods.js | 3 +- .../src/workers/viewportFeatures.worker.js | 18 ++- 15 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 packages/react-core/__tests__/operations/scatterPlot.test.js create mode 100644 packages/react-core/src/operations/scatterPlot.js create mode 100644 packages/react-ui/__tests__/widgets/ScatterPlotWidget.test.js create mode 100644 packages/react-ui/src/widgets/ScatterPlotWidgetUI.js create mode 100644 packages/react-ui/storybook/stories/widgets/ScatterPlotWidget.stories.js create mode 100644 packages/react-ui/storybook/stories/widgetsUI/ScatterPlotWidgetUI.stories.js create mode 100644 packages/react-widgets/__tests__/models/ScatterModel.test.js create mode 100644 packages/react-widgets/src/models/ScatterPlotModel.js create mode 100644 packages/react-widgets/src/widgets/ScatterPlotWidget.js diff --git a/packages/react-core/__tests__/operations/scatterPlot.test.js b/packages/react-core/__tests__/operations/scatterPlot.test.js new file mode 100644 index 000000000..459717323 --- /dev/null +++ b/packages/react-core/__tests__/operations/scatterPlot.test.js @@ -0,0 +1,18 @@ +import { scatterPlot } from '../../src/operations/scatterPlot'; +test('filter invalid values', () => { + const data = [ + { x: 0 }, // Missing y + { y: 1 }, // Missing x + { x: null, y: 1 }, // null x + { x: 1, y: null }, // null y + { x: 0, y: 0 }, // zero for both + { x: 1, y: 2 }, // valid + {}, // no values for both + { x: 2, y: 3 } // valid + ]; + expect(scatterPlot(data, 'x', 'y')).toEqual([ + [0, 0], + [1, 2], + [2, 3] + ]); +}); diff --git a/packages/react-core/src/index.js b/packages/react-core/src/index.js index 8e69eacbd..afdc96784 100644 --- a/packages/react-core/src/index.js +++ b/packages/react-core/src/index.js @@ -15,8 +15,9 @@ export { AggregationTypes } from './operations/aggregation/AggregationTypes'; export { aggregationFunctions } from './operations/aggregation/values'; export { groupValuesByColumn } from './operations/groupby'; export { histogram } from './operations/histogram'; +export { scatterPlot } from './operations/scatterPlot'; -export { +export { FilterTypes as _FilterTypes, filtersToSQL as _filtersToSQL, getApplicableFilters as _getApplicableFilters diff --git a/packages/react-core/src/operations/scatterPlot.js b/packages/react-core/src/operations/scatterPlot.js new file mode 100644 index 000000000..090480fd8 --- /dev/null +++ b/packages/react-core/src/operations/scatterPlot.js @@ -0,0 +1,13 @@ +/** + * Filters invalid features and formats data + */ +export const scatterPlot = (features, xAxisColumn, yAxisColumn) => + features + .filter((feature) => { + const xValue = feature[xAxisColumn]; + const xIsValid = xValue !== null && xValue !== undefined; + const yValue = feature[yAxisColumn]; + const yIsValid = yValue !== null && yValue !== undefined; + return xIsValid && yIsValid; + }) + .map((feature) => [feature[xAxisColumn], feature[yAxisColumn]]); diff --git a/packages/react-ui/__tests__/widgets/ScatterPlotWidget.test.js b/packages/react-ui/__tests__/widgets/ScatterPlotWidget.test.js new file mode 100644 index 000000000..76f3ef886 --- /dev/null +++ b/packages/react-ui/__tests__/widgets/ScatterPlotWidget.test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ScatterPlotWidgetUI from '../../src/widgets/ScatterPlotWidgetUI'; +import { getMaterialUIContext, mockEcharts } from './testUtils'; + +describe('ScatterPlotWidgetUI', () => { + beforeAll(() => { + mockEcharts.init(); + }); + + afterAll(() => { + mockEcharts.destroy(); + }); + const DATA = [ + [1, 2], + [2, 4], + [3, 6] + ]; + const Widget = (props) => + getMaterialUIContext( + {}} {...props} /> + ); + + test('renders correctly', () => { + render(); + }); + + test('re-render with different data', () => { + const { rerender } = render(); + + rerender(); + rerender(); + }); +}); diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 2519f9ab6..147fc9a2f 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -4,6 +4,7 @@ import CategoryWidgetUI from './widgets/CategoryWidgetUI'; import FormulaWidgetUI from './widgets/FormulaWidgetUI'; import HistogramWidgetUI from './widgets/HistogramWidgetUI'; import PieWidgetUI from './widgets/PieWidgetUI'; +import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI'; export { cartoThemeOptions, @@ -12,4 +13,5 @@ export { FormulaWidgetUI, HistogramWidgetUI, PieWidgetUI, + ScatterPlotWidgetUI }; diff --git a/packages/react-ui/src/widgets/ScatterPlotWidgetUI.js b/packages/react-ui/src/widgets/ScatterPlotWidgetUI.js new file mode 100644 index 000000000..47245678f --- /dev/null +++ b/packages/react-ui/src/widgets/ScatterPlotWidgetUI.js @@ -0,0 +1,104 @@ +import { useTheme } from '@material-ui/core'; +import PropTypes from 'prop-types'; +import React, { useRef, useState, useEffect } from 'react'; +import ReactEcharts from 'echarts-for-react'; +import { isDataEqual } from './utils/chartUtils'; +function __generateDefaultConfig( + { tooltipFormatter, xAxisFormatter = (v) => v, yAxisFormatter = (v) => v }, + theme +) { + return { + grid: {}, + tooltip: { + padding: [theme.spacing(0.5), theme.spacing(1)], + textStyle: { + ...theme.typography.caption, + fontSize: 12, + lineHeight: 16 + }, + backgroundColor: theme.palette.other.tooltip, + ...(tooltipFormatter ? { formatter: tooltipFormatter } : {}) + }, + color: [theme.palette.secondary.main], + xAxis: { + axisLabel: { + ...theme.typography.charts, + padding: [theme.spacing(0.5), 0, 0, 0], + formatter: (v) => { + const formatted = xAxisFormatter(v); + return typeof formatted === 'object' + ? `${formatted.prefix || ''}${formatted.value}${formatted.suffix || ''}` + : formatted; + } + } + }, + yAxis: { + axisLabel: { + ...theme.typography.charts, + formatter: (v) => { + const formatted = yAxisFormatter(v); + return typeof formatted === 'object' + ? `${formatted.prefix}${formatted.value}${formatted.suffix || ''}` + : formatted; + } + } + } + }; +} + +function __generateSerie({ name, data, theme }) { + return [ + { + type: 'scatter', + name, + data: data + } + ]; +} + +const EchartsWrapper = React.memo( + ReactEcharts, + ({ option: optionPrev }, { option: optionNext }) => isDataEqual(optionPrev, optionNext) +); + +function ScatterPlotWidgetUI(props) { + const theme = useTheme(); + const { data = [], name, xAxisFormatter, yAxisFormatter, tooltipFormatter } = props; + const chartInstance = useRef(); + const [options, setOptions] = useState({ + series: [] + }); + + useEffect(() => { + const config = __generateDefaultConfig( + { xAxisFormatter, yAxisFormatter, tooltipFormatter }, + theme + ); + const series = __generateSerie({ + name, + data: data || [] + }); + setOptions({ + ...config, + series + }); + }, [data, name, theme, xAxisFormatter, yAxisFormatter, tooltipFormatter]); + return ; +} + +ScatterPlotWidgetUI.defaultProps = { + name: null, + tooltipFormatter: (v) => `[${v.value[0]}, ${v.value[1]})`, + xAxisFormatter: (v) => v, + yAxisFormatter: (v) => v +}; + +ScatterPlotWidgetUI.propTypes = { + name: PropTypes.string, + data: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), + tooltipFormatter: PropTypes.func, + xAxisFormatter: PropTypes.func, + yAxisFormatter: PropTypes.func +}; + +export default ScatterPlotWidgetUI; diff --git a/packages/react-ui/storybook/stories/widgets/ScatterPlotWidget.stories.js b/packages/react-ui/storybook/stories/widgets/ScatterPlotWidget.stories.js new file mode 100644 index 000000000..d56de977e --- /dev/null +++ b/packages/react-ui/storybook/stories/widgets/ScatterPlotWidget.stories.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { + Title, + Subtitle, + Primary, + ArgsTable, + Stories, + PRIMARY_STORY +} from '@storybook/addon-docs/blocks'; +import * as cartoSlice from '@carto/react-redux/src/slices/cartoSlice'; +import ScatterPlotWidget from '../../../../react-widgets/src/widgets/ScatterPlotWidget'; +import { mockAppStoreConfiguration } from './utils'; +import { buildReactPropsAsString } from '../../utils'; + +const store = mockAppStoreConfiguration(); +store.dispatch( + cartoSlice.setWidgetLoadingState({ widgetId: 'sb-scatter-id', isLoading: false }) +); +store.dispatch( + cartoSlice.setViewportFeatures({ + sourceId: 'sb-data-source', + features: [ + { 'sb-x-column': 5000, 'sb-y-column': 3000 }, + { 'sb-x-column': 2000, 'sb-y-column': 1000 } + ] + }) +); + +const options = { + title: 'Widgets/ScatterPlotWidget', + component: ScatterPlotWidget, + decorators: [ + (Story) => ( + + + + ) + ], + parameters: { + docs: { + page: () => ( + <> + + <Subtitle /> + <Primary /> + <ArgsTable story={PRIMARY_STORY} /> + <Stories /> + </> + ) + } + } +}; + +export default options; + +const Template = (args) => <ScatterPlotWidget {...args} />; + +const DEFAULT_PROPS = { + id: 'sb-scatter-id', + title: 'wrapper title', + dataSource: 'sb-data-source', + xAxisColumn: 'sb-x-column', + yAxisColumn: 'sb-y-column' +}; + +export const Default = Template.bind({}); +Default.args = DEFAULT_PROPS; +Default.parameters = buildReactPropsAsString(DEFAULT_PROPS, 'ScatterPlotWidget'); + +export const xAxisFormatter = Template.bind({}); +const xAxisFormatterProps = { ...DEFAULT_PROPS, xAxisFormatter: (v) => `${v}$` }; +xAxisFormatter.args = xAxisFormatterProps; +xAxisFormatter.parameters = buildReactPropsAsString( + xAxisFormatterProps, + 'ScatterPlotWidget' +); + +export const yAxisFormatter = Template.bind({}); +const yAxisFormatterProps = { ...DEFAULT_PROPS, yAxisFormatter: (v) => `$${v}` }; +yAxisFormatter.args = yAxisFormatterProps; +yAxisFormatter.parameters = buildReactPropsAsString( + yAxisFormatterProps, + 'ScatterPlotWidget' +); + +export const tooltipFormatter = Template.bind({}); +const tooltipFormatterProps = { + ...DEFAULT_PROPS, + tooltipFormatter: (v) => `Price: $ ${v.value[0]}` +}; +tooltipFormatter.args = tooltipFormatterProps; +tooltipFormatter.parameters = buildReactPropsAsString( + tooltipFormatterProps, + 'ScatterPlotWidget' +); diff --git a/packages/react-ui/storybook/stories/widgetsUI/ScatterPlotWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ScatterPlotWidgetUI.stories.js new file mode 100644 index 000000000..e6ee219f5 --- /dev/null +++ b/packages/react-ui/storybook/stories/widgetsUI/ScatterPlotWidgetUI.stories.js @@ -0,0 +1,59 @@ +import React from 'react'; +import ScatterPlotWidgetUI from '../../../src/widgets/ScatterPlotWidgetUI'; +import { buildReactPropsAsString } from '../../utils'; + +const options = { + title: 'Widgets UI/ScatterPlotWidgetUI', + component: ScatterPlotWidgetUI +}; + +export default options; + +const dataDefault = [ + [1000.0, 800.04], + [800.07, 600.95] +]; + +const Template = (args) => <ScatterPlotWidgetUI {...args} />; + +export const Default = Template.bind({}); +const DefaultProps = { data: dataDefault, name: 'name' }; +Default.args = DefaultProps; +Default.parameters = buildReactPropsAsString(DefaultProps, 'ScatterPlotWidgetUI'); + +export const xAxisFormatter = Template.bind({}); +const xAxisFormatterProps = { + name: 'xFormatter', + data: dataDefault, + xAxisFormatter: (v) => `${v / 1000}k` +}; +xAxisFormatter.args = xAxisFormatterProps; +xAxisFormatter.parameters = buildReactPropsAsString( + xAxisFormatterProps, + 'ScatterPlotWidgetUI' +); + +export const yAxisFormatter = Template.bind({}); +const yAxisFormatterProps = { + name: 'yFormatter', + data: dataDefault, + yAxisFormatter: (v) => ({ prefix: `$`, value: v }) +}; +yAxisFormatter.args = yAxisFormatterProps; +yAxisFormatter.parameters = buildReactPropsAsString( + yAxisFormatterProps, + 'ScatterPlotWidgetUI' +); + +export const tooltipFormatter = Template.bind({}); +const tooltipFormatterProps = { + name: 'tooltipFormatter', + data: dataDefault, + tooltipFormatter: (v) => `Price $ ${v.value[1]} Sales: ${v.value[0]}` +}; + +tooltipFormatter.args = tooltipFormatterProps; +tooltipFormatter.parameters = buildReactPropsAsString( + tooltipFormatterProps, + 'ScatterPlotWidgetUI' +); diff --git a/packages/react-widgets/__tests__/models/ScatterModel.test.js b/packages/react-widgets/__tests__/models/ScatterModel.test.js new file mode 100644 index 000000000..d42542f9b --- /dev/null +++ b/packages/react-widgets/__tests__/models/ScatterModel.test.js @@ -0,0 +1,117 @@ +import { minify } from 'pgsql-minify'; + +import { getScatter, buildSqlQueryToGetScatter } from '../../src/models/ScatterPlotModel'; +import { SourceTypes } from '@carto/react-api'; +import { Methods, executeTask } from '@carto/react-workers'; + +import { mockSqlApiRequest, mockClear } from '../mockSqlApiRequest'; + +jest.mock('@carto/react-workers', () => ({ + executeTask: jest.fn(), + Methods: { + VIEWPORT_FEATURES_SCATTERPLOT: 'viewportFeaturesScatterPlot' + } +})); + +const features = (xColumn, yColumn) => [ + { + [xColumn]: 1, + [yColumn]: 2 + }, + { + [xColumn]: 2, + [yColumn]: 4 + }, + { + [xColumn]: 3, + [yColumn]: 6 + } +]; + +describe('getScatter', () => { + test('should throw with array data', async () => { + await expect(getScatter({ data: [] })).rejects.toThrow( + 'Array is not a valid type to get scatter' + ); + }); + + test('should throw if using CartoBQTilerLayer without viewportFilter', async () => { + await expect( + getScatter({ type: SourceTypes.BIGQUERY, viewportFilter: false }) + ).rejects.toThrow( + 'Scatter Widget error: BigQuery layer needs "viewportFilter" prop set to true.' + ); + }); + + describe('SQL Layer', () => { + describe('should execute a SqlApi request when using "viewportFilter": false', () => { + const response = { rows: features('x', 'y') }; + const sql = 'SELECT * FROM retail_stores LIMIT 4'; + const credentials = { + username: 'public', + apiKey: 'default_public', + serverUrlTemplate: 'https://{user}.carto.com' + }; + + mockSqlApiRequest({ response, sql, credentials }); + + beforeEach(() => { + mockClear(); + }); + + test('should call SqlApi', async () => { + const params = { + data: sql, + credentials, + type: SourceTypes.SQL, + xAxisColumn: 'x', + yAxisColumn: 'y', + viewportFilter: false + }; + const scatterData = await getScatter(params); + const values = features('x', 'y').map((f) => [f.x, f.y]); + expect(scatterData).toEqual(values); + }); + }); + }); + test('should filter viewport features - "viewportFilter" prop is true', async () => { + const viewportFeatures = features('x', 'y'); + const values = viewportFeatures.map((f) => [f.x, f.y]); + executeTask.mockImplementation(() => Promise.resolve(values)); + const params = { + type: SourceTypes.SQL, + xAxisColumn: 'x', + yAxisColumn: 'y', + viewportFilter: true, + viewportFeatures + }; + const scatterData = await getScatter(params); + expect(scatterData).toEqual(values); + }); +}); + +test('buildSqlQueryToGetScatter', () => { + const sql = 'SELECT * FROM retail_stores LIMIT 4'; + const credentials = { + username: 'public', + apiKey: 'default_public', + serverUrlTemplate: 'https://{user}.carto.com' + }; + const params = { + data: sql, + credentials, + type: SourceTypes.SQL, + xAxisColumn: 'x', + yAxisColumn: 'y', + viewportFilter: false + }; + + const query = ` + SELECT + ${params.xAxisColumn}, ${params.yAxisColumn} + FROM + (${params.data}) as q1 + `; + + expect(buildSqlQueryToGetScatter(params)).toEqual(minify(query)); +}); diff --git a/packages/react-widgets/src/index.js b/packages/react-widgets/src/index.js index be7675529..b9f6c177f 100644 --- a/packages/react-widgets/src/index.js +++ b/packages/react-widgets/src/index.js @@ -3,4 +3,11 @@ export { default as FormulaWidget } from './widgets/FormulaWidget'; export { default as GeocoderWidget } from './widgets/GeocoderWidget'; export { default as HistogramWidget } from './widgets/HistogramWidget'; export { default as PieWidget } from './widgets/PieWidget'; -export { getFormula, getHistogram, getCategories, geocodeStreetPoint } from './models'; \ No newline at end of file +export { default as ScatterPlotWidget } from './widgets/ScatterPlotWidget'; +export { + getFormula, + getHistogram, + getCategories, + geocodeStreetPoint, + getScatter +} from './models'; diff --git a/packages/react-widgets/src/models/ScatterPlotModel.js b/packages/react-widgets/src/models/ScatterPlotModel.js new file mode 100644 index 000000000..8f840b530 --- /dev/null +++ b/packages/react-widgets/src/models/ScatterPlotModel.js @@ -0,0 +1,62 @@ +import { minify } from 'pgsql-minify'; +import { + _buildFeatureFilter as buildFeatureFilter, + _filtersToSQL as filtersToSQL, + scatterPlot +} from '@carto/react-core'; +import { executeSQL, SourceTypes } from '@carto/react-api'; +import { Methods, executeTask } from '@carto/react-workers'; + +export const getScatter = async (props) => { + const { + data, + credentials, + xAxisColumn, + yAxisColumn, + filters, + opts, + viewportFilter, + dataSource, + type + } = props; + if (Array.isArray(data)) { + throw new Error('Array is not a valid type to get scatter plot'); + } + if (type === SourceTypes.BIGQUERY && !viewportFilter) { + throw new Error( + 'Scatter Widget error: BigQuery layer needs "viewportFilter" prop set to true.' + ); + } + if (viewportFilter) { + return executeTask(dataSource, Methods.VIEWPORT_FEATURES_SCATTERPLOT, { + filters, + xAxisColumn, + yAxisColumn + }); + } + const query = buildSqlQueryToGetScatter({ + data, + xAxisColumn, + yAxisColumn, + filters + }); + const queryResult = await executeSQL(credentials, query, opts); + const result = scatterPlot(queryResult, xAxisColumn, yAxisColumn); + return result; +}; + +export const buildSqlQueryToGetScatter = ({ + data, + xAxisColumn, + yAxisColumn, + filters +}) => { + const query = ` + SELECT + ${xAxisColumn}, ${yAxisColumn} + FROM (${data}) as q1 + ${filtersToSQL(filters)} + `; + + return minify(query); +}; diff --git a/packages/react-widgets/src/models/index.js b/packages/react-widgets/src/models/index.js index 538ff0611..490064d3c 100644 --- a/packages/react-widgets/src/models/index.js +++ b/packages/react-widgets/src/models/index.js @@ -2,3 +2,4 @@ export { getFormula } from './FormulaModel'; export { getHistogram } from './HistogramModel'; export { getCategories } from './CategoryModel'; export { geocodeStreetPoint } from './GeocodingModel'; +export { getScatter } from './ScatterPlotModel'; diff --git a/packages/react-widgets/src/widgets/ScatterPlotWidget.js b/packages/react-widgets/src/widgets/ScatterPlotWidget.js new file mode 100644 index 000000000..8736fe8b5 --- /dev/null +++ b/packages/react-widgets/src/widgets/ScatterPlotWidget.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { PropTypes } from 'prop-types'; +import { selectSourceById } from '@carto/react-redux'; +import { WrapperWidgetUI, ScatterPlotWidgetUI } from '@carto/react-ui'; +import { _getApplicableFilters as getApplicableFilters } from '@carto/react-core'; +import { getScatter } from '../models'; +import useWidgetLoadingState from './useWidgetLoadingState'; + +/** + * Renders a <ScatterPlotWidget /> component + * @param props + * @param {string} props.id - ID for the widget instance. + * @param {string} props.title - Title to show in the widget header. + * @param {string} props.dataSource - ID of the data source to get the data from. + * @param {string} props.xAxisColumn - Name of the data source's column to get the x axis from. + * @param {string} props.yAxisColumn - Name of the data source's column to get the y axis from. + * @param {formatterCallback} [props.xAxisFormatter] - Function to format X axis values. + * @param {formatterCallback} [props.yAxisFormatter] - Function to format Y axis values. + * @param {formatterCallback} [props.tooltipFormatter] - Function to format Y axis values. + * @param {boolean} [props.viewportFilter=true] - Defines whether filter by the viewport or globally. + * @param {errorCallback} [props.onError] - Function to handle error messages from the widget. + * @param {Object} [props.wrapperProps] - Extra props to pass to [WrapperWidgetUI](https://storybook-react.carto.com/?path=/docs/widgets-wrapperwidgetui--default) + */ +function ScatterPlotWidget(props) { + const [scatterData, setScatterData] = useState([]); + const source = useSelector((state) => selectSourceById(state, props.dataSource) || {}); + const viewportFeaturesReady = useSelector((state) => state.carto.viewportFeaturesReady); + const widgetsLoadingState = useSelector((state) => state.carto.widgetsLoadingState); + const [hasLoadingState, setIsLoading] = useWidgetLoadingState( + props.id, + props.viewportFilter + ); + const { + title, + xAxisColumn, + yAxisColumn, + yAxisFormatter, + xAxisFormatter, + tooltipFormatter + } = props; + const { data, credentials, type } = source; + + useEffect(() => { + const abortController = new AbortController(); + + if (data && credentials && hasLoadingState) { + const filters = getApplicableFilters(source.filters, props.id); + !props.viewportFilter && setIsLoading(true); + getScatter({ + ...props, + data, + filters, + xAxisColumn, + yAxisColumn, + credentials, + viewportFeatures: viewportFeaturesReady[props.dataSource] || false, + type, + opts: { abortController } + }) + .then((data) => data && setScatterData(data)) + .catch((error) => { + if (error.name === 'AbortError') return; + if (props.onError) props.onError(error); + }) + .finally(() => setIsLoading(false)); + } else { + setScatterData([]); + } + + return function cleanup() { + abortController.abort(); + }; + }, [ + credentials, + data, + setIsLoading, + source.filters, + type, + viewportFeaturesReady, + props, + hasLoadingState + ]); + return ( + <WrapperWidgetUI + title={title} + {...props.wrapperProps} + isLoading={widgetsLoadingState[props.id]} + > + <ScatterPlotWidgetUI + data={scatterData} + tooltipFormatter={tooltipFormatter} + xAxisFormatter={xAxisFormatter} + yAxisFormatter={yAxisFormatter} + /> + </WrapperWidgetUI> + ); +} + +ScatterPlotWidget.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + dataSource: PropTypes.string.isRequired, + xAxisColumn: PropTypes.string.isRequired, + yAxisColumn: PropTypes.string.isRequired, + xAxisFormatter: PropTypes.func, + yAxisFormatter: PropTypes.func, + tooltipFormatter: PropTypes.func, + viewportFilter: PropTypes.bool, + onError: PropTypes.func, + wrapperProps: PropTypes.object +}; + +ScatterPlotWidget.defaultProps = { + tooltip: true, + viewportFilter: true, + wrapperProps: {}, + tooltipFormatter: (v) => `[${v.value[0]}, ${v.value[1]})`, + xAxisFormatter: (v) => v, + yAxisFormatter: (v) => v +}; + +export default ScatterPlotWidget; diff --git a/packages/react-workers/src/workerMethods.js b/packages/react-workers/src/workerMethods.js index 90f87405d..56dec4225 100644 --- a/packages/react-workers/src/workerMethods.js +++ b/packages/react-workers/src/workerMethods.js @@ -2,5 +2,6 @@ export const Methods = Object.freeze({ VIEWPORT_FEATURES: 'viewportFeatures', VIEWPORT_FEATURES_FORMULA: 'viewportFeaturesFormula', VIEWPORT_FEATURES_HISTOGRAM: 'viewportFeaturesHistogram', - VIEWPORT_FEATURES_CATEGORY: 'viewportFeaturesCategory' + VIEWPORT_FEATURES_CATEGORY: 'viewportFeaturesCategory', + VIEWPORT_FEATURES_SCATTERPLOT: 'viewportFeaturesScatterPlot' }); diff --git a/packages/react-workers/src/workers/viewportFeatures.worker.js b/packages/react-workers/src/workers/viewportFeatures.worker.js index 0fc86cc63..98835be4d 100644 --- a/packages/react-workers/src/workers/viewportFeatures.worker.js +++ b/packages/react-workers/src/workers/viewportFeatures.worker.js @@ -3,7 +3,9 @@ import { aggregationFunctions, _buildFeatureFilter, histogram, - groupValuesByColumn } from '@carto/react-core'; + groupValuesByColumn, + scatterPlot +} from '@carto/react-core'; import { Methods } from '../workerMethods'; let currentViewportFeatures; @@ -22,6 +24,9 @@ onmessage = ({ data: { method, ...params } }) => { case Methods.VIEWPORT_FEATURES_CATEGORY: getCategories(params); break; + case Methods.VIEWPORT_FEATURES_SCATTERPLOT: + getScatterPlot(params); + break; default: throw new Error('Invalid worker method'); } @@ -85,4 +90,13 @@ function getFilteredFeatures(filters) { return !Object.keys(currentViewportFeatures).length ? currentViewportFeatures : currentViewportFeatures.filter(_buildFeatureFilter({ filters })); -} \ No newline at end of file +} +function getScatterPlot({ filters, xAxisColumn, yAxisColumn }) { + let result = []; + if (currentViewportFeatures) { + const filteredFeatures = getFilteredFeatures(filters); + result = scatterPlot(filteredFeatures, xAxisColumn, yAxisColumn); + } + + postMessage({ result }); +}