You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@echarts.apache.org by wa...@apache.org on 2025/12/18 19:19:14 UTC
(echarts) 01/01: fix(scatter): fix jitter layout does not support progressive rendering and causes chart to stuck and potential NPE
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 <yh...@all-my-life.cn>
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: commits-unsubscribe@echarts.apache.org
For additional commands, e-mail: commits-help@echarts.apache.org