From bbe3591a8a946b62a16e2ee6e254d8d1ed0c2741 Mon Sep 17 00:00:00 2001 From: plainheart Date: Fri, 19 Dec 2025 03:18:24 +0800 Subject: [PATCH 1/5] 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(-) create mode 100644 test/runTest/actions/scatter-jitter.json diff --git a/src/chart/scatter/install.ts b/src/chart/scatter/install.ts index e4257b5820..308fc31030 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 b3f88284b1..753cda5e98 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 42690c1ddd..567a359686 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 c78118d8db..f75709f528 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 0000000000..8b06e0f1b5 --- /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 01c5bb31e4..e1355dc73b 100644 --- a/test/scatter-jitter.html +++ b/test/scatter-jitter.html @@ -56,6 +56,8 @@
+
+
+ + + + From cc164456fb168123a12d3f64a46ba0f0c32e8571 Mon Sep 17 00:00:00 2001 From: plainheart Date: Fri, 19 Dec 2025 03:52:55 +0800 Subject: [PATCH 2/5] fix(scatter): tweak types --- src/chart/scatter/jitterLayout.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chart/scatter/jitterLayout.ts b/src/chart/scatter/jitterLayout.ts index 753cda5e98..36857fe35c 100644 --- a/src/chart/scatter/jitterLayout.ts +++ b/src/chart/scatter/jitterLayout.ts @@ -36,7 +36,7 @@ export default function jitterLayout(): StageHandler { if (!coordSys || (coordSys.type !== 'cartesian2d' && coordSys.type !== 'single')) { return; } - const baseAxis = coordSys.getBaseAxis && coordSys.getBaseAxis(); + const baseAxis = coordSys.getBaseAxis && coordSys.getBaseAxis() as Axis2D | SingleAxis; const hasJitter = baseAxis && needFixJitter(seriesModel, baseAxis); if (!hasJitter) { return; @@ -66,7 +66,7 @@ export default function jitterLayout(): StageHandler { if (dim === 'y' || (dim === 'single' && isSingleY)) { // x is fixed, and y is floating const jittered = fixJitter( - baseAxis as Axis2D | SingleAxis, + baseAxis, layout[0], layout[1], size / 2 @@ -81,7 +81,7 @@ export default function jitterLayout(): StageHandler { else if (dim === 'x' || (dim === 'single' && !isSingleY)) { // y is fixed, and x is floating const jittered = fixJitter( - baseAxis as Axis2D | SingleAxis, + baseAxis, layout[1], layout[0], size / 2 From d3ec53039614239adcba6152e41292193b04fd17 Mon Sep 17 00:00:00 2001 From: plainheart Date: Fri, 19 Dec 2025 13:10:47 +0800 Subject: [PATCH 3/5] fix(scatter): simplify jitterLayout code and update test cases --- src/chart/scatter/jitterLayout.ts | 36 +++++++++++++------------------ test/scatter-jitter.html | 34 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/chart/scatter/jitterLayout.ts b/src/chart/scatter/jitterLayout.ts index 36857fe35c..ad2c2e9baf 100644 --- a/src/chart/scatter/jitterLayout.ts +++ b/src/chart/scatter/jitterLayout.ts @@ -21,7 +21,6 @@ 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'; @@ -47,11 +46,16 @@ export default function jitterLayout(): StageHandler { const isSingleY = orient === 'horizontal' && baseAxis.type !== 'category' || orient === 'vertical' && baseAxis.type === 'category'; + const jitterOnY = dim === 'y' || (dim === 'single' && isSingleY); + const jitterOnX = dim === 'x' || (dim === 'single' && !isSingleY); + if (!jitterOnY && !jitterOnX) { + return; + } + 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; + progress(params, data): void { + const points = data.getLayout('points') as Float32Array; + const hasPoints = !!points; for (let i = params.start; i < params.end; i++) { const offset = hasPoints ? (i - params.start) * 2 : -1; @@ -63,34 +67,24 @@ export default function jitterLayout(): StageHandler { const rawSize = data.getItemVisual(i, 'symbolSize'); const size = rawSize instanceof Array ? (rawSize[1] + rawSize[0]) / 2 : rawSize; - if (dim === 'y' || (dim === 'single' && isSingleY)) { + if (jitterOnY) { // x is fixed, and y is floating - const jittered = fixJitter( - baseAxis, - layout[0], - layout[1], - size / 2 - ); + const jittered = fixJitter(baseAxis, layout[0], layout[1], size / 2); if (hasPoints) { points[offset + 1] = jittered; } else { - data.setItemLayout(i, [layout[0], jittered]); + layout[1] = jittered; } } - else if (dim === 'x' || (dim === 'single' && !isSingleY)) { + else if (jitterOnX) { // y is fixed, and x is floating - const jittered = fixJitter( - baseAxis, - layout[1], - layout[0], - size / 2 - ); + const jittered = fixJitter(baseAxis, layout[1], layout[0], size / 2); if (hasPoints) { points[offset] = jittered; } else { - data.setItemLayout(i, [jittered, layout[1]]); + layout[0] = jittered; } } } diff --git a/test/scatter-jitter.html b/test/scatter-jitter.html index e1355dc73b..bb83ce57ad 100644 --- a/test/scatter-jitter.html +++ b/test/scatter-jitter.html @@ -775,14 +775,13 @@ var chart = testHelper.create(echarts, 'main4', { title: [ 'Large Dataset **without** Jittering', - 'The chart **SHOULD NOT** get stuck', + 'The chart **SHOULD NOT** get stuck and render as expected', '(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'; @@ -793,14 +792,23 @@ 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); + const startTime = performance.now(); + chart.setOption(option); + + // NOTICE: Remove this logic to see the stuck behavior + requestIdleCallback((deadline) => { + if (deadline.didTimeout) { + // Show tip and stop rendering to prevent getting stuck + elapsedTimeTip.innerText = 'Rendering is taking longer than expected! Stopping to prevent getting stuck.'; + chart.clear(); + } + }, { timeout: 5000 } ); } } ] @@ -872,13 +880,17 @@ ] }; - var chart = testHelper.create(echarts, 'main5', { - title: [ - 'Large Dataset **with** Jittering', - 'The chart **SHOULD NOT** throw errors' - ], - option: option - }); + try { + var chart = testHelper.create(echarts, 'main5', { + title: [ + 'Large Dataset **with** Jittering', + 'The chart **SHOULD NOT** throw errors' + ], + option: option + }); + } catch (e) { + console.error(e); + } }); From b5733e7f02b5818d165f8ce9694197c872ed796a Mon Sep 17 00:00:00 2001 From: plainheart Date: Fri, 19 Dec 2025 13:20:23 +0800 Subject: [PATCH 4/5] test(scatter): tweak test case --- test/scatter-jitter.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/scatter-jitter.html b/test/scatter-jitter.html index bb83ce57ad..ffdf2b0435 100644 --- a/test/scatter-jitter.html +++ b/test/scatter-jitter.html @@ -776,7 +776,8 @@ title: [ 'Large Dataset **without** Jittering', 'The chart **SHOULD NOT** get stuck and render as expected', - '(Manual Test Only)' + '(Manual Test Only)', + 'Notice: **DO NOT** use SVG renderer for this test case' ], buttons: [ { @@ -792,6 +793,10 @@ elapsedTimeTip.style.zIndex = '9999'; elapsedTimeTip.innerText = 'Rendering...'; chart.getDom().appendChild(elapsedTimeTip); + if (chart.getZr().painter.type === 'svg') { + elapsedTimeTip.innerText = 'SVG renderer detected! Please switch to Canvas renderer to test large dataset rendering performance.'; + return; + } const onFinished = () => { const elapsed = performance.now() - startTime; elapsedTimeTip.innerText = `Rendered in ${elapsed.toFixed(2)} ms`; From 5592113f81819f2130a69c7b3116bf4f8ca65dfb Mon Sep 17 00:00:00 2001 From: plainheart Date: Fri, 19 Dec 2025 13:25:28 +0800 Subject: [PATCH 5/5] test(scatter)L fix test case --- test/scatter-jitter.html | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/test/scatter-jitter.html b/test/scatter-jitter.html index ffdf2b0435..320d8c430f 100644 --- a/test/scatter-jitter.html +++ b/test/scatter-jitter.html @@ -776,7 +776,6 @@ title: [ 'Large Dataset **without** Jittering', 'The chart **SHOULD NOT** get stuck and render as expected', - '(Manual Test Only)', 'Notice: **DO NOT** use SVG renderer for this test case' ], buttons: [ @@ -824,7 +823,7 @@