From c738621732764ad9a013a2f63379ced92660a272 Mon Sep 17 00:00:00 2001 From: Xun Li Date: Thu, 12 Dec 2024 22:26:32 -0700 Subject: [PATCH] [Fix] Render issue in scatter plot with regression lines (#206) --- .../src/components/plots/echarts-updater.tsx | 9 +- .../src/components/plots/scatter-plot.tsx | 582 +++++++----------- .../plots/scatter-regression-plot.tsx | 168 +++++ .../components/plots/simple-scatter-plot.tsx | 12 +- geoda-ai/src/utils/plots/scatterplot-utils.ts | 1 + 5 files changed, 386 insertions(+), 386 deletions(-) create mode 100644 geoda-ai/src/components/plots/scatter-regression-plot.tsx diff --git a/geoda-ai/src/components/plots/echarts-updater.tsx b/geoda-ai/src/components/plots/echarts-updater.tsx index 06f41ab..81e6987 100644 --- a/geoda-ai/src/components/plots/echarts-updater.tsx +++ b/geoda-ai/src/components/plots/echarts-updater.tsx @@ -55,6 +55,11 @@ const debouncedOnSelected = debounce( 500 ); +// Move the debounced dispatch outside the function to avoid recreating it on every call +const debouncedDispatch = debounce((dispatch: Dispatch, action: any) => { + dispatch(action); +}, 100); + export function onBrushSelected( params: any, dispatch: Dispatch, @@ -86,7 +91,7 @@ export function onBrushSelected( if (brushed.length > 0) { // Debounce the onSelected callback debouncedOnSelected({dataId, filteredIndex: brushed}, onSelected); - // Dispatch action to highlight selected in other components - dispatch(geodaBrushLink({sourceId: id, dataId, filteredIndex: brushed})); + // Debounce the dispatch + debouncedDispatch(dispatch, geodaBrushLink({sourceId: id, dataId, filteredIndex: brushed})); } } diff --git a/geoda-ai/src/components/plots/scatter-plot.tsx b/geoda-ai/src/components/plots/scatter-plot.tsx index d0017c7..4b32a98 100644 --- a/geoda-ai/src/components/plots/scatter-plot.tsx +++ b/geoda-ai/src/components/plots/scatter-plot.tsx @@ -1,19 +1,6 @@ -import React, {useRef, useMemo, useState, useCallback} from 'react'; +import React, {useState, useCallback} from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; -//import {ScatterplotDataItemProps, ScatPlotDataProps} from '@/utils/scatterplot-utils'; -import {ScatterChart} from 'echarts/charts'; -import * as echarts from 'echarts/core'; -//import { transform } from 'echarts-stat'; import {useDispatch, useSelector} from 'react-redux'; -import {GeoDaState} from '@/store'; -import { - TooltipComponent, - GridComponent, - BrushComponent, - ToolboxComponent - //DataZoomComponent -} from 'echarts/components'; -import ReactEChartsCore from 'echarts-for-react/lib/core'; import { Card, CardHeader, @@ -27,40 +14,19 @@ import { TableRow, TableCell } from '@nextui-org/react'; -import {CanvasRenderer} from 'echarts/renderers'; -import {getScatterChartOption} from '@/utils/plots/scatterplot-utils'; -import {EChartsUpdater} from './echarts-updater'; -import {getColumnDataFromKeplerDataset} from '@/utils/data-utils'; import {selectKeplerDataset} from '@/store/selectors'; import {ChartInsightButton} from '../common/chart-insight'; import {ScatterPlotStateProps} from '@/reducers/plot-reducer'; import {Icon} from '@iconify/react/dist/iconify.js'; -import {linearRegression, RegressionResults} from '../../utils/math/linear-regression'; +import {RegressionResults} from '../../utils/math/linear-regression'; import {updatePlot} from '@/actions/plot-actions'; -import {ChowTestResult, chowTest} from '@/utils/math-utils'; +import {ChowTestResult} from '@/utils/math-utils'; import {SimpleScatterPlot} from './simple-scatter-plot'; - -// Register the required ECharts components -echarts.use([ - TooltipComponent, - GridComponent, - ScatterChart, - CanvasRenderer, - BrushComponent, - ToolboxComponent - //DataZoomComponent -]); -//echarts.registerTransform(transform.regression); +import {ScatterRegressionPlot} from './scatter-regression-plot'; export const Scatterplot = ({props}: {props: ScatterPlotStateProps}) => { const dispatch = useDispatch(); - const eChartsRef = useRef(null); - const [rendered, setRendered] = useState(false); const [showMore, setShowMore] = useState(false); - const [xSelected, setXSelected] = useState([]); - const [ySelected, setYSelected] = useState([]); - const [xUnselected, setXUnselected] = useState([]); - const [yUnselected, setYUnselected] = useState([]); const [regressionResults, setRegressionResults] = useState<{ all: RegressionResults; selected: RegressionResults | null; @@ -70,99 +36,10 @@ export const Scatterplot = ({props}: {props: ScatterPlotStateProps}) => { const {id, datasetId, variableX, variableY} = props; - // use selector to get theme and table name - const theme = useSelector((state: GeoDaState) => state.root.uiState.theme); - // use selector to get sourceId of interaction - const sourceId = useSelector((state: GeoDaState) => state.root.interaction?.sourceId); // use selector to get keplerDataset const keplerDataset = useSelector(selectKeplerDataset(datasetId)); - const {xData, yData} = useMemo(() => { - const xData = getColumnDataFromKeplerDataset(variableX, keplerDataset); - const yData = getColumnDataFromKeplerDataset(variableY, keplerDataset); - return {xData, yData}; - }, [keplerDataset, variableX, variableY]); - - // get chart option by calling getChartOption only once - const option = useMemo(() => { - const showRegressionLine = true; - const showLoess = true; - return getScatterChartOption( - variableX, - xData, - variableY, - yData, - showRegressionLine, - showLoess, - regressionResults?.all, - regressionResults?.selected, - regressionResults?.unselected - ); - }, [variableX, variableY, xData, yData, regressionResults]); - - const onSelected = useCallback( - ({filteredIndex}: {filteredIndex: number[]}) => { - const selected = new Set(filteredIndex); - const selectedX: number[] = []; - const selectedY: number[] = []; - const unselectedX: number[] = []; - const unselectedY: number[] = []; - - xData.forEach((x, i) => { - if (selected.has(i)) { - selectedX.push(x); - selectedY.push(yData[i]); - } else { - unselectedX.push(x); - unselectedY.push(yData[i]); - } - }); - - setXSelected(selectedX); - setYSelected(selectedY); - setXUnselected(unselectedX); - setYUnselected(unselectedY); - - // update regression results - const allResults = linearRegression(xData, yData); - const selectedResults = selectedX.length > 0 ? linearRegression(selectedX, selectedY) : null; - const unselectedResults = - unselectedX.length > 0 ? linearRegression(unselectedX, unselectedY) : null; - setRegressionResults({ - all: allResults, - selected: selectedResults, - unselected: unselectedResults - }); - - // Calculate Chow test results if both selected and unselected data exist - if (showMore && selectedX.length > 0 && unselectedX.length > 0) { - const chowResults = chowTest(selectedX, selectedY, unselectedX, unselectedY); - setChowTestResults(chowResults); - } else { - setChowTestResults(null); - } - - // highlight selected points - eChartsRef.current?.getEchartsInstance()?.dispatchAction({ - type: 'highlight', - dataIndex: filteredIndex, - data: 'scatter-selected' - }); - }, - [xData, yData, showMore] - ); - - const bindEvents = useMemo( - () => ({ - highlight: function (params: any) { - if (!params.data) { - // trigger onSelected - onSelected({filteredIndex: params.dataIndex}); - } - } - }), - [onSelected] - ); + const numberOfRows = keplerDataset?.length || 0; const title = `X: ${variableX} vs Y: ${variableY}`; @@ -170,260 +47,217 @@ export const Scatterplot = ({props}: {props: ScatterPlotStateProps}) => { const chartId = `scatterplot-${id}`; const handleMorePress = useCallback(() => { - if (!showMore) { - const allResults = linearRegression(xData, yData); - const selectedResults = xSelected.length > 0 ? linearRegression(xSelected, ySelected) : null; - const unselectedResults = - xUnselected.length > 0 ? linearRegression(xUnselected, yUnselected) : null; - - setRegressionResults({ - all: allResults, - selected: selectedResults, - unselected: unselectedResults - }); - } setShowMore(!showMore); dispatch(updatePlot({...props, showMore: !showMore})); - }, [showMore, xData, yData, xSelected, ySelected, xUnselected, yUnselected, dispatch, props]); + }, [showMore, dispatch, props]); - return useMemo( - () => ( - - {({height, width}) => ( -
- - -

{title}

- {keplerDataset.label} - -
- -
- { - setRendered(true); - }} - /> - {rendered && sourceId && sourceId !== id && ( - - )} -
-
- + {({height, width}) => ( +
+ + +

{title}

+ {keplerDataset.label} + +
+ +
+ +
+
+ +
+
+ +
+ +
+ + {showMore && regressionResults && ( +
+
+ + R² (All) = {regressionResults.all.rSquared.toFixed(4)} + {regressionResults.selected && + ` | R² (Selected) = ${regressionResults.selected.rSquared.toFixed(4)}`} + {regressionResults.unselected && + ` | R² (Unselected) = ${regressionResults.unselected.rSquared.toFixed(4)}`} + +
+ - - - -
- -
+ + Type + Parameter + Estimate + Std. Error + t-Statistic + p-Value + + + {[ + // All Data Results + + All + Intercept + + {regressionResults.all.intercept.estimate.toFixed(4)} + + + {regressionResults.all.intercept.standardError.toFixed(4)} + + + {regressionResults.all.intercept.tStatistic.toFixed(4)} + + {regressionResults.all.intercept.pValue.toFixed(4)} + , + + All + Slope + {regressionResults.all.slope.estimate.toFixed(4)} + + {regressionResults.all.slope.standardError.toFixed(4)} + + {regressionResults.all.slope.tStatistic.toFixed(4)} + {regressionResults.all.slope.pValue.toFixed(4)} + , + // Selected Data Results + ...(regressionResults.selected + ? [ + + Selected + Intercept + + {regressionResults.selected.intercept.estimate.toFixed(4)} + + + {regressionResults.selected.intercept.standardError.toFixed(4)} + + + {regressionResults.selected.intercept.tStatistic.toFixed(4)} + + + {regressionResults.selected.intercept.pValue.toFixed(4)} + + , + + Selected + Slope + + {regressionResults.selected.slope.estimate.toFixed(4)} + + + {regressionResults.selected.slope.standardError.toFixed(4)} + + + {regressionResults.selected.slope.tStatistic.toFixed(4)} + + + {regressionResults.selected.slope.pValue.toFixed(4)} + + + ] + : []), + // Unselected Data Results + ...(regressionResults.unselected + ? [ + + Unselected + Intercept + + {regressionResults.unselected.intercept.estimate.toFixed(4)} + + + {regressionResults.unselected.intercept.standardError.toFixed(4)} + + + {regressionResults.unselected.intercept.tStatistic.toFixed(4)} + + + {regressionResults.unselected.intercept.pValue.toFixed(4)} + + , + + Unselected + Slope + + {regressionResults.unselected.slope.estimate.toFixed(4)} + + + {regressionResults.unselected.slope.standardError.toFixed(4)} + + + {regressionResults.unselected.slope.tStatistic.toFixed(4)} + + + {regressionResults.unselected.slope.pValue.toFixed(4)} + + + ] + : []) + ]} + +
- {showMore && regressionResults && ( -
-
+ {chowTestResults && ( +
- R² (All) = {regressionResults.all.rSquared.toFixed(4)} - {regressionResults.selected && - ` | R² (Selected) = ${regressionResults.selected.rSquared.toFixed(4)}`} - {regressionResults.unselected && - ` | R² (Unselected) = ${regressionResults.unselected.rSquared.toFixed(4)}`} + Chow test for sel/unsel regression subsets: distrib=F(2, {numberOfRows - 4} + ), ratio={chowTestResults.fStat.toFixed(4)}, p-val= + {chowTestResults.pValue.toFixed(4)}
- - - Type - Parameter - Estimate - Std. Error - t-Statistic - p-Value - - - {[ - // All Data Results - - All - Intercept - - {regressionResults.all.intercept.estimate.toFixed(4)} - - - {regressionResults.all.intercept.standardError.toFixed(4)} - - - {regressionResults.all.intercept.tStatistic.toFixed(4)} - - - {regressionResults.all.intercept.pValue.toFixed(4)} - - , - - All - Slope - {regressionResults.all.slope.estimate.toFixed(4)} - - {regressionResults.all.slope.standardError.toFixed(4)} - - - {regressionResults.all.slope.tStatistic.toFixed(4)} - - {regressionResults.all.slope.pValue.toFixed(4)} - , - // Selected Data Results - ...(regressionResults.selected - ? [ - - Selected - Intercept - - {regressionResults.selected.intercept.estimate.toFixed(4)} - - - {regressionResults.selected.intercept.standardError.toFixed(4)} - - - {regressionResults.selected.intercept.tStatistic.toFixed(4)} - - - {regressionResults.selected.intercept.pValue.toFixed(4)} - - , - - Selected - Slope - - {regressionResults.selected.slope.estimate.toFixed(4)} - - - {regressionResults.selected.slope.standardError.toFixed(4)} - - - {regressionResults.selected.slope.tStatistic.toFixed(4)} - - - {regressionResults.selected.slope.pValue.toFixed(4)} - - - ] - : []), - // Unselected Data Results - ...(regressionResults.unselected - ? [ - - Unselected - Intercept - - {regressionResults.unselected.intercept.estimate.toFixed(4)} - - - {regressionResults.unselected.intercept.standardError.toFixed( - 4 - )} - - - {regressionResults.unselected.intercept.tStatistic.toFixed(4)} - - - {regressionResults.unselected.intercept.pValue.toFixed(4)} - - , - - Unselected - Slope - - {regressionResults.unselected.slope.estimate.toFixed(4)} - - - {regressionResults.unselected.slope.standardError.toFixed(4)} - - - {regressionResults.unselected.slope.tStatistic.toFixed(4)} - - - {regressionResults.unselected.slope.pValue.toFixed(4)} - - - ] - : []) - ]} - -
- - {chowTestResults && ( -
- - Chow test for sel/unsel regression subsets: distrib=F(2,{' '} - {xData.length - 4}), ratio={chowTestResults.fStat.toFixed(4)}, p-val= - {chowTestResults.pValue.toFixed(4)} - -
- )} -
- )} - - -
- )} - - ), - [ - chartId, - title, - keplerDataset.label, - option, - theme, - bindEvents, - rendered, - sourceId, - id, - datasetId, - variableX, - variableY, - showMore, - handleMorePress, - regressionResults, - chowTestResults, - xData.length - ] + )} +
+ )} +
+
+
+ )} + ); }; diff --git a/geoda-ai/src/components/plots/scatter-regression-plot.tsx b/geoda-ai/src/components/plots/scatter-regression-plot.tsx new file mode 100644 index 0000000..4245ff0 --- /dev/null +++ b/geoda-ai/src/components/plots/scatter-regression-plot.tsx @@ -0,0 +1,168 @@ +import React, {useRef, useMemo, useState, useEffect, useCallback} from 'react'; +import {ScatterChart} from 'echarts/charts'; +import * as echarts from 'echarts/core'; +import {useSelector} from 'react-redux'; +import {GeoDaState} from '@/store'; +import { + TooltipComponent, + GridComponent, + BrushComponent, + ToolboxComponent +} from 'echarts/components'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import {CanvasRenderer} from 'echarts/renderers'; +import {getColumnDataFromKeplerDataset} from '@/utils/data-utils'; +import {selectKeplerDataset} from '@/store/selectors'; +import {ScatterPlotStateProps} from '@/reducers/plot-reducer'; +import {getScatterChartOption} from '@/utils/plots/scatterplot-utils'; +import {linearRegression, RegressionResults} from '../../utils/math/linear-regression'; +import {ChowTestResult, chowTest} from '@/utils/math-utils'; + +// Register the required ECharts components +echarts.use([ + TooltipComponent, + GridComponent, + ScatterChart, + CanvasRenderer, + BrushComponent, + ToolboxComponent +]); + +type ScatterRegressionPlotProps = ScatterPlotStateProps & { + regressionResults: { + all: RegressionResults; + selected: RegressionResults | null; + unselected: RegressionResults | null; + } | null; + setRegressionResults: (results: { + all: RegressionResults; + selected: RegressionResults | null; + unselected: RegressionResults | null; + }) => void; + setChowTestResults: (results: ChowTestResult | null) => void; +}; + +export const ScatterRegressionPlot = ({props}: {props: ScatterRegressionPlotProps}) => { + const eChartsRef = useRef(null); + const [rendered, setRendered] = useState(false); + const {id, datasetId, variableX, variableY} = props; + + const theme = useSelector((state: GeoDaState) => state.root.uiState.theme); + const keplerDataset = useSelector(selectKeplerDataset(datasetId)); + // use selector to get source id + const sourceId = useSelector((state: GeoDaState) => state.root.interaction?.sourceId); + + const {xData, yData} = useMemo(() => { + const xData = getColumnDataFromKeplerDataset(variableX, keplerDataset); + const yData = getColumnDataFromKeplerDataset(variableY, keplerDataset); + return {xData, yData}; + }, [keplerDataset, variableX, variableY]); + + const filteredIndexes = useSelector( + (state: GeoDaState) => state.root.interaction?.brushLink?.[datasetId] + ); + // get dataset from store + const dataset = useSelector(selectKeplerDataset(datasetId)); + const numberOfRows = dataset?.length || 0; + + // get chart option by calling getChartOption only once + const option = useMemo(() => { + const showRegressionLine = true; + const showLoess = true; + return getScatterChartOption( + variableX, + xData, + variableY, + yData, + showRegressionLine, + showLoess, + props.regressionResults?.all, + props.regressionResults?.selected, + props.regressionResults?.unselected + ); + }, [variableX, variableY, xData, yData, props.regressionResults]); + + const computeRegressionResults = useCallback( + ({filteredIndex}: {filteredIndex: number[]}) => { + const selected = new Set(filteredIndex); + const selectedX: number[] = []; + const selectedY: number[] = []; + const unselectedX: number[] = []; + const unselectedY: number[] = []; + + xData.forEach((x, i) => { + if (selected.has(i)) { + selectedX.push(x); + selectedY.push(yData[i]); + } else { + unselectedX.push(x); + unselectedY.push(yData[i]); + } + }); + + // update regression results + const allResults = linearRegression(xData, yData); + const selectedResults = selectedX.length > 0 ? linearRegression(selectedX, selectedY) : null; + const unselectedResults = + unselectedX.length > 0 ? linearRegression(unselectedX, unselectedY) : null; + props.setRegressionResults({ + all: allResults, + selected: selectedResults, + unselected: unselectedResults + }); + + // Calculate Chow test results if both selected and unselected data exist + if (selectedX.length > 0 && unselectedX.length > 0) { + const chowResults = chowTest(selectedX, selectedY, unselectedX, unselectedY); + props.setChowTestResults(chowResults); + } else { + props.setChowTestResults(null); + } + }, + [xData, props, yData] + ); + + // when filteredIndexTrigger changes, update the chart option using setOption + useEffect(() => { + if (rendered && sourceId && sourceId !== id && eChartsRef.current && filteredIndexes) { + computeRegressionResults({filteredIndex: filteredIndexes}); + const showRegressionLine = true; + const showLoess = true; + const updatedOption = getScatterChartOption( + variableX, + xData, + variableY, + yData, + showRegressionLine, + showLoess, + props.regressionResults?.all, + props.regressionResults?.selected, + props.regressionResults?.unselected + ); + const chart = eChartsRef.current; + if (chart && filteredIndexes.length < numberOfRows) { + const chartInstance = chart.getEchartsInstance(); + chartInstance.setOption(updatedOption, true); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredIndexes, rendered]); + + const bindEvents = useMemo(() => ({}), []); + + return ( + { + setRendered(true); + }} + /> + ); +}; diff --git a/geoda-ai/src/components/plots/simple-scatter-plot.tsx b/geoda-ai/src/components/plots/simple-scatter-plot.tsx index 7c1af4f..d1f3f42 100644 --- a/geoda-ai/src/components/plots/simple-scatter-plot.tsx +++ b/geoda-ai/src/components/plots/simple-scatter-plot.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useMemo, useState} from 'react'; +import React, {useRef, useMemo} from 'react'; import {LineChart, ScatterChart} from 'echarts/charts'; import * as echarts from 'echarts/core'; import {useDispatch, useSelector} from 'react-redux'; @@ -11,7 +11,7 @@ import { } from 'echarts/components'; import ReactEChartsCore from 'echarts-for-react/lib/core'; import {CanvasRenderer} from 'echarts/renderers'; -import {EChartsUpdater, onBrushSelected} from './echarts-updater'; +import {onBrushSelected} from './echarts-updater'; import {getColumnDataFromKeplerDataset} from '@/utils/data-utils'; import {selectKeplerDataset} from '@/store/selectors'; import {SimpleScatterPlotStateProps} from '@/reducers/plot-reducer'; @@ -31,13 +31,11 @@ echarts.use([ export const SimpleScatterPlot = ({props}: {props: SimpleScatterPlotStateProps}) => { const dispatch = useDispatch(); const eChartsRef = useRef(null); - const [rendered, setRendered] = useState(false); const {id: parentId, datasetId, variableX, variableY} = props; const id = parentId + '-simple-scatter-plot'; const theme = useSelector((state: GeoDaState) => state.root.uiState.theme); - const sourceId = useSelector((state: GeoDaState) => state.root.interaction?.sourceId); const keplerDataset = useSelector(selectKeplerDataset(datasetId)); const {xData, yData} = useMemo(() => { @@ -71,13 +69,7 @@ export const SimpleScatterPlot = ({props}: {props: SimpleScatterPlotStateProps}) onEvents={bindEvents} style={{height: '100%', width: '100%', opacity: '0.5'}} ref={eChartsRef} - onChartReady={() => { - setRendered(true); - }} /> - {rendered && sourceId && sourceId !== id && ( - - )}
); }; diff --git a/geoda-ai/src/utils/plots/scatterplot-utils.ts b/geoda-ai/src/utils/plots/scatterplot-utils.ts index 53c9373..16d2d46 100644 --- a/geoda-ai/src/utils/plots/scatterplot-utils.ts +++ b/geoda-ai/src/utils/plots/scatterplot-utils.ts @@ -249,6 +249,7 @@ export function getScatterChartOption( }, // avoid flickering when brushing animation: false, + // to disable progressive rendering permanently progressive: 0 };