This is an automated email from the ASF dual-hosted git repository. wangzx pushed a commit to branch fix/scatter-large-stuck in repository https://gitbox.apache.org/repos/asf/echarts.git
commit bbe3591a8a946b62a16e2ee6e254d8d1ed0c2741 Author: plainheart <[email protected]> AuthorDate: Fri Dec 19 03:18:24 2025 +0800 fix(scatter): fix jitter layout does not support progressive rendering and causes chart to stuck and potential NPE --- src/chart/scatter/install.ts | 2 +- src/chart/scatter/jitterLayout.ts | 110 +++++++++++++------- src/util/jitter.ts | 14 ++- test/runTest/actions/__meta__.json | 1 + test/runTest/actions/scatter-jitter.json | 1 + test/scatter-jitter.html | 173 +++++++++++++++++++++++++++++++ 6 files changed, 253 insertions(+), 48 deletions(-) diff --git a/src/chart/scatter/install.ts b/src/chart/scatter/install.ts index e4257b582..308fc3103 100644 --- a/src/chart/scatter/install.ts +++ b/src/chart/scatter/install.ts @@ -36,5 +36,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { } export function installScatterJitter(registers: EChartsExtensionInstallRegisters) { - registers.registerLayout(registers.PRIORITY.VISUAL.POST_CHART_LAYOUT, jitterLayout); + registers.registerLayout(registers.PRIORITY.VISUAL.POST_CHART_LAYOUT, jitterLayout()); } diff --git a/src/chart/scatter/jitterLayout.ts b/src/chart/scatter/jitterLayout.ts index b3f88284b..753cda5e9 100644 --- a/src/chart/scatter/jitterLayout.ts +++ b/src/chart/scatter/jitterLayout.ts @@ -17,53 +17,85 @@ * under the License. */ -import type GlobalModel from '../../model/Global'; import type ScatterSeriesModel from './ScatterSeries'; import { needFixJitter, fixJitter } from '../../util/jitter'; import type SingleAxis from '../../coord/single/SingleAxis'; import type Axis2D from '../../coord/cartesian/Axis2D'; +import type SeriesData from '../../data/SeriesData'; +import type { StageHandler } from '../../util/types'; +import createRenderPlanner from '../helper/createRenderPlanner'; -export default function jitterLayout(ecModel: GlobalModel) { - ecModel.eachSeriesByType('scatter', function (seriesModel: ScatterSeriesModel) { - const coordSys = seriesModel.coordinateSystem; - if (coordSys - && (coordSys.type === 'cartesian2d' || coordSys.type === 'single')) { - const baseAxis = coordSys.getBaseAxis ? coordSys.getBaseAxis() : null; +export default function jitterLayout(): StageHandler { + return { + seriesType: 'scatter', + + plan: createRenderPlanner(), + + reset(seriesModel: ScatterSeriesModel) { + const coordSys = seriesModel.coordinateSystem; + if (!coordSys || (coordSys.type !== 'cartesian2d' && coordSys.type !== 'single')) { + return; + } + const baseAxis = coordSys.getBaseAxis && coordSys.getBaseAxis(); const hasJitter = baseAxis && needFixJitter(seriesModel, baseAxis); + if (!hasJitter) { + return; + } - if (hasJitter) { - const data = seriesModel.getData(); - data.each(function (idx) { - const dim = baseAxis.dim; - const orient = (baseAxis as SingleAxis).orient; - const isSingleY = orient === 'horizontal' && baseAxis.type !== 'category' - || orient === 'vertical' && baseAxis.type === 'category'; - const layout = data.getItemLayout(idx); - const rawSize = data.getItemVisual(idx, 'symbolSize'); - const size = rawSize instanceof Array ? (rawSize[1] + rawSize[0]) / 2 : rawSize; + const dim = baseAxis.dim; + const orient = (baseAxis as SingleAxis).orient; + const isSingleY = orient === 'horizontal' && baseAxis.type !== 'category' + || orient === 'vertical' && baseAxis.type === 'category'; - if (dim === 'y' || dim === 'single' && isSingleY) { - // x is fixed, and y is floating - const jittered = fixJitter( - baseAxis as Axis2D | SingleAxis, - layout[0], - layout[1], - size / 2 - ); - data.setItemLayout(idx, [layout[0], jittered]); - } - else if (dim === 'x' || dim === 'single' && !isSingleY) { - // y is fixed, and x is floating - const jittered = fixJitter( - baseAxis as Axis2D | SingleAxis, - layout[1], - layout[0], - size / 2 - ); - data.setItemLayout(idx, [jittered, layout[1]]); + return { + progress(params, data: SeriesData): void { + const points = data.getLayout('points') as number[] | Float32Array; + const chunkPointCount = (params.end - params.start) * 2; + const hasPoints = !!points && points.length >= chunkPointCount; + + for (let i = params.start; i < params.end; i++) { + const offset = hasPoints ? (i - params.start) * 2 : -1; + const layout = hasPoints ? [points[offset], points[offset + 1]] : data.getItemLayout(i); + if (!layout) { + continue; + } + + const rawSize = data.getItemVisual(i, 'symbolSize'); + const size = rawSize instanceof Array ? (rawSize[1] + rawSize[0]) / 2 : rawSize; + + if (dim === 'y' || (dim === 'single' && isSingleY)) { + // x is fixed, and y is floating + const jittered = fixJitter( + baseAxis as Axis2D | SingleAxis, + layout[0], + layout[1], + size / 2 + ); + if (hasPoints) { + points[offset + 1] = jittered; + } + else { + data.setItemLayout(i, [layout[0], jittered]); + } + } + else if (dim === 'x' || (dim === 'single' && !isSingleY)) { + // y is fixed, and x is floating + const jittered = fixJitter( + baseAxis as Axis2D | SingleAxis, + layout[1], + layout[0], + size / 2 + ); + if (hasPoints) { + points[offset] = jittered; + } + else { + data.setItemLayout(i, [jittered, layout[1]]); + } + } } - }); - } + } + }; } - }); + }; } diff --git a/src/util/jitter.ts b/src/util/jitter.ts index 42690c1dd..567a35968 100644 --- a/src/util/jitter.ts +++ b/src/util/jitter.ts @@ -67,21 +67,19 @@ export function fixJitter( } const axisModel = fixedAxis.model as AxisBaseModel; const jitter = axisModel.get('jitter'); + if (!(jitter > 0)) { + return floatCoord; + } const jitterOverlap = axisModel.get('jitterOverlap'); const jitterMargin = axisModel.get('jitterMargin') || 0; // Get band width to limit jitter range const bandWidth = fixedAxis.scale.type === 'ordinal' ? fixedAxis.getBandWidth() : null; - if (jitter > 0) { - if (jitterOverlap) { - return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius); - } - else { - return fixJitterAvoidOverlaps(fixedAxis, fixedCoord, floatCoord, radius, jitter, jitterMargin); - } + if (jitterOverlap) { + return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius); } - return floatCoord; + return fixJitterAvoidOverlaps(fixedAxis, fixedCoord, floatCoord, radius, jitter, jitterMargin); } function fixJitterIgnoreOverlaps( diff --git a/test/runTest/actions/__meta__.json b/test/runTest/actions/__meta__.json index c78118d8d..f75709f52 100644 --- a/test/runTest/actions/__meta__.json +++ b/test/runTest/actions/__meta__.json @@ -191,6 +191,7 @@ "sankey-jump": 1, "sankey-level": 1, "scale-extreme-number": 2, + "scatter-jitter": 1, "scatter-random-stream-fix-axis": 1, "scatter-single-axis": 3, "scatterMatrix": 3, diff --git a/test/runTest/actions/scatter-jitter.json b/test/runTest/actions/scatter-jitter.json new file mode 100644 index 000000000..8b06e0f1b --- /dev/null +++ b/test/runTest/actions/scatter-jitter.json @@ -0,0 +1 @@ +[{"name":"Action 1","ops":[{"type":"mousemove","time":393,"x":92,"y":93},{"type":"mousemove","time":597,"x":99,"y":121},{"type":"mousedown","time":757,"x":101,"y":126},{"type":"mousemove","time":813,"x":101,"y":126},{"type":"mouseup","time":865,"x":101,"y":126},{"time":866,"delay":5000,"type":"screenshot-auto"}],"scrollY":6000,"scrollX":0,"timestamp":1766084422908}] diff --git a/test/scatter-jitter.html b/test/scatter-jitter.html index 01c5bb31e..e1355dc73 100644 --- a/test/scatter-jitter.html +++ b/test/scatter-jitter.html @@ -56,6 +56,8 @@ under the License. <div id="main3-boundary-test"></div> <div id="main3-boundary-test-no-overlap"></div> + <div id="main4"></div> + <div id="main5"></div> <script> @@ -708,6 +710,177 @@ under the License. }); }); </script> + + <script> + require([ + 'echarts' + ], function (echarts) { + function genData(len, offset) { + let arr = new Float32Array(len * 2); + let off = 0; + for (let i = 0; i < len; i++) { + let x = +Math.random() * 10; + let y = +Math.sin(x) - x * (len % 2 ? 0.1 : -0.1) * Math.random() + (offset || 0) / 10; + arr[off++] = x; + arr[off++] = y; + } + return arr; + } + const data1 = genData(5e5); + const data2 = genData(5e5, 10); + var option = { + title: { + text: + echarts.format.addCommas(data1.length / 2 + data2.length / 2) + ' Points' + }, + tooltip: {}, + xAxis: {}, + yAxis: {}, + dataZoom: [ + { + type: 'inside' + }, + { + type: 'slider' + } + ], + animation: true, + series: [ + { + name: 'A', + type: 'scatter', + data: data1, + dimensions: ['x', 'y'], + symbolSize: 3, + itemStyle: { + opacity: 0.4 + }, + large: true + }, + { + name: 'B', + type: 'scatter', + data: data2, + dimensions: ['x', 'y'], + symbolSize: 3, + itemStyle: { + opacity: 0.4 + }, + large: true + } + ] + }; + + // NOTE: Please manually test this case, as the chart may get stuck and cause the VRT to fail. + var chart = testHelper.create(echarts, 'main4', { + title: [ + 'Large Dataset **without** Jittering', + 'The chart **SHOULD NOT** get stuck', + '(Manual Test Only)' + ], + buttons: [ + { + text: 'Click to load chart', + onclick: function () { + const startTime = performance.now(); + const elapsedTimeTip = document.createElement('div'); + elapsedTimeTip.style.position = 'absolute'; + elapsedTimeTip.style.top = '0'; + elapsedTimeTip.style.left = '0'; + elapsedTimeTip.style.backgroundColor = 'rgba(0,0,0,0.7)'; + elapsedTimeTip.style.color = '#fff'; + elapsedTimeTip.style.padding = '4px 8px'; + elapsedTimeTip.style.zIndex = '9999'; + elapsedTimeTip.innerText = 'Rendering...'; + chart.getDom().appendChild(elapsedTimeTip); + chart.setOption(option); + const onFinished = () => { + const elapsed = performance.now() - startTime; + + elapsedTimeTip.innerText = `Rendered in ${elapsed.toFixed(2)} ms`; + chart.off('finished', onFinished); + } + chart.on('finished', onFinished); + } + } + ] + }); + }); + </script> + + <script> + require([ + 'echarts' + ], function (echarts) { + function genData(len, offset) { + let arr = new Float32Array(len * 2); + let off = 0; + for (let i = 0; i < len; i++) { + let x = +Math.random() * 10; + let y = +Math.sin(x) - x * (len % 2 ? 0.1 : -0.1) * Math.random() + (offset || 0) / 10; + arr[off++] = x; + arr[off++] = y; + } + return arr; + } + const data1 = genData(5e5); + const data2 = genData(5e5, 10); + var option = { + title: { + text: + echarts.format.addCommas(data1.length / 2 + data2.length / 2) + ' Points' + }, + tooltip: {}, + xAxis: { + jitter: 1.5, + type: 'category', + data: Array.from({ length: 5 }, (_, idx) => idx) + }, + yAxis: {}, + dataZoom: [ + { + type: 'inside' + }, + { + type: 'slider' + } + ], + animation: false, + series: [ + { + name: 'A', + type: 'scatter', + data: data1, + dimensions: ['x', 'y'], + symbolSize: 3, + itemStyle: { + opacity: 0.4 + }, + large: true + }, + { + name: 'B', + type: 'scatter', + data: data2, + dimensions: ['x', 'y'], + symbolSize: 3, + itemStyle: { + opacity: 0.4 + }, + large: true + } + ] + }; + + var chart = testHelper.create(echarts, 'main5', { + title: [ + 'Large Dataset **with** Jittering', + 'The chart **SHOULD NOT** throw errors' + ], + option: option + }); + }); + </script> </body> </html> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
